aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
author苏向夜 <fu050409@163.com>2026-02-24 22:41:36 +0800
committer苏向夜 <fu050409@163.com>2026-02-24 22:41:36 +0800
commitb275a3668b140d9ce4663de646519d2dbd4297e7 (patch)
tree13799773d7d6ef5ac566bbfc4e28ed81798b965b
parent888f57b6f2ef3b81ba61f4009799f046739ba4dd (diff)
downloadDropOut-b275a3668b140d9ce4663de646519d2dbd4297e7.tar.gz
DropOut-b275a3668b140d9ce4663de646519d2dbd4297e7.zip
refactor: rewrite login and settings pages
-rw-r--r--package.json2
-rw-r--r--packages/ui-new/package.json2
-rw-r--r--packages/ui-new/src/components/bottom-bar.tsx157
-rw-r--r--packages/ui-new/src/components/config-editor.tsx111
-rw-r--r--packages/ui-new/src/components/login-modal.tsx292
-rw-r--r--packages/ui-new/src/components/sidebar.tsx117
-rw-r--r--packages/ui-new/src/components/ui/avatar.tsx107
-rw-r--r--packages/ui-new/src/components/ui/dropdown-menu.tsx269
-rw-r--r--packages/ui-new/src/components/ui/field.tsx238
-rw-r--r--packages/ui-new/src/components/ui/spinner.tsx10
-rw-r--r--packages/ui-new/src/components/ui/tabs.tsx4
-rw-r--r--packages/ui-new/src/components/user-avatar.tsx23
-rw-r--r--packages/ui-new/src/main.tsx11
-rw-r--r--packages/ui-new/src/models/auth.ts142
-rw-r--r--packages/ui-new/src/models/settings.ts75
-rw-r--r--packages/ui-new/src/pages/home-view.tsx212
-rw-r--r--packages/ui-new/src/pages/index-old.tsx187
-rw-r--r--packages/ui-new/src/pages/index.tsx173
-rw-r--r--packages/ui-new/src/pages/settings.tsx310
-rw-r--r--packages/ui-new/src/types/bindings/auth.ts6
-rw-r--r--packages/ui-new/src/types/bindings/java/core.ts22
-rw-r--r--pnpm-lock.yaml49
-rw-r--r--src-tauri/src/core/auth.rs6
-rw-r--r--src-tauri/src/core/java/mod.rs2
24 files changed, 1845 insertions, 682 deletions
diff --git a/package.json b/package.json
index c4d5afc..b52ef7d 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"modpack"
],
"license": "MIT",
- "packageManager": "pnpm@10.27.0",
+ "packageManager": "pnpm@10.30.1",
"dependencies": {
"consola": "^3.4.2",
"toml": "^3.0.0"
diff --git a/packages/ui-new/package.json b/packages/ui-new/package.json
index fcd6aed..b26d733 100644
--- a/packages/ui-new/package.json
+++ b/packages/ui-new/package.json
@@ -26,6 +26,7 @@
"@tauri-apps/plugin-shell": "^2.3.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "es-toolkit": "^1.44.0",
"lucide-react": "^0.562.0",
"marked": "^17.0.1",
"next-themes": "^0.4.6",
@@ -35,6 +36,7 @@
"react-router": "^7.12.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.1",
+ "zod": "^4.3.6",
"zustand": "^5.0.10"
},
"devDependencies": {
diff --git a/packages/ui-new/src/components/bottom-bar.tsx b/packages/ui-new/src/components/bottom-bar.tsx
index a0c2c00..2653880 100644
--- a/packages/ui-new/src/components/bottom-bar.tsx
+++ b/packages/ui-new/src/components/bottom-bar.tsx
@@ -1,11 +1,13 @@
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
-import { Check, ChevronDown, Play, Terminal, User } from "lucide-react";
+import { Check, ChevronDown, Play, User } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
-import { useAuthStore } from "@/stores/auth-store";
+import { cn } from "@/lib/utils";
+import { useAuthStore } from "@/models/auth";
import { useGameStore } from "@/stores/game-store";
import { useInstancesStore } from "@/stores/instances-store";
-import { useUIStore } from "@/stores/ui-store";
+import { LoginModal } from "./login-modal";
+import { Button } from "./ui/button";
interface InstalledVersion {
id: string;
@@ -16,15 +18,13 @@ export function BottomBar() {
const authStore = useAuthStore();
const gameStore = useGameStore();
const instancesStore = useInstancesStore();
- const uiStore = useUIStore();
const [isVersionDropdownOpen, setIsVersionDropdownOpen] = useState(false);
const [installedVersions, setInstalledVersions] = useState<
InstalledVersion[]
>([]);
const [isLoadingVersions, setIsLoadingVersions] = useState(true);
-
- const dropdownRef = useRef<HTMLDivElement>(null);
+ const [showLoginModal, setShowLoginModal] = useState(false);
const loadInstalledVersions = useCallback(async () => {
if (!instancesStore.activeInstanceId) {
@@ -61,17 +61,6 @@ export function BottomBar() {
useEffect(() => {
loadInstalledVersions();
- const handleClickOutside = (event: MouseEvent) => {
- if (
- dropdownRef.current &&
- !dropdownRef.current.contains(event.target as Node)
- ) {
- setIsVersionDropdownOpen(false);
- }
- };
-
- document.addEventListener("mousedown", handleClickOutside);
-
// Listen for backend events that should refresh installed versions.
let unlistenDownload: UnlistenFn | null = null;
let unlistenVersionDeleted: UnlistenFn | null = null;
@@ -98,7 +87,6 @@ export function BottomBar() {
})();
return () => {
- document.removeEventListener("mousedown", handleClickOutside);
try {
if (unlistenDownload) unlistenDownload();
} catch {
@@ -120,12 +108,12 @@ export function BottomBar() {
};
const handleStartGame = async () => {
- await gameStore.startGame(
- authStore.currentAccount,
- authStore.openLoginModal,
- instancesStore.activeInstanceId,
- uiStore.setView,
- );
+ // await gameStore.startGame(
+ // authStore.currentAccount,
+ // authStore.openLoginModal,
+ // instancesStore.activeInstanceId,
+ // uiStore.setView,
+ // );
};
const getVersionTypeColor = (type: string) => {
@@ -155,8 +143,7 @@ export function BottomBar() {
return (
<div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/30 via-transparent to-transparent p-4 z-10">
<div className="max-w-7xl mx-auto">
- <div className="flex items-center justify-between bg-white/5 dark:bg-black/20 backdrop-blur-xl rounded-xl border border-white/10 dark:border-white/5 p-3 shadow-lg">
- {/* Left: Instance Info */}
+ <div className="flex items-center justify-between bg-white/5 dark:bg-black/20 backdrop-blur-xl border border-white/10 dark:border-white/5 p-3 shadow-lg">
<div className="flex items-center gap-4">
<div className="flex flex-col">
<span className="text-xs font-mono text-zinc-400 uppercase tracking-wider">
@@ -166,104 +153,38 @@ export function BottomBar() {
{instancesStore.activeInstance?.name || "No instance selected"}
</span>
</div>
-
- {/* Version Selector */}
- <div className="relative" ref={dropdownRef}>
- <button
- type="button"
- onClick={() => setIsVersionDropdownOpen(!isVersionDropdownOpen)}
- className="flex items-center gap-2 px-4 py-2 bg-black/20 dark:bg-white/5 hover:bg-black/30 dark:hover:bg-white/10 rounded-lg border border-white/10 transition-colors"
- >
- <span className="text-sm text-white">
- {gameStore.selectedVersion || "Select Version"}
- </span>
- <ChevronDown
- size={16}
- className={`text-zinc-400 transition-transform ${
- isVersionDropdownOpen ? "rotate-180" : ""
- }`}
- />
- </button>
-
- {/* Dropdown */}
- {isVersionDropdownOpen && (
- <div className="absolute bottom-full mb-2 w-64 bg-zinc-900 border border-zinc-700 rounded-lg shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-bottom-2">
- <div className="p-2">
- {versionOptions.map((option) => (
- <button
- type="button"
- key={option.id}
- onClick={() => selectVersion(option.id)}
- disabled={
- option.id === "loading" || option.id === "empty"
- }
- className={`flex items-center justify-between w-full px-3 py-2 text-left rounded-md transition-colors ${
- gameStore.selectedVersion === option.id
- ? "bg-indigo-500/20 text-indigo-300"
- : "hover:bg-white/5 text-zinc-300"
- } ${
- option.id === "loading" || option.id === "empty"
- ? "opacity-50 cursor-not-allowed"
- : ""
- }`}
- >
- <div className="flex items-center gap-2">
- <div
- className={`w-2 h-2 rounded-full ${getVersionTypeColor(
- option.type,
- )}`}
- ></div>
- <span className="text-sm font-medium">
- {option.label}
- </span>
- </div>
- {gameStore.selectedVersion === option.id && (
- <Check size={14} className="text-indigo-400" />
- )}
- </button>
- ))}
- </div>
- </div>
- )}
- </div>
</div>
- {/* Right: Action Buttons */}
<div className="flex items-center gap-3">
- {/* Console Toggle */}
- <button
- type="button"
- onClick={() => uiStore.toggleConsole()}
- className="flex items-center gap-2 px-3 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 hover:text-white rounded-lg transition-colors"
- >
- <Terminal size={16} />
- <span className="text-sm font-medium">Console</span>
- </button>
-
- {/* User Login/Info */}
- <button
- type="button"
- onClick={() => authStore.openLoginModal()}
- className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg transition-colors"
- >
- <User size={16} />
- <span className="text-sm font-medium">
- {authStore.currentAccount?.username || "Login"}
- </span>
- </button>
-
- {/* Start Game */}
- <button
- type="button"
- onClick={handleStartGame}
- className="flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg transition-colors shadow-lg shadow-emerald-500/20"
- >
- <Play size={16} />
- <span className="text-sm font-medium">Start</span>
- </button>
+ {authStore.account ? (
+ <Button
+ className={cn(
+ "px-4 py-2 shadow-xl",
+ "bg-emerald-600! hover:bg-emerald-500!",
+ )}
+ size="lg"
+ onClick={handleStartGame}
+ >
+ <Play />
+ Start
+ </Button>
+ ) : (
+ <Button
+ className="px-4 py-2"
+ size="lg"
+ onClick={() => setShowLoginModal(true)}
+ >
+ <User /> Login
+ </Button>
+ )}
</div>
</div>
</div>
+
+ <LoginModal
+ open={showLoginModal}
+ onOpenChange={() => setShowLoginModal(false)}
+ />
</div>
);
}
diff --git a/packages/ui-new/src/components/config-editor.tsx b/packages/ui-new/src/components/config-editor.tsx
new file mode 100644
index 0000000..129b8f7
--- /dev/null
+++ b/packages/ui-new/src/components/config-editor.tsx
@@ -0,0 +1,111 @@
+import type React from "react";
+import { useEffect, useState } from "react";
+import { type ZodType, z } from "zod";
+import { useSettingsStore } from "@/models/settings";
+import type { LauncherConfig } from "@/types";
+import { Button } from "./ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "./ui/dialog";
+import { FieldError } from "./ui/field";
+import { Spinner } from "./ui/spinner";
+import { Textarea } from "./ui/textarea";
+
+const launcherConfigSchema: ZodType<LauncherConfig> = z.object({
+ minMemory: z.number(),
+ maxMemory: z.number(),
+ javaPath: z.string(),
+ width: z.number(),
+ height: z.number(),
+ downloadThreads: z.number(),
+ customBackgroundPath: z.string().nullable(),
+ enableGpuAcceleration: z.boolean(),
+ enableVisualEffects: z.boolean(),
+ activeEffect: z.string(),
+ theme: z.string(),
+ logUploadService: z.string(),
+ pastebinApiKey: z.string().nullable(),
+ assistant: z.any(), // TODO: AssistantConfig schema
+ useSharedCaches: z.boolean(),
+ keepLegacyPerInstanceStorage: z.boolean(),
+ featureFlags: z.any(), // TODO: FeatureFlags schema
+});
+
+export interface ConfigEditorProps
+ extends Omit<React.ComponentPropsWithoutRef<typeof Dialog>, "onOpenChange"> {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function ConfigEditor({ onOpenChange, ...props }: ConfigEditorProps) {
+ const settings = useSettingsStore();
+
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
+ const [rawConfigContent, setRawConfigContent] = useState(
+ JSON.stringify(settings.config, null, 2),
+ );
+ const [isSaving, setIsSaving] = useState(false);
+
+ useEffect(() => {
+ setRawConfigContent(JSON.stringify(settings.config, null, 2));
+ }, [settings.config]);
+
+ const handleSave = async () => {
+ setIsSaving(true);
+ setErrorMessage(null);
+ try {
+ const validatedConfig = launcherConfigSchema.parse(
+ JSON.parse(rawConfigContent),
+ );
+ settings.config = validatedConfig;
+ await settings.save();
+ onOpenChange?.(false);
+ } catch (error) {
+ setErrorMessage(error instanceof Error ? error.message : String(error));
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+ <Dialog onOpenChange={onOpenChange} {...props}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Edit Configuration</DialogTitle>
+ <DialogDescription>
+ Edit the raw JSON configuration file.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Textarea
+ value={rawConfigContent}
+ onChange={(e) => setRawConfigContent(e.target.value)}
+ className="font-mono text-sm h-100 resize-none"
+ spellCheck={false}
+ aria-invalid={!!errorMessage}
+ />
+
+ {errorMessage && <FieldError errors={[{ message: errorMessage }]} />}
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange?.(false)}
+ disabled={isSaving}
+ >
+ Cancel
+ </Button>
+ <Button onClick={handleSave} disabled={isSaving}>
+ {isSaving && <Spinner />}
+ Save Changes
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/packages/ui-new/src/components/login-modal.tsx b/packages/ui-new/src/components/login-modal.tsx
index 9152494..49596da 100644
--- a/packages/ui-new/src/components/login-modal.tsx
+++ b/packages/ui-new/src/components/login-modal.tsx
@@ -1,156 +1,188 @@
import { Mail, User } from "lucide-react";
-import { useAuthStore } from "@/stores/auth-store";
+import { useCallback, useState } from "react";
+import { toast } from "sonner";
+import { useAuthStore } from "@/models/auth";
+import { Button } from "./ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "./ui/dialog";
+import {
+ Field,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+} from "./ui/field";
+import { Input } from "./ui/input";
-export function LoginModal() {
+export interface LoginModalProps
+ extends Omit<React.ComponentPropsWithoutRef<typeof Dialog>, "onOpenChange"> {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function LoginModal({ onOpenChange, ...props }: LoginModalProps) {
const authStore = useAuthStore();
- const handleOfflineLogin = () => {
- if (authStore.offlineUsername.trim()) {
- authStore.performOfflineLogin();
- }
- };
+ const [offlineUsername, setOfflineUsername] = useState<string>("");
+ const [errorMessage, setErrorMessage] = useState<string>("");
+ const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
- const handleKeyPress = (e: React.KeyboardEvent) => {
- if (e.key === "Enter") {
- handleOfflineLogin();
+ const handleMicrosoftLogin = useCallback(async () => {
+ setIsLoggingIn(true);
+ authStore.setLoginMode("microsoft");
+ try {
+ await authStore.loginOnline(() => onOpenChange?.(false));
+ } catch (error) {
+ const err = error as Error;
+ console.error("Failed to login with Microsoft:", err);
+ setErrorMessage(err.message);
+ } finally {
+ setIsLoggingIn(false);
}
- };
+ }, [authStore.loginOnline, authStore.setLoginMode, onOpenChange]);
- if (!authStore.isLoginModalOpen) return null;
+ const handleOfflineLogin = useCallback(async () => {
+ setIsLoggingIn(true);
+ try {
+ await authStore.loginOffline(offlineUsername);
+ toast.success("Logged in offline successfully");
+ onOpenChange?.(false);
+ } catch (error) {
+ const err = error as Error;
+ console.error("Failed to login offline:", err);
+ setErrorMessage(err.message);
+ } finally {
+ setIsLoggingIn(false);
+ }
+ }, [authStore, offlineUsername, onOpenChange]);
return (
- <div className="fixed inset-0 z-200 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
- <div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-md w-full animate-in fade-in zoom-in-95 duration-200">
- <div className="p-6">
- {/* Header */}
- <div className="flex items-center justify-between mb-6">
- <h3 className="text-xl font-bold text-white">Login</h3>
- <button
- type="button"
- onClick={() => {
- authStore.setLoginMode("select");
- authStore.setOfflineUsername("");
- authStore.cancelMicrosoftLogin();
- }}
- className="text-zinc-400 hover:text-white transition-colors p-1"
- >
- ×
- </button>
- </div>
-
- {/* Content based on mode */}
- {authStore.loginMode === "select" && (
- <div className="space-y-4">
- <p className="text-zinc-400 text-sm">
- Choose your preferred login method
- </p>
- <button
- type="button"
- onClick={() => authStore.startMicrosoftLogin()}
- className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors"
+ <Dialog onOpenChange={onOpenChange} {...props}>
+ <DialogContent className="md:max-w-md">
+ <DialogHeader>
+ <DialogTitle>Login</DialogTitle>
+ <DialogDescription>
+ Login to your Minecraft account or play offline
+ </DialogDescription>
+ </DialogHeader>
+ <div className="p-4 w-full overflow-hidden">
+ {!authStore.loginMode && (
+ <div className="flex flex-col space-y-4">
+ <Button size="lg" onClick={handleMicrosoftLogin}>
+ <Mail />
+ Login with Microsoft
+ </Button>
+ <Button
+ variant="secondary"
+ onClick={() => authStore.setLoginMode("offline")}
+ size="lg"
>
- <Mail size={18} />
- <span className="font-medium">Microsoft Account</span>
- </button>
+ <User />
+ Login Offline
+ </Button>
+ </div>
+ )}
+ {authStore.loginMode === "microsoft" && (
+ <div className="flex flex-col space-y-4">
<button
type="button"
+ className="text-4xl font-bold text-center bg-accent p-4 cursor-pointer"
onClick={() => {
- authStore.loginMode = "offline";
+ if (authStore.deviceCode?.userCode) {
+ navigator.clipboard?.writeText(
+ authStore.deviceCode?.userCode,
+ );
+ toast.success("Copied to clipboard");
+ }
}}
- className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg transition-colors"
>
- <User size={18} />
- <span className="font-medium">Offline Mode</span>
+ {authStore.deviceCode?.userCode}
</button>
- </div>
- )}
-
- {authStore.loginMode === "offline" && (
- <div className="space-y-4">
- <div>
- <label
- htmlFor="username"
- className="block text-sm font-medium text-zinc-300 mb-2"
- >
- Username
- </label>
- <input
- name="username"
- type="text"
- value={authStore.offlineUsername}
- onChange={(e) => authStore.setOfflineUsername(e.target.value)}
- onKeyDown={handleKeyPress}
- className="w-full px-4 py-2.5 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors"
- placeholder="Enter your Minecraft username"
- />
- </div>
- <div className="flex gap-3">
- <button
- type="button"
+ <span className="text-muted-foreground w-full overflow-hidden text-ellipsis">
+ To sign in, use a web browser to open the page{" "}
+ <a href={authStore.deviceCode?.verificationUri}>
+ {authStore.deviceCode?.verificationUri}
+ </a>{" "}
+ and enter the code{" "}
+ <code
+ className="font-semibold cursor-pointer"
onClick={() => {
- authStore.loginMode = "select";
- authStore.setOfflineUsername("");
+ if (authStore.deviceCode?.userCode) {
+ navigator.clipboard?.writeText(
+ authStore.deviceCode?.userCode,
+ );
+ }
+ }}
+ onKeyDown={() => {
+ if (authStore.deviceCode?.userCode) {
+ navigator.clipboard?.writeText(
+ authStore.deviceCode?.userCode,
+ );
+ }
}}
- className="flex-1 px-4 py-2.5 text-sm font-medium text-zinc-300 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
- >
- Back
- </button>
- <button
- type="button"
- onClick={handleOfflineLogin}
- disabled={!authStore.offlineUsername.trim()}
- className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 disabled:cursor-not-allowed rounded-lg transition-colors"
>
- Login
- </button>
- </div>
+ {authStore.deviceCode?.userCode}
+ </code>{" "}
+ to authenticate, this code will be expired in{" "}
+ {authStore.deviceCode?.expiresIn} seconds.
+ </span>
+ <FieldError>{errorMessage}</FieldError>
</div>
)}
-
- {authStore.loginMode === "microsoft" && (
- <div className="space-y-4">
- {authStore.deviceCodeData && (
- <div className="bg-zinc-800/50 border border-zinc-700 rounded-lg p-4">
- <div className="text-center mb-4">
- <div className="text-xs font-mono bg-zinc-900 px-3 py-2 rounded border border-zinc-700 mb-3">
- {authStore.deviceCodeData.userCode}
- </div>
- <p className="text-zinc-300 text-sm font-medium">
- Your verification code
- </p>
- </div>
- <p className="text-zinc-400 text-sm text-center">
- Visit{" "}
- <a
- href={authStore.deviceCodeData.verificationUri}
- target="_blank"
- className="text-indigo-400 hover:text-indigo-300 font-medium"
- >
- {authStore.deviceCodeData.verificationUri}
- </a>{" "}
- and enter the code above
- </p>
- </div>
- )}
- <div className="text-center">
- <p className="text-zinc-300 text-sm mb-2">
- {authStore.msLoginStatus}
- </p>
- <button
- type="button"
- onClick={() => {
- authStore.cancelMicrosoftLogin();
- authStore.setLoginMode("select");
+ {authStore.loginMode === "offline" && (
+ <FieldGroup>
+ <Field>
+ <FieldLabel>Username</FieldLabel>
+ <FieldDescription>
+ Enter a username to play offline
+ </FieldDescription>
+ <Input
+ value={offlineUsername}
+ onChange={(e) => {
+ setOfflineUsername(e.target.value);
+ setErrorMessage("");
}}
- className="text-sm text-zinc-400 hover:text-white transition-colors"
- >
- Cancel
- </button>
- </div>
- </div>
+ aria-invalid={!!errorMessage}
+ />
+ <FieldError>{errorMessage}</FieldError>
+ </Field>
+ </FieldGroup>
)}
</div>
- </div>
- </div>
+ <DialogFooter>
+ <div className="flex flex-col justify-center items-center">
+ <span className="text-xs text-muted-foreground ">
+ {authStore.statusMessage}
+ </span>
+ </div>
+ <Button
+ variant="outline"
+ onClick={() => {
+ if (authStore.loginMode) {
+ if (authStore.loginMode === "microsoft") {
+ authStore.cancelLoginOnline();
+ }
+ authStore.setLoginMode(null);
+ } else {
+ onOpenChange?.(false);
+ }
+ }}
+ >
+ Cancel
+ </Button>
+ {authStore.loginMode === "offline" && (
+ <Button onClick={handleOfflineLogin} disabled={isLoggingIn}>
+ Login
+ </Button>
+ )}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
);
}
diff --git a/packages/ui-new/src/components/sidebar.tsx b/packages/ui-new/src/components/sidebar.tsx
index a8c899b..0147b0a 100644
--- a/packages/ui-new/src/components/sidebar.tsx
+++ b/packages/ui-new/src/components/sidebar.tsx
@@ -1,51 +1,62 @@
-import { Bot, Folder, Home, Package, Settings } from "lucide-react";
-import { Link, useLocation } from "react-router";
-import { useUIStore, type ViewType } from "../stores/ui-store";
+import { Folder, Home, LogOutIcon, Settings } from "lucide-react";
+import { useLocation, useNavigate } from "react-router";
+import { cn } from "@/lib/utils";
+import { useAuthStore } from "@/models/auth";
+import { Button } from "./ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "./ui/dropdown-menu";
+import { UserAvatar } from "./user-avatar";
interface NavItemProps {
- view: string;
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
label: string;
to: string;
}
-function NavItem({ view, Icon, label, to }: NavItemProps) {
- const uiStore = useUIStore();
+function NavItem({ Icon, label, to }: NavItemProps) {
+ const navigate = useNavigate();
const location = useLocation();
- const isActive = location.pathname === to || uiStore.currentView === view;
+ const isActive = location.pathname === to;
const handleClick = () => {
- uiStore.setView(view as ViewType);
+ navigate(to);
};
return (
- <Link to={to}>
- <button
- type="button"
- className={`group flex items-center lg:gap-3 justify-center lg:justify-start w-full px-0 lg:px-4 py-2.5 rounded-sm transition-all duration-200 relative ${
- isActive
- ? "bg-black/5 dark:bg-white/10 dark:text-white text-black font-medium"
- : "dark:text-zinc-400 text-zinc-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5"
- }`}
- onClick={handleClick}
- >
- <Icon className="size-5" strokeWidth={isActive ? 2.5 : 2} />
- <span className="hidden lg:block text-sm relative z-10">{label}</span>
-
- {/* Active Indicator */}
- {isActive && (
- <div className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-black dark:bg-white rounded-r-full hidden lg:block"></div>
- )}
- </button>
- </Link>
+ <Button
+ variant="ghost"
+ className={cn(
+ "w-fit lg:w-full justify-center lg:justify-start",
+ isActive && "relative bg-accent",
+ )}
+ size="lg"
+ onClick={handleClick}
+ >
+ <Icon className="size-5" strokeWidth={isActive ? 2.5 : 2} />
+ <span className="hidden lg:block text-sm relative z-10">{label}</span>
+ {isActive && (
+ <div className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-black dark:bg-white rounded-r-full hidden lg:block"></div>
+ )}
+ </Button>
);
}
export function Sidebar() {
- const uiStore = useUIStore();
+ const authStore = useAuthStore();
return (
- <aside className="w-20 lg:w-64 dark:bg-[#09090b] bg-white border-r dark:border-white/10 border-gray-200 flex flex-col items-center lg:items-start transition-all duration-300 shrink-0 py-6 z-20">
+ <aside
+ className={cn(
+ "flex flex-col items-center lg:items-start",
+ "bg-sidebar transition-all duration-300",
+ "w-20 lg:w-64 shrink-0 py-6 h-full",
+ )}
+ >
{/* Logo Area */}
<div className="h-16 w-full flex items-center justify-center lg:justify-start lg:px-6 mb-6">
{/* Icon Logo (Small) */}
@@ -145,35 +156,29 @@ export function Sidebar() {
</div>
</div>
- {/* Navigation */}
- <nav className="flex-1 w-full flex flex-col gap-1 px-3">
- <NavItem view="home" Icon={Home} label="Overview" to="/" />
- <NavItem
- view="instances"
- Icon={Folder}
- label="Instances"
- to="/instances"
- />
- <NavItem
- view="versions"
- Icon={Package}
- label="Versions"
- to="/versions"
- />
- <NavItem view="guide" Icon={Bot} label="Assistant" to="/guide" />
- <NavItem
- view="settings"
- Icon={Settings}
- label="Settings"
- to="/settings"
- />
+ <nav className="w-full flex flex-col space-y-1 px-3 items-center">
+ <NavItem Icon={Home} label="Overview" to="/" />
+ <NavItem Icon={Folder} label="Instances" to="/instances" />
+ <NavItem Icon={Settings} label="Settings" to="/settings" />
</nav>
- {/* Footer Info */}
- <div className="p-4 w-full flex justify-center lg:justify-start lg:px-6 opacity-40 hover:opacity-100 transition-opacity">
- <div className="text-[10px] font-mono text-zinc-500 uppercase tracking-wider">
- v{uiStore.appVersion}
- </div>
+ <div className="flex-1 flex flex-col justify-end">
+ <DropdownMenu>
+ <DropdownMenuTrigger render={<UserAvatar />}>
+ Open
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" side="right" sideOffset={20}>
+ <DropdownMenuGroup>
+ <DropdownMenuItem
+ variant="destructive"
+ onClick={authStore.logout}
+ >
+ <LogOutIcon />
+ Logout
+ </DropdownMenuItem>
+ </DropdownMenuGroup>
+ </DropdownMenuContent>
+ </DropdownMenu>
</div>
</aside>
);
diff --git a/packages/ui-new/src/components/ui/avatar.tsx b/packages/ui-new/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..9fd72a2
--- /dev/null
+++ b/packages/ui-new/src/components/ui/avatar.tsx
@@ -0,0 +1,107 @@
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: AvatarPrimitive.Root.Props & {
+ size?: "default" | "sm" | "lg";
+}) {
+ return (
+ <AvatarPrimitive.Root
+ data-slot="avatar"
+ data-size={size}
+ className={cn(
+ "size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
+ return (
+ <AvatarPrimitive.Image
+ data-slot="avatar-image"
+ className={cn(
+ "rounded-full aspect-square size-full object-cover",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: AvatarPrimitive.Fallback.Props) {
+ return (
+ <AvatarPrimitive.Fallback
+ data-slot="avatar-fallback"
+ className={cn(
+ "bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="avatar-badge"
+ className={cn(
+ "bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
+ "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="avatar-group"
+ className={cn(
+ "*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="avatar-group-count"
+ className={cn(
+ "bg-muted text-muted-foreground size-8 rounded-full text-xs group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+};
diff --git a/packages/ui-new/src/components/ui/dropdown-menu.tsx b/packages/ui-new/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..ee97374
--- /dev/null
+++ b/packages/ui-new/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,269 @@
+import { Menu as MenuPrimitive } from "@base-ui/react/menu";
+import { CheckIcon, ChevronRightIcon } from "lucide-react";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+ <MenuPrimitive.Portal>
+ <MenuPrimitive.Positioner
+ className="isolate z-50 outline-none"
+ align={align}
+ alignOffset={alignOffset}
+ side={side}
+ sideOffset={sideOffset}
+ >
+ <MenuPrimitive.Popup
+ data-slot="dropdown-menu-content"
+ className={cn(
+ "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-none shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden",
+ className,
+ )}
+ {...props}
+ />
+ </MenuPrimitive.Positioner>
+ </MenuPrimitive.Portal>
+ );
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.GroupLabel
+ data-slot="dropdown-menu-label"
+ data-inset={inset}
+ className={cn(
+ "text-muted-foreground px-2 py-2 text-xs data-inset:pl-7",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+ <MenuPrimitive.Item
+ data-slot="dropdown-menu-item"
+ data-inset={inset}
+ data-variant={variant}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none px-2 py-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.SubmenuTrigger
+ data-slot="dropdown-menu-sub-trigger"
+ data-inset={inset}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none px-2 py-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 data-popup-open:bg-accent data-popup-open:text-accent-foreground flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronRightIcon className="ml-auto" />
+ </MenuPrimitive.SubmenuTrigger>
+ );
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuContent>) {
+ return (
+ <DropdownMenuContent
+ data-slot="dropdown-menu-sub-content"
+ className={cn(
+ "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-24 rounded-none shadow-lg ring-1 duration-100 w-auto",
+ className,
+ )}
+ align={align}
+ alignOffset={alignOffset}
+ side={side}
+ sideOffset={sideOffset}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.CheckboxItem
+ data-slot="dropdown-menu-checkbox-item"
+ data-inset={inset}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ checked={checked}
+ {...props}
+ >
+ <span
+ className="absolute right-2 flex items-center justify-center pointer-events-none"
+ data-slot="dropdown-menu-checkbox-item-indicator"
+ >
+ <MenuPrimitive.CheckboxItemIndicator>
+ <CheckIcon />
+ </MenuPrimitive.CheckboxItemIndicator>
+ </span>
+ {children}
+ </MenuPrimitive.CheckboxItem>
+ );
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+ <MenuPrimitive.RadioGroup
+ data-slot="dropdown-menu-radio-group"
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.RadioItem
+ data-slot="dropdown-menu-radio-item"
+ data-inset={inset}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ <span
+ className="absolute right-2 flex items-center justify-center pointer-events-none"
+ data-slot="dropdown-menu-radio-item-indicator"
+ >
+ <MenuPrimitive.RadioItemIndicator>
+ <CheckIcon />
+ </MenuPrimitive.RadioItemIndicator>
+ </span>
+ {children}
+ </MenuPrimitive.RadioItem>
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+ <MenuPrimitive.Separator
+ data-slot="dropdown-menu-separator"
+ className={cn("bg-border -mx-1 h-px", className)}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="dropdown-menu-shortcut"
+ className={cn(
+ "text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/packages/ui-new/src/components/ui/field.tsx b/packages/ui-new/src/components/ui/field.tsx
new file mode 100644
index 0000000..ab9fb71
--- /dev/null
+++ b/packages/ui-new/src/components/ui/field.tsx
@@ -0,0 +1,238 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { useMemo } from "react";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+ <fieldset
+ data-slot="field-set"
+ className={cn(
+ "gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldLegend({
+ className,
+ variant = "legend",
+ ...props
+}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
+ return (
+ <legend
+ data-slot="field-legend"
+ data-variant={variant}
+ className={cn(
+ "mb-2.5 font-medium data-[variant=label]:text-xs data-[variant=legend]:text-sm",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-group"
+ className={cn(
+ "gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+const fieldVariants = cva(
+ "data-[invalid=true]:text-destructive gap-2 group/field flex w-full",
+ {
+ variants: {
+ orientation: {
+ vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
+ horizontal:
+ "flex-row items-center *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ responsive:
+ "flex-col *:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:*:data-[slot=field-label]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ },
+ },
+ defaultVariants: {
+ orientation: "vertical",
+ },
+ },
+);
+
+function Field({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
+ return (
+ <div
+ data-slot="field"
+ data-orientation={orientation}
+ className={cn(fieldVariants({ orientation }), className)}
+ {...props}
+ />
+ );
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-content"
+ className={cn(
+ "gap-0.5 group/field-content flex flex-1 flex-col leading-snug",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps<typeof Label>) {
+ return (
+ <Label
+ data-slot="field-label"
+ className={cn(
+ "has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 group/field-label peer/field-label flex w-fit leading-snug",
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-label"
+ className={cn(
+ "gap-2 text-xs/relaxed group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ <p
+ data-slot="field-description"
+ className={cn(
+ "text-muted-foreground text-left text-xs/relaxed [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance",
+ "last:mt-0 nth-last-2:-mt-1",
+ "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode;
+}) {
+ return (
+ <div
+ data-slot="field-separator"
+ data-content={!!children}
+ className={cn(
+ "-my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2 relative",
+ className,
+ )}
+ {...props}
+ >
+ <Separator className="absolute inset-0 top-1/2" />
+ {children && (
+ <span
+ className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
+ data-slot="field-separator-content"
+ >
+ {children}
+ </span>
+ )}
+ </div>
+ );
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: Array<{ message?: string } | undefined>;
+}) {
+ const content = useMemo(() => {
+ if (children) {
+ return children;
+ }
+
+ if (!errors?.length) {
+ return null;
+ }
+
+ const uniqueErrors = [
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
+ ];
+
+ if (uniqueErrors?.length === 1) {
+ return uniqueErrors[0]?.message;
+ }
+
+ return (
+ <ul className="ml-4 flex list-disc flex-col gap-1">
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message && (
+ <li key={`${error.message.slice(6)}-${index}`}>
+ {error.message}
+ </li>
+ ),
+ )}
+ </ul>
+ );
+ }, [children, errors]);
+
+ if (!content) {
+ return null;
+ }
+
+ return (
+ <div
+ role="alert"
+ data-slot="field-error"
+ className={cn("text-destructive text-xs font-normal", className)}
+ {...props}
+ >
+ {content}
+ </div>
+ );
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+};
diff --git a/packages/ui-new/src/components/ui/spinner.tsx b/packages/ui-new/src/components/ui/spinner.tsx
new file mode 100644
index 0000000..91f6a63
--- /dev/null
+++ b/packages/ui-new/src/components/ui/spinner.tsx
@@ -0,0 +1,10 @@
+import { cn } from "@/lib/utils"
+import { Loader2Icon } from "lucide-react"
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return (
+ <Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
+ )
+}
+
+export { Spinner }
diff --git a/packages/ui-new/src/components/ui/tabs.tsx b/packages/ui-new/src/components/ui/tabs.tsx
index 6349f40..c66893f 100644
--- a/packages/ui-new/src/components/ui/tabs.tsx
+++ b/packages/ui-new/src/components/ui/tabs.tsx
@@ -22,7 +22,7 @@ function Tabs({
}
const tabsListVariants = cva(
- "rounded-none p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
+ "rounded-none p-0.75 group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
{
variants: {
variant: {
@@ -59,7 +59,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
"gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium group-data-vertical/tabs:py-[calc(--spacing(1.25))] [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
- "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
+ "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:-bottom-1.25 group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className,
)}
{...props}
diff --git a/packages/ui-new/src/components/user-avatar.tsx b/packages/ui-new/src/components/user-avatar.tsx
new file mode 100644
index 0000000..bbdb84c
--- /dev/null
+++ b/packages/ui-new/src/components/user-avatar.tsx
@@ -0,0 +1,23 @@
+import { useAuthStore } from "@/models/auth";
+import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "./ui/avatar";
+
+export function UserAvatar({
+ className,
+ ...props
+}: React.ComponentProps<typeof Avatar>) {
+ const authStore = useAuthStore();
+
+ if (!authStore.account) {
+ return null;
+ }
+
+ return (
+ <Avatar {...props}>
+ <AvatarImage
+ src={`https://minotar.net/helm/${authStore.account.username}/100.png`}
+ />
+ <AvatarFallback>{authStore.account.username.slice(0, 2)}</AvatarFallback>
+ <AvatarBadge />
+ </Avatar>
+ );
+}
diff --git a/packages/ui-new/src/main.tsx b/packages/ui-new/src/main.tsx
index e2ae9c2..bda693d 100644
--- a/packages/ui-new/src/main.tsx
+++ b/packages/ui-new/src/main.tsx
@@ -7,6 +7,7 @@ import { AssistantView } from "./pages/assistant-view";
import { HomeView } from "./pages/home-view";
import { IndexPage } from "./pages/index";
import { InstancesView } from "./pages/instances-view";
+import { SettingsPage } from "./pages/settings";
import { SettingsView } from "./pages/settings-view";
import { VersionsView } from "./pages/versions-view";
@@ -29,12 +30,12 @@ const router = createHashRouter([
},
{
path: "settings",
- element: <SettingsView />,
- },
- {
- path: "guide",
- element: <AssistantView />,
+ element: <SettingsPage />,
},
+ // {
+ // path: "guide",
+ // element: <AssistantView />,
+ // },
],
},
]);
diff --git a/packages/ui-new/src/models/auth.ts b/packages/ui-new/src/models/auth.ts
new file mode 100644
index 0000000..10b2a0d
--- /dev/null
+++ b/packages/ui-new/src/models/auth.ts
@@ -0,0 +1,142 @@
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { open } from "@tauri-apps/plugin-shell";
+import { Mutex } from "es-toolkit";
+import { toString as stringify } from "es-toolkit/compat";
+import { toast } from "sonner";
+import { create } from "zustand";
+import {
+ completeMicrosoftLogin,
+ getActiveAccount,
+ loginOffline,
+ logout,
+ startMicrosoftLogin,
+} from "@/client";
+import type { Account, DeviceCodeResponse } from "@/types";
+
+export interface AuthState {
+ account: Account | null;
+ loginMode: Account["type"] | null;
+ deviceCode: DeviceCodeResponse | null;
+ _pollingInterval: number | null;
+ _mutex: Mutex;
+ statusMessage: string | null;
+ _progressUnlisten: UnlistenFn | null;
+
+ init: () => Promise<void>;
+ setLoginMode: (mode: Account["type"] | null) => void;
+ loginOnline: (onSuccess?: () => void | Promise<void>) => Promise<void>;
+ _pollLoginStatus: (
+ deviceCode: string,
+ onSuccess?: () => void | Promise<void>,
+ ) => Promise<void>;
+ cancelLoginOnline: () => Promise<void>;
+ loginOffline: (username: string) => Promise<void>;
+ logout: () => Promise<void>;
+}
+
+export const useAuthStore = create<AuthState>((set, get) => ({
+ account: null,
+ loginMode: null,
+ deviceCode: null,
+ _pollingInterval: null,
+ statusMessage: null,
+ _progressUnlisten: null,
+ _mutex: new Mutex(),
+
+ init: async () => {
+ try {
+ const account = await getActiveAccount();
+ set({ account });
+ } catch (error) {
+ console.error("Failed to initialize auth store:", error);
+ }
+ },
+ setLoginMode: (mode) => set({ loginMode: mode }),
+ loginOnline: async (onSuccess) => {
+ const { _pollLoginStatus } = get();
+
+ set({ statusMessage: "Waiting for authorization..." });
+
+ try {
+ const unlisten = await listen("auth-progress", (event) => {
+ const message = event.payload;
+ console.log(message);
+ set({ statusMessage: stringify(message), _progressUnlisten: unlisten });
+ });
+ } catch (error) {
+ console.warn("Failed to attch auth-progress listener:", error);
+ toast.warning("Failed to attch auth-progress listener");
+ }
+
+ const deviceCode = await startMicrosoftLogin();
+ navigator.clipboard?.writeText(deviceCode.userCode).catch((err) => {
+ console.error("Failed to copy to clipboard:", err);
+ });
+ open(deviceCode.verificationUri).catch((err) => {
+ console.error("Failed to open browser:", err);
+ });
+ const ms = Number(deviceCode.interval) * 1000;
+ const interval = setInterval(() => {
+ _pollLoginStatus(deviceCode.deviceCode, onSuccess);
+ }, ms);
+ set({ _pollingInterval: interval, deviceCode });
+ },
+ _pollLoginStatus: async (deviceCode, onSuccess) => {
+ const { _pollingInterval, _mutex: mutex, _progressUnlisten } = get();
+ if (mutex.isLocked) return;
+ mutex.acquire();
+ try {
+ const account = await completeMicrosoftLogin(deviceCode);
+ clearInterval(_pollingInterval ?? undefined);
+ _progressUnlisten?.();
+ onSuccess?.();
+ set({ account, loginMode: "microsoft" });
+ } catch (error) {
+ if (error === "authorization_pending") {
+ console.log("Authorization pending...");
+ } else {
+ console.error("Failed to poll login status:", error);
+ toast.error("Failed to poll login status");
+ }
+ } finally {
+ mutex.release();
+ }
+ },
+ cancelLoginOnline: async () => {
+ const { account, logout, _pollingInterval, _progressUnlisten } = get();
+ clearInterval(_pollingInterval ?? undefined);
+ _progressUnlisten?.();
+ if (account) {
+ await logout();
+ }
+ set({
+ loginMode: null,
+ _pollingInterval: null,
+ statusMessage: null,
+ _progressUnlisten: null,
+ });
+ },
+ loginOffline: async (username: string) => {
+ const trimmedUsername = username.trim();
+ if (trimmedUsername.length === 0) {
+ throw new Error("Username cannot be empty");
+ }
+
+ try {
+ const account = await loginOffline(trimmedUsername);
+ set({ account, loginMode: "offline" });
+ } catch (error) {
+ console.error("Failed to login offline:", error);
+ toast.error("Failed to login offline");
+ }
+ },
+ logout: async () => {
+ try {
+ await logout();
+ set({ account: null });
+ } catch (error) {
+ console.error("Failed to logout:", error);
+ toast.error("Failed to logout");
+ }
+ },
+}));
diff --git a/packages/ui-new/src/models/settings.ts b/packages/ui-new/src/models/settings.ts
new file mode 100644
index 0000000..9f4119c
--- /dev/null
+++ b/packages/ui-new/src/models/settings.ts
@@ -0,0 +1,75 @@
+import { toast } from "sonner";
+import { create } from "zustand/react";
+import { getConfigPath, getSettings, saveSettings } from "@/client";
+import type { LauncherConfig } from "@/types";
+
+export interface SettingsState {
+ config: LauncherConfig | null;
+ configPath: string | null;
+
+ /* Theme getter */
+ get theme(): string;
+ /* Apply theme to the document */
+ applyTheme: (theme?: string) => void;
+
+ /* Refresh settings from the backend */
+ refresh: () => Promise<void>;
+ /* Save settings to the backend */
+ save: () => Promise<void>;
+ /* Update settings in the backend */
+ update: (config: LauncherConfig) => Promise<void>;
+ /* Merge settings with the current config without saving */
+ merge: (config: Partial<LauncherConfig>) => void;
+}
+
+export const useSettingsStore = create<SettingsState>((set, get) => ({
+ config: null,
+ configPath: null,
+
+ get theme() {
+ const { config } = get();
+ return config?.theme || "dark";
+ },
+ applyTheme: (theme?: string) => {
+ const { config } = get();
+ if (!config) return;
+ if (!theme) theme = config.theme;
+ let themeValue = theme;
+ if (theme === "system") {
+ themeValue = window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+ }
+ document.documentElement.classList.remove("light", "dark");
+ document.documentElement.setAttribute("data-theme", themeValue);
+ document.documentElement.classList.add(themeValue);
+ set({ config: { ...config, theme } });
+ },
+
+ refresh: async () => {
+ const { applyTheme } = get();
+ try {
+ const settings = await getSettings();
+ const path = await getConfigPath();
+ set({ config: settings, configPath: path });
+ applyTheme(settings.theme);
+ } catch (error) {
+ console.error("Failed to load settings:", error);
+ toast.error("Failed to load settings");
+ }
+ },
+ save: async () => {
+ const { config } = get();
+ if (!config) return;
+ await saveSettings(config);
+ },
+ update: async (config) => {
+ await saveSettings(config);
+ set({ config });
+ },
+ merge: (config) => {
+ const { config: currentConfig } = get();
+ if (!currentConfig) throw new Error("Settings not loaded");
+ set({ config: { ...currentConfig, ...config } });
+ },
+}));
diff --git a/packages/ui-new/src/pages/home-view.tsx b/packages/ui-new/src/pages/home-view.tsx
index bcee7e6..4f80cb0 100644
--- a/packages/ui-new/src/pages/home-view.tsx
+++ b/packages/ui-new/src/pages/home-view.tsx
@@ -1,5 +1,5 @@
-import { Calendar, ExternalLink } from "lucide-react";
import { useEffect, useState } from "react";
+import { BottomBar } from "@/components/bottom-bar";
import type { SaturnEffect } from "@/lib/effects/SaturnEffect";
import { useGameStore } from "../stores/game-store";
import { useReleasesStore } from "../stores/releases-store";
@@ -108,125 +108,6 @@ export function HomeView() {
}
};
- const formatDate = (dateString: string) => {
- return new Date(dateString).toLocaleDateString(undefined, {
- year: "numeric",
- month: "long",
- day: "numeric",
- });
- };
-
- const escapeHtml = (unsafe: string) => {
- return unsafe
- .replace(/&/g, "&amp;")
- .replace(/</g, "&lt;")
- .replace(/>/g, "&gt;")
- .replace(/"/g, "&quot;")
- .replace(/'/g, "&#039;");
- };
-
- const formatBody = (body: string) => {
- if (!body) return "";
-
- let processed = escapeHtml(body);
-
- const emojiMap: Record<string, string> = {
- ":tada:": "🎉",
- ":sparkles:": "✨",
- ":bug:": "🐛",
- ":memo:": "📝",
- ":rocket:": "🚀",
- ":white_check_mark:": "✅",
- ":construction:": "🚧",
- ":recycle:": "♻️",
- ":wrench:": "🔧",
- ":package:": "📦",
- ":arrow_up:": "⬆️",
- ":arrow_down:": "⬇️",
- ":warning:": "⚠️",
- ":fire:": "🔥",
- ":heart:": "❤️",
- ":star:": "⭐",
- ":zap:": "⚡",
- ":art:": "🎨",
- ":lipstick:": "💄",
- ":globe_with_meridians:": "🌐",
- };
-
- processed = processed.replace(
- /:[a-z0-9_]+:/g,
- (match) => emojiMap[match] || match,
- );
-
- processed = processed.replace(/`([0-9a-f]{7,40})`/g, (_match, hash) => {
- return `<a href="https://github.com/HydroRoll-Team/DropOut/commit/${hash}" target="_blank" class="text-emerald-500 hover:underline font-mono bg-emerald-500/10 px-1 rounded text-[10px] py-0.5 transition-colors border border-emerald-500/20 hover:border-emerald-500/50">${hash.substring(
- 0,
- 7,
- )}</a>`;
- });
-
- processed = processed.replace(
- /@([a-zA-Z0-9-]+)/g,
- '<a href="https://github.com/$1" target="_blank" class="text-zinc-300 hover:text-white hover:underline font-medium">@$1</a>',
- );
-
- return processed
- .split("\n")
- .map((line) => {
- line = line.trim();
-
- const formatLine = (text: string) =>
- text
- .replace(
- /\*\*(.*?)\*\*/g,
- '<strong class="text-zinc-200">$1</strong>',
- )
- .replace(
- /(?<!\*)\*([^*]+)\*(?!\*)/g,
- '<em class="text-zinc-400 italic">$1</em>',
- )
- .replace(
- /`([^`]+)`/g,
- '<code class="bg-zinc-800 px-1 py-0.5 rounded text-xs text-zinc-300 font-mono border border-white/5 break-all whitespace-normal">$1</code>',
- )
- .replace(
- /\[(.*?)\]\((.*?)\)/g,
- '<a href="$2" target="_blank" class="text-indigo-400 hover:text-indigo-300 hover:underline decoration-indigo-400/30 break-all">$1</a>',
- );
-
- if (line.startsWith("- ") || line.startsWith("* ")) {
- return `<li class="ml-4 list-disc marker:text-zinc-600 mb-1 pl-1 text-zinc-400">${formatLine(
- line.substring(2),
- )}</li>`;
- }
-
- if (line.startsWith("##")) {
- return `<h3 class="text-sm font-bold mt-6 mb-3 text-zinc-100 flex items-center gap-2 border-b border-white/5 pb-2 uppercase tracking-wide">${line.replace(
- /^#+\s+/,
- "",
- )}</h3>`;
- }
-
- if (line.startsWith("#")) {
- return `<h3 class="text-base font-bold mt-6 mb-3 text-white">${line.replace(
- /^#+\s+/,
- "",
- )}</h3>`;
- }
-
- if (line.startsWith("> ")) {
- return `<blockquote class="border-l-2 border-zinc-700 pl-4 py-1 my-2 text-zinc-500 italic bg-white/5 rounded-r-sm">${formatLine(
- line.substring(2),
- )}</blockquote>`;
- }
-
- if (line === "") return '<div class="h-2"></div>';
-
- return `<p class="mb-1.5 leading-relaxed">${formatLine(line)}</p>`;
- })
- .join("");
- };
-
return (
<div
className="relative z-10 h-full overflow-y-auto custom-scrollbar scroll-smooth"
@@ -286,96 +167,7 @@ export function HomeView() {
</div>
</div>
- {/* Scroll Hint */}
- {!releasesStore.isLoading && releasesStore.releases.length > 0 && (
- <div className="absolute bottom-10 left-12 animate-bounce text-zinc-600 flex flex-col items-center gap-2 w-fit opacity-50 hover:opacity-100 transition-opacity">
- <span className="text-[10px] font-mono uppercase tracking-widest">
- Scroll for Updates
- </span>
- <svg
- width="20"
- height="20"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
- >
- <title>Scroll for Updates</title>
- <path d="M7 13l5 5 5-5M7 6l5 5 5-5" />
- </svg>
- </div>
- )}
- </div>
-
- {/* Changelog / Updates Section */}
- <div className="bg-[#09090b] relative z-20 px-12 pb-24 pt-12 border-t border-white/5 min-h-[50vh]">
- <div className="max-w-4xl">
- <h2 className="text-2xl font-bold text-white mb-10 flex items-center gap-3">
- <span className="w-1.5 h-8 bg-emerald-500 rounded-sm"></span>
- LATEST UPDATES
- </h2>
-
- {releasesStore.isLoading ? (
- <div className="flex flex-col gap-8">
- {Array(3)
- .fill(0)
- .map((_, i) => (
- <div
- key={`release_skeleton_${i.toString()}`}
- className="h-48 bg-white/5 rounded-sm animate-pulse border border-white/5"
- ></div>
- ))}
- </div>
- ) : releasesStore.error ? (
- <div className="p-6 border border-red-500/20 bg-red-500/10 text-red-400 rounded-sm">
- Failed to load updates: {releasesStore.error}
- </div>
- ) : releasesStore.releases.length === 0 ? (
- <div className="text-zinc-500 italic">No releases found.</div>
- ) : (
- <div className="space-y-12">
- {releasesStore.releases.map((release, index) => (
- <div
- key={`${release.name}_${index.toString()}`}
- className="group relative pl-8 border-l border-white/10 pb-4 last:pb-0 last:border-l-0"
- >
- {/* Timeline Dot */}
- <div className="absolute -left-1.25 top-1.5 w-2.5 h-2.5 rounded-full bg-zinc-800 border border-zinc-600 group-hover:bg-emerald-500 group-hover:border-emerald-400 transition-colors"></div>
-
- <div className="flex items-baseline gap-4 mb-3">
- <h3 className="text-xl font-bold text-white group-hover:text-emerald-400 transition-colors">
- {release.name || release.tagName}
- </h3>
- <div className="text-xs font-mono text-zinc-500 flex items-center gap-2">
- <Calendar size={12} />
- {formatDate(release.publishedAt)}
- </div>
- </div>
-
- <div className="bg-zinc-900/50 border border-white/5 hover:border-white/10 rounded-sm p-6 text-zinc-400 text-sm leading-relaxed transition-colors overflow-hidden">
- <div
- className="prose prose-invert prose-sm max-w-none prose-p:text-zinc-400 prose-headings:text-zinc-200 prose-ul:my-2 prose-li:my-0 break-words whitespace-normal"
- dangerouslySetInnerHTML={{
- __html: formatBody(release.body),
- }}
- />
- </div>
-
- <a
- href={release.htmlUrl}
- target="_blank"
- rel="noopener noreferrer"
- className="inline-flex items-center gap-2 mt-3 text-[10px] font-bold uppercase tracking-wider text-zinc-600 hover:text-white transition-colors"
- >
- View full changelog on GitHub <ExternalLink size={10} />
- </a>
- </div>
- ))}
- </div>
- )}
- </div>
+ <BottomBar />
</div>
</div>
);
diff --git a/packages/ui-new/src/pages/index-old.tsx b/packages/ui-new/src/pages/index-old.tsx
new file mode 100644
index 0000000..a6626c9
--- /dev/null
+++ b/packages/ui-new/src/pages/index-old.tsx
@@ -0,0 +1,187 @@
+import { useEffect } from "react";
+import { Outlet } from "react-router";
+import { BottomBar } from "@/components/bottom-bar";
+import { DownloadMonitor } from "@/components/download-monitor";
+import { GameConsole } from "@/components/game-console";
+import { LoginModal } from "@/components/login-modal";
+import { ParticleBackground } from "@/components/particle-background";
+import { Sidebar } from "@/components/sidebar";
+
+import { useAuthStore } from "@/stores/auth-store";
+import { useGameStore } from "@/stores/game-store";
+import { useInstancesStore } from "@/stores/instances-store";
+import { useLogsStore } from "@/stores/logs-store";
+import { useSettingsStore } from "@/stores/settings-store";
+import { useUIStore } from "@/stores/ui-store";
+
+export function IndexPage() {
+ const authStore = useAuthStore();
+ const settingsStore = useSettingsStore();
+ const uiStore = useUIStore();
+ const instancesStore = useInstancesStore();
+ const gameStore = useGameStore();
+ const logsStore = useLogsStore();
+ useEffect(() => {
+ // ENFORCE DARK MODE: Always add 'dark' class and attribute
+ document.documentElement.classList.add("dark");
+ document.documentElement.setAttribute("data-theme", "dark");
+ document.documentElement.classList.remove("light");
+
+ // Initialize stores
+ // Include store functions in the dependency array to satisfy hooks lint.
+ // These functions are stable in our store implementation, so listing them
+ // here is safe and prevents lint warnings.
+ authStore.checkAccount();
+ settingsStore.loadSettings();
+ logsStore.init();
+ settingsStore.detectJava();
+ instancesStore.loadInstances();
+ gameStore.loadVersions();
+
+ // Note: getVersion() would need Tauri API setup
+ // getVersion().then((v) => uiStore.setAppVersion(v));
+ }, [
+ authStore.checkAccount,
+ settingsStore.loadSettings,
+ logsStore.init,
+ settingsStore.detectJava,
+ instancesStore.loadInstances,
+ gameStore.loadVersions,
+ ]);
+
+ // Refresh versions when active instance changes
+ useEffect(() => {
+ if (instancesStore.activeInstanceId) {
+ gameStore.loadVersions();
+ } else {
+ gameStore.setVersions([]);
+ }
+ }, [
+ instancesStore.activeInstanceId,
+ gameStore.loadVersions,
+ gameStore.setVersions,
+ ]);
+
+ return (
+ <div className="relative h-screen w-screen overflow-hidden dark:text-white text-gray-900 font-sans selection:bg-indigo-500/30">
+ {/* Modern Animated Background */}
+ <div className="absolute inset-0 z-0 bg-gray-100 dark:bg-[#09090b] overflow-hidden">
+ {settingsStore.settings.customBackgroundPath && (
+ <img
+ src={settingsStore.settings.customBackgroundPath}
+ alt="Background"
+ className="absolute inset-0 w-full h-full object-cover transition-transform duration-[20s] ease-linear"
+ onError={(e) => console.error("Failed to load main background:", e)}
+ />
+ )}
+
+ {/* Dimming Overlay for readability */}
+ {settingsStore.settings.customBackgroundPath && (
+ <div className="absolute inset-0 bg-black/50"></div>
+ )}
+
+ {!settingsStore.settings.customBackgroundPath && (
+ <>
+ {settingsStore.settings.theme === "dark" ? (
+ <div className="absolute inset-0 opacity-60 bg-linear-to-br from-emerald-900 via-zinc-900 to-indigo-950"></div>
+ ) : (
+ <div className="absolute inset-0 opacity-100 bg-linear-to-br from-emerald-100 via-gray-100 to-indigo-100"></div>
+ )}
+
+ {uiStore.currentView === "home" && <ParticleBackground />}
+
+ <div className="absolute inset-0 bg-linear-to-t from-zinc-900 via-transparent to-black/50 dark:from-zinc-900 dark:to-black/50"></div>
+ </>
+ )}
+
+ {/* Subtle Grid Overlay */}
+ <div
+ className="absolute inset-0 z-0 dark:opacity-10 opacity-30 pointer-events-none"
+ style={{
+ backgroundImage: `linear-gradient(${
+ settingsStore.settings.theme === "dark" ? "#ffffff" : "#000000"
+ } 1px, transparent 1px), linear-gradient(90deg, ${
+ settingsStore.settings.theme === "dark" ? "#ffffff" : "#000000"
+ } 1px, transparent 1px)`,
+ backgroundSize: "40px 40px",
+ maskImage:
+ "radial-gradient(circle at 50% 50%, black 30%, transparent 70%)",
+ }}
+ ></div>
+ </div>
+
+ {/* Content Wrapper */}
+ <div className="relative z-10 flex h-full p-4 gap-4 text-gray-900 dark:text-white">
+ {/* Floating Sidebar */}
+ <Sidebar />
+
+ {/* Main Content Area - Transparent & Flat */}
+ <main className="flex-1 flex flex-col relative min-w-0 overflow-hidden transition-all duration-300">
+ {/* Window Drag Region */}
+ <div
+ className="h-8 w-full absolute top-0 left-0 z-50 drag-region"
+ data-tauri-drag-region
+ ></div>
+
+ {/* App Content */}
+ <div className="flex-1 relative overflow-hidden flex flex-col">
+ {/* Views Container */}
+ <div className="flex-1 relative overflow-hidden">
+ <Outlet />
+ </div>
+
+ {/* Download Monitor Overlay */}
+ <div className="absolute bottom-20 left-4 right-4 pointer-events-none z-20">
+ <div className="pointer-events-auto">
+ <DownloadMonitor />
+ </div>
+ </div>
+
+ {/* Bottom Bar */}
+ {uiStore.currentView === "home" && <BottomBar />}
+ </div>
+ </main>
+ </div>
+
+ {/* Logout Confirmation Dialog */}
+ {authStore.isLogoutConfirmOpen && (
+ <div className="fixed inset-0 z-200 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
+ <div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200">
+ <h3 className="text-lg font-bold text-white mb-2">Logout</h3>
+ <p className="text-zinc-400 text-sm mb-6">
+ Are you sure you want to logout{" "}
+ <span className="text-white font-medium">
+ {authStore.currentAccount?.username}
+ </span>
+ ?
+ </p>
+ <div className="flex gap-3 justify-end">
+ <button
+ type="button"
+ onClick={() => authStore.cancelLogout()}
+ className="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ type="button"
+ onClick={() => authStore.confirmLogout()}
+ className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors"
+ >
+ Logout
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {uiStore.showConsole && (
+ <div className="fixed inset-0 z-100 bg-black/80 backdrop-blur-sm flex items-center justify-center p-8">
+ <div className="w-full h-full max-w-6xl max-h-[85vh] bg-[#1e1e1e] rounded-lg overflow-hidden border border-zinc-700 shadow-2xl relative flex flex-col">
+ <GameConsole />
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/pages/index.tsx b/packages/ui-new/src/pages/index.tsx
index 2b3c2b2..54cfc1e 100644
--- a/packages/ui-new/src/pages/index.tsx
+++ b/packages/ui-new/src/pages/index.tsx
@@ -1,94 +1,48 @@
import { useEffect } from "react";
-import { Outlet } from "react-router";
-import { BottomBar } from "@/components/bottom-bar";
-import { DownloadMonitor } from "@/components/download-monitor";
-import { GameConsole } from "@/components/game-console";
-import { LoginModal } from "@/components/login-modal";
+import { Outlet, useLocation } from "react-router";
import { ParticleBackground } from "@/components/particle-background";
import { Sidebar } from "@/components/sidebar";
-
-import { useAuthStore } from "@/stores/auth-store";
-import { useGameStore } from "@/stores/game-store";
-import { useInstancesStore } from "@/stores/instances-store";
-import { useLogsStore } from "@/stores/logs-store";
-import { useSettingsStore } from "@/stores/settings-store";
-import { useUIStore } from "@/stores/ui-store";
+import { useAuthStore } from "@/models/auth";
+import { useSettingsStore } from "@/models/settings";
export function IndexPage() {
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
- const uiStore = useUIStore();
- const instancesStore = useInstancesStore();
- const gameStore = useGameStore();
- const logsStore = useLogsStore();
- useEffect(() => {
- // ENFORCE DARK MODE: Always add 'dark' class and attribute
- document.documentElement.classList.add("dark");
- document.documentElement.setAttribute("data-theme", "dark");
- document.documentElement.classList.remove("light");
- // Initialize stores
- // Include store functions in the dependency array to satisfy hooks lint.
- // These functions are stable in our store implementation, so listing them
- // here is safe and prevents lint warnings.
- authStore.checkAccount();
- settingsStore.loadSettings();
- logsStore.init();
- settingsStore.detectJava();
- instancesStore.loadInstances();
- gameStore.loadVersions();
+ const location = useLocation();
- // Note: getVersion() would need Tauri API setup
- // getVersion().then((v) => uiStore.setAppVersion(v));
- }, [
- authStore.checkAccount,
- settingsStore.loadSettings,
- logsStore.init,
- settingsStore.detectJava,
- instancesStore.loadInstances,
- gameStore.loadVersions,
- ]);
-
- // Refresh versions when active instance changes
useEffect(() => {
- if (instancesStore.activeInstanceId) {
- gameStore.loadVersions();
- } else {
- gameStore.setVersions([]);
- }
- }, [
- instancesStore.activeInstanceId,
- gameStore.loadVersions,
- gameStore.setVersions,
- ]);
+ authStore.init();
+ settingsStore.refresh();
+ }, [authStore.init, settingsStore.refresh]);
return (
- <div className="relative h-screen w-screen overflow-hidden dark:text-white text-gray-900 font-sans selection:bg-indigo-500/30">
- {/* Modern Animated Background */}
+ <div className="relative h-screen w-full overflow-hidden bg-background font-sans">
<div className="absolute inset-0 z-0 bg-gray-100 dark:bg-[#09090b] overflow-hidden">
- {settingsStore.settings.customBackgroundPath && (
- <img
- src={settingsStore.settings.customBackgroundPath}
- alt="Background"
- className="absolute inset-0 w-full h-full object-cover transition-transform duration-[20s] ease-linear"
- onError={(e) => console.error("Failed to load main background:", e)}
- />
- )}
-
- {/* Dimming Overlay for readability */}
- {settingsStore.settings.customBackgroundPath && (
- <div className="absolute inset-0 bg-black/50"></div>
+ {settingsStore.config?.customBackgroundPath && (
+ <>
+ <img
+ src={settingsStore.config?.customBackgroundPath}
+ alt="Background"
+ className="absolute inset-0 w-full h-full object-cover transition-transform duration-[20s] ease-linear"
+ onError={(e) =>
+ console.error("Failed to load main background:", e)
+ }
+ />
+ {/* Dimming Overlay for readability */}
+ <div className="absolute inset-0 bg-black/50" />
+ </>
)}
- {!settingsStore.settings.customBackgroundPath && (
+ {!settingsStore.config?.customBackgroundPath && (
<>
- {settingsStore.settings.theme === "dark" ? (
+ {settingsStore.theme === "dark" ? (
<div className="absolute inset-0 opacity-60 bg-linear-to-br from-emerald-900 via-zinc-900 to-indigo-950"></div>
) : (
<div className="absolute inset-0 opacity-100 bg-linear-to-br from-emerald-100 via-gray-100 to-indigo-100"></div>
)}
- {uiStore.currentView === "home" && <ParticleBackground />}
+ {location.pathname === "/" && <ParticleBackground />}
<div className="absolute inset-0 bg-linear-to-t from-zinc-900 via-transparent to-black/50 dark:from-zinc-900 dark:to-black/50"></div>
</>
@@ -99,91 +53,24 @@ export function IndexPage() {
className="absolute inset-0 z-0 dark:opacity-10 opacity-30 pointer-events-none"
style={{
backgroundImage: `linear-gradient(${
- settingsStore.settings.theme === "dark" ? "#ffffff" : "#000000"
+ settingsStore.config?.theme === "dark" ? "#ffffff" : "#000000"
} 1px, transparent 1px), linear-gradient(90deg, ${
- settingsStore.settings.theme === "dark" ? "#ffffff" : "#000000"
+ settingsStore.config?.theme === "dark" ? "#ffffff" : "#000000"
} 1px, transparent 1px)`,
backgroundSize: "40px 40px",
maskImage:
"radial-gradient(circle at 50% 50%, black 30%, transparent 70%)",
}}
- ></div>
+ />
</div>
- {/* Content Wrapper */}
- <div className="relative z-10 flex h-full p-4 gap-4 text-gray-900 dark:text-white">
- {/* Floating Sidebar */}
+ <div className="size-full flex flex-row p-4 space-x-4 z-20 relative">
<Sidebar />
- {/* Main Content Area - Transparent & Flat */}
- <main className="flex-1 flex flex-col relative min-w-0 overflow-hidden transition-all duration-300">
- {/* Window Drag Region */}
- <div
- className="h-8 w-full absolute top-0 left-0 z-50 drag-region"
- data-tauri-drag-region
- ></div>
-
- {/* App Content */}
- <div className="flex-1 relative overflow-hidden flex flex-col">
- {/* Views Container */}
- <div className="flex-1 relative overflow-hidden">
- <Outlet />
- </div>
-
- {/* Download Monitor Overlay */}
- <div className="absolute bottom-20 left-4 right-4 pointer-events-none z-20">
- <div className="pointer-events-auto">
- <DownloadMonitor />
- </div>
- </div>
-
- {/* Bottom Bar */}
- {uiStore.currentView === "home" && <BottomBar />}
- </div>
+ <main className="size-full overflow-hidden">
+ <Outlet />
</main>
</div>
-
- <LoginModal />
-
- {/* Logout Confirmation Dialog */}
- {authStore.isLogoutConfirmOpen && (
- <div className="fixed inset-0 z-200 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
- <div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200">
- <h3 className="text-lg font-bold text-white mb-2">Logout</h3>
- <p className="text-zinc-400 text-sm mb-6">
- Are you sure you want to logout{" "}
- <span className="text-white font-medium">
- {authStore.currentAccount?.username}
- </span>
- ?
- </p>
- <div className="flex gap-3 justify-end">
- <button
- type="button"
- onClick={() => authStore.cancelLogout()}
- className="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
- >
- Cancel
- </button>
- <button
- type="button"
- onClick={() => authStore.confirmLogout()}
- className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors"
- >
- Logout
- </button>
- </div>
- </div>
- </div>
- )}
-
- {uiStore.showConsole && (
- <div className="fixed inset-0 z-100 bg-black/80 backdrop-blur-sm flex items-center justify-center p-8">
- <div className="w-full h-full max-w-6xl max-h-[85vh] bg-[#1e1e1e] rounded-lg overflow-hidden border border-zinc-700 shadow-2xl relative flex flex-col">
- <GameConsole />
- </div>
- </div>
- )}
</div>
);
}
diff --git a/packages/ui-new/src/pages/settings.tsx b/packages/ui-new/src/pages/settings.tsx
new file mode 100644
index 0000000..440a5dc
--- /dev/null
+++ b/packages/ui-new/src/pages/settings.tsx
@@ -0,0 +1,310 @@
+import { toNumber } from "es-toolkit/compat";
+import { FileJsonIcon } from "lucide-react";
+import { useEffect, useState } from "react";
+import { migrateSharedCaches } from "@/client";
+import { ConfigEditor } from "@/components/config-editor";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+ FieldLegend,
+ FieldSet,
+} from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useSettingsStore } from "@/models/settings";
+
+export type SettingsTab = "general" | "appearance" | "advanced";
+
+export function SettingsPage() {
+ const { config, ...settings } = useSettingsStore();
+ const [showConfigEditor, setShowConfigEditor] = useState<boolean>(false);
+ const [activeTab, setActiveTab] = useState<SettingsTab>("general");
+
+ useEffect(() => {
+ if (!config) settings.refresh();
+ }, [config, settings.refresh]);
+
+ const renderScrollArea = () => {
+ if (!config) {
+ return (
+ <div className="size-full justify-center items-center">
+ <Spinner />
+ </div>
+ );
+ }
+ return (
+ <ScrollArea className="size-full pr-2">
+ <TabsContent value="general" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">General</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FieldGroup>
+ <FieldSet>
+ <FieldLegend>Window Options</FieldLegend>
+ <FieldDescription>
+ May not work on some platforms like Linux Niri.
+ </FieldDescription>
+ <FieldGroup>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <Field>
+ <FieldLabel htmlFor="width">
+ Window Default Width
+ </FieldLabel>
+ <Input
+ type="number"
+ name="width"
+ value={config?.width}
+ onChange={(e) => {
+ settings.merge({
+ width: toNumber(e.target.value),
+ });
+ }}
+ onBlur={() => {
+ settings.save();
+ }}
+ min={800}
+ max={3840}
+ />
+ </Field>
+ <Field>
+ <FieldLabel htmlFor="height">
+ Window Default Height
+ </FieldLabel>
+ <Input
+ type="number"
+ name="height"
+ value={config?.height}
+ onChange={(e) => {
+ settings.merge({
+ height: toNumber(e.target.value),
+ });
+ }}
+ onBlur={() => {
+ settings.save();
+ }}
+ min={600}
+ max={2160}
+ />
+ </Field>
+ </div>
+ <Field className="flex flex-row items-center justify-between">
+ <FieldContent>
+ <FieldLabel htmlFor="gpu-acceleration">
+ GPU Acceleration
+ </FieldLabel>
+ <FieldDescription>
+ Enable GPU acceleration for the interface.
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ checked={config?.enableGpuAcceleration}
+ onCheckedChange={(checked) => {
+ settings.merge({
+ enableGpuAcceleration: checked,
+ });
+ settings.save();
+ }}
+ />
+ </Field>
+ </FieldGroup>
+ </FieldSet>
+ <FieldSet>
+ <FieldLegend>Network Options</FieldLegend>
+ <Field>
+ <Label htmlFor="download-threads">Download Threads</Label>
+ <Input
+ type="number"
+ name="download-threads"
+ value={config?.downloadThreads}
+ onChange={(e) => {
+ settings.merge({
+ downloadThreads: toNumber(e.target.value),
+ });
+ }}
+ onBlur={() => {
+ settings.save();
+ }}
+ min={1}
+ max={64}
+ />
+ </Field>
+ </FieldSet>
+ </FieldGroup>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ <TabsContent value="java" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">
+ Java Installations
+ </CardTitle>
+ <CardContent></CardContent>
+ </CardHeader>
+ </Card>
+ </TabsContent>
+ <TabsContent value="appearance" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">Appearance</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FieldGroup>
+ <Field className="flex flex-row">
+ <FieldContent>
+ <FieldLabel htmlFor="theme">Theme</FieldLabel>
+ <FieldDescription>
+ Select your prefered theme.
+ </FieldDescription>
+ </FieldContent>
+ <Select
+ items={[
+ { label: "Dark", value: "dark" },
+ { label: "Light", value: "light" },
+ { label: "System", value: "system" },
+ ]}
+ value={config.theme}
+ onValueChange={async (value) => {
+ if (
+ value === "system" ||
+ value === "light" ||
+ value === "dark"
+ ) {
+ settings.merge({ theme: value });
+ await settings.save();
+ settings.applyTheme(value);
+ }
+ }}
+ >
+ <SelectTrigger className="w-full max-w-48">
+ <SelectValue placeholder="Please select a prefered theme" />
+ </SelectTrigger>
+ <SelectContent alignItemWithTrigger={false}>
+ <SelectGroup>
+ <SelectItem value="system">System</SelectItem>
+ <SelectItem value="light">Light</SelectItem>
+ <SelectItem value="dark">Dark</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </Field>
+ </FieldGroup>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ <TabsContent value="advanced" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">Advanced</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FieldGroup>
+ <FieldSet>
+ <FieldLegend>Advanced Options</FieldLegend>
+ <FieldGroup>
+ <Field className="flex flex-row items-center justify-between">
+ <FieldContent>
+ <FieldLabel htmlFor="use-shared-caches">
+ Use Shared Caches
+ </FieldLabel>
+ <FieldDescription>
+ Share downloaded assets between instances.
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ checked={config?.useSharedCaches}
+ onCheckedChange={async (checked) => {
+ checked && (await migrateSharedCaches());
+ settings.merge({
+ useSharedCaches: checked,
+ });
+ settings.save();
+ }}
+ />
+ </Field>
+ <Field className="flex flex-row items-center justify-between">
+ <FieldContent>
+ <FieldLabel htmlFor="keep-per-instance-storage">
+ Keep Legacy Per-Instance Storage
+ </FieldLabel>
+ <FieldDescription>
+ Maintain separate cache folders for compatibility.
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ checked={config?.keepLegacyPerInstanceStorage}
+ onCheckedChange={(checked) => {
+ settings.merge({
+ keepLegacyPerInstanceStorage: checked,
+ });
+ settings.save();
+ }}
+ />
+ </Field>
+ </FieldGroup>
+ </FieldSet>
+ </FieldGroup>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </ScrollArea>
+ );
+ };
+
+ return (
+ <div className="size-full flex flex-col p-6 space-y-6">
+ <div className="flex items-center justify-between">
+ <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">
+ Settings
+ </h2>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setShowConfigEditor(true)}
+ >
+ <FileJsonIcon />
+ <span className="hidden sm:inline">Open JSON</span>
+ </Button>
+ </div>
+
+ <Tabs
+ value={activeTab}
+ onValueChange={setActiveTab}
+ className="size-full flex flex-col gap-6"
+ >
+ <TabsList>
+ <TabsTrigger value="general">General</TabsTrigger>
+ <TabsTrigger value="java">Java</TabsTrigger>
+ <TabsTrigger value="appearance">Appearance</TabsTrigger>
+ <TabsTrigger value="advanced">Advanced</TabsTrigger>
+ </TabsList>
+ {renderScrollArea()}
+ </Tabs>
+
+ <ConfigEditor
+ open={showConfigEditor}
+ onOpenChange={() => setShowConfigEditor(false)}
+ />
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/types/bindings/auth.ts b/packages/ui-new/src/types/bindings/auth.ts
index a65f0a4..563a924 100644
--- a/packages/ui-new/src/types/bindings/auth.ts
+++ b/packages/ui-new/src/types/bindings/auth.ts
@@ -26,7 +26,7 @@ export type MinecraftProfile = { id: string; name: string };
export type OfflineAccount = { username: string; uuid: string };
export type TokenResponse = {
- accessToken: string;
- refreshToken: string | null;
- expiresIn: bigint;
+ access_token: string;
+ refresh_token: string | null;
+ expires_in: bigint;
};
diff --git a/packages/ui-new/src/types/bindings/java/core.ts b/packages/ui-new/src/types/bindings/java/core.ts
index d0dfcbd..8094c71 100644
--- a/packages/ui-new/src/types/bindings/java/core.ts
+++ b/packages/ui-new/src/types/bindings/java/core.ts
@@ -2,9 +2,9 @@
export type JavaCatalog = {
releases: Array<JavaReleaseInfo>;
- available_major_versions: Array<number>;
- lts_versions: Array<number>;
- cached_at: bigint;
+ availableMajorVersions: Array<number>;
+ ltsVersions: Array<number>;
+ cachedAt: bigint;
};
export type JavaDownloadInfo = {
@@ -27,15 +27,15 @@ export type JavaInstallation = {
};
export type JavaReleaseInfo = {
- major_version: number;
- image_type: string;
+ majorVersion: number;
+ imageType: string;
version: string;
- release_name: string;
- release_date: string | null;
- file_size: bigint;
+ releaseName: string;
+ releaseDate: string | null;
+ fileSize: bigint;
checksum: string | null;
- download_url: string;
- is_lts: boolean;
- is_available: boolean;
+ downloadUrl: string;
+ isLts: boolean;
+ isAvailable: boolean;
architecture: string;
};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 02b16ac..a36b11f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -221,6 +221,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ es-toolkit:
+ specifier: ^1.44.0
+ version: 1.44.0
lucide-react:
specifier: ^0.562.0
version: 0.562.0(react@19.2.4)
@@ -248,6 +251,9 @@ importers:
tailwind-merge:
specifier: ^3.4.1
version: 3.4.1
+ zod:
+ specifier: ^4.3.6
+ version: 4.3.6
zustand:
specifier: ^5.0.10
version: 5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
@@ -480,24 +486,28 @@ packages:
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@biomejs/cli-linux-arm64@2.4.2':
resolution: {integrity: sha512-DI3Mi7GT2zYNgUTDEbSjl3e1KhoP76OjQdm8JpvZYZWtVDRyLd3w8llSr2TWk1z+U3P44kUBWY3X7H9MD1/DGQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@biomejs/cli-linux-x64-musl@2.4.2':
resolution: {integrity: sha512-wbBmTkeAoAYbOQ33f6sfKG7pcRSydQiF+dTYOBjJsnXO2mWEOQHllKlC2YVnedqZFERp2WZhFUoO7TNRwnwEHQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@biomejs/cli-linux-x64@2.4.2':
resolution: {integrity: sha512-GK2ErnrKpWFigYP68cXiCHK4RTL4IUWhK92AFS3U28X/nuAL5+hTuy6hyobc8JZRSt+upXt1nXChK+tuHHx4mA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@biomejs/cli-win32-arm64@2.4.2':
resolution: {integrity: sha512-k2uqwLYrNNxnaoiW3RJxoMGnbKda8FuCmtYG3cOtVljs3CzWxaTR+AoXwKGHscC9thax9R4kOrtWqWN0+KdPTw==}
@@ -877,21 +887,25 @@ packages:
resolution: {integrity: sha512-ItPDOPoQ0wLj/s8osc5ch57uUcA1Wk8r0YdO8vLRpXA3UNg7KPOm1vdbkIZRRiSUphZcuX5ioOEetEK8H7RlTw==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@oxfmt/linux-arm64-musl@0.24.0':
resolution: {integrity: sha512-JkQO3WnQjQTJONx8nxdgVBfl6BBFfpp9bKhChYhWeakwJdr7QPOAWJ/v3FGZfr0TbqINwnNR74aVZayDDRyXEA==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@oxfmt/linux-x64-gnu@0.24.0':
resolution: {integrity: sha512-N/SXlFO+2kak5gMt0oxApi0WXQDhwA0PShR0UbkY0PwtHjfSiDqJSOumyNqgQVoroKr1GNnoRmUqjZIz6DKIcw==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@oxfmt/linux-x64-musl@0.24.0':
resolution: {integrity: sha512-WM0pek5YDCQf50XQ7GLCE9sMBCMPW/NPAEPH/Hx6Qyir37lEsP4rUmSECo/QFNTU6KBc9NnsviAyJruWPpCMXw==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@oxfmt/win32-arm64@0.24.0':
resolution: {integrity: sha512-vFCseli1KWtwdHrVlT/jWfZ8jP8oYpnPPEjI23mPLW8K/6GEJmmvy0PZP5NpWUFNTzX0lqie58XnrATJYAe9Xw==}
@@ -950,48 +964,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.48.0':
resolution: {integrity: sha512-adu5txuwGvQ4C4fjYHJD+vnY+OCwCixBzn7J3KF3iWlVHBBImcosSv+Ye+fbMMJui4HGjifNXzonjKm9pXmOiw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.48.0':
resolution: {integrity: sha512-inlQQRUnHCny/7b7wA6NjEoJSSZPNea4qnDhWyeqBYWx8ukf2kzNDSiamfhOw6bfAYPm/PVlkVRYaNXQbkLeTQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.48.0':
resolution: {integrity: sha512-YiJx6sW6bYebQDZRVWLKm/Drswx/hcjIgbLIhULSn0rRcBKc7d9V6mkqPjKDbhcxJgQD5Zi0yVccJiOdF40AWA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.48.0':
resolution: {integrity: sha512-zwSqxMgmb2ITamNfDv9Q9EKBc/4ZhCBP9gkg2hhcgR6sEVGPUDl1AKPC89CBKMxkmPUi3685C38EvqtZn5OtHw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.48.0':
resolution: {integrity: sha512-c/+2oUWAOsQB5JTem0rW8ODlZllF6pAtGSGXoLSvPTonKI1vAwaKhD9Qw1X36jRbcI3Etkpu/9z/RRjMba8vFQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.48.0':
resolution: {integrity: sha512-PhauDqeFW5DGed6QxCY5lXZYKSlcBdCXJnH03ZNU6QmDZ0BFM/zSy1oPT2MNb1Afx1G6yOOVk8ErjWsQ7c59ng==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.48.0':
resolution: {integrity: sha512-6d7LIFFZGiavbHndhf1cK9kG9qmy2Dmr37sV9Ep7j3H+ciFdKSuOzdLh85mEUYMih+b+esMDlF5DU0WQRZPQjw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@oxlint/binding-openharmony-arm64@1.48.0':
resolution: {integrity: sha512-r+0KK9lK6vFp3tXAgDMOW32o12dxvKS3B9La1uYMGdWAMoSeu2RzG34KmzSpXu6MyLDl4aSVyZLFM8KGdEjwaw==}
@@ -1845,24 +1867,28 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.53':
resolution: {integrity: sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.53':
resolution: {integrity: sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-beta.53':
resolution: {integrity: sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-beta.53':
resolution: {integrity: sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==}
@@ -1902,6 +1928,7 @@ packages:
resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@@ -2032,24 +2059,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
@@ -2153,30 +2184,35 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.10.0':
resolution: {integrity: sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.10.0':
resolution: {integrity: sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.10.0':
resolution: {integrity: sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.10.0':
resolution: {integrity: sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.10.0':
resolution: {integrity: sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==}
@@ -2830,6 +2866,9 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
+ es-toolkit@1.44.0:
+ resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==}
+
esast-util-from-estree@2.0.0:
resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
@@ -3502,48 +3541,56 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-gnu@1.31.1:
resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
@@ -7431,6 +7478,8 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.2
+ es-toolkit@1.44.0: {}
+
esast-util-from-estree@2.0.0:
dependencies:
'@types/estree-jsx': 1.0.5
diff --git a/src-tauri/src/core/auth.rs b/src-tauri/src/core/auth.rs
index b137957..03752fd 100644
--- a/src-tauri/src/core/auth.rs
+++ b/src-tauri/src/core/auth.rs
@@ -84,8 +84,8 @@ const CLIENT_ID: &str = "fe165602-5410-4441-92f7-326e10a7cb82";
const SCOPE: &str = "XboxLive.SignIn XboxLive.offline_access";
#[derive(Debug, Serialize, Deserialize, TS)]
-#[serde(rename_all = "camelCase")]
-#[ts(export, export_to = "auth.ts")]
+#[serde(rename_all(serialize = "camelCase"))]
+#[ts(export, export_to = "auth.ts", rename_all = "camelCase")]
pub struct DeviceCodeResponse {
pub user_code: String,
pub device_code: String,
@@ -96,7 +96,7 @@ pub struct DeviceCodeResponse {
}
#[derive(Debug, Serialize, Deserialize, TS)]
-#[serde(rename_all = "camelCase")]
+#[serde(rename_all(serialize = "camelCase"))]
#[ts(export, export_to = "auth.ts")]
pub struct TokenResponse {
pub access_token: String,
diff --git a/src-tauri/src/core/java/mod.rs b/src-tauri/src/core/java/mod.rs
index c8d936d..9036829 100644
--- a/src-tauri/src/core/java/mod.rs
+++ b/src-tauri/src/core/java/mod.rs
@@ -67,6 +67,7 @@ impl std::fmt::Display for ImageType {
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "java/core.ts")]
+#[serde(rename_all = "camelCase")]
pub struct JavaReleaseInfo {
pub major_version: u32,
pub image_type: String,
@@ -83,6 +84,7 @@ pub struct JavaReleaseInfo {
#[derive(Debug, Clone, Serialize, Deserialize, Default, TS)]
#[ts(export, export_to = "java/core.ts")]
+#[serde(rename_all = "camelCase")]
pub struct JavaCatalog {
pub releases: Vec<JavaReleaseInfo>,
pub available_major_versions: Vec<u32>,