aboutsummaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
-rw-r--r--.pre-commit-config.yaml2
-rw-r--r--package.json1
-rw-r--r--packages/ui-new/.gitignore24
-rw-r--r--packages/ui-new/components.json22
-rw-r--r--packages/ui-new/index.html13
-rw-r--r--packages/ui-new/package.json50
-rw-r--r--packages/ui-new/public/icon.svg50
-rw-r--r--packages/ui-new/src/components/bottom-bar.tsx269
-rw-r--r--packages/ui-new/src/components/download-monitor.tsx61
-rw-r--r--packages/ui-new/src/components/game-console.tsx290
-rw-r--r--packages/ui-new/src/components/instance-creation-modal.tsx566
-rw-r--r--packages/ui-new/src/components/instance-editor-modal.tsx548
-rw-r--r--packages/ui-new/src/components/login-modal.tsx156
-rw-r--r--packages/ui-new/src/components/particle-background.tsx63
-rw-r--r--packages/ui-new/src/components/sidebar.tsx180
-rw-r--r--packages/ui-new/src/components/ui/badge.tsx46
-rw-r--r--packages/ui-new/src/components/ui/button.tsx62
-rw-r--r--packages/ui-new/src/components/ui/card.tsx92
-rw-r--r--packages/ui-new/src/components/ui/checkbox.tsx32
-rw-r--r--packages/ui-new/src/components/ui/dialog.tsx141
-rw-r--r--packages/ui-new/src/components/ui/input.tsx21
-rw-r--r--packages/ui-new/src/components/ui/label.tsx24
-rw-r--r--packages/ui-new/src/components/ui/scroll-area.tsx56
-rw-r--r--packages/ui-new/src/components/ui/select.tsx188
-rw-r--r--packages/ui-new/src/components/ui/separator.tsx28
-rw-r--r--packages/ui-new/src/components/ui/sonner.tsx38
-rw-r--r--packages/ui-new/src/components/ui/switch.tsx29
-rw-r--r--packages/ui-new/src/components/ui/tabs.tsx66
-rw-r--r--packages/ui-new/src/components/ui/textarea.tsx18
-rw-r--r--packages/ui-new/src/index.css300
-rw-r--r--packages/ui-new/src/lib/effects/SaturnEffect.ts299
-rw-r--r--packages/ui-new/src/lib/tsrs-utils.ts67
-rw-r--r--packages/ui-new/src/lib/utils.ts6
-rw-r--r--packages/ui-new/src/main.tsx48
-rw-r--r--packages/ui-new/src/pages/assistant-view.tsx485
-rw-r--r--packages/ui-new/src/pages/home-view.tsx382
-rw-r--r--packages/ui-new/src/pages/index.tsx189
-rw-r--r--packages/ui-new/src/pages/instances-view.tsx370
-rw-r--r--packages/ui-new/src/pages/settings-view.tsx1158
-rw-r--r--packages/ui-new/src/pages/versions-view.tsx662
-rw-r--r--packages/ui-new/src/stores/assistant-store.ts201
-rw-r--r--packages/ui-new/src/stores/auth-store.ts296
-rw-r--r--packages/ui-new/src/stores/game-store.ts101
-rw-r--r--packages/ui-new/src/stores/instances-store.ts149
-rw-r--r--packages/ui-new/src/stores/logs-store.ts200
-rw-r--r--packages/ui-new/src/stores/releases-store.ts63
-rw-r--r--packages/ui-new/src/stores/settings-store.ts568
-rw-r--r--packages/ui-new/src/stores/ui-store.ts42
-rw-r--r--packages/ui-new/src/types/bindings/assistant.ts25
-rw-r--r--packages/ui-new/src/types/bindings/auth.ts32
-rw-r--r--packages/ui-new/src/types/bindings/config.ts61
-rw-r--r--packages/ui-new/src/types/bindings/core.ts47
-rw-r--r--packages/ui-new/src/types/bindings/downloader.ts63
-rw-r--r--packages/ui-new/src/types/bindings/fabric.ts74
-rw-r--r--packages/ui-new/src/types/bindings/forge.ts21
-rw-r--r--packages/ui-new/src/types/bindings/game_version.ts89
-rw-r--r--packages/ui-new/src/types/bindings/index.ts11
-rw-r--r--packages/ui-new/src/types/bindings/instance.ts32
-rw-r--r--packages/ui-new/src/types/bindings/java.ts52
-rw-r--r--packages/ui-new/src/types/bindings/manifest.ts22
-rw-r--r--packages/ui-new/tsconfig.app.json34
-rw-r--r--packages/ui-new/tsconfig.json13
-rw-r--r--packages/ui-new/tsconfig.node.json26
-rw-r--r--packages/ui-new/vite.config.ts18
-rw-r--r--pnpm-lock.yaml1487
-rw-r--r--src-tauri/tauri.conf.json6
66 files changed, 10829 insertions, 6 deletions
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1713952..8d5e056 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -8,7 +8,7 @@ repos:
rev: v6.0.0
hooks:
- id: check-json
- exclude: ^packages/ui/tsconfig.*\.json$
+ exclude: ^packages/ui(-new)?/tsconfig.*\.json$
- id: check-toml
- id: check-yaml
- id: check-case-conflict
diff --git a/package.json b/package.json
index 4d1093b..800b455 100644
--- a/package.json
+++ b/package.json
@@ -4,6 +4,7 @@
"private": true,
"description": "Dropout, the next-generation Minecraft game launcher",
"scripts": {
+ "generate": "cargo test export_bindings && biome check packages/ui-new/src/types/bindings --fix",
"bump-tauri": "tsx scripts/bump-tauri.ts",
"prepare": "prek install"
},
diff --git a/packages/ui-new/.gitignore b/packages/ui-new/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/packages/ui-new/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/packages/ui-new/components.json b/packages/ui-new/components.json
new file mode 100644
index 0000000..2b0833f
--- /dev/null
+++ b/packages/ui-new/components.json
@@ -0,0 +1,22 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/index.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/packages/ui-new/index.html b/packages/ui-new/index.html
new file mode 100644
index 0000000..5191e6f
--- /dev/null
+++ b/packages/ui-new/index.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8" />
+ <link rel="icon" type="image/svg+xml" href="/icon.svg" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Dropout Launcher</title>
+ </head>
+ <body>
+ <div id="root"></div>
+ <script type="module" src="/src/main.tsx"></script>
+ </body>
+</html>
diff --git a/packages/ui-new/package.json b/packages/ui-new/package.json
new file mode 100644
index 0000000..706c12b
--- /dev/null
+++ b/packages/ui-new/package.json
@@ -0,0 +1,50 @@
+{
+ "name": "@dropout/ui-new",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "biome check .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-label": "^2.1.8",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-separator": "^1.1.8",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@tauri-apps/api": "^2.9.1",
+ "@tauri-apps/plugin-dialog": "^2.6.0",
+ "@tauri-apps/plugin-fs": "^2.4.5",
+ "@tauri-apps/plugin-shell": "^2.3.4",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.562.0",
+ "marked": "^17.0.1",
+ "next-themes": "^0.4.6",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "react-router": "^7.12.0",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.4.0",
+ "zustand": "^5.0.10"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.1.18",
+ "@types/node": "^24.10.1",
+ "@types/react": "^19.2.5",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.1",
+ "globals": "^16.5.0",
+ "tailwindcss": "^4.1.18",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "~5.9.3",
+ "vite": "npm:rolldown-vite@^7"
+ }
+}
diff --git a/packages/ui-new/public/icon.svg b/packages/ui-new/public/icon.svg
new file mode 100644
index 0000000..0baf00f
--- /dev/null
+++ b/packages/ui-new/public/icon.svg
@@ -0,0 +1,50 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
+ <!-- Background -->
+ <rect width="100%" height="100%" fill="#23272a"/>
+
+ <!-- Grid Pattern -->
+ <defs>
+ <pattern id="smallGrid" width="40" height="40" patternUnits="userSpaceOnUse">
+ <path d="M 40 0 L 0 0 0 40" fill="none" stroke="#2c2f33" stroke-width="2"/>
+ </pattern>
+ <!-- Glow filter for active connections -->
+ <filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
+ <feGaussianBlur stdDeviation="3" result="blur" />
+ <feComposite in="SourceGraphic" in2="blur" operator="over" />
+ </filter>
+ </defs>
+ <rect width="100%" height="100%" fill="url(#smallGrid)" />
+
+ <!-- Neural Network Connections (Lines) -->
+ <!-- Only lines between ACTIVE nodes are drawn normally -->
+
+ <!-- Input (Left) to Hidden (Middle Active) -->
+ <path d="M 100 128 L 256 256" stroke="#43b581" stroke-width="8" stroke-linecap="round" opacity="0.8"/> <!-- Top to Center -->
+ <path d="M 100 256 L 256 256" stroke="#43b581" stroke-width="8" stroke-linecap="round" opacity="1.0" filter="url(#glow)"/> <!-- Mid to Center (Strongest) -->
+ <path d="M 100 384 L 256 256" stroke="#43b581" stroke-width="8" stroke-linecap="round" opacity="0.8"/> <!-- Bot to Center -->
+
+ <!-- Hidden (Middle Active) to Output (Right) -->
+ <path d="M 256 256 L 412 256" stroke="#43b581" stroke-width="8" stroke-linecap="round" opacity="1.0" filter="url(#glow)"/>
+
+ <!-- Disconnected "Ghost" Lines (Optional: faint traces, or just omit to emphasize dropout) -->
+ <!-- Let's omit them to keep it clean and high-contrast, representing true dropout -->
+
+ <!-- Nodes -->
+
+ <!-- Layer 1: Input (All Active) - x=100 -->
+ <circle cx="100" cy="128" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
+ <circle cx="100" cy="256" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
+ <circle cx="100" cy="384" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
+
+ <!-- Layer 2: Hidden (Dropout Layer) - x=256 -->
+ <!-- Node 1: DROPPED (Ghost) -->
+ <circle cx="256" cy="128" r="28" fill="none" stroke="#4f545c" stroke-width="4" stroke-dasharray="8,6"/>
+ <!-- Node 2: ACTIVE -->
+ <circle cx="256" cy="256" r="32" fill="#43b581" stroke="#ffffff" stroke-width="4"/>
+ <!-- Node 3: DROPPED (Ghost) -->
+ <circle cx="256" cy="384" r="28" fill="none" stroke="#4f545c" stroke-width="4" stroke-dasharray="8,6"/>
+
+ <!-- Layer 3: Output - x=412 -->
+ <circle cx="412" cy="256" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
+
+</svg>
diff --git a/packages/ui-new/src/components/bottom-bar.tsx b/packages/ui-new/src/components/bottom-bar.tsx
new file mode 100644
index 0000000..a0c2c00
--- /dev/null
+++ b/packages/ui-new/src/components/bottom-bar.tsx
@@ -0,0 +1,269 @@
+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 { useCallback, useEffect, useRef, useState } from "react";
+import { useAuthStore } from "@/stores/auth-store";
+import { useGameStore } from "@/stores/game-store";
+import { useInstancesStore } from "@/stores/instances-store";
+import { useUIStore } from "@/stores/ui-store";
+
+interface InstalledVersion {
+ id: string;
+ type: string;
+}
+
+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 loadInstalledVersions = useCallback(async () => {
+ if (!instancesStore.activeInstanceId) {
+ setInstalledVersions([]);
+ setIsLoadingVersions(false);
+ return;
+ }
+
+ setIsLoadingVersions(true);
+ try {
+ const versions = await invoke<InstalledVersion[]>(
+ "list_installed_versions",
+ { instanceId: instancesStore.activeInstanceId },
+ );
+
+ const installed = versions || [];
+ setInstalledVersions(installed);
+
+ // If no version is selected but we have installed versions, select the first one
+ if (!gameStore.selectedVersion && installed.length > 0) {
+ gameStore.setSelectedVersion(installed[0].id);
+ }
+ } catch (error) {
+ console.error("Failed to load installed versions:", error);
+ } finally {
+ setIsLoadingVersions(false);
+ }
+ }, [
+ instancesStore.activeInstanceId,
+ gameStore.selectedVersion,
+ gameStore.setSelectedVersion,
+ ]);
+
+ 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;
+
+ (async () => {
+ try {
+ unlistenDownload = await listen("download-complete", () => {
+ loadInstalledVersions();
+ });
+ } catch (err) {
+ // best-effort: do not break UI if listening fails
+ // eslint-disable-next-line no-console
+ console.warn("Failed to attach download-complete listener:", err);
+ }
+
+ try {
+ unlistenVersionDeleted = await listen("version-deleted", () => {
+ loadInstalledVersions();
+ });
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn("Failed to attach version-deleted listener:", err);
+ }
+ })();
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ try {
+ if (unlistenDownload) unlistenDownload();
+ } catch {
+ // ignore
+ }
+ try {
+ if (unlistenVersionDeleted) unlistenVersionDeleted();
+ } catch {
+ // ignore
+ }
+ };
+ }, [loadInstalledVersions]);
+
+ const selectVersion = (id: string) => {
+ if (id !== "loading" && id !== "empty") {
+ gameStore.setSelectedVersion(id);
+ setIsVersionDropdownOpen(false);
+ }
+ };
+
+ const handleStartGame = async () => {
+ await gameStore.startGame(
+ authStore.currentAccount,
+ authStore.openLoginModal,
+ instancesStore.activeInstanceId,
+ uiStore.setView,
+ );
+ };
+
+ const getVersionTypeColor = (type: string) => {
+ switch (type) {
+ case "release":
+ return "bg-emerald-500";
+ case "snapshot":
+ return "bg-amber-500";
+ case "old_beta":
+ return "bg-rose-500";
+ case "old_alpha":
+ return "bg-violet-500";
+ default:
+ return "bg-gray-500";
+ }
+ };
+
+ const versionOptions = isLoadingVersions
+ ? [{ id: "loading", type: "loading", label: "Loading..." }]
+ : installedVersions.length === 0
+ ? [{ id: "empty", type: "empty", label: "No versions installed" }]
+ : installedVersions.map((v) => ({
+ ...v,
+ label: `${v.id}${v.type !== "release" ? ` (${v.type})` : ""}`,
+ }));
+
+ 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 gap-4">
+ <div className="flex flex-col">
+ <span className="text-xs font-mono text-zinc-400 uppercase tracking-wider">
+ Active Instance
+ </span>
+ <span className="text-sm font-medium text-white">
+ {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>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/components/download-monitor.tsx b/packages/ui-new/src/components/download-monitor.tsx
new file mode 100644
index 0000000..d67e173
--- /dev/null
+++ b/packages/ui-new/src/components/download-monitor.tsx
@@ -0,0 +1,61 @@
+import { X } from "lucide-react";
+import { useState } from "react";
+
+export function DownloadMonitor() {
+ const [isVisible, setIsVisible] = useState(true);
+
+ if (!isVisible) return null;
+
+ return (
+ <div className="bg-zinc-900/80 backdrop-blur-md border border-zinc-700 rounded-lg shadow-2xl overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between px-4 py-3 bg-zinc-800/50 border-b border-zinc-700">
+ <div className="flex items-center gap-2">
+ <div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
+ <span className="text-sm font-medium text-white">Downloads</span>
+ </div>
+ <button
+ onClick={() => setIsVisible(false)}
+ className="text-zinc-400 hover:text-white transition-colors p-1"
+ >
+ <X size={16} />
+ </button>
+ </div>
+
+ {/* Content */}
+ <div className="p-4">
+ <div className="space-y-3">
+ {/* Download Item */}
+ <div className="space-y-1">
+ <div className="flex justify-between text-xs">
+ <span className="text-zinc-300">Minecraft 1.20.4</span>
+ <span className="text-zinc-400">65%</span>
+ </div>
+ <div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
+ <div
+ className="h-full bg-emerald-500 rounded-full transition-all duration-300"
+ style={{ width: "65%" }}
+ ></div>
+ </div>
+ <div className="flex justify-between text-[10px] text-zinc-500">
+ <span>142 MB / 218 MB</span>
+ <span>2.1 MB/s • 36s remaining</span>
+ </div>
+ </div>
+
+ {/* Download Item */}
+ <div className="space-y-1">
+ <div className="flex justify-between text-xs">
+ <span className="text-zinc-300">Java 17</span>
+ <span className="text-zinc-400">100%</span>
+ </div>
+ <div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
+ <div className="h-full bg-emerald-500 rounded-full"></div>
+ </div>
+ <div className="text-[10px] text-emerald-400">Completed</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/components/game-console.tsx b/packages/ui-new/src/components/game-console.tsx
new file mode 100644
index 0000000..6980c8c
--- /dev/null
+++ b/packages/ui-new/src/components/game-console.tsx
@@ -0,0 +1,290 @@
+import { Copy, Download, Filter, Search, Trash2, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { useLogsStore } from "@/stores/logs-store";
+import { useUIStore } from "@/stores/ui-store";
+
+export function GameConsole() {
+ const uiStore = useUIStore();
+ const logsStore = useLogsStore();
+
+ const [searchTerm, setSearchTerm] = useState("");
+ const [selectedLevels, setSelectedLevels] = useState<Set<string>>(
+ new Set(["info", "warn", "error", "debug", "fatal"]),
+ );
+ const [autoScroll, setAutoScroll] = useState(true);
+ const consoleEndRef = useRef<HTMLDivElement>(null);
+ const logsContainerRef = useRef<HTMLDivElement>(null);
+
+ const levelColors: Record<string, string> = {
+ info: "text-blue-400",
+ warn: "text-amber-400",
+ error: "text-red-400",
+ debug: "text-purple-400",
+ fatal: "text-rose-400",
+ };
+
+ const levelBgColors: Record<string, string> = {
+ info: "bg-blue-400/10",
+ warn: "bg-amber-400/10",
+ error: "bg-red-400/10",
+ debug: "bg-purple-400/10",
+ fatal: "bg-rose-400/10",
+ };
+
+ // Filter logs based on search term and selected levels
+ const filteredLogs = logsStore.logs.filter((log) => {
+ const matchesSearch =
+ searchTerm === "" ||
+ log.message.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ log.source.toLowerCase().includes(searchTerm.toLowerCase());
+
+ const matchesLevel = selectedLevels.has(log.level);
+
+ return matchesSearch && matchesLevel;
+ });
+
+ // Auto-scroll to bottom when new logs arrive or autoScroll is enabled
+ useEffect(() => {
+ if (autoScroll && consoleEndRef.current && filteredLogs.length > 0) {
+ consoleEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [filteredLogs, autoScroll]);
+
+ // Handle keyboard shortcuts
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Ctrl/Cmd + K to focus search
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
+ e.preventDefault();
+ // Focus search input
+ const searchInput = document.querySelector(
+ 'input[type="text"]',
+ ) as HTMLInputElement;
+ if (searchInput) searchInput.focus();
+ }
+ // Escape to close console
+ if (e.key === "Escape") {
+ uiStore.toggleConsole();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [uiStore.toggleConsole]);
+
+ const toggleLevel = (level: string) => {
+ const newLevels = new Set(selectedLevels);
+ if (newLevels.has(level)) {
+ newLevels.delete(level);
+ } else {
+ newLevels.add(level);
+ }
+ setSelectedLevels(newLevels);
+ };
+
+ const handleCopyAll = () => {
+ const logsText = logsStore.exportLogs(filteredLogs);
+ navigator.clipboard.writeText(logsText);
+ };
+
+ const handleExport = () => {
+ const logsText = logsStore.exportLogs(filteredLogs);
+ const blob = new Blob([logsText], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `dropout_logs_${new Date().toISOString().replace(/[:.]/g, "-")}.txt`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ const handleClear = () => {
+ logsStore.clear();
+ };
+
+ return (
+ <>
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 border-b border-zinc-700 bg-[#252526]">
+ <div className="flex items-center gap-3">
+ <h2 className="text-lg font-bold text-white">Game Console</h2>
+ <div className="flex items-center gap-1">
+ <span className="text-xs text-zinc-400">Logs:</span>
+ <span className="text-xs font-medium text-emerald-400">
+ {filteredLogs.length}
+ </span>
+ <span className="text-xs text-zinc-400">/</span>
+ <span className="text-xs text-zinc-400">
+ {logsStore.logs.length}
+ </span>
+ </div>
+ </div>
+ <button
+ type="button"
+ onClick={() => uiStore.toggleConsole()}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ >
+ <X size={20} />
+ </button>
+ </div>
+
+ {/* Toolbar */}
+ <div className="flex items-center gap-3 p-3 border-b border-zinc-700 bg-[#2D2D30]">
+ {/* Search */}
+ <div className="relative flex-1">
+ <Search
+ className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-500"
+ size={16}
+ />
+ <input
+ type="text"
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ placeholder="Search logs..."
+ className="w-full pl-10 pr-4 py-2 bg-[#3E3E42] border border-zinc-600 rounded text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
+ />
+ {searchTerm && (
+ <button
+ type="button"
+ onClick={() => setSearchTerm("")}
+ className="absolute right-3 top-1/2 transform -translate-y-1/2 text-zinc-400 hover:text-white"
+ >
+ ×
+ </button>
+ )}
+ </div>
+
+ {/* Level Filters */}
+ <div className="flex items-center gap-1">
+ {Object.entries(levelColors).map(([level, colorClass]) => (
+ <button
+ type="button"
+ key={level}
+ onClick={() => toggleLevel(level)}
+ className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
+ selectedLevels.has(level)
+ ? `${levelBgColors[level]} ${colorClass}`
+ : "bg-[#3E3E42] text-zinc-400 hover:text-white"
+ }`}
+ >
+ {level.toUpperCase()}
+ </button>
+ ))}
+ </div>
+
+ {/* Actions */}
+ <div className="flex items-center gap-1">
+ <button
+ type="button"
+ onClick={handleCopyAll}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ title="Copy all logs"
+ >
+ <Copy size={16} />
+ </button>
+ <button
+ type="button"
+ onClick={handleExport}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ title="Export logs"
+ >
+ <Download size={16} />
+ </button>
+ <button
+ type="button"
+ onClick={handleClear}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ title="Clear logs"
+ >
+ <Trash2 size={16} />
+ </button>
+ </div>
+
+ {/* Auto-scroll Toggle */}
+ <div className="flex items-center gap-2 pl-2 border-l border-zinc-700">
+ <label className="inline-flex items-center cursor-pointer">
+ <input
+ type="checkbox"
+ checked={autoScroll}
+ onChange={(e) => setAutoScroll(e.target.checked)}
+ className="sr-only peer"
+ />
+ <div className="relative w-9 h-5 bg-zinc-700 peer-focus:outline-none peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
+ <span className="ml-2 text-xs text-zinc-400">Auto-scroll</span>
+ </label>
+ </div>
+ </div>
+
+ {/* Logs Container */}
+ <div
+ ref={logsContainerRef}
+ className="flex-1 overflow-y-auto font-mono text-sm bg-[#1E1E1E]"
+ style={{ fontFamily: "'Cascadia Code', 'Consolas', monospace" }}
+ >
+ {filteredLogs.length === 0 ? (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center text-zinc-500">
+ <Filter className="mx-auto mb-2" size={24} />
+ <p>No logs match the current filters</p>
+ </div>
+ </div>
+ ) : (
+ <div className="p-4 space-y-1">
+ {filteredLogs.map((log) => (
+ <div
+ key={log.id}
+ className="group hover:bg-white/5 p-2 rounded transition-colors"
+ >
+ <div className="flex items-start gap-3">
+ <div
+ className={`px-2 py-0.5 rounded text-xs font-medium ${levelBgColors[log.level]} ${levelColors[log.level]}`}
+ >
+ {log.level.toUpperCase()}
+ </div>
+ <div className="text-zinc-400 text-xs shrink-0">
+ {log.timestamp}
+ </div>
+ <div className="text-amber-300 text-xs shrink-0">
+ [{log.source}]
+ </div>
+ <div className="text-gray-300 flex-1">{log.message}</div>
+ </div>
+ </div>
+ ))}
+ <div ref={consoleEndRef} />
+ </div>
+ )}
+ </div>
+
+ {/* Footer */}
+ <div className="flex items-center justify-between p-3 border-t border-zinc-700 bg-[#252526] text-xs text-zinc-400">
+ <div className="flex items-center gap-4">
+ <div>
+ <span>Total: </span>
+ <span className="text-white">{logsStore.logs.length}</span>
+ <span> | Filtered: </span>
+ <span className="text-emerald-400">{filteredLogs.length}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <kbd className="px-1.5 py-0.5 bg-[#3E3E42] rounded text-xs">
+ Ctrl+K
+ </kbd>
+ <span>to search</span>
+ </div>
+ </div>
+ <div>
+ <span>Updated: </span>
+ <span>
+ {new Date().toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ })}
+ </span>
+ </div>
+ </div>
+ </>
+ );
+}
diff --git a/packages/ui-new/src/components/instance-creation-modal.tsx b/packages/ui-new/src/components/instance-creation-modal.tsx
new file mode 100644
index 0000000..bdc1a6f
--- /dev/null
+++ b/packages/ui-new/src/components/instance-creation-modal.tsx
@@ -0,0 +1,566 @@
+import { invoke } from "@tauri-apps/api/core";
+import { Loader2, Search } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { useGameStore } from "@/stores/game-store";
+import { useInstancesStore } from "@/stores/instances-store";
+import type { Version } from "@/types/bindings/manifest";
+import type { FabricLoaderEntry } from "../types/bindings/fabric";
+import type { ForgeVersion as ForgeVersionEntry } from "../types/bindings/forge";
+import type { Instance } from "../types/bindings/instance";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+/**
+ * InstanceCreationModal
+ * 3-step wizard:
+ * 1) Name
+ * 2) Select base Minecraft version
+ * 3) Optional: choose mod loader (vanilla/fabric/forge) and loader version
+ *
+ * Behavior:
+ * - On Create: invoke("create_instance", { name })
+ * - If a base version selected: invoke("install_version", { instanceId, versionId })
+ * - If Fabric selected: invoke("install_fabric", { instanceId, gameVersion, loaderVersion })
+ * - If Forge selected: invoke("install_forge", { instanceId, gameVersion, forgeVersion })
+ * - Reload instances via instancesStore.loadInstances()
+ */
+export function InstanceCreationModal({ open, onOpenChange }: Props) {
+ const gameStore = useGameStore();
+ const instancesStore = useInstancesStore();
+
+ // Steps: 1 = name, 2 = version, 3 = mod loader
+ const [step, setStep] = useState<number>(1);
+
+ // Step 1
+ const [instanceName, setInstanceName] = useState<string>("");
+
+ // Step 2
+ const [versionSearch, setVersionSearch] = useState<string>("");
+ const [versionFilter, setVersionFilter] = useState<
+ "all" | "release" | "snapshot"
+ >("release");
+ const [selectedVersionUI, setSelectedVersionUI] = useState<Version | null>(
+ null,
+ );
+
+ // Step 3
+ const [modLoaderType, setModLoaderType] = useState<
+ "vanilla" | "fabric" | "forge"
+ >("vanilla");
+ const [fabricLoaders, setFabricLoaders] = useState<FabricLoaderEntry[]>([]);
+ const [forgeVersions, setForgeVersions] = useState<ForgeVersionEntry[]>([]);
+ const [selectedFabricLoader, setSelectedFabricLoader] = useState<string>("");
+ const [selectedForgeLoader, setSelectedForgeLoader] = useState<string>("");
+ const [loadingLoaders, setLoadingLoaders] = useState(false);
+
+ const loadModLoaders = useCallback(async () => {
+ if (!selectedVersionUI) return;
+ setLoadingLoaders(true);
+ setFabricLoaders([]);
+ setForgeVersions([]);
+ try {
+ if (modLoaderType === "fabric") {
+ const loaders = await invoke<FabricLoaderEntry[]>(
+ "get_fabric_loaders_for_version",
+ {
+ gameVersion: selectedVersionUI.id,
+ },
+ );
+ setFabricLoaders(loaders || []);
+ if (loaders && loaders.length > 0) {
+ setSelectedFabricLoader(loaders[0].loader.version);
+ } else {
+ setSelectedFabricLoader("");
+ }
+ } else if (modLoaderType === "forge") {
+ const versions = await invoke<ForgeVersionEntry[]>(
+ "get_forge_versions_for_game",
+ {
+ gameVersion: selectedVersionUI.id,
+ },
+ );
+ setForgeVersions(versions || []);
+ if (versions && versions.length > 0) {
+ // Binding `ForgeVersion` uses `version` (not `id`) — use `.version` here.
+ setSelectedForgeLoader(versions[0].version);
+ } else {
+ setSelectedForgeLoader("");
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load mod loaders:", e);
+ toast.error("Failed to fetch mod loader versions");
+ } finally {
+ setLoadingLoaders(false);
+ }
+ }, [modLoaderType, selectedVersionUI]);
+
+ // When entering step 3 and a base version exists, fetch loaders if needed
+ useEffect(() => {
+ if (step === 3 && modLoaderType !== "vanilla" && selectedVersionUI) {
+ loadModLoaders();
+ }
+ }, [step, modLoaderType, selectedVersionUI, loadModLoaders]);
+
+ // Creating state
+ const [creating, setCreating] = useState(false);
+ const [errorMessage, setErrorMessage] = useState<string>("");
+
+ // Derived filtered versions
+ const filteredVersions = useMemo(() => {
+ const all = gameStore.versions || [];
+ let list = all.slice();
+ if (versionFilter !== "all") {
+ list = list.filter((v) => v.type === versionFilter);
+ }
+ if (versionSearch.trim()) {
+ const q = versionSearch.trim().toLowerCase().replace(/。/g, ".");
+ list = list.filter((v) => v.id.toLowerCase().includes(q));
+ }
+ return list;
+ }, [gameStore.versions, versionFilter, versionSearch]);
+
+ // Reset when opened/closed
+ useEffect(() => {
+ if (open) {
+ // ensure versions are loaded
+ gameStore.loadVersions();
+ setStep(1);
+ setInstanceName("");
+ setVersionSearch("");
+ setVersionFilter("release");
+ setSelectedVersionUI(null);
+ setModLoaderType("vanilla");
+ setFabricLoaders([]);
+ setForgeVersions([]);
+ setSelectedFabricLoader("");
+ setSelectedForgeLoader("");
+ setErrorMessage("");
+ setCreating(false);
+ }
+ }, [open, gameStore.loadVersions]);
+
+ function validateStep1(): boolean {
+ if (!instanceName.trim()) {
+ setErrorMessage("Please enter an instance name");
+ return false;
+ }
+ setErrorMessage("");
+ return true;
+ }
+
+ function validateStep2(): boolean {
+ if (!selectedVersionUI) {
+ setErrorMessage("Please select a Minecraft version");
+ return false;
+ }
+ setErrorMessage("");
+ return true;
+ }
+
+ async function handleNext() {
+ setErrorMessage("");
+ if (step === 1) {
+ if (!validateStep1()) return;
+ setStep(2);
+ } else if (step === 2) {
+ if (!validateStep2()) return;
+ setStep(3);
+ }
+ }
+
+ function handleBack() {
+ setErrorMessage("");
+ setStep((s) => Math.max(1, s - 1));
+ }
+
+ async function handleCreate() {
+ if (!validateStep1() || !validateStep2()) return;
+ setCreating(true);
+ setErrorMessage("");
+
+ try {
+ // Step 1: create instance
+ const instance = await invoke<Instance>("create_instance", {
+ name: instanceName.trim(),
+ });
+
+ // If selectedVersion provided, install it
+ if (selectedVersionUI) {
+ try {
+ await invoke("install_version", {
+ instanceId: instance.id,
+ versionId: selectedVersionUI.id,
+ });
+ } catch (err) {
+ console.error("Failed to install base version:", err);
+ // continue - instance created but version install failed
+ toast.error(
+ `Failed to install version ${selectedVersionUI.id}: ${String(err)}`,
+ );
+ }
+ }
+
+ // If mod loader selected, install it
+ if (modLoaderType === "fabric" && selectedFabricLoader) {
+ try {
+ await invoke("install_fabric", {
+ instanceId: instance.id,
+ gameVersion: selectedVersionUI?.id ?? "",
+ loaderVersion: selectedFabricLoader,
+ });
+ } catch (err) {
+ console.error("Failed to install Fabric:", err);
+ toast.error(`Failed to install Fabric: ${String(err)}`);
+ }
+ } else if (modLoaderType === "forge" && selectedForgeLoader) {
+ try {
+ await invoke("install_forge", {
+ instanceId: instance.id,
+ gameVersion: selectedVersionUI?.id ?? "",
+ installerVersion: selectedForgeLoader,
+ });
+ } catch (err) {
+ console.error("Failed to install Forge:", err);
+ toast.error(`Failed to install Forge: ${String(err)}`);
+ }
+ }
+
+ // Refresh instances list
+ await instancesStore.loadInstances();
+
+ toast.success("Instance created successfully");
+ onOpenChange(false);
+ } catch (e) {
+ console.error("Failed to create instance:", e);
+ setErrorMessage(String(e));
+ toast.error(`Failed to create instance: ${e}`);
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ // UI pieces
+ const StepIndicator = () => (
+ <div className="flex gap-2 w-full">
+ <div
+ className={`flex-1 h-1 rounded ${step >= 1 ? "bg-indigo-500" : "bg-zinc-700"}`}
+ />
+ <div
+ className={`flex-1 h-1 rounded ${step >= 2 ? "bg-indigo-500" : "bg-zinc-700"}`}
+ />
+ <div
+ className={`flex-1 h-1 rounded ${step >= 3 ? "bg-indigo-500" : "bg-zinc-700"}`}
+ />
+ </div>
+ );
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="w-full max-w-3xl max-h-[90vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Create New Instance</DialogTitle>
+ <DialogDescription>
+ Multi-step wizard — create an instance and optionally install a
+ version or mod loader.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="px-6">
+ <div className="pt-4 pb-6">
+ <StepIndicator />
+ </div>
+
+ {/* Step 1 - Name */}
+ {step === 1 && (
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="instance-name"
+ className="block text-sm font-medium mb-2"
+ >
+ Instance Name
+ </label>
+ <Input
+ id="instance-name"
+ placeholder="My Minecraft Instance"
+ value={instanceName}
+ onChange={(e) => setInstanceName(e.target.value)}
+ disabled={creating}
+ />
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Give your instance a memorable name.
+ </p>
+ </div>
+ )}
+
+ {/* Step 2 - Version selection */}
+ {step === 2 && (
+ <div className="space-y-4">
+ <div className="flex gap-3">
+ <div className="relative flex-1">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
+ <Input
+ value={versionSearch}
+ onChange={(e) => setVersionSearch(e.target.value)}
+ placeholder="Search versions..."
+ className="pl-9"
+ />
+ </div>
+
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant={versionFilter === "all" ? "default" : "outline"}
+ onClick={() => setVersionFilter("all")}
+ >
+ All
+ </Button>
+ <Button
+ type="button"
+ variant={
+ versionFilter === "release" ? "default" : "outline"
+ }
+ onClick={() => setVersionFilter("release")}
+ >
+ Release
+ </Button>
+ <Button
+ type="button"
+ variant={
+ versionFilter === "snapshot" ? "default" : "outline"
+ }
+ onClick={() => setVersionFilter("snapshot")}
+ >
+ Snapshot
+ </Button>
+ </div>
+ </div>
+
+ <ScrollArea className="max-h-[36vh]">
+ <div className="space-y-2 py-2">
+ {gameStore.versions.length === 0 ? (
+ <div className="flex items-center justify-center py-8 text-muted-foreground">
+ <Loader2 className="animate-spin mr-2" />
+ Loading versions...
+ </div>
+ ) : filteredVersions.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ No matching versions found
+ </div>
+ ) : (
+ filteredVersions.map((v) => {
+ const isSelected = selectedVersionUI?.id === v.id;
+ return (
+ <button
+ key={v.id}
+ type="button"
+ onClick={() => setSelectedVersionUI(v)}
+ className={`w-full text-left p-3 rounded-lg border transition-colors ${
+ isSelected
+ ? "bg-indigo-50 dark:bg-indigo-600/20 border-indigo-200"
+ : "bg-white/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:bg-white/60"
+ }`}
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-mono font-bold">{v.id}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {v.type}{" "}
+ {v.releaseTime
+ ? ` • ${new Date(v.releaseTime).toLocaleDateString()}`
+ : ""}
+ </div>
+ </div>
+ {v.javaVersion && (
+ <div className="text-sm">
+ Java {v.javaVersion}
+ </div>
+ )}
+ </div>
+ </button>
+ );
+ })
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ )}
+
+ {/* Step 3 - Mod loader */}
+ {step === 3 && (
+ <div className="space-y-4">
+ <div>
+ <div className="text-sm font-medium mb-2">Mod Loader Type</div>
+ <div className="flex gap-3">
+ <Button
+ type="button"
+ variant={
+ modLoaderType === "vanilla" ? "default" : "outline"
+ }
+ onClick={() => setModLoaderType("vanilla")}
+ >
+ Vanilla
+ </Button>
+ <Button
+ type="button"
+ variant={modLoaderType === "fabric" ? "default" : "outline"}
+ onClick={() => setModLoaderType("fabric")}
+ >
+ Fabric
+ </Button>
+ <Button
+ type="button"
+ variant={modLoaderType === "forge" ? "default" : "outline"}
+ onClick={() => setModLoaderType("forge")}
+ >
+ Forge
+ </Button>
+ </div>
+ </div>
+
+ {modLoaderType === "fabric" && (
+ <div>
+ {loadingLoaders ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="animate-spin" />
+ Loading Fabric versions...
+ </div>
+ ) : fabricLoaders.length > 0 ? (
+ <div className="space-y-2">
+ <select
+ value={selectedFabricLoader}
+ onChange={(e) =>
+ setSelectedFabricLoader(e.target.value)
+ }
+ className="w-full px-3 py-2 rounded border bg-transparent"
+ >
+ {fabricLoaders.map((f) => (
+ <option
+ key={f.loader.version}
+ value={f.loader.version}
+ >
+ {f.loader.version}{" "}
+ {f.loader.stable ? "(Stable)" : "(Beta)"}
+ </option>
+ ))}
+ </select>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ No Fabric loaders available for this version
+ </p>
+ )}
+ </div>
+ )}
+
+ {modLoaderType === "forge" && (
+ <div>
+ {loadingLoaders ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="animate-spin" />
+ Loading Forge versions...
+ </div>
+ ) : forgeVersions.length > 0 ? (
+ <div className="space-y-2">
+ <select
+ value={selectedForgeLoader}
+ onChange={(e) => setSelectedForgeLoader(e.target.value)}
+ className="w-full px-3 py-2 rounded border bg-transparent"
+ >
+ {forgeVersions.map((f) => (
+ // binding ForgeVersion uses `version` as the identifier
+ <option key={f.version} value={f.version}>
+ {f.version}
+ </option>
+ ))}
+ </select>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ No Forge versions available for this version
+ </p>
+ )}
+ </div>
+ )}
+ </div>
+ )}
+
+ {errorMessage && (
+ <div className="text-sm text-red-400 mt-3">{errorMessage}</div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <div className="w-full flex justify-between items-center">
+ <div>
+ <Button
+ type="button"
+ variant="ghost"
+ onClick={() => {
+ // cancel
+ onOpenChange(false);
+ }}
+ disabled={creating}
+ >
+ Cancel
+ </Button>
+ </div>
+
+ <div className="flex gap-2">
+ {step > 1 && (
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleBack}
+ disabled={creating}
+ >
+ Back
+ </Button>
+ )}
+
+ {step < 3 ? (
+ <Button type="button" onClick={handleNext} disabled={creating}>
+ Next
+ </Button>
+ ) : (
+ <Button
+ type="button"
+ onClick={handleCreate}
+ disabled={creating}
+ >
+ {creating ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Creating...
+ </>
+ ) : (
+ "Create"
+ )}
+ </Button>
+ )}
+ </div>
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+export default InstanceCreationModal;
diff --git a/packages/ui-new/src/components/instance-editor-modal.tsx b/packages/ui-new/src/components/instance-editor-modal.tsx
new file mode 100644
index 0000000..012e62c
--- /dev/null
+++ b/packages/ui-new/src/components/instance-editor-modal.tsx
@@ -0,0 +1,548 @@
+import { invoke } from "@tauri-apps/api/core";
+import { Folder, Loader2, Save, Trash2, X } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+
+import { toNumber } from "@/lib/tsrs-utils";
+import { useInstancesStore } from "@/stores/instances-store";
+import { useSettingsStore } from "@/stores/settings-store";
+import type { FileInfo } from "../types/bindings/core";
+import type { Instance } from "../types/bindings/instance";
+
+type Props = {
+ open: boolean;
+ instance: Instance | null;
+ onOpenChange: (open: boolean) => void;
+};
+
+export function InstanceEditorModal({ open, instance, onOpenChange }: Props) {
+ const instancesStore = useInstancesStore();
+ const { settings } = useSettingsStore();
+
+ const [activeTab, setActiveTab] = useState<
+ "info" | "version" | "files" | "settings"
+ >("info");
+ const [saving, setSaving] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
+
+ // Info tab fields
+ const [editName, setEditName] = useState("");
+ const [editNotes, setEditNotes] = useState("");
+
+ // Files tab state
+ const [selectedFileFolder, setSelectedFileFolder] = useState<
+ "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots"
+ >("mods");
+ const [fileList, setFileList] = useState<FileInfo[]>([]);
+ const [loadingFiles, setLoadingFiles] = useState(false);
+ const [deletingPath, setDeletingPath] = useState<string | null>(null);
+
+ // Version tab state (placeholder - the Svelte implementation used a ModLoaderSelector component)
+ // React versions-view/instance-creation handle mod loader installs; here we show basic current info.
+
+ // Settings tab fields
+ const [editMemoryMin, setEditMemoryMin] = useState<number>(0);
+ const [editMemoryMax, setEditMemoryMax] = useState<number>(0);
+ const [editJavaArgs, setEditJavaArgs] = useState<string>("");
+
+ // initialize when open & instance changes
+ useEffect(() => {
+ if (open && instance) {
+ setActiveTab("info");
+ setSaving(false);
+ setErrorMessage("");
+ setEditName(instance.name || "");
+ setEditNotes(instance.notes ?? "");
+ setEditMemoryMin(
+ (instance.memoryOverride && toNumber(instance.memoryOverride.min)) ??
+ settings.minMemory ??
+ 512,
+ );
+ setEditMemoryMax(
+ (instance.memoryOverride && toNumber(instance.memoryOverride.max)) ??
+ settings.maxMemory ??
+ 2048,
+ );
+ setEditJavaArgs(instance.jvmArgsOverride ?? "");
+ setFileList([]);
+ setSelectedFileFolder("mods");
+ }
+ }, [open, instance, settings.minMemory, settings.maxMemory]);
+
+ // load files when switching to files tab
+ const loadFileList = useCallback(
+ async (
+ folder:
+ | "mods"
+ | "resourcepacks"
+ | "shaderpacks"
+ | "saves"
+ | "screenshots",
+ ) => {
+ if (!instance) return;
+ setLoadingFiles(true);
+ try {
+ const files = await invoke<FileInfo[]>("list_instance_directory", {
+ instanceId: instance.id,
+ folder,
+ });
+ setFileList(files || []);
+ } catch (err) {
+ console.error("Failed to load files:", err);
+ toast.error("Failed to load files: " + String(err));
+ setFileList([]);
+ } finally {
+ setLoadingFiles(false);
+ }
+ },
+ [instance],
+ );
+
+ useEffect(() => {
+ if (open && instance && activeTab === "files") {
+ // explicitly pass the selected folder so loadFileList doesn't rely on stale closures
+ loadFileList(selectedFileFolder);
+ }
+ }, [activeTab, open, instance, selectedFileFolder, loadFileList]);
+
+ async function changeFolder(
+ folder: "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots",
+ ) {
+ setSelectedFileFolder(folder);
+ // reload the list for the newly selected folder
+ if (open && instance) await loadFileList(folder);
+ }
+
+ async function deleteFile(filePath: string) {
+ if (
+ !confirm(
+ `Are you sure you want to delete "${filePath.split("/").pop()}"?`,
+ )
+ ) {
+ return;
+ }
+ setDeletingPath(filePath);
+ try {
+ await invoke("delete_instance_file", { path: filePath });
+ // refresh the currently selected folder
+ await loadFileList(selectedFileFolder);
+ toast.success("Deleted");
+ } catch (err) {
+ console.error("Failed to delete file:", err);
+ toast.error("Failed to delete file: " + String(err));
+ } finally {
+ setDeletingPath(null);
+ }
+ }
+
+ async function openInExplorer(filePath: string) {
+ try {
+ await invoke("open_file_explorer", { path: filePath });
+ } catch (err) {
+ console.error("Failed to open in explorer:", err);
+ toast.error("Failed to open file explorer: " + String(err));
+ }
+ }
+
+ async function saveChanges() {
+ if (!instance) return;
+ if (!editName.trim()) {
+ setErrorMessage("Instance name cannot be empty");
+ return;
+ }
+ setSaving(true);
+ setErrorMessage("");
+ try {
+ // Build updated instance shape compatible with backend
+ const updatedInstance: Instance = {
+ ...instance,
+ name: editName.trim(),
+ // some bindings may use camelCase; set optional string fields to null when empty
+ notes: editNotes.trim() ? editNotes.trim() : null,
+ memoryOverride: {
+ min: editMemoryMin,
+ max: editMemoryMax,
+ },
+ jvmArgsOverride: editJavaArgs.trim() ? editJavaArgs.trim() : null,
+ };
+
+ await instancesStore.updateInstance(updatedInstance as Instance);
+ toast.success("Instance saved");
+ onOpenChange(false);
+ } catch (err) {
+ console.error("Failed to save instance:", err);
+ setErrorMessage(String(err));
+ toast.error("Failed to save instance: " + String(err));
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function formatFileSize(bytesBig: FileInfo["size"]): string {
+ const bytes = Number(bytesBig ?? 0);
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
+ }
+
+ function formatDate(
+ tsBig?:
+ | FileInfo["modified"]
+ | Instance["createdAt"]
+ | Instance["lastPlayed"],
+ ) {
+ if (tsBig === undefined || tsBig === null) return "";
+ const n = toNumber(tsBig);
+ // tsrs bindings often use seconds for createdAt/lastPlayed; if value looks like seconds use *1000
+ const maybeMs = n > 1e12 ? n : n * 1000;
+ return new Date(maybeMs).toLocaleDateString();
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="w-full max-w-4xl max-h-[90vh] overflow-hidden">
+ <DialogHeader>
+ <div className="flex items-center justify-between gap-4">
+ <div>
+ <DialogTitle>Edit Instance</DialogTitle>
+ <DialogDescription>{instance?.name ?? ""}</DialogDescription>
+ </div>
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={() => onOpenChange(false)}
+ disabled={saving}
+ className="p-2 rounded hover:bg-zinc-800 text-zinc-400"
+ aria-label="Close"
+ >
+ <X />
+ </button>
+ </div>
+ </div>
+ </DialogHeader>
+
+ {/* Tab Navigation */}
+ <div className="flex gap-1 px-6 pt-2 border-b border-zinc-700">
+ {[
+ { id: "info", label: "Info" },
+ { id: "version", label: "Version" },
+ { id: "files", label: "Files" },
+ { id: "settings", label: "Settings" },
+ ].map((tab) => (
+ <button
+ type="button"
+ key={tab.id}
+ onClick={() =>
+ setActiveTab(
+ tab.id as "info" | "version" | "files" | "settings",
+ )
+ }
+ className={`px-4 py-2 text-sm font-medium transition-colors rounded-t-lg ${
+ activeTab === tab.id
+ ? "bg-indigo-600 text-white"
+ : "bg-zinc-800 text-zinc-400 hover:text-white"
+ }`}
+ >
+ {tab.label}
+ </button>
+ ))}
+ </div>
+
+ {/* Content */}
+ <div className="p-6 overflow-y-auto max-h-[60vh]">
+ {activeTab === "info" && (
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="instance-name-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Instance Name
+ </label>
+ <Input
+ id="instance-name-edit"
+ value={editName}
+ onChange={(e) => setEditName(e.target.value)}
+ disabled={saving}
+ />
+ </div>
+
+ <div>
+ <label
+ htmlFor="instance-notes-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Notes
+ </label>
+ <Textarea
+ id="instance-notes-edit"
+ value={editNotes}
+ onChange={(e) => setEditNotes(e.target.value)}
+ rows={4}
+ disabled={saving}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Created</p>
+ <p className="text-white font-medium">
+ {instance?.createdAt ? formatDate(instance.createdAt) : "-"}
+ </p>
+ </div>
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Last Played</p>
+ <p className="text-white font-medium">
+ {instance?.lastPlayed
+ ? formatDate(instance.lastPlayed)
+ : "Never"}
+ </p>
+ </div>
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Game Directory</p>
+ <p
+ className="text-white font-medium text-xs truncate"
+ title={instance?.gameDir ?? ""}
+ >
+ {instance?.gameDir
+ ? String(instance.gameDir).split("/").pop()
+ : ""}
+ </p>
+ </div>
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Current Version</p>
+ <p className="text-white font-medium">
+ {instance?.versionId ?? "None"}
+ </p>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {activeTab === "version" && (
+ <div className="space-y-4">
+ {instance?.versionId ? (
+ <div className="p-4 bg-indigo-500/10 border border-indigo-500/30 rounded-lg">
+ <p className="text-sm text-indigo-400">
+ Currently playing:{" "}
+ <span className="font-medium">{instance.versionId}</span>
+ {instance.modLoader && (
+ <>
+ {" "}
+ with{" "}
+ <span className="capitalize">{instance.modLoader}</span>
+ {instance.modLoaderVersion
+ ? ` ${instance.modLoaderVersion}`
+ : ""}
+ </>
+ )}
+ </p>
+ </div>
+ ) : (
+ <div className="text-sm text-zinc-400">
+ No version selected for this instance
+ </div>
+ )}
+
+ <div>
+ <p className="text-sm font-medium mb-2">
+ Change Version / Mod Loader
+ </p>
+ <p className="text-xs text-zinc-400">
+ Use the Versions page to install new game versions or mod
+ loaders, then set them here.
+ </p>
+ </div>
+ </div>
+ )}
+
+ {activeTab === "files" && (
+ <div className="space-y-4">
+ <div className="flex gap-2 flex-wrap">
+ {(
+ [
+ "mods",
+ "resourcepacks",
+ "shaderpacks",
+ "saves",
+ "screenshots",
+ ] as const
+ ).map((folder) => (
+ <button
+ type="button"
+ key={folder}
+ onClick={() => changeFolder(folder)}
+ className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
+ selectedFileFolder === folder
+ ? "bg-indigo-600 text-white"
+ : "bg-zinc-800 text-zinc-400 hover:text-white"
+ }`}
+ >
+ {folder}
+ </button>
+ ))}
+ </div>
+
+ {loadingFiles ? (
+ <div className="flex items-center gap-2 text-zinc-400 py-8 justify-center">
+ <Loader2 className="animate-spin" />
+ Loading files...
+ </div>
+ ) : fileList.length === 0 ? (
+ <div className="text-center py-8 text-zinc-500">
+ No files in this folder
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {fileList.map((file) => (
+ <div
+ key={file.path}
+ className="flex items-center justify-between p-3 bg-zinc-800 rounded-lg hover:bg-zinc-700 transition-colors"
+ >
+ <div className="flex-1 min-w-0">
+ <p className="font-medium text-white truncate">
+ {file.name}
+ </p>
+ <p className="text-xs text-zinc-400">
+ {file.isDirectory
+ ? "Folder"
+ : formatFileSize(file.size)}{" "}
+ • {formatDate(file.modified)}
+ </p>
+ </div>
+ <div className="flex gap-2 ml-4">
+ <button
+ type="button"
+ onClick={() => openInExplorer(file.path)}
+ title="Open in explorer"
+ className="p-2 rounded-lg hover:bg-zinc-600 text-zinc-400 hover:text-white transition-colors"
+ >
+ <Folder />
+ </button>
+ <button
+ type="button"
+ onClick={() => deleteFile(file.path)}
+ disabled={deletingPath === file.path}
+ title="Delete"
+ className="p-2 rounded-lg hover:bg-red-600/20 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
+ >
+ {deletingPath === file.path ? (
+ <Loader2 className="animate-spin" />
+ ) : (
+ <Trash2 />
+ )}
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+
+ {activeTab === "settings" && (
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="min-memory-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Minimum Memory (MB)
+ </label>
+ <Input
+ id="min-memory-edit"
+ type="number"
+ value={String(editMemoryMin)}
+ onChange={(e) => setEditMemoryMin(Number(e.target.value))}
+ disabled={saving}
+ />
+ <p className="text-xs text-zinc-400 mt-1">
+ Default: {settings.minMemory} MB
+ </p>
+ </div>
+
+ <div>
+ <label
+ htmlFor="max-memory-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Maximum Memory (MB)
+ </label>
+ <Input
+ id="max-memory-edit"
+ type="number"
+ value={String(editMemoryMax)}
+ onChange={(e) => setEditMemoryMax(Number(e.target.value))}
+ disabled={saving}
+ />
+ <p className="text-xs text-zinc-400 mt-1">
+ Default: {settings.maxMemory} MB
+ </p>
+ </div>
+
+ <div>
+ <label
+ htmlFor="jvm-args-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ JVM Arguments (Advanced)
+ </label>
+ <Textarea
+ id="jvm-args-edit"
+ value={editJavaArgs}
+ onChange={(e) => setEditJavaArgs(e.target.value)}
+ rows={4}
+ disabled={saving}
+ />
+ </div>
+ </div>
+ )}
+ </div>
+
+ {errorMessage && (
+ <div className="px-6 text-sm text-red-400">{errorMessage}</div>
+ )}
+
+ <DialogFooter>
+ <div className="flex items-center justify-between w-full">
+ <div />
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button onClick={saveChanges} disabled={saving}>
+ {saving ? (
+ <Loader2 className="animate-spin mr-2" />
+ ) : (
+ <Save className="mr-2" />
+ )}
+ Save
+ </Button>
+ </div>
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+export default InstanceEditorModal;
diff --git a/packages/ui-new/src/components/login-modal.tsx b/packages/ui-new/src/components/login-modal.tsx
new file mode 100644
index 0000000..9152494
--- /dev/null
+++ b/packages/ui-new/src/components/login-modal.tsx
@@ -0,0 +1,156 @@
+import { Mail, User } from "lucide-react";
+import { useAuthStore } from "@/stores/auth-store";
+
+export function LoginModal() {
+ const authStore = useAuthStore();
+
+ const handleOfflineLogin = () => {
+ if (authStore.offlineUsername.trim()) {
+ authStore.performOfflineLogin();
+ }
+ };
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ handleOfflineLogin();
+ }
+ };
+
+ if (!authStore.isLoginModalOpen) return null;
+
+ 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"
+ >
+ <Mail size={18} />
+ <span className="font-medium">Microsoft Account</span>
+ </button>
+ <button
+ type="button"
+ onClick={() => {
+ authStore.loginMode = "offline";
+ }}
+ 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>
+ </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"
+ onClick={() => {
+ authStore.loginMode = "select";
+ authStore.setOfflineUsername("");
+ }}
+ 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>
+ </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");
+ }}
+ className="text-sm text-zinc-400 hover:text-white transition-colors"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/components/particle-background.tsx b/packages/ui-new/src/components/particle-background.tsx
new file mode 100644
index 0000000..2e0b15a
--- /dev/null
+++ b/packages/ui-new/src/components/particle-background.tsx
@@ -0,0 +1,63 @@
+import { useEffect, useRef } from "react";
+import { SaturnEffect } from "../lib/effects/SaturnEffect";
+
+export function ParticleBackground() {
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
+ const effectRef = useRef<SaturnEffect | null>(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ // Instantiate SaturnEffect and attach to canvas
+ let effect: SaturnEffect | null = null;
+ try {
+ effect = new SaturnEffect(canvas);
+ effectRef.current = effect;
+ } catch (err) {
+ // If effect fails, silently degrade (keep background blank)
+ // eslint-disable-next-line no-console
+ console.warn("SaturnEffect initialization failed:", err);
+ }
+
+ const resizeHandler = () => {
+ if (effectRef.current) {
+ try {
+ effectRef.current.resize(window.innerWidth, window.innerHeight);
+ } catch {
+ // ignore
+ }
+ }
+ };
+
+ window.addEventListener("resize", resizeHandler);
+
+ // Expose getter for HomeView interactions (getSaturnEffect)
+ // HomeView will call window.getSaturnEffect()?.handleMouseDown/Move/Up
+ (
+ window as unknown as { getSaturnEffect?: () => SaturnEffect | null }
+ ).getSaturnEffect = () => effectRef.current;
+
+ return () => {
+ window.removeEventListener("resize", resizeHandler);
+ if (effectRef.current) {
+ try {
+ effectRef.current.destroy();
+ } catch {
+ // ignore
+ }
+ }
+ effectRef.current = null;
+ (
+ window as unknown as { getSaturnEffect?: () => SaturnEffect | null }
+ ).getSaturnEffect = undefined;
+ };
+ }, []);
+
+ return (
+ <canvas
+ ref={canvasRef}
+ className="absolute inset-0 z-0 pointer-events-none"
+ />
+ );
+}
diff --git a/packages/ui-new/src/components/sidebar.tsx b/packages/ui-new/src/components/sidebar.tsx
new file mode 100644
index 0000000..a8c899b
--- /dev/null
+++ b/packages/ui-new/src/components/sidebar.tsx
@@ -0,0 +1,180 @@
+import { Bot, Folder, Home, Package, Settings } from "lucide-react";
+import { Link, useLocation } from "react-router";
+import { useUIStore, type ViewType } from "../stores/ui-store";
+
+interface NavItemProps {
+ view: string;
+ Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
+ label: string;
+ to: string;
+}
+
+function NavItem({ view, Icon, label, to }: NavItemProps) {
+ const uiStore = useUIStore();
+ const location = useLocation();
+ const isActive = location.pathname === to || uiStore.currentView === view;
+
+ const handleClick = () => {
+ uiStore.setView(view as ViewType);
+ };
+
+ 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>
+ );
+}
+
+export function Sidebar() {
+ const uiStore = useUIStore();
+
+ 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">
+ {/* Logo Area */}
+ <div className="h-16 w-full flex items-center justify-center lg:justify-start lg:px-6 mb-6">
+ {/* Icon Logo (Small) */}
+ <div className="lg:hidden text-black dark:text-white">
+ <svg
+ width="32"
+ height="32"
+ viewBox="0 0 100 100"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Logo</title>
+ <path
+ d="M25 25 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M25 75 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M50 50 L75 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
+ <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
+ <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
+ </svg>
+ </div>
+ {/* Full Logo (Large) */}
+ <div className="hidden lg:flex items-center gap-3 font-bold text-xl tracking-tighter dark:text-white text-black">
+ <svg
+ width="42"
+ height="42"
+ viewBox="0 0 100 100"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ className="shrink-0"
+ >
+ <title>Logo</title>
+ <path
+ d="M25 25 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M25 75 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M50 50 L75 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+
+ <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
+
+ <circle
+ cx="50"
+ cy="25"
+ r="7"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeDasharray="4 2"
+ fill="none"
+ className="opacity-30"
+ />
+ <circle
+ cx="50"
+ cy="75"
+ r="7"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeDasharray="4 2"
+ fill="none"
+ className="opacity-30"
+ />
+ <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
+ <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
+ </svg>
+
+ <span>DROPOUT</span>
+ </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>
+
+ {/* 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>
+ </aside>
+ );
+}
diff --git a/packages/ui-new/src/components/ui/badge.tsx b/packages/ui-new/src/components/ui/badge.tsx
new file mode 100644
index 0000000..ccfa4e7
--- /dev/null
+++ b/packages/ui-new/src/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span";
+
+ return (
+ <Comp
+ data-slot="badge"
+ className={cn(badgeVariants({ variant }), className)}
+ {...props}
+ />
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/packages/ui-new/src/components/ui/button.tsx b/packages/ui-new/src/components/ui/button.tsx
new file mode 100644
index 0000000..37a7d4b
--- /dev/null
+++ b/packages/ui-new/src/components/ui/button.tsx
@@ -0,0 +1,62 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ "icon-sm": "size-8",
+ "icon-lg": "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps<typeof buttonVariants> & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+ <Comp
+ data-slot="button"
+ data-variant={variant}
+ data-size={size}
+ className={cn(buttonVariants({ variant, size, className }))}
+ {...props}
+ />
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/packages/ui-new/src/components/ui/card.tsx b/packages/ui-new/src/components/ui/card.tsx
new file mode 100644
index 0000000..681ad98
--- /dev/null
+++ b/packages/ui-new/src/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card"
+ className={cn(
+ "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-header"
+ className={cn(
+ "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-title"
+ className={cn("leading-none font-semibold", className)}
+ {...props}
+ />
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-description"
+ className={cn("text-muted-foreground text-sm", className)}
+ {...props}
+ />
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-action"
+ className={cn(
+ "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-content"
+ className={cn("px-6", className)}
+ {...props}
+ />
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-footer"
+ className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
+ {...props}
+ />
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/packages/ui-new/src/components/ui/checkbox.tsx b/packages/ui-new/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..cb0b07b
--- /dev/null
+++ b/packages/ui-new/src/components/ui/checkbox.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { CheckIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Checkbox({
+ className,
+ ...props
+}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
+ return (
+ <CheckboxPrimitive.Root
+ data-slot="checkbox"
+ className={cn(
+ "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+ className
+ )}
+ {...props}
+ >
+ <CheckboxPrimitive.Indicator
+ data-slot="checkbox-indicator"
+ className="grid place-content-center text-current transition-none"
+ >
+ <CheckIcon className="size-3.5" />
+ </CheckboxPrimitive.Indicator>
+ </CheckboxPrimitive.Root>
+ )
+}
+
+export { Checkbox }
diff --git a/packages/ui-new/src/components/ui/dialog.tsx b/packages/ui-new/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..60cc10e
--- /dev/null
+++ b/packages/ui-new/src/components/ui/dialog.tsx
@@ -0,0 +1,141 @@
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { XIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Dialog({
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Root>) {
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Close>) {
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
+ return (
+ <DialogPrimitive.Overlay
+ data-slot="dialog-overlay"
+ className={cn(
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Content> & {
+ showCloseButton?: boolean
+}) {
+ return (
+ <DialogPortal data-slot="dialog-portal">
+ <DialogOverlay />
+ <DialogPrimitive.Content
+ data-slot="dialog-content"
+ className={cn(
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
+ className
+ )}
+ {...props}
+ >
+ {children}
+ {showCloseButton && (
+ <DialogPrimitive.Close
+ data-slot="dialog-close"
+ className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
+ >
+ <XIcon />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ )}
+ </DialogPrimitive.Content>
+ </DialogPortal>
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="dialog-header"
+ className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
+ {...props}
+ />
+ )
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="dialog-footer"
+ className={cn(
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Title>) {
+ return (
+ <DialogPrimitive.Title
+ data-slot="dialog-title"
+ className={cn("text-lg leading-none font-semibold", className)}
+ {...props}
+ />
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Description>) {
+ return (
+ <DialogPrimitive.Description
+ data-slot="dialog-description"
+ className={cn("text-muted-foreground text-sm", className)}
+ {...props}
+ />
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/packages/ui-new/src/components/ui/input.tsx b/packages/ui-new/src/components/ui/input.tsx
new file mode 100644
index 0000000..8916905
--- /dev/null
+++ b/packages/ui-new/src/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+ <input
+ type={type}
+ data-slot="input"
+ className={cn(
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Input }
diff --git a/packages/ui-new/src/components/ui/label.tsx b/packages/ui-new/src/components/ui/label.tsx
new file mode 100644
index 0000000..fb5fbc3
--- /dev/null
+++ b/packages/ui-new/src/components/ui/label.tsx
@@ -0,0 +1,24 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+
+import { cn } from "@/lib/utils"
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps<typeof LabelPrimitive.Root>) {
+ return (
+ <LabelPrimitive.Root
+ data-slot="label"
+ className={cn(
+ "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Label }
diff --git a/packages/ui-new/src/components/ui/scroll-area.tsx b/packages/ui-new/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..9376f59
--- /dev/null
+++ b/packages/ui-new/src/components/ui/scroll-area.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
+ return (
+ <ScrollAreaPrimitive.Root
+ data-slot="scroll-area"
+ className={cn("relative", className)}
+ {...props}
+ >
+ <ScrollAreaPrimitive.Viewport
+ data-slot="scroll-area-viewport"
+ className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
+ >
+ {children}
+ </ScrollAreaPrimitive.Viewport>
+ <ScrollBar />
+ <ScrollAreaPrimitive.Corner />
+ </ScrollAreaPrimitive.Root>
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
+ return (
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
+ data-slot="scroll-area-scrollbar"
+ orientation={orientation}
+ className={cn(
+ "flex touch-none p-px transition-colors select-none",
+ orientation === "vertical" &&
+ "h-full w-2.5 border-l border-l-transparent",
+ orientation === "horizontal" &&
+ "h-2.5 flex-col border-t border-t-transparent",
+ className
+ )}
+ {...props}
+ >
+ <ScrollAreaPrimitive.ScrollAreaThumb
+ data-slot="scroll-area-thumb"
+ className="bg-border relative flex-1 rounded-full"
+ />
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/packages/ui-new/src/components/ui/select.tsx b/packages/ui-new/src/components/ui/select.tsx
new file mode 100644
index 0000000..b8aab97
--- /dev/null
+++ b/packages/ui-new/src/components/ui/select.tsx
@@ -0,0 +1,188 @@
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Select({
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Root>) {
+ return <SelectPrimitive.Root data-slot="select" {...props} />
+}
+
+function SelectGroup({
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Group>) {
+ return <SelectPrimitive.Group data-slot="select-group" {...props} />
+}
+
+function SelectValue({
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Value>) {
+ return <SelectPrimitive.Value data-slot="select-value" {...props} />
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
+ size?: "sm" | "default"
+}) {
+ return (
+ <SelectPrimitive.Trigger
+ data-slot="select-trigger"
+ data-size={size}
+ className={cn(
+ "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ className
+ )}
+ {...props}
+ >
+ {children}
+ <SelectPrimitive.Icon asChild>
+ <ChevronDownIcon className="size-4 opacity-50" />
+ </SelectPrimitive.Icon>
+ </SelectPrimitive.Trigger>
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ position = "item-aligned",
+ align = "center",
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Content>) {
+ return (
+ <SelectPrimitive.Portal>
+ <SelectPrimitive.Content
+ data-slot="select-content"
+ className={cn(
+ "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
+ position === "popper" &&
+ "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+ className
+ )}
+ position={position}
+ align={align}
+ {...props}
+ >
+ <SelectScrollUpButton />
+ <SelectPrimitive.Viewport
+ className={cn(
+ "p-1",
+ position === "popper" &&
+ "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
+ )}
+ >
+ {children}
+ </SelectPrimitive.Viewport>
+ <SelectScrollDownButton />
+ </SelectPrimitive.Content>
+ </SelectPrimitive.Portal>
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Label>) {
+ return (
+ <SelectPrimitive.Label
+ data-slot="select-label"
+ className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
+ {...props}
+ />
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Item>) {
+ return (
+ <SelectPrimitive.Item
+ data-slot="select-item"
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
+ className
+ )}
+ {...props}
+ >
+ <span
+ data-slot="select-item-indicator"
+ className="absolute right-2 flex size-3.5 items-center justify-center"
+ >
+ <SelectPrimitive.ItemIndicator>
+ <CheckIcon className="size-4" />
+ </SelectPrimitive.ItemIndicator>
+ </span>
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+ </SelectPrimitive.Item>
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
+ return (
+ <SelectPrimitive.Separator
+ data-slot="select-separator"
+ className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
+ {...props}
+ />
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
+ return (
+ <SelectPrimitive.ScrollUpButton
+ data-slot="select-scroll-up-button"
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className
+ )}
+ {...props}
+ >
+ <ChevronUpIcon className="size-4" />
+ </SelectPrimitive.ScrollUpButton>
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
+ return (
+ <SelectPrimitive.ScrollDownButton
+ data-slot="select-scroll-down-button"
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className
+ )}
+ {...props}
+ >
+ <ChevronDownIcon className="size-4" />
+ </SelectPrimitive.ScrollDownButton>
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/packages/ui-new/src/components/ui/separator.tsx b/packages/ui-new/src/components/ui/separator.tsx
new file mode 100644
index 0000000..275381c
--- /dev/null
+++ b/packages/ui-new/src/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import * as SeparatorPrimitive from "@radix-ui/react-separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
+ return (
+ <SeparatorPrimitive.Root
+ data-slot="separator"
+ decorative={decorative}
+ orientation={orientation}
+ className={cn(
+ "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Separator }
diff --git a/packages/ui-new/src/components/ui/sonner.tsx b/packages/ui-new/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..9f46e06
--- /dev/null
+++ b/packages/ui-new/src/components/ui/sonner.tsx
@@ -0,0 +1,38 @@
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+} from "lucide-react"
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+ <Sonner
+ theme={theme as ToasterProps["theme"]}
+ className="toaster group"
+ icons={{
+ success: <CircleCheckIcon className="size-4" />,
+ info: <InfoIcon className="size-4" />,
+ warning: <TriangleAlertIcon className="size-4" />,
+ error: <OctagonXIcon className="size-4" />,
+ loading: <Loader2Icon className="size-4 animate-spin" />,
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ {...props}
+ />
+ )
+}
+
+export { Toaster }
diff --git a/packages/ui-new/src/components/ui/switch.tsx b/packages/ui-new/src/components/ui/switch.tsx
new file mode 100644
index 0000000..b0363e3
--- /dev/null
+++ b/packages/ui-new/src/components/ui/switch.tsx
@@ -0,0 +1,29 @@
+import * as React from "react"
+import * as SwitchPrimitive from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+function Switch({
+ className,
+ ...props
+}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
+ return (
+ <SwitchPrimitive.Root
+ data-slot="switch"
+ className={cn(
+ "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+ className
+ )}
+ {...props}
+ >
+ <SwitchPrimitive.Thumb
+ data-slot="switch-thumb"
+ className={cn(
+ "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
+ )}
+ />
+ </SwitchPrimitive.Root>
+ )
+}
+
+export { Switch }
diff --git a/packages/ui-new/src/components/ui/tabs.tsx b/packages/ui-new/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..497ba5e
--- /dev/null
+++ b/packages/ui-new/src/components/ui/tabs.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import * as TabsPrimitive from "@radix-ui/react-tabs"
+
+import { cn } from "@/lib/utils"
+
+function Tabs({
+ className,
+ ...props
+}: React.ComponentProps<typeof TabsPrimitive.Root>) {
+ return (
+ <TabsPrimitive.Root
+ data-slot="tabs"
+ className={cn("flex flex-col gap-2", className)}
+ {...props}
+ />
+ )
+}
+
+function TabsList({
+ className,
+ ...props
+}: React.ComponentProps<typeof TabsPrimitive.List>) {
+ return (
+ <TabsPrimitive.List
+ data-slot="tabs-list"
+ className={cn(
+ "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
+ return (
+ <TabsPrimitive.Trigger
+ data-slot="tabs-trigger"
+ className={cn(
+ "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps<typeof TabsPrimitive.Content>) {
+ return (
+ <TabsPrimitive.Content
+ data-slot="tabs-content"
+ className={cn("flex-1 outline-none", className)}
+ {...props}
+ />
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }
diff --git a/packages/ui-new/src/components/ui/textarea.tsx b/packages/ui-new/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..7f21b5e
--- /dev/null
+++ b/packages/ui-new/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+ <textarea
+ data-slot="textarea"
+ className={cn(
+ "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export { Textarea }
diff --git a/packages/ui-new/src/index.css b/packages/ui-new/src/index.css
new file mode 100644
index 0000000..917b793
--- /dev/null
+++ b/packages/ui-new/src/index.css
@@ -0,0 +1,300 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 8px);
+ --radius-3xl: calc(var(--radius) + 12px);
+ --radius-4xl: calc(var(--radius) + 16px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: #f3f4f6; /* bg-gray-100 */
+ --foreground: #18181b; /* zinc-900 */
+ --card: #ffffff;
+ --card-foreground: #18181b;
+ --popover: #ffffff;
+ --popover-foreground: #18181b;
+ --primary: #4f46e5; /* indigo-600 */
+ --primary-foreground: #ffffff;
+ --secondary: #f4f4f5; /* zinc-100 */
+ --secondary-foreground: #18181b;
+ --muted: #f4f4f5; /* zinc-100 */
+ --muted-foreground: #71717a; /* zinc-500 */
+ --accent: #f4f4f5; /* zinc-100 */
+ --accent-foreground: #18181b;
+ --destructive: #ef4444; /* red-500 */
+ --destructive-foreground: #ffffff;
+ --border: #e4e4e7; /* zinc-200 */
+ --input: #ffffff;
+ --ring: #6366f1; /* indigo-500 */
+ --chart-1: #059669; /* emerald-600 */
+ --chart-2: #0d9488; /* teal-600 */
+ --chart-3: #4f46e5; /* indigo-600 */
+ --chart-4: #7c3aed; /* violet-600 */
+ --chart-5: #dc2626; /* red-600 */
+ --sidebar: #ffffff;
+ --sidebar-foreground: #18181b;
+ --sidebar-primary: #4f46e5; /* indigo-600 */
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: #f4f4f5; /* zinc-100 */
+ --sidebar-accent-foreground: #18181b;
+ --sidebar-border: #e4e4e7; /* zinc-200 */
+ --sidebar-ring: #6366f1; /* indigo-500 */
+}
+
+.dark {
+ --background: #09090b;
+ --foreground: #fafafa; /* zinc-50 */
+ --card: #18181b; /* zinc-900 */
+ --card-foreground: #fafafa;
+ --popover: #18181b;
+ --popover-foreground: #fafafa;
+ --primary: #6366f1; /* indigo-500 */
+ --primary-foreground: #ffffff;
+ --secondary: #27272a; /* zinc-800 */
+ --secondary-foreground: #fafafa;
+ --muted: #27272a; /* zinc-800 */
+ --muted-foreground: #a1a1aa; /* zinc-400 */
+ --accent: #27272a; /* zinc-800 */
+ --accent-foreground: #fafafa;
+ --destructive: #f87171; /* red-400 */
+ --destructive-foreground: #ffffff;
+ --border: #3f3f46; /* zinc-700 */
+ --input: rgba(255, 255, 255, 0.15);
+ --ring: #6366f1; /* indigo-500 */
+ --chart-1: #10b981; /* emerald-500 */
+ --chart-2: #06b6d4; /* cyan-500 */
+ --chart-3: #6366f1; /* indigo-500 */
+ --chart-4: #8b5cf6; /* violet-500 */
+ --chart-5: #f87171; /* red-400 */
+ --sidebar: #09090b;
+ --sidebar-foreground: #fafafa;
+ --sidebar-primary: #6366f1; /* indigo-500 */
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: #27272a; /* zinc-800 */
+ --sidebar-accent-foreground: #fafafa;
+ --sidebar-border: #3f3f46; /* zinc-700 */
+ --sidebar-ring: #6366f1; /* indigo-500 */
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+
+ ::selection {
+ @apply bg-indigo-500/30;
+ }
+
+ /* Custom Scrollbar */
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: #3f3f46 transparent;
+ }
+
+ ::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ }
+
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background-color: #3f3f46;
+ border-radius: 4px;
+ border: 2px solid transparent;
+ background-clip: content-box;
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ background-color: #52525b;
+ }
+
+ ::-webkit-scrollbar-corner {
+ background: transparent;
+ }
+
+ /* Input/Form Element Consistency */
+ input[type="text"],
+ input[type="number"],
+ input[type="password"],
+ input[type="email"],
+ textarea {
+ background-color: rgba(0, 0, 0, 0.4);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+ }
+
+ input[type="text"]:focus,
+ input[type="number"]:focus,
+ input[type="password"]:focus,
+ input[type="email"]:focus,
+ textarea:focus {
+ border-color: rgba(99, 102, 241, 0.5);
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
+ outline: none;
+ }
+
+ /* Number input - hide spinner */
+ input[type="number"]::-webkit-outer-spin-button,
+ input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ }
+
+ input[type="number"] {
+ appearance: textfield;
+ -moz-appearance: textfield;
+ }
+
+ /* Checkbox Styling */
+ input[type="checkbox"] {
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ border: 1px solid #3f3f46;
+ border-radius: 4px;
+ background-color: #18181b;
+ cursor: pointer;
+ position: relative;
+ transition: all 0.15s ease;
+ }
+
+ input[type="checkbox"]:hover {
+ border-color: #52525b;
+ }
+
+ input[type="checkbox"]:checked {
+ background-color: #4f46e5;
+ border-color: #4f46e5;
+ }
+
+ input[type="checkbox"]:checked::after {
+ content: "";
+ position: absolute;
+ left: 5px;
+ top: 2px;
+ width: 4px;
+ height: 8px;
+ border: solid white;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+ }
+
+ input[type="checkbox"]:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
+ }
+
+ /* Custom Select/Dropdown Styles */
+ select {
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2371717a'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 0.5rem center;
+ background-size: 1rem;
+ padding-right: 2rem;
+ }
+
+ /* Option styling - works in WebView/Chromium */
+ select option {
+ background-color: #18181b;
+ color: #e4e4e7;
+ padding: 12px 16px;
+ font-size: 13px;
+ border: none;
+ }
+
+ select option:hover,
+ select option:focus {
+ background-color: #3730a3 !important;
+ color: white !important;
+ }
+
+ select option:checked {
+ background: linear-gradient(0deg, #4f46e5 0%, #4f46e5 100%);
+ color: white;
+ font-weight: 500;
+ }
+
+ select option:disabled {
+ color: #52525b;
+ background-color: #18181b;
+ }
+
+ /* Optgroup styling */
+ select optgroup {
+ background-color: #18181b;
+ color: #a1a1aa;
+ font-weight: 600;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 8px 12px 4px;
+ }
+
+ /* Select focus state */
+ select:focus {
+ outline: none;
+ border-color: #6366f1;
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
+ }
+
+ /* Global body styles from App.svelte */
+ body {
+ margin: 0;
+ padding: 0;
+ background: #000;
+ }
+
+ /* Window Drag Region */
+ .drag-region {
+ -webkit-app-region: drag;
+ app-region: drag;
+ }
+}
diff --git a/packages/ui-new/src/lib/effects/SaturnEffect.ts b/packages/ui-new/src/lib/effects/SaturnEffect.ts
new file mode 100644
index 0000000..497a340
--- /dev/null
+++ b/packages/ui-new/src/lib/effects/SaturnEffect.ts
@@ -0,0 +1,299 @@
+/**
+ * Ported SaturnEffect for the React UI (ui-new).
+ * Adapted from the original Svelte implementation but written as a standalone
+ * TypeScript class that manages a 2D canvas particle effect resembling a
+ * rotating "Saturn" with rings. Designed to be instantiated and controlled
+ * from a React component (e.g. ParticleBackground).
+ *
+ * Usage:
+ * const effect = new SaturnEffect(canvasElement);
+ * effect.handleMouseDown(clientX);
+ * effect.handleMouseMove(clientX);
+ * effect.handleMouseUp();
+ * // on resize:
+ * effect.resize(width, height);
+ * // on unmount:
+ * effect.destroy();
+ */
+
+export class SaturnEffect {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ private width = 0;
+ private height = 0;
+
+ // Particle storage
+ private xyz: Float32Array | null = null; // interleaved x,y,z
+ private types: Uint8Array | null = null; // 0 = planet, 1 = ring
+ private count = 0;
+
+ // Animation
+ private animationId = 0;
+ private angle = 0;
+ private scaleFactor = 1;
+
+ // Interaction
+ private isDragging = false;
+ private lastMouseX = 0;
+ private lastMouseTime = 0;
+ private mouseVelocities: number[] = [];
+
+ // Speed control
+ private readonly baseSpeed = 0.005;
+ private currentSpeed = 0.005;
+ private rotationDirection = 1;
+ private readonly speedDecayRate = 0.992;
+ private readonly minSpeedMultiplier = 1;
+ private readonly maxSpeedMultiplier = 50;
+ private isStopped = false;
+
+ constructor(canvas: HTMLCanvasElement) {
+ this.canvas = canvas;
+ const ctx = canvas.getContext("2d", { alpha: true, desynchronized: false });
+ if (!ctx) {
+ throw new Error("Failed to get 2D context for SaturnEffect");
+ }
+ this.ctx = ctx;
+
+ // Initialize size & particles
+ this.resize(window.innerWidth, window.innerHeight);
+ this.initParticles();
+
+ this.animate = this.animate.bind(this);
+ this.animate();
+ }
+
+ // External interaction handlers (accept clientX)
+ handleMouseDown(clientX: number) {
+ this.isDragging = true;
+ this.lastMouseX = clientX;
+ this.lastMouseTime = performance.now();
+ this.mouseVelocities = [];
+ }
+
+ handleMouseMove(clientX: number) {
+ if (!this.isDragging) return;
+ const now = performance.now();
+ const dt = now - this.lastMouseTime;
+ if (dt > 0) {
+ const dx = clientX - this.lastMouseX;
+ const velocity = dx / dt;
+ this.mouseVelocities.push(velocity);
+ if (this.mouseVelocities.length > 5) this.mouseVelocities.shift();
+ // Rotate directly while dragging for immediate feedback
+ this.angle += dx * 0.002;
+ }
+ this.lastMouseX = clientX;
+ this.lastMouseTime = now;
+ }
+
+ handleMouseUp() {
+ if (this.isDragging && this.mouseVelocities.length > 0) {
+ this.applyFlingVelocity();
+ }
+ this.isDragging = false;
+ }
+
+ handleTouchStart(clientX: number) {
+ this.handleMouseDown(clientX);
+ }
+
+ handleTouchMove(clientX: number) {
+ this.handleMouseMove(clientX);
+ }
+
+ handleTouchEnd() {
+ this.handleMouseUp();
+ }
+
+ // Resize canvas & scale (call on window resize)
+ resize(width: number, height: number) {
+ const dpr = window.devicePixelRatio || 1;
+ this.width = width;
+ this.height = height;
+
+ // Update canvas pixel size and CSS size
+ this.canvas.width = Math.max(1, Math.floor(width * dpr));
+ this.canvas.height = Math.max(1, Math.floor(height * dpr));
+ this.canvas.style.width = `${width}px`;
+ this.canvas.style.height = `${height}px`;
+
+ // Reset transform and scale for devicePixelRatio
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0); // reset
+ this.ctx.scale(dpr, dpr);
+
+ const minDim = Math.min(width, height);
+ this.scaleFactor = Math.max(1, minDim * 0.45);
+ }
+
+ // Initialize particle arrays with reduced counts to keep performance reasonable
+ private initParticles() {
+ // Tuned particle counts for reasonable performance across platforms
+ const planetCount = 1000;
+ const ringCount = 2500;
+ this.count = planetCount + ringCount;
+
+ this.xyz = new Float32Array(this.count * 3);
+ this.types = new Uint8Array(this.count);
+
+ let idx = 0;
+
+ // Planet points
+ for (let i = 0; i < planetCount; i++, idx++) {
+ const theta = Math.random() * Math.PI * 2;
+ const phi = Math.acos(Math.random() * 2 - 1);
+ const r = 1.0;
+
+ this.xyz[idx * 3] = r * Math.sin(phi) * Math.cos(theta);
+ this.xyz[idx * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
+ this.xyz[idx * 3 + 2] = r * Math.cos(phi);
+
+ this.types[idx] = 0;
+ }
+
+ // Ring points
+ const ringInner = 1.4;
+ const ringOuter = 2.3;
+ for (let i = 0; i < ringCount; i++, idx++) {
+ const angle = Math.random() * Math.PI * 2;
+ const dist = Math.sqrt(
+ Math.random() * (ringOuter * ringOuter - ringInner * ringInner) +
+ ringInner * ringInner,
+ );
+
+ this.xyz[idx * 3] = dist * Math.cos(angle);
+ this.xyz[idx * 3 + 1] = (Math.random() - 0.5) * 0.05;
+ this.xyz[idx * 3 + 2] = dist * Math.sin(angle);
+
+ this.types[idx] = 1;
+ }
+ }
+
+ // Map fling/velocity samples to a rotation speed and direction
+ private applyFlingVelocity() {
+ if (this.mouseVelocities.length === 0) return;
+ const avg =
+ this.mouseVelocities.reduce((a, b) => a + b, 0) /
+ this.mouseVelocities.length;
+ const flingThreshold = 0.3;
+ const stopThreshold = 0.1;
+
+ if (Math.abs(avg) > flingThreshold) {
+ this.isStopped = false;
+ const newDir = avg > 0 ? 1 : -1;
+ if (newDir !== this.rotationDirection) this.rotationDirection = newDir;
+ const multiplier = Math.min(
+ this.maxSpeedMultiplier,
+ this.minSpeedMultiplier + Math.abs(avg) * 10,
+ );
+ this.currentSpeed = this.baseSpeed * multiplier;
+ } else if (Math.abs(avg) < stopThreshold) {
+ this.isStopped = true;
+ this.currentSpeed = 0;
+ }
+ }
+
+ // Main render loop
+ private animate() {
+ // Clear with full alpha to allow layering over background
+ this.ctx.clearRect(0, 0, this.width, this.height);
+
+ // Standard composition
+ this.ctx.globalCompositeOperation = "source-over";
+
+ // Update rotation speed (decay)
+ if (!this.isDragging && !this.isStopped) {
+ if (this.currentSpeed > this.baseSpeed) {
+ this.currentSpeed =
+ this.baseSpeed +
+ (this.currentSpeed - this.baseSpeed) * this.speedDecayRate;
+ if (this.currentSpeed - this.baseSpeed < 0.00001) {
+ this.currentSpeed = this.baseSpeed;
+ }
+ }
+ this.angle += this.currentSpeed * this.rotationDirection;
+ }
+
+ // Center positions
+ const cx = this.width * 0.6;
+ const cy = this.height * 0.5;
+
+ // Pre-calc rotations
+ const rotationY = this.angle;
+ const rotationX = 0.4;
+ const rotationZ = 0.15;
+
+ const sinY = Math.sin(rotationY);
+ const cosY = Math.cos(rotationY);
+ const sinX = Math.sin(rotationX);
+ const cosX = Math.cos(rotationX);
+ const sinZ = Math.sin(rotationZ);
+ const cosZ = Math.cos(rotationZ);
+
+ const fov = 1500;
+ const scaleFactor = this.scaleFactor;
+
+ if (!this.xyz || !this.types) {
+ this.animationId = requestAnimationFrame(this.animate);
+ return;
+ }
+
+ // Loop particles
+ for (let i = 0; i < this.count; i++) {
+ const x = this.xyz[i * 3];
+ const y = this.xyz[i * 3 + 1];
+ const z = this.xyz[i * 3 + 2];
+
+ // Scale to screen
+ const px = x * scaleFactor;
+ const py = y * scaleFactor;
+ const pz = z * scaleFactor;
+
+ // Rotate Y then X then Z
+ const x1 = px * cosY - pz * sinY;
+ const z1 = pz * cosY + px * sinY;
+ const y2 = py * cosX - z1 * sinX;
+ const z2 = z1 * cosX + py * sinX;
+ const x3 = x1 * cosZ - y2 * sinZ;
+ const y3 = y2 * cosZ + x1 * sinZ;
+ const z3 = z2;
+
+ const scale = fov / (fov + z3);
+
+ if (z3 > -fov) {
+ const x2d = cx + x3 * scale;
+ const y2d = cy + y3 * scale;
+
+ const type = this.types[i];
+ const sizeBase = type === 0 ? 2.4 : 1.5;
+ const size = sizeBase * scale;
+
+ let alpha = scale * scale * scale;
+ if (alpha > 1) alpha = 1;
+ if (alpha < 0.15) continue;
+
+ if (type === 0) {
+ // Planet: warm-ish
+ this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`;
+ } else {
+ // Ring: cool-ish
+ this.ctx.fillStyle = `rgba(220, 240, 255, ${alpha})`;
+ }
+
+ // Render as small rectangles (faster than arc)
+ this.ctx.fillRect(x2d, y2d, size, size);
+ }
+ }
+
+ this.animationId = requestAnimationFrame(this.animate);
+ }
+
+ // Stop animations and release resources
+ destroy() {
+ if (this.animationId) {
+ cancelAnimationFrame(this.animationId);
+ this.animationId = 0;
+ }
+ // Intentionally do not null out arrays to allow reuse if desired.
+ }
+}
diff --git a/packages/ui-new/src/lib/tsrs-utils.ts b/packages/ui-new/src/lib/tsrs-utils.ts
new file mode 100644
index 0000000..f48f851
--- /dev/null
+++ b/packages/ui-new/src/lib/tsrs-utils.ts
@@ -0,0 +1,67 @@
+export type Maybe<T> = T | null | undefined;
+
+export function toNumber(
+ value: Maybe<number | bigint | string>,
+ fallback = 0,
+): number {
+ if (value === null || value === undefined) return fallback;
+
+ if (typeof value === "number") {
+ if (Number.isFinite(value)) return value;
+ return fallback;
+ }
+
+ if (typeof value === "bigint") {
+ // safe conversion for typical values (timestamps, sizes). Might overflow for huge bigint.
+ return Number(value);
+ }
+
+ if (typeof value === "string") {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : fallback;
+ }
+
+ return fallback;
+}
+
+/**
+ * Like `toNumber` but ensures non-negative result (clamps at 0).
+ */
+export function toNonNegativeNumber(
+ value: Maybe<number | bigint | string>,
+ fallback = 0,
+): number {
+ const n = toNumber(value, fallback);
+ return n < 0 ? 0 : n;
+}
+
+export function toDate(
+ value: Maybe<number | bigint | string>,
+ opts?: { isSeconds?: boolean },
+): Date | null {
+ if (value === null || value === undefined) return null;
+
+ const isSeconds = opts?.isSeconds ?? true;
+
+ // accept bigint, number, numeric string
+ const n = toNumber(value, NaN);
+ if (Number.isNaN(n)) return null;
+
+ const ms = isSeconds ? Math.floor(n) * 1000 : Math.floor(n);
+ return new Date(ms);
+}
+
+/**
+ * Convert a binding boolean-ish value (0/1, "true"/"false", boolean) to boolean.
+ */
+export function toBoolean(value: unknown, fallback = false): boolean {
+ if (value === null || value === undefined) return fallback;
+ if (typeof value === "boolean") return value;
+ if (typeof value === "number") return value !== 0;
+ if (typeof value === "string") {
+ const s = value.toLowerCase().trim();
+ if (s === "true" || s === "1") return true;
+ if (s === "false" || s === "0") return false;
+ }
+ return fallback;
+}
diff --git a/packages/ui-new/src/lib/utils.ts b/packages/ui-new/src/lib/utils.ts
new file mode 100644
index 0000000..365058c
--- /dev/null
+++ b/packages/ui-new/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/packages/ui-new/src/main.tsx b/packages/ui-new/src/main.tsx
new file mode 100644
index 0000000..e2ae9c2
--- /dev/null
+++ b/packages/ui-new/src/main.tsx
@@ -0,0 +1,48 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import { createHashRouter, RouterProvider } from "react-router";
+import { Toaster } from "./components/ui/sonner";
+import { AssistantView } from "./pages/assistant-view";
+import { HomeView } from "./pages/home-view";
+import { IndexPage } from "./pages/index";
+import { InstancesView } from "./pages/instances-view";
+import { SettingsView } from "./pages/settings-view";
+import { VersionsView } from "./pages/versions-view";
+
+const router = createHashRouter([
+ {
+ path: "/",
+ element: <IndexPage />,
+ children: [
+ {
+ index: true,
+ element: <HomeView />,
+ },
+ {
+ path: "instances",
+ element: <InstancesView />,
+ },
+ {
+ path: "versions",
+ element: <VersionsView />,
+ },
+ {
+ path: "settings",
+ element: <SettingsView />,
+ },
+ {
+ path: "guide",
+ element: <AssistantView />,
+ },
+ ],
+ },
+]);
+
+const root = createRoot(document.getElementById("root") as HTMLElement);
+root.render(
+ <StrictMode>
+ <RouterProvider router={router} />
+ <Toaster />
+ </StrictMode>,
+);
diff --git a/packages/ui-new/src/pages/assistant-view.tsx b/packages/ui-new/src/pages/assistant-view.tsx
new file mode 100644
index 0000000..56f827b
--- /dev/null
+++ b/packages/ui-new/src/pages/assistant-view.tsx
@@ -0,0 +1,485 @@
+import {
+ AlertTriangle,
+ Bot,
+ Brain,
+ ChevronDown,
+ Loader2,
+ RefreshCw,
+ Send,
+ Settings,
+ Trash2,
+} from "lucide-react";
+import { marked } from "marked";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Textarea } from "@/components/ui/textarea";
+import { toNumber } from "@/lib/tsrs-utils";
+import { type Message, useAssistantStore } from "../stores/assistant-store";
+import { useSettingsStore } from "../stores/settings-store";
+import { useUiStore } from "../stores/ui-store";
+
+interface ParsedMessage {
+ thinking: string | null;
+ content: string;
+ isThinking: boolean;
+}
+
+function parseMessageContent(content: string): ParsedMessage {
+ if (!content) return { thinking: null, content: "", isThinking: false };
+
+ // Support both <thinking> and <think> (DeepSeek uses <think>)
+ let startTag = "<thinking>";
+ let endTag = "</thinking>";
+ let startIndex = content.indexOf(startTag);
+
+ if (startIndex === -1) {
+ startTag = "<think>";
+ endTag = "</think>";
+ startIndex = content.indexOf(startTag);
+ }
+
+ // Also check for encoded tags if they weren't decoded properly
+ if (startIndex === -1) {
+ startTag = "\u003cthink\u003e";
+ endTag = "\u003c/think\u003e";
+ startIndex = content.indexOf(startTag);
+ }
+
+ if (startIndex !== -1) {
+ const endIndex = content.indexOf(endTag, startIndex);
+
+ if (endIndex !== -1) {
+ // Completed thinking block
+ const before = content.substring(0, startIndex);
+ const thinking = content
+ .substring(startIndex + startTag.length, endIndex)
+ .trim();
+ const after = content.substring(endIndex + endTag.length);
+
+ return {
+ thinking,
+ content: (before + after).trim(),
+ isThinking: false,
+ };
+ } else {
+ // Incomplete thinking block (still streaming)
+ const before = content.substring(0, startIndex);
+ const thinking = content.substring(startIndex + startTag.length).trim();
+
+ return {
+ thinking,
+ content: before.trim(),
+ isThinking: true,
+ };
+ }
+ }
+
+ return { thinking: null, content, isThinking: false };
+}
+
+function renderMarkdown(content: string): string {
+ if (!content) return "";
+ try {
+ return marked(content, { breaks: true, gfm: true }) as string;
+ } catch {
+ return content;
+ }
+}
+
+export function AssistantView() {
+ const {
+ messages,
+ isProcessing,
+ isProviderHealthy,
+ streamingContent,
+ init,
+ checkHealth,
+ sendMessage,
+ clearHistory,
+ } = useAssistantStore();
+ const { settings } = useSettingsStore();
+ const { setView } = useUiStore();
+
+ const [input, setInput] = useState("");
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+ const messagesContainerRef = useRef<HTMLDivElement>(null);
+
+ const provider = settings.assistant.llmProvider;
+ const endpoint =
+ provider === "ollama"
+ ? settings.assistant.ollamaEndpoint
+ : settings.assistant.openaiEndpoint;
+ const model =
+ provider === "ollama"
+ ? settings.assistant.ollamaModel
+ : settings.assistant.openaiModel;
+
+ const getProviderName = (): string => {
+ if (provider === "ollama") {
+ return `Ollama (${model})`;
+ } else if (provider === "openai") {
+ return `OpenAI (${model})`;
+ }
+ return provider;
+ };
+
+ const getProviderHelpText = (): string => {
+ if (provider === "ollama") {
+ return `Please ensure Ollama is installed and running at ${endpoint}.`;
+ } else if (provider === "openai") {
+ return "Please check your OpenAI API key in Settings > AI Assistant.";
+ }
+ return "";
+ };
+
+ const scrollToBottom = useCallback(() => {
+ if (messagesContainerRef.current) {
+ setTimeout(() => {
+ if (messagesContainerRef.current) {
+ messagesContainerRef.current.scrollTop =
+ messagesContainerRef.current.scrollHeight;
+ }
+ }, 0);
+ }
+ }, []);
+
+ useEffect(() => {
+ init();
+ }, [init]);
+
+ useEffect(() => {
+ if (messages.length > 0 || isProcessing) {
+ scrollToBottom();
+ }
+ }, [messages.length, isProcessing, scrollToBottom]);
+
+ const handleSubmit = async () => {
+ if (!input.trim() || isProcessing) return;
+ const text = input;
+ setInput("");
+ await sendMessage(text, settings.assistant.enabled, provider, endpoint);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ };
+
+ const renderMessage = (message: Message, index: number) => {
+ const isUser = message.role === "user";
+ const parsed = parseMessageContent(message.content);
+
+ return (
+ <div
+ key={index}
+ className={`flex ${isUser ? "justify-end" : "justify-start"} mb-4`}
+ >
+ <div
+ className={`max-w-[80%] rounded-2xl px-4 py-3 ${
+ isUser
+ ? "bg-indigo-500 text-white rounded-br-none"
+ : "bg-zinc-800 text-zinc-100 rounded-bl-none"
+ }`}
+ >
+ {!isUser && parsed.thinking && (
+ <div className="mb-3 max-w-full overflow-hidden">
+ <details className="group" open={parsed.isThinking}>
+ <summary className="list-none cursor-pointer flex items-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors text-xs font-medium select-none bg-black/20 p-2 rounded-lg border border-white/5 w-fit mb-2 outline-none">
+ <Brain className="h-3 w-3" />
+ <span>Thinking Process</span>
+ <ChevronDown className="h-3 w-3 transition-transform duration-200 group-open:rotate-180" />
+ </summary>
+ <div className="pl-3 border-l-2 border-zinc-700 text-zinc-500 text-xs italic leading-relaxed whitespace-pre-wrap font-mono max-h-96 overflow-y-auto custom-scrollbar bg-black/10 p-2 rounded-r-md">
+ {parsed.thinking}
+ {parsed.isThinking && (
+ <span className="inline-block w-1.5 h-3 bg-zinc-500 ml-1 animate-pulse align-middle" />
+ )}
+ </div>
+ </details>
+ </div>
+ )}
+ <div
+ className="prose prose-invert max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: renderMarkdown(parsed.content),
+ }}
+ />
+ {!isUser && message.stats && (
+ <div className="mt-2 pt-2 border-t border-zinc-700/50">
+ <div className="text-xs text-zinc-400">
+ {message.stats.evalCount} tokens ·{" "}
+ {Math.round(toNumber(message.stats.totalDuration) / 1000000)}
+ ms
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ };
+
+ return (
+ <div className="h-full w-full flex flex-col gap-4 p-4 lg:p-8">
+ <div className="flex items-center justify-between mb-2">
+ <div className="flex items-center gap-3">
+ <div className="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
+ <Bot size={24} />
+ </div>
+ <div>
+ <h2 className="text-2xl font-bold">Game Assistant</h2>
+ <p className="text-zinc-400 text-sm">
+ Powered by {getProviderName()}
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {!settings.assistant.enabled ? (
+ <Badge
+ variant="outline"
+ className="bg-zinc-500/10 text-zinc-400 border-zinc-500/20"
+ >
+ <AlertTriangle className="h-3 w-3 mr-1" />
+ Disabled
+ </Badge>
+ ) : !isProviderHealthy ? (
+ <Badge
+ variant="outline"
+ className="bg-red-500/10 text-red-400 border-red-500/20"
+ >
+ <AlertTriangle className="h-3 w-3 mr-1" />
+ Offline
+ </Badge>
+ ) : (
+ <Badge
+ variant="outline"
+ className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
+ >
+ <div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse mr-1" />
+ Online
+ </Badge>
+ )}
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={checkHealth}
+ title="Check Connection"
+ disabled={isProcessing}
+ >
+ <RefreshCw
+ className={`h-4 w-4 ${isProcessing ? "animate-spin" : ""}`}
+ />
+ </Button>
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={clearHistory}
+ title="Clear History"
+ disabled={isProcessing}
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => setView("settings")}
+ title="Settings"
+ >
+ <Settings className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+
+ {/* Chat Area */}
+ <div className="flex-1 bg-black/20 border border-white/5 rounded-xl overflow-hidden flex flex-col relative">
+ {/* Warning when assistant is disabled */}
+ {!settings.assistant.enabled && (
+ <div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-10">
+ <Card className="bg-yellow-500/10 border-yellow-500/20">
+ <CardContent className="p-3 flex items-center gap-2">
+ <AlertTriangle className="h-4 w-4 text-yellow-500" />
+ <span className="text-yellow-500 text-sm font-medium">
+ Assistant is disabled. Enable it in Settings &gt; AI
+ Assistant.
+ </span>
+ </CardContent>
+ </Card>
+ </div>
+ )}
+
+ {/* Provider offline warning */}
+ {settings.assistant.enabled && !isProviderHealthy && (
+ <div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-10">
+ <Card className="bg-red-500/10 border-red-500/20">
+ <CardContent className="p-3 flex items-center gap-2">
+ <AlertTriangle className="h-4 w-4 text-red-500" />
+ <div className="flex flex-col">
+ <span className="text-red-500 text-sm font-medium">
+ Assistant is offline
+ </span>
+ <span className="text-red-400 text-xs">
+ {getProviderHelpText()}
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )}
+
+ {/* Messages Container */}
+ <ScrollArea className="flex-1 p-4 lg:p-6" ref={messagesContainerRef}>
+ {messages.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-full text-zinc-400 gap-4 mt-8">
+ <div className="p-4 bg-zinc-800/50 rounded-full">
+ <Bot className="h-12 w-12" />
+ </div>
+ <h3 className="text-xl font-medium">How can I help you today?</h3>
+ <p className="text-center max-w-md text-sm">
+ I can analyze your game logs, diagnose crashes, or explain mod
+ features.
+ {!settings.assistant.enabled && (
+ <span className="block mt-2 text-yellow-500">
+ Assistant is disabled. Enable it in{" "}
+ <button
+ type="button"
+ onClick={() => setView("settings")}
+ className="text-indigo-400 hover:underline"
+ >
+ Settings &gt; AI Assistant
+ </button>
+ .
+ </span>
+ )}
+ </p>
+ <div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-2 max-w-lg">
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput("How do I fix Minecraft crashing on launch?")
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ How do I fix Minecraft crashing on launch?
+ </div>
+ </Button>
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput("What's the best way to improve FPS?")
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ What's the best way to improve FPS?
+ </div>
+ </Button>
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput(
+ "Can you help me install Fabric for Minecraft 1.20.4?",
+ )
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ Can you help me install Fabric for 1.20.4?
+ </div>
+ </Button>
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput("What mods do you recommend for performance?")
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ What mods do you recommend for performance?
+ </div>
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <>
+ {messages.map((message, index) => renderMessage(message, index))}
+ {isProcessing && streamingContent && (
+ <div className="flex justify-start mb-4">
+ <div className="max-w-[80%] bg-zinc-800 text-zinc-100 rounded-2xl rounded-bl-none px-4 py-3">
+ <div
+ className="prose prose-invert max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: renderMarkdown(streamingContent),
+ }}
+ />
+ <div className="flex items-center gap-1 mt-2 text-xs text-zinc-400">
+ <Loader2 className="h-3 w-3 animate-spin" />
+ <span>Assistant is typing...</span>
+ </div>
+ </div>
+ </div>
+ )}
+ </>
+ )}
+ <div ref={messagesEndRef} />
+ </ScrollArea>
+
+ <Separator />
+
+ {/* Input Area */}
+ <div className="p-3 lg:p-4">
+ <div className="flex gap-2">
+ <Textarea
+ placeholder={
+ settings.assistant.enabled
+ ? "Ask about your game..."
+ : "Assistant is disabled. Enable it in Settings to use."
+ }
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ className="min-h-11 max-h-50 resize-none border-zinc-700 bg-zinc-900/50 focus:bg-zinc-900/80"
+ disabled={!settings.assistant.enabled || isProcessing}
+ />
+ <Button
+ onClick={handleSubmit}
+ disabled={
+ !settings.assistant.enabled || !input.trim() || isProcessing
+ }
+ className="px-6 bg-indigo-600 hover:bg-indigo-700 text-white"
+ >
+ {isProcessing ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Send className="h-4 w-4" />
+ )}
+ </Button>
+ </div>
+ <div className="mt-2 flex items-center justify-between">
+ <div className="text-xs text-zinc-500">
+ {settings.assistant.enabled
+ ? "Press Enter to send, Shift+Enter for new line"
+ : "Enable the assistant in Settings to use"}
+ </div>
+ <div className="text-xs text-zinc-500">
+ Model: {model} • Provider: {provider}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/pages/home-view.tsx b/packages/ui-new/src/pages/home-view.tsx
new file mode 100644
index 0000000..bcee7e6
--- /dev/null
+++ b/packages/ui-new/src/pages/home-view.tsx
@@ -0,0 +1,382 @@
+import { Calendar, ExternalLink } from "lucide-react";
+import { useEffect, useState } from "react";
+import type { SaturnEffect } from "@/lib/effects/SaturnEffect";
+import { useGameStore } from "../stores/game-store";
+import { useReleasesStore } from "../stores/releases-store";
+
+export function HomeView() {
+ const gameStore = useGameStore();
+ const releasesStore = useReleasesStore();
+ const [mouseX, setMouseX] = useState(0);
+ const [mouseY, setMouseY] = useState(0);
+
+ useEffect(() => {
+ releasesStore.loadReleases();
+ }, [releasesStore.loadReleases]);
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ const x = (e.clientX / window.innerWidth) * 2 - 1;
+ const y = (e.clientY / window.innerHeight) * 2 - 1;
+ setMouseX(x);
+ setMouseY(y);
+
+ // Forward mouse move to SaturnEffect (if available) for parallax/rotation interactions
+ try {
+ const saturn = (
+ window as unknown as {
+ getSaturnEffect?: () => SaturnEffect;
+ }
+ ).getSaturnEffect?.();
+ if (saturn?.handleMouseMove) {
+ saturn.handleMouseMove(e.clientX);
+ }
+ } catch {
+ /* best-effort, ignore errors from effect */
+ }
+ };
+
+ const handleSaturnMouseDown = (e: React.MouseEvent) => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseDown) {
+ saturn.handleMouseDown(e.clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnMouseUp = () => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseUp) {
+ saturn.handleMouseUp();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnMouseLeave = () => {
+ // Treat leaving the area as mouse-up for the effect
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseUp) {
+ saturn.handleMouseUp();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnTouchStart = (e: React.TouchEvent) => {
+ if (e.touches && e.touches.length === 1) {
+ try {
+ const clientX = e.touches[0].clientX;
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchStart) {
+ saturn.handleTouchStart(clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ };
+
+ const handleSaturnTouchMove = (e: React.TouchEvent) => {
+ if (e.touches && e.touches.length === 1) {
+ try {
+ const clientX = e.touches[0].clientX;
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchMove) {
+ saturn.handleTouchMove(clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ };
+
+ const handleSaturnTouchEnd = () => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchEnd) {
+ saturn.handleTouchEnd();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ 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"
+ style={{
+ overflow: releasesStore.isLoading ? "hidden" : "auto",
+ }}
+ >
+ {/* Hero Section (Full Height) - Interactive area */}
+ <div
+ role="tab"
+ className="min-h-full flex flex-col justify-end p-12 pb-32 cursor-grab active:cursor-grabbing select-none"
+ onMouseDown={handleSaturnMouseDown}
+ onMouseMove={handleMouseMove}
+ onMouseUp={handleSaturnMouseUp}
+ onMouseLeave={handleSaturnMouseLeave}
+ onTouchStart={handleSaturnTouchStart}
+ onTouchMove={handleSaturnTouchMove}
+ onTouchEnd={handleSaturnTouchEnd}
+ tabIndex={0}
+ >
+ {/* 3D Floating Hero Text */}
+ <div
+ className="transition-transform duration-200 ease-out origin-bottom-left"
+ style={{
+ transform: `perspective(1000px) rotateX(${mouseY * -1}deg) rotateY(${mouseX * 1}deg)`,
+ }}
+ >
+ <div className="flex items-center gap-3 mb-6">
+ <div className="h-px w-12 bg-white/50"></div>
+ <span className="text-xs font-mono font-bold tracking-[0.2em] text-white/50 uppercase">
+ Launcher Active
+ </span>
+ </div>
+
+ <h1 className="text-8xl font-black tracking-tighter text-white mb-6 leading-none">
+ MINECRAFT
+ </h1>
+
+ <div className="flex items-center gap-4">
+ <div className="bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-sm text-xs font-bold uppercase tracking-widest text-white shadow-sm">
+ Java Edition
+ </div>
+ <div className="h-4 w-px bg-white/20"></div>
+ <div className="text-sm text-zinc-400">
+ Latest Release{" "}
+ <span className="text-white font-medium">
+ {gameStore.latestRelease?.id || "..."}
+ </span>
+ </div>
+ </div>
+ </div>
+
+ {/* Action Area */}
+ <div className="mt-8 flex gap-4">
+ <div className="text-zinc-500 text-sm font-mono">
+ &gt; Ready to launch session.
+ </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>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/pages/index.tsx b/packages/ui-new/src/pages/index.tsx
new file mode 100644
index 0000000..180cf0c
--- /dev/null
+++ b/packages/ui-new/src/pages/index.tsx
@@ -0,0 +1,189 @@
+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 hover:scale-105"
+ 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>
+
+ <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/instances-view.tsx b/packages/ui-new/src/pages/instances-view.tsx
new file mode 100644
index 0000000..0c511a1
--- /dev/null
+++ b/packages/ui-new/src/pages/instances-view.tsx
@@ -0,0 +1,370 @@
+import { Copy, Edit2, Plus, Trash2 } from "lucide-react";
+import { useEffect, useState } from "react";
+import InstanceEditorModal from "@/components/instance-editor-modal";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { toNumber } from "@/lib/tsrs-utils";
+import { useInstancesStore } from "@/stores/instances-store";
+import type { Instance } from "../types/bindings/instance";
+
+export function InstancesView() {
+ const instancesStore = useInstancesStore();
+
+ // Modal / UI state
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [showDuplicateModal, setShowDuplicateModal] = useState(false);
+
+ // Selected / editing instance state
+ const [selectedInstance, setSelectedInstance] = useState<Instance | null>(
+ null,
+ );
+ const [editingInstance, setEditingInstance] = useState<Instance | null>(null);
+
+ // Form fields
+ const [newInstanceName, setNewInstanceName] = useState("");
+ const [duplicateName, setDuplicateName] = useState("");
+
+ // Load instances on mount (matches Svelte onMount behavior)
+ useEffect(() => {
+ instancesStore.loadInstances();
+ // instancesStore methods are stable (Zustand); do not add to deps to avoid spurious runs
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [instancesStore.loadInstances]);
+
+ // Handlers to open modals
+ const openCreate = () => {
+ setNewInstanceName("");
+ setShowCreateModal(true);
+ };
+
+ const openEdit = (instance: Instance) => {
+ setEditingInstance({ ...instance });
+ setShowEditModal(true);
+ };
+
+ const openDelete = (instance: Instance) => {
+ setSelectedInstance(instance);
+ setShowDeleteConfirm(true);
+ };
+
+ const openDuplicate = (instance: Instance) => {
+ setSelectedInstance(instance);
+ setDuplicateName(`${instance.name} (Copy)`);
+ setShowDuplicateModal(true);
+ };
+
+ // Confirm actions
+ const confirmCreate = async () => {
+ const name = newInstanceName.trim();
+ if (!name) return;
+ await instancesStore.createInstance(name);
+ setShowCreateModal(false);
+ setNewInstanceName("");
+ };
+
+ const confirmEdit = async () => {
+ if (!editingInstance) return;
+ await instancesStore.updateInstance(editingInstance);
+ setEditingInstance(null);
+ setShowEditModal(false);
+ };
+
+ const confirmDelete = async () => {
+ if (!selectedInstance) return;
+ await instancesStore.deleteInstance(selectedInstance.id);
+ setSelectedInstance(null);
+ setShowDeleteConfirm(false);
+ };
+
+ const confirmDuplicate = async () => {
+ if (!selectedInstance) return;
+ const name = duplicateName.trim();
+ if (!name) return;
+ await instancesStore.duplicateInstance(selectedInstance.id, name);
+ setSelectedInstance(null);
+ setDuplicateName("");
+ setShowDuplicateModal(false);
+ };
+
+ const setActiveInstance = async (id: string) => {
+ await instancesStore.setActiveInstance(id);
+ };
+
+ const formatDate = (timestamp: number): string =>
+ new Date(timestamp * 1000).toLocaleDateString();
+
+ const formatLastPlayed = (timestamp: number): string => {
+ const date = new Date(timestamp * 1000);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days === 0) return "Today";
+ if (days === 1) return "Yesterday";
+ if (days < 7) return `${days} days ago`;
+ return date.toLocaleDateString();
+ };
+
+ return (
+ <div className="h-full flex flex-col gap-4 p-6 overflow-y-auto">
+ <div className="flex items-center justify-between">
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
+ Instances
+ </h1>
+ <Button
+ type="button"
+ onClick={openCreate}
+ className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
+ >
+ <Plus size={18} />
+ Create Instance
+ </Button>
+ </div>
+
+ {instancesStore.instances.length === 0 ? (
+ <div className="flex-1 flex items-center justify-center">
+ <div className="text-center text-gray-500 dark:text-gray-400">
+ <p className="text-lg mb-2">No instances yet</p>
+ <p className="text-sm">Create your first instance to get started</p>
+ </div>
+ </div>
+ ) : (
+ <ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ {instancesStore.instances.map((instance) => {
+ const isActive = instancesStore.activeInstanceId === instance.id;
+
+ return (
+ <li
+ key={instance.id}
+ onClick={() => setActiveInstance(instance.id)}
+ onKeyDown={(e) =>
+ e.key === "Enter" && setActiveInstance(instance.id)
+ }
+ className={`relative p-4 text-left rounded-lg border-2 transition-all cursor-pointer hover:border-blue-500 ${
+ isActive ? "border-blue-500" : "border-transparent"
+ } bg-gray-100 dark:bg-gray-800`}
+ >
+ {/* Instance Icon */}
+ {instance.iconPath ? (
+ <div className="w-12 h-12 mb-3 rounded overflow-hidden">
+ <img
+ src={instance.iconPath}
+ alt={instance.name}
+ className="w-full h-full object-cover"
+ />
+ </div>
+ ) : (
+ <div className="w-12 h-12 mb-3 rounded bg-linear-to-br from-blue-500 to-purple-600 flex items-center justify-center">
+ <span className="text-white font-bold text-lg">
+ {instance.name.charAt(0).toUpperCase()}
+ </span>
+ </div>
+ )}
+
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
+ {instance.name}
+ </h3>
+
+ <div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
+ {instance.versionId ? (
+ <p className="truncate">Version: {instance.versionId}</p>
+ ) : (
+ <p className="text-gray-400">No version selected</p>
+ )}
+
+ {instance.modLoader && (
+ <p className="truncate">
+ Mod Loader:{" "}
+ <span className="capitalize">{instance.modLoader}</span>
+ </p>
+ )}
+
+ <p className="truncate">
+ Created: {formatDate(toNumber(instance.createdAt))}
+ </p>
+
+ {instance.lastPlayed && (
+ <p className="truncate">
+ Last played:{" "}
+ {formatLastPlayed(toNumber(instance.lastPlayed))}
+ </p>
+ )}
+ </div>
+
+ {/* Action Buttons */}
+ <div className="mt-4 flex gap-2">
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openEdit(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-sm transition-colors"
+ >
+ <Edit2 size={14} />
+ Edit
+ </button>
+
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openDuplicate(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-sm transition-colors"
+ >
+ <Copy size={14} />
+ Duplicate
+ </button>
+
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openDelete(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded text-sm transition-colors"
+ >
+ <Trash2 size={14} />
+ Delete
+ </button>
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ )}
+
+ {/* Create Modal */}
+ <Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create Instance</DialogTitle>
+ <DialogDescription>
+ Enter a name for the new instance.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="mt-4">
+ <Input
+ value={newInstanceName}
+ onChange={(e) => setNewInstanceName(e.target.value)}
+ placeholder="Instance name"
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setShowCreateModal(false)}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmCreate}
+ disabled={!newInstanceName.trim()}
+ >
+ Create
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ <InstanceEditorModal
+ open={showEditModal}
+ instance={editingInstance}
+ onOpenChange={(open) => {
+ setShowEditModal(open);
+ if (!open) setEditingInstance(null);
+ }}
+ />
+
+ {/* Delete Confirmation */}
+ <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Instance</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete "{selectedInstance?.name}"? This
+ action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowDeleteConfirm(false);
+ setSelectedInstance(null);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmDelete}
+ className="bg-red-600 text-white hover:bg-red-500"
+ >
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Duplicate Modal */}
+ <Dialog open={showDuplicateModal} onOpenChange={setShowDuplicateModal}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Duplicate Instance</DialogTitle>
+ <DialogDescription>
+ Provide a name for the duplicated instance.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="mt-4">
+ <Input
+ value={duplicateName}
+ onChange={(e) => setDuplicateName(e.target.value)}
+ placeholder="New instance name"
+ onKeyDown={(e) => e.key === "Enter" && confirmDuplicate()}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowDuplicateModal(false);
+ setSelectedInstance(null);
+ setDuplicateName("");
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmDuplicate}
+ disabled={!duplicateName.trim()}
+ >
+ Duplicate
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/pages/settings-view.tsx b/packages/ui-new/src/pages/settings-view.tsx
new file mode 100644
index 0000000..ac43d9b
--- /dev/null
+++ b/packages/ui-new/src/pages/settings-view.tsx
@@ -0,0 +1,1158 @@
+import { open } from "@tauri-apps/plugin-dialog";
+import {
+ Coffee,
+ Download,
+ FileJson,
+ Loader2,
+ RefreshCw,
+ Upload,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Textarea } from "@/components/ui/textarea";
+import { useSettingsStore } from "../stores/settings-store";
+
+const effectOptions = [
+ { value: "saturn", label: "Saturn" },
+ { value: "constellation", label: "Network (Constellation)" },
+];
+
+const logServiceOptions = [
+ { value: "paste.rs", label: "paste.rs (Free, No Account)" },
+ { value: "pastebin.com", label: "pastebin.com (Requires API Key)" },
+];
+
+const llmProviderOptions = [
+ { value: "ollama", label: "Ollama (Local)" },
+ { value: "openai", label: "OpenAI (Remote)" },
+];
+
+const languageOptions = [
+ { value: "auto", label: "Auto (Match User)" },
+ { value: "English", label: "English" },
+ { value: "Chinese", label: "中文" },
+ { value: "Japanese", label: "日本語" },
+ { value: "Korean", label: "한국어" },
+ { value: "Spanish", label: "Español" },
+ { value: "French", label: "Français" },
+ { value: "German", label: "Deutsch" },
+ { value: "Russian", label: "Русский" },
+];
+
+const ttsProviderOptions = [
+ { value: "disabled", label: "Disabled" },
+ { value: "piper", label: "Piper TTS (Local)" },
+ { value: "edge", label: "Edge TTS (Online)" },
+];
+
+const personas = [
+ {
+ value: "default",
+ label: "Minecraft Expert (Default)",
+ prompt:
+ "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.",
+ },
+ {
+ value: "technical",
+ label: "Technical Debugger",
+ prompt:
+ "You are a technical support specialist for Minecraft. Focus strictly on analyzing logs, identifying crash causes, and providing technical solutions. Be precise and avoid conversational filler.",
+ },
+ {
+ value: "concise",
+ label: "Concise Helper",
+ prompt:
+ "You are a direct and concise assistant. Provide answers in as few words as possible while remaining accurate. Use bullet points for lists.",
+ },
+ {
+ value: "explain",
+ label: "Teacher / Explainer",
+ prompt:
+ "You are a patient teacher. Explain Minecraft concepts, redstone mechanics, and mod features in simple, easy-to-understand terms suitable for beginners.",
+ },
+ {
+ value: "pirate",
+ label: "Pirate Captain",
+ prompt:
+ "You are a salty Minecraft Pirate Captain! Yarr! Speak like a pirate while helping the crew (the user) with their blocky adventures. Use terms like 'matey', 'landlubber', and 'treasure'.",
+ },
+];
+
+export function SettingsView() {
+ const {
+ settings,
+ backgroundUrl,
+ javaInstallations,
+ isDetectingJava,
+ showJavaDownloadModal,
+ selectedDownloadSource,
+ javaCatalog,
+ isLoadingCatalog,
+ catalogError,
+ selectedMajorVersion,
+ selectedImageType,
+ showOnlyRecommended,
+ searchQuery,
+ isDownloadingJava,
+ downloadProgress,
+ javaDownloadStatus,
+ pendingDownloads,
+ ollamaModels,
+ openaiModels,
+ isLoadingOllamaModels,
+ isLoadingOpenaiModels,
+ ollamaModelsError,
+ openaiModelsError,
+ showConfigEditor,
+ rawConfigContent,
+ configFilePath,
+ configEditorError,
+ filteredReleases,
+ availableMajorVersions,
+ installStatus,
+ selectedRelease,
+ currentModelOptions,
+ loadSettings,
+ saveSettings,
+ detectJava,
+ selectJava,
+ openJavaDownloadModal,
+ closeJavaDownloadModal,
+ loadJavaCatalog,
+ refreshCatalog,
+ loadPendingDownloads,
+ selectMajorVersion,
+ downloadJava,
+ cancelDownload,
+ resumeDownloads,
+ openConfigEditor,
+ closeConfigEditor,
+ saveRawConfig,
+ loadOllamaModels,
+ loadOpenaiModels,
+ set,
+ setSetting,
+ setAssistantSetting,
+ setFeatureFlag,
+ } = useSettingsStore();
+
+ // Mark potentially-unused variables as referenced so TypeScript does not report
+ // them as unused in this file (they are part of the store API and used elsewhere).
+ // This is a no-op but satisfies the compiler.
+ void selectedDownloadSource;
+ void javaCatalog;
+ void javaDownloadStatus;
+ void pendingDownloads;
+ void ollamaModels;
+ void openaiModels;
+ void isLoadingOllamaModels;
+ void isLoadingOpenaiModels;
+ void ollamaModelsError;
+ void openaiModelsError;
+ void selectedRelease;
+ void loadJavaCatalog;
+ void loadPendingDownloads;
+ void cancelDownload;
+ void resumeDownloads;
+ void setFeatureFlag;
+ const [selectedPersona, setSelectedPersona] = useState("default");
+ const [migrating, setMigrating] = useState(false);
+ const [activeTab, setActiveTab] = useState("appearance");
+
+ useEffect(() => {
+ loadSettings();
+ detectJava();
+ }, [loadSettings, detectJava]);
+
+ useEffect(() => {
+ if (activeTab === "assistant") {
+ if (settings.assistant.llmProvider === "ollama") {
+ loadOllamaModels();
+ } else if (settings.assistant.llmProvider === "openai") {
+ loadOpenaiModels();
+ }
+ }
+ }, [
+ activeTab,
+ settings.assistant.llmProvider,
+ loadOllamaModels,
+ loadOpenaiModels,
+ ]);
+
+ const handleSelectBackground = async () => {
+ try {
+ const selected = await open({
+ multiple: false,
+ filters: [
+ {
+ name: "Images",
+ extensions: ["png", "jpg", "jpeg", "webp", "gif"],
+ },
+ ],
+ });
+
+ if (selected && typeof selected === "string") {
+ setSetting("customBackgroundPath", selected);
+ saveSettings();
+ }
+ } catch (e) {
+ console.error("Failed to select background:", e);
+ toast.error("Failed to select background");
+ }
+ };
+
+ const handleClearBackground = () => {
+ setSetting("customBackgroundPath", null);
+ saveSettings();
+ };
+
+ const handleApplyPersona = (value: string) => {
+ const persona = personas.find((p) => p.value === value);
+ if (persona) {
+ setAssistantSetting("systemPrompt", persona.prompt);
+ setSelectedPersona(value);
+ saveSettings();
+ }
+ };
+
+ const handleResetSystemPrompt = () => {
+ const defaultPersona = personas.find((p) => p.value === "default");
+ if (defaultPersona) {
+ setAssistantSetting("systemPrompt", defaultPersona.prompt);
+ setSelectedPersona("default");
+ saveSettings();
+ }
+ };
+
+ const handleRunMigration = async () => {
+ if (migrating) return;
+ setMigrating(true);
+ try {
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ toast.success("Migration complete! Files migrated successfully");
+ } catch (e) {
+ console.error("Migration failed:", e);
+ toast.error(`Migration failed: ${e}`);
+ } finally {
+ setMigrating(false);
+ }
+ };
+
+ return (
+ <div className="h-full flex flex-col p-6 overflow-hidden">
+ <div className="flex items-center justify-between mb-6">
+ <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={openConfigEditor}
+ className="gap-2"
+ >
+ <FileJson className="h-4 w-4" />
+ <span className="hidden sm:inline">Open JSON</span>
+ </Button>
+ </div>
+
+ <Tabs
+ value={activeTab}
+ onValueChange={setActiveTab}
+ className="flex-1 overflow-hidden"
+ >
+ <TabsList className="grid grid-cols-4 mb-6">
+ <TabsTrigger value="appearance">Appearance</TabsTrigger>
+ <TabsTrigger value="java">Java</TabsTrigger>
+ <TabsTrigger value="advanced">Advanced</TabsTrigger>
+ <TabsTrigger value="assistant">Assistant</TabsTrigger>
+ </TabsList>
+
+ <ScrollArea className="flex-1 pr-2">
+ <TabsContent value="appearance" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Appearance</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-3">Custom Background Image</Label>
+ <div className="flex items-center gap-6">
+ <div className="w-40 h-24 rounded-xl overflow-hidden bg-secondary border relative group shadow-lg">
+ {backgroundUrl ? (
+ <img
+ src={backgroundUrl}
+ alt="Background Preview"
+ className="w-full h-full object-cover"
+ onError={(e) => {
+ console.error("Failed to load image");
+ e.currentTarget.style.display = "none";
+ }}
+ />
+ ) : (
+ <div className="w-full h-full bg-linear-to-br from-emerald-900 via-zinc-900 to-indigo-950" />
+ )}
+ {!backgroundUrl && (
+ <div className="absolute inset-0 flex items-center justify-center text-xs text-white/50 bg-black/20">
+ Default Gradient
+ </div>
+ )}
+ </div>
+
+ <div className="flex flex-col gap-2">
+ <Button
+ variant="outline"
+ onClick={handleSelectBackground}
+ >
+ Select Image
+ </Button>
+ {backgroundUrl && (
+ <Button
+ variant="ghost"
+ className="text-red-500"
+ onClick={handleClearBackground}
+ >
+ Reset to Default
+ </Button>
+ )}
+ </div>
+ </div>
+ <p className="text-sm text-muted-foreground mt-3">
+ Select an image from your computer to replace the default
+ gradient background.
+ </p>
+ </div>
+
+ <Separator />
+
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Visual Effects</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable particle effects and animated gradients.
+ </p>
+ </div>
+ <Switch
+ checked={settings.enableVisualEffects}
+ onCheckedChange={(checked) => {
+ setSetting("enableVisualEffects", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {settings.enableVisualEffects && (
+ <div className="pl-4 border-l-2 border-border">
+ <div className="space-y-2">
+ <Label>Theme Effect</Label>
+ <Select
+ value={settings.activeEffect}
+ onValueChange={(value) => {
+ setSetting("activeEffect", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger className="w-52">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {effectOptions.map((option) => (
+ <SelectItem
+ key={option.value}
+ value={option.value}
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <p className="text-sm text-muted-foreground">
+ Select the active visual theme.
+ </p>
+ </div>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">GPU Acceleration</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable GPU acceleration for the interface.
+ </p>
+ </div>
+ <Switch
+ checked={settings.enableGpuAcceleration}
+ onCheckedChange={(checked) => {
+ setSetting("enableGpuAcceleration", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="java" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Java Environment</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-2">Java Path</Label>
+ <div className="flex gap-2">
+ <Input
+ value={settings.javaPath}
+ onChange={(e) => setSetting("javaPath", e.target.value)}
+ className="flex-1"
+ placeholder="java or full path to java executable"
+ />
+ <Button
+ variant="outline"
+ onClick={() => detectJava()}
+ disabled={isDetectingJava}
+ >
+ {isDetectingJava ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ "Detect"
+ )}
+ </Button>
+ </div>
+ <p className="text-sm text-muted-foreground mt-2">
+ Path to Java executable.
+ </p>
+ </div>
+
+ <div>
+ <Label className="mb-2">Memory Settings (MB)</Label>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label htmlFor="min-memory" className="text-sm">
+ Minimum Memory
+ </Label>
+ <Input
+ id="min-memory"
+ type="number"
+ value={settings.minMemory}
+ onChange={(e) =>
+ setSetting(
+ "minMemory",
+ parseInt(e.target.value, 10) || 1024,
+ )
+ }
+ min={512}
+ step={256}
+ />
+ </div>
+ <div>
+ <Label htmlFor="max-memory" className="text-sm">
+ Maximum Memory
+ </Label>
+ <Input
+ id="max-memory"
+ type="number"
+ value={settings.maxMemory}
+ onChange={(e) =>
+ setSetting(
+ "maxMemory",
+ parseInt(e.target.value, 10) || 2048,
+ )
+ }
+ min={1024}
+ step={256}
+ />
+ </div>
+ </div>
+ <p className="text-sm text-muted-foreground mt-2">
+ Memory allocation for Minecraft.
+ </p>
+ </div>
+
+ <Separator />
+
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <Label className="text-base">
+ Detected Java Installations
+ </Label>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => detectJava()}
+ disabled={isDetectingJava}
+ >
+ <RefreshCw
+ className={`h-4 w-4 mr-2 ${isDetectingJava ? "animate-spin" : ""}`}
+ />
+ Rescan
+ </Button>
+ </div>
+
+ {javaInstallations.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground border rounded-lg">
+ <Coffee className="h-12 w-12 mx-auto mb-4 opacity-30" />
+ <p>No Java installations detected</p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {javaInstallations.map((installation) => (
+ <Card
+ key={installation.path}
+ className={`p-3 cursor-pointer transition-colors ${
+ settings.javaPath === installation.path
+ ? "border-primary bg-primary/5"
+ : ""
+ }`}
+ onClick={() => selectJava(installation.path)}
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-medium flex items-center gap-2">
+ <Coffee className="h-4 w-4" />
+ {installation.version}
+ </div>
+ <div className="text-sm text-muted-foreground font-mono">
+ {installation.path}
+ </div>
+ </div>
+ {settings.javaPath === installation.path && (
+ <div className="h-5 w-5 text-primary">✓</div>
+ )}
+ </div>
+ </Card>
+ ))}
+ </div>
+ )}
+
+ <div className="mt-4">
+ <Button
+ variant="default"
+ className="w-full"
+ onClick={openJavaDownloadModal}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ Download Java
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="advanced" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Advanced Settings</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-2">Download Threads</Label>
+ <Input
+ type="number"
+ value={settings.downloadThreads}
+ onChange={(e) =>
+ setSetting(
+ "downloadThreads",
+ parseInt(e.target.value, 10) || 32,
+ )
+ }
+ min={1}
+ max={64}
+ />
+ <p className="text-sm text-muted-foreground mt-2">
+ Number of concurrent downloads.
+ </p>
+ </div>
+
+ <div>
+ <Label className="mb-2">Log Upload Service</Label>
+ <Select
+ value={settings.logUploadService}
+ onValueChange={(value) => {
+ setSetting("logUploadService", value as any);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {logServiceOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {settings.logUploadService === "pastebin.com" && (
+ <div>
+ <Label className="mb-2">Pastebin API Key</Label>
+ <Input
+ type="password"
+ value={settings.pastebinApiKey || ""}
+ onChange={(e) =>
+ setSetting("pastebinApiKey", e.target.value || null)
+ }
+ placeholder="Enter your Pastebin API key"
+ />
+ </div>
+ )}
+
+ <Separator />
+
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Use Shared Caches</Label>
+ <p className="text-sm text-muted-foreground">
+ Share downloaded assets between instances.
+ </p>
+ </div>
+ <Switch
+ checked={settings.useSharedCaches}
+ onCheckedChange={(checked) => {
+ setSetting("useSharedCaches", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {!settings.useSharedCaches && (
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">
+ Keep Legacy Per-Instance Storage
+ </Label>
+ <p className="text-sm text-muted-foreground">
+ Maintain separate cache folders for compatibility.
+ </p>
+ </div>
+ <Switch
+ checked={settings.keepLegacyPerInstanceStorage}
+ onCheckedChange={(checked) => {
+ setSetting("keepLegacyPerInstanceStorage", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+ )}
+
+ {settings.useSharedCaches && (
+ <div className="mt-4">
+ <Button
+ variant="outline"
+ className="w-full"
+ onClick={handleRunMigration}
+ disabled={migrating}
+ >
+ {migrating ? (
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ ) : (
+ <Upload className="h-4 w-4 mr-2" />
+ )}
+ {migrating
+ ? "Migrating..."
+ : "Migrate to Shared Caches"}
+ </Button>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="assistant" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">AI Assistant</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Enable Assistant</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable the AI assistant for help with Minecraft issues.
+ </p>
+ </div>
+ <Switch
+ checked={settings.assistant.enabled}
+ onCheckedChange={(checked) => {
+ setAssistantSetting("enabled", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {settings.assistant.enabled && (
+ <>
+ <div>
+ <Label className="mb-2">LLM Provider</Label>
+ <Select
+ value={settings.assistant.llmProvider}
+ onValueChange={(value) => {
+ setAssistantSetting("llmProvider", value as any);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {llmProviderOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Model</Label>
+ <Select
+ value={
+ settings.assistant.llmProvider === "ollama"
+ ? settings.assistant.ollamaModel
+ : settings.assistant.openaiModel
+ }
+ onValueChange={(value) => {
+ if (settings.assistant.llmProvider === "ollama") {
+ setAssistantSetting("ollamaModel", value);
+ } else {
+ setAssistantSetting("openaiModel", value);
+ }
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {currentModelOptions.map((model) => (
+ <SelectItem key={model.value} value={model.value}>
+ {model.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {settings.assistant.llmProvider === "ollama" && (
+ <div>
+ <Label className="mb-2">Ollama Endpoint</Label>
+ <Input
+ value={settings.assistant.ollamaEndpoint}
+ onChange={(e) => {
+ setAssistantSetting(
+ "ollamaEndpoint",
+ e.target.value,
+ );
+ saveSettings();
+ }}
+ placeholder="http://localhost:11434"
+ />
+ </div>
+ )}
+
+ {settings.assistant.llmProvider === "openai" && (
+ <>
+ <div>
+ <Label className="mb-2">OpenAI API Key</Label>
+ <Input
+ type="password"
+ value={settings.assistant.openaiApiKey || ""}
+ onChange={(e) => {
+ setAssistantSetting(
+ "openaiApiKey",
+ e.target.value || null,
+ );
+ saveSettings();
+ }}
+ placeholder="Enter your OpenAI API key"
+ />
+ </div>
+ <div>
+ <Label className="mb-2">OpenAI Endpoint</Label>
+ <Input
+ value={settings.assistant.openaiEndpoint}
+ onChange={(e) => {
+ setAssistantSetting(
+ "openaiEndpoint",
+ e.target.value,
+ );
+ saveSettings();
+ }}
+ placeholder="https://api.openai.com/v1"
+ />
+ </div>
+ </>
+ )}
+
+ <div>
+ <Label className="mb-2">Response Language</Label>
+ <Select
+ value={settings.assistant.responseLanguage}
+ onValueChange={(value) => {
+ setAssistantSetting("responseLanguage", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {languageOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Assistant Persona</Label>
+ <Select
+ value={selectedPersona}
+ onValueChange={handleApplyPersona}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {personas.map((persona) => (
+ <SelectItem
+ key={persona.value}
+ value={persona.value}
+ >
+ {persona.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <div className="mt-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleResetSystemPrompt}
+ >
+ Reset to Default
+ </Button>
+ </div>
+ </div>
+
+ <div>
+ <Label className="mb-2">System Prompt</Label>
+
+ <Textarea
+ value={settings.assistant.systemPrompt}
+ onChange={(e) => {
+ setAssistantSetting("systemPrompt", e.target.value);
+ saveSettings();
+ }}
+ rows={6}
+ className="font-mono text-sm"
+ />
+ </div>
+
+ <div>
+ <Label className="mb-2">Text-to-Speech</Label>
+
+ <Select
+ value={settings.assistant.ttsProvider}
+ onValueChange={(value) => {
+ setAssistantSetting("ttsProvider", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+
+ <SelectContent>
+ {ttsProviderOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </ScrollArea>
+ </Tabs>
+
+ {/* Java Download Modal */}
+ <Dialog
+ open={showJavaDownloadModal}
+ onOpenChange={closeJavaDownloadModal}
+ >
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Download Java</DialogTitle>
+ <DialogDescription>
+ Download and install Java for Minecraft.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-4">
+ <div>
+ <Label className="mb-2">Java Version</Label>
+ <Select
+ value={selectedMajorVersion?.toString() || ""}
+ onValueChange={(v) => selectMajorVersion(parseInt(v, 10))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select version" />
+ </SelectTrigger>
+ <SelectContent>
+ {availableMajorVersions.map((version) => (
+ <SelectItem key={version} value={version.toString()}>
+ Java {version}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Type</Label>
+ <Select
+ value={selectedImageType}
+ onValueChange={(v) => set({ selectedImageType: v as any })}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="jre">JRE (Runtime)</SelectItem>
+ <SelectItem value="jdk">JDK (Development)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="recommended"
+ checked={showOnlyRecommended}
+ onCheckedChange={(checked) =>
+ set({ showOnlyRecommended: !!checked })
+ }
+ />
+ <Label htmlFor="recommended">Show only LTS/Recommended</Label>
+ </div>
+
+ <div>
+ <Label className="mb-2">Search</Label>
+ <Input
+ placeholder="Search versions..."
+ value={searchQuery}
+ onChange={(e) => set({ searchQuery: e.target.value })}
+ />
+ </div>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={refreshCatalog}
+ disabled={isLoadingCatalog}
+ >
+ <RefreshCw
+ className={`h-4 w-4 mr-2 ${isLoadingCatalog ? "animate-spin" : ""}`}
+ />
+ Refresh Catalog
+ </Button>
+ </div>
+
+ <div className="md:col-span-2">
+ <ScrollArea className="h-75 pr-4">
+ {isLoadingCatalog ? (
+ <div className="flex items-center justify-center h-full">
+ <Loader2 className="h-8 w-8 animate-spin" />
+ </div>
+ ) : catalogError ? (
+ <div className="text-red-500 p-4">{catalogError}</div>
+ ) : filteredReleases.length === 0 ? (
+ <div className="text-muted-foreground p-4 text-center">
+ No Java versions found
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredReleases.map((release) => {
+ const status = installStatus(
+ release.majorVersion,
+ release.imageType,
+ );
+ return (
+ <Card
+ key={`${release.majorVersion}-${release.imageType}`}
+ className="p-3 cursor-pointer hover:bg-accent"
+ onClick={() =>
+ selectMajorVersion(release.majorVersion)
+ }
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-medium">
+ Java {release.majorVersion}{" "}
+ {release.imageType.toUpperCase()}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {release.releaseName} • {release.architecture}{" "}
+ {release.architecture}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {release.isLts && (
+ <Badge variant="secondary">LTS</Badge>
+ )}
+ {status === "installed" && (
+ <Badge variant="default">Installed</Badge>
+ )}
+ {status === "available" && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation();
+ selectMajorVersion(release.majorVersion);
+ downloadJava();
+ }}
+ >
+ <Download className="h-3 w-3 mr-1" />
+ Download
+ </Button>
+ )}
+ </div>
+ </div>
+ </Card>
+ );
+ })}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+ </div>
+
+ {isDownloadingJava && downloadProgress && (
+ <div className="mt-4 p-4 border rounded-lg">
+ <div className="flex justify-between items-center mb-2">
+ <span className="text-sm font-medium">
+ {downloadProgress.fileName}
+ </span>
+ <span className="text-sm text-muted-foreground">
+ {Math.round(downloadProgress.percentage)}%
+ </span>
+ </div>
+ <div className="w-full bg-secondary h-2 rounded-full overflow-hidden">
+ <div
+ className="bg-primary h-full transition-all duration-300"
+ style={{ width: `${downloadProgress.percentage}%` }}
+ />
+ </div>
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={closeJavaDownloadModal}
+ disabled={isDownloadingJava}
+ >
+ Cancel
+ </Button>
+ {selectedMajorVersion && (
+ <Button
+ onClick={() => downloadJava()}
+ disabled={isDownloadingJava}
+ >
+ {isDownloadingJava ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Downloading...
+ </>
+ ) : (
+ <>
+ <Download className="mr-2 h-4 w-4" />
+ Download Java {selectedMajorVersion}
+ </>
+ )}
+ </Button>
+ )}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Config Editor Modal */}
+ <Dialog open={showConfigEditor} onOpenChange={closeConfigEditor}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Edit Configuration</DialogTitle>
+ <DialogDescription>
+ Edit the raw JSON configuration file.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="text-sm text-muted-foreground mb-2">
+ File: {configFilePath}
+ </div>
+
+ {configEditorError && (
+ <div className="text-red-500 p-3 bg-red-50 dark:bg-red-950/30 rounded-md">
+ {configEditorError}
+ </div>
+ )}
+
+ <Textarea
+ value={rawConfigContent}
+ onChange={(e) => set({ rawConfigContent: e.target.value })}
+ className="font-mono text-sm h-100 resize-none"
+ spellCheck={false}
+ />
+
+ <DialogFooter>
+ <Button variant="outline" onClick={closeConfigEditor}>
+ Cancel
+ </Button>
+ <Button onClick={() => saveRawConfig()}>Save Changes</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/pages/versions-view.tsx b/packages/ui-new/src/pages/versions-view.tsx
new file mode 100644
index 0000000..7f44611
--- /dev/null
+++ b/packages/ui-new/src/pages/versions-view.tsx
@@ -0,0 +1,662 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { Coffee, Loader2, Search, Trash2 } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useGameStore } from "../stores/game-store";
+import { useInstancesStore } from "../stores/instances-store";
+import type { Version } from "../types/bindings/manifest";
+
+interface InstalledModdedVersion {
+ id: string;
+ javaVersion?: number;
+}
+
+type TypeFilter = "all" | "release" | "snapshot" | "installed";
+
+export function VersionsView() {
+ const { versions, selectedVersion, loadVersions, setSelectedVersion } =
+ useGameStore();
+ const { activeInstanceId } = useInstancesStore();
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
+ const [installedModdedVersions, setInstalledModdedVersions] = useState<
+ InstalledModdedVersion[]
+ >([]);
+ const [, setIsLoadingModded] = useState(false);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [selectedVersionMetadata, setSelectedVersionMetadata] = useState<{
+ id: string;
+ javaVersion?: number;
+ isInstalled: boolean;
+ } | null>(null);
+ const [isLoadingMetadata, setIsLoadingMetadata] = useState(false);
+ const [showModLoaderSelector, setShowModLoaderSelector] = useState(false);
+
+ const normalizedQuery = searchQuery.trim().toLowerCase().replace(/。/g, ".");
+
+ // Load installed modded versions with Java version info
+ const loadInstalledModdedVersions = useCallback(async () => {
+ if (!activeInstanceId) {
+ setInstalledModdedVersions([]);
+ setIsLoadingModded(false);
+ return;
+ }
+
+ setIsLoadingModded(true);
+ try {
+ const allInstalled = await invoke<Array<{ id: string; type: string }>>(
+ "list_installed_versions",
+ { instanceId: activeInstanceId },
+ );
+
+ const moddedIds = allInstalled
+ .filter((v) => v.type === "fabric" || v.type === "forge")
+ .map((v) => v.id);
+
+ const versionsWithJava = await Promise.all(
+ moddedIds.map(async (id) => {
+ try {
+ const javaVersion = await invoke<number | null>(
+ "get_version_java_version",
+ {
+ instanceId: activeInstanceId,
+ versionId: id,
+ },
+ );
+ return {
+ id,
+ javaVersion: javaVersion ?? undefined,
+ };
+ } catch (e) {
+ console.error(`Failed to get Java version for ${id}:`, e);
+ return { id, javaVersion: undefined };
+ }
+ }),
+ );
+
+ setInstalledModdedVersions(versionsWithJava);
+ } catch (e) {
+ console.error("Failed to load installed modded versions:", e);
+ toast.error("Error loading modded versions");
+ } finally {
+ setIsLoadingModded(false);
+ }
+ }, [activeInstanceId]);
+
+ // Combined versions list (vanilla + modded)
+ const allVersions = (() => {
+ const moddedVersions: Version[] = installedModdedVersions.map((v) => {
+ const versionType = v.id.startsWith("fabric-loader-")
+ ? "fabric"
+ : v.id.includes("-forge-")
+ ? "forge"
+ : "fabric";
+ return {
+ id: v.id,
+ type: versionType,
+ url: "",
+ time: "",
+ releaseTime: new Date().toISOString(),
+ javaVersion: BigInt(v.javaVersion ?? 0),
+ isInstalled: true,
+ };
+ });
+ return [...moddedVersions, ...versions];
+ })();
+
+ // Filter versions based on search and type filter
+ const filteredVersions = allVersions.filter((version) => {
+ if (typeFilter === "release" && version.type !== "release") return false;
+ if (typeFilter === "snapshot" && version.type !== "snapshot") return false;
+ if (typeFilter === "installed" && !version.isInstalled) return false;
+
+ if (
+ normalizedQuery &&
+ !version.id.toLowerCase().includes(normalizedQuery)
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+
+ // Get version badge styling
+ const getVersionBadge = (type: string) => {
+ switch (type) {
+ case "release":
+ return {
+ text: "Release",
+ variant: "default" as const,
+ className: "bg-emerald-500 hover:bg-emerald-600",
+ };
+ case "snapshot":
+ return {
+ text: "Snapshot",
+ variant: "secondary" as const,
+ className: "bg-amber-500 hover:bg-amber-600",
+ };
+ case "fabric":
+ return {
+ text: "Fabric",
+ variant: "outline" as const,
+ className: "border-indigo-500 text-indigo-700 dark:text-indigo-300",
+ };
+ case "forge":
+ return {
+ text: "Forge",
+ variant: "outline" as const,
+ className: "border-orange-500 text-orange-700 dark:text-orange-300",
+ };
+ case "modpack":
+ return {
+ text: "Modpack",
+ variant: "outline" as const,
+ className: "border-purple-500 text-purple-700 dark:text-purple-300",
+ };
+ default:
+ return {
+ text: type,
+ variant: "outline" as const,
+ className: "border-gray-500 text-gray-700 dark:text-gray-300",
+ };
+ }
+ };
+
+ // Load version metadata
+ const loadVersionMetadata = useCallback(
+ async (versionId: string) => {
+ if (!versionId || !activeInstanceId) {
+ setSelectedVersionMetadata(null);
+ return;
+ }
+
+ setIsLoadingMetadata(true);
+ try {
+ const metadata = await invoke<{
+ id: string;
+ javaVersion?: number;
+ isInstalled: boolean;
+ }>("get_version_metadata", {
+ instanceId: activeInstanceId,
+ versionId,
+ });
+ setSelectedVersionMetadata(metadata);
+ } catch (e) {
+ console.error("Failed to load version metadata:", e);
+ setSelectedVersionMetadata(null);
+ } finally {
+ setIsLoadingMetadata(false);
+ }
+ },
+ [activeInstanceId],
+ );
+
+ // Get base version for mod loader selector
+ const selectedBaseVersion = (() => {
+ if (!selectedVersion) return "";
+
+ if (selectedVersion.startsWith("fabric-loader-")) {
+ const parts = selectedVersion.split("-");
+ return parts[parts.length - 1];
+ }
+ if (selectedVersion.includes("-forge-")) {
+ return selectedVersion.split("-forge-")[0];
+ }
+
+ const version = versions.find((v) => v.id === selectedVersion);
+ return version ? selectedVersion : "";
+ })();
+
+ // Handle version deletion
+ const handleDeleteVersion = async () => {
+ if (!versionToDelete || !activeInstanceId) return;
+
+ setIsDeleting(true);
+ try {
+ await invoke("delete_version", {
+ instanceId: activeInstanceId,
+ versionId: versionToDelete,
+ });
+
+ if (selectedVersion === versionToDelete) {
+ setSelectedVersion("");
+ }
+
+ setShowDeleteDialog(false);
+ setVersionToDelete(null);
+ toast.success("Version deleted successfully");
+
+ await loadVersions(activeInstanceId);
+ await loadInstalledModdedVersions();
+ } catch (e) {
+ console.error("Failed to delete version:", e);
+ toast.error(`Failed to delete version: ${e}`);
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ // Show delete confirmation dialog
+ const showDeleteConfirmation = (versionId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ setVersionToDelete(versionId);
+ setShowDeleteDialog(true);
+ };
+
+ // Setup event listeners for version updates
+ useEffect(() => {
+ let unlisteners: UnlistenFn[] = [];
+
+ const setupEventListeners = async () => {
+ try {
+ const versionDeletedUnlisten = await listen(
+ "version-deleted",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const downloadCompleteUnlisten = await listen(
+ "download-complete",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const versionInstalledUnlisten = await listen(
+ "version-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const fabricInstalledUnlisten = await listen(
+ "fabric-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const forgeInstalledUnlisten = await listen(
+ "forge-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ unlisteners = [
+ versionDeletedUnlisten,
+ downloadCompleteUnlisten,
+ versionInstalledUnlisten,
+ fabricInstalledUnlisten,
+ forgeInstalledUnlisten,
+ ];
+ } catch (e) {
+ console.error("Failed to setup event listeners:", e);
+ }
+ };
+
+ setupEventListeners();
+ loadInstalledModdedVersions();
+
+ return () => {
+ unlisteners.forEach((unlisten) => {
+ unlisten();
+ });
+ };
+ }, [activeInstanceId, loadVersions, loadInstalledModdedVersions]);
+
+ // Load metadata when selected version changes
+ useEffect(() => {
+ if (selectedVersion) {
+ loadVersionMetadata(selectedVersion);
+ } else {
+ setSelectedVersionMetadata(null);
+ }
+ }, [selectedVersion, loadVersionMetadata]);
+
+ return (
+ <div className="h-full flex flex-col p-6 overflow-hidden">
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/60">
+ Version Manager
+ </h2>
+ <div className="text-sm dark:text-white/40 text-black/50">
+ Select a version to play or modify
+ </div>
+ </div>
+
+ <div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 overflow-hidden">
+ {/* Left: Version List */}
+ <div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
+ {/* Search and Filters */}
+ <div className="flex gap-3">
+ <div className="relative flex-1">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ type="text"
+ placeholder="Search versions..."
+ className="pl-9 bg-white/60 dark:bg-black/20 border-black/10 dark:border-white/10 dark:text-white text-gray-900 placeholder-black/30 dark:placeholder-white/30 focus:border-indigo-500/50 dark:focus:bg-black/40 focus:bg-white/80 backdrop-blur-sm"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+ </div>
+
+ {/* Type Filter Tabs */}
+ <Tabs
+ value={typeFilter}
+ onValueChange={(v) => setTypeFilter(v as TypeFilter)}
+ className="w-full"
+ >
+ <TabsList className="grid grid-cols-4 bg-white/60 dark:bg-black/20 border-black/5 dark:border-white/5">
+ <TabsTrigger value="all">All</TabsTrigger>
+ <TabsTrigger value="release">Release</TabsTrigger>
+ <TabsTrigger value="snapshot">Snapshot</TabsTrigger>
+ <TabsTrigger value="installed">Installed</TabsTrigger>
+ </TabsList>
+ </Tabs>
+
+ {/* Version List */}
+ <ScrollArea className="flex-1 pr-2">
+ {versions.length === 0 ? (
+ <div className="flex items-center justify-center h-40 dark:text-white/30 text-black/30 italic animate-pulse">
+ Loading versions...
+ </div>
+ ) : filteredVersions.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-40 dark:text-white/30 text-black/30 gap-2">
+ <span className="text-2xl">👻</span>
+ <span>No matching versions found</span>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredVersions.map((version) => {
+ const badge = getVersionBadge(version.type);
+ const isSelected = selectedVersion === version.id;
+
+ return (
+ <Card
+ key={version.id}
+ className={`w-full cursor-pointer transition-all duration-200 relative overflow-hidden group ${
+ isSelected
+ ? "border-indigo-200 dark:border-indigo-500/50 bg-indigo-50 dark:bg-indigo-600/20 shadow-[0_0_20px_rgba(99,102,241,0.2)]"
+ : "border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/10 dark:hover:border-white/10 hover:translate-x-1"
+ }`}
+ onClick={() => setSelectedVersion(version.id)}
+ >
+ {isSelected && (
+ <div className="absolute inset-0 bg-linear-to-r from-indigo-500/10 to-transparent pointer-events-none" />
+ )}
+
+ <CardContent className="p-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4 flex-1">
+ <Badge
+ variant={badge.variant}
+ className={badge.className}
+ >
+ {badge.text}
+ </Badge>
+ <div className="flex-1">
+ <div
+ className={`font-bold font-mono text-lg tracking-tight ${
+ isSelected
+ ? "text-black dark:text-white"
+ : "text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white"
+ }`}
+ >
+ {version.id}
+ </div>
+ <div className="flex items-center gap-2 mt-0.5">
+ {version.releaseTime &&
+ version.type !== "fabric" &&
+ version.type !== "forge" && (
+ <div className="text-xs dark:text-white/30 text-black/30">
+ {new Date(
+ version.releaseTime,
+ ).toLocaleDateString()}
+ </div>
+ )}
+ {version.javaVersion && (
+ <div className="flex items-center gap-1 text-xs dark:text-white/40 text-black/40">
+ <Coffee className="h-3 w-3 opacity-60" />
+ <span className="font-medium">
+ Java {version.javaVersion}
+ </span>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {version.isInstalled && (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="opacity-0 group-hover:opacity-100 transition-opacity text-red-500 dark:text-red-400 hover:bg-red-500/10 dark:hover:bg-red-500/20"
+ onClick={(e) =>
+ showDeleteConfirmation(version.id, e)
+ }
+ title="Delete version"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+ })}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+
+ {/* Right: Version Details */}
+ <div className="flex flex-col gap-6">
+ <Card className="border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 backdrop-blur-sm">
+ <CardHeader>
+ <CardTitle className="text-lg">Version Details</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {selectedVersion ? (
+ <>
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Selected Version
+ </div>
+ <div className="font-mono text-xl font-bold">
+ {selectedVersion}
+ </div>
+ </div>
+
+ {isLoadingMetadata ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="text-sm">Loading metadata...</span>
+ </div>
+ ) : selectedVersionMetadata ? (
+ <div className="space-y-3">
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Installation Status
+ </div>
+ <Badge
+ variant={
+ selectedVersionMetadata.isInstalled
+ ? "default"
+ : "outline"
+ }
+ >
+ {selectedVersionMetadata.isInstalled
+ ? "Installed"
+ : "Not Installed"}
+ </Badge>
+ </div>
+
+ {selectedVersionMetadata.javaVersion && (
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Java Version
+ </div>
+ <div className="flex items-center gap-2">
+ <Coffee className="h-4 w-4" />
+ <span>
+ Java {selectedVersionMetadata.javaVersion}
+ </span>
+ </div>
+ </div>
+ )}
+
+ {!selectedVersionMetadata.isInstalled && (
+ <Button
+ className="w-full"
+ onClick={() => setShowModLoaderSelector(true)}
+ >
+ Install with Mod Loader
+ </Button>
+ )}
+ </div>
+ ) : null}
+ </>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ Select a version to view details
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* Mod Loader Installation */}
+ {showModLoaderSelector && selectedBaseVersion && (
+ <Card className="border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 backdrop-blur-sm">
+ <CardHeader>
+ <CardTitle className="text-lg">Install Mod Loader</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="text-sm text-muted-foreground">
+ Install {selectedBaseVersion} with Fabric or Forge
+ </div>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ className="flex-1"
+ onClick={async () => {
+ if (!activeInstanceId) return;
+ try {
+ await invoke("install_fabric", {
+ instanceId: activeInstanceId,
+ gameVersion: selectedBaseVersion,
+ loaderVersion: "latest",
+ });
+ toast.success("Fabric installation started");
+ setShowModLoaderSelector(false);
+ } catch (e) {
+ console.error("Failed to install Fabric:", e);
+ toast.error(`Failed to install Fabric: ${e}`);
+ }
+ }}
+ >
+ Install Fabric
+ </Button>
+ <Button
+ variant="outline"
+ className="flex-1"
+ onClick={async () => {
+ if (!activeInstanceId) return;
+ try {
+ await invoke("install_forge", {
+ instanceId: activeInstanceId,
+ gameVersion: selectedBaseVersion,
+ installerVersion: "latest",
+ });
+ toast.success("Forge installation started");
+ setShowModLoaderSelector(false);
+ } catch (e) {
+ console.error("Failed to install Forge:", e);
+ toast.error(`Failed to install Forge: ${e}`);
+ }
+ }}
+ >
+ Install Forge
+ </Button>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setShowModLoaderSelector(false)}
+ >
+ Cancel
+ </Button>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ </div>
+
+ {/* Delete Confirmation Dialog */}
+ <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Version</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete version "{versionToDelete}"? This
+ action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setShowDeleteDialog(false);
+ setVersionToDelete(null);
+ }}
+ disabled={isDeleting}
+ >
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDeleteVersion}
+ disabled={isDeleting}
+ >
+ {isDeleting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Deleting...
+ </>
+ ) : (
+ "Delete"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/stores/assistant-store.ts b/packages/ui-new/src/stores/assistant-store.ts
new file mode 100644
index 0000000..180031b
--- /dev/null
+++ b/packages/ui-new/src/stores/assistant-store.ts
@@ -0,0 +1,201 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { create } from "zustand";
+import type { GenerationStats, StreamChunk } from "@/types/bindings/assistant";
+
+export interface Message {
+ role: "user" | "assistant" | "system";
+ content: string;
+ stats?: GenerationStats;
+}
+
+interface AssistantState {
+ // State
+ messages: Message[];
+ isProcessing: boolean;
+ isProviderHealthy: boolean | undefined;
+ streamingContent: string;
+ initialized: boolean;
+ streamUnlisten: UnlistenFn | null;
+
+ // Actions
+ init: () => Promise<void>;
+ checkHealth: () => Promise<void>;
+ sendMessage: (
+ content: string,
+ isEnabled: boolean,
+ provider: string,
+ endpoint: string,
+ ) => Promise<void>;
+ finishStreaming: () => void;
+ clearHistory: () => void;
+ setMessages: (messages: Message[]) => void;
+ setIsProcessing: (isProcessing: boolean) => void;
+ setIsProviderHealthy: (isProviderHealthy: boolean | undefined) => void;
+ setStreamingContent: (streamingContent: string) => void;
+}
+
+export const useAssistantStore = create<AssistantState>((set, get) => ({
+ // Initial state
+ messages: [],
+ isProcessing: false,
+ isProviderHealthy: false,
+ streamingContent: "",
+ initialized: false,
+ streamUnlisten: null,
+
+ // Actions
+ init: async () => {
+ const { initialized } = get();
+ if (initialized) return;
+ set({ initialized: true });
+ await get().checkHealth();
+ },
+
+ checkHealth: async () => {
+ try {
+ const isHealthy = await invoke<boolean>("assistant_check_health");
+ set({ isProviderHealthy: isHealthy });
+ } catch (e) {
+ console.error("Failed to check provider health:", e);
+ set({ isProviderHealthy: false });
+ }
+ },
+
+ finishStreaming: () => {
+ const { streamUnlisten } = get();
+ set({ isProcessing: false, streamingContent: "" });
+
+ if (streamUnlisten) {
+ streamUnlisten();
+ set({ streamUnlisten: null });
+ }
+ },
+
+ sendMessage: async (content, isEnabled, provider, endpoint) => {
+ if (!content.trim()) return;
+
+ const { messages } = get();
+
+ if (!isEnabled) {
+ const newMessage: Message = {
+ role: "assistant",
+ content: "Assistant is disabled. Enable it in Settings > AI Assistant.",
+ };
+ set({ messages: [...messages, { role: "user", content }, newMessage] });
+ return;
+ }
+
+ // Add user message
+ const userMessage: Message = { role: "user", content };
+ const updatedMessages = [...messages, userMessage];
+ set({
+ messages: updatedMessages,
+ isProcessing: true,
+ streamingContent: "",
+ });
+
+ // Add empty assistant message for streaming
+ const assistantMessage: Message = { role: "assistant", content: "" };
+ const withAssistantMessage = [...updatedMessages, assistantMessage];
+ set({ messages: withAssistantMessage });
+
+ try {
+ // Set up stream listener
+ const unlisten = await listen<StreamChunk>(
+ "assistant-stream",
+ (event) => {
+ const chunk = event.payload;
+ const currentState = get();
+
+ if (chunk.content) {
+ const newStreamingContent =
+ currentState.streamingContent + chunk.content;
+ const currentMessages = [...currentState.messages];
+ const lastIdx = currentMessages.length - 1;
+
+ if (lastIdx >= 0 && currentMessages[lastIdx].role === "assistant") {
+ currentMessages[lastIdx] = {
+ ...currentMessages[lastIdx],
+ content: newStreamingContent,
+ };
+ set({
+ streamingContent: newStreamingContent,
+ messages: currentMessages,
+ });
+ }
+ }
+
+ if (chunk.done) {
+ const finalMessages = [...currentState.messages];
+ const lastIdx = finalMessages.length - 1;
+
+ if (
+ chunk.stats &&
+ lastIdx >= 0 &&
+ finalMessages[lastIdx].role === "assistant"
+ ) {
+ finalMessages[lastIdx] = {
+ ...finalMessages[lastIdx],
+ stats: chunk.stats,
+ };
+ set({ messages: finalMessages });
+ }
+
+ get().finishStreaming();
+ }
+ },
+ );
+
+ set({ streamUnlisten: unlisten });
+
+ // Start streaming chat
+ await invoke<string>("assistant_chat_stream", {
+ messages: withAssistantMessage.slice(0, -1), // Exclude the empty assistant message
+ });
+ } catch (e) {
+ console.error("Failed to send message:", e);
+ const errorMessage = e instanceof Error ? e.message : String(e);
+
+ let helpText = "";
+ if (provider === "ollama") {
+ helpText = `\n\nPlease ensure Ollama is running at ${endpoint}.`;
+ } else if (provider === "openai") {
+ helpText = "\n\nPlease check your OpenAI API key in Settings.";
+ }
+
+ // Update the last message with error
+ const currentMessages = [...get().messages];
+ const lastIdx = currentMessages.length - 1;
+ if (lastIdx >= 0 && currentMessages[lastIdx].role === "assistant") {
+ currentMessages[lastIdx] = {
+ role: "assistant",
+ content: `Error: ${errorMessage}${helpText}`,
+ };
+ set({ messages: currentMessages });
+ }
+
+ get().finishStreaming();
+ }
+ },
+
+ clearHistory: () => {
+ set({ messages: [], streamingContent: "" });
+ },
+
+ setMessages: (messages) => {
+ set({ messages });
+ },
+
+ setIsProcessing: (isProcessing) => {
+ set({ isProcessing });
+ },
+
+ setIsProviderHealthy: (isProviderHealthy) => {
+ set({ isProviderHealthy });
+ },
+
+ setStreamingContent: (streamingContent) => {
+ set({ streamingContent });
+ },
+}));
diff --git a/packages/ui-new/src/stores/auth-store.ts b/packages/ui-new/src/stores/auth-store.ts
new file mode 100644
index 0000000..bf7e3c5
--- /dev/null
+++ b/packages/ui-new/src/stores/auth-store.ts
@@ -0,0 +1,296 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { open } from "@tauri-apps/plugin-shell";
+import { toast } from "sonner";
+import { create } from "zustand";
+import type { Account, DeviceCodeResponse } from "../types/bindings/auth";
+
+interface AuthState {
+ // State
+ currentAccount: Account | null;
+ isLoginModalOpen: boolean;
+ isLogoutConfirmOpen: boolean;
+ loginMode: "select" | "offline" | "microsoft";
+ offlineUsername: string;
+ deviceCodeData: DeviceCodeResponse | null;
+ msLoginLoading: boolean;
+ msLoginStatus: string;
+
+ // Private state
+ pollInterval: ReturnType<typeof setInterval> | null;
+ isPollingRequestActive: boolean;
+ authProgressUnlisten: UnlistenFn | null;
+
+ // Actions
+ checkAccount: () => Promise<void>;
+ openLoginModal: () => void;
+ openLogoutConfirm: () => void;
+ cancelLogout: () => void;
+ confirmLogout: () => Promise<void>;
+ closeLoginModal: () => void;
+ resetLoginState: () => void;
+ performOfflineLogin: () => Promise<void>;
+ startMicrosoftLogin: () => Promise<void>;
+ checkLoginStatus: (deviceCode: string) => Promise<void>;
+ stopPolling: () => void;
+ cancelMicrosoftLogin: () => void;
+ setLoginMode: (mode: "select" | "offline" | "microsoft") => void;
+ setOfflineUsername: (username: string) => void;
+}
+
+export const useAuthStore = create<AuthState>((set, get) => ({
+ // Initial state
+ currentAccount: null,
+ isLoginModalOpen: false,
+ isLogoutConfirmOpen: false,
+ loginMode: "select",
+ offlineUsername: "",
+ deviceCodeData: null,
+ msLoginLoading: false,
+ msLoginStatus: "Waiting for authorization...",
+
+ // Private state
+ pollInterval: null,
+ isPollingRequestActive: false,
+ authProgressUnlisten: null,
+
+ // Actions
+ checkAccount: async () => {
+ try {
+ const acc = await invoke<Account | null>("get_active_account");
+ set({ currentAccount: acc });
+ } catch (error) {
+ console.error("Failed to check account:", error);
+ }
+ },
+
+ openLoginModal: () => {
+ const { currentAccount } = get();
+ if (currentAccount) {
+ // Show custom logout confirmation dialog
+ set({ isLogoutConfirmOpen: true });
+ return;
+ }
+ get().resetLoginState();
+ set({ isLoginModalOpen: true });
+ },
+
+ openLogoutConfirm: () => {
+ set({ isLogoutConfirmOpen: true });
+ },
+
+ cancelLogout: () => {
+ set({ isLogoutConfirmOpen: false });
+ },
+
+ confirmLogout: async () => {
+ set({ isLogoutConfirmOpen: false });
+ try {
+ await invoke("logout");
+ set({ currentAccount: null });
+ } catch (error) {
+ console.error("Logout failed:", error);
+ }
+ },
+
+ closeLoginModal: () => {
+ get().stopPolling();
+ set({ isLoginModalOpen: false });
+ },
+
+ resetLoginState: () => {
+ set({
+ loginMode: "select",
+ offlineUsername: "",
+ deviceCodeData: null,
+ msLoginLoading: false,
+ msLoginStatus: "Waiting for authorization...",
+ });
+ },
+
+ performOfflineLogin: async () => {
+ const { offlineUsername } = get();
+ if (!offlineUsername.trim()) return;
+
+ try {
+ const account = await invoke<Account>("login_offline", {
+ username: offlineUsername,
+ });
+ set({
+ currentAccount: account,
+ isLoginModalOpen: false,
+ offlineUsername: "",
+ });
+ } catch (error) {
+ // Keep UI-friendly behavior consistent with prior code
+ alert("Login failed: " + String(error));
+ }
+ },
+
+ startMicrosoftLogin: async () => {
+ // Prepare UI state
+ set({
+ msLoginLoading: true,
+ msLoginStatus: "Waiting for authorization...",
+ loginMode: "microsoft",
+ deviceCodeData: null,
+ });
+
+ // Listen to general launcher logs so we can display progress to the user.
+ // The backend emits logs via "launcher-log"; using that keeps this store decoupled
+ // from a dedicated auth event channel (backend may reuse launcher-log).
+ try {
+ const unlisten = await listen("launcher-log", (event) => {
+ const payload = event.payload;
+ // Normalize payload to string if possible
+ const message =
+ typeof payload === "string"
+ ? payload
+ : (payload?.toString?.() ?? JSON.stringify(payload));
+ set({ msLoginStatus: message });
+ });
+ set({ authProgressUnlisten: unlisten });
+ } catch (err) {
+ console.warn("Failed to attach launcher-log listener:", err);
+ }
+
+ try {
+ const deviceCodeData = await invoke<DeviceCodeResponse>(
+ "start_microsoft_login",
+ );
+ set({ deviceCodeData });
+
+ if (deviceCodeData) {
+ // Try to copy user code to clipboard for convenience (best-effort)
+ try {
+ await navigator.clipboard?.writeText(deviceCodeData.userCode ?? "");
+ } catch (err) {
+ // ignore clipboard errors
+ console.debug("Clipboard copy failed:", err);
+ }
+
+ // Open verification URI in default browser
+ try {
+ if (deviceCodeData.verificationUri) {
+ await open(deviceCodeData.verificationUri);
+ }
+ } catch (err) {
+ console.debug("Failed to open verification URI:", err);
+ }
+
+ // Start polling for completion
+ // `interval` from the bindings is a bigint (seconds). Convert safely to number.
+ const intervalSeconds =
+ deviceCodeData.interval !== undefined &&
+ deviceCodeData.interval !== null
+ ? Number(deviceCodeData.interval)
+ : 5;
+ const intervalMs = intervalSeconds * 1000;
+ const pollInterval = setInterval(
+ () => get().checkLoginStatus(deviceCodeData.deviceCode),
+ intervalMs,
+ );
+ set({ pollInterval });
+ }
+ } catch (error) {
+ toast.error(`Failed to start Microsoft login: ${error}`);
+ set({ loginMode: "select" });
+ // cleanup listener if present
+ const { authProgressUnlisten } = get();
+ if (authProgressUnlisten) {
+ authProgressUnlisten();
+ set({ authProgressUnlisten: null });
+ }
+ } finally {
+ set({ msLoginLoading: false });
+ }
+ },
+
+ checkLoginStatus: async (deviceCode: string) => {
+ const { isPollingRequestActive } = get();
+ if (isPollingRequestActive) return;
+
+ set({ isPollingRequestActive: true });
+
+ try {
+ const account = await invoke<Account>("complete_microsoft_login", {
+ deviceCode,
+ });
+
+ // On success, stop polling and cleanup listener
+ get().stopPolling();
+ const { authProgressUnlisten } = get();
+ if (authProgressUnlisten) {
+ authProgressUnlisten();
+ set({ authProgressUnlisten: null });
+ }
+
+ set({
+ currentAccount: account,
+ isLoginModalOpen: false,
+ });
+ } catch (error: unknown) {
+ const errStr = String(error);
+ if (errStr.includes("authorization_pending")) {
+ // Still waiting — keep polling
+ } else {
+ set({ msLoginStatus: "Error: " + errStr });
+
+ if (
+ errStr.includes("expired_token") ||
+ errStr.includes("access_denied")
+ ) {
+ // Terminal errors — stop polling and reset state
+ get().stopPolling();
+ const { authProgressUnlisten } = get();
+ if (authProgressUnlisten) {
+ authProgressUnlisten();
+ set({ authProgressUnlisten: null });
+ }
+ alert("Login failed: " + errStr);
+ set({ loginMode: "select" });
+ }
+ }
+ } finally {
+ set({ isPollingRequestActive: false });
+ }
+ },
+
+ stopPolling: () => {
+ const { pollInterval, authProgressUnlisten } = get();
+ if (pollInterval) {
+ try {
+ clearInterval(pollInterval);
+ } catch (err) {
+ console.debug("Failed to clear poll interval:", err);
+ }
+ set({ pollInterval: null });
+ }
+ if (authProgressUnlisten) {
+ try {
+ authProgressUnlisten();
+ } catch (err) {
+ console.debug("Failed to unlisten auth progress:", err);
+ }
+ set({ authProgressUnlisten: null });
+ }
+ },
+
+ cancelMicrosoftLogin: () => {
+ get().stopPolling();
+ set({
+ deviceCodeData: null,
+ msLoginLoading: false,
+ msLoginStatus: "",
+ loginMode: "select",
+ });
+ },
+
+ setLoginMode: (mode: "select" | "offline" | "microsoft") => {
+ set({ loginMode: mode });
+ },
+
+ setOfflineUsername: (username: string) => {
+ set({ offlineUsername: username });
+ },
+}));
diff --git a/packages/ui-new/src/stores/game-store.ts b/packages/ui-new/src/stores/game-store.ts
new file mode 100644
index 0000000..541b386
--- /dev/null
+++ b/packages/ui-new/src/stores/game-store.ts
@@ -0,0 +1,101 @@
+import { invoke } from "@tauri-apps/api/core";
+import { toast } from "sonner";
+import { create } from "zustand";
+import type { Version } from "@/types/bindings/manifest";
+
+interface GameState {
+ // State
+ versions: Version[];
+ selectedVersion: string;
+
+ // Computed property
+ latestRelease: Version | undefined;
+
+ // Actions
+ loadVersions: (instanceId?: string) => Promise<void>;
+ startGame: (
+ currentAccount: any,
+ openLoginModal: () => void,
+ activeInstanceId: string | null,
+ setView: (view: any) => void,
+ ) => Promise<void>;
+ setSelectedVersion: (version: string) => void;
+ setVersions: (versions: Version[]) => void;
+}
+
+export const useGameStore = create<GameState>((set, get) => ({
+ // Initial state
+ versions: [],
+ selectedVersion: "",
+
+ // Computed property
+ get latestRelease() {
+ return get().versions.find((v) => v.type === "release");
+ },
+
+ // Actions
+ loadVersions: async (instanceId?: string) => {
+ console.log("Loading versions for instance:", instanceId);
+ try {
+ // Ask the backend for known versions (optionally scoped to an instance).
+ // The Tauri command `get_versions` is expected to return an array of `Version`.
+ const versions = await invoke<Version[]>("get_versions", { instanceId });
+ set({ versions: versions ?? [] });
+ } catch (e) {
+ console.error("Failed to load versions:", e);
+ // Keep the store consistent on error by clearing versions.
+ set({ versions: [] });
+ }
+ },
+
+ startGame: async (
+ currentAccount,
+ openLoginModal,
+ activeInstanceId,
+ setView,
+ ) => {
+ const { selectedVersion } = get();
+
+ if (!currentAccount) {
+ alert("Please login first!");
+ openLoginModal();
+ return;
+ }
+
+ if (!selectedVersion) {
+ alert("Please select a version!");
+ return;
+ }
+
+ if (!activeInstanceId) {
+ alert("Please select an instance first!");
+ setView("instances");
+ return;
+ }
+
+ toast.info("Preparing to launch " + selectedVersion + "...");
+
+ try {
+ // Note: In production, this would call Tauri invoke
+ // const msg = await invoke<string>("start_game", {
+ // instanceId: activeInstanceId,
+ // versionId: selectedVersion,
+ // });
+
+ // Simulate success
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ toast.success("Game started successfully!");
+ } catch (e) {
+ console.error(e);
+ toast.error(`Error: ${e}`);
+ }
+ },
+
+ setSelectedVersion: (version: string) => {
+ set({ selectedVersion: version });
+ },
+
+ setVersions: (versions: Version[]) => {
+ set({ versions });
+ },
+}));
diff --git a/packages/ui-new/src/stores/instances-store.ts b/packages/ui-new/src/stores/instances-store.ts
new file mode 100644
index 0000000..4636b79
--- /dev/null
+++ b/packages/ui-new/src/stores/instances-store.ts
@@ -0,0 +1,149 @@
+import { invoke } from "@tauri-apps/api/core";
+import { toast } from "sonner";
+import { create } from "zustand";
+import type { Instance } from "../types/bindings/instance";
+
+interface InstancesState {
+ // State
+ instances: Instance[];
+ activeInstanceId: string | null;
+
+ // Computed property
+ activeInstance: Instance | null;
+
+ // Actions
+ loadInstances: () => Promise<void>;
+ createInstance: (name: string) => Promise<Instance | null>;
+ deleteInstance: (id: string) => Promise<void>;
+ updateInstance: (instance: Instance) => Promise<void>;
+ setActiveInstance: (id: string) => Promise<void>;
+ duplicateInstance: (id: string, newName: string) => Promise<Instance | null>;
+ getInstance: (id: string) => Promise<Instance | null>;
+ setInstances: (instances: Instance[]) => void;
+ setActiveInstanceId: (id: string | null) => void;
+}
+
+export const useInstancesStore = create<InstancesState>((set, get) => ({
+ // Initial state
+ instances: [],
+ activeInstanceId: null,
+
+ // Computed property
+ get activeInstance() {
+ const { instances, activeInstanceId } = get();
+ if (!activeInstanceId) return null;
+ return instances.find((i) => i.id === activeInstanceId) || null;
+ },
+
+ // Actions
+ loadInstances: async () => {
+ try {
+ const instances = await invoke<Instance[]>("list_instances");
+ const active = await invoke<Instance | null>("get_active_instance");
+
+ let newActiveInstanceId = null;
+ if (active) {
+ newActiveInstanceId = active.id;
+ } else if (instances.length > 0) {
+ // If no active instance but instances exist, set the first one as active
+ await get().setActiveInstance(instances[0].id);
+ newActiveInstanceId = instances[0].id;
+ }
+
+ set({ instances, activeInstanceId: newActiveInstanceId });
+ } catch (e) {
+ console.error("Failed to load instances:", e);
+ toast.error("Error loading instances: " + String(e));
+ }
+ },
+
+ createInstance: async (name) => {
+ try {
+ const instance = await invoke<Instance>("create_instance", { name });
+ await get().loadInstances();
+ toast.success(`Instance "${name}" created successfully`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to create instance:", e);
+ toast.error("Error creating instance: " + String(e));
+ return null;
+ }
+ },
+
+ deleteInstance: async (id) => {
+ try {
+ await invoke("delete_instance", { instanceId: id });
+ await get().loadInstances();
+
+ // If deleted instance was active, set another as active
+ const { instances, activeInstanceId } = get();
+ if (activeInstanceId === id) {
+ if (instances.length > 0) {
+ await get().setActiveInstance(instances[0].id);
+ } else {
+ set({ activeInstanceId: null });
+ }
+ }
+
+ toast.success("Instance deleted successfully");
+ } catch (e) {
+ console.error("Failed to delete instance:", e);
+ toast.error("Error deleting instance: " + String(e));
+ }
+ },
+
+ updateInstance: async (instance) => {
+ try {
+ await invoke("update_instance", { instance });
+ await get().loadInstances();
+ toast.success("Instance updated successfully");
+ } catch (e) {
+ console.error("Failed to update instance:", e);
+ toast.error("Error updating instance: " + String(e));
+ }
+ },
+
+ setActiveInstance: async (id) => {
+ try {
+ await invoke("set_active_instance", { instanceId: id });
+ set({ activeInstanceId: id });
+ toast.success("Active instance changed");
+ } catch (e) {
+ console.error("Failed to set active instance:", e);
+ toast.error("Error setting active instance: " + String(e));
+ }
+ },
+
+ duplicateInstance: async (id, newName) => {
+ try {
+ const instance = await invoke<Instance>("duplicate_instance", {
+ instanceId: id,
+ newName,
+ });
+ await get().loadInstances();
+ toast.success(`Instance duplicated as "${newName}"`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to duplicate instance:", e);
+ toast.error("Error duplicating instance: " + String(e));
+ return null;
+ }
+ },
+
+ getInstance: async (id) => {
+ try {
+ return await invoke<Instance>("get_instance", { instanceId: id });
+ } catch (e) {
+ console.error("Failed to get instance:", e);
+ return null;
+ }
+ },
+
+ setInstances: (instances) => {
+ set({ instances });
+ },
+
+ setActiveInstanceId: (id) => {
+ set({ activeInstanceId: id });
+ },
+}));
diff --git a/packages/ui-new/src/stores/logs-store.ts b/packages/ui-new/src/stores/logs-store.ts
new file mode 100644
index 0000000..b19f206
--- /dev/null
+++ b/packages/ui-new/src/stores/logs-store.ts
@@ -0,0 +1,200 @@
+import { listen } from "@tauri-apps/api/event";
+import { create } from "zustand";
+
+export interface LogEntry {
+ id: number;
+ timestamp: string;
+ level: "info" | "warn" | "error" | "debug" | "fatal";
+ source: string;
+ message: string;
+}
+
+// Parse Minecraft/Java log format: [HH:MM:SS] [Thread/LEVEL]: message
+// or: [HH:MM:SS] [Thread/LEVEL] [Source]: message
+const GAME_LOG_REGEX =
+ /^\[[\d:]+\]\s*\[([^\]]+)\/(\w+)\](?:\s*\[([^\]]+)\])?:\s*(.*)$/;
+
+function parseGameLogLevel(levelStr: string): LogEntry["level"] {
+ const upper = levelStr.toUpperCase();
+ if (upper === "INFO") return "info";
+ if (upper === "WARN" || upper === "WARNING") return "warn";
+ if (upper === "ERROR" || upper === "SEVERE") return "error";
+ if (
+ upper === "DEBUG" ||
+ upper === "TRACE" ||
+ upper === "FINE" ||
+ upper === "FINER" ||
+ upper === "FINEST"
+ )
+ return "debug";
+ if (upper === "FATAL") return "fatal";
+ return "info";
+}
+
+interface LogsState {
+ // State
+ logs: LogEntry[];
+ sources: Set<string>;
+ nextId: number;
+ maxLogs: number;
+ initialized: boolean;
+
+ // Actions
+ addLog: (level: LogEntry["level"], source: string, message: string) => void;
+ addGameLog: (rawLine: string, isStderr: boolean) => void;
+ clear: () => void;
+ exportLogs: (filteredLogs: LogEntry[]) => string;
+ init: () => Promise<void>;
+ setLogs: (logs: LogEntry[]) => void;
+ setSources: (sources: Set<string>) => void;
+}
+
+export const useLogsStore = create<LogsState>((set, get) => ({
+ // Initial state
+ logs: [],
+ sources: new Set(["Launcher"]),
+ nextId: 0,
+ maxLogs: 5000,
+ initialized: false,
+
+ // Actions
+ addLog: (level, source, message) => {
+ const { nextId, logs, maxLogs, sources } = get();
+ const now = new Date();
+ const timestamp =
+ now.toLocaleTimeString() +
+ "." +
+ now.getMilliseconds().toString().padStart(3, "0");
+
+ const newLog: LogEntry = {
+ id: nextId,
+ timestamp,
+ level,
+ source,
+ message,
+ };
+
+ const newLogs = [...logs, newLog];
+ const newSources = new Set(sources);
+
+ // Track source
+ if (!newSources.has(source)) {
+ newSources.add(source);
+ }
+
+ // Trim logs if exceeding max
+ const trimmedLogs =
+ newLogs.length > maxLogs ? newLogs.slice(-maxLogs) : newLogs;
+
+ set({
+ logs: trimmedLogs,
+ sources: newSources,
+ nextId: nextId + 1,
+ });
+ },
+
+ addGameLog: (rawLine, isStderr) => {
+ const match = rawLine.match(GAME_LOG_REGEX);
+
+ if (match) {
+ const [, thread, levelStr, extraSource, message] = match;
+ const level = parseGameLogLevel(levelStr);
+ // Use extraSource if available, otherwise use thread name as source hint
+ const source = extraSource || `Game/${thread.split("-")[0]}`;
+ get().addLog(level, source, message);
+ } else {
+ // Fallback: couldn't parse, use stderr as error indicator
+ const level = isStderr ? "error" : "info";
+ get().addLog(level, "Game", rawLine);
+ }
+ },
+
+ clear: () => {
+ set({
+ logs: [],
+ sources: new Set(["Launcher"]),
+ });
+ get().addLog("info", "Launcher", "Logs cleared");
+ },
+
+ exportLogs: (filteredLogs) => {
+ return filteredLogs
+ .map(
+ (l) =>
+ `[${l.timestamp}] [${l.source}/${l.level.toUpperCase()}] ${l.message}`,
+ )
+ .join("\n");
+ },
+
+ init: async () => {
+ const { initialized } = get();
+ if (initialized) return;
+
+ set({ initialized: true });
+
+ // Initial log
+ get().addLog("info", "Launcher", "Logs initialized");
+
+ // General Launcher Logs
+ await listen<string>("launcher-log", (e) => {
+ get().addLog("info", "Launcher", e.payload);
+ });
+
+ // Game Stdout - parse log level
+ await listen<string>("game-stdout", (e) => {
+ get().addGameLog(e.payload, false);
+ });
+
+ // Game Stderr - parse log level, default to error
+ await listen<string>("game-stderr", (e) => {
+ get().addGameLog(e.payload, true);
+ });
+
+ // Download Events (Summarized)
+ await listen("download-start", (e: any) => {
+ get().addLog(
+ "info",
+ "Downloader",
+ `Starting batch download of ${e.payload} files...`,
+ );
+ });
+
+ await listen("download-complete", () => {
+ get().addLog("info", "Downloader", "All downloads completed.");
+ });
+
+ // Listen to file download progress to log finished files
+ await listen<any>("download-progress", (e) => {
+ const p = e.payload;
+ if (p.status === "Finished") {
+ if (p.file.endsWith(".jar")) {
+ get().addLog("info", "Downloader", `Downloaded ${p.file}`);
+ }
+ }
+ });
+
+ // Java Download
+ await listen<any>("java-download-progress", (e) => {
+ const p = e.payload;
+ if (p.status === "Downloading" && p.percentage === 0) {
+ get().addLog(
+ "info",
+ "JavaInstaller",
+ `Downloading Java: ${p.file_name}`,
+ );
+ } else if (p.status === "Completed") {
+ get().addLog("info", "JavaInstaller", `Java installed: ${p.file_name}`);
+ } else if (p.status === "Error") {
+ get().addLog("error", "JavaInstaller", `Java download error`);
+ }
+ });
+ },
+
+ setLogs: (logs) => {
+ set({ logs });
+ },
+
+ setSources: (sources) => {
+ set({ sources });
+ },
+}));
diff --git a/packages/ui-new/src/stores/releases-store.ts b/packages/ui-new/src/stores/releases-store.ts
new file mode 100644
index 0000000..56afa08
--- /dev/null
+++ b/packages/ui-new/src/stores/releases-store.ts
@@ -0,0 +1,63 @@
+import { invoke } from "@tauri-apps/api/core";
+import { create } from "zustand";
+import type { GithubRelease } from "@/types/bindings/core";
+
+interface ReleasesState {
+ // State
+ releases: GithubRelease[];
+ isLoading: boolean;
+ isLoaded: boolean;
+ error: string | null;
+
+ // Actions
+ loadReleases: () => Promise<void>;
+ setReleases: (releases: GithubRelease[]) => void;
+ setIsLoading: (isLoading: boolean) => void;
+ setIsLoaded: (isLoaded: boolean) => void;
+ setError: (error: string | null) => void;
+}
+
+export const useReleasesStore = create<ReleasesState>((set, get) => ({
+ // Initial state
+ releases: [],
+ isLoading: false,
+ isLoaded: false,
+ error: null,
+
+ // Actions
+ loadReleases: async () => {
+ const { isLoaded, isLoading } = get();
+
+ // If already loaded or currently loading, skip to prevent duplicate requests
+ if (isLoaded || isLoading) return;
+
+ set({ isLoading: true, error: null });
+
+ try {
+ const releases = await invoke<GithubRelease[]>("get_github_releases");
+ set({ releases, isLoaded: true });
+ } catch (e) {
+ const error = e instanceof Error ? e.message : String(e);
+ console.error("Failed to load releases:", e);
+ set({ error });
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ setReleases: (releases) => {
+ set({ releases });
+ },
+
+ setIsLoading: (isLoading) => {
+ set({ isLoading });
+ },
+
+ setIsLoaded: (isLoaded) => {
+ set({ isLoaded });
+ },
+
+ setError: (error) => {
+ set({ error });
+ },
+}));
diff --git a/packages/ui-new/src/stores/settings-store.ts b/packages/ui-new/src/stores/settings-store.ts
new file mode 100644
index 0000000..52da7fd
--- /dev/null
+++ b/packages/ui-new/src/stores/settings-store.ts
@@ -0,0 +1,568 @@
+import { convertFileSrc, invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { toast } from "sonner";
+import { create } from "zustand";
+import type { ModelInfo } from "../types/bindings/assistant";
+import type { LauncherConfig } from "../types/bindings/config";
+import type {
+ JavaDownloadProgress,
+ PendingJavaDownload,
+} from "../types/bindings/downloader";
+import type {
+ JavaCatalog,
+ JavaDownloadInfo,
+ JavaInstallation,
+ JavaReleaseInfo,
+} from "../types/bindings/java";
+
+type JavaDownloadSource = "adoptium" | "mojang" | "azul";
+
+/**
+ * State shape for settings store.
+ *
+ * Note: Uses camelCase naming to match ts-rs generated bindings (which now use
+ * `serde(rename_all = "camelCase")`). When reading raw binding objects from
+ * invoke, convert/mapping should be applied where necessary.
+ */
+interface SettingsState {
+ // State
+ settings: LauncherConfig;
+ javaInstallations: JavaInstallation[];
+ isDetectingJava: boolean;
+ showJavaDownloadModal: boolean;
+ selectedDownloadSource: JavaDownloadSource;
+ javaCatalog: JavaCatalog | null;
+ isLoadingCatalog: boolean;
+ catalogError: string;
+ selectedMajorVersion: number | null;
+ selectedImageType: "jre" | "jdk";
+ showOnlyRecommended: boolean;
+ searchQuery: string;
+ isDownloadingJava: boolean;
+ downloadProgress: JavaDownloadProgress | null;
+ javaDownloadStatus: string;
+ pendingDownloads: PendingJavaDownload[];
+ ollamaModels: ModelInfo[];
+ openaiModels: ModelInfo[];
+ isLoadingOllamaModels: boolean;
+ isLoadingOpenaiModels: boolean;
+ ollamaModelsError: string;
+ openaiModelsError: string;
+ showConfigEditor: boolean;
+ rawConfigContent: string;
+ configFilePath: string;
+ configEditorError: string;
+
+ // Computed / derived
+ backgroundUrl: string | undefined;
+ filteredReleases: JavaReleaseInfo[];
+ availableMajorVersions: number[];
+ installStatus: (
+ version: number,
+ imageType: string,
+ ) => "installed" | "downloading" | "available";
+ selectedRelease: JavaReleaseInfo | null;
+ currentModelOptions: Array<{
+ value: string;
+ label: string;
+ details?: string;
+ }>;
+
+ // Actions
+ loadSettings: () => Promise<void>;
+ saveSettings: () => Promise<void>;
+ // compatibility helper to mirror the older set({ key: value }) usage
+ set: (patch: Partial<Record<string, unknown>>) => void;
+
+ detectJava: () => Promise<void>;
+ selectJava: (path: string) => void;
+
+ openJavaDownloadModal: () => Promise<void>;
+ closeJavaDownloadModal: () => void;
+ loadJavaCatalog: (forceRefresh: boolean) => Promise<void>;
+ refreshCatalog: () => Promise<void>;
+ loadPendingDownloads: () => Promise<void>;
+ selectMajorVersion: (version: number) => void;
+ downloadJava: () => Promise<void>;
+ cancelDownload: () => Promise<void>;
+ resumeDownloads: () => Promise<void>;
+
+ openConfigEditor: () => Promise<void>;
+ closeConfigEditor: () => void;
+ saveRawConfig: () => Promise<void>;
+
+ loadOllamaModels: () => Promise<void>;
+ loadOpenaiModels: () => Promise<void>;
+
+ setSetting: <K extends keyof LauncherConfig>(
+ key: K,
+ value: LauncherConfig[K],
+ ) => void;
+ setAssistantSetting: <K extends keyof LauncherConfig["assistant"]>(
+ key: K,
+ value: LauncherConfig["assistant"][K],
+ ) => void;
+ setFeatureFlag: <K extends keyof LauncherConfig["featureFlags"]>(
+ key: K,
+ value: LauncherConfig["featureFlags"][K],
+ ) => void;
+
+ // Private
+ progressUnlisten: UnlistenFn | null;
+}
+
+/**
+ * Default settings (camelCase) — lightweight defaults used until `get_settings`
+ * returns real values.
+ */
+const defaultSettings: LauncherConfig = {
+ minMemory: 1024,
+ maxMemory: 2048,
+ javaPath: "java",
+ width: 854,
+ height: 480,
+ downloadThreads: 32,
+ enableGpuAcceleration: false,
+ enableVisualEffects: true,
+ activeEffect: "constellation",
+ theme: "dark",
+ customBackgroundPath: null,
+ logUploadService: "paste.rs",
+ pastebinApiKey: null,
+ assistant: {
+ enabled: true,
+ llmProvider: "ollama",
+ ollamaEndpoint: "http://localhost:11434",
+ ollamaModel: "llama3",
+ openaiApiKey: null,
+ openaiEndpoint: "https://api.openai.com/v1",
+ openaiModel: "gpt-3.5-turbo",
+ systemPrompt:
+ "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.",
+ responseLanguage: "auto",
+ ttsEnabled: false,
+ ttsProvider: "disabled",
+ },
+ useSharedCaches: false,
+ keepLegacyPerInstanceStorage: true,
+ featureFlags: {
+ demoUser: false,
+ quickPlayEnabled: false,
+ quickPlayPath: null,
+ quickPlaySingleplayer: true,
+ quickPlayMultiplayerServer: null,
+ },
+};
+
+export const useSettingsStore = create<SettingsState>((set, get) => ({
+ // initial state
+ settings: defaultSettings,
+ javaInstallations: [],
+ isDetectingJava: false,
+ showJavaDownloadModal: false,
+ selectedDownloadSource: "adoptium",
+ javaCatalog: null,
+ isLoadingCatalog: false,
+ catalogError: "",
+ selectedMajorVersion: null,
+ selectedImageType: "jre",
+ showOnlyRecommended: true,
+ searchQuery: "",
+ isDownloadingJava: false,
+ downloadProgress: null,
+ javaDownloadStatus: "",
+ pendingDownloads: [],
+ ollamaModels: [],
+ openaiModels: [],
+ isLoadingOllamaModels: false,
+ isLoadingOpenaiModels: false,
+ ollamaModelsError: "",
+ openaiModelsError: "",
+ showConfigEditor: false,
+ rawConfigContent: "",
+ configFilePath: "",
+ configEditorError: "",
+ progressUnlisten: null,
+
+ // derived getters
+ get backgroundUrl() {
+ const { settings } = get();
+ if (settings.customBackgroundPath) {
+ return convertFileSrc(settings.customBackgroundPath);
+ }
+ return undefined;
+ },
+
+ get filteredReleases() {
+ const {
+ javaCatalog,
+ selectedMajorVersion,
+ selectedImageType,
+ showOnlyRecommended,
+ searchQuery,
+ } = get();
+
+ if (!javaCatalog) return [];
+
+ let releases = javaCatalog.releases;
+
+ if (selectedMajorVersion !== null) {
+ releases = releases.filter(
+ (r) => r.majorVersion === selectedMajorVersion,
+ );
+ }
+
+ releases = releases.filter((r) => r.imageType === selectedImageType);
+
+ if (showOnlyRecommended) {
+ releases = releases.filter((r) => r.isLts);
+ }
+
+ if (searchQuery.trim() !== "") {
+ const q = searchQuery.toLowerCase();
+ releases = releases.filter(
+ (r) =>
+ r.version.toLowerCase().includes(q) ||
+ (r.releaseName ?? "").toLowerCase().includes(q),
+ );
+ }
+
+ // sort newest-first by parsed version number
+ return releases.sort((a, b) => {
+ const aVer = parseFloat(a.version.split("-")[0]);
+ const bVer = parseFloat(b.version.split("-")[0]);
+ return bVer - aVer;
+ });
+ },
+
+ get availableMajorVersions() {
+ return get().javaCatalog?.availableMajorVersions || [];
+ },
+
+ installStatus: (version: number, imageType: string) => {
+ const {
+ javaInstallations,
+ pendingDownloads,
+ isDownloadingJava,
+ downloadProgress,
+ } = get();
+
+ const installed = javaInstallations.some(
+ (inst) => parseInt(inst.version.split(".")[0], 10) === version,
+ );
+ if (installed) return "installed";
+
+ if (
+ isDownloadingJava &&
+ downloadProgress?.fileName?.includes(`${version}`)
+ ) {
+ return "downloading";
+ }
+
+ const pending = pendingDownloads.some(
+ (d) => d.majorVersion === version && d.imageType === imageType,
+ );
+ if (pending) return "downloading";
+
+ return "available";
+ },
+
+ get selectedRelease() {
+ const { javaCatalog, selectedMajorVersion, selectedImageType } = get();
+ if (!javaCatalog || selectedMajorVersion === null) return null;
+ return (
+ javaCatalog.releases.find(
+ (r) =>
+ r.majorVersion === selectedMajorVersion &&
+ r.imageType === selectedImageType,
+ ) || null
+ );
+ },
+
+ get currentModelOptions() {
+ const { settings, ollamaModels, openaiModels } = get();
+ const provider = settings.assistant.llmProvider;
+ if (provider === "ollama") {
+ return ollamaModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.details || m.size || "",
+ }));
+ } else {
+ return openaiModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.details || "",
+ }));
+ }
+ },
+
+ // actions
+ loadSettings: async () => {
+ try {
+ const result = await invoke<LauncherConfig>("get_settings");
+ // result already uses camelCase fields from bindings
+ set({ settings: result });
+
+ // enforce dark theme at app-level if necessary
+ if (result.theme !== "dark") {
+ const updated = { ...result, theme: "dark" } as LauncherConfig;
+ set({ settings: updated });
+ await invoke("save_settings", { config: updated });
+ }
+
+ // ensure customBackgroundPath is undefined rather than null for reactiveness
+ if (!result.customBackgroundPath) {
+ set((s) => ({
+ settings: { ...s.settings, customBackgroundPath: null },
+ }));
+ }
+ } catch (e) {
+ console.error("Failed to load settings:", e);
+ }
+ },
+
+ saveSettings: async () => {
+ try {
+ const { settings } = get();
+
+ // Clean up empty strings to null where appropriate
+ if ((settings.customBackgroundPath ?? "") === "") {
+ set((state) => ({
+ settings: { ...state.settings, customBackgroundPath: null },
+ }));
+ }
+
+ await invoke("save_settings", { config: settings });
+ toast.success("Settings saved!");
+ } catch (e) {
+ console.error("Failed to save settings:", e);
+ toast.error(`Error saving settings: ${String(e)}`);
+ }
+ },
+
+ set: (patch: Partial<Record<string, unknown>>) => {
+ set(patch);
+ },
+
+ detectJava: async () => {
+ set({ isDetectingJava: true });
+ try {
+ const installs = await invoke<JavaInstallation[]>("detect_java");
+ set({ javaInstallations: installs });
+ if (installs.length === 0) toast.info("No Java installations found");
+ else toast.success(`Found ${installs.length} Java installation(s)`);
+ } catch (e) {
+ console.error("Failed to detect Java:", e);
+ toast.error(`Error detecting Java: ${String(e)}`);
+ } finally {
+ set({ isDetectingJava: false });
+ }
+ },
+
+ selectJava: (path: string) => {
+ set((s) => ({ settings: { ...s.settings, javaPath: path } }));
+ },
+
+ openJavaDownloadModal: async () => {
+ set({
+ showJavaDownloadModal: true,
+ javaDownloadStatus: "",
+ catalogError: "",
+ downloadProgress: null,
+ });
+
+ // attach event listener for download progress
+ const state = get();
+ if (state.progressUnlisten) {
+ state.progressUnlisten();
+ }
+
+ const unlisten = await listen<JavaDownloadProgress>(
+ "java-download-progress",
+ (event) => {
+ set({ downloadProgress: event.payload });
+ },
+ );
+
+ set({ progressUnlisten: unlisten });
+
+ // load catalog and pending downloads
+ await get().loadJavaCatalog(false);
+ await get().loadPendingDownloads();
+ },
+
+ closeJavaDownloadModal: () => {
+ const { isDownloadingJava, progressUnlisten } = get();
+
+ if (!isDownloadingJava) {
+ set({ showJavaDownloadModal: false });
+ if (progressUnlisten) {
+ try {
+ progressUnlisten();
+ } catch {
+ // ignore
+ }
+ set({ progressUnlisten: null });
+ }
+ }
+ },
+
+ loadJavaCatalog: async (forceRefresh: boolean) => {
+ set({ isLoadingCatalog: true, catalogError: "" });
+ try {
+ const cmd = forceRefresh ? "refresh_java_catalog" : "get_java_catalog";
+ const result = await invoke<JavaCatalog>(cmd);
+ set({ javaCatalog: result, isLoadingCatalog: false });
+ } catch (e) {
+ console.error("Failed to load Java catalog:", e);
+ set({ catalogError: String(e), isLoadingCatalog: false });
+ }
+ },
+
+ refreshCatalog: async () => {
+ await get().loadJavaCatalog(true);
+ },
+
+ loadPendingDownloads: async () => {
+ try {
+ const pending = await invoke<PendingJavaDownload[]>(
+ "get_pending_java_downloads",
+ );
+ set({ pendingDownloads: pending });
+ } catch (e) {
+ console.error("Failed to load pending downloads:", e);
+ }
+ },
+
+ selectMajorVersion: (version: number) => {
+ set({ selectedMajorVersion: version });
+ },
+
+ downloadJava: async () => {
+ const { selectedMajorVersion, selectedImageType, selectedDownloadSource } =
+ get();
+ if (!selectedMajorVersion) return;
+ set({ isDownloadingJava: true, javaDownloadStatus: "Starting..." });
+ try {
+ const result = await invoke<JavaDownloadInfo>("download_java", {
+ majorVersion: selectedMajorVersion,
+ imageType: selectedImageType,
+ source: selectedDownloadSource,
+ });
+ set({
+ javaDownloadStatus: `Java ${selectedMajorVersion} download started: ${result.fileName}`,
+ });
+ toast.success("Download started");
+ } catch (e) {
+ console.error("Failed to download Java:", e);
+ toast.error(`Failed to start Java download: ${String(e)}`);
+ } finally {
+ set({ isDownloadingJava: false });
+ }
+ },
+
+ cancelDownload: async () => {
+ try {
+ await invoke("cancel_java_download");
+ toast.success("Cancelled Java download");
+ set({ isDownloadingJava: false, javaDownloadStatus: "" });
+ } catch (e) {
+ console.error("Failed to cancel download:", e);
+ toast.error(`Failed to cancel download: ${String(e)}`);
+ }
+ },
+
+ resumeDownloads: async () => {
+ try {
+ const installed = await invoke<boolean>("resume_java_downloads");
+ if (installed) toast.success("Resumed Java downloads");
+ else toast.info("No downloads to resume");
+ } catch (e) {
+ console.error("Failed to resume downloads:", e);
+ toast.error(`Failed to resume downloads: ${String(e)}`);
+ }
+ },
+
+ openConfigEditor: async () => {
+ try {
+ const path = await invoke<string>("get_config_path");
+ const content = await invoke<string>("read_config_raw");
+ set({
+ configFilePath: path,
+ rawConfigContent: content,
+ showConfigEditor: true,
+ });
+ } catch (e) {
+ console.error("Failed to open config editor:", e);
+ set({ configEditorError: String(e) });
+ }
+ },
+
+ closeConfigEditor: () => {
+ set({
+ showConfigEditor: false,
+ rawConfigContent: "",
+ configFilePath: "",
+ configEditorError: "",
+ });
+ },
+
+ saveRawConfig: async () => {
+ try {
+ await invoke("write_config_raw", { content: get().rawConfigContent });
+ toast.success("Config saved");
+ set({ showConfigEditor: false });
+ } catch (e) {
+ console.error("Failed to save config:", e);
+ set({ configEditorError: String(e) });
+ toast.error(`Failed to save config: ${String(e)}`);
+ }
+ },
+
+ loadOllamaModels: async () => {
+ set({ isLoadingOllamaModels: true, ollamaModelsError: "" });
+ try {
+ const models = await invoke<ModelInfo[]>("get_ollama_models");
+ set({ ollamaModels: models, isLoadingOllamaModels: false });
+ } catch (e) {
+ console.error("Failed to load Ollama models:", e);
+ set({ isLoadingOllamaModels: false, ollamaModelsError: String(e) });
+ }
+ },
+
+ loadOpenaiModels: async () => {
+ set({ isLoadingOpenaiModels: true, openaiModelsError: "" });
+ try {
+ const models = await invoke<ModelInfo[]>("get_openai_models");
+ set({ openaiModels: models, isLoadingOpenaiModels: false });
+ } catch (e) {
+ console.error("Failed to load OpenAI models:", e);
+ set({ isLoadingOpenaiModels: false, openaiModelsError: String(e) });
+ }
+ },
+
+ setSetting: (key, value) => {
+ set((s) => ({
+ settings: { ...s.settings, [key]: value } as unknown as LauncherConfig,
+ }));
+ },
+
+ setAssistantSetting: (key, value) => {
+ set((s) => ({
+ settings: {
+ ...s.settings,
+ assistant: { ...s.settings.assistant, [key]: value },
+ } as LauncherConfig,
+ }));
+ },
+
+ setFeatureFlag: (key, value) => {
+ set((s) => ({
+ settings: {
+ ...s.settings,
+ featureFlags: { ...s.settings.featureFlags, [key]: value },
+ } as LauncherConfig,
+ }));
+ },
+}));
diff --git a/packages/ui-new/src/stores/ui-store.ts b/packages/ui-new/src/stores/ui-store.ts
new file mode 100644
index 0000000..89b9191
--- /dev/null
+++ b/packages/ui-new/src/stores/ui-store.ts
@@ -0,0 +1,42 @@
+import { create } from "zustand";
+
+export type ViewType = "home" | "versions" | "settings" | "guide" | "instances";
+
+interface UIState {
+ // State
+ currentView: ViewType;
+ showConsole: boolean;
+ appVersion: string;
+
+ // Actions
+ toggleConsole: () => void;
+ setView: (view: ViewType) => void;
+ setAppVersion: (version: string) => void;
+}
+
+export const useUIStore = create<UIState>((set) => ({
+ // Initial state
+ currentView: "home",
+ showConsole: false,
+ appVersion: "...",
+
+ // Actions
+ toggleConsole: () => {
+ set((state) => ({ showConsole: !state.showConsole }));
+ },
+
+ setView: (view: ViewType) => {
+ set({ currentView: view });
+ },
+
+ setAppVersion: (version: string) => {
+ set({ appVersion: version });
+ },
+}));
+
+// Provide lowercase alias for compatibility with existing imports.
+// Use a function wrapper to ensure the named export exists as a callable value
+// at runtime (some bundlers/tree-shakers may remove simple aliases).
+export function useUiStore() {
+ return useUIStore();
+}
diff --git a/packages/ui-new/src/types/bindings/assistant.ts b/packages/ui-new/src/types/bindings/assistant.ts
new file mode 100644
index 0000000..827f008
--- /dev/null
+++ b/packages/ui-new/src/types/bindings/assistant.ts
@@ -0,0 +1,25 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type GenerationStats = {
+ totalDuration: bigint;
+ loadDuration: bigint;
+ promptEvalCount: bigint;
+ promptEvalDuration: bigint;
+ evalCount: bigint;
+ evalDuration: bigint;
+};
+
+export type Message = { role: string; content: string };
+
+export type ModelInfo = {
+ id: string;
+ name: string;
+ size: string | null;
+ details: string | null;
+};
+
+export type StreamChunk = {
+ content: string;
+ done: boolean;
+ stats: GenerationStats | null;
+};
diff --git a/packages/ui-new/src/types/bindings/auth.ts b/packages/ui-new/src/types/bindings/auth.ts
new file mode 100644
index 0000000..a65f0a4
--- /dev/null
+++ b/packages/ui-new/src/types/bindings/auth.ts
@@ -0,0 +1,32 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type Account =
+ | ({ type: "offline" } & OfflineAccount)
+ | ({ type: "microsoft" } & MicrosoftAccount);
+
+export type DeviceCodeResponse = {
+ userCode: string;
+ deviceCode: string;
+ verificationUri: string;
+ expiresIn: bigint;
+ interval: bigint;
+ message: string | null;
+};
+
+export type MicrosoftAccount = {
+ username: string;
+ uuid: string;
+ accessToken: string;
+ refreshToken: string | null;
+ expiresAt: bigint;
+};
+
+export type MinecraftProfile = { id: string; name: string };
+
+export type OfflineAccount = { username: string; uuid: string };
+
+export type TokenResponse = {
+ accessToken: string;
+ refreshToken: string | null;
+ expiresIn: bigint;
+};
diff --git a/packages/ui-new/src/types/bindings/config.ts b/packages/ui-new/src/types/bindings/config.ts
new file mode 100644
index 0000000..e9de4f5
--- /dev/null
+++ b/packages/ui-new/src/types/bindings/config.ts
@@ -0,0 +1,61 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type AssistantConfig = {
+ enabled: boolean;
+ llmProvider: string;
+ ollamaEndpoint: string;
+ ollamaModel: string;
+ openaiApiKey: string | null;
+ openaiEndpoint: string;
+ openaiModel: string;
+ systemPrompt: string;
+ responseLanguage: string;
+ ttsEnabled: boolean;
+ ttsProvider: string;
+};
+
+/**
+ * Feature-gated arguments configuration
+ */
+export type FeatureFlags = {
+ /**
+ * Demo user: enables demo-related arguments when rules require it
+ */
+ demoUser: boolean;
+ /**
+ * Quick Play: enable quick play arguments
+ */
+ quickPlayEnabled: boolean;
+ /**
+ * Quick Play singleplayer world path (if provided)
+ */
+ quickPlayPath: string | null;
+ /**
+ * Quick Play singleplayer flag
+ */
+ quickPlaySingleplayer: boolean;
+ /**
+ * Quick Play multiplayer server address (optional)
+ */
+ quickPlayMultiplayerServer: string | null;
+};
+
+export type LauncherConfig = {
+ minMemory: number;
+ maxMemory: number;
+ javaPath: string;
+ width: number;
+ height: number;
+ downloadThreads: number;
+ customBackgroundPath: string | null;
+ enableGpuAcceleration: boolean;
+ enableVisualEffects: boolean;
+ activeEffect: string;
+ theme: string;
+ logUploadService: string;
+ pastebinApiKey: string | null;
+ assistant: AssistantConfig;
+ useSharedCaches: boolean;
+ keepLegacyPerInstanceStorage: boolean;
+ featureFlags: FeatureFlags;
+};
diff --git a/packages/ui-new/src/types/bindings/core.ts b/packages/ui-new/src/types/bindings/core.ts
new file mode 100644
index 0000000..94e3bde
--- /dev/null
+++ b/packages/ui-new/src/types/bindings/core.ts
@@ -0,0 +1,47 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * File information for instance file browser
+ */
+export type FileInfo = {
+ name: string;
+ path: string;
+ isDirectory: boolean;
+ size: bigint;
+ modified: bigint;
+};
+
+export type GithubRelease = {
+ tagName: string;
+ name: string;
+ publishedAt: string;
+ body: string;
+ htmlUrl: string;
+};
+
+/**
+ * Installed version info
+ */
+export type InstalledVersion = { id: string; type: string };
+
+/**
+ * Migrate instance caches to shared global caches
+ */
+export type MigrationResult = {
+ movedFiles: number;
+ hardlinks: number;
+ copies: number;
+ savedBytes: bigint;
+ savedMb: number;
+};
+
+export type PastebinResponse = { url: string };
+
+/**
+ * Version metadata for display in the UI
+ */
+export type VersionMetadata = {
+ id: string;
+ javaVersion: bigint | null;
+ isInstalled: boolean;
+};
diff --git a/packages/ui-new/src/types/bindings/downloader.ts b/packages/ui-new/src/types/bindings/downloader.ts
new file mode 100644
index 0000000..a1734d5
--- /dev/null
+++ b/packages/ui-new/src/types/bindings/downloader.ts
@@ -0,0 +1,63 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Metadata for resumable downloads stored in .part.meta file
+ */
+export type DownloadMetadata = {
+ url: string;
+ fileName: string;
+ totalSize: bigint;
+ downloadedBytes: bigint;
+ checksum: string | null;
+ timestamp: bigint;
+ segments: Array<DownloadSegment>;
+};
+
+/**
+ * Download queue for persistence
+ */
+export type DownloadQueue = { pendingDownloads: Array<PendingJavaDownload> };
+
+/**
+ * A download segment for multi-segment parallel downloading
+ */
+export type DownloadSegment = {
+ start: bigint;
+ end: bigint;
+ downloaded: bigint;
+ completed: boolean;
+};
+
+export type DownloadTask = {
+ url: string;
+ path: string;
+ sha1: string | null;
+ sha256: string | null;
+};
+
+/**
+ * Progress event for Java download
+ */
+export type JavaDownloadProgress = {
+ fileName: string;
+ downloadedBytes: bigint;
+ totalBytes: bigint;
+ speedBytesPerSec: bigint;
+ etaSeconds: bigint;
+ status: string;
+ percentage: number;
+};
+
+/**
+ * Pending download task for queue persistence
+ */
+export type PendingJavaDownload = {
+ majorVersion: number;
+ imageType: string;
+ downloadUrl: string;
+ fileName: string;
+ fileSize: bigint;
+ checksum: string | null;
+ installPath: string;
+ createdAt: bigint;
+};
diff --git a/packages/ui-new/src/types/bindings/fabric.ts b/packages/ui-new/src/types/bindings/fabric.ts
new file mode 100644
index 0000000..181f8be
--- /dev/null
+++ b/packages/ui-new/src/types/bindings/fabric.ts
@@ -0,0 +1,74 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Represents a Minecraft version supported by Fabric.
+ */
+export type FabricGameVersion = { version: string; stable: boolean };
+
+/**
+ * Represents a Fabric intermediary mapping version.
+ */
+export type FabricIntermediaryVersion = {
+ maven: string;
+ version: string;
+ stable: boolean;
+};
+
+/**
+ * Launcher metadata from Fabric Meta API.
+ */
+export type FabricLauncherMeta = {
+ version: number;
+ libraries: FabricLibraries;
+ mainClass: FabricMainClass;
+};
+
+/**
+ * Libraries required by Fabric loader.
+ */
+export type FabricLibraries = {
+ client: Array<FabricLibrary>;
+ common: Array<FabricLibrary>;
+ server: Array<FabricLibrary>;
+};
+
+/**
+ * A single Fabric library dependency.
+ */
+export type FabricLibrary = { name: string; url: string | null };
+
+/**
+ * Represents a combined loader + intermediary version entry.
+ */
+export type FabricLoaderEntry = {
+ loader: FabricLoaderVersion;
+ intermediary: FabricIntermediaryVersion;
+ launcherMeta: FabricLauncherMeta;
+};
+
+/**
+ * Represents a Fabric loader version from the Meta API.
+ */
+export type FabricLoaderVersion = {
+ separator: string;
+ build: number;
+ maven: string;
+ version: string;
+ stable: boolean;
+};
+
+/**
+ * Main class configuration for Fabric.
+ * Can be either a struct with client/server fields or a simple string.
+ */
+export type FabricMainClass = { client: string; server: string } | string;
+
+/**
+ * Information about an installed Fabric version.
+ */
+export type InstalledFabricVersion = {
+ id: string;
+ minecraftVersion: string;
+ loaderVersion: string;
+ path: string;
+};
diff --git a/packages/ui-new/src/types/bindings/forge.ts b/packages/ui-new/src/types/bindings/forge.ts
new file mode 100644
index 0000000..a9790e7
--- /dev/null
+++ b/packages/ui-new/src/types/bindings/forge.ts
@@ -0,0 +1,21 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Represents a Forge version entry.
+ */
+export type ForgeVersion = {
+ version: string;
+ minecraftVersion: string;
+ recommended: boolean;
+ latest: boolean;
+};
+
+/**
+ * Information about an installed Forge version.
+ */
+export type InstalledForgeVersion = {
+ id: string;
+ minecraftVersion: string;
+ forgeVersion: string;
+ path: string;
+};
diff --git a/packages/ui-new/src/types/bindings/game_version.ts b/packages/ui-new/src/types/bindings/game_version.ts
new file mode 100644
index 0000000..1b1c395
--- /dev/null
+++ b/packages/ui-new/src/types/bindings/game_version.ts
@@ -0,0 +1,89 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type Arguments = {
+ game: Record<string, unknown>;
+ jvm: Record<string, unknown>;
+};
+
+export type AssetIndex = {
+ id: string;
+ sha1: string;
+ size: bigint;
+ url: string;
+ totalSize: bigint | null;
+};
+
+export type DownloadArtifact = {
+ sha1: string | null;
+ size: bigint | null;
+ url: string;
+ path: string | null;
+};
+
+export type Downloads = {
+ client: DownloadArtifact;
+ server: DownloadArtifact | null;
+};
+
+/**
+ * Represents a Minecraft version JSON, supporting both vanilla and modded (Fabric/Forge) formats.
+ * Modded versions use `inheritsFrom` to reference a parent vanilla version.
+ */
+export type GameVersion = {
+ id: string;
+ /**
+ * Optional for mod loaders that inherit from vanilla
+ */
+ downloads: Downloads | null;
+ /**
+ * Optional for mod loaders that inherit from vanilla
+ */
+ assetIndex: AssetIndex | null;
+ libraries: Array<Library>;
+ mainClass: string;
+ minecraftArguments: string | null;
+ arguments: Arguments | null;
+ javaVersion: JavaVersion | null;
+ /**
+ * For mod loaders: the vanilla version this inherits from
+ */
+ inheritsFrom: string | null;
+ /**
+ * Fabric/Forge may specify a custom assets version
+ */
+ assets: string | null;
+ /**
+ * Release type (release, snapshot, old_beta, etc.)
+ */
+ type: string | null;
+};
+
+export type JavaVersion = { component: string; majorVersion: bigint };
+
+export type Library = {
+ downloads: LibraryDownloads | null;
+ name: string;
+ rules: Array<Rule> | null;
+ natives: Record<string, unknown>;
+ /**
+ * Maven repository URL for mod loader libraries
+ */
+ url: string | null;
+};
+
+export type LibraryDownloads = {
+ artifact: DownloadArtifact | null;
+ classifiers: Record<string, unknown>;
+};
+
+export type OsRule = {
+ name: string | null;
+ version: string | null;
+ arch: string | null;
+};
+
+export type Rule = {
+ action: string;
+ os: OsRule | null;
+ features: Record<string, unknown>;
+};
diff --git a/packages/ui-new/src/types/bindings/index.ts b/packages/ui-new/src/types/bindings/index.ts
new file mode 100644
index 0000000..510c240
--- /dev/null
+++ b/packages/ui-new/src/types/bindings/index.ts
@@ -0,0 +1,11 @@
+export * from "./assistant";
+export * from "./auth";
+export * from "./config";
+export * from "./core";
+export * from "./downloader";
+export * from "./fabric";
+export * from "./forge";
+export * from "./game_version";
+export * from "./instance";
+export * from "./java";
+export * from "./manifest";
diff --git a/packages/ui-new/src/types/bindings/instance.ts b/packages/ui-new/src/types/bindings/instance.ts
new file mode 100644
index 0000000..079e8f0
--- /dev/null
+++ b/packages/ui-new/src/types/bindings/instance.ts
@@ -0,0 +1,32 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Represents a game instance/profile
+ */
+export type Instance = {
+ id: string;
+ name: string;
+ gameDir: string;
+ versionId: string | null;
+ createdAt: bigint;
+ lastPlayed: bigint | null;
+ iconPath: string | null;
+ notes: string | null;
+ modLoader: string | null;
+ modLoaderVersion: string | null;
+ jvmArgsOverride: string | null;
+ memoryOverride: MemoryOverride | null;
+};
+
+/**
+ * Configuration for all instances
+ */
+export type InstanceConfig = {
+ instances: Array<Instance>;
+ activeInstanceId: string | null;
+};
+
+/**
+ * Memory settings override for an instance
+ */
+export type MemoryOverride = { min: number; max: number };
diff --git a/packages/ui-new/src/types/bindings/java.ts b/packages/ui-new/src/types/bindings/java.ts
new file mode 100644
index 0000000..5db128e
--- /dev/null
+++ b/packages/ui-new/src/types/bindings/java.ts
@@ -0,0 +1,52 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Java image type: JRE or JDK
+ */
+export type ImageType = "jre" | "jdk";
+
+/**
+ * Java catalog containing all available versions
+ */
+export type JavaCatalog = {
+ releases: Array<JavaReleaseInfo>;
+ availableMajorVersions: Array<number>;
+ ltsVersions: Array<number>;
+ cachedAt: bigint;
+};
+
+/**
+ * Java download information from Adoptium
+ */
+export type JavaDownloadInfo = {
+ version: string;
+ releaseName: string;
+ downloadUrl: string;
+ fileName: string;
+ fileSize: bigint;
+ checksum: string | null;
+ imageType: string;
+};
+
+export type JavaInstallation = {
+ path: string;
+ version: string;
+ is64bit: boolean;
+};
+
+/**
+ * Java release information for UI display
+ */
+export type JavaReleaseInfo = {
+ majorVersion: number;
+ imageType: string;
+ version: string;
+ releaseName: string;
+ releaseDate: string | null;
+ fileSize: bigint;
+ checksum: string | null;
+ downloadUrl: string;
+ isLts: boolean;
+ isAvailable: boolean;
+ architecture: string;
+};
diff --git a/packages/ui-new/src/types/bindings/manifest.ts b/packages/ui-new/src/types/bindings/manifest.ts
new file mode 100644
index 0000000..2180962
--- /dev/null
+++ b/packages/ui-new/src/types/bindings/manifest.ts
@@ -0,0 +1,22 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type Latest = { release: string; snapshot: string };
+
+export type Version = {
+ id: string;
+ type: string;
+ url: string;
+ time: string;
+ releaseTime: string;
+ /**
+ * Java version requirement (major version number)
+ * This is populated from the version JSON file if the version is installed locally
+ */
+ javaVersion: bigint | null;
+ /**
+ * Whether this version is installed locally
+ */
+ isInstalled: boolean | null;
+};
+
+export type VersionManifest = { latest: Latest; versions: Array<Version> };
diff --git a/packages/ui-new/tsconfig.app.json b/packages/ui-new/tsconfig.app.json
new file mode 100644
index 0000000..ce9121a
--- /dev/null
+++ b/packages/ui-new/tsconfig.app.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ /* Paths */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"],
+ },
+ },
+ "include": ["src"],
+}
diff --git a/packages/ui-new/tsconfig.json b/packages/ui-new/tsconfig.json
new file mode 100644
index 0000000..59578c3
--- /dev/null
+++ b/packages/ui-new/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" },
+ ],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"],
+ },
+ },
+}
diff --git a/packages/ui-new/tsconfig.node.json b/packages/ui-new/tsconfig.node.json
new file mode 100644
index 0000000..8a67f62
--- /dev/null
+++ b/packages/ui-new/tsconfig.node.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/packages/ui-new/vite.config.ts b/packages/ui-new/vite.config.ts
new file mode 100644
index 0000000..27ce1ff
--- /dev/null
+++ b/packages/ui-new/vite.config.ts
@@ -0,0 +1,18 @@
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import path from "path";
+import { defineConfig } from "vite";
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ "@components": path.resolve(__dirname, "./src/components"),
+ "@stores": path.resolve(__dirname, "./src/stores"),
+ "@types": path.resolve(__dirname, "./src/types"),
+ "@pages": path.resolve(__dirname, "./src/pages"),
+ },
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 91232c9..5a86e4c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -104,8 +104,200 @@ importers:
specifier: npm:rolldown-vite@^7
version: rolldown-vite@7.2.5(@types/node@24.10.9)(jiti@2.6.1)(tsx@4.21.0)
+ packages/ui-new:
+ dependencies:
+ '@radix-ui/react-checkbox':
+ specifier: ^1.3.3
+ version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-dialog':
+ specifier: ^1.1.15
+ version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-label':
+ specifier: ^2.1.8
+ version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-scroll-area':
+ specifier: ^1.2.10
+ version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-select':
+ specifier: ^2.2.6
+ version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-separator':
+ specifier: ^1.1.8
+ version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-slot':
+ specifier: ^1.2.4
+ version: 1.2.4(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-switch':
+ specifier: ^1.2.6
+ version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-tabs':
+ specifier: ^1.1.13
+ version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@tauri-apps/api':
+ specifier: ^2.9.1
+ version: 2.9.1
+ '@tauri-apps/plugin-dialog':
+ specifier: ^2.6.0
+ version: 2.6.0
+ '@tauri-apps/plugin-fs':
+ specifier: ^2.4.5
+ version: 2.4.5
+ '@tauri-apps/plugin-shell':
+ specifier: ^2.3.4
+ version: 2.3.4
+ class-variance-authority:
+ specifier: ^0.7.1
+ version: 0.7.1
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ dompurify:
+ specifier: ^2.4.0
+ version: 2.5.8
+ lucide-react:
+ specifier: ^0.562.0
+ version: 0.562.0(react@19.2.3)
+ marked:
+ specifier: ^17.0.1
+ version: 17.0.1
+ next-themes:
+ specifier: ^0.4.6
+ version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ react:
+ specifier: ^19.2.0
+ version: 19.2.3
+ react-dom:
+ specifier: ^19.2.0
+ version: 19.2.3(react@19.2.3)
+ react-router:
+ specifier: ^7.12.0
+ version: 7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ sonner:
+ specifier: ^2.0.7
+ version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ tailwind-merge:
+ specifier: ^3.4.0
+ version: 3.4.0
+ zustand:
+ specifier: ^5.0.10
+ version: 5.0.10(@types/react@19.2.8)(react@19.2.3)
+ devDependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.1.18
+ version: 4.1.18(rolldown-vite@7.2.5(@types/node@24.10.9)(jiti@2.6.1)(tsx@4.21.0))
+ '@types/node':
+ specifier: ^24.10.1
+ version: 24.10.9
+ '@types/react':
+ specifier: ^19.2.5
+ version: 19.2.8
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.8)
+ '@vitejs/plugin-react':
+ specifier: ^5.1.1
+ version: 5.1.2(rolldown-vite@7.2.5(@types/node@24.10.9)(jiti@2.6.1)(tsx@4.21.0))
+ globals:
+ specifier: ^16.5.0
+ version: 16.5.0
+ tailwindcss:
+ specifier: ^4.1.18
+ version: 4.1.18
+ tw-animate-css:
+ specifier: ^1.4.0
+ version: 1.4.0
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+ vite:
+ specifier: npm:rolldown-vite@^7
+ version: rolldown-vite@7.2.5(@types/node@24.10.9)(jiti@2.6.1)(tsx@4.21.0)
+
packages:
+ '@babel/code-frame@7.28.6':
+ resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.28.6':
+ resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.28.6':
+ resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.28.6':
+ resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.28.6':
+ resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.28.0':
+ resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.28.6':
+ resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.28.6':
+ resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.28.6':
+ resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.27.1':
+ resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.28.5':
+ resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.27.1':
+ resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.28.6':
+ resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.28.6':
+ resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-transform-react-jsx-self@7.27.1':
+ resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.27.1':
+ resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/template@7.28.6':
+ resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.28.6':
+ resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.28.6':
+ resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==}
+ engines: {node: '>=6.9.0'}
+
'@biomejs/biome@2.3.11':
resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==}
engines: {node: '>=14.21.3'}
@@ -324,6 +516,21 @@ packages:
cpu: [x64]
os: [win32]
+ '@floating-ui/core@1.7.3':
+ resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
+
+ '@floating-ui/dom@1.7.4':
+ resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
+
+ '@floating-ui/react-dom@2.1.6':
+ resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
+ '@floating-ui/utils@0.2.10':
+ resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
+
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
@@ -443,6 +650,397 @@ packages:
cpu: [x64]
os: [win32]
+ '@radix-ui/number@1.1.1':
+ resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
+
+ '@radix-ui/primitive@1.1.3':
+ resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
+
+ '@radix-ui/react-arrow@1.1.7':
+ resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-checkbox@1.3.3':
+ resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-collection@1.1.7':
+ resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-compose-refs@1.1.2':
+ resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-context@1.1.2':
+ resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-dialog@1.1.15':
+ resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-direction@1.1.1':
+ resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-dismissable-layer@1.1.11':
+ resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-focus-guards@1.1.3':
+ resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-focus-scope@1.1.7':
+ resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-id@1.1.1':
+ resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-label@2.1.8':
+ resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-popper@1.2.8':
+ resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-portal@1.1.9':
+ resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-presence@1.1.5':
+ resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-primitive@2.1.3':
+ resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-primitive@2.1.4':
+ resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-roving-focus@1.1.11':
+ resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-scroll-area@1.2.10':
+ resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-select@2.2.6':
+ resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-separator@1.1.8':
+ resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-slot@1.2.3':
+ resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-slot@1.2.4':
+ resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-switch@1.2.6':
+ resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-tabs@1.1.13':
+ resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/react-use-callback-ref@1.1.1':
+ resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-controllable-state@1.2.2':
+ resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-effect-event@0.0.2':
+ resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-escape-keydown@1.1.1':
+ resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-layout-effect@1.1.1':
+ resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-previous@1.1.1':
+ resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-rect@1.1.1':
+ resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-use-size@1.1.1':
+ resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ '@radix-ui/react-visually-hidden@1.2.3':
+ resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@radix-ui/rect@1.1.1':
+ resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+
'@rolldown/binding-android-arm64@1.0.0-beta.50':
resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -529,6 +1127,9 @@ packages:
'@rolldown/pluginutils@1.0.0-beta.50':
resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==}
+ '@rolldown/pluginutils@1.0.0-beta.53':
+ resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
+
'@sindresorhus/is@4.6.0':
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
@@ -732,6 +1333,18 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -741,11 +1354,29 @@ packages:
'@types/prismjs@1.26.5':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
+ '@types/react-dom@19.2.3':
+ resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.8':
+ resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==}
+
+ '@vitejs/plugin-react@5.1.2':
+ resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
hasBin: true
+ aria-hidden@1.2.6:
+ resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
+ engines: {node: '>=10'}
+
aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
@@ -794,6 +1425,9 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
+ class-variance-authority@0.7.1:
+ resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+
clone@1.0.4:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
@@ -814,6 +1448,25 @@ packages:
resolution: {integrity: sha512-dPyZofqggxuvSf7WXvNjuRfnsOk1YazkVP8FdxH4tcH2c37wc79/Yl6Bhr7Lsu00KMgy2ql/qCMuNu8xctZM8g==}
engines: {node: '> 0.10'}
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
@@ -829,9 +1482,15 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
+ detect-node-es@1.1.0:
+ resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+
devalue@5.6.2:
resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==}
+ dompurify@2.5.8:
+ resolution: {integrity: sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==}
+
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -913,10 +1572,18 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
+ get-nonce@1.0.1:
+ resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
+ engines: {node: '>=6'}
+
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
@@ -928,6 +1595,10 @@ packages:
resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==}
engines: {node: 20 || >=22}
+ globals@16.5.0:
+ resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
+ engines: {node: '>=18'}
+
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -954,6 +1625,19 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'}
@@ -1031,6 +1715,14 @@ packages:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22}
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ lucide-react@0.562.0:
+ resolution: {integrity: sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
lucide-svelte@0.562.0:
resolution: {integrity: sha512-kSJDH/55lf0mun/o4nqWBXOcq0fWYzPeIjbTD97ywoeumAB9kWxtM06gC7oynqjtK3XhAljWSz5RafIzPEYIQA==}
peerDependencies:
@@ -1068,11 +1760,20 @@ packages:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ next-themes@0.4.6:
+ resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
+ peerDependencies:
+ react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+
node-emoji@2.2.0:
resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==}
engines: {node: '>=18'}
@@ -1126,6 +1827,59 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+ react-dom@19.2.3:
+ resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
+ peerDependencies:
+ react: ^19.2.3
+
+ react-refresh@0.18.0:
+ resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
+ engines: {node: '>=0.10.0'}
+
+ react-remove-scroll-bar@2.3.8:
+ resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-remove-scroll@2.7.2:
+ resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react-router@7.12.0:
+ resolution: {integrity: sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
+ react-style-singleton@2.2.3:
+ resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ react@19.2.3:
+ resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
+ engines: {node: '>=0.10.0'}
+
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@@ -1187,10 +1941,26 @@ packages:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
skin-tone@2.0.0:
resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==}
engines: {node: '>=8'}
+ sonner@2.0.7:
+ resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1207,6 +1977,9 @@ packages:
resolution: {integrity: sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==}
engines: {node: '>=18'}
+ tailwind-merge@3.4.0:
+ resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
+
tailwindcss@4.1.18:
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
@@ -1237,6 +2010,9 @@ packages:
resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==}
engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'}
+ tw-animate-css@1.4.0:
+ resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
+
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -1255,6 +2031,26 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
+ use-callback-ref@1.3.3:
+ resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ use-sidecar@1.1.3:
+ resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
vitefu@1.1.1:
resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==}
peerDependencies:
@@ -1266,11 +2062,144 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
+ zustand@5.0.10:
+ resolution: {integrity: sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg==}
+ engines: {node: '>=12.20.0'}
+ peerDependencies:
+ '@types/react': '>=18.0.0'
+ immer: '>=9.0.6'
+ react: '>=18.0.0'
+ use-sync-external-store: '>=1.2.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ use-sync-external-store:
+ optional: true
+
snapshots:
+ '@babel/code-frame@7.28.6':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.28.5
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.28.6': {}
+
+ '@babel/core@7.28.6':
+ dependencies:
+ '@babel/code-frame': 7.28.6
+ '@babel/generator': 7.28.6
+ '@babel/helper-compilation-targets': 7.28.6
+ '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6)
+ '@babel/helpers': 7.28.6
+ '@babel/parser': 7.28.6
+ '@babel/template': 7.28.6
+ '@babel/traverse': 7.28.6
+ '@babel/types': 7.28.6
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.28.6':
+ dependencies:
+ '@babel/parser': 7.28.6
+ '@babel/types': 7.28.6
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.28.6':
+ dependencies:
+ '@babel/compat-data': 7.28.6
+ '@babel/helper-validator-option': 7.27.1
+ browserslist: 4.28.1
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.28.0': {}
+
+ '@babel/helper-module-imports@7.28.6':
+ dependencies:
+ '@babel/traverse': 7.28.6
+ '@babel/types': 7.28.6
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)':
+ dependencies:
+ '@babel/core': 7.28.6
+ '@babel/helper-module-imports': 7.28.6
+ '@babel/helper-validator-identifier': 7.28.5
+ '@babel/traverse': 7.28.6
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-plugin-utils@7.28.6': {}
+
+ '@babel/helper-string-parser@7.27.1': {}
+
+ '@babel/helper-validator-identifier@7.28.5': {}
+
+ '@babel/helper-validator-option@7.27.1': {}
+
+ '@babel/helpers@7.28.6':
+ dependencies:
+ '@babel/template': 7.28.6
+ '@babel/types': 7.28.6
+
+ '@babel/parser@7.28.6':
+ dependencies:
+ '@babel/types': 7.28.6
+
+ '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.6)':
+ dependencies:
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+
+ '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.6)':
+ dependencies:
+ '@babel/core': 7.28.6
+ '@babel/helper-plugin-utils': 7.28.6
+
+ '@babel/template@7.28.6':
+ dependencies:
+ '@babel/code-frame': 7.28.6
+ '@babel/parser': 7.28.6
+ '@babel/types': 7.28.6
+
+ '@babel/traverse@7.28.6':
+ dependencies:
+ '@babel/code-frame': 7.28.6
+ '@babel/generator': 7.28.6
+ '@babel/helper-globals': 7.28.0
+ '@babel/parser': 7.28.6
+ '@babel/template': 7.28.6
+ '@babel/types': 7.28.6
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.28.6':
+ dependencies:
+ '@babel/helper-string-parser': 7.27.1
+ '@babel/helper-validator-identifier': 7.28.5
+
'@biomejs/biome@2.3.11':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.3.11
@@ -1400,6 +2329,23 @@ snapshots:
'@esbuild/win32-x64@0.27.2':
optional: true
+ '@floating-ui/core@1.7.3':
+ dependencies:
+ '@floating-ui/utils': 0.2.10
+
+ '@floating-ui/dom@1.7.4':
+ dependencies:
+ '@floating-ui/core': 1.7.3
+ '@floating-ui/utils': 0.2.10
+
+ '@floating-ui/react-dom@2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@floating-ui/dom': 1.7.4
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+
+ '@floating-ui/utils@0.2.10': {}
+
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
@@ -1494,6 +2440,371 @@ snapshots:
'@oxlint/win32-x64@1.39.0':
optional: true
+ '@radix-ui/number@1.1.1': {}
+
+ '@radix-ui/primitive@1.1.3': {}
+
+ '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-context@1.1.2(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
+ aria-hidden: 1.2.6
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ react-remove-scroll: 2.7.2(@types/react@19.2.8)(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-direction@1.1.1(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-id@1.1.1(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@floating-ui/react-dom': 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/rect': 1.1.1
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-slot': 1.2.4(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/number': 1.1.1
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/number': 1.1.1
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ aria-hidden: 1.2.6
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ react-remove-scroll: 2.7.2(@types/react@19.2.8)(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-slot@1.2.3(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-slot@1.2.4(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-id': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.8)(react@19.2.3)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/rect': 1.1.1
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-use-size@1.1.1(@types/react@19.2.8)(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.8)(react@19.2.3)
+ react: 19.2.3
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
+
+ '@radix-ui/rect@1.1.1': {}
+
'@rolldown/binding-android-arm64@1.0.0-beta.50':
optional: true
@@ -1540,6 +2851,8 @@ snapshots:
'@rolldown/pluginutils@1.0.0-beta.50': {}
+ '@rolldown/pluginutils@1.0.0-beta.53': {}
+
'@sindresorhus/is@4.6.0': {}
'@sveltejs/acorn-typescript@1.0.8(acorn@8.15.0)':
@@ -1699,6 +3012,27 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.28.6
+ '@babel/types': 7.28.6
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.28.6
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.28.6
+ '@babel/types': 7.28.6
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.28.6
+
'@types/estree@1.0.8': {}
'@types/node@24.10.9':
@@ -1707,8 +3041,32 @@ snapshots:
'@types/prismjs@1.26.5': {}
+ '@types/react-dom@19.2.3(@types/react@19.2.8)':
+ dependencies:
+ '@types/react': 19.2.8
+
+ '@types/react@19.2.8':
+ dependencies:
+ csstype: 3.2.3
+
+ '@vitejs/plugin-react@5.1.2(rolldown-vite@7.2.5(@types/node@24.10.9)(jiti@2.6.1)(tsx@4.21.0))':
+ dependencies:
+ '@babel/core': 7.28.6
+ '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6)
+ '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.6)
+ '@rolldown/pluginutils': 1.0.0-beta.53
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.18.0
+ vite: rolldown-vite@7.2.5(@types/node@24.10.9)(jiti@2.6.1)(tsx@4.21.0)
+ transitivePeerDependencies:
+ - supports-color
+
acorn@8.15.0: {}
+ aria-hidden@1.2.6:
+ dependencies:
+ tslib: 2.8.1
+
aria-query@5.3.2: {}
asynckit@0.4.0: {}
@@ -1759,6 +3117,10 @@ snapshots:
dependencies:
readdirp: 4.1.2
+ class-variance-authority@0.7.1:
+ dependencies:
+ clsx: 2.1.1
+
clone@1.0.4:
optional: true
@@ -1774,6 +3136,16 @@ snapshots:
dependencies:
easy-table: 1.1.0
+ convert-source-map@2.0.0: {}
+
+ cookie@1.1.1: {}
+
+ csstype@3.2.3: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
deepmerge@4.3.1: {}
defaults@1.0.4:
@@ -1785,8 +3157,12 @@ snapshots:
detect-libc@2.1.2: {}
+ detect-node-es@1.1.0: {}
+
devalue@5.6.2: {}
+ dompurify@2.5.8: {}
+
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -1879,6 +3255,8 @@ snapshots:
function-bind@1.1.2: {}
+ gensync@1.0.0-beta.2: {}
+
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -1892,6 +3270,8 @@ snapshots:
hasown: 2.0.2
math-intrinsics: 1.1.0
+ get-nonce@1.0.1: {}
+
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
@@ -1907,6 +3287,8 @@ snapshots:
minipass: 7.1.2
path-scurry: 2.0.1
+ globals@16.5.0: {}
+
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
@@ -1927,6 +3309,12 @@ snapshots:
jiti@2.6.1: {}
+ js-tokens@4.0.0: {}
+
+ jsesc@3.1.0: {}
+
+ json5@2.2.3: {}
+
lightningcss-android-arm64@1.30.2:
optional: true
@@ -1980,6 +3368,14 @@ snapshots:
lru-cache@11.2.4: {}
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ lucide-react@0.562.0(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+
lucide-svelte@0.562.0(svelte@5.46.4):
dependencies:
svelte: 5.46.4
@@ -2006,8 +3402,15 @@ snapshots:
mri@1.2.0: {}
+ ms@2.1.3: {}
+
nanoid@3.3.11: {}
+ next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+
node-emoji@2.2.0:
dependencies:
'@sindresorhus/is': 4.6.0
@@ -2066,6 +3469,50 @@ snapshots:
proxy-from-env@1.1.0: {}
+ react-dom@19.2.3(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ scheduler: 0.27.0
+
+ react-refresh@0.18.0: {}
+
+ react-remove-scroll-bar@2.3.8(@types/react@19.2.8)(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-style-singleton: 2.2.3(@types/react@19.2.8)(react@19.2.3)
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ react-remove-scroll@2.7.2(@types/react@19.2.8)(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-remove-scroll-bar: 2.3.8(@types/react@19.2.8)(react@19.2.3)
+ react-style-singleton: 2.2.3(@types/react@19.2.8)(react@19.2.3)
+ tslib: 2.8.1
+ use-callback-ref: 1.3.3(@types/react@19.2.8)(react@19.2.3)
+ use-sidecar: 1.1.3(@types/react@19.2.8)(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ react-router@7.12.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ cookie: 1.1.1
+ react: 19.2.3
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 19.2.3(react@19.2.3)
+
+ react-style-singleton@2.2.3(@types/react@19.2.8)(react@19.2.3):
+ dependencies:
+ get-nonce: 1.0.1
+ react: 19.2.3
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ react@19.2.3: {}
+
readdirp@4.1.2: {}
resolve-pkg-maps@1.0.0: {}
@@ -2114,10 +3561,21 @@ snapshots:
dependencies:
mri: 1.2.0
+ scheduler@0.27.0: {}
+
+ semver@6.3.1: {}
+
+ set-cookie-parser@2.7.2: {}
+
skin-tone@2.0.0:
dependencies:
unicode-emoji-modifier-base: 1.0.0
+ sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+
source-map-js@1.2.1: {}
svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3):
@@ -2150,6 +3608,8 @@ snapshots:
magic-string: 0.30.21
zimmerframe: 1.1.4
+ tailwind-merge@3.4.0: {}
+
tailwindcss@4.1.18: {}
tapable@2.3.0: {}
@@ -2163,8 +3623,7 @@ snapshots:
toml@3.0.0: {}
- tslib@2.8.1:
- optional: true
+ tslib@2.8.1: {}
tsx@4.21.0:
dependencies:
@@ -2175,6 +3634,8 @@ snapshots:
tunnel@0.0.6: {}
+ tw-animate-css@1.4.0: {}
+
typescript@5.9.3: {}
undici-types@7.16.0: {}
@@ -2187,6 +3648,21 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
+ use-callback-ref@1.3.3(@types/react@19.2.8)(react@19.2.3):
+ dependencies:
+ react: 19.2.3
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.8
+
+ use-sidecar@1.1.3(@types/react@19.2.8)(react@19.2.3):
+ dependencies:
+ detect-node-es: 1.1.0
+ react: 19.2.3
+ tslib: 2.8.1
+ optionalDependencies:
+ '@types/react': 19.2.8
+
vitefu@1.1.1(rolldown-vite@7.2.5(@types/node@24.10.9)(jiti@2.6.1)(tsx@4.21.0)):
optionalDependencies:
vite: rolldown-vite@7.2.5(@types/node@24.10.9)(jiti@2.6.1)(tsx@4.21.0)
@@ -2196,4 +3672,11 @@ snapshots:
defaults: 1.0.4
optional: true
+ yallist@3.1.1: {}
+
zimmerframe@1.1.4: {}
+
+ zustand@5.0.10(@types/react@19.2.8)(react@19.2.3):
+ optionalDependencies:
+ '@types/react': 19.2.8
+ react: 19.2.3
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 1f7ebe9..a64d7e9 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -3,10 +3,10 @@
"version": "0.2.0-alpha.1",
"identifier": "com.dropout.launcher",
"build": {
- "beforeDevCommand": "pnpm --filter @dropout/ui dev",
- "beforeBuildCommand": "pnpm --filter @dropout/ui build",
+ "beforeDevCommand": "pnpm --filter @dropout/ui-new dev",
+ "beforeBuildCommand": "pnpm --filter @dropout/ui-new build",
"devUrl": "http://localhost:5173",
- "frontendDist": "../packages/ui/dist"
+ "frontendDist": "../packages/ui-new/dist"
},
"app": {
"windows": [