feat: update ui

This commit is contained in:
Khairul Hidayat 2024-11-08 18:24:08 +07:00
parent ca3fe7150b
commit 887cb64878
19 changed files with 45204 additions and 57 deletions

File diff suppressed because it is too large Load Diff

36
frontend/app.config.ts Normal file
View File

@ -0,0 +1,36 @@
import { ExpoConfig, ConfigContext } from "expo/config";
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: "frontend",
slug: "frontend",
version: "1.0.0",
orientation: "portrait",
icon: "./assets/images/icon.png",
scheme: "myapp",
userInterfaceStyle: "automatic",
newArchEnabled: true,
splash: {
image: "./assets/images/splash.png",
resizeMode: "contain",
backgroundColor: "#ffffff",
},
ios: {
supportsTablet: true,
},
android: {
adaptiveIcon: {
foregroundImage: "./assets/images/adaptive-icon.png",
backgroundColor: "#ffffff",
},
},
web: {
bundler: "metro",
output: "static",
favicon: "./assets/images/favicon.png",
},
plugins: ["expo-router"],
experiments: {
typedRoutes: true,
},
});

View File

@ -1,37 +0,0 @@
{
"expo": {
"name": "frontend",
"slug": "frontend",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "myapp",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router"
],
"experiments": {
"typedRoutes": true
}
}
}

View File

@ -1,20 +1,15 @@
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from "@react-navigation/native";
import { useFonts } from "expo-font"; import { useFonts } from "expo-font";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { useEffect } from "react"; import { useEffect } from "react";
import "react-native-reanimated"; import "react-native-reanimated";
import Providers from "./_providers";
// Prevent the splash screen from auto-hiding before asset loading is complete. // Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
export default function RootLayout() { export default function RootLayout() {
const colorScheme: string = "light";
const [loaded] = useFonts({ const [loaded] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
}); });
@ -30,11 +25,11 @@ export default function RootLayout() {
} }
return ( return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}> <Providers>
<Stack> <Stack>
<Stack.Screen name="+not-found" /> <Stack.Screen name="+not-found" />
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style="auto" />
</ThemeProvider> </Providers>
); );
} }

View File

@ -0,0 +1,48 @@
import React, { PropsWithChildren, useMemo, useState } from "react";
import tamaguiConfig from "@/tamagui.config";
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from "@react-navigation/native";
import { TamaguiProvider, Theme } from "@tamagui/core";
import useThemeStore from "@/stores/theme";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
type Props = PropsWithChildren;
const Providers = ({ children }: Props) => {
const colorScheme = useThemeStore((i) => i.theme);
const [queryClient] = useState(() => new QueryClient());
const theme = useMemo(() => {
return colorScheme === "dark"
? tamaguiConfig.themes.dark_blue
: tamaguiConfig.themes.light_blue;
}, [colorScheme]);
const navTheme = useMemo(() => {
const base = colorScheme === "dark" ? DarkTheme : DefaultTheme;
return {
...base,
colors: {
...base.colors,
background: theme.background.val,
},
};
}, [theme, colorScheme]);
return (
<ThemeProvider value={navTheme}>
<TamaguiProvider config={tamaguiConfig} defaultTheme={colorScheme}>
<Theme name="blue">
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</Theme>
</TamaguiProvider>
</ThemeProvider>
);
};
export default Providers;

View File

