From fc923f3ab16384088ed51c43f2da6c880cafbc4e Mon Sep 17 00:00:00 2001 From: UUBulb <35923940+uubulb@users.noreply.github.com> Date: Mon, 18 Nov 2024 20:48:30 +0800 Subject: [PATCH] further implementing server page (#4) * further implementing server page * optimize icon button * rename some unnecessary file extensions * add terminal page & fm card --- package-lock.json | 63 +++++ package.json | 5 + src/api/fm.ts | 6 + src/api/server.ts | 10 + src/api/terminal.ts | 8 + src/components/action-button-group.tsx | 56 ++--- src/components/fm.tsx | 93 ++++++++ src/components/header-button-group.tsx | 60 ++--- src/components/install-commands.tsx | 38 +++ src/components/note-menu.tsx | 49 ++++ src/components/server.tsx | 222 ++++++++++++++++++ src/components/service.tsx | 6 +- src/components/terminal.tsx | 190 +++++++++++++++ src/components/ui/alert-dialog.tsx | 139 +++++++++++ src/components/ui/textarea.tsx | 22 ++ src/components/xui/icon-button.tsx | 42 ++++ src/components/xui/icon-buttons.tsx | 27 --- src/components/xui/overlayless-sheet.tsx | 121 ++++++++++ src/lib/utils.ts | 4 + src/main.tsx | 5 + src/routes/server.tsx | 202 +++++++++++----- src/routes/service.tsx | 24 +- src/types/{authContext.tsx => authContext.ts} | 0 src/types/{mainStore.tsx => mainStore.ts} | 0 vite.config.ts | 5 + 25 files changed, 1248 insertions(+), 149 deletions(-) create mode 100644 src/api/fm.ts create mode 100644 src/api/server.ts create mode 100644 src/api/terminal.ts create mode 100644 src/components/fm.tsx create mode 100644 src/components/install-commands.tsx create mode 100644 src/components/note-menu.tsx create mode 100644 src/components/server.tsx create mode 100644 src/components/terminal.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/textarea.tsx create mode 100644 src/components/xui/icon-button.tsx delete mode 100644 src/components/xui/icon-buttons.tsx create mode 100644 src/components/xui/overlayless-sheet.tsx rename src/types/{authContext.tsx => authContext.ts} (100%) rename src/types/{mainStore.tsx => mainStore.ts} (100%) diff --git a/package-lock.json b/package-lock.json index 058dc1b..7df9140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", @@ -19,6 +20,9 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-table": "^8.20.5", + "@xterm/addon-attach": "^0.11.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "jotai-zustand": "^0.6.0", @@ -28,6 +32,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.53.1", "react-router-dom": "^6.27.0", + "react-use-websocket": "^4.10.1", "sonner": "^1.6.1", "swr": "^2.2.5", "tailwind-merge": "^2.5.4", @@ -1118,6 +1123,34 @@ "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.2.tgz", + "integrity": "sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dialog": "1.1.2", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", @@ -2594,6 +2627,30 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@xterm/addon-attach": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-attach/-/addon-attach-0.11.0.tgz", + "integrity": "sha512-JboCN0QAY6ZLY/SSB/Zl2cQ5zW1Eh4X3fH7BnuR1NB7xGRhzbqU2Npmpiw/3zFlxDaU88vtKzok44JKi2L2V2Q==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -4977,6 +5034,12 @@ } } }, + "node_modules/react-use-websocket": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.10.1.tgz", + "integrity": "sha512-PrZbKj3BSy9kRU9otKEoMi0FOcEVh1abyYxJDzB/oL7kMBDBs+ZXhnWWed/sc679nPHAWMOn1gotoV04j5gJUw==", + "license": "MIT" + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index ed93608..747de86 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", @@ -21,6 +22,9 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-table": "^8.20.5", + "@xterm/addon-attach": "^0.11.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "jotai-zustand": "^0.6.0", @@ -30,6 +34,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.53.1", "react-router-dom": "^6.27.0", + "react-use-websocket": "^4.10.1", "sonner": "^1.6.1", "swr": "^2.2.5", "tailwind-merge": "^2.5.4", diff --git a/src/api/fm.ts b/src/api/fm.ts new file mode 100644 index 0000000..82c3d68 --- /dev/null +++ b/src/api/fm.ts @@ -0,0 +1,6 @@ +import { ModelCreateFMResponse } from "@/types"; +import { fetcher, FetcherMethod } from "./api" + +export const createFM = async (id: string): Promise => { + return fetcher(FetcherMethod.GET, `/api/v1/file?id=${id}`, null); +} diff --git a/src/api/server.ts b/src/api/server.ts new file mode 100644 index 0000000..3bc90ff --- /dev/null +++ b/src/api/server.ts @@ -0,0 +1,10 @@ +import { ModelServerForm } from "@/types" +import { fetcher, FetcherMethod } from "./api" + +export const updateServer = async (id: number, data: ModelServerForm): Promise => { + return fetcher(FetcherMethod.PATCH, `/api/v1/server/${id}`, data) +} + +export const deleteServer = async (id: number[]): Promise => { + return fetcher(FetcherMethod.POST, '/api/v1/batch-delete/server', id) +} diff --git a/src/api/terminal.ts b/src/api/terminal.ts new file mode 100644 index 0000000..0e21c05 --- /dev/null +++ b/src/api/terminal.ts @@ -0,0 +1,8 @@ +import { ModelTerminalForm, ModelCreateTerminalResponse } from "@/types"; +import { fetcher, FetcherMethod } from "./api" + +export const createTerminal = async (id: number): Promise => { + return fetcher(FetcherMethod.POST, '/api/v1/terminal', { + server_id: id, + }); +} diff --git a/src/components/action-button-group.tsx b/src/components/action-button-group.tsx index 208519a..b639879 100644 --- a/src/components/action-button-group.tsx +++ b/src/components/action-button-group.tsx @@ -1,16 +1,17 @@ -import { Button } from "@/components/ui/button"; -import { TrashButton } from "@/components/xui/icon-buttons"; +import { IconButton } from "@/components/xui/icon-button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogClose, -} from "@/components/ui/dialog" + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" import { KeyedMutator } from "swr"; +import { buttonVariants } from "@/components/ui/button" interface ButtonGroupProps { className?: string; @@ -27,24 +28,23 @@ export function ActionButtonGroup({ className, children, delete: { fn, id, mu return (
{children} - - - - - - - Confirm Deletion? - + + + + + + + Confirm Deletion? + This operation is unrecoverable! - - - - - - - - - + + + + Cancel + Confirm + + +
) } diff --git a/src/components/fm.tsx b/src/components/fm.tsx new file mode 100644 index 0000000..0288290 --- /dev/null +++ b/src/components/fm.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState, useRef } from "react" +import { + Sheet, + SheetTrigger, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from "./xui/overlayless-sheet" +import { IconButton } from "./xui/icon-button" +import { createFM } from "@/api/fm" +import { ModelCreateFMResponse } from "@/types" +import useWebSocket from "react-use-websocket" +import { toast } from "sonner" + +interface FMProps { + wsUrl: string; +} + +const FMComponent: React.FC = ({ wsUrl, ...props }) => { + const fmRef = useRef(null); + + const { sendMessage } = useWebSocket(wsUrl, { + share: false, + onOpen: () => { + listFile(); + }, + onClose: (e) => { + console.log('WebSocket connection closed:', e); + }, + onError: (e) => { + console.log(e); + toast("Websocket error", { + description: "View console for details.", + }) + }, + onMessage: async (e) => { + + } + }); + + const currentPath = useRef('').current; + + const listFile = () => { + const prefix = new Int8Array([0]); + const resizeMessage = new TextEncoder().encode(currentPath); + + const msg = new Int8Array(prefix.length + resizeMessage.length); + msg.set(prefix); + msg.set(resizeMessage, prefix.length); + + sendMessage(msg); + } + + return
; +} + +export const FMCard = ({ id }: { id?: string }) => { + const [open, setOpen] = useState(false); + const [fm, setFM] = useState(null); + + const fetchFM = async () => { + if (id && !fm) { + try { + const createdFM = await createFM(id); + setFM(createdFM); + } catch (e) { + toast("FM API Error", { + description: "View console for details.", + }) + console.log("fetch error", e); + return; + } + } + } + + return ( + { if (isOpen) setOpen(true); }}> + + + + + + Pseudo File Manager + + +
+ +
+
+
+ ) +} diff --git a/src/components/header-button-group.tsx b/src/components/header-button-group.tsx index 8534b47..4ecfc80 100644 --- a/src/components/header-button-group.tsx +++ b/src/components/header-button-group.tsx @@ -1,21 +1,22 @@ -import { Button } from "@/components/ui/button"; -import { TrashButton } from "@/components/xui/icon-buttons"; +import { buttonVariants } from "@/components/ui/button"; +import { IconButton } from "@/components/xui/icon-button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogClose, -} from "@/components/ui/dialog" + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" import { KeyedMutator } from "swr"; import { toast } from "sonner" interface ButtonGroupProps { className?: string; - children: React.ReactNode; + children?: React.ReactNode; delete: { fn: (id: number[]) => Promise, id: number[], mutate: KeyedMutator }; } @@ -29,7 +30,7 @@ export function HeaderButtonGroup({ className, children, delete: { fn, id, mu
{id.length < 1 ? ( <> - { + { toast("Error", { description: "No rows are selected." }); @@ -38,24 +39,23 @@ export function HeaderButtonGroup({ className, children, delete: { fn, id, mu ) : ( <> - - - - - - - Confirm Deletion? - + + + + + + + Confirm Deletion? + This operation is unrecoverable! - - - - - - - - - + + + + Cancel + Confirm + + + {children} )} diff --git a/src/components/install-commands.tsx b/src/components/install-commands.tsx new file mode 100644 index 0000000..d540cc2 --- /dev/null +++ b/src/components/install-commands.tsx @@ -0,0 +1,38 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { ButtonProps } from "@/components/ui/button" +import { forwardRef, useState } from "react" +import { IconButton } from "./xui/icon-button" + +export const InstallCommandsMenu = forwardRef((props, ref) => { + const [copy, setCopy] = useState(false); + + const switchState = async () => { + if (!copy) { + setCopy(true); + await navigator.clipboard.writeText("stub"); + setTimeout(() => { + setCopy(false); + }, 2 * 1000); + } + } + + return ( + + + + + + Linux + macOS + Windows + + + ); +}) diff --git a/src/components/note-menu.tsx b/src/components/note-menu.tsx new file mode 100644 index 0000000..46f508d --- /dev/null +++ b/src/components/note-menu.tsx @@ -0,0 +1,49 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { ButtonProps } from "@/components/ui/button" +import { forwardRef, useState } from "react" +import { IconButton } from "./xui/icon-button" +import { toast } from "sonner"; + +interface NoteMenuProps extends ButtonProps { + note: { private?: string, public?: string }; +} + +export const NoteMenu = forwardRef((props, ref) => { + const [copy, setCopy] = useState(false); + + const switchState = async (text?: string) => { + if (!text) { + toast("Warning", { + description: "You didn't have any note." + }) + return; + } + + if (!copy) { + setCopy(true); + await navigator.clipboard.writeText(text); + setTimeout(() => { + setCopy(false); + }, 2 * 1000); + } + } + + return ( + + + + + + { switchState(props.note.private) }}>Private + { switchState(props.note.public) }}>Public + + + ); +}) diff --git a/src/components/server.tsx b/src/components/server.tsx new file mode 100644 index 0000000..60060a1 --- /dev/null +++ b/src/components/server.tsx @@ -0,0 +1,222 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { ScrollArea } from "@/components/ui/scroll-area" +import { useForm } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { ModelServer } from "@/types" +import { updateServer } from "@/api/server" +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label" +import { conv } from "@/lib/utils" +import { useState } from "react" +import { KeyedMutator } from "swr" +import { asOptionalField } from "@/lib/utils" +import { IconButton } from "@/components/xui/icon-button" +import { Textarea } from "@/components/ui/textarea" + +interface ServerCardProps { + data: ModelServer; + mutate: KeyedMutator; +} + +const serverFormSchema = z.object({ + name: z.string().min(1), + note: asOptionalField(z.string()), + public_note: asOptionalField(z.string()), + display_index: z.number(), + hide_for_guest: asOptionalField(z.boolean()), + enable_ddns: asOptionalField(z.boolean()), + ddns_profiles: z.array(z.string()).transform((v => { + return v.filter(Boolean).map(Number); + })), +}); + +export const ServerCard: React.FC = ({ data, mutate }) => { + const form = useForm>({ + resolver: zodResolver(serverFormSchema), + defaultValues: data, + resetOptions: { + keepDefaultValues: false, + } + }) + + const [open, setOpen] = useState(false); + + const onSubmit = async (values: z.infer) => { + await updateServer(data.id, values); + setOpen(false); + await mutate(); + form.reset(); + } + + return ( + + + + + + +
+ + New Service + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Display Index + + + + + + )} + /> + ( + + DDNS Profile IDs (Separate with comma) + + { + const arr = conv.strToArr(e.target.value); + field.onChange(arr); + }} + /> + + + + )} + /> + ( + + +
+ + +
+
+ +
+ )} + /> + ( + + +
+ + +
+
+ +
+ )} + /> + ( + + Note + +