mirror of
https://github.com/khairul169/home-lab.git
synced 2026-10-02 17:13:57 +07:00
feat: add terminal & wake pc
This commit is contained in:
@@ -7,8 +7,10 @@ import { cn, tw } from "@/lib/utils";
|
||||
import { useDeviceContext } from "twrnc";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Toast from "react-native-toast-notifications";
|
||||
import { useStore } from "zustand";
|
||||
import authStore from "@/stores/authStore";
|
||||
import { toastStore } from "@/stores/toastStore";
|
||||
|
||||
const RootLayout = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -39,6 +41,11 @@ const RootLayout = () => {
|
||||
<View style={cn("flex-1 bg-[#f2f7fb]", { paddingTop: insets.top })}>
|
||||
<Slot />
|
||||
</View>
|
||||
<Toast
|
||||
ref={(ref) => {
|
||||
toastStore.setState(ref);
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// src/app/apps/lib.ts
|
||||
|
||||
import api from "@/lib/api";
|
||||
import { showToast } from "@/stores/toastStore";
|
||||
|
||||
export const wakePcUp = async () => {
|
||||
try {
|
||||
await api.apps.wakepc.$post();
|
||||
showToast("Waking up PC...");
|
||||
} catch (err) {
|
||||
showToast("Cannot wake up the PC!", { type: "danger" });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Platform } from "react-native";
|
||||
import { Terminal } from "xterm";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import { AttachAddon } from "xterm-addon-attach";
|
||||
|
||||
import Box from "@ui/Box";
|
||||
import Text from "@ui/Text";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import "xterm/css/xterm.css";
|
||||
import { API_BASEURL } from "@/lib/constants";
|
||||
import authStore, { useAuth } from "@/stores/authStore";
|
||||
|
||||
const isWeb = Platform.OS === "web";
|
||||
|
||||
const TerminalPage = () => {
|
||||
const { token } = useAuth();
|
||||
const terminalRef = useRef<any>();
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const term = new Terminal({ theme: { background: "#1d1e2b" } });
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
|
||||
const baseUrl = API_BASEURL.replace("https://", "wss://").replace(
|
||||
"http://",
|
||||
"ws://"
|
||||
);
|
||||
const socket = new WebSocket(baseUrl + "/terminal?token=" + token);
|
||||
const attachAddon = new AttachAddon(socket);
|
||||
|
||||
// Attach the socket to term
|
||||
term.loadAddon(attachAddon);
|
||||
term.open(terminalRef.current);
|
||||
|
||||
fitAddon.fit();
|
||||
fitRef.current = fitAddon;
|
||||
|
||||
const sendResizeSignal = (data: { cols: number; rows: number }) => {
|
||||
socket.send("resize:" + [data.cols, data.rows].join(","));
|
||||
};
|
||||
|
||||
term.onResize(sendResizeSignal);
|
||||
setTimeout(() => {
|
||||
sendResizeSignal({ cols: term.cols, rows: term.rows });
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
attachAddon.dispose();
|
||||
fitAddon.dispose();
|
||||
term.dispose();
|
||||
fitRef.current = null;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onResize = () => {
|
||||
if (fitRef.current) {
|
||||
fitRef.current.fit();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isWeb) {
|
||||
return (
|
||||
<Box className="p-8">
|
||||
<Text className="text-center">Only web platform suppported.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={terminalRef}
|
||||
style={{ height: "100vh", background: "#1d1e2b", padding: 16 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalPage;
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { ComponentProps } from "react";
|
||||
import Text from "@ui/Text";
|
||||
import Box from "@ui/Box";
|
||||
import { Ionicons } from "@ui/Icons";
|
||||
import { HStack } from "@ui/Stack";
|
||||
import Button from "@ui/Button";
|
||||
import { useNavigation } from "expo-router";
|
||||
import { wakePcUp } from "@/app/apps/lib";
|
||||
|
||||
type Props = ComponentProps<typeof Box>;
|
||||
|
||||
const Apps = (props: Props) => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const appList = [
|
||||
{
|
||||
name: "Terminal",
|
||||
icon: <Ionicons name="terminal" />,
|
||||
path: "terminal",
|
||||
},
|
||||
{
|
||||
name: "PC Control",
|
||||
icon: <Ionicons name="desktop" />,
|
||||
action: wakePcUp,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box {...props}>
|
||||
<Text className="text-lg md:text-2xl font-medium">Apps</Text>
|
||||
<HStack className="mt-4 flex-wrap gap-4 md:gap-8">
|
||||
{appList.map((app, idx) => (
|
||||
<Button
|
||||
key={idx}
|
||||
className="flex-col flex-1 basis-1/3 md:max-w-[160px] h-32 bg-white"
|
||||
labelClasses="text-sm text-center text-gray-900"
|
||||
iconClassName="text-2xl mb-1 text-primary"
|
||||
variant="outline"
|
||||
icon={app.icon}
|
||||
label={app.name}
|
||||
onPress={() => {
|
||||
if (app.path) {
|
||||
navigation.navigate(("apps/" + app.path) as never);
|
||||
}
|
||||
|
||||
if (app.action) {
|
||||
app.action();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(Apps);
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
|
||||
const Summary = ({ data }: Props) => {
|
||||
return (
|
||||
<Box className="px-4 py-6 mt-4 bg-white border border-gray-100 rounded-lg">
|
||||
<Box className="px-4 mt-4 py-6 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")}
|
||||
|
||||
+14
-6
@@ -7,6 +7,9 @@ import Summary from "./_sections/Summary";
|
||||
import Storage from "./_sections/Storage";
|
||||
import Container from "@ui/Container";
|
||||
import { useAuth } from "@/stores/authStore";
|
||||
import { HStack } from "@ui/Stack";
|
||||
import Box from "@ui/Box";
|
||||
import Apps from "./_sections/Apps";
|
||||
|
||||
const HomePage = () => {
|
||||
const { isLoggedIn } = useAuth();
|
||||
@@ -22,12 +25,17 @@ const HomePage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container scrollable className="px-4 py-8 md:py-16">
|
||||
<Text className="text-2xl font-medium">Home Lab</Text>
|
||||
|
||||
<Summary data={system} />
|
||||
<Performance data={system} />
|
||||
<Storage data={system} />
|
||||
<Container scrollable className="px-4 md:px-8 max-w-none py-8">
|
||||
<HStack className="items-start gap-8">
|
||||
<Box className="flex-1 md:max-w-lg">
|
||||
<Text className="text-2xl font-medium">Home Lab</Text>
|
||||
<Summary data={system} />
|
||||
<Apps className="md:hidden mt-6" />
|
||||
<Performance data={system} />
|
||||
<Storage data={system} />
|
||||
</Box>
|
||||
<Apps className="hidden md:flex md:flex-col md:flex-1" />
|
||||
</HStack>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,6 +60,7 @@ interface ButtonProps
|
||||
labelClasses?: string;
|
||||
className?: string;
|
||||
icon?: React.ReactNode;
|
||||
iconClassName?: string;
|
||||
children?: string;
|
||||
}
|
||||
function Button({
|
||||
@@ -69,12 +70,15 @@ function Button({
|
||||
variant,
|
||||
size,
|
||||
icon,
|
||||
iconClassName,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const textStyles = cn(
|
||||
buttonTextVariants({ variant, size, className: labelClasses })
|
||||
);
|
||||
const textStyles = buttonTextVariants({
|
||||
variant,
|
||||
size,
|
||||
className: labelClasses,
|
||||
});
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -86,10 +90,12 @@ function Button({
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{icon ? <Slot.View style={textStyles}>{icon}</Slot.View> : null}
|
||||
{icon ? (
|
||||
<Slot.View style={cn(textStyles, iconClassName)}>{icon}</Slot.View>
|
||||
) : null}
|
||||
|
||||
{label || children ? (
|
||||
<Text style={textStyles}>{label || children}</Text>
|
||||
<Text style={cn(textStyles)}>{label || children}</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createStore } from "zustand";
|
||||
import { Toast, ToastOptions } from "react-native-toast-notifications";
|
||||
|
||||
export const toastStore = createStore<typeof Toast | null>(() => null);
|
||||
|
||||
export const showToast = (
|
||||
message: string | JSX.Element,
|
||||
toastOptions?: ToastOptions
|
||||
) => {
|
||||
const toast = toastStore.getState();
|
||||
if (toast) {
|
||||
toast.show(message, toastOptions);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user