@ -0,0 +1,69 @@
import Icons from "@/components/ui/icons";
import Select, { SelectItem } from "@/components/ui/select";
import api from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import React from "react";
import { Button, Input, Label, ScrollView, Text, View, XStack } from "tamagui";
type Props = {};
const typeOptions: SelectItem[] = [
{ label: "SSH", value: "ssh" },
{ label: "Proxmox VE", value: "pve" },
{ label: "Incus", value: "incus" },
];
const HostForm = (props: Props) => {
const keys = useQuery({
queryKey: ["keychains"],
queryFn: () => api("/keychains"),
select: (i) => i.rows,
});
return (
<>
<ScrollView contentContainerStyle={{ padding: "$4" }}>
<Label>Hostname</Label>
<Input placeholder="IP or hostname..." />
<Label>Type</Label>
<Select items={typeOptions} />
<Label>Port</Label>
<Input keyboardType="number-pad" placeholder="SSH Port" />
<Label>Label</Label>
<Input placeholder="Label..." />
<XStack gap="$3" mt="$3" mb="$1">
<Label flex={1}>Credentials</Label>
<Button size="$3" icon={<Icons size={16} name="plus" />}>
Add
</Button>
</XStack>
<Select
placeholder="Username & Password"
items={keys.data?.map((key: any) => ({
label: key.label,
value: key.id,
}))}
/>
<Select
mt="$3"
placeholder="Private Key"
items={keys.data?.map((key: any) => ({
label: key.label,
value: key.id,
}))}
/>
</ScrollView>
<View p="$4">
<Button icon={<Icons name="content-save" size={18} />}>Save</Button>
</View>
</>
);
};
export default HostForm;

View File

@ -0,0 +1,12 @@
import React from "react";
import { Stack } from "expo-router";
import HostForm from "./_comp/host-form";
export default function CreateHostPage() {
return (
<>
<Stack.Screen options={{ title: "Add Host" }} />
<HostForm />
</>
);
}

View File

@ -0,0 +1,12 @@
import React from "react";
import { Stack } from "expo-router";
import HostForm from "./_comp/host-form";
export default function EditHostPage() {
return (
<>
<Stack.Screen options={{ title: "Edit Host" }} />
<HostForm />
</>
);
}

View File

@ -0,0 +1,80 @@
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>
)}
</>
);
}

6
frontend/app/index.tsx Normal file
View File

@ -0,0 +1,6 @@
import React from "react";
import { Redirect } from "expo-router";
export default function index() {
return <Redirect href="/hosts" />;
}

20
frontend/babel.config.js Normal file
View File

@ -0,0 +1,20 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [
[
"@tamagui/babel-plugin",
{
components: ["tamagui"],
config: "./tamagui.config.ts",
logTimings: true,
disableExtraction: process.env.NODE_ENV === "development",
},
],
// NOTE: this is only necessary if you are using reanimated for animations
"react-native-reanimated/plugin",
],
};
};

View File

@ -0,0 +1,8 @@
import MaterialCommunityIcons from "@expo/vector-icons/MaterialCommunityIcons";
import { styled } from "tamagui";
export const Icons = styled(MaterialCommunityIcons, {
color: "$color",
});
export default Icons;

View File

@ -0,0 +1,20 @@
import { Pressable as BasePressable } from "react-native";
import { GetProps, styled, ViewStyle } from "tamagui";
const StyledPressable = styled(BasePressable);
export type PressableProps = GetProps<typeof StyledPressable> & {
$hover?: ViewStyle;
$pressed?: ViewStyle;
};
const Pressable = ({
$hover,
$pressed = { opacity: 0.5 },
...props
}: PressableProps) => {
return (
<StyledPressable pressStyle={$pressed} hoverStyle={$hover} {...props} />
);
};
export default Pressable;

View File

@ -0,0 +1,65 @@
import React, { forwardRef } from "react";
import { Select as BaseSelect } from "tamagui";
export type SelectItem = {
label: string;
value: string;
};
type SelectProps = React.ComponentPropsWithoutRef<typeof BaseSelect.Trigger> & {
items?: SelectItem[] | null;
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
placeholder?: string;
};
type SelectRef = React.ElementRef<typeof BaseSelect.Trigger>;
const Select = forwardRef<SelectRef, SelectProps>(
(
{
items,
value,
defaultValue,
onChange,
placeholder = "Select...",
...props
},
ref
) => {
return (
<BaseSelect
defaultValue={defaultValue}
value={value}
onValueChange={onChange}
>
<BaseSelect.Trigger ref={ref} {...props}>
<BaseSelect.Value placeholder={placeholder} />
</BaseSelect.Trigger>
<BaseSelect.Content>
<BaseSelect.ScrollUpButton />
<BaseSelect.Viewport>
<BaseSelect.Item value="" index={0}>
<BaseSelect.ItemText>{placeholder}</BaseSelect.ItemText>
</BaseSelect.Item>
{items?.map((item, idx) => (
<BaseSelect.Item
key={item.value}
value={item.value}
index={idx + 1}
>
<BaseSelect.ItemText>{item.label}</BaseSelect.ItemText>
</BaseSelect.Item>
))}
</BaseSelect.Viewport>
<BaseSelect.ScrollDownButton />
</BaseSelect.Content>
</BaseSelect>
);
}
);
export default Select;

