feat: project init

This commit is contained in:
2024-03-15 09:05:49 +07:00
parent 01e5ae1a19
commit 9402b650b6
25 changed files with 1036 additions and 219 deletions
+11 -3
View File
@@ -3,18 +3,26 @@ import { Slot } from "expo-router";
import { QueryClientProvider } from "react-query";
import queryClient from "@/lib/queryClient";
import { View } from "react-native";
import { cn } from "@/lib/utils";
import { cn, tw } from "@/lib/utils";
import { useDeviceContext } from "twrnc";
import { StatusBar } from "expo-status-bar";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const RootLayout = () => {
const insets = useSafeAreaInsets();
useDeviceContext(tw);
return (
<QueryClientProvider client={queryClient}>
<StatusBar style="auto" />
<View style={cn("flex-1 bg-white", { paddingTop: insets.top })}>
<Slot />
<View style={cn("flex-1 bg-[#f2f7fb]")}>
<View
style={cn("flex-1 mx-auto w-full max-w-xl", {
paddingTop: insets.top,
})}
>
<Slot />
</View>
</View>
</QueryClientProvider>
);
+102
View File
@@ -0,0 +1,102 @@
import React, { useState } from "react";
import { AnimatedCircularProgress } from "react-native-circular-progress";
import { InferResponseType } from "hono/client";
import api from "@/lib/api";
import Text from "@ui/Text";
import { HStack, VStack } from "@ui/Stack";
import Box from "@ui/Box";
import ProcessList from "./ProcessList";
import Divider from "@ui/Divider";
import Button from "@ui/Button";
import { Ionicons } from "@ui/Icons";
type Props = {
data: InferResponseType<typeof api.system.$get>;
};
const Performance = ({ data: system }: Props) => {
const [showProcess, setShowProcess] = useState(false);
return (
<>
<Text className="text-lg font-medium mt-8">Performance</Text>
<Box className="px-4 py-6 mt-2 bg-white border border-gray-100 rounded-lg relative">
<HStack className="justify-evenly">
<VStack className="items-center">
<AnimatedCircularProgress
size={120}
width={15}
backgroundWidth={5}
fill={system?.perf.cpu.load || 0}
tintColor="#6366F1"
backgroundColor="#3d5875"
arcSweepAngle={240}
rotation={240}
lineCap="round"
>
{() => (
<Text>
<Text className="text-2xl mr-0.5">
{Math.round(system?.perf.cpu.load || 0)}
</Text>
%
</Text>
)}
</AnimatedCircularProgress>
<Text className="-mt-8 text-lg">CPU</Text>
{system ? (
<Text className="text-xs">
{`${system.perf.cpu.speed.toFixed(1)} GHz / ${
system.perf.cpu.temp
}°C`}
</Text>
) : null}
</VStack>
<VStack className="items-center">
<AnimatedCircularProgress
size={120}
width={15}
backgroundWidth={5}
fill={system?.perf.mem.percent || 0}
tintColor="#6366F1"
backgroundColor="#3d5875"
arcSweepAngle={240}
rotation={240}
lineCap="round"
>
{() => (
<Text>
<Text className="text-2xl mr-0.5">
{Math.round(system?.perf.mem.percent || 0)}
</Text>
%
</Text>
)}
</AnimatedCircularProgress>
<Text className="-mt-8 text-lg">Mem</Text>
<Text className="text-xs">{system?.perf.mem.used}</Text>
</VStack>
</HStack>
<Button
icon={
<Ionicons
name="chevron-forward"
style={{
transform: showProcess ? [{ rotate: "90deg" }] : undefined,
}}
/>
}
className="absolute right-0 top-1"
variant="ghost"
onPress={() => setShowProcess(!showProcess)}
/>
{showProcess && <ProcessList />}
</Box>
</>
);
};
export default Performance;
+54
View File
@@ -0,0 +1,54 @@
import api from "@/lib/api";
import Box from "@ui/Box";
import Button from "@ui/Button";
import { HStack, VStack } from "@ui/Stack";
import Text from "@ui/Text";
import React, { useState } from "react";
import { useQuery } from "react-query";
const ProcessList = () => {
const [sort, setSort] = useState<string>("mem");
const { data } = useQuery({
queryKey: ["process", sort],
queryFn: async () => {
return api.process
.$get({ query: { sort, limit: 5 } })
.then((r) => r.json());
},
select: (i) => i.list,
refetchInterval: 1000,
});
return (
<Box className="mt-4">
<HStack className="gap-2 flex-wrap">
<Button
label="Mem"
variant={sort === "mem" ? "default" : "outline"}
size="sm"
onPress={() => setSort("mem")}
/>
<Button
label="CPU"
variant={sort === "cpu" ? "default" : "outline"}
size="sm"
onPress={() => setSort("cpu")}
/>
</HStack>
<VStack className="gap-2 mt-3">
{data?.map((item, idx) => (
<HStack key={idx} className="pb-2 border-b border-gray-200">
<Text className="flex-1" numberOfLines={1}>
{item.cmd}
</Text>
<Text>{sort === "mem" ? item.memUsage : item.cpuPercent}</Text>
</HStack>
))}
</VStack>
</Box>
);
};
export default ProcessList;
+49
View File
@@ -0,0 +1,49 @@
import React from "react";
import { InferResponseType } from "hono/client";
import api from "@/lib/api";
import Text from "@ui/Text";
import Box from "@ui/Box";
import DriveIcon from "@/assets/icons/harddrive.svg";
import { HStack, VStack } from "@ui/Stack";
type Props = {
data: InferResponseType<typeof api.system.$get>;
};
const Storage = ({ data }: Props) => {
return (
<>
<Text className="text-lg font-medium mt-8">Storage</Text>
<HStack className="px-1 py-4 mt-2 bg-white border border-gray-100 rounded-lg gap-3 flex-wrap">
{data?.storage.map((item) => (
<Box key={item.mount} className="flex-1 basis-full sm:basis-[40%]">
<HStack className="flex items-center justify-center gap-2">
<DriveIcon style={{ width: 72, height: 72 }} />
<VStack className="flex-1 gap-1">
<Text className="text-primary font-bold">{item.mount}</Text>
<Text>{`Total: ${item.total}`}</Text>
<Text>{`Free: ${item.free}`}</Text>
</VStack>
</HStack>
<Box className="rounded-full h-2 mx-4 bg-gray-200 overflow-hidden">
<Box
className={[
"rounded-full h-2",
item.percent > 90
? "bg-red-500"
: item.percent > 75
? "bg-yellow-500"
: "bg-primary-400",
]}
style={{ width: `${item.percent}%` }}
></Box>
</Box>
</Box>
))}
</HStack>
</>
);
};
export default Storage;
+24
View File
@@ -0,0 +1,24 @@
import React from "react";
import Box from "@ui/Box";
import Text from "@ui/Text";
import dayjs from "dayjs";
import { InferResponseType } from "hono/client";
import api from "@/lib/api";
type Props = {
data: InferResponseType<typeof api.system.$get>;
};
const Summary = ({ data }: Props) => {
return (
<Box className="px-4 py-6 mt-4 bg-white border border-gray-100 rounded-lg">
<Text className="text-5xl">{dayjs(data?.date).format("HH:mm")}</Text>
<Text className="mt-2">
{dayjs(data?.date).format("dddd, DD MMM YYYY")}
</Text>
<Text className="flex-1">{`Uptime: ${data?.uptime || "-"}`}</Text>
</Box>
);
};
export default Summary;
+21 -16
View File
@@ -1,26 +1,31 @@
import { Text } from "react-native";
import React from "react";
import api from "@/lib/api";
import { useQuery } from "react-query";
import Text from "@ui/Text";
import Performance from "./_sections/Performance";
import Summary from "./_sections/Summary";
import Storage from "./_sections/Storage";
import { ScrollView } from "react-native";
import { cn } from "@/lib/utils";
import useAPI from "@/hooks/useAPI";
import { Ionicons } from "@ui/Icons";
import { VStack } from "@ui/Stack";
import Button from "@ui/Button";
const App = () => {
const { data } = useAPI("/posts/1");
const { data: system } = useQuery({
queryKey: ["system"],
queryFn: () => api.system.$get().then((r) => r.json()),
refetchInterval: 1000,
});
return (
<VStack className="gap-3 p-4">
<Text style={cn("text-2xl font-medium")}>App</Text>
<Text style={cn("w-full")}>{data?.body}</Text>
<ScrollView
contentContainerStyle={cn("px-4 py-8 md:py-16")}
showsVerticalScrollIndicator={false}
>
<Text className="text-2xl font-medium">Home Lab</Text>
<Button label="Click me" />
<Button label="Click me" variant="secondary" />
<Button label="Click me" variant="ghost" />
<Button label="Click me" variant="outline" />
<Button label="Click me" variant="destructive" />
<Button icon={<Ionicons name="trash" />} size="icon" />
</VStack>
<Summary data={system} />
<Performance data={system} />
<Storage data={system} />
</ScrollView>
);
};
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 48 48">
<g>
<path d="m 12.563 7.5 l 22.875 0 c 1.141 0 2.063 0.922 2.063 2.063 l 0 28.875 c 0 1.141 -0.922 2.063 -2.063 2.063 l -22.875 0 c -1.141 0 -2.063 -0.922 -2.063 -2.063 l 0 -28.875 c 0 -1.141 0.922 -2.063 2.063 -2.063 m 0 0" style="fill:#829495;fill-opacity:1;stroke:none;fill-rule:nonzero"/>
<path d="m 30.01 7.5 l 4.5 0 l 0 33 l -4.5 0 m 0 -33" style="fill:#fcf5e3;fill-opacity:0.463;stroke:none;fill-rule:nonzero"/>
<path d="m 24.09 11.965 c -1.453 -0.016 -2.926 0.316 -4.297 1.043 c -4.395 2.328 -6.03 7.77 -3.699 12.164 c 1.93 3.641 5.984 5.402 9.828 4.582 l -1.676 -5.91 c -0.055 0.004 -0.102 0.031 -0.156 0.031 c -1.676 0 -3.035 -1.355 -3.035 -3.03 c 0 -1.676 1.359 -3.035 3.035 -3.035 c 1.672 0 3.03 1.359 3.03 3.035 c 0 0.75 -0.277 1.43 -0.727 1.957 l 3.887 4.645 c 2.816 -2.719 3.629 -7.078 1.703 -10.711 c -1.598 -3.02 -4.703 -4.734 -7.895 -4.77 m 0 0" style="fill:#063642;fill-opacity:1;stroke:none;fill-rule:nonzero"/>
<path d="m 26.805 26.719 l 3.094 9.281 l 3.094 0 l 0 -2.063 m -6.188 -7.219" style="fill:#063642;fill-opacity:1;stroke:none;fill-rule:nonzero"/>
<path d="m 15.527 23.621 c 0.805 2.531 2.738 4.629 5.316 5.637 l 2.121 -5.645 c -0.309 -0.121 -0.582 -0.293 -0.848 -0.52 c -0.426 -0.367 -0.734 -0.824 -0.902 -1.32 m -5.688 1.848" style="fill:#93a1a1;fill-opacity:0.494;stroke:none;fill-rule:nonzero"/>
<path d="m 15.27 18.992 c -0.566 2.594 0.059 5.379 1.789 7.543 l 4.652 -3.828 c -0.203 -0.262 -0.355 -0.547 -0.469 -0.875 c -0.188 -0.531 -0.227 -1.082 -0.121 -1.598 m -5.852 -1.242" style="fill:#268bd1;fill-opacity:0.247;stroke:none;fill-rule:nonzero"/>
<path d="m 32.523 17.969 c -0.824 -2.523 -2.77 -4.605 -5.359 -5.598 l -2.078 5.66 c 0.309 0.117 0.586 0.289 0.852 0.512 c 0.426 0.363 0.738 0.82 0.91 1.316 m 5.676 -1.891" style="fill:#93a1a1;fill-opacity:0.494;stroke:none;fill-rule:nonzero"/>
<path d="m 32.816 22.598 c 0.547 -2.598 -0.098 -5.379 -1.844 -7.527 l -4.629 3.859 c 0.207 0.258 0.363 0.543 0.48 0.871 c 0.188 0.527 0.23 1.082 0.129 1.594 m 5.863 1.203" style="fill:#268bd1;fill-opacity:0.192;stroke:none;fill-rule:nonzero"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+4 -2
View File
@@ -4,8 +4,10 @@ import { View } from "react-native";
type Props = ComponentPropsWithClassName<typeof View>;
const Box = ({ className, ...props }: Props) => {
return <View style={cn(className)} {...props} />;
const Box = ({ className, style, ...props }: Props) => {
return (
<View style={{ ...cn(className), ...((style || {}) as any) }} {...props} />
);
};
export default Box;
+9
View File
@@ -0,0 +1,9 @@
import { View, Text } from "react-native";
import React from "react";
import { cn } from "@/lib/utils";
const Divider = ({ className }: { className?: string }) => {
return <View style={cn("border-b border-gray-300 w-full h-1", className)} />;
};
export default Divider;
+13
View File
@@ -0,0 +1,13 @@
import { Text as BaseText } from "react-native";
import React from "react";
import { cn } from "@/lib/utils";
import { ComponentPropsWithClassName } from "@/types/components";
const Text = ({
className,
...props
}: ComponentPropsWithClassName<typeof BaseText>) => {
return <BaseText style={cn(className)} {...props} />;
};
export default Text;
-11
View File
@@ -1,11 +0,0 @@
import api, { APIParams } from "@/lib/api";
import { useQuery } from "react-query";
const useAPI = <T = any>(url: string, params?: APIParams) => {
return useQuery({
queryKey: [url, params],
queryFn: async () => api<T>(url, { params }),
});
};
export default useAPI;
+15 -70
View File
@@ -1,75 +1,20 @@
import { API_BASEURL } from "./constants";
import { hc } from "hono/dist/client";
import type {
hc as hono,
InferRequestType,
InferResponseType,
ClientRequestOptions,
ClientResponse,
} from "hono/client";
import type { AppType } from "../../backend";
export type APIParams = { [key: string]: any };
const api = hc(API_BASEURL) as ReturnType<typeof hono<AppType>>;
export type APIOptions = RequestInit & {
params?: APIParams;
export {
InferRequestType,
InferResponseType,
ClientRequestOptions,
ClientResponse,
};
const api = async <T>(url: string, options: APIOptions = {}): Promise<T> => {
const fullUrl = new URL(url, API_BASEURL);
if (options.params && Object.keys(options.params).length > 0) {
for (const [key, value] of Object.entries(options.params)) {
fullUrl.searchParams.append(key, value);
}
}
const response = await fetch(fullUrl.toString(), options);
if (!response.ok) {
throw new APIError(response);
}
if (response.headers.get("content-type")?.includes("application/json")) {
const data = (await response.json()) as T;
return data;
}
const data = await response.text();
return data as T;
};
api.post = async <T>(url: string, body: any, options: APIOptions = {}) => {
return api<T>(url, {
method: "POST",
body: JSON.stringify(body),
...options,
});
};
api.patch = async <T>(url: string, body: any, options: APIOptions = {}) => {
return api<T>(url, {
method: "PATCH",
body: JSON.stringify(body),
...options,
});
};
api.put = async <T>(url: string, body: any, options: APIOptions = {}) => {
return api<T>(url, {
method: "PUT",
body: JSON.stringify(body),
...options,
});
};
api.delete = async <T>(url: string, options: APIOptions = {}) => {
return api<T>(url, {
method: "DELETE",
...options,
});
};
export class APIError extends Error {
res: Response;
code: number;
constructor(res: Response) {
super(res.statusText);
this.res = res;
this.code = res.status;
}
}
export default api;
+1 -2
View File
@@ -1,2 +1 @@
// export const API_BASEURL = "http://localhost:8000";
export const API_BASEURL = "https://jsonplaceholder.typicode.com";
export const API_BASEURL = "http://localhost:3000";
+1 -1
View File
@@ -1,6 +1,6 @@
import { ClassInput, create as createTwrnc } from "twrnc";
const tw = createTwrnc(require(`../../tailwind.config.js`));
export const tw = createTwrnc(require(`../../tailwind.config.js`));
export const cn = (...args: ClassInput[]) => {
if (Array.isArray(args[0])) {