diff options
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 > 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 > 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, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; + + 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"> + > 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": [ |