View File

@ -1,2 +1,10 @@
import { ofetch } from "ofetch";
export const BASE_API_URL = process.env.EXPO_PUBLIC_API_URL || ""; //"http://10.0.0.100:3000"; export const BASE_API_URL = process.env.EXPO_PUBLIC_API_URL || ""; //"http://10.0.0.100:3000";
export const BASE_WS_URL = BASE_API_URL.replace("http", "ws"); export const BASE_WS_URL = BASE_API_URL.replace("http", "ws");
const api = ofetch.create({
baseURL: BASE_API_URL,
});
export default api;

View File

@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"license": "0BSD", "license": "MIT",
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
@ -18,8 +18,11 @@
"dependencies": { "dependencies": {
"@expo/vector-icons": "^14.0.2", "@expo/vector-icons": "^14.0.2",
"@novnc/novnc": "^1.5.0", "@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/bottom-tabs": "7.0.0-rc.36",
"@react-navigation/native": "7.0.0-rc.21", "@react-navigation/native": "7.0.0-rc.21",
"@tamagui/config": "^1.116.14",
"@tanstack/react-query": "^5.59.20",
"@xterm/addon-attach": "^0.11.0", "@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
@ -35,6 +38,7 @@
"expo-symbols": "~0.2.0", "expo-symbols": "~0.2.0",
"expo-system-ui": "~4.0.2", "expo-system-ui": "~4.0.2",
"expo-web-browser": "~14.0.0", "expo-web-browser": "~14.0.0",
"ofetch": "^1.4.1",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.76.1", "react-native": "0.76.1",
@ -44,10 +48,13 @@
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "4.0.0-beta.16", "react-native-screens": "4.0.0-beta.16",
"react-native-web": "~0.19.13", "react-native-web": "~0.19.13",
"react-native-webview": "^13.12.2" "react-native-webview": "^13.12.2",
"tamagui": "^1.116.14",
"zustand": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",
"@tamagui/babel-plugin": "^1.116.14",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/novnc__novnc": "^1.5.0", "@types/novnc__novnc": "^1.5.0",
"@types/react": "~18.3.12", "@types/react": "~18.3.12",

2664
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

29
frontend/stores/theme.ts Normal file
View File

@ -0,0 +1,29 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
type Store = {
theme: "light" | "dark";
setTheme: (theme: "light" | "dark") => void;
toggle: () => void;
};
const useThemeStore = create(
persist<Store>(
(set) => ({
theme: "light",
setTheme: (theme: "light" | "dark") => {
set({ theme });
},
toggle: () => {
set((state) => ({ theme: state.theme === "light" ? "dark" : "light" }));
},
}),
{
name: "theme",
storage: createJSONStorage(() => AsyncStorage),
}
)
);
export default useThemeStore;

View File

@ -0,0 +1,13 @@
import { createTamagui } from "@tamagui/core";
import { config } from "@tamagui/config/v3";
// you usually export this from a tamagui.config.ts file
const tamaguiConfig = createTamagui(config);
// TypeScript types across all Tamagui APIs
type Conf = typeof tamaguiConfig;
declare module "@tamagui/core" {
interface TamaguiCustomConfig extends Conf {}
}
export default tamaguiConfig;