diff options
Diffstat (limited to 'packages/ui-new/src/components')
22 files changed, 3016 insertions, 0 deletions
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..f3902d9 --- /dev/null +++ b/packages/ui-new/src/components/download-monitor.tsx @@ -0,0 +1,62 @@ +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 + type="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..74e0873 --- /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 / 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..425ab9e --- /dev/null +++ b/packages/ui-new/src/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import { mergeProps } from "@base-ui/react/merge-props"; +import { useRender } from "@base-ui/react/use-render"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "h-5 gap-1 rounded-none border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>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 overflow-hidden group/badge", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props, + ), + render, + state: { + slot: "badge", + variant, + }, + }); +} + +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..7dee494 --- /dev/null +++ b/packages/ui-new/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "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:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground", + destructive: + "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", + icon: "size-8", + "icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-7 rounded-none", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { + return ( + <ButtonPrimitive + data-slot="button" + 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..b7084a0 --- /dev/null +++ b/packages/ui-new/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( + <div + data-slot="card" + data-size={size} + className={cn( + "ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none group/card flex flex-col", + className, + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-header" + className={cn( + "gap-1 rounded-none px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]", + className, + )} + {...props} + /> + ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-title" + className={cn( + "text-sm font-medium group-data-[size=sm]/card:text-sm", + className, + )} + {...props} + /> + ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-description" + className={cn("text-muted-foreground text-xs/relaxed", 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-4 group-data-[size=sm]/card:px-3", className)} + {...props} + /> + ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-footer" + className={cn( + "rounded-none border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", + 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..9f22cea --- /dev/null +++ b/packages/ui-new/src/components/ui/checkbox.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"; +import { CheckIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { + return ( + <CheckboxPrimitive.Root + data-slot="checkbox" + className={cn( + "border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-none border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-1 aria-invalid:ring-1 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + {...props} + > + <CheckboxPrimitive.Indicator + data-slot="checkbox-indicator" + className="[&>svg]:size-3.5 grid place-content-center text-current transition-none" + > + <CheckIcon /> + </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..033b47c --- /dev/null +++ b/packages/ui-new/src/components/ui/dialog.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { XIcon } from "lucide-react"; +import type * as React from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return <DialogPrimitive.Root data-slot="dialog" {...props} />; +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />; +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />; +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return <DialogPrimitive.Close data-slot="dialog-close" {...props} />; +} + +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + <DialogPrimitive.Backdrop + data-slot="dialog-overlay" + className={cn( + "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", + className, + )} + {...props} + /> + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean; +}) { + return ( + <DialogPortal> + <DialogOverlay /> + <DialogPrimitive.Popup + data-slot="dialog-content" + className={cn( + "bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-none p-4 text-xs/relaxed ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none", + className, + )} + {...props} + > + {children} + {showCloseButton && ( + <DialogPrimitive.Close + data-slot="dialog-close" + render={ + <Button + variant="ghost" + className="absolute top-2 right-2" + size="icon-sm" + /> + } + > + <XIcon /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + )} + </DialogPrimitive.Popup> + </DialogPortal> + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="dialog-header" + className={cn("gap-1 text-left flex flex-col", className)} + {...props} + /> + ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean; +}) { + return ( + <div + data-slot="dialog-footer" + className={cn( + "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", + className, + )} + {...props} + > + {children} + {showCloseButton && ( + <DialogPrimitive.Close render={<Button variant="outline" />}> + Close + </DialogPrimitive.Close> + )} + </div> + ); +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + <DialogPrimitive.Title + data-slot="dialog-title" + className={cn("text-sm font-medium", className)} + {...props} + /> + ); +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + <DialogPrimitive.Description + data-slot="dialog-description" + className={cn( + "text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed *:[a]:underline *:[a]:underline-offset-3", + 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..bb0390a --- /dev/null +++ b/packages/ui-new/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import { Input as InputPrimitive } from "@base-ui/react/input"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + <InputPrimitive + type={type} + data-slot="input" + className={cn( + "dark:bg-input/30 border-input 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:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-none border bg-transparent px-2.5 py-1 text-xs transition-colors file:h-6 file:text-xs file:font-medium focus-visible:ring-1 aria-invalid:ring-1 md:text-xs file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", + 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..9a998c7 --- /dev/null +++ b/packages/ui-new/src/components/ui/label.tsx @@ -0,0 +1,19 @@ +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( + // biome-ignore lint/a11y/noLabelWithoutControl: shadcn component + <label + data-slot="label" + className={cn( + "gap-2 text-xs leading-none group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed", + 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..4a68eb2 --- /dev/null +++ b/packages/ui-new/src/components/ui/scroll-area.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"; +import { cn } from "@/lib/utils"; + +function ScrollArea({ + className, + children, + ...props +}: ScrollAreaPrimitive.Root.Props) { + 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 +}: ScrollAreaPrimitive.Scrollbar.Props) { + return ( + <ScrollAreaPrimitive.Scrollbar + data-slot="scroll-area-scrollbar" + data-orientation={orientation} + orientation={orientation} + className={cn( + "data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none", + className, + )} + {...props} + > + <ScrollAreaPrimitive.Thumb + data-slot="scroll-area-thumb" + className="rounded-none bg-border relative flex-1" + /> + </ScrollAreaPrimitive.Scrollbar> + ); +} + +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..210adba --- /dev/null +++ b/packages/ui-new/src/components/ui/select.tsx @@ -0,0 +1,199 @@ +import { Select as SelectPrimitive } from "@base-ui/react/select"; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; +import type * as React from "react"; +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; + +function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { + return ( + <SelectPrimitive.Group + data-slot="select-group" + className={cn("scroll-my-1", className)} + {...props} + /> + ); +} + +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + <SelectPrimitive.Value + data-slot="select-value" + className={cn("flex flex-1 text-left", className)} + {...props} + /> + ); +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: SelectPrimitive.Trigger.Props & { + size?: "sm" | "default"; +}) { + return ( + <SelectPrimitive.Trigger + data-slot="select-trigger" + data-size={size} + className={cn( + "border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 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:aria-invalid:border-destructive/50 gap-1.5 rounded-none border bg-transparent py-2 pr-2 pl-2.5 text-xs transition-colors select-none focus-visible:ring-1 aria-invalid:ring-1 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + className, + )} + {...props} + > + {children} + <SelectPrimitive.Icon + render={ + <ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" /> + } + /> + </SelectPrimitive.Trigger> + ); +} + +function SelectContent({ + className, + children, + side = "bottom", + sideOffset = 4, + align = "center", + alignOffset = 0, + alignItemWithTrigger = true, + ...props +}: SelectPrimitive.Popup.Props & + Pick< + SelectPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger" + >) { + return ( + <SelectPrimitive.Portal> + <SelectPrimitive.Positioner + side={side} + sideOffset={sideOffset} + align={align} + alignOffset={alignOffset} + alignItemWithTrigger={alignItemWithTrigger} + className="isolate z-50" + > + <SelectPrimitive.Popup + data-slot="select-content" + data-align-trigger={alignItemWithTrigger} + className={cn( + "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-none shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", + className, + )} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.List>{children}</SelectPrimitive.List> + <SelectScrollDownButton /> + </SelectPrimitive.Popup> + </SelectPrimitive.Positioner> + </SelectPrimitive.Portal> + ); +} + +function SelectLabel({ + className, + ...props +}: SelectPrimitive.GroupLabel.Props) { + return ( + <SelectPrimitive.GroupLabel + data-slot="select-label" + className={cn("text-muted-foreground px-2 py-2 text-xs", className)} + {...props} + /> + ); +} + +function SelectItem({ + className, + children, + ...props +}: SelectPrimitive.Item.Props) { + return ( + <SelectPrimitive.Item + data-slot="select-item" + className={cn( + "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", + className, + )} + {...props} + > + <SelectPrimitive.ItemText className="flex flex-1 gap-2 shrink-0 whitespace-nowrap"> + {children} + </SelectPrimitive.ItemText> + <SelectPrimitive.ItemIndicator + render={ + <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" /> + } + > + <CheckIcon className="pointer-events-none" /> + </SelectPrimitive.ItemIndicator> + </SelectPrimitive.Item> + ); +} + +function SelectSeparator({ + className, + ...props +}: SelectPrimitive.Separator.Props) { + return ( + <SelectPrimitive.Separator + data-slot="select-separator" + className={cn("bg-border -mx-1 h-px pointer-events-none", className)} + {...props} + /> + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) { + return ( + <SelectPrimitive.ScrollUpArrow + data-slot="select-scroll-up-button" + className={cn( + "bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 top-0 w-full", + className, + )} + {...props} + > + <ChevronUpIcon /> + </SelectPrimitive.ScrollUpArrow> + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) { + return ( + <SelectPrimitive.ScrollDownArrow + data-slot="select-scroll-down-button" + className={cn( + "bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full", + className, + )} + {...props} + > + <ChevronDownIcon /> + </SelectPrimitive.ScrollDownArrow> + ); +} + +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..e91a862 --- /dev/null +++ b/packages/ui-new/src/components/ui/separator.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"; + +import { cn } from "@/lib/utils"; + +function Separator({ + className, + orientation = "horizontal", + ...props +}: SeparatorPrimitive.Props) { + return ( + <SeparatorPrimitive + data-slot="separator" + orientation={orientation} + className={cn( + "bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", + 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..d6e293d --- /dev/null +++ b/packages/ui-new/src/components/ui/sonner.tsx @@ -0,0 +1,43 @@ +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 + } + toastOptions={{ + classNames: { + toast: "cn-toast", + }, + }} + {...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..fef14e3 --- /dev/null +++ b/packages/ui-new/src/components/ui/switch.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Switch as SwitchPrimitive } from "@base-ui/react/switch"; + +import { cn } from "@/lib/utils"; + +function Switch({ + className, + size = "default", + ...props +}: SwitchPrimitive.Root.Props & { + size?: "sm" | "default"; +}) { + return ( + <SwitchPrimitive.Root + data-slot="switch" + data-size={size} + className={cn( + "data-checked:bg-primary data-unchecked:bg-input 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:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-1 aria-invalid:ring-1 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50", + className, + )} + {...props} + > + <SwitchPrimitive.Thumb + data-slot="switch-thumb" + className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform" + /> + </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..6349f40 --- /dev/null +++ b/packages/ui-new/src/components/ui/tabs.tsx @@ -0,0 +1,80 @@ +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: TabsPrimitive.Root.Props) { + return ( + <TabsPrimitive.Root + data-slot="tabs" + data-orientation={orientation} + className={cn( + "gap-2 group/tabs flex data-horizontal:flex-col", + className, + )} + {...props} + /> + ); +} + +const tabsListVariants = cva( + "rounded-none p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function TabsList({ + className, + variant = "default", + ...props +}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) { + return ( + <TabsPrimitive.List + data-slot="tabs-list" + data-variant={variant} + className={cn(tabsListVariants({ variant }), className)} + {...props} + /> + ); +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + <TabsPrimitive.Tab + data-slot="tabs-trigger" + className={cn( + "gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium group-data-vertical/tabs:py-[calc(--spacing(1.25))] [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", + "data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground", + "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", + className, + )} + {...props} + /> + ); +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + <TabsPrimitive.Panel + data-slot="tabs-content" + className={cn("text-xs/relaxed flex-1 outline-none", className)} + {...props} + /> + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }; 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..3c3e5d0 --- /dev/null +++ b/packages/ui-new/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import type * 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 dark:bg-input/30 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:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-none border bg-transparent px-2.5 py-2 text-xs transition-colors focus-visible:ring-1 aria-invalid:ring-1 md:text-xs placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + {...props} + /> + ); +} + +export { Textarea }; |