diff --git a/package-lock.json b/package-lock.json
index f126cf5..67e4174 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
- "@tanstack/react-form": "^1.15.0",
+ "@tanstack/react-form": "^1.23.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-router": "^1.121.2",
"@tanstack/react-router-devtools": "^1.121.2",
@@ -31,6 +31,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
+ "zod": "^3.25.76",
"zustand": "^5.0.6"
},
"devDependencies": {
@@ -2385,12 +2386,26 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
+ "node_modules/@tanstack/devtools-event-client": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.2.5.tgz",
+ "integrity": "sha512-iVdqw879KETXyyPHc3gQR5Ld0GjlPLk7bKenBUhzr3+z1FiQZvsbfgYfRRokTSPcgwANAV7aA2Uv05nx5xWT8A==",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@tanstack/form-core": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.15.0.tgz",
- "integrity": "sha512-zMNyxb/J/JnFmW4Gzb1TSxaXmwNhvsaF9p3dGRpE93TMGp2ojPKK7V5LZ43ZV7iFTYWTL8NOIU8ZXuf9qZVkmw==",
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.23.0.tgz",
+ "integrity": "sha512-aBJnO5iA1TtMK/SSpAvd/y6cVsBgbu5Br2FftvW6r7TkHCcZ/lB2DMx3G2dk6SG7sW0g8s6F7iaMjrvqBGooDw==",
"dependencies": {
- "@tanstack/store": "^0.7.2"
+ "@tanstack/devtools-event-client": "^0.2.5",
+ "@tanstack/store": "^0.7.5",
+ "uuid": "^13.0.0"
},
"funding": {
"type": "github",
@@ -2419,30 +2434,26 @@
}
},
"node_modules/@tanstack/react-form": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.15.0.tgz",
- "integrity": "sha512-bAawFDxR1wLn+eXli6MSyS4Nw0vTyHuW3CybjZGtk7NIcYxyaAm9cW/jPUX2j/KMDtjVNg5RMpfmufxDrsNHyA==",
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.23.0.tgz",
+ "integrity": "sha512-wZ/cBjqj5dgWG79TQOsilbs8WzdouJj3iXw1DAZvQ4nejobsTGl0huU7SZZ0mnFtdNHbre1bFSdWyw+GqFcIhQ==",
"dependencies": {
- "@tanstack/form-core": "1.15.0",
- "@tanstack/react-store": "^0.7.3",
+ "@tanstack/form-core": "1.23.0",
+ "@tanstack/react-store": "^0.7.5",
"decode-formdata": "^0.9.0",
- "devalue": "^5.1.1"
+ "devalue": "^5.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
- "@tanstack/react-start": "^1.112.0",
- "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
- "vinxi": "^0.5.0"
+ "@tanstack/react-start": "^1.130.10",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@tanstack/react-start": {
"optional": true
- },
- "vinxi": {
- "optional": true
}
}
},
@@ -2506,11 +2517,11 @@
}
},
"node_modules/@tanstack/react-store": {
- "version": "0.7.3",
- "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.3.tgz",
- "integrity": "sha512-3Dnqtbw9P2P0gw8uUM8WP2fFfg8XMDSZCTsywRPZe/XqqYW8PGkXKZTvP0AHkE4mpqP9Y43GpOg9vwO44azu6Q==",
+ "version": "0.7.7",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz",
+ "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==",
"dependencies": {
- "@tanstack/store": "0.7.2",
+ "@tanstack/store": "0.7.7",
"use-sync-external-store": "^1.5.0"
},
"funding": {
@@ -2685,9 +2696,9 @@
}
},
"node_modules/@tanstack/store": {
- "version": "0.7.2",
- "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.2.tgz",
- "integrity": "sha512-RP80Z30BYiPX2Pyo0Nyw4s1SJFH2jyM6f9i3HfX4pA+gm5jsnYryscdq2aIQLnL4TaGuQMO+zXmN9nh1Qck+Pg==",
+ "version": "0.7.7",
+ "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.7.tgz",
+ "integrity": "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
@@ -3215,9 +3226,9 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
- "version": "1.11.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
- "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
+ "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
@@ -3897,9 +3908,9 @@
"license": "MIT"
},
"node_modules/devalue": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz",
- "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw=="
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
+ "integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw=="
},
"node_modules/diff": {
"version": "8.0.2",
@@ -7232,6 +7243,18 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
+ "node_modules/uuid": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -7241,10 +7264,9 @@
}
},
"node_modules/vite": {
- "version": "6.3.5",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
- "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
- "license": "MIT",
+ "version": "6.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
+ "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
diff --git a/package.json b/package.json
index d13ff78..f3d9afd 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
- "@tanstack/react-form": "^1.15.0",
+ "@tanstack/react-form": "^1.23.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-router": "^1.121.2",
"@tanstack/react-router-devtools": "^1.121.2",
@@ -35,6 +35,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
+ "zod": "^3.25.76",
"zustand": "^5.0.6"
},
"devDependencies": {
diff --git a/src/components/command-form.tsx b/src/components/command-form.tsx
new file mode 100644
index 0000000..315620b
--- /dev/null
+++ b/src/components/command-form.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "@tanstack/react-form";
+import { z } from "zod";
+
+import { Button } from "@/components/ui/button";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Loader2, Terminal, AlertTriangle, CheckCircle } from "lucide-react";
+
+interface ShellCommandFormProps {
+ onExecute: (command: string) => Promise<{ success: boolean; output: string }>;
+}
+
+export function ShellCommandForm({ onExecute }: ShellCommandFormProps) {
+ const [isLoading, setIsLoading] = useState(false);
+
+ // init form
+ const form = useForm({
+ defaultValues: {
+ command: "",
+ },
+ onSubmit: async ({ value }) => {
+ setIsLoading(true);
+ try {
+ const res = await onExecute(value.command);
+ if (res.success) {
+ form.reset();
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ });
+
+ return (
+
+
+ {/* Form */}
+
(
+
+ )}
+ />
+
+
+
+
+ );
+}
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
new file mode 100644
index 0000000..1421354
--- /dev/null
+++ b/src/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..0205413
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span"
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/upload-dialog.tsx b/src/components/upload-dialog.tsx
index 579e21f..624a70d 100644
--- a/src/components/upload-dialog.tsx
+++ b/src/components/upload-dialog.tsx
@@ -1,36 +1,36 @@
+import { useState } from "react";
import {
- Dialog, DialogContent, DialogDescription, DialogFooter,
- DialogHeader, DialogTitle, DialogTrigger,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
-import { Plus } from "lucide-react";
-import { formOptions, useForm } from "@tanstack/react-form";
-import { useState } from "react";
+import { useForm, formOptions } from "@tanstack/react-form";
import { toast } from "sonner";
+import type { AxiosProgressEvent } from "axios";
interface UploadDialogProps {
- onSubmit: (formData: FormData) => Promise;
- accept?: string;
+ onSubmit: (
+ fd: FormData,
+ config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
+ ) => Promise;
}
-interface UploadFormProps {
- files: FileList;
- newVersion: string;
-}
+const formOpts = formOptions({
+ defaultValues: { files: new DataTransfer().files, newVersion: "" },
+});
-const defaultInput: UploadFormProps = {
- files: new DataTransfer().files,
- newVersion: "",
-};
-
-const formOpts = formOptions({ defaultValues: defaultInput });
-
-export function UploadDialog({ onSubmit, accept = ".exe,.msi,.apk" }: UploadDialogProps) {
+export function UploadDialog({ onSubmit }: UploadDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
+ const [isUploading, setIsUploading] = useState(false);
+ const [isDone, setIsDone] = useState(false);
const form = useForm({
...formOpts,
@@ -40,39 +40,71 @@ export function UploadDialog({ onSubmit, accept = ".exe,.msi,.apk" }: UploadDial
return;
}
- const fd = new FormData();
- Array.from(value.files).forEach((file) => fd.append("files", file));
- fd.append("newVersion", value.newVersion);
+ try {
+ setIsUploading(true);
+ setUploadPercent(0);
+ setIsDone(false);
- setUploadPercent(0);
- await onSubmit(fd);
- setIsOpen(false);
- form.reset();
+ const fd = new FormData();
+ Array.from(value.files).forEach((f) => fd.append("FileInput", f));
+ fd.append("Version", value.newVersion);
+
+ await onSubmit(fd, {
+ onUploadProgress: (e: AxiosProgressEvent) => {
+ if (e.total) {
+ const progress = Math.round((e.loaded * 100) / e.total);
+ setUploadPercent(progress);
+ }
+ },
+ });
+
+ setIsDone(true);
+ } catch (error) {
+ console.error("Upload error:", error);
+ toast.error("Upload thất bại!");
+ } finally {
+ setIsUploading(false);
+ }
},
});
+ const handleDialogClose = (open: boolean) => {
+ if (isUploading) return;
+ setIsOpen(open);
+ if (!open) {
+ setUploadPercent(0);
+ setIsDone(false);
+ form.reset();
+ }
+ };
+
return (
-