diff options
Diffstat (limited to 'packages/ui-new/src/components')
22 files changed, 2974 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..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 } |