aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui/src/pages')
-rw-r--r--packages/ui/src/pages/assistant-view.tsx.bk485
-rw-r--r--packages/ui/src/pages/home-view.tsx174
-rw-r--r--packages/ui/src/pages/index.tsx79
-rw-r--r--packages/ui/src/pages/instances-view.tsx315
-rw-r--r--packages/ui/src/pages/settings-view.tsx.bk1158
-rw-r--r--packages/ui/src/pages/settings.tsx310
-rw-r--r--packages/ui/src/pages/versions-view.tsx.bk662
7 files changed, 3183 insertions, 0 deletions
diff --git a/packages/ui/src/pages/assistant-view.tsx.bk b/packages/ui/src/pages/assistant-view.tsx.bk
new file mode 100644
index 0000000..56f827b
--- /dev/null
+++ b/packages/ui/src/pages/assistant-view.tsx.bk
@@ -0,0 +1,485 @@
+import {
+ AlertTriangle,
+ Bot,
+ Brain,
+ ChevronDown,
+ Loader2,
+ RefreshCw,
+ Send,
+ Settings,
+ Trash2,
+} from "lucide-react";
+import { marked } from "marked";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Textarea } from "@/components/ui/textarea";
+import { toNumber } from "@/lib/tsrs-utils";
+import { type Message, useAssistantStore } from "../stores/assistant-store";
+import { useSettingsStore } from "../stores/settings-store";
+import { useUiStore } from "../stores/ui-store";
+
+interface ParsedMessage {
+ thinking: string | null;
+ content: string;
+ isThinking: boolean;
+}
+
+function parseMessageContent(content: string): ParsedMessage {
+ if (!content) return { thinking: null, content: "", isThinking: false };
+
+ // Support both <thinking> and <think> (DeepSeek uses <think>)
+ let startTag = "<thinking>";
+ let endTag = "</thinking>";
+ let startIndex = content.indexOf(startTag);
+
+ if (startIndex === -1) {
+ startTag = "<think>";
+ endTag = "</think>";
+ startIndex = content.indexOf(startTag);
+ }
+
+ // Also check for encoded tags if they weren't decoded properly
+ if (startIndex === -1) {
+ startTag = "\u003cthink\u003e";
+ endTag = "\u003c/think\u003e";
+ startIndex = content.indexOf(startTag);
+ }
+
+ if (startIndex !== -1) {
+ const endIndex = content.indexOf(endTag, startIndex);
+
+ if (endIndex !== -1) {
+ // Completed thinking block
+ const before = content.substring(0, startIndex);
+ const thinking = content
+ .substring(startIndex + startTag.length, endIndex)
+ .trim();
+ const after = content.substring(endIndex + endTag.length);
+
+ return {
+ thinking,
+ content: (before + after).trim(),
+ isThinking: false,
+ };
+ } else {
+ // Incomplete thinking block (still streaming)
+ const before = content.substring(0, startIndex);
+ const thinking = content.substring(startIndex + startTag.length).trim();
+
+ return {
+ thinking,
+ content: before.trim(),
+ isThinking: true,
+ };
+ }
+ }
+
+ return { thinking: null, content, isThinking: false };
+}
+
+function renderMarkdown(content: string): string {
+ if (!content) return "";
+ try {
+ return marked(content, { breaks: true, gfm: true }) as string;
+ } catch {
+ return content;
+ }
+}
+
+export function AssistantView() {
+ const {
+ messages,
+ isProcessing,
+ isProviderHealthy,
+ streamingContent,
+ init,
+ checkHealth,
+ sendMessage,
+ clearHistory,
+ } = useAssistantStore();
+ const { settings } = useSettingsStore();
+ const { setView } = useUiStore();
+
+ const [input, setInput] = useState("");
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+ const messagesContainerRef = useRef<HTMLDivElement>(null);
+
+ const provider = settings.assistant.llmProvider;
+ const endpoint =
+ provider === "ollama"
+ ? settings.assistant.ollamaEndpoint
+ : settings.assistant.openaiEndpoint;
+ const model =
+ provider === "ollama"
+ ? settings.assistant.ollamaModel
+ : settings.assistant.openaiModel;
+
+ const getProviderName = (): string => {
+ if (provider === "ollama") {
+ return `Ollama (${model})`;
+ } else if (provider === "openai") {
+ return `OpenAI (${model})`;
+ }
+ return provider;
+ };
+
+ const getProviderHelpText = (): string => {
+ if (provider === "ollama") {
+ return `Please ensure Ollama is installed and running at ${endpoint}.`;
+ } else if (provider === "openai") {
+ return "Please check your OpenAI API key in Settings > AI Assistant.";
+ }
+ return "";
+ };
+
+ const scrollToBottom = useCallback(() => {
+ if (messagesContainerRef.current) {
+ setTimeout(() => {
+ if (messagesContainerRef.current) {
+ messagesContainerRef.current.scrollTop =
+ messagesContainerRef.current.scrollHeight;
+ }
+ }, 0);
+ }
+ }, []);
+
+ useEffect(() => {
+ init();
+ }, [init]);
+
+ useEffect(() => {
+ if (messages.length > 0 || isProcessing) {
+ scrollToBottom();
+ }
+ }, [messages.length, isProcessing, scrollToBottom]);
+
+ const handleSubmit = async () => {
+ if (!input.trim() || isProcessing) return;
+ const text = input;
+ setInput("");
+ await sendMessage(text, settings.assistant.enabled, provider, endpoint);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ };
+
+ const renderMessage = (message: Message, index: number) => {
+ const isUser = message.role === "user";
+ const parsed = parseMessageContent(message.content);
+
+ return (
+ <div
+ key={index}
+ className={`flex ${isUser ? "justify-end" : "justify-start"} mb-4`}
+ >
+ <div
+ className={`max-w-[80%] rounded-2xl px-4 py-3 ${
+ isUser
+ ? "bg-indigo-500 text-white rounded-br-none"
+ : "bg-zinc-800 text-zinc-100 rounded-bl-none"
+ }`}
+ >
+ {!isUser && parsed.thinking && (
+ <div className="mb-3 max-w-full overflow-hidden">
+ <details className="group" open={parsed.isThinking}>
+ <summary className="list-none cursor-pointer flex items-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors text-xs font-medium select-none bg-black/20 p-2 rounded-lg border border-white/5 w-fit mb-2 outline-none">
+ <Brain className="h-3 w-3" />
+ <span>Thinking Process</span>
+ <ChevronDown className="h-3 w-3 transition-transform duration-200 group-open:rotate-180" />
+ </summary>
+ <div className="pl-3 border-l-2 border-zinc-700 text-zinc-500 text-xs italic leading-relaxed whitespace-pre-wrap font-mono max-h-96 overflow-y-auto custom-scrollbar bg-black/10 p-2 rounded-r-md">
+ {parsed.thinking}
+ {parsed.isThinking && (
+ <span className="inline-block w-1.5 h-3 bg-zinc-500 ml-1 animate-pulse align-middle" />
+ )}
+ </div>
+ </details>
+ </div>
+ )}
+ <div
+ className="prose prose-invert max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: renderMarkdown(parsed.content),
+ }}
+ />
+ {!isUser && message.stats && (
+ <div className="mt-2 pt-2 border-t border-zinc-700/50">
+ <div className="text-xs text-zinc-400">
+ {message.stats.evalCount} tokens ·{" "}
+ {Math.round(toNumber(message.stats.totalDuration) / 1000000)}
+ ms
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ };
+
+ return (
+ <div className="h-full w-full flex flex-col gap-4 p-4 lg:p-8">
+ <div className="flex items-center justify-between mb-2">
+ <div className="flex items-center gap-3">
+ <div className="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
+ <Bot size={24} />
+ </div>
+ <div>
+ <h2 className="text-2xl font-bold">Game Assistant</h2>
+ <p className="text-zinc-400 text-sm">
+ Powered by {getProviderName()}
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {!settings.assistant.enabled ? (
+ <Badge
+ variant="outline"
+ className="bg-zinc-500/10 text-zinc-400 border-zinc-500/20"
+ >
+ <AlertTriangle className="h-3 w-3 mr-1" />
+ Disabled
+ </Badge>
+ ) : !isProviderHealthy ? (
+ <Badge
+ variant="outline"
+ className="bg-red-500/10 text-red-400 border-red-500/20"
+ >
+ <AlertTriangle className="h-3 w-3 mr-1" />
+ Offline
+ </Badge>
+ ) : (
+ <Badge
+ variant="outline"
+ className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
+ >
+ <div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse mr-1" />
+ Online
+ </Badge>
+ )}
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={checkHealth}
+ title="Check Connection"
+ disabled={isProcessing}
+ >
+ <RefreshCw
+ className={`h-4 w-4 ${isProcessing ? "animate-spin" : ""}`}
+ />
+ </Button>
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={clearHistory}
+ title="Clear History"
+ disabled={isProcessing}
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => setView("settings")}
+ title="Settings"
+ >
+ <Settings className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+
+ {/* Chat Area */}
+ <div className="flex-1 bg-black/20 border border-white/5 rounded-xl overflow-hidden flex flex-col relative">
+ {/* Warning when assistant is disabled */}
+ {!settings.assistant.enabled && (
+ <div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-10">
+ <Card className="bg-yellow-500/10 border-yellow-500/20">
+ <CardContent className="p-3 flex items-center gap-2">
+ <AlertTriangle className="h-4 w-4 text-yellow-500" />
+ <span className="text-yellow-500 text-sm font-medium">
+ Assistant is disabled. Enable it in Settings &gt; AI
+ Assistant.
+ </span>
+ </CardContent>
+ </Card>
+ </div>
+ )}
+
+ {/* Provider offline warning */}
+ {settings.assistant.enabled && !isProviderHealthy && (
+ <div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-10">
+ <Card className="bg-red-500/10 border-red-500/20">
+ <CardContent className="p-3 flex items-center gap-2">
+ <AlertTriangle className="h-4 w-4 text-red-500" />
+ <div className="flex flex-col">
+ <span className="text-red-500 text-sm font-medium">
+ Assistant is offline
+ </span>
+ <span className="text-red-400 text-xs">
+ {getProviderHelpText()}
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )}
+
+ {/* Messages Container */}
+ <ScrollArea className="flex-1 p-4 lg:p-6" ref={messagesContainerRef}>
+ {messages.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-full text-zinc-400 gap-4 mt-8">
+ <div className="p-4 bg-zinc-800/50 rounded-full">
+ <Bot className="h-12 w-12" />
+ </div>
+ <h3 className="text-xl font-medium">How can I help you today?</h3>
+ <p className="text-center max-w-md text-sm">
+ I can analyze your game logs, diagnose crashes, or explain mod
+ features.
+ {!settings.assistant.enabled && (
+ <span className="block mt-2 text-yellow-500">
+ Assistant is disabled. Enable it in{" "}
+ <button
+ type="button"
+ onClick={() => setView("settings")}
+ className="text-indigo-400 hover:underline"
+ >
+ Settings &gt; AI Assistant
+ </button>
+ .
+ </span>
+ )}
+ </p>
+ <div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-2 max-w-lg">
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput("How do I fix Minecraft crashing on launch?")
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ How do I fix Minecraft crashing on launch?
+ </div>
+ </Button>
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput("What's the best way to improve FPS?")
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ What's the best way to improve FPS?
+ </div>
+ </Button>
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput(
+ "Can you help me install Fabric for Minecraft 1.20.4?",
+ )
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ Can you help me install Fabric for 1.20.4?
+ </div>
+ </Button>
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput("What mods do you recommend for performance?")
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ What mods do you recommend for performance?
+ </div>
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <>
+ {messages.map((message, index) => renderMessage(message, index))}
+ {isProcessing && streamingContent && (
+ <div className="flex justify-start mb-4">
+ <div className="max-w-[80%] bg-zinc-800 text-zinc-100 rounded-2xl rounded-bl-none px-4 py-3">
+ <div
+ className="prose prose-invert max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: renderMarkdown(streamingContent),
+ }}
+ />
+ <div className="flex items-center gap-1 mt-2 text-xs text-zinc-400">
+ <Loader2 className="h-3 w-3 animate-spin" />
+ <span>Assistant is typing...</span>
+ </div>
+ </div>
+ </div>
+ )}
+ </>
+ )}
+ <div ref={messagesEndRef} />
+ </ScrollArea>
+
+ <Separator />
+
+ {/* Input Area */}
+ <div className="p-3 lg:p-4">
+ <div className="flex gap-2">
+ <Textarea
+ placeholder={
+ settings.assistant.enabled
+ ? "Ask about your game..."
+ : "Assistant is disabled. Enable it in Settings to use."
+ }
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ className="min-h-11 max-h-50 resize-none border-zinc-700 bg-zinc-900/50 focus:bg-zinc-900/80"
+ disabled={!settings.assistant.enabled || isProcessing}
+ />
+ <Button
+ onClick={handleSubmit}
+ disabled={
+ !settings.assistant.enabled || !input.trim() || isProcessing
+ }
+ className="px-6 bg-indigo-600 hover:bg-indigo-700 text-white"
+ >
+ {isProcessing ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Send className="h-4 w-4" />
+ )}
+ </Button>
+ </div>
+ <div className="mt-2 flex items-center justify-between">
+ <div className="text-xs text-zinc-500">
+ {settings.assistant.enabled
+ ? "Press Enter to send, Shift+Enter for new line"
+ : "Enable the assistant in Settings to use"}
+ </div>
+ <div className="text-xs text-zinc-500">
+ Model: {model} • Provider: {provider}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/home-view.tsx b/packages/ui/src/pages/home-view.tsx
new file mode 100644
index 0000000..4f80cb0
--- /dev/null
+++ b/packages/ui/src/pages/home-view.tsx
@@ -0,0 +1,174 @@
+import { useEffect, useState } from "react";
+import { BottomBar } from "@/components/bottom-bar";
+import type { SaturnEffect } from "@/lib/effects/SaturnEffect";
+import { useGameStore } from "../stores/game-store";
+import { useReleasesStore } from "../stores/releases-store";
+
+export function HomeView() {
+ const gameStore = useGameStore();
+ const releasesStore = useReleasesStore();
+ const [mouseX, setMouseX] = useState(0);
+ const [mouseY, setMouseY] = useState(0);
+
+ useEffect(() => {
+ releasesStore.loadReleases();
+ }, [releasesStore.loadReleases]);
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ const x = (e.clientX / window.innerWidth) * 2 - 1;
+ const y = (e.clientY / window.innerHeight) * 2 - 1;
+ setMouseX(x);
+ setMouseY(y);
+
+ // Forward mouse move to SaturnEffect (if available) for parallax/rotation interactions
+ try {
+ const saturn = (
+ window as unknown as {
+ getSaturnEffect?: () => SaturnEffect;
+ }
+ ).getSaturnEffect?.();
+ if (saturn?.handleMouseMove) {
+ saturn.handleMouseMove(e.clientX);
+ }
+ } catch {
+ /* best-effort, ignore errors from effect */
+ }
+ };
+
+ const handleSaturnMouseDown = (e: React.MouseEvent) => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseDown) {
+ saturn.handleMouseDown(e.clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnMouseUp = () => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseUp) {
+ saturn.handleMouseUp();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnMouseLeave = () => {
+ // Treat leaving the area as mouse-up for the effect
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseUp) {
+ saturn.handleMouseUp();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnTouchStart = (e: React.TouchEvent) => {
+ if (e.touches && e.touches.length === 1) {
+ try {
+ const clientX = e.touches[0].clientX;
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchStart) {
+ saturn.handleTouchStart(clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ };
+
+ const handleSaturnTouchMove = (e: React.TouchEvent) => {
+ if (e.touches && e.touches.length === 1) {
+ try {
+ const clientX = e.touches[0].clientX;
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchMove) {
+ saturn.handleTouchMove(clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ };
+
+ const handleSaturnTouchEnd = () => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchEnd) {
+ saturn.handleTouchEnd();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ return (
+ <div
+ className="relative z-10 h-full overflow-y-auto custom-scrollbar scroll-smooth"
+ style={{
+ overflow: releasesStore.isLoading ? "hidden" : "auto",
+ }}
+ >
+ {/* Hero Section (Full Height) - Interactive area */}
+ <div
+ role="tab"
+ className="min-h-full flex flex-col justify-end p-12 pb-32 cursor-grab active:cursor-grabbing select-none"
+ onMouseDown={handleSaturnMouseDown}
+ onMouseMove={handleMouseMove}
+ onMouseUp={handleSaturnMouseUp}
+ onMouseLeave={handleSaturnMouseLeave}
+ onTouchStart={handleSaturnTouchStart}
+ onTouchMove={handleSaturnTouchMove}
+ onTouchEnd={handleSaturnTouchEnd}
+ tabIndex={0}
+ >
+ {/* 3D Floating Hero Text */}
+ <div
+ className="transition-transform duration-200 ease-out origin-bottom-left"
+ style={{
+ transform: `perspective(1000px) rotateX(${mouseY * -1}deg) rotateY(${mouseX * 1}deg)`,
+ }}
+ >
+ <div className="flex items-center gap-3 mb-6">
+ <div className="h-px w-12 bg-white/50"></div>
+ <span className="text-xs font-mono font-bold tracking-[0.2em] text-white/50 uppercase">
+ Launcher Active
+ </span>
+ </div>
+
+ <h1 className="text-8xl font-black tracking-tighter text-white mb-6 leading-none">
+ MINECRAFT
+ </h1>
+
+ <div className="flex items-center gap-4">
+ <div className="bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-sm text-xs font-bold uppercase tracking-widest text-white shadow-sm">
+ Java Edition
+ </div>
+ <div className="h-4 w-px bg-white/20"></div>
+ <div className="text-sm text-zinc-400">
+ Latest Release{" "}
+ <span className="text-white font-medium">
+ {gameStore.latestRelease?.id || "..."}
+ </span>
+ </div>
+ </div>
+ </div>
+
+ {/* Action Area */}
+ <div className="mt-8 flex gap-4">
+ <div className="text-zinc-500 text-sm font-mono">
+ &gt; Ready to launch session.
+ </div>
+ </div>
+
+ <BottomBar />
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/index.tsx b/packages/ui/src/pages/index.tsx
new file mode 100644
index 0000000..093ccb2
--- /dev/null
+++ b/packages/ui/src/pages/index.tsx
@@ -0,0 +1,79 @@
+import { useEffect } from "react";
+import { Outlet, useLocation } from "react-router";
+import { ParticleBackground } from "@/components/particle-background";
+import { Sidebar } from "@/components/sidebar";
+import { useAuthStore } from "@/models/auth";
+import { useInstanceStore } from "@/models/instance";
+import { useSettingsStore } from "@/models/settings";
+
+export function IndexPage() {
+ const authStore = useAuthStore();
+ const settingsStore = useSettingsStore();
+ const instanceStore = useInstanceStore();
+
+ const location = useLocation();
+
+ useEffect(() => {
+ authStore.init();
+ settingsStore.refresh();
+ instanceStore.refresh();
+ }, [authStore.init, settingsStore.refresh, instanceStore.refresh]);
+
+ return (
+ <div className="relative h-screen w-full overflow-hidden bg-background font-sans">
+ <div className="absolute inset-0 z-0 bg-gray-100 dark:bg-[#09090b] overflow-hidden">
+ {settingsStore.config?.customBackgroundPath && (
+ <>
+ <img
+ src={settingsStore.config?.customBackgroundPath}
+ alt="Background"
+ className="absolute inset-0 w-full h-full object-cover transition-transform duration-[20s] ease-linear"
+ onError={(e) =>
+ console.error("Failed to load main background:", e)
+ }
+ />
+ {/* Dimming Overlay for readability */}
+ <div className="absolute inset-0 bg-black/50" />
+ </>
+ )}
+
+ {!settingsStore.config?.customBackgroundPath && (
+ <>
+ {settingsStore.theme === "dark" ? (
+ <div className="absolute inset-0 opacity-60 bg-linear-to-br from-emerald-900 via-zinc-900 to-indigo-950"></div>
+ ) : (
+ <div className="absolute inset-0 opacity-100 bg-linear-to-br from-emerald-100 via-gray-100 to-indigo-100"></div>
+ )}
+
+ {location.pathname === "/" && <ParticleBackground />}
+
+ <div className="absolute inset-0 bg-linear-to-t from-zinc-900 via-transparent to-black/50 dark:from-zinc-900 dark:to-black/50"></div>
+ </>
+ )}
+
+ {/* Subtle Grid Overlay */}
+ <div
+ className="absolute inset-0 z-0 dark:opacity-10 opacity-30 pointer-events-none"
+ style={{
+ backgroundImage: `linear-gradient(${
+ settingsStore.config?.theme === "dark" ? "#ffffff" : "#000000"
+ } 1px, transparent 1px), linear-gradient(90deg, ${
+ settingsStore.config?.theme === "dark" ? "#ffffff" : "#000000"
+ } 1px, transparent 1px)`,
+ backgroundSize: "40px 40px",
+ maskImage:
+ "radial-gradient(circle at 50% 50%, black 30%, transparent 70%)",
+ }}
+ />
+ </div>
+
+ <div className="size-full flex flex-row p-4 space-x-4 z-20 relative">
+ <Sidebar />
+
+ <main className="size-full overflow-hidden">
+ <Outlet />
+ </main>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/instances-view.tsx b/packages/ui/src/pages/instances-view.tsx
new file mode 100644
index 0000000..1634905
--- /dev/null
+++ b/packages/ui/src/pages/instances-view.tsx
@@ -0,0 +1,315 @@
+import { Copy, Edit2, Plus, Trash2 } from "lucide-react";
+import { useEffect, useState } from "react";
+import InstanceCreationModal from "@/components/instance-creation-modal";
+import InstanceEditorModal from "@/components/instance-editor-modal";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { toNumber } from "@/lib/tsrs-utils";
+import { useInstanceStore } from "@/models/instance";
+import type { Instance } from "../types/bindings/instance";
+
+export function InstancesView() {
+ const instancesStore = useInstanceStore();
+
+ // Modal / UI state
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [showDuplicateModal, setShowDuplicateModal] = useState(false);
+
+ // Selected / editing instance state
+ const [selectedInstance, setSelectedInstance] = useState<Instance | null>(
+ null,
+ );
+ const [editingInstance, setEditingInstance] = useState<Instance | null>(null);
+
+ // Form fields
+ const [duplicateName, setDuplicateName] = useState("");
+
+ useEffect(() => {
+ instancesStore.refresh();
+ }, [instancesStore.refresh]);
+
+ // Handlers to open modals
+ const openCreate = () => {
+ setShowCreateModal(true);
+ };
+
+ const openEdit = (instance: Instance) => {
+ setEditingInstance({ ...instance });
+ setShowEditModal(true);
+ };
+
+ const openDelete = (instance: Instance) => {
+ setSelectedInstance(instance);
+ setShowDeleteConfirm(true);
+ };
+
+ const openDuplicate = (instance: Instance) => {
+ setSelectedInstance(instance);
+ setDuplicateName(`${instance.name} (Copy)`);
+ setShowDuplicateModal(true);
+ };
+
+ const confirmDelete = async () => {
+ if (!selectedInstance) return;
+ await instancesStore.delete(selectedInstance.id);
+ setSelectedInstance(null);
+ setShowDeleteConfirm(false);
+ };
+
+ const confirmDuplicate = async () => {
+ if (!selectedInstance) return;
+ const name = duplicateName.trim();
+ if (!name) return;
+ await instancesStore.duplicate(selectedInstance.id, name);
+ setSelectedInstance(null);
+ setDuplicateName("");
+ setShowDuplicateModal(false);
+ };
+
+ const formatDate = (timestamp: number): string =>
+ new Date(timestamp * 1000).toLocaleDateString();
+
+ const formatLastPlayed = (timestamp: number): string => {
+ const date = new Date(timestamp * 1000);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days === 0) return "Today";
+ if (days === 1) return "Yesterday";
+ if (days < 7) return `${days} days ago`;
+ return date.toLocaleDateString();
+ };
+
+ return (
+ <div className="h-full flex flex-col gap-4 p-6 overflow-y-auto">
+ <div className="flex items-center justify-between">
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
+ Instances
+ </h1>
+ <Button
+ type="button"
+ onClick={openCreate}
+ className="px-4 py-2 transition-colors"
+ >
+ <Plus size={18} />
+ Create Instance
+ </Button>
+ </div>
+
+ {instancesStore.instances.length === 0 ? (
+ <div className="flex-1 flex items-center justify-center">
+ <div className="text-center text-gray-500 dark:text-gray-400">
+ <p className="text-lg mb-2">No instances yet</p>
+ <p className="text-sm">Create your first instance to get started</p>
+ </div>
+ </div>
+ ) : (
+ <ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ {instancesStore.instances.map((instance) => {
+ const isActive = instancesStore.activeInstance?.id === instance.id;
+
+ return (
+ <li
+ key={instance.id}
+ onClick={() => instancesStore.setActiveInstance(instance)}
+ onKeyDown={(e) =>
+ e.key === "Enter" &&
+ instancesStore.setActiveInstance(instance)
+ }
+ className={`relative p-4 text-left border-2 transition-all cursor-pointer hover:border-blue-500 ${
+ isActive ? "border-blue-500" : "border-transparent"
+ } bg-gray-100 dark:bg-gray-800`}
+ >
+ {/* Instance Icon */}
+ {instance.iconPath ? (
+ <div className="w-12 h-12 mb-3 rounded overflow-hidden">
+ <img
+ src={instance.iconPath}
+ alt={instance.name}
+ className="w-full h-full object-cover"
+ />
+ </div>
+ ) : (
+ <div className="w-12 h-12 mb-3 rounded bg-linear-to-br from-blue-500 to-purple-600 flex items-center justify-center">
+ <span className="text-white font-bold text-lg">
+ {instance.name.charAt(0).toUpperCase()}
+ </span>
+ </div>
+ )}
+
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
+ {instance.name}
+ </h3>
+
+ <div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
+ {instance.versionId ? (
+ <p className="truncate">Version: {instance.versionId}</p>
+ ) : (
+ <p className="text-gray-400">No version selected</p>
+ )}
+
+ {instance.modLoader && (
+ <p className="truncate">
+ Mod Loader:{" "}
+ <span className="capitalize">{instance.modLoader}</span>
+ </p>
+ )}
+
+ <p className="truncate">
+ Created: {formatDate(toNumber(instance.createdAt))}
+ </p>
+
+ {instance.lastPlayed && (
+ <p className="truncate">
+ Last played:{" "}
+ {formatLastPlayed(toNumber(instance.lastPlayed))}
+ </p>
+ )}
+ </div>
+
+ {/* Action Buttons */}
+ <div className="mt-4 flex gap-2">
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openEdit(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-sm transition-colors"
+ >
+ <Edit2 size={14} />
+ Edit
+ </button>
+
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openDuplicate(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-sm transition-colors"
+ >
+ <Copy size={14} />
+ Duplicate
+ </button>
+
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openDelete(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded text-sm transition-colors"
+ >
+ <Trash2 size={14} />
+ Delete
+ </button>
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ )}
+
+ <InstanceCreationModal
+ open={showCreateModal}
+ onOpenChange={setShowCreateModal}
+ />
+
+ <InstanceEditorModal
+ open={showEditModal}
+ instance={editingInstance}
+ onOpenChange={(open) => {
+ setShowEditModal(open);
+ if (!open) setEditingInstance(null);
+ }}
+ />
+
+ {/* Delete Confirmation */}
+ <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Instance</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete "{selectedInstance?.name}"? This
+ action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowDeleteConfirm(false);
+ setSelectedInstance(null);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmDelete}
+ className="bg-red-600 text-white hover:bg-red-500"
+ >
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Duplicate Modal */}
+ <Dialog open={showDuplicateModal} onOpenChange={setShowDuplicateModal}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Duplicate Instance</DialogTitle>
+ <DialogDescription>
+ Provide a name for the duplicated instance.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="mt-4">
+ <Input
+ value={duplicateName}
+ onChange={(e) => setDuplicateName(e.target.value)}
+ placeholder="New instance name"
+ onKeyDown={(e) => e.key === "Enter" && confirmDuplicate()}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowDuplicateModal(false);
+ setSelectedInstance(null);
+ setDuplicateName("");
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmDuplicate}
+ disabled={!duplicateName.trim()}
+ >
+ Duplicate
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/settings-view.tsx.bk b/packages/ui/src/pages/settings-view.tsx.bk
new file mode 100644
index 0000000..ac43d9b
--- /dev/null
+++ b/packages/ui/src/pages/settings-view.tsx.bk
@@ -0,0 +1,1158 @@
+import { open } from "@tauri-apps/plugin-dialog";
+import {
+ Coffee,
+ Download,
+ FileJson,
+ Loader2,
+ RefreshCw,
+ Upload,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Textarea } from "@/components/ui/textarea";
+import { useSettingsStore } from "../stores/settings-store";
+
+const effectOptions = [
+ { value: "saturn", label: "Saturn" },
+ { value: "constellation", label: "Network (Constellation)" },
+];
+
+const logServiceOptions = [
+ { value: "paste.rs", label: "paste.rs (Free, No Account)" },
+ { value: "pastebin.com", label: "pastebin.com (Requires API Key)" },
+];
+
+const llmProviderOptions = [
+ { value: "ollama", label: "Ollama (Local)" },
+ { value: "openai", label: "OpenAI (Remote)" },
+];
+
+const languageOptions = [
+ { value: "auto", label: "Auto (Match User)" },
+ { value: "English", label: "English" },
+ { value: "Chinese", label: "中文" },
+ { value: "Japanese", label: "日本語" },
+ { value: "Korean", label: "한국어" },
+ { value: "Spanish", label: "Español" },
+ { value: "French", label: "Français" },
+ { value: "German", label: "Deutsch" },
+ { value: "Russian", label: "Русский" },
+];
+
+const ttsProviderOptions = [
+ { value: "disabled", label: "Disabled" },
+ { value: "piper", label: "Piper TTS (Local)" },
+ { value: "edge", label: "Edge TTS (Online)" },
+];
+
+const personas = [
+ {
+ value: "default",
+ label: "Minecraft Expert (Default)",
+ prompt:
+ "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.",
+ },
+ {
+ value: "technical",
+ label: "Technical Debugger",
+ prompt:
+ "You are a technical support specialist for Minecraft. Focus strictly on analyzing logs, identifying crash causes, and providing technical solutions. Be precise and avoid conversational filler.",
+ },
+ {
+ value: "concise",
+ label: "Concise Helper",
+ prompt:
+ "You are a direct and concise assistant. Provide answers in as few words as possible while remaining accurate. Use bullet points for lists.",
+ },
+ {
+ value: "explain",
+ label: "Teacher / Explainer",
+ prompt:
+ "You are a patient teacher. Explain Minecraft concepts, redstone mechanics, and mod features in simple, easy-to-understand terms suitable for beginners.",
+ },
+ {
+ value: "pirate",
+ label: "Pirate Captain",
+ prompt:
+ "You are a salty Minecraft Pirate Captain! Yarr! Speak like a pirate while helping the crew (the user) with their blocky adventures. Use terms like 'matey', 'landlubber', and 'treasure'.",
+ },
+];
+
+export function SettingsView() {
+ const {
+ settings,
+ backgroundUrl,
+ javaInstallations,
+ isDetectingJava,
+ showJavaDownloadModal,
+ selectedDownloadSource,
+ javaCatalog,
+ isLoadingCatalog,
+ catalogError,
+ selectedMajorVersion,
+ selectedImageType,
+ showOnlyRecommended,
+ searchQuery,
+ isDownloadingJava,
+ downloadProgress,
+ javaDownloadStatus,
+ pendingDownloads,
+ ollamaModels,
+ openaiModels,
+ isLoadingOllamaModels,
+ isLoadingOpenaiModels,
+ ollamaModelsError,
+ openaiModelsError,
+ showConfigEditor,
+ rawConfigContent,
+ configFilePath,
+ configEditorError,
+ filteredReleases,
+ availableMajorVersions,
+ installStatus,
+ selectedRelease,
+ currentModelOptions,
+ loadSettings,
+ saveSettings,
+ detectJava,
+ selectJava,
+ openJavaDownloadModal,
+ closeJavaDownloadModal,
+ loadJavaCatalog,
+ refreshCatalog,
+ loadPendingDownloads,
+ selectMajorVersion,
+ downloadJava,
+ cancelDownload,
+ resumeDownloads,
+ openConfigEditor,
+ closeConfigEditor,
+ saveRawConfig,
+ loadOllamaModels,
+ loadOpenaiModels,
+ set,
+ setSetting,
+ setAssistantSetting,
+ setFeatureFlag,
+ } = useSettingsStore();
+
+ // Mark potentially-unused variables as referenced so TypeScript does not report
+ // them as unused in this file (they are part of the store API and used elsewhere).
+ // This is a no-op but satisfies the compiler.
+ void selectedDownloadSource;
+ void javaCatalog;
+ void javaDownloadStatus;
+ void pendingDownloads;
+ void ollamaModels;
+ void openaiModels;
+ void isLoadingOllamaModels;
+ void isLoadingOpenaiModels;
+ void ollamaModelsError;
+ void openaiModelsError;
+ void selectedRelease;
+ void loadJavaCatalog;
+ void loadPendingDownloads;
+ void cancelDownload;
+ void resumeDownloads;
+ void setFeatureFlag;
+ const [selectedPersona, setSelectedPersona] = useState("default");
+ const [migrating, setMigrating] = useState(false);
+ const [activeTab, setActiveTab] = useState("appearance");
+
+ useEffect(() => {
+ loadSettings();
+ detectJava();
+ }, [loadSettings, detectJava]);
+
+ useEffect(() => {
+ if (activeTab === "assistant") {
+ if (settings.assistant.llmProvider === "ollama") {
+ loadOllamaModels();
+ } else if (settings.assistant.llmProvider === "openai") {
+ loadOpenaiModels();
+ }
+ }
+ }, [
+ activeTab,
+ settings.assistant.llmProvider,
+ loadOllamaModels,
+ loadOpenaiModels,
+ ]);
+
+ const handleSelectBackground = async () => {
+ try {
+ const selected = await open({
+ multiple: false,
+ filters: [
+ {
+ name: "Images",
+ extensions: ["png", "jpg", "jpeg", "webp", "gif"],
+ },
+ ],
+ });
+
+ if (selected && typeof selected === "string") {
+ setSetting("customBackgroundPath", selected);
+ saveSettings();
+ }
+ } catch (e) {
+ console.error("Failed to select background:", e);
+ toast.error("Failed to select background");
+ }
+ };
+
+ const handleClearBackground = () => {
+ setSetting("customBackgroundPath", null);
+ saveSettings();
+ };
+
+ const handleApplyPersona = (value: string) => {
+ const persona = personas.find((p) => p.value === value);
+ if (persona) {
+ setAssistantSetting("systemPrompt", persona.prompt);
+ setSelectedPersona(value);
+ saveSettings();
+ }
+ };
+
+ const handleResetSystemPrompt = () => {
+ const defaultPersona = personas.find((p) => p.value === "default");
+ if (defaultPersona) {
+ setAssistantSetting("systemPrompt", defaultPersona.prompt);
+ setSelectedPersona("default");
+ saveSettings();
+ }
+ };
+
+ const handleRunMigration = async () => {
+ if (migrating) return;
+ setMigrating(true);
+ try {
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ toast.success("Migration complete! Files migrated successfully");
+ } catch (e) {
+ console.error("Migration failed:", e);
+ toast.error(`Migration failed: ${e}`);
+ } finally {
+ setMigrating(false);
+ }
+ };
+
+ return (
+ <div className="h-full flex flex-col p-6 overflow-hidden">
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">
+ Settings
+ </h2>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={openConfigEditor}
+ className="gap-2"
+ >
+ <FileJson className="h-4 w-4" />
+ <span className="hidden sm:inline">Open JSON</span>
+ </Button>
+ </div>
+
+ <Tabs
+ value={activeTab}
+ onValueChange={setActiveTab}
+ className="flex-1 overflow-hidden"
+ >
+ <TabsList className="grid grid-cols-4 mb-6">
+ <TabsTrigger value="appearance">Appearance</TabsTrigger>
+ <TabsTrigger value="java">Java</TabsTrigger>
+ <TabsTrigger value="advanced">Advanced</TabsTrigger>
+ <TabsTrigger value="assistant">Assistant</TabsTrigger>
+ </TabsList>
+
+ <ScrollArea className="flex-1 pr-2">
+ <TabsContent value="appearance" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Appearance</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-3">Custom Background Image</Label>
+ <div className="flex items-center gap-6">
+ <div className="w-40 h-24 rounded-xl overflow-hidden bg-secondary border relative group shadow-lg">
+ {backgroundUrl ? (
+ <img
+ src={backgroundUrl}
+ alt="Background Preview"
+ className="w-full h-full object-cover"
+ onError={(e) => {
+ console.error("Failed to load image");
+ e.currentTarget.style.display = "none";
+ }}
+ />
+ ) : (
+ <div className="w-full h-full bg-linear-to-br from-emerald-900 via-zinc-900 to-indigo-950" />
+ )}
+ {!backgroundUrl && (
+ <div className="absolute inset-0 flex items-center justify-center text-xs text-white/50 bg-black/20">
+ Default Gradient
+ </div>
+ )}
+ </div>
+
+ <div className="flex flex-col gap-2">
+ <Button
+ variant="outline"
+ onClick={handleSelectBackground}
+ >
+ Select Image
+ </Button>
+ {backgroundUrl && (
+ <Button
+ variant="ghost"
+ className="text-red-500"
+ onClick={handleClearBackground}
+ >
+ Reset to Default
+ </Button>
+ )}
+ </div>
+ </div>
+ <p className="text-sm text-muted-foreground mt-3">
+ Select an image from your computer to replace the default
+ gradient background.
+ </p>
+ </div>
+
+ <Separator />
+
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Visual Effects</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable particle effects and animated gradients.
+ </p>
+ </div>
+ <Switch
+ checked={settings.enableVisualEffects}
+ onCheckedChange={(checked) => {
+ setSetting("enableVisualEffects", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {settings.enableVisualEffects && (
+ <div className="pl-4 border-l-2 border-border">
+ <div className="space-y-2">
+ <Label>Theme Effect</Label>
+ <Select
+ value={settings.activeEffect}
+ onValueChange={(value) => {
+ setSetting("activeEffect", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger className="w-52">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {effectOptions.map((option) => (
+ <SelectItem
+ key={option.value}
+ value={option.value}
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <p className="text-sm text-muted-foreground">
+ Select the active visual theme.
+ </p>
+ </div>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">GPU Acceleration</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable GPU acceleration for the interface.
+ </p>
+ </div>
+ <Switch
+ checked={settings.enableGpuAcceleration}
+ onCheckedChange={(checked) => {
+ setSetting("enableGpuAcceleration", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="java" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Java Environment</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-2">Java Path</Label>
+ <div className="flex gap-2">
+ <Input
+ value={settings.javaPath}
+ onChange={(e) => setSetting("javaPath", e.target.value)}
+ className="flex-1"
+ placeholder="java or full path to java executable"
+ />
+ <Button
+ variant="outline"
+ onClick={() => detectJava()}
+ disabled={isDetectingJava}
+ >
+ {isDetectingJava ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ "Detect"
+ )}
+ </Button>
+ </div>
+ <p className="text-sm text-muted-foreground mt-2">
+ Path to Java executable.
+ </p>
+ </div>
+
+ <div>
+ <Label className="mb-2">Memory Settings (MB)</Label>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label htmlFor="min-memory" className="text-sm">
+ Minimum Memory
+ </Label>
+ <Input
+ id="min-memory"
+ type="number"
+ value={settings.minMemory}
+ onChange={(e) =>
+ setSetting(
+ "minMemory",
+ parseInt(e.target.value, 10) || 1024,
+ )
+ }
+ min={512}
+ step={256}
+ />
+ </div>
+ <div>
+ <Label htmlFor="max-memory" className="text-sm">
+ Maximum Memory
+ </Label>
+ <Input
+ id="max-memory"
+ type="number"
+ value={settings.maxMemory}
+ onChange={(e) =>
+ setSetting(
+ "maxMemory",
+ parseInt(e.target.value, 10) || 2048,
+ )
+ }
+ min={1024}
+ step={256}
+ />
+ </div>
+ </div>
+ <p className="text-sm text-muted-foreground mt-2">
+ Memory allocation for Minecraft.
+ </p>
+ </div>
+
+ <Separator />
+
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <Label className="text-base">
+ Detected Java Installations
+ </Label>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => detectJava()}
+ disabled={isDetectingJava}
+ >
+ <RefreshCw
+ className={`h-4 w-4 mr-2 ${isDetectingJava ? "animate-spin" : ""}`}
+ />
+ Rescan
+ </Button>
+ </div>
+
+ {javaInstallations.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground border rounded-lg">
+ <Coffee className="h-12 w-12 mx-auto mb-4 opacity-30" />
+ <p>No Java installations detected</p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {javaInstallations.map((installation) => (
+ <Card
+ key={installation.path}
+ className={`p-3 cursor-pointer transition-colors ${
+ settings.javaPath === installation.path
+ ? "border-primary bg-primary/5"
+ : ""
+ }`}
+ onClick={() => selectJava(installation.path)}
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-medium flex items-center gap-2">
+ <Coffee className="h-4 w-4" />
+ {installation.version}
+ </div>
+ <div className="text-sm text-muted-foreground font-mono">
+ {installation.path}
+ </div>
+ </div>
+ {settings.javaPath === installation.path && (
+ <div className="h-5 w-5 text-primary">✓</div>
+ )}
+ </div>
+ </Card>
+ ))}
+ </div>
+ )}
+
+ <div className="mt-4">
+ <Button
+ variant="default"
+ className="w-full"
+ onClick={openJavaDownloadModal}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ Download Java
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="advanced" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Advanced Settings</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-2">Download Threads</Label>
+ <Input
+ type="number"
+ value={settings.downloadThreads}
+ onChange={(e) =>
+ setSetting(
+ "downloadThreads",
+ parseInt(e.target.value, 10) || 32,
+ )
+ }
+ min={1}
+ max={64}
+ />
+ <p className="text-sm text-muted-foreground mt-2">
+ Number of concurrent downloads.
+ </p>
+ </div>
+
+ <div>
+ <Label className="mb-2">Log Upload Service</Label>
+ <Select
+ value={settings.logUploadService}
+ onValueChange={(value) => {
+ setSetting("logUploadService", value as any);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {logServiceOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {settings.logUploadService === "pastebin.com" && (
+ <div>
+ <Label className="mb-2">Pastebin API Key</Label>
+ <Input
+ type="password"
+ value={settings.pastebinApiKey || ""}
+ onChange={(e) =>
+ setSetting("pastebinApiKey", e.target.value || null)
+ }
+ placeholder="Enter your Pastebin API key"
+ />
+ </div>
+ )}
+
+ <Separator />
+
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Use Shared Caches</Label>
+ <p className="text-sm text-muted-foreground">
+ Share downloaded assets between instances.
+ </p>
+ </div>
+ <Switch
+ checked={settings.useSharedCaches}
+ onCheckedChange={(checked) => {
+ setSetting("useSharedCaches", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {!settings.useSharedCaches && (
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">
+ Keep Legacy Per-Instance Storage
+ </Label>
+ <p className="text-sm text-muted-foreground">
+ Maintain separate cache folders for compatibility.
+ </p>
+ </div>
+ <Switch
+ checked={settings.keepLegacyPerInstanceStorage}
+ onCheckedChange={(checked) => {
+ setSetting("keepLegacyPerInstanceStorage", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+ )}
+
+ {settings.useSharedCaches && (
+ <div className="mt-4">
+ <Button
+ variant="outline"
+ className="w-full"
+ onClick={handleRunMigration}
+ disabled={migrating}
+ >
+ {migrating ? (
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ ) : (
+ <Upload className="h-4 w-4 mr-2" />
+ )}
+ {migrating
+ ? "Migrating..."
+ : "Migrate to Shared Caches"}
+ </Button>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="assistant" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">AI Assistant</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Enable Assistant</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable the AI assistant for help with Minecraft issues.
+ </p>
+ </div>
+ <Switch
+ checked={settings.assistant.enabled}
+ onCheckedChange={(checked) => {
+ setAssistantSetting("enabled", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {settings.assistant.enabled && (
+ <>
+ <div>
+ <Label className="mb-2">LLM Provider</Label>
+ <Select
+ value={settings.assistant.llmProvider}
+ onValueChange={(value) => {
+ setAssistantSetting("llmProvider", value as any);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {llmProviderOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Model</Label>
+ <Select
+ value={
+ settings.assistant.llmProvider === "ollama"
+ ? settings.assistant.ollamaModel
+ : settings.assistant.openaiModel
+ }
+ onValueChange={(value) => {
+ if (settings.assistant.llmProvider === "ollama") {
+ setAssistantSetting("ollamaModel", value);
+ } else {
+ setAssistantSetting("openaiModel", value);
+ }
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {currentModelOptions.map((model) => (
+ <SelectItem key={model.value} value={model.value}>
+ {model.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {settings.assistant.llmProvider === "ollama" && (
+ <div>
+ <Label className="mb-2">Ollama Endpoint</Label>
+ <Input
+ value={settings.assistant.ollamaEndpoint}
+ onChange={(e) => {
+ setAssistantSetting(
+ "ollamaEndpoint",
+ e.target.value,
+ );
+ saveSettings();
+ }}
+ placeholder="http://localhost:11434"
+ />
+ </div>
+ )}
+
+ {settings.assistant.llmProvider === "openai" && (
+ <>
+ <div>
+ <Label className="mb-2">OpenAI API Key</Label>
+ <Input
+ type="password"
+ value={settings.assistant.openaiApiKey || ""}
+ onChange={(e) => {
+ setAssistantSetting(
+ "openaiApiKey",
+ e.target.value || null,
+ );
+ saveSettings();
+ }}
+ placeholder="Enter your OpenAI API key"
+ />
+ </div>
+ <div>
+ <Label className="mb-2">OpenAI Endpoint</Label>
+ <Input
+ value={settings.assistant.openaiEndpoint}
+ onChange={(e) => {
+ setAssistantSetting(
+ "openaiEndpoint",
+ e.target.value,
+ );
+ saveSettings();
+ }}
+ placeholder="https://api.openai.com/v1"
+ />
+ </div>
+ </>
+ )}
+
+ <div>
+ <Label className="mb-2">Response Language</Label>
+ <Select
+ value={settings.assistant.responseLanguage}
+ onValueChange={(value) => {
+ setAssistantSetting("responseLanguage", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {languageOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Assistant Persona</Label>
+ <Select
+ value={selectedPersona}
+ onValueChange={handleApplyPersona}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {personas.map((persona) => (
+ <SelectItem
+ key={persona.value}
+ value={persona.value}
+ >
+ {persona.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <div className="mt-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleResetSystemPrompt}
+ >
+ Reset to Default
+ </Button>
+ </div>
+ </div>
+
+ <div>
+ <Label className="mb-2">System Prompt</Label>
+
+ <Textarea
+ value={settings.assistant.systemPrompt}
+ onChange={(e) => {
+ setAssistantSetting("systemPrompt", e.target.value);
+ saveSettings();
+ }}
+ rows={6}
+ className="font-mono text-sm"
+ />
+ </div>
+
+ <div>
+ <Label className="mb-2">Text-to-Speech</Label>
+
+ <Select
+ value={settings.assistant.ttsProvider}
+ onValueChange={(value) => {
+ setAssistantSetting("ttsProvider", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+
+ <SelectContent>
+ {ttsProviderOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </ScrollArea>
+ </Tabs>
+
+ {/* Java Download Modal */}
+ <Dialog
+ open={showJavaDownloadModal}
+ onOpenChange={closeJavaDownloadModal}
+ >
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Download Java</DialogTitle>
+ <DialogDescription>
+ Download and install Java for Minecraft.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-4">
+ <div>
+ <Label className="mb-2">Java Version</Label>
+ <Select
+ value={selectedMajorVersion?.toString() || ""}
+ onValueChange={(v) => selectMajorVersion(parseInt(v, 10))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select version" />
+ </SelectTrigger>
+ <SelectContent>
+ {availableMajorVersions.map((version) => (
+ <SelectItem key={version} value={version.toString()}>
+ Java {version}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Type</Label>
+ <Select
+ value={selectedImageType}
+ onValueChange={(v) => set({ selectedImageType: v as any })}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="jre">JRE (Runtime)</SelectItem>
+ <SelectItem value="jdk">JDK (Development)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="recommended"
+ checked={showOnlyRecommended}
+ onCheckedChange={(checked) =>
+ set({ showOnlyRecommended: !!checked })
+ }
+ />
+ <Label htmlFor="recommended">Show only LTS/Recommended</Label>
+ </div>
+
+ <div>
+ <Label className="mb-2">Search</Label>
+ <Input
+ placeholder="Search versions..."
+ value={searchQuery}
+ onChange={(e) => set({ searchQuery: e.target.value })}
+ />
+ </div>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={refreshCatalog}
+ disabled={isLoadingCatalog}
+ >
+ <RefreshCw
+ className={`h-4 w-4 mr-2 ${isLoadingCatalog ? "animate-spin" : ""}`}
+ />
+ Refresh Catalog
+ </Button>
+ </div>
+
+ <div className="md:col-span-2">
+ <ScrollArea className="h-75 pr-4">
+ {isLoadingCatalog ? (
+ <div className="flex items-center justify-center h-full">
+ <Loader2 className="h-8 w-8 animate-spin" />
+ </div>
+ ) : catalogError ? (
+ <div className="text-red-500 p-4">{catalogError}</div>
+ ) : filteredReleases.length === 0 ? (
+ <div className="text-muted-foreground p-4 text-center">
+ No Java versions found
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredReleases.map((release) => {
+ const status = installStatus(
+ release.majorVersion,
+ release.imageType,
+ );
+ return (
+ <Card
+ key={`${release.majorVersion}-${release.imageType}`}
+ className="p-3 cursor-pointer hover:bg-accent"
+ onClick={() =>
+ selectMajorVersion(release.majorVersion)
+ }
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-medium">
+ Java {release.majorVersion}{" "}
+ {release.imageType.toUpperCase()}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {release.releaseName} • {release.architecture}{" "}
+ {release.architecture}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {release.isLts && (
+ <Badge variant="secondary">LTS</Badge>
+ )}
+ {status === "installed" && (
+ <Badge variant="default">Installed</Badge>
+ )}
+ {status === "available" && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation();
+ selectMajorVersion(release.majorVersion);
+ downloadJava();
+ }}
+ >
+ <Download className="h-3 w-3 mr-1" />
+ Download
+ </Button>
+ )}
+ </div>
+ </div>
+ </Card>
+ );
+ })}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+ </div>
+
+ {isDownloadingJava && downloadProgress && (
+ <div className="mt-4 p-4 border rounded-lg">
+ <div className="flex justify-between items-center mb-2">
+ <span className="text-sm font-medium">
+ {downloadProgress.fileName}
+ </span>
+ <span className="text-sm text-muted-foreground">
+ {Math.round(downloadProgress.percentage)}%
+ </span>
+ </div>
+ <div className="w-full bg-secondary h-2 rounded-full overflow-hidden">
+ <div
+ className="bg-primary h-full transition-all duration-300"
+ style={{ width: `${downloadProgress.percentage}%` }}
+ />
+ </div>
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={closeJavaDownloadModal}
+ disabled={isDownloadingJava}
+ >
+ Cancel
+ </Button>
+ {selectedMajorVersion && (
+ <Button
+ onClick={() => downloadJava()}
+ disabled={isDownloadingJava}
+ >
+ {isDownloadingJava ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Downloading...
+ </>
+ ) : (
+ <>
+ <Download className="mr-2 h-4 w-4" />
+ Download Java {selectedMajorVersion}
+ </>
+ )}
+ </Button>
+ )}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Config Editor Modal */}
+ <Dialog open={showConfigEditor} onOpenChange={closeConfigEditor}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Edit Configuration</DialogTitle>
+ <DialogDescription>
+ Edit the raw JSON configuration file.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="text-sm text-muted-foreground mb-2">
+ File: {configFilePath}
+ </div>
+
+ {configEditorError && (
+ <div className="text-red-500 p-3 bg-red-50 dark:bg-red-950/30 rounded-md">
+ {configEditorError}
+ </div>
+ )}
+
+ <Textarea
+ value={rawConfigContent}
+ onChange={(e) => set({ rawConfigContent: e.target.value })}
+ className="font-mono text-sm h-100 resize-none"
+ spellCheck={false}
+ />
+
+ <DialogFooter>
+ <Button variant="outline" onClick={closeConfigEditor}>
+ Cancel
+ </Button>
+ <Button onClick={() => saveRawConfig()}>Save Changes</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/settings.tsx b/packages/ui/src/pages/settings.tsx
new file mode 100644
index 0000000..440a5dc
--- /dev/null
+++ b/packages/ui/src/pages/settings.tsx
@@ -0,0 +1,310 @@
+import { toNumber } from "es-toolkit/compat";
+import { FileJsonIcon } from "lucide-react";
+import { useEffect, useState } from "react";
+import { migrateSharedCaches } from "@/client";
+import { ConfigEditor } from "@/components/config-editor";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+ FieldLegend,
+ FieldSet,
+} from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useSettingsStore } from "@/models/settings";
+
+export type SettingsTab = "general" | "appearance" | "advanced";
+
+export function SettingsPage() {
+ const { config, ...settings } = useSettingsStore();
+ const [showConfigEditor, setShowConfigEditor] = useState<boolean>(false);
+ const [activeTab, setActiveTab] = useState<SettingsTab>("general");
+
+ useEffect(() => {
+ if (!config) settings.refresh();
+ }, [config, settings.refresh]);
+
+ const renderScrollArea = () => {
+ if (!config) {
+ return (
+ <div className="size-full justify-center items-center">
+ <Spinner />
+ </div>
+ );
+ }
+ return (
+ <ScrollArea className="size-full pr-2">
+ <TabsContent value="general" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">General</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FieldGroup>
+ <FieldSet>
+ <FieldLegend>Window Options</FieldLegend>
+ <FieldDescription>
+ May not work on some platforms like Linux Niri.
+ </FieldDescription>
+ <FieldGroup>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <Field>
+ <FieldLabel htmlFor="width">
+ Window Default Width
+ </FieldLabel>
+ <Input
+ type="number"
+ name="width"
+ value={config?.width}
+ onChange={(e) => {
+ settings.merge({
+ width: toNumber(e.target.value),
+ });
+ }}
+ onBlur={() => {
+ settings.save();
+ }}
+ min={800}
+ max={3840}
+ />
+ </Field>
+ <Field>
+ <FieldLabel htmlFor="height">
+ Window Default Height
+ </FieldLabel>
+ <Input
+ type="number"
+ name="height"
+ value={config?.height}
+ onChange={(e) => {
+ settings.merge({
+ height: toNumber(e.target.value),
+ });
+ }}
+ onBlur={() => {
+ settings.save();
+ }}
+ min={600}
+ max={2160}
+ />
+ </Field>
+ </div>
+ <Field className="flex flex-row items-center justify-between">
+ <FieldContent>
+ <FieldLabel htmlFor="gpu-acceleration">
+ GPU Acceleration
+ </FieldLabel>
+ <FieldDescription>
+ Enable GPU acceleration for the interface.
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ checked={config?.enableGpuAcceleration}
+ onCheckedChange={(checked) => {
+ settings.merge({
+ enableGpuAcceleration: checked,
+ });
+ settings.save();
+ }}
+ />
+ </Field>
+ </FieldGroup>
+ </FieldSet>
+ <FieldSet>
+ <FieldLegend>Network Options</FieldLegend>
+ <Field>
+ <Label htmlFor="download-threads">Download Threads</Label>
+ <Input
+ type="number"
+ name="download-threads"
+ value={config?.downloadThreads}
+ onChange={(e) => {
+ settings.merge({
+ downloadThreads: toNumber(e.target.value),
+ });
+ }}
+ onBlur={() => {
+ settings.save();
+ }}
+ min={1}
+ max={64}
+ />
+ </Field>
+ </FieldSet>
+ </FieldGroup>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ <TabsContent value="java" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">
+ Java Installations
+ </CardTitle>
+ <CardContent></CardContent>
+ </CardHeader>
+ </Card>
+ </TabsContent>
+ <TabsContent value="appearance" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">Appearance</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FieldGroup>
+ <Field className="flex flex-row">
+ <FieldContent>
+ <FieldLabel htmlFor="theme">Theme</FieldLabel>
+ <FieldDescription>
+ Select your prefered theme.
+ </FieldDescription>
+ </FieldContent>
+ <Select
+ items={[
+ { label: "Dark", value: "dark" },
+ { label: "Light", value: "light" },
+ { label: "System", value: "system" },
+ ]}
+ value={config.theme}
+ onValueChange={async (value) => {
+ if (
+ value === "system" ||
+ value === "light" ||
+ value === "dark"
+ ) {
+ settings.merge({ theme: value });
+ await settings.save();
+ settings.applyTheme(value);
+ }
+ }}
+ >
+ <SelectTrigger className="w-full max-w-48">
+ <SelectValue placeholder="Please select a prefered theme" />
+ </SelectTrigger>
+ <SelectContent alignItemWithTrigger={false}>
+ <SelectGroup>
+ <SelectItem value="system">System</SelectItem>
+ <SelectItem value="light">Light</SelectItem>
+ <SelectItem value="dark">Dark</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </Field>
+ </FieldGroup>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ <TabsContent value="advanced" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">Advanced</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FieldGroup>
+ <FieldSet>
+ <FieldLegend>Advanced Options</FieldLegend>
+ <FieldGroup>
+ <Field className="flex flex-row items-center justify-between">
+ <FieldContent>
+ <FieldLabel htmlFor="use-shared-caches">
+ Use Shared Caches
+ </FieldLabel>
+ <FieldDescription>
+ Share downloaded assets between instances.
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ checked={config?.useSharedCaches}
+ onCheckedChange={async (checked) => {
+ checked && (await migrateSharedCaches());
+ settings.merge({
+ useSharedCaches: checked,
+ });
+ settings.save();
+ }}
+ />
+ </Field>
+ <Field className="flex flex-row items-center justify-between">
+ <FieldContent>
+ <FieldLabel htmlFor="keep-per-instance-storage">
+ Keep Legacy Per-Instance Storage
+ </FieldLabel>
+ <FieldDescription>
+ Maintain separate cache folders for compatibility.
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ checked={config?.keepLegacyPerInstanceStorage}
+ onCheckedChange={(checked) => {
+ settings.merge({
+ keepLegacyPerInstanceStorage: checked,
+ });
+ settings.save();
+ }}
+ />
+ </Field>
+ </FieldGroup>
+ </FieldSet>
+ </FieldGroup>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </ScrollArea>
+ );
+ };
+
+ return (
+ <div className="size-full flex flex-col p-6 space-y-6">
+ <div className="flex items-center justify-between">
+ <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">
+ Settings
+ </h2>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setShowConfigEditor(true)}
+ >
+ <FileJsonIcon />
+ <span className="hidden sm:inline">Open JSON</span>
+ </Button>
+ </div>
+
+ <Tabs
+ value={activeTab}
+ onValueChange={setActiveTab}
+ className="size-full flex flex-col gap-6"
+ >
+ <TabsList>
+ <TabsTrigger value="general">General</TabsTrigger>
+ <TabsTrigger value="java">Java</TabsTrigger>
+ <TabsTrigger value="appearance">Appearance</TabsTrigger>
+ <TabsTrigger value="advanced">Advanced</TabsTrigger>
+ </TabsList>
+ {renderScrollArea()}
+ </Tabs>
+
+ <ConfigEditor
+ open={showConfigEditor}
+ onOpenChange={() => setShowConfigEditor(false)}
+ />
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/versions-view.tsx.bk b/packages/ui/src/pages/versions-view.tsx.bk
new file mode 100644
index 0000000..d54596d
--- /dev/null
+++ b/packages/ui/src/pages/versions-view.tsx.bk
@@ -0,0 +1,662 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { Coffee, Loader2, Search, Trash2 } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useInstancesStore } from "../models/instances";
+import { useGameStore } from "../stores/game-store";
+import type { Version } from "../types/bindings/manifest";
+
+interface InstalledModdedVersion {
+ id: string;
+ javaVersion?: number;
+}
+
+type TypeFilter = "all" | "release" | "snapshot" | "installed";
+
+export function VersionsView() {
+ const { versions, selectedVersion, loadVersions, setSelectedVersion } =
+ useGameStore();
+ const { activeInstance } = useInstancesStore();
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
+ const [installedModdedVersions, setInstalledModdedVersions] = useState<
+ InstalledModdedVersion[]
+ >([]);
+ const [, setIsLoadingModded] = useState(false);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [selectedVersionMetadata, setSelectedVersionMetadata] = useState<{
+ id: string;
+ javaVersion?: number;
+ isInstalled: boolean;
+ } | null>(null);
+ const [isLoadingMetadata, setIsLoadingMetadata] = useState(false);
+ const [showModLoaderSelector, setShowModLoaderSelector] = useState(false);
+
+ const normalizedQuery = searchQuery.trim().toLowerCase().replace(/。/g, ".");
+
+ // Load installed modded versions with Java version info
+ const loadInstalledModdedVersions = useCallback(async () => {
+ if (!activeInstance) {
+ setInstalledModdedVersions([]);
+ setIsLoadingModded(false);
+ return;
+ }
+
+ setIsLoadingModded(true);
+ try {
+ const allInstalled = await invoke<Array<{ id: string; type: string }>>(
+ "list_installed_versions",
+ { instanceId: activeInstanceId },
+ );
+
+ const moddedIds = allInstalled
+ .filter((v) => v.type === "fabric" || v.type === "forge")
+ .map((v) => v.id);
+
+ const versionsWithJava = await Promise.all(
+ moddedIds.map(async (id) => {
+ try {
+ const javaVersion = await invoke<number | null>(
+ "get_version_java_version",
+ {
+ instanceId: activeInstanceId,
+ versionId: id,
+ },
+ );
+ return {
+ id,
+ javaVersion: javaVersion ?? undefined,
+ };
+ } catch (e) {
+ console.error(`Failed to get Java version for ${id}:`, e);
+ return { id, javaVersion: undefined };
+ }
+ }),
+ );
+
+ setInstalledModdedVersions(versionsWithJava);
+ } catch (e) {
+ console.error("Failed to load installed modded versions:", e);
+ toast.error("Error loading modded versions");
+ } finally {
+ setIsLoadingModded(false);
+ }
+ }, [activeInstanceId]);
+
+ // Combined versions list (vanilla + modded)
+ const allVersions = (() => {
+ const moddedVersions: Version[] = installedModdedVersions.map((v) => {
+ const versionType = v.id.startsWith("fabric-loader-")
+ ? "fabric"
+ : v.id.includes("-forge-")
+ ? "forge"
+ : "fabric";
+ return {
+ id: v.id,
+ type: versionType,
+ url: "",
+ time: "",
+ releaseTime: new Date().toISOString(),
+ javaVersion: BigInt(v.javaVersion ?? 0),
+ isInstalled: true,
+ };
+ });
+ return [...moddedVersions, ...versions];
+ })();
+
+ // Filter versions based on search and type filter
+ const filteredVersions = allVersions.filter((version) => {
+ if (typeFilter === "release" && version.type !== "release") return false;
+ if (typeFilter === "snapshot" && version.type !== "snapshot") return false;
+ if (typeFilter === "installed" && !version.isInstalled) return false;
+
+ if (
+ normalizedQuery &&
+ !version.id.toLowerCase().includes(normalizedQuery)
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+
+ // Get version badge styling
+ const getVersionBadge = (type: string) => {
+ switch (type) {
+ case "release":
+ return {
+ text: "Release",
+ variant: "default" as const,
+ className: "bg-emerald-500 hover:bg-emerald-600",
+ };
+ case "snapshot":
+ return {
+ text: "Snapshot",
+ variant: "secondary" as const,
+ className: "bg-amber-500 hover:bg-amber-600",
+ };
+ case "fabric":
+ return {
+ text: "Fabric",
+ variant: "outline" as const,
+ className: "border-indigo-500 text-indigo-700 dark:text-indigo-300",
+ };
+ case "forge":
+ return {
+ text: "Forge",
+ variant: "outline" as const,
+ className: "border-orange-500 text-orange-700 dark:text-orange-300",
+ };
+ case "modpack":
+ return {
+ text: "Modpack",
+ variant: "outline" as const,
+ className: "border-purple-500 text-purple-700 dark:text-purple-300",
+ };
+ default:
+ return {
+ text: type,
+ variant: "outline" as const,
+ className: "border-gray-500 text-gray-700 dark:text-gray-300",
+ };
+ }
+ };
+
+ // Load version metadata
+ const loadVersionMetadata = useCallback(
+ async (versionId: string) => {
+ if (!versionId || !activeInstanceId) {
+ setSelectedVersionMetadata(null);
+ return;
+ }
+
+ setIsLoadingMetadata(true);
+ try {
+ const metadata = await invoke<{
+ id: string;
+ javaVersion?: number;
+ isInstalled: boolean;
+ }>("get_version_metadata", {
+ instanceId: activeInstanceId,
+ versionId,
+ });
+ setSelectedVersionMetadata(metadata);
+ } catch (e) {
+ console.error("Failed to load version metadata:", e);
+ setSelectedVersionMetadata(null);
+ } finally {
+ setIsLoadingMetadata(false);
+ }
+ },
+ [activeInstanceId],
+ );
+
+ // Get base version for mod loader selector
+ const selectedBaseVersion = (() => {
+ if (!selectedVersion) return "";
+
+ if (selectedVersion.startsWith("fabric-loader-")) {
+ const parts = selectedVersion.split("-");
+ return parts[parts.length - 1];
+ }
+ if (selectedVersion.includes("-forge-")) {
+ return selectedVersion.split("-forge-")[0];
+ }
+
+ const version = versions.find((v) => v.id === selectedVersion);
+ return version ? selectedVersion : "";
+ })();
+
+ // Handle version deletion
+ const handleDeleteVersion = async () => {
+ if (!versionToDelete || !activeInstanceId) return;
+
+ setIsDeleting(true);
+ try {
+ await invoke("delete_version", {
+ instanceId: activeInstanceId,
+ versionId: versionToDelete,
+ });
+
+ if (selectedVersion === versionToDelete) {
+ setSelectedVersion("");
+ }
+
+ setShowDeleteDialog(false);
+ setVersionToDelete(null);
+ toast.success("Version deleted successfully");
+
+ await loadVersions(activeInstanceId);
+ await loadInstalledModdedVersions();
+ } catch (e) {
+ console.error("Failed to delete version:", e);
+ toast.error(`Failed to delete version: ${e}`);
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ // Show delete confirmation dialog
+ const showDeleteConfirmation = (versionId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ setVersionToDelete(versionId);
+ setShowDeleteDialog(true);
+ };
+
+ // Setup event listeners for version updates
+ useEffect(() => {
+ let unlisteners: UnlistenFn[] = [];
+
+ const setupEventListeners = async () => {
+ try {
+ const versionDeletedUnlisten = await listen(
+ "version-deleted",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const downloadCompleteUnlisten = await listen(
+ "download-complete",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const versionInstalledUnlisten = await listen(
+ "version-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const fabricInstalledUnlisten = await listen(
+ "fabric-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const forgeInstalledUnlisten = await listen(
+ "forge-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ unlisteners = [
+ versionDeletedUnlisten,
+ downloadCompleteUnlisten,
+ versionInstalledUnlisten,
+ fabricInstalledUnlisten,
+ forgeInstalledUnlisten,
+ ];
+ } catch (e) {
+ console.error("Failed to setup event listeners:", e);
+ }
+ };
+
+ setupEventListeners();
+ loadInstalledModdedVersions();
+
+ return () => {
+ unlisteners.forEach((unlisten) => {
+ unlisten();
+ });
+ };
+ }, [activeInstanceId, loadVersions, loadInstalledModdedVersions]);
+
+ // Load metadata when selected version changes
+ useEffect(() => {
+ if (selectedVersion) {
+ loadVersionMetadata(selectedVersion);
+ } else {
+ setSelectedVersionMetadata(null);
+ }
+ }, [selectedVersion, loadVersionMetadata]);
+
+ return (
+ <div className="h-full flex flex-col p-6 overflow-hidden">
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/60">
+ Version Manager
+ </h2>
+ <div className="text-sm dark:text-white/40 text-black/50">
+ Select a version to play or modify
+ </div>
+ </div>
+
+ <div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 overflow-hidden">
+ {/* Left: Version List */}
+ <div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
+ {/* Search and Filters */}
+ <div className="flex gap-3">
+ <div className="relative flex-1">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ type="text"
+ placeholder="Search versions..."
+ className="pl-9 bg-white/60 dark:bg-black/20 border-black/10 dark:border-white/10 dark:text-white text-gray-900 placeholder-black/30 dark:placeholder-white/30 focus:border-indigo-500/50 dark:focus:bg-black/40 focus:bg-white/80 backdrop-blur-sm"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+ </div>
+
+ {/* Type Filter Tabs */}
+ <Tabs
+ value={typeFilter}
+ onValueChange={(v) => setTypeFilter(v as TypeFilter)}
+ className="w-full"
+ >
+ <TabsList className="grid grid-cols-4 bg-white/60 dark:bg-black/20 border-black/5 dark:border-white/5">
+ <TabsTrigger value="all">All</TabsTrigger>
+ <TabsTrigger value="release">Release</TabsTrigger>
+ <TabsTrigger value="snapshot">Snapshot</TabsTrigger>
+ <TabsTrigger value="installed">Installed</TabsTrigger>
+ </TabsList>
+ </Tabs>
+
+ {/* Version List */}
+ <ScrollArea className="flex-1 pr-2">
+ {versions.length === 0 ? (
+ <div className="flex items-center justify-center h-40 dark:text-white/30 text-black/30 italic animate-pulse">
+ Loading versions...
+ </div>
+ ) : filteredVersions.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-40 dark:text-white/30 text-black/30 gap-2">
+ <span className="text-2xl">👻</span>
+ <span>No matching versions found</span>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredVersions.map((version) => {
+ const badge = getVersionBadge(version.type);
+ const isSelected = selectedVersion === version.id;
+
+ return (
+ <Card
+ key={version.id}
+ className={`w-full cursor-pointer transition-all duration-200 relative overflow-hidden group ${
+ isSelected
+ ? "border-indigo-200 dark:border-indigo-500/50 bg-indigo-50 dark:bg-indigo-600/20 shadow-[0_0_20px_rgba(99,102,241,0.2)]"
+ : "border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/10 dark:hover:border-white/10 hover:translate-x-1"
+ }`}
+ onClick={() => setSelectedVersion(version.id)}
+ >
+ {isSelected && (
+ <div className="absolute inset-0 bg-linear-to-r from-indigo-500/10 to-transparent pointer-events-none" />
+ )}
+
+ <CardContent className="p-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4 flex-1">
+ <Badge
+ variant={badge.variant}
+ className={badge.className}
+ >
+ {badge.text}
+ </Badge>
+ <div className="flex-1">
+ <div
+ className={`font-bold font-mono text-lg tracking-tight ${
+ isSelected
+ ? "text-black dark:text-white"
+ : "text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white"
+ }`}
+ >
+ {version.id}
+ </div>
+ <div className="flex items-center gap-2 mt-0.5">
+ {version.releaseTime &&
+ version.type !== "fabric" &&
+ version.type !== "forge" && (
+ <div className="text-xs dark:text-white/30 text-black/30">
+ {new Date(
+ version.releaseTime,
+ ).toLocaleDateString()}
+ </div>
+ )}
+ {version.javaVersion && (
+ <div className="flex items-center gap-1 text-xs dark:text-white/40 text-black/40">
+ <Coffee className="h-3 w-3 opacity-60" />
+ <span className="font-medium">
+ Java {version.javaVersion}
+ </span>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {version.isInstalled && (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="opacity-0 group-hover:opacity-100 transition-opacity text-red-500 dark:text-red-400 hover:bg-red-500/10 dark:hover:bg-red-500/20"
+ onClick={(e) =>
+ showDeleteConfirmation(version.id, e)
+ }
+ title="Delete version"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+ })}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+
+ {/* Right: Version Details */}
+ <div className="flex flex-col gap-6">
+ <Card className="border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 backdrop-blur-sm">
+ <CardHeader>
+ <CardTitle className="text-lg">Version Details</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {selectedVersion ? (
+ <>
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Selected Version
+ </div>
+ <div className="font-mono text-xl font-bold">
+ {selectedVersion}
+ </div>
+ </div>
+
+ {isLoadingMetadata ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="text-sm">Loading metadata...</span>
+ </div>
+ ) : selectedVersionMetadata ? (
+ <div className="space-y-3">
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Installation Status
+ </div>
+ <Badge
+ variant={
+ selectedVersionMetadata.isInstalled
+ ? "default"
+ : "outline"
+ }
+ >
+ {selectedVersionMetadata.isInstalled
+ ? "Installed"
+ : "Not Installed"}
+ </Badge>
+ </div>
+
+ {selectedVersionMetadata.javaVersion && (
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Java Version
+ </div>
+ <div className="flex items-center gap-2">
+ <Coffee className="h-4 w-4" />
+ <span>
+ Java {selectedVersionMetadata.javaVersion}
+ </span>
+ </div>
+ </div>
+ )}
+
+ {!selectedVersionMetadata.isInstalled && (
+ <Button
+ className="w-full"
+ onClick={() => setShowModLoaderSelector(true)}
+ >
+ Install with Mod Loader
+ </Button>
+ )}
+ </div>
+ ) : null}
+ </>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ Select a version to view details
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* Mod Loader Installation */}
+ {showModLoaderSelector && selectedBaseVersion && (
+ <Card className="border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 backdrop-blur-sm">
+ <CardHeader>
+ <CardTitle className="text-lg">Install Mod Loader</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="text-sm text-muted-foreground">
+ Install {selectedBaseVersion} with Fabric or Forge
+ </div>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ className="flex-1"
+ onClick={async () => {
+ if (!activeInstanceId) return;
+ try {
+ await invoke("install_fabric", {
+ instanceId: activeInstanceId,
+ gameVersion: selectedBaseVersion,
+ loaderVersion: "latest",
+ });
+ toast.success("Fabric installation started");
+ setShowModLoaderSelector(false);
+ } catch (e) {
+ console.error("Failed to install Fabric:", e);
+ toast.error(`Failed to install Fabric: ${e}`);
+ }
+ }}
+ >
+ Install Fabric
+ </Button>
+ <Button
+ variant="outline"
+ className="flex-1"
+ onClick={async () => {
+ if (!activeInstanceId) return;
+ try {
+ await invoke("install_forge", {
+ instanceId: activeInstanceId,
+ gameVersion: selectedBaseVersion,
+ installerVersion: "latest",
+ });
+ toast.success("Forge installation started");
+ setShowModLoaderSelector(false);
+ } catch (e) {
+ console.error("Failed to install Forge:", e);
+ toast.error(`Failed to install Forge: ${e}`);
+ }
+ }}
+ >
+ Install Forge
+ </Button>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setShowModLoaderSelector(false)}
+ >
+ Cancel
+ </Button>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ </div>
+
+ {/* Delete Confirmation Dialog */}
+ <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Version</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete version "{versionToDelete}"? This
+ action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setShowDeleteDialog(false);
+ setVersionToDelete(null);
+ }}
+ disabled={isDeleting}
+ >
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDeleteVersion}
+ disabled={isDeleting}
+ >
+ {isDeleting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Deleting...
+ </>
+ ) : (
+ "Delete"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}