mirror of
https://github.com/khairul169/vaulterm.git
synced 2025-04-28 16:49:39 +07:00
feat: update ui
This commit is contained in:
parent
887cb64878
commit
334d90e691
26
frontend/app/(drawer)/_layout.tsx
Normal file
26
frontend/app/(drawer)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
3
frontend/app/(drawer)/hosts.tsx
Normal file
3
frontend/app/(drawer)/hosts.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import HostsPage from "@/pages/hosts/page";
|
||||
|
||||
export default HostsPage;
|
3
frontend/app/(drawer)/terminal.tsx
Normal file
3
frontend/app/(drawer)/terminal.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import TerminalPage from "@/pages/terminal/page";
|
||||
|
||||
export default TerminalPage;
|
@ -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>
|
||||
|
@ -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,6 +35,8 @@ const Providers = ({ children }: Props) => {
|
||||
}, [theme, colorScheme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthProvider />
|
||||
<ThemeProvider value={navTheme}>
|
||||
<TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}>
|
||||
<Theme name="blue">
|
||||
@ -42,7 +46,28 @@ const Providers = ({ children }: Props) => {
|
||||
</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;
|
||||
|
14
frontend/app/auth/login.tsx
Normal file
14
frontend/app/auth/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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 (
|
||||
|
@ -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 (
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
3
frontend/app/terminal/sessions.tsx
Normal file
3
frontend/app/terminal/sessions.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import SessionsPage from "@/pages/terminal/sessions-page";
|
||||
|
||||
export default SessionsPage;
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
25
frontend/components/ui/search-input.tsx
Normal file
25
frontend/components/ui/search-input.tsx
Normal 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;
|
28
frontend/hooks/useDebounce.ts
Normal file
28
frontend/hooks/useDebounce.ts
Normal 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;
|
||||
};
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
124
frontend/pages/hosts/components/hosts-list.tsx
Normal file
124
frontend/pages/hosts/components/hosts-list.tsx
Normal 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;
|
25
frontend/pages/hosts/page.tsx
Normal file
25
frontend/pages/hosts/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
63
frontend/pages/terminal/components/session-tabs.tsx
Normal file
63
frontend/pages/terminal/components/session-tabs.tsx
Normal 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;
|
48
frontend/pages/terminal/page.tsx
Normal file
48
frontend/pages/terminal/page.tsx
Normal 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;
|
93
frontend/pages/terminal/sessions-page.tsx
Normal file
93
frontend/pages/terminal/sessions-page.tsx
Normal 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;
|
25
frontend/patches/react-native-drawer-layout.patch
Normal file
25
frontend/patches/react-native-drawer-layout.patch
Normal 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)();
|
76
frontend/pnpm-lock.yaml
generated
76
frontend/pnpm-lock.yaml
generated
@ -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
|
||||
|
26
frontend/stores/auth.ts
Normal file
26
frontend/stores/auth.ts
Normal 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;
|
43
frontend/stores/terminal-sessions.ts
Normal file
43
frontend/stores/terminal-sessions.ts
Normal 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) }
|
||||
)
|
||||
);
|
@ -11,7 +11,7 @@ type Store = {
|
||||
const useThemeStore = create(
|
||||
persist<Store>(
|
||||
(set) => ({
|
||||
theme: "light",
|
||||
theme: "dark",
|
||||
setTheme: (theme: "light" | "dark") => {
|
||||
set({ theme });
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user