& {
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
(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 (
onChangePage?.(e.nativeEvent.position)}
- />
+ onPageSelected={(e) => {
+ const pos = e.nativeEvent.position;
+ onPageSelect(EmptyComponent ? pos - 1 : pos);
+ }}
+ >
+ {EmptyComponent ? : null}
+ {children}
+
);
};
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 ? : 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 & {
$hover?: ViewStyle;
$pressed?: ViewStyle;
@@ -13,7 +23,49 @@ const Pressable = ({
...props
}: PressableProps) => {
return (
-
+
+ );
+};
+
+type MultiTapPressableProps = GetProps & {
+ numberOfTaps: number;
+ onTap?: () => void;
+ onMultiTap?: () => void;
+};
+
+export const MultiTapPressable = ({
+ numberOfTaps,
+ onTap,
+ onMultiTap,
+ ...props
+}: MultiTapPressableProps) => {
+ const tapRef = useRef();
+
+ return (
+ {
+ if (e.nativeEvent.state === GestureState.ACTIVE) {
+ onTap?.();
+ }
+ }}
+ waitFor={tapRef}
+ >
+ {
+ if (e.nativeEvent.state === GestureState.ACTIVE) {
+ onMultiTap?.();
+ }
+ }}
+ numberOfTaps={numberOfTaps}
+ ref={tapRef}
+ >
+
+
+
);
};
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 & {
+ _container?: ViewStyle;
+};
+
+const SearchInput = ({ _container, ...props }: SearchInputProps) => {
+ return (
+
+
+
+
+ );
+};
+
+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 = any>(
+ callback: T,
+ delay: number = 300
+) => {
+ const timeoutRef = useRef(null);
+
+ const clear = useCallback(() => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ }, []);
+
+ const fn = useCallback(
+ (...args: Parameters) => {
+ 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 (
+ <>
+
+
+
+
+ {hosts.isLoading ? (
+
+
+ Loading...
+
+ ) : (
+
+ {hostsList?.map((host: any) => (
+ onOpen(host)}
+ >
+
+
+
+ {host.label}
+
+ {host.host}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+ >
+ );
+};
+
+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 (
+ <>
+ (
+
+ ),
+ }}
+ />
+
+
+ >
+ );
+}
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 (
+
+ {sessions.map((session, idx) => (
+
+
+
+ ))}
+
+
+ );
+};
+
+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 (
+ <>
+ (
+ }
+ onPress={() => router.push("/terminal/sessions")}
+ />
+ ),
+ }}
+ />
+
+ {sessions.length > 0 && media.gtSm ? : null}
+
+
+ {sessions.map((session) => (
+
+ ))}
+
+ >
+ );
+};
+
+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 (
+ <>
+ (
+ }
+ onPress={() => {
+ router.back();
+ router.push("/hosts");
+ }}
+ >
+ New
+
+ ),
+ }}
+ />
+
+
+
+
+
+
+ {sessionList.map((session, idx) => (
+
+ }
+ onPress={() => {
+ router.back();
+ setTimeout(() => setSession(idx), 20);
+ }}
+ >
+ {session.label}
+
+
+
+
+ ))}
+
+ >
+ );
+};
+
+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(
+ () => ({
+ 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(
+ (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(
(set) => ({
- theme: "light",
+ theme: "dark",
setTheme: (theme: "light" | "dark") => {
set({ theme });
},