aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui-new/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui-new/src/components')
-rw-r--r--packages/ui-new/src/components/bottom-bar.tsx269
-rw-r--r--packages/ui-new/src/components/download-monitor.tsx61
-rw-r--r--packages/ui-new/src/components/game-console.tsx290
-rw-r--r--packages/ui-new/src/components/instance-creation-modal.tsx566
-rw-r--r--packages/ui-new/src/components/instance-editor-modal.tsx548
-rw-r--r--packages/ui-new/src/components/login-modal.tsx156
-rw-r--r--packages/ui-new/src/components/particle-background.tsx63
-rw-r--r--packages/ui-new/src/components/sidebar.tsx180
-rw-r--r--packages/ui-new/src/components/ui/badge.tsx46
-rw-r--r--packages/ui-new/src/components/ui/button.tsx62
-rw-r--r--packages/ui-new/src/components/ui/card.tsx92
-rw-r--r--packages/ui-new/src/components/ui/checkbox.tsx32
-rw-r--r--packages/ui-new/src/components/ui/dialog.tsx141
-rw-r--r--packages/ui-new/src/components/ui/input.tsx21
-rw-r--r--packages/ui-new/src/components/ui/label.tsx24
-rw-r--r--packages/ui-new/src/components/ui/scroll-area.tsx56
-rw-r--r--packages/ui-new/src/components/ui/select.tsx188
-rw-r--r--packages/ui-new/src/components/ui/separator.tsx28
-rw-r--r--packages/ui-new/src/components/ui/sonner.tsx38
-rw-r--r--packages/ui-new/src/components/ui/switch.tsx29
-rw-r--r--packages/ui-new/src/components/ui/tabs.tsx66
-rw-r--r--packages/ui-new/src/components/ui/textarea.tsx18
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 }