feat: update ui

This commit is contained in:
Khairul Hidayat 2024-11-08 18:53:30 +00:00
parent 887cb64878
commit 334d90e691
32 changed files with 829 additions and 262 deletions

View File

@ -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>
);
}

View File

@ -0,0 +1,3 @@
import HostsPage from "@/pages/hosts/page";
export default HostsPage;

View File

@ -0,0 +1,3 @@
import TerminalPage from "@/pages/terminal/page";
export default TerminalPage;

View File

@ -27,7 +27,12 @@ export default function RootLayout() {
return ( return (
<Providers> <Providers>
<Stack> <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> </Stack>
<StatusBar style="auto" /> <StatusBar style="auto" />
</Providers> </Providers>

View File

@ -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 tamaguiConfig from "@/tamagui.config";
import { import {
DarkTheme, DarkTheme,
@ -8,6 +8,8 @@ import {
import { TamaguiProvider, Theme } from "@tamagui/core"; import { TamaguiProvider, Theme } from "@tamagui/core";
import useThemeStore from "@/stores/theme"; import useThemeStore from "@/stores/theme";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { router, usePathname, useRootNavigationState } from "expo-router";
import { useAuthStore } from "@/stores/auth";
type Props = PropsWithChildren; type Props = PropsWithChildren;
@ -33,16 +35,39 @@ const Providers = ({ children }: Props) => {
}, [theme, colorScheme]); }, [theme, colorScheme]);
return ( return (
<ThemeProvider value={navTheme}> <>
<TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}> <AuthProvider />
<Theme name="blue"> <ThemeProvider value={navTheme}>
<QueryClientProvider client={queryClient}> <TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}>
{children} <Theme name="blue">
</QueryClientProvider> <QueryClientProvider client={queryClient}>
</Theme> {children}
</TamaguiProvider> </QueryClientProvider>
</ThemeProvider> </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; export default Providers;

View File

@ -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>
);
}

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import HostForm from "./_comp/host-form"; import HostForm from "@/pages/hosts/components/form";
export default function CreateHostPage() { export default function CreateHostPage() {
return ( return (

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import HostForm from "./_comp/host-form"; import HostForm from "@/pages/hosts/components/form";
export default function EditHostPage() { export default function EditHostPage() {
return ( return (

View File

@ -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>
)}
</>
);
}

View File

@ -1,6 +1,13 @@
import React from "react"; import React from "react";
import { Redirect } from "expo-router"; import { Redirect } from "expo-router";
import { useTermSession } from "@/stores/terminal-sessions";
export default function index() { export default function index() {
return <Redirect href="/hosts" />; const { sessions, curSession } = useTermSession();
return (
<Redirect
href={sessions.length > 0 && curSession >= 0 ? "/terminal" : "/hosts"}
/>
);
} }

View File

@ -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;

View File

@ -0,0 +1,3 @@
import SessionsPage from "@/pages/terminal/sessions-page";
export default SessionsPage;

View File

@ -22,11 +22,10 @@ type IncusSessionProps = {
}; };
}; };
export type InteractiveSessionProps = { params: { hostId: string } } & ( export type InteractiveSessionProps = {
| SSHSessionProps label: string;
| PVESessionProps params: { hostId: string };
| IncusSessionProps } & (SSHSessionProps | PVESessionProps | IncusSessionProps);
);
const InteractiveSession = ({ type, params }: InteractiveSessionProps) => { const InteractiveSession = ({ type, params }: InteractiveSessionProps) => {
const query = new URLSearchParams(params); const query = new URLSearchParams(params);

View File

@ -1,15 +1,9 @@
import React, { ComponentPropsWithoutRef } from "react"; import React, { ComponentPropsWithoutRef } from "react";
import XTermJs, { XTermRef } from "./xtermjs"; import XTermJs, { XTermRef } from "./xtermjs";
import Ionicons from "@expo/vector-icons/Ionicons"; import Ionicons from "@expo/vector-icons/Ionicons";
import { import { ScrollView, Text, TextStyle, View } from "tamagui";
Pressable, import Pressable from "../ui/pressable";
ScrollView, import Icons from "../ui/icons";
StyleProp,
StyleSheet,
Text,
TextStyle,
View,
} from "react-native";
const Keys = { const Keys = {
ArrowLeft: "\x1b[D", ArrowLeft: "\x1b[D",
@ -45,7 +39,7 @@ const Terminal = ({ client = "xtermjs", style, ...props }: TerminalProps) => {
}; };
return ( return (
<View style={[styles.container, style]} {...props}> <View flex={1} bg="$background" {...props}>
{client === "xtermjs" && ( {client === "xtermjs" && (
<XTermJs <XTermJs
ref={xtermRef} ref={xtermRef}
@ -56,32 +50,32 @@ const Terminal = ({ client = "xtermjs", style, ...props }: TerminalProps) => {
<ScrollView <ScrollView
horizontal horizontal
style={{ flexGrow: 0 }} flexGrow={0}
contentContainerStyle={styles.buttons} contentContainerStyle={{ flexDirection: "row" }}
> >
<TerminalButton <TerminalButton
title={<Ionicons name="swap-horizontal" color="white" size={16} />} title={<Icons name="swap-horizontal" size={16} />}
onPress={() => send(Keys.Tab)} // onPress={() => send(Keys.Tab)}
/> />
<TerminalButton title="ESC" onPress={() => send(Keys.Escape)} /> <TerminalButton title="ESC" onPress={() => send(Keys.Escape)} />
<TerminalButton <TerminalButton
title={<Ionicons name="home" color="white" size={16} />} title={<Icons name="home" size={16} />}
onPress={() => send(Keys.Home)} onPress={() => send(Keys.Home)}
/> />
<TerminalButton <TerminalButton
title={<Ionicons name="arrow-back" color="white" size={18} />} title={<Icons name="arrow-left" size={18} />}
onPress={() => send(Keys.ArrowLeft)} onPress={() => send(Keys.ArrowLeft)}
/> />
<TerminalButton <TerminalButton
title={<Ionicons name="arrow-up" color="white" size={18} />} title={<Icons name="arrow-up" size={18} />}
onPress={() => send(Keys.ArrowUp)} onPress={() => send(Keys.ArrowUp)}
/> />
<TerminalButton <TerminalButton
title={<Ionicons name="arrow-down" color="white" size={18} />} title={<Icons name="arrow-down" size={18} />}
onPress={() => send(Keys.ArrowDown)} onPress={() => send(Keys.ArrowDown)}
/> />
<TerminalButton <TerminalButton
title={<Ionicons name="arrow-forward" color="white" size={18} />} title={<Icons name="arrow-right" size={18} />}
onPress={() => send(Keys.ArrowRight)} onPress={() => send(Keys.ArrowRight)}
/> />
<TerminalButton title="Enter" onPress={() => send(Keys.Enter)} /> <TerminalButton title="Enter" onPress={() => send(Keys.Enter)} />
@ -108,45 +102,11 @@ const TerminalButton = ({
...props ...props
}: ComponentPropsWithoutRef<typeof Pressable> & { }: ComponentPropsWithoutRef<typeof Pressable> & {
title: string | React.ReactNode; title: string | React.ReactNode;
textStyle?: StyleProp<TextStyle>; textStyle?: TextStyle;
}) => ( }) => (
<Pressable <Pressable px="$4" py="$3" $hover={{ bg: "$blue3" }} {...props}>
style={({ pressed }) => [styles.btn, pressed && styles.btnPressed]} {typeof title === "string" ? <Text {...textStyle}>{title}</Text> : title}
{...props}
>
{typeof title === "string" ? (
<Text style={[styles.btnText, textStyle]}>{title}</Text>
) : (
title
)}
</Pressable> </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; export default Terminal;

View File

@ -20,6 +20,29 @@ type XTermJsProps = {
wsUrl: string; 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 { export interface XTermRef extends DOMImperativeFactory {
send: (...args: JSONValue[]) => void; send: (...args: JSONValue[]) => void;
} }
@ -35,7 +58,11 @@ const XTermJs = forwardRef<XTermRef, XTermJsProps>((props, ref) => {
return; return;
} }
const xterm = new XTerm(); const xterm = new XTerm({
fontFamily: '"Cascadia Code", Menlo, monospace',
theme: snazzyTheme,
cursorBlink: true,
});
xterm.open(container); xterm.open(container);
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
@ -131,6 +158,8 @@ const XTermJs = forwardRef<XTermRef, XTermJsProps>((props, ref) => {
<div <div
ref={containerRef} ref={containerRef}
style={{ style={{
background: snazzyTheme.background,
padding: 12,
flex: !IS_DOM ? 1 : undefined, flex: !IS_DOM ? 1 : undefined,
width: "100%", width: "100%",
height: IS_DOM ? "100vh" : undefined, height: IS_DOM ? "100vh" : undefined,

View File

@ -1,26 +1,51 @@
import { useDebounceCallback } from "@/hooks/useDebounce";
import React, { ComponentPropsWithoutRef, useEffect, useRef } from "react"; import React, { ComponentPropsWithoutRef, useEffect, useRef } from "react";
import RNPagerView from "react-native-pager-view"; import RNPagerView from "react-native-pager-view";
export type PagerViewProps = ComponentPropsWithoutRef<typeof RNPagerView> & { export type PagerViewProps = ComponentPropsWithoutRef<typeof RNPagerView> & {
page?: number; page?: number;
onChangePage?: (page: number) => void; 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 ref = useRef<RNPagerView>(null);
const [onPageSelect, clearPageSelectDebounce] = useDebounceCallback(
(page) => onChangePage?.(page),
100
);
const [setPage] = useDebounceCallback((page) => {
ref.current?.setPage(page);
clearPageSelectDebounce();
}, 300);
useEffect(() => { useEffect(() => {
if (page != null) { if (page != null) {
ref.current?.setPage(page); const npage = EmptyComponent != null ? page + 1 : page;
setPage(npage);
} }
}, [page]); }, [page, EmptyComponent]);
return ( return (
<RNPagerView <RNPagerView
ref={ref} ref={ref}
{...props} {...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>
); );
}; };

View File

@ -3,7 +3,7 @@ import { View } from "react-native";
import { PagerViewProps } from "./pager-view"; import { PagerViewProps } from "./pager-view";
const PagerView = ({ const PagerView = ({
className, EmptyComponent,
children, children,
page, page,
initialPage, initialPage,
@ -33,7 +33,16 @@ const PagerView = ({
}); });
}, [curPage, children]); }, [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; export default PagerView;

View File

@ -1,7 +1,17 @@
import { Pressable as BasePressable } from "react-native"; import { useRef } from "react";
import { GetProps, styled, ViewStyle } from "tamagui"; 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> & { export type PressableProps = GetProps<typeof StyledPressable> & {
$hover?: ViewStyle; $hover?: ViewStyle;
$pressed?: ViewStyle; $pressed?: ViewStyle;
@ -13,7 +23,49 @@ const Pressable = ({
...props ...props
}: PressableProps) => { }: PressableProps) => {
return ( 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>
); );
}; };

View File

@ -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;

View File

@ -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;
};

View File

@ -20,6 +20,7 @@
"@novnc/novnc": "^1.5.0", "@novnc/novnc": "^1.5.0",
"@react-native-async-storage/async-storage": "1.23.1", "@react-native-async-storage/async-storage": "1.23.1",
"@react-navigation/bottom-tabs": "7.0.0-rc.36", "@react-navigation/bottom-tabs": "7.0.0-rc.36",
"@react-navigation/drawer": "^7.0.0",
"@react-navigation/native": "7.0.0-rc.21", "@react-navigation/native": "7.0.0-rc.21",
"@tamagui/config": "^1.116.14", "@tamagui/config": "^1.116.14",
"@tanstack/react-query": "^5.59.20", "@tanstack/react-query": "^5.59.20",
@ -64,5 +65,10 @@
"react-test-renderer": "18.3.1", "react-test-renderer": "18.3.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"private": true "private": true,
"pnpm": {
"patchedDependencies": {
"react-native-drawer-layout": "patches/react-native-drawer-layout.patch"
}
}
} }

View File

@ -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;

View File

@ -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 />
</>
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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)();

View File

@ -4,6 +4,11 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
patchedDependencies:
react-native-drawer-layout:
hash: ipghvwpiqcl5liuijnfvmjzcvq
path: patches/react-native-drawer-layout.patch
importers: importers:
.: .:
@ -20,6 +25,9 @@ importers:
'@react-navigation/bottom-tabs': '@react-navigation/bottom-tabs':
specifier: 7.0.0-rc.36 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) 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': '@react-navigation/native':
specifier: 7.0.0-rc.21 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) 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) 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: expo-router:
specifier: ~4.0.0-preview.12 specifier: ~4.0.0-preview.12
version: 4.0.0-preview.12(yd2wh2xxaopmvq6w6kpgmfoxyy) version: 4.0.0-preview.12(dw2oobptqthz2eo3bvppba5ugq)
expo-splash-screen: expo-splash-screen:
specifier: ~0.29.1 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)) 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: peerDependencies:
react: '*' 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': '@react-navigation/elements@2.0.0-rc.26':
resolution: {integrity: sha512-omtEkb2E8j3dYLq08YGsDykQoVLtTLWAQXp0ql6cB8qjtMhP7rMhoBU50veh0Tes/96Sm3X0e3WZPQMVBKrSSg==} resolution: {integrity: sha512-omtEkb2E8j3dYLq08YGsDykQoVLtTLWAQXp0ql6cB8qjtMhP7rMhoBU50veh0Tes/96Sm3X0e3WZPQMVBKrSSg==}
peerDependencies: peerDependencies:
@ -4611,6 +4642,14 @@ packages:
react-is@18.3.1: react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} 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: react-native-gesture-handler@2.20.2:
resolution: {integrity: sha512-HqzFpFczV4qCnwKlvSAvpzEXisL+Z9fsR08YV5LfJDkzuArMhBu2sOoSPUF/K62PCoAb+ObGlTC83TKHfUd0vg==} resolution: {integrity: sha512-HqzFpFczV4qCnwKlvSAvpzEXisL+Z9fsR08YV5LfJDkzuArMhBu2sOoSPUF/K62PCoAb+ObGlTC83TKHfUd0vg==}
peerDependencies: peerDependencies:
@ -7456,6 +7495,30 @@ snapshots:
use-latest-callback: 0.2.1(react@18.3.1) use-latest-callback: 0.2.1(react@18.3.1)
use-sync-external-store: 1.2.2(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)': '@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: 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) '@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: dependencies:
invariant: 2.2.4 invariant: 2.2.4
expo-router@4.0.0-preview.12(yd2wh2xxaopmvq6w6kpgmfoxyy): expo-router@4.0.0-preview.12(dw2oobptqthz2eo3bvppba5ugq):
dependencies: 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/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) '@expo/server': 0.5.0-preview.0(typescript@5.6.3)
@ -9866,6 +9929,7 @@ snapshots:
schema-utils: 4.2.0 schema-utils: 4.2.0
server-only: 0.0.1 server-only: 0.0.1
optionalDependencies: 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) 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: transitivePeerDependencies:
- '@react-native-masked-view/masked-view' - '@react-native-masked-view/masked-view'
@ -11706,6 +11770,14 @@ snapshots:
react-is@18.3.1: {} 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): 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: dependencies:
'@egjs/hammerjs': 2.0.17 '@egjs/hammerjs': 2.0.17

26
frontend/stores/auth.ts Normal file
View File

@ -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;

View File

@ -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) }
)
);

View File

@ -11,7 +11,7 @@ type Store = {
const useThemeStore = create( const useThemeStore = create(
persist<Store>( persist<Store>(
(set) => ({ (set) => ({
theme: "light", theme: "dark",
setTheme: (theme: "light" | "dark") => { setTheme: (theme: "light" | "dark") => {
set({ theme }); set({ theme });
}, },