From 334d90e69105f72160a1aab07c66f9254c9e4977 Mon Sep 17 00:00:00 2001
From: Khairul Hidayat <me@khairul.my.id>
Date: Fri, 8 Nov 2024 18:53:30 +0000
Subject: [PATCH] feat: update ui

---
 frontend/app/(drawer)/_layout.tsx             |  26 ++++
 frontend/app/(drawer)/hosts.tsx               |   3 +
 frontend/app/(drawer)/terminal.tsx            |   3 +
 frontend/app/_layout.tsx                      |   7 +-
 frontend/app/_providers.tsx                   |  45 +++++--
 frontend/app/auth/login.tsx                   |  14 ++
 frontend/app/hosts/create.tsx                 |   2 +-
 frontend/app/hosts/edit.tsx                   |   2 +-
 frontend/app/hosts/index.tsx                  |  80 -----------
 frontend/app/index.tsx                        |   9 +-
 frontend/app/term/index.tsx                   |  91 -------------
 frontend/app/terminal/sessions.tsx            |   3 +
 .../containers/interactive-session.tsx        |   9 +-
 frontend/components/containers/terminal.tsx   |  72 +++-------
 frontend/components/containers/xtermjs.tsx    |  31 ++++-
 frontend/components/ui/pager-view.tsx         |  35 ++++-
 frontend/components/ui/pager-view.web.tsx     |  13 +-
 frontend/components/ui/pressable.tsx          |  60 ++++++++-
 frontend/components/ui/search-input.tsx       |  25 ++++
 frontend/hooks/useDebounce.ts                 |  28 ++++
 frontend/package.json                         |   8 +-
 .../hosts/components/form.tsx}                |   0
 .../pages/hosts/components/hosts-list.tsx     | 124 ++++++++++++++++++
 frontend/pages/hosts/page.tsx                 |  25 ++++
 .../terminal/components/session-tabs.tsx      |  63 +++++++++
 frontend/pages/terminal/page.tsx              |  48 +++++++
 frontend/pages/terminal/sessions-page.tsx     |  93 +++++++++++++
 .../patches/react-native-drawer-layout.patch  |  25 ++++
 frontend/pnpm-lock.yaml                       |  76 ++++++++++-
 frontend/stores/auth.ts                       |  26 ++++
 frontend/stores/terminal-sessions.ts          |  43 ++++++
 frontend/stores/theme.ts                      |   2 +-
 32 files changed, 829 insertions(+), 262 deletions(-)
 create mode 100644 frontend/app/(drawer)/_layout.tsx
 create mode 100644 frontend/app/(drawer)/hosts.tsx
 create mode 100644 frontend/app/(drawer)/terminal.tsx
 create mode 100644 frontend/app/auth/login.tsx
 delete mode 100644 frontend/app/hosts/index.tsx
 delete mode 100644 frontend/app/term/index.tsx
 create mode 100644 frontend/app/terminal/sessions.tsx
 create mode 100644 frontend/components/ui/search-input.tsx
 create mode 100644 frontend/hooks/useDebounce.ts
 rename frontend/{app/hosts/_comp/host-form.tsx => pages/hosts/components/form.tsx} (100%)
 create mode 100644 frontend/pages/hosts/components/hosts-list.tsx
 create mode 100644 frontend/pages/hosts/page.tsx
 create mode 100644 frontend/pages/terminal/components/session-tabs.tsx
 create mode 100644 frontend/pages/terminal/page.tsx
 create mode 100644 frontend/pages/terminal/sessions-page.tsx
 create mode 100644 frontend/patches/react-native-drawer-layout.patch
 create mode 100644 frontend/stores/auth.ts
 create mode 100644 frontend/stores/terminal-sessions.ts

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