From 334d90e69105f72160a1aab07c66f9254c9e4977 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat <me@khairul.my.id> Date: Fri, 8 Nov 2024 18:53:30 +0000 Subject: [PATCH] feat: update ui --- frontend/app/(drawer)/_layout.tsx | 26 ++++ frontend/app/(drawer)/hosts.tsx | 3 + frontend/app/(drawer)/terminal.tsx | 3 + frontend/app/_layout.tsx | 7 +- frontend/app/_providers.tsx | 45 +++++-- frontend/app/auth/login.tsx | 14 ++ frontend/app/hosts/create.tsx | 2 +- frontend/app/hosts/edit.tsx | 2 +- frontend/app/hosts/index.tsx | 80 ----------- frontend/app/index.tsx | 9 +- frontend/app/term/index.tsx | 91 ------------- frontend/app/terminal/sessions.tsx | 3 + .../containers/interactive-session.tsx | 9 +- frontend/components/containers/terminal.tsx | 72 +++------- frontend/components/containers/xtermjs.tsx | 31 ++++- frontend/components/ui/pager-view.tsx | 35 ++++- frontend/components/ui/pager-view.web.tsx | 13 +- frontend/components/ui/pressable.tsx | 60 ++++++++- frontend/components/ui/search-input.tsx | 25 ++++ frontend/hooks/useDebounce.ts | 28 ++++ frontend/package.json | 8 +- .../hosts/components/form.tsx} | 0 .../pages/hosts/components/hosts-list.tsx | 124 ++++++++++++++++++ frontend/pages/hosts/page.tsx | 25 ++++ .../terminal/components/session-tabs.tsx | 63 +++++++++ frontend/pages/terminal/page.tsx | 48 +++++++ frontend/pages/terminal/sessions-page.tsx | 93 +++++++++++++ .../patches/react-native-drawer-layout.patch | 25 ++++ frontend/pnpm-lock.yaml | 76 ++++++++++- frontend/stores/auth.ts | 26 ++++ frontend/stores/terminal-sessions.ts | 43 ++++++ frontend/stores/theme.ts | 2 +- 32 files changed, 829 insertions(+), 262 deletions(-) create mode 100644 frontend/app/(drawer)/_layout.tsx create mode 100644 frontend/app/(drawer)/hosts.tsx create mode 100644 frontend/app/(drawer)/terminal.tsx create mode 100644 frontend/app/auth/login.tsx delete mode 100644 frontend/app/hosts/index.tsx delete mode 100644 frontend/app/term/index.tsx create mode 100644 frontend/app/terminal/sessions.tsx create mode 100644 frontend/components/ui/search-input.tsx create mode 100644 frontend/hooks/useDebounce.ts rename frontend/{app/hosts/_comp/host-form.tsx => pages/hosts/components/form.tsx} (100%) create mode 100644 frontend/pages/hosts/components/hosts-list.tsx create mode 100644 frontend/pages/hosts/page.tsx create mode 100644 frontend/pages/terminal/components/session-tabs.tsx create mode 100644 frontend/pages/terminal/page.tsx create mode 100644 frontend/pages/terminal/sessions-page.tsx create mode 100644 frontend/patches/react-native-drawer-layout.patch create mode 100644 frontend/stores/auth.ts create mode 100644 frontend/stores/terminal-sessions.ts diff --git a/frontend/app/(drawer)/_layout.tsx b/frontend/app/(drawer)/_layout.tsx new file mode 100644 index 0000000..935daab --- /dev/null +++ b/frontend/app/(drawer)/_layout.tsx @@ -0,0 +1,26 @@ +import { GestureHandlerRootView } from "react-native-gesture-handler"; +import { Drawer } from "expo-router/drawer"; +import React from "react"; +import { useMedia } from "tamagui"; + +export default function Layout() { + const media = useMedia(); + + return ( + <GestureHandlerRootView style={{ flex: 1 }}> + <Drawer + screenOptions={{ + drawerType: media.sm ? "front" : "permanent", + drawerStyle: { width: 250 }, + headerLeft: media.sm ? undefined : () => null, + }} + > + <Drawer.Screen name="hosts" options={{ title: "Hosts" }} /> + <Drawer.Screen + name="terminal" + options={{ title: "Terminal", headerShown: true }} + /> + </Drawer> + </GestureHandlerRootView> + ); +} diff --git a/frontend/app/(drawer)/hosts.tsx b/frontend/app/(drawer)/hosts.tsx new file mode 100644 index 0000000..e5d45c3 --- /dev/null +++ b/frontend/app/(drawer)/hosts.tsx @@ -0,0 +1,3 @@ +import HostsPage from "@/pages/hosts/page"; + +export default HostsPage; diff --git a/frontend/app/(drawer)/terminal.tsx b/frontend/app/(drawer)/terminal.tsx new file mode 100644 index 0000000..a4c65c7 --- /dev/null +++ b/frontend/app/(drawer)/terminal.tsx @@ -0,0 +1,3 @@ +import TerminalPage from "@/pages/terminal/page"; + +export default TerminalPage; diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index ae1b170..182f93f 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -27,7 +27,12 @@ export default function RootLayout() { return ( <Providers> <Stack> - <Stack.Screen name="+not-found" /> + <Stack.Screen + name="index" + options={{ headerShown: false, title: "Loading..." }} + /> + <Stack.Screen name="(drawer)" options={{ headerShown: false }} /> + <Stack.Screen name="+not-found" options={{ title: "Not Found" }} /> </Stack> <StatusBar style="auto" /> </Providers> diff --git a/frontend/app/_providers.tsx b/frontend/app/_providers.tsx index 2eea6a0..8824aae 100644 --- a/frontend/app/_providers.tsx +++ b/frontend/app/_providers.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useMemo, useState } from "react"; +import React, { PropsWithChildren, useEffect, useMemo, useState } from "react"; import tamaguiConfig from "@/tamagui.config"; import { DarkTheme, @@ -8,6 +8,8 @@ import { import { TamaguiProvider, Theme } from "@tamagui/core"; import useThemeStore from "@/stores/theme"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { router, usePathname, useRootNavigationState } from "expo-router"; +import { useAuthStore } from "@/stores/auth"; type Props = PropsWithChildren; @@ -33,16 +35,39 @@ const Providers = ({ children }: Props) => { }, [theme, colorScheme]); return ( - <ThemeProvider value={navTheme}> - <TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}> - <Theme name="blue"> - <QueryClientProvider client={queryClient}> - {children} - </QueryClientProvider> - </Theme> - </TamaguiProvider> - </ThemeProvider> + <> + <AuthProvider /> + <ThemeProvider value={navTheme}> + <TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}> + <Theme name="blue"> + <QueryClientProvider client={queryClient}> + {children} + </QueryClientProvider> + </Theme> + </TamaguiProvider> + </ThemeProvider> + </> ); }; +const AuthProvider = () => { + const pathname = usePathname(); + const rootNavigationState = useRootNavigationState(); + const { isLoggedIn } = useAuthStore(); + + useEffect(() => { + if (!rootNavigationState?.key) { + return; + } + + if (!pathname.startsWith("/auth") && !isLoggedIn) { + router.replace("/auth/login"); + } else if (pathname.startsWith("/auth") && isLoggedIn) { + router.replace("/"); + } + }, [pathname, rootNavigationState, isLoggedIn]); + + return null; +}; + export default Providers; diff --git a/frontend/app/auth/login.tsx b/frontend/app/auth/login.tsx new file mode 100644 index 0000000..0a0da13 --- /dev/null +++ b/frontend/app/auth/login.tsx @@ -0,0 +1,14 @@ +import { View, Text, Button } from "tamagui"; +import React from "react"; +import authStore from "@/stores/auth"; + +export default function LoginPage() { + return ( + <View> + <Text>LoginPage</Text> + <Button onPress={() => authStore.setState({ token: "123" })}> + Login + </Button> + </View> + ); +} diff --git a/frontend/app/hosts/create.tsx b/frontend/app/hosts/create.tsx index 76e475d..db54d54 100644 --- a/frontend/app/hosts/create.tsx +++ b/frontend/app/hosts/create.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Stack } from "expo-router"; -import HostForm from "./_comp/host-form"; +import HostForm from "@/pages/hosts/components/form"; export default function CreateHostPage() { return ( diff --git a/frontend/app/hosts/edit.tsx b/frontend/app/hosts/edit.tsx index 268a502..abff198 100644 --- a/frontend/app/hosts/edit.tsx +++ b/frontend/app/hosts/edit.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Stack } from "expo-router"; -import HostForm from "./_comp/host-form"; +import HostForm from "@/pages/hosts/components/form"; export default function EditHostPage() { return ( diff --git a/frontend/app/hosts/index.tsx b/frontend/app/hosts/index.tsx deleted file mode 100644 index d8d0490..0000000 --- a/frontend/app/hosts/index.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { View, Text, Button, ScrollView, Spinner, Card, XStack } from "tamagui"; -import React from "react"; -import useThemeStore from "@/stores/theme"; -import { useQuery } from "@tanstack/react-query"; -import api from "@/lib/api"; -import { Stack } from "expo-router"; -import Pressable from "@/components/ui/pressable"; -import Icons from "@/components/ui/icons"; - -export default function Hosts() { - const { toggle } = useThemeStore(); - - const hosts = useQuery({ - queryKey: ["hosts"], - queryFn: () => api("/hosts"), - }); - - return ( - <> - <Stack.Screen - options={{ - title: "Hosts", - headerRight: () => ( - <Button onPress={() => toggle()} mr="$2"> - Toggle Theme - </Button> - ), - }} - /> - - {hosts.isLoading ? ( - <View alignItems="center" justifyContent="center" flex={1}> - <Spinner size="large" /> - <Text mt="$4">Loading...</Text> - </View> - ) : ( - <ScrollView - contentContainerStyle={{ - padding: "$2", - flexDirection: "row", - flexWrap: "wrap", - // gap: "$4", - }} - > - {hosts.data.rows?.map((host: any) => ( - <Pressable - key={host.id} - flexBasis="100%" - $gtXs={{ flexBasis: "50%" }} - $gtSm={{ flexBasis: "33.3%" }} - $gtMd={{ flexBasis: "25%" }} - $gtLg={{ flexBasis: "20%" }} - p="$2" - group - > - <Card elevate bordered p="$4"> - <XStack> - <View flex={1}> - <Text>{host.label}</Text> - <Text fontSize="$3" mt="$2"> - {host.host} - </Text> - </View> - - <Button - circular - display="none" - $group-hover={{ display: "block" }} - > - <Icons name="pencil" size={16} /> - </Button> - </XStack> - </Card> - </Pressable> - ))} - </ScrollView> - )} - </> - ); -} diff --git a/frontend/app/index.tsx b/frontend/app/index.tsx index 9fd40d9..fac85db 100644 --- a/frontend/app/index.tsx +++ b/frontend/app/index.tsx @@ -1,6 +1,13 @@ import React from "react"; import { Redirect } from "expo-router"; +import { useTermSession } from "@/stores/terminal-sessions"; export default function index() { - return <Redirect href="/hosts" />; + const { sessions, curSession } = useTermSession(); + + return ( + <Redirect + href={sessions.length > 0 && curSession >= 0 ? "/terminal" : "/hosts"} + /> + ); } diff --git a/frontend/app/term/index.tsx b/frontend/app/term/index.tsx deleted file mode 100644 index c53ba69..0000000 --- a/frontend/app/term/index.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { View, ScrollView, Button } from "react-native"; -import React, { useState } from "react"; -import { Stack } from "expo-router"; -import InteractiveSession, { - InteractiveSessionProps, -} from "@/components/containers/interactive-session"; -import PagerView from "@/components/ui/pager-view"; - -type Session = InteractiveSessionProps & { id: string }; - -const HomePage = () => { - const [sessions, setSessions] = useState<Session[]>([ - { - id: "1", - type: "ssh", - params: { hostId: "01jc3v9w609f8e2wzw60amv195" }, - }, - // { - // id: "2", - // type: "pve", - // params: { client: "vnc", hostId: "01jc3wp2b3zvgr777f4e3caw4w" }, - // }, - // { - // id: "3", - // type: "pve", - // params: { client: "xtermjs", hostId: "01jc3z3yyn2fgb77tyfxc1tkfy" }, - // }, - // { - // id: "4", - // type: "incus", - // params: { - // client: "xtermjs", - // hostId: "01jc3xz9db0v54dg10hk70a13b", - // shell: "fish", - // }, - // }, - ]); - const [curSession, setSession] = useState(0); - - return ( - <View style={{ flex: 1 }}> - <Stack.Screen options={{ title: "Home", headerShown: false }} /> - - <ScrollView - horizontal - style={{ flexGrow: 0, backgroundColor: "#111" }} - contentContainerStyle={{ flexDirection: "row", gap: 8 }} - > - {sessions.map((session, idx) => ( - <View - key={session.id} - style={{ flexDirection: "row", alignItems: "center" }} - > - <Button - title={"Session " + session.id} - color="#333" - onPress={() => setSession(idx)} - /> - <Button - title="X" - onPress={() => { - const newSessions = sessions.filter((s) => s.id !== session.id); - setSessions(newSessions); - setSession( - Math.min(Math.max(curSession, 0), newSessions.length - 1) - ); - }} - /> - </View> - ))} - - {/* <Button - title="[ + ]" - onPress={() => { - nextSession += 1; - setSessions([...sessions, nextSession.toString()]); - setSession(sessions.length); - }} - /> */} - </ScrollView> - - <PagerView style={{ flex: 1 }} page={curSession}> - {sessions.map((session) => ( - <InteractiveSession key={session.id} {...session} /> - ))} - </PagerView> - </View> - ); -}; - -export default HomePage; diff --git a/frontend/app/terminal/sessions.tsx b/frontend/app/terminal/sessions.tsx new file mode 100644 index 0000000..98cbe2d --- /dev/null +++ b/frontend/app/terminal/sessions.tsx @@ -0,0 +1,3 @@ +import SessionsPage from "@/pages/terminal/sessions-page"; + +export default SessionsPage; diff --git a/frontend/components/containers/interactive-session.tsx b/frontend/components/containers/interactive-session.tsx index 3cc0ff7..82b8095 100644 --- a/frontend/components/containers/interactive-session.tsx +++ b/frontend/components/containers/interactive-session.tsx @@ -22,11 +22,10 @@ type IncusSessionProps = { }; }; -export type InteractiveSessionProps = { params: { hostId: string } } & ( - | SSHSessionProps - | PVESessionProps - | IncusSessionProps -); +export type InteractiveSessionProps = { + label: string; + params: { hostId: string }; +} & (SSHSessionProps | PVESessionProps | IncusSessionProps); const InteractiveSession = ({ type, params }: InteractiveSessionProps) => { const query = new URLSearchParams(params); diff --git a/frontend/components/containers/terminal.tsx b/frontend/components/containers/terminal.tsx index 7429cec..84a9082 100644 --- a/frontend/components/containers/terminal.tsx +++ b/frontend/components/containers/terminal.tsx @@ -1,15 +1,9 @@ import React, { ComponentPropsWithoutRef } from "react"; import XTermJs, { XTermRef } from "./xtermjs"; import Ionicons from "@expo/vector-icons/Ionicons"; -import { - Pressable, - ScrollView, - StyleProp, - StyleSheet, - Text, - TextStyle, - View, -} from "react-native"; +import { ScrollView, Text, TextStyle, View } from "tamagui"; +import Pressable from "../ui/pressable"; +import Icons from "../ui/icons"; const Keys = { ArrowLeft: "\x1b[D", @@ -45,7 +39,7 @@ const Terminal = ({ client = "xtermjs", style, ...props }: TerminalProps) => { }; return ( - <View style={[styles.container, style]} {...props}> + <View flex={1} bg="$background" {...props}> {client === "xtermjs" && ( <XTermJs ref={xtermRef} @@ -56,32 +50,32 @@ const Terminal = ({ client = "xtermjs", style, ...props }: TerminalProps) => { <ScrollView horizontal - style={{ flexGrow: 0 }} - contentContainerStyle={styles.buttons} + flexGrow={0} + contentContainerStyle={{ flexDirection: "row" }} > <TerminalButton - title={<Ionicons name="swap-horizontal" color="white" size={16} />} - onPress={() => send(Keys.Tab)} + title={<Icons name="swap-horizontal" size={16} />} + // onPress={() => send(Keys.Tab)} /> <TerminalButton title="ESC" onPress={() => send(Keys.Escape)} /> <TerminalButton - title={<Ionicons name="home" color="white" size={16} />} + title={<Icons name="home" size={16} />} onPress={() => send(Keys.Home)} /> <TerminalButton - title={<Ionicons name="arrow-back" color="white" size={18} />} + title={<Icons name="arrow-left" size={18} />} onPress={() => send(Keys.ArrowLeft)} /> <TerminalButton - title={<Ionicons name="arrow-up" color="white" size={18} />} + title={<Icons name="arrow-up" size={18} />} onPress={() => send(Keys.ArrowUp)} /> <TerminalButton - title={<Ionicons name="arrow-down" color="white" size={18} />} + title={<Icons name="arrow-down" size={18} />} onPress={() => send(Keys.ArrowDown)} /> <TerminalButton - title={<Ionicons name="arrow-forward" color="white" size={18} />} + title={<Icons name="arrow-right" size={18} />} onPress={() => send(Keys.ArrowRight)} /> <TerminalButton title="Enter" onPress={() => send(Keys.Enter)} /> @@ -108,45 +102,11 @@ const TerminalButton = ({ ...props }: ComponentPropsWithoutRef<typeof Pressable> & { title: string | React.ReactNode; - textStyle?: StyleProp<TextStyle>; + textStyle?: TextStyle; }) => ( - <Pressable - style={({ pressed }) => [styles.btn, pressed && styles.btnPressed]} - {...props} - > - {typeof title === "string" ? ( - <Text style={[styles.btnText, textStyle]}>{title}</Text> - ) : ( - title - )} + <Pressable px="$4" py="$3" $hover={{ bg: "$blue3" }} {...props}> + {typeof title === "string" ? <Text {...textStyle}>{title}</Text> : title} </Pressable> ); -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#232323", - }, - buttons: { - display: "flex", - flexDirection: "row", - alignItems: "stretch", - backgroundColor: "#232323", - }, - btn: { - display: "flex", - justifyContent: "center", - alignItems: "center", - paddingHorizontal: 14, - paddingVertical: 10, - }, - btnPressed: { - backgroundColor: "#3a3a3a", - }, - btnText: { - color: "white", - fontSize: 16, - }, -}); - export default Terminal; diff --git a/frontend/components/containers/xtermjs.tsx b/frontend/components/containers/xtermjs.tsx index 3cc151d..94d09f4 100644 --- a/frontend/components/containers/xtermjs.tsx +++ b/frontend/components/containers/xtermjs.tsx @@ -20,6 +20,29 @@ type XTermJsProps = { wsUrl: string; }; +// vscode-snazzy https://github.com/Tyriar/vscode-snazzy +const snazzyTheme = { + foreground: "#eff0eb", + background: "#282a36", + selection: "#97979b33", + black: "#282a36", + brightBlack: "#686868", + red: "#ff5c57", + brightRed: "#ff5c57", + green: "#5af78e", + brightGreen: "#5af78e", + yellow: "#f3f99d", + brightYellow: "#f3f99d", + blue: "#57c7ff", + brightBlue: "#57c7ff", + magenta: "#ff6ac1", + brightMagenta: "#ff6ac1", + cyan: "#9aedfe", + brightCyan: "#9aedfe", + white: "#f1f1f0", + brightWhite: "#eff0eb", +}; + export interface XTermRef extends DOMImperativeFactory { send: (...args: JSONValue[]) => void; } @@ -35,7 +58,11 @@ const XTermJs = forwardRef<XTermRef, XTermJsProps>((props, ref) => { return; } - const xterm = new XTerm(); + const xterm = new XTerm({ + fontFamily: '"Cascadia Code", Menlo, monospace', + theme: snazzyTheme, + cursorBlink: true, + }); xterm.open(container); const fitAddon = new FitAddon(); @@ -131,6 +158,8 @@ const XTermJs = forwardRef<XTermRef, XTermJsProps>((props, ref) => { <div ref={containerRef} style={{ + background: snazzyTheme.background, + padding: 12, flex: !IS_DOM ? 1 : undefined, width: "100%", height: IS_DOM ? "100vh" : undefined, diff --git a/frontend/components/ui/pager-view.tsx b/frontend/components/ui/pager-view.tsx index 99c8568..d2a54f6 100644 --- a/frontend/components/ui/pager-view.tsx +++ b/frontend/components/ui/pager-view.tsx @@ -1,26 +1,51 @@ +import { useDebounceCallback } from "@/hooks/useDebounce"; import React, { ComponentPropsWithoutRef, useEffect, useRef } from "react"; import RNPagerView from "react-native-pager-view"; export type PagerViewProps = ComponentPropsWithoutRef<typeof RNPagerView> & { page?: number; onChangePage?: (page: number) => void; + EmptyComponent?: () => JSX.Element; }; -const PagerView = ({ page, onChangePage, ...props }: PagerViewProps) => { +const PagerView = ({ + page, + onChangePage, + EmptyComponent, + children, + ...props +}: PagerViewProps) => { const ref = useRef<RNPagerView>(null); + const [onPageSelect, clearPageSelectDebounce] = useDebounceCallback( + (page) => onChangePage?.(page), + 100 + ); + + const [setPage] = useDebounceCallback((page) => { + ref.current?.setPage(page); + clearPageSelectDebounce(); + }, 300); + useEffect(() => { if (page != null) { - ref.current?.setPage(page); + const npage = EmptyComponent != null ? page + 1 : page; + setPage(npage); } - }, [page]); + }, [page, EmptyComponent]); return ( <RNPagerView ref={ref} {...props} - onPageSelected={(e) => onChangePage?.(e.nativeEvent.position)} - /> + onPageSelected={(e) => { + const pos = e.nativeEvent.position; + onPageSelect(EmptyComponent ? pos - 1 : pos); + }} + > + {EmptyComponent ? <EmptyComponent key="-1" /> : null} + {children} + </RNPagerView> ); }; diff --git a/frontend/components/ui/pager-view.web.tsx b/frontend/components/ui/pager-view.web.tsx index 0989bf6..93c2da5 100644 --- a/frontend/components/ui/pager-view.web.tsx +++ b/frontend/components/ui/pager-view.web.tsx @@ -3,7 +3,7 @@ import { View } from "react-native"; import { PagerViewProps } from "./pager-view"; const PagerView = ({ - className, + EmptyComponent, children, page, initialPage, @@ -33,7 +33,16 @@ const PagerView = ({ }); }, [curPage, children]); - return content; + const pageElement = useMemo(() => { + return Array.isArray(children) ? children[curPage] : null; + }, [curPage, children]); + + return ( + <> + {!pageElement && EmptyComponent ? <EmptyComponent key="-1" /> : null} + {content} + </> + ); }; export default PagerView; diff --git a/frontend/components/ui/pressable.tsx b/frontend/components/ui/pressable.tsx index 67a9e78..d504ba2 100644 --- a/frontend/components/ui/pressable.tsx +++ b/frontend/components/ui/pressable.tsx @@ -1,7 +1,17 @@ -import { Pressable as BasePressable } from "react-native"; -import { GetProps, styled, ViewStyle } from "tamagui"; +import { useRef } from "react"; +import { + TapGestureHandler, + State as GestureState, +} from "react-native-gesture-handler"; +import { Button, GetProps, styled, View, ViewStyle } from "tamagui"; + +const StyledPressable = styled(Button, { + unstyled: true, + backgroundColor: "$colorTransparent", + borderWidth: 0, + cursor: "pointer", +}); -const StyledPressable = styled(BasePressable); export type PressableProps = GetProps<typeof StyledPressable> & { $hover?: ViewStyle; $pressed?: ViewStyle; @@ -13,7 +23,49 @@ const Pressable = ({ ...props }: PressableProps) => { return ( - <StyledPressable pressStyle={$pressed} hoverStyle={$hover} {...props} /> + <StyledPressable + hoverStyle={$hover} + pressStyle={$pressed} + {...(props as any)} + /> + ); +}; + +type MultiTapPressableProps = GetProps<typeof View> & { + numberOfTaps: number; + onTap?: () => void; + onMultiTap?: () => void; +}; + +export const MultiTapPressable = ({ + numberOfTaps, + onTap, + onMultiTap, + ...props +}: MultiTapPressableProps) => { + const tapRef = useRef<any>(); + + return ( + <TapGestureHandler + onHandlerStateChange={(e) => { + if (e.nativeEvent.state === GestureState.ACTIVE) { + onTap?.(); + } + }} + waitFor={tapRef} + > + <TapGestureHandler + onHandlerStateChange={(e) => { + if (e.nativeEvent.state === GestureState.ACTIVE) { + onMultiTap?.(); + } + }} + numberOfTaps={numberOfTaps} + ref={tapRef} + > + <View pressStyle={{ opacity: 0.5 }} {...props} /> + </TapGestureHandler> + </TapGestureHandler> ); }; diff --git a/frontend/components/ui/search-input.tsx b/frontend/components/ui/search-input.tsx new file mode 100644 index 0000000..162ab84 --- /dev/null +++ b/frontend/components/ui/search-input.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { GetProps, Input, View, ViewStyle } from "tamagui"; +import Icons from "./icons"; + +type SearchInputProps = GetProps<typeof Input> & { + _container?: ViewStyle; +}; + +const SearchInput = ({ _container, ...props }: SearchInputProps) => { + return ( + <View position="relative" {..._container}> + <Icons + name="magnify" + size={20} + position="absolute" + top={11} + left="$3" + zIndex={1} + /> + <Input pl="$7" placeholder="Search..." {...props} /> + </View> + ); +}; + +export default SearchInput; diff --git a/frontend/hooks/useDebounce.ts b/frontend/hooks/useDebounce.ts new file mode 100644 index 0000000..46c6a6d --- /dev/null +++ b/frontend/hooks/useDebounce.ts @@ -0,0 +1,28 @@ +import { useCallback, useMemo, useRef } from "react"; + +export const useDebounceCallback = <T extends (...args: any[]) => any>( + callback: T, + delay: number = 300 +) => { + const timeoutRef = useRef<NodeJS.Timeout | null>(null); + + const clear = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }, []); + + const fn = useCallback( + (...args: Parameters<T>) => { + clear(); + + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null; + callback(...args); + }, delay); + }, + [delay, clear] + ); + + return [fn, clear] as const; +}; diff --git a/frontend/package.json b/frontend/package.json index 0db8d2c..469f27e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@novnc/novnc": "^1.5.0", "@react-native-async-storage/async-storage": "1.23.1", "@react-navigation/bottom-tabs": "7.0.0-rc.36", + "@react-navigation/drawer": "^7.0.0", "@react-navigation/native": "7.0.0-rc.21", "@tamagui/config": "^1.116.14", "@tanstack/react-query": "^5.59.20", @@ -64,5 +65,10 @@ "react-test-renderer": "18.3.1", "typescript": "^5.3.3" }, - "private": true + "private": true, + "pnpm": { + "patchedDependencies": { + "react-native-drawer-layout": "patches/react-native-drawer-layout.patch" + } + } } \ No newline at end of file diff --git a/frontend/app/hosts/_comp/host-form.tsx b/frontend/pages/hosts/components/form.tsx similarity index 100% rename from frontend/app/hosts/_comp/host-form.tsx rename to frontend/pages/hosts/components/form.tsx diff --git a/frontend/pages/hosts/components/hosts-list.tsx b/frontend/pages/hosts/components/hosts-list.tsx new file mode 100644 index 0000000..27e455b --- /dev/null +++ b/frontend/pages/hosts/components/hosts-list.tsx @@ -0,0 +1,124 @@ +import { View, Text, Button, ScrollView, Spinner, Card, XStack } from "tamagui"; +import React, { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import api from "@/lib/api"; +import { useNavigation } from "expo-router"; +import { MultiTapPressable } from "@/components/ui/pressable"; +import Icons from "@/components/ui/icons"; +import SearchInput from "@/components/ui/search-input"; +import { useTermSession } from "@/stores/terminal-sessions"; + +const HostsList = () => { + const openSession = useTermSession((i) => i.push); + const navigation = useNavigation(); + const [search, setSearch] = useState(""); + + const hosts = useQuery({ + queryKey: ["hosts"], + queryFn: () => api("/hosts"), + select: (i) => i.rows, + }); + + const hostsList = useMemo(() => { + let items = hosts.data || []; + + if (search) { + items = items.filter((item: any) => { + const q = search.toLowerCase(); + return ( + item.label.toLowerCase().includes(q) || + item.host.toLowerCase().includes(q) + ); + }); + } + + return items; + }, [hosts.data, search]); + + const onOpen = (host: any) => { + const session: any = { + id: host.id, + label: host.label, + type: host.type, + params: { + hostId: host.id, + }, + }; + + if (host.type === "pve") { + session.params.client = host.metadata?.type === "lxc" ? "xtermjs" : "vnc"; + } + + if (host.type === "incus") { + session.params.shell = "bash"; + } + + openSession(session); + navigation.navigate("terminal" as never); + }; + + return ( + <> + <View p="$4" pb="$3"> + <SearchInput + placeholder="Search label, host, or IP..." + value={search} + onChangeText={setSearch} + /> + </View> + + {hosts.isLoading ? ( + <View alignItems="center" justifyContent="center" flex={1}> + <Spinner size="large" /> + <Text mt="$4">Loading...</Text> + </View> + ) : ( + <ScrollView + contentContainerStyle={{ + padding: "$3", + paddingTop: 0, + flexDirection: "row", + flexWrap: "wrap", + }} + > + {hostsList?.map((host: any) => ( + <MultiTapPressable + key={host.id} + flexBasis="100%" + cursor="pointer" + $gtXs={{ flexBasis: "50%" }} + $gtSm={{ flexBasis: "33.3%" }} + $gtMd={{ flexBasis: "25%" }} + $gtLg={{ flexBasis: "20%" }} + p="$2" + group + numberOfTaps={2} + onMultiTap={() => onOpen(host)} + > + <Card bordered p="$4"> + <XStack> + <View flex={1}> + <Text>{host.label}</Text> + <Text fontSize="$3" mt="$2"> + {host.host} + </Text> + </View> + + <Button + circular + display="none" + $group-hover={{ display: "block" }} + > + <Icons name="pencil" size={16} /> + </Button> + </XStack> + </Card> + </MultiTapPressable> + ))} + </ScrollView> + )} + </> + ); +}; + +export default HostsList; diff --git a/frontend/pages/hosts/page.tsx b/frontend/pages/hosts/page.tsx new file mode 100644 index 0000000..93cebf1 --- /dev/null +++ b/frontend/pages/hosts/page.tsx @@ -0,0 +1,25 @@ +import { Button } from "tamagui"; +import React from "react"; +import useThemeStore from "@/stores/theme"; +import Drawer from "expo-router/drawer"; +import HostsList from "./components/hosts-list"; + +export default function HostsPage() { + const { toggle } = useThemeStore(); + + return ( + <> + <Drawer.Screen + options={{ + headerRight: () => ( + <Button onPress={() => toggle()} mr="$2"> + Toggle Theme + </Button> + ), + }} + /> + + <HostsList /> + </> + ); +} diff --git a/frontend/pages/terminal/components/session-tabs.tsx b/frontend/pages/terminal/components/session-tabs.tsx new file mode 100644 index 0000000..ce43237 --- /dev/null +++ b/frontend/pages/terminal/components/session-tabs.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { useTermSession } from "@/stores/terminal-sessions"; +import { Button, ScrollView, View } from "tamagui"; +import Icons from "@/components/ui/icons"; + +const SessionTabs = () => { + const { sessions, curSession, setSession, remove } = useTermSession(); + + return ( + <ScrollView + horizontal + flexGrow={0} + bg="$background" + contentContainerStyle={{ + flexDirection: "row", + pt: "$2", + px: "$2", + gap: "$2", + }} + > + {sessions.map((session, idx) => ( + <View key={session.id} position="relative"> + <Button + size="$3" + borderBottomLeftRadius={0} + borderBottomRightRadius={0} + onPress={() => setSession(idx)} + pl="$4" + pr="$6" + bg={curSession === idx ? "$blue7" : "$blue3"} + > + {session.label} + </Button> + <Button + circular + bg="$colorTransparent" + onPress={(e) => { + e.stopPropagation(); + remove(idx); + }} + icon={<Icons name="close" size={16} />} + size="$2" + position="absolute" + top="$1.5" + right="$1" + opacity={0.6} + hoverStyle={{ opacity: 1 }} + /> + </View> + ))} + + <Button + onPress={() => setSession(-1)} + size="$2.5" + bg="$colorTransparent" + circular + icon={<Icons name="plus" size={16} />} + /> + </ScrollView> + ); +}; + +export default SessionTabs; diff --git a/frontend/pages/terminal/page.tsx b/frontend/pages/terminal/page.tsx new file mode 100644 index 0000000..a7877e4 --- /dev/null +++ b/frontend/pages/terminal/page.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import InteractiveSession from "@/components/containers/interactive-session"; +import PagerView from "@/components/ui/pager-view"; +import { useTermSession } from "@/stores/terminal-sessions"; +import { Button, useMedia } from "tamagui"; +import SessionTabs from "./components/session-tabs"; +import HostsList from "../hosts/components/hosts-list"; +import Drawer from "expo-router/drawer"; +import { router } from "expo-router"; +import Icons from "@/components/ui/icons"; + +const TerminalPage = () => { + const { sessions, curSession, setSession } = useTermSession(); + const session = sessions[curSession]; + const media = useMedia(); + + return ( + <> + <Drawer.Screen + options={{ + headerTitle: session?.label || "Terminal", + headerRight: () => ( + <Button + bg="$colorTransparent" + icon={<Icons name="view-list" size={24} />} + onPress={() => router.push("/terminal/sessions")} + /> + ), + }} + /> + + {sessions.length > 0 && media.gtSm ? <SessionTabs /> : null} + + <PagerView + style={{ flex: 1 }} + page={curSession} + onChangePage={setSession} + EmptyComponent={HostsList} + > + {sessions.map((session) => ( + <InteractiveSession key={session.id} {...session} /> + ))} + </PagerView> + </> + ); +}; + +export default TerminalPage; diff --git a/frontend/pages/terminal/sessions-page.tsx b/frontend/pages/terminal/sessions-page.tsx new file mode 100644 index 0000000..38a2e5a --- /dev/null +++ b/frontend/pages/terminal/sessions-page.tsx @@ -0,0 +1,93 @@ +import React, { useMemo, useState } from "react"; +import { router, Stack } from "expo-router"; +import { Button, ScrollView, View } from "tamagui"; +import { useTermSession } from "@/stores/terminal-sessions"; +import SearchInput from "@/components/ui/search-input"; +import Icons from "@/components/ui/icons"; + +const SessionsPage = () => { + const { sessions, setSession, curSession, remove } = useTermSession(); + const [search, setSearch] = useState(""); + + const sessionList = useMemo(() => { + let items = sessions; + + if (search) { + items = items.filter((item) => + item.label.toLowerCase().includes(search.toLowerCase()) + ); + } + + return items; + }, [sessions, search]); + + return ( + <> + <Stack.Screen + options={{ + title: "Sessions", + headerRight: () => ( + <Button + bg="$colorTransparent" + icon={<Icons name="plus" size={24} />} + onPress={() => { + router.back(); + router.push("/hosts"); + }} + > + New + </Button> + ), + }} + /> + + <View p="$3"> + <SearchInput + placeholder="Search..." + value={search} + onChangeText={setSearch} + /> + </View> + + <ScrollView contentContainerStyle={{ px: "$3", pt: "$1" }}> + {sessionList.map((session, idx) => ( + <View key={session.id} mb="$3" position="relative"> + <Button + bg={idx !== curSession ? "$colorTransparent" : undefined} + borderWidth={1} + borderColor="$blue4" + justifyContent="flex-start" + textAlign="left" + size="$5" + pl="$4" + icon={<Icons name="connection" size={16} />} + onPress={() => { + router.back(); + setTimeout(() => setSession(idx), 20); + }} + > + {session.label} + </Button> + + <Button + bg="$colorTransparent" + circular + size="$3" + position="absolute" + top="$2" + right="$2" + onPress={(e) => { + e.stopPropagation(); + remove(idx); + }} + > + <Icons name="close" size={16} /> + </Button> + </View> + ))} + </ScrollView> + </> + ); +}; + +export default SessionsPage; diff --git a/frontend/patches/react-native-drawer-layout.patch b/frontend/patches/react-native-drawer-layout.patch new file mode 100644 index 0000000..e89b246 --- /dev/null +++ b/frontend/patches/react-native-drawer-layout.patch @@ -0,0 +1,25 @@ +diff --git a/lib/commonjs/views/Drawer.native.js b/lib/commonjs/views/Drawer.native.js +index 7bac11bd3e76fc78284aabd81b6a94446c64813f..6cf9d65503f5ec35cea276bdc6803adfc60d2c0e 100644 +--- a/lib/commonjs/views/Drawer.native.js ++++ b/lib/commonjs/views/Drawer.native.js +@@ -159,16 +159,20 @@ function Drawer({ + React.useEffect(() => toggleDrawer(open), [open, toggleDrawer]); + const startX = (0, _reactNativeReanimated.useSharedValue)(0); + let pan = _GestureHandler.Gesture?.Pan().onBegin(event => { ++ 'worklet'; + startX.value = translationX.value; + gestureState.value = event.state; + touchStartX.value = event.x; + }).onStart(() => { ++ 'worklet'; + (0, _reactNativeReanimated.runOnJS)(onGestureBegin)(); + }).onChange(event => { ++ 'worklet'; + touchX.value = event.x; + translationX.value = startX.value + event.translationX; + gestureState.value = event.state; + }).onEnd((event, success) => { ++ 'worklet'; + gestureState.value = event.state; + if (!success) { + (0, _reactNativeReanimated.runOnJS)(onGestureAbort)(); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 9f5f4aa..be61f5a 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + react-native-drawer-layout: + hash: ipghvwpiqcl5liuijnfvmjzcvq + path: patches/react-native-drawer-layout.patch + importers: .: @@ -20,6 +25,9 @@ importers: '@react-navigation/bottom-tabs': specifier: 7.0.0-rc.36 version: 7.0.0-rc.36(@react-navigation/native@7.0.0-rc.21(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native-screens@4.0.0-beta.16(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + '@react-navigation/drawer': + specifier: ^7.0.0 + version: 7.0.0(s2kwfzlicenreg74lts3a6znsu) '@react-navigation/native': specifier: 7.0.0-rc.21 version: 7.0.0-rc.21(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) @@ -58,7 +66,7 @@ importers: version: 7.0.2(expo@52.0.0-preview.19(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(react-native-webview@13.12.2(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) expo-router: specifier: ~4.0.0-preview.12 - version: 4.0.0-preview.12(yd2wh2xxaopmvq6w6kpgmfoxyy) + version: 4.0.0-preview.12(dw2oobptqthz2eo3bvppba5ugq) expo-splash-screen: specifier: ~0.29.1 version: 0.29.1(expo-modules-autolinking@2.0.0-preview.3)(expo@52.0.0-preview.19(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(react-native-webview@13.12.2(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1)) @@ -1399,6 +1407,29 @@ packages: peerDependencies: react: '*' + '@react-navigation/drawer@7.0.0': + resolution: {integrity: sha512-JbJ2ziSFVTV/qr2ffs2qMhziQJ8XHzRJhsF+PH2zFu4FCguRYaPqtf3Kl//tS4QWXVhpO4g5jweJQ7CfSousgw==} + peerDependencies: + '@react-navigation/native': ^7.0.0 + react: '>= 18.2.0' + react-native: '*' + react-native-gesture-handler: '>= 2.0.0' + react-native-reanimated: '>= 2.0.0' + react-native-safe-area-context: '>= 4.0.0' + react-native-screens: '>= 4.0.0' + + '@react-navigation/elements@2.0.0': + resolution: {integrity: sha512-kt2Q5WLJ9jjJMA/Jt8S3z3Jub2V+HIJ2LM4z+dZqL00FVsTfa4rSk3BTktI3MmBiUCgzUo6jPOxkxsUbjoL/ig==} + peerDependencies: + '@react-native-masked-view/masked-view': '>= 0.2.0' + '@react-navigation/native': ^7.0.0 + react: '>= 18.2.0' + react-native: '*' + react-native-safe-area-context: '>= 4.0.0' + peerDependenciesMeta: + '@react-native-masked-view/masked-view': + optional: true + '@react-navigation/elements@2.0.0-rc.26': resolution: {integrity: sha512-omtEkb2E8j3dYLq08YGsDykQoVLtTLWAQXp0ql6cB8qjtMhP7rMhoBU50veh0Tes/96Sm3X0e3WZPQMVBKrSSg==} peerDependencies: @@ -4611,6 +4642,14 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-native-drawer-layout@4.0.0: + resolution: {integrity: sha512-l9xu7YDXHImg3wpLjD12CokWV2H4Nu/Uc9EVxg/DFqEwgyDbZqE/8IGhQhN32TiZPgelAZNj5c3MBE2yTR1ivw==} + peerDependencies: + react: '>= 18.2.0' + react-native: '*' + react-native-gesture-handler: '>= 2.0.0' + react-native-reanimated: '>= 2.0.0' + react-native-gesture-handler@2.20.2: resolution: {integrity: sha512-HqzFpFczV4qCnwKlvSAvpzEXisL+Z9fsR08YV5LfJDkzuArMhBu2sOoSPUF/K62PCoAb+ObGlTC83TKHfUd0vg==} peerDependencies: @@ -7456,6 +7495,30 @@ snapshots: use-latest-callback: 0.2.1(react@18.3.1) use-sync-external-store: 1.2.2(react@18.3.1) + '@react-navigation/drawer@7.0.0(s2kwfzlicenreg74lts3a6znsu)': + dependencies: + '@react-navigation/elements': 2.0.0(@react-navigation/native@7.0.0-rc.21(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + '@react-navigation/native': 7.0.0-rc.21(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + color: 4.2.3 + react: 18.3.1 + react-native: 0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1) + react-native-drawer-layout: 4.0.0(patch_hash=ipghvwpiqcl5liuijnfvmjzcvq)(react-native-gesture-handler@2.20.2(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native-reanimated@3.16.1(@babel/core@7.26.0)(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + react-native-gesture-handler: 2.20.2(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + react-native-reanimated: 3.16.1(@babel/core@7.26.0)(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + react-native-safe-area-context: 4.12.0(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + react-native-screens: 4.0.0-beta.16(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + use-latest-callback: 0.2.1(react@18.3.1) + transitivePeerDependencies: + - '@react-native-masked-view/masked-view' + + '@react-navigation/elements@2.0.0(@react-navigation/native@7.0.0-rc.21(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1)': + dependencies: + '@react-navigation/native': 7.0.0-rc.21(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + color: 4.2.3 + react: 18.3.1 + react-native: 0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1) + react-native-safe-area-context: 4.12.0(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + '@react-navigation/elements@2.0.0-rc.26(@react-navigation/native@7.0.0-rc.21(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native-safe-area-context@4.12.0(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1)': dependencies: '@react-navigation/native': 7.0.0-rc.21(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) @@ -9846,7 +9909,7 @@ snapshots: dependencies: invariant: 2.2.4 - expo-router@4.0.0-preview.12(yd2wh2xxaopmvq6w6kpgmfoxyy): + expo-router@4.0.0-preview.12(dw2oobptqthz2eo3bvppba5ugq): dependencies: '@expo/metro-runtime': 4.0.0-preview.1(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1)) '@expo/server': 0.5.0-preview.0(typescript@5.6.3) @@ -9866,6 +9929,7 @@ snapshots: schema-utils: 4.2.0 server-only: 0.0.1 optionalDependencies: + '@react-navigation/drawer': 7.0.0(s2kwfzlicenreg74lts3a6znsu) react-native-reanimated: 3.16.1(@babel/core@7.26.0)(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) transitivePeerDependencies: - '@react-native-masked-view/masked-view' @@ -11706,6 +11770,14 @@ snapshots: react-is@18.3.1: {} + react-native-drawer-layout@4.0.0(patch_hash=ipghvwpiqcl5liuijnfvmjzcvq)(react-native-gesture-handler@2.20.2(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native-reanimated@3.16.1(@babel/core@7.26.0)(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1))(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-native: 0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1) + react-native-gesture-handler: 2.20.2(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + react-native-reanimated: 3.16.1(@babel/core@7.26.0)(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1) + use-latest-callback: 0.2.1(react@18.3.1) + react-native-gesture-handler@2.20.2(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1): dependencies: '@egjs/hammerjs': 2.0.17 diff --git a/frontend/stores/auth.ts b/frontend/stores/auth.ts new file mode 100644 index 0000000..d4facf4 --- /dev/null +++ b/frontend/stores/auth.ts @@ -0,0 +1,26 @@ +import { createStore, useStore } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +type AuthStore = { + token?: string | null; +}; + +const authStore = createStore( + persist<AuthStore>( + () => ({ + token: null, + }), + { + name: "auth", + storage: createJSONStorage(() => AsyncStorage), + } + ) +); + +export const useAuthStore = () => { + const state = useStore(authStore); + return { ...state, isLoggedIn: state.token != null }; +}; + +export default authStore; diff --git a/frontend/stores/terminal-sessions.ts b/frontend/stores/terminal-sessions.ts new file mode 100644 index 0000000..992bb2f --- /dev/null +++ b/frontend/stores/terminal-sessions.ts @@ -0,0 +1,43 @@ +import { InteractiveSessionProps } from "@/components/containers/interactive-session"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export type Session = InteractiveSessionProps & { id: string }; + +type TerminalSessionsStore = { + sessions: Session[]; + curSession: number; + push: (session: Session) => void; + remove: (idx: number) => void; + setSession: (idx: number) => void; +}; + +export const useTermSession = create( + persist<TerminalSessionsStore>( + (set) => ({ + sessions: [], + curSession: 0, + push: (session: Session) => { + set((state) => ({ + sessions: [ + ...state.sessions, + { ...session, id: session.id + "." + Date.now() }, + ], + curSession: state.sessions.length, + })); + }, + remove: (idx: number) => { + set((state) => { + const sessions = [...state.sessions]; + sessions.splice(idx, 1); + return { sessions, curSession: Math.min(idx, sessions.length - 1) }; + }); + }, + setSession: (idx: number) => { + set({ curSession: idx }); + }, + }), + { name: "term-sessions", storage: createJSONStorage(() => AsyncStorage) } + ) +); diff --git a/frontend/stores/theme.ts b/frontend/stores/theme.ts index 8ea5ef2..105fef7 100644 --- a/frontend/stores/theme.ts +++ b/frontend/stores/theme.ts @@ -11,7 +11,7 @@ type Store = { const useThemeStore = create( persist<Store>( (set) => ({ - theme: "light", + theme: "dark", setTheme: (theme: "light" | "dark") => { set({ theme }); },