aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages/ui-new/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui-new/src/pages')
-rw-r--r--packages/ui-new/src/pages/assistant-view.tsx485
-rw-r--r--packages/ui-new/src/pages/home-view.tsx382
-rw-r--r--packages/ui-new/src/pages/index.tsx189
-rw-r--r--packages/ui-new/src/pages/instances-view.tsx370
-rw-r--r--packages/ui-new/src/pages/settings-view.tsx1158
-rw-r--r--packages/ui-new/src/pages/versions-view.tsx662
6 files changed, 3246 insertions, 0 deletions
diff --git a/packages/ui-new/src/pages/assistant-view.tsx b/packages/ui-new/src/pages/assistant-view.tsx
new file mode 100644
index 0000000..56f827b
--- /dev/null
+++ b/packages/ui-new/src/pages/assistant-view.tsx
@@ -0,0 +1,485 @@
+import {
+ AlertTriangle,
+ Bot,
+ Brain,
+ ChevronDown,
+ Loader2,
+ RefreshCw,
+ Send,
+ Settings,
+ Trash2,
+} from "lucide-react";
+import { marked } from "marked";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Textarea } from "@/components/ui/textarea";
+import { toNumber } from "@/lib/tsrs-utils";
+import { type Message, useAssistantStore } from "../stores/assistant-store";
+import { useSettingsStore } from "../stores/settings-store";
+import { useUiStore } from "../stores/ui-store";
+
+interface ParsedMessage {
+ thinking: string | null;
+ content: string;
+ isThinking: boolean;
+}
+
+function parseMessageContent(content: string): ParsedMessage {
+ if (!content) return { thinking: null, content: "", isThinking: false };
+
+ // Support both <thinking> and <think> (DeepSeek uses <think>)
+ let startTag = "<thinking>";
+ let endTag = "</thinking>";
+ let startIndex = content.indexOf(startTag);
+
+ if (startIndex === -1) {
+ startTag = "<think>";
+ endTag = "</think>";
+ startIndex = content.indexOf(startTag);
+ }
+
+ // Also check for encoded tags if they weren't decoded properly
+ if (startIndex === -1) {
+ startTag = "\u003cthink\u003e";
+ endTag = "\u003c/think\u003e";
+ startIndex = content.indexOf(startTag);
+ }
+
+ if (startIndex !== -1) {
+ const endIndex = content.indexOf(endTag, startIndex);
+
+ if (endIndex !== -1) {
+ // Completed thinking block
+ const before = content.substring(0, startIndex);
+ const thinking = content
+ .substring(startIndex + startTag.length, endIndex)
+ .trim();
+ const after = content.substring(endIndex + endTag.length);
+
+ return {
+ thinking,
+ content: (before + after).trim(),
+ isThinking: false,
+ };
+ } else {
+ // Incomplete thinking block (still streaming)
+ const before = content.substring(0, startIndex);
+ const thinking = content.substring(startIndex + startTag.length).trim();
+
+ return {
+ thinking,
+ content: before.trim(),
+ isThinking: true,
+ };
+ }
+ }
+
+ return { thinking: null, content, isThinking: false };
+}
+
+function renderMarkdown(content: string): string {
+ if (!content) return "";
+ try {
+ return marked(content, { breaks: true, gfm: true }) as string;
+ } catch {
+ return content;
+ }
+}
+
+export function AssistantView() {
+ const {
+ messages,
+ isProcessing,
+ isProviderHealthy,
+ streamingContent,
+ init,
+ checkHealth,
+ sendMessage,
+ clearHistory,
+ } = useAssistantStore();
+ const { settings } = useSettingsStore();
+ const { setView } = useUiStore();
+
+ const [input, setInput] = useState("");
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+ const messagesContainerRef = useRef<HTMLDivElement>(null);
+
+ const provider = settings.assistant.llmProvider;
+ const endpoint =
+ provider === "ollama"
+ ? settings.assistant.ollamaEndpoint
+ : settings.assistant.openaiEndpoint;
+ const model =
+ provider === "ollama"
+ ? settings.assistant.ollamaModel
+ : settings.assistant.openaiModel;
+
+ const getProviderName = (): string => {
+ if (provider === "ollama") {
+ return `Ollama (${model})`;
+ } else if (provider === "openai") {
+ return `OpenAI (${model})`;
+ }
+ return provider;
+ };
+
+ const getProviderHelpText = (): string => {
+ if (provider === "ollama") {
+ return `Please ensure Ollama is installed and running at ${endpoint}.`;
+ } else if (provider === "openai") {
+ return "Please check your OpenAI API key in Settings > AI Assistant.";
+ }
+ return "";
+ };
+
+ const scrollToBottom = useCallback(() => {
+ if (messagesContainerRef.current) {
+ setTimeout(() => {
+ if (messagesContainerRef.current) {
+ messagesContainerRef.current.scrollTop =
+ messagesContainerRef.current.scrollHeight;
+ }
+ }, 0);
+ }
+ }, []);
+
+ useEffect(() => {
+ init();
+ }, [init]);
+
+ useEffect(() => {
+ if (messages.length > 0 || isProcessing) {
+ scrollToBottom();
+ }
+ }, [messages.length, isProcessing, scrollToBottom]);
+
+ const handleSubmit = async () => {
+ if (!input.trim() || isProcessing) return;
+ const text = input;
+ setInput("");
+ await sendMessage(text, settings.assistant.enabled, provider, endpoint);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ };
+
+ const renderMessage = (message: Message, index: number) => {
+ const isUser = message.role === "user";
+ const parsed = parseMessageContent(message.content);
+
+ return (
+ <div
+ key={index}
+ className={`flex ${isUser ? "justify-end" : "justify-start"} mb-4`}
+ >
+ <div
+ className={`max-w-[80%] rounded-2xl px-4 py-3 ${
+ isUser
+ ? "bg-indigo-500 text-white rounded-br-none"
+ : "bg-zinc-800 text-zinc-100 rounded-bl-none"
+ }`}
+ >
+ {!isUser && parsed.thinking && (
+ <div className="mb-3 max-w-full overflow-hidden">
+ <details className="group" open={parsed.isThinking}>
+ <summary className="list-none cursor-pointer flex items-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors text-xs font-medium select-none bg-black/20 p-2 rounded-lg border border-white/5 w-fit mb-2 outline-none">
+ <Brain className="h-3 w-3" />
+ <span>Thinking Process</span>
+ <ChevronDown className="h-3 w-3 transition-transform duration-200 group-open:rotate-180" />
+ </summary>
+ <div className="pl-3 border-l-2 border-zinc-700 text-zinc-500 text-xs italic leading-relaxed whitespace-pre-wrap font-mono max-h-96 overflow-y-auto custom-scrollbar bg-black/10 p-2 rounded-r-md">
+ {parsed.thinking}
+ {parsed.isThinking && (
+ <span className="inline-block w-1.5 h-3 bg-zinc-500 ml-1 animate-pulse align-middle" />
+ )}
+ </div>
+ </details>
+ </div>
+ )}
+ <div
+ className="prose prose-invert max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: renderMarkdown(parsed.content),
+ }}
+ />
+ {!isUser && message.stats && (
+ <div className="mt-2 pt-2 border-t border-zinc-700/50">
+ <div className="text-xs text-zinc-400">
+ {message.stats.evalCount} tokens ยท{" "}
+ {Math.round(toNumber(message.stats.totalDuration) / 1000000)}
+ ms
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ };
+
+ return (
+ <div className="h-full w-full flex flex-col gap-4 p-4 lg:p-8">
+ <div className="flex items-center justify-between mb-2">
+ <div className="flex items-center gap-3">
+ <div className="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
+ <Bot size={24} />
+ </div>
+ <div>
+ <h2 className="text-2xl font-bold">Game Assistant</h2>
+ <p className="text-zinc-400 text-sm">
+ Powered by {getProviderName()}
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {!settings.assistant.enabled ? (
+ <Badge
+ variant="outline"
+ className="bg-zinc-500/10 text-zinc-400 border-zinc-500/20"
+ >
+ <AlertTriangle className="h-3 w-3 mr-1" />
+ Disabled
+ </Badge>
+ ) : !isProviderHealthy ? (
+ <Badge
+ variant="outline"
+ className="bg-red-500/10 text-red-400 border-red-500/20"
+ >
+ <AlertTriangle className="h-3 w-3 mr-1" />
+ Offline
+ </Badge>
+ ) : (
+ <Badge
+ variant="outline"
+ className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
+ >
+ <div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse mr-1" />
+ Online
+ </Badge>
+ )}
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={checkHealth}
+ title="Check Connection"
+ disabled={isProcessing}
+ >
+ <RefreshCw
+ className={`h-4 w-4 ${isProcessing ? "animate-spin" : ""}`}
+ />
+ </Button>
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={clearHistory}
+ title="Clear History"
+ disabled={isProcessing}
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => setView("settings")}
+ title="Settings"
+ >
+ <Settings className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+
+ {/* Chat Area */}
+ <div className="flex-1 bg-black/20 border border-white/5 rounded-xl overflow-hidden flex flex-col relative">
+ {/* Warning when assistant is disabled */}
+ {!settings.assistant.enabled && (
+ <div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-10">
+ <Card className="bg-yellow-500/10 border-yellow-500/20">
+ <CardContent className="p-3 flex items-center gap-2">
+ <AlertTriangle className="h-4 w-4 text-yellow-500" />
+ <span className="text-yellow-500 text-sm font-medium">
+ Assistant is disabled. Enable it in Settings &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-new/src/pages/home-view.tsx b/packages/ui-new/src/pages/home-view.tsx
new file mode 100644
index 0000000..bcee7e6
--- /dev/null
+++ b/packages/ui-new/src/pages/home-view.tsx
@@ -0,0 +1,382 @@
+import { Calendar, ExternalLink } from "lucide-react";
+import { useEffect, useState } from "react";
+import type { SaturnEffect } from "@/lib/effects/SaturnEffect";
+import { useGameStore } from "../stores/game-store";
+import { useReleasesStore } from "../stores/releases-store";
+
+export function HomeView() {
+ const gameStore = useGameStore();
+ const releasesStore = useReleasesStore();
+ const [mouseX, setMouseX] = useState(0);
+ const [mouseY, setMouseY] = useState(0);
+
+ useEffect(() => {
+ releasesStore.loadReleases();
+ }, [releasesStore.loadReleases]);
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ const x = (e.clientX / window.innerWidth) * 2 - 1;
+ const y = (e.clientY / window.innerHeight) * 2 - 1;
+ setMouseX(x);
+ setMouseY(y);
+
+ // Forward mouse move to SaturnEffect (if available) for parallax/rotation interactions
+ try {
+ const saturn = (
+ window as unknown as {
+ getSaturnEffect?: () => SaturnEffect;
+ }
+ ).getSaturnEffect?.();
+ if (saturn?.handleMouseMove) {
+ saturn.handleMouseMove(e.clientX);
+ }
+ } catch {
+ /* best-effort, ignore errors from effect */
+ }
+ };
+
+ const handleSaturnMouseDown = (e: React.MouseEvent) => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseDown) {
+ saturn.handleMouseDown(e.clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnMouseUp = () => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseUp) {
+ saturn.handleMouseUp();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnMouseLeave = () => {
+ // Treat leaving the area as mouse-up for the effect
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseUp) {
+ saturn.handleMouseUp();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnTouchStart = (e: React.TouchEvent) => {
+ if (e.touches && e.touches.length === 1) {
+ try {
+ const clientX = e.touches[0].clientX;
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchStart) {
+ saturn.handleTouchStart(clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ };
+
+ const handleSaturnTouchMove = (e: React.TouchEvent) => {
+ if (e.touches && e.touches.length === 1) {
+ try {
+ const clientX = e.touches[0].clientX;
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchMove) {
+ saturn.handleTouchMove(clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ };
+
+ const handleSaturnTouchEnd = () => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchEnd) {
+ saturn.handleTouchEnd();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString(undefined, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ };
+
+ const escapeHtml = (unsafe: string) => {
+ return unsafe
+ .replace(/&/g, "&amp;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#039;");
+ };
+
+ const formatBody = (body: string) => {
+ if (!body) return "";
+
+ let processed = escapeHtml(body);
+
+ const emojiMap: Record<string, string> = {
+ ":tada:": "๐ŸŽ‰",
+ ":sparkles:": "โœจ",
+ ":bug:": "๐Ÿ›",
+ ":memo:": "๐Ÿ“",
+ ":rocket:": "๐Ÿš€",
+ ":white_check_mark:": "โœ…",
+ ":construction:": "๐Ÿšง",
+ ":recycle:": "โ™ป๏ธ",
+ ":wrench:": "๐Ÿ”ง",
+ ":package:": "๐Ÿ“ฆ",
+ ":arrow_up:": "โฌ†๏ธ",
+ ":arrow_down:": "โฌ‡๏ธ",
+ ":warning:": "โš ๏ธ",
+ ":fire:": "๐Ÿ”ฅ",
+ ":heart:": "โค๏ธ",
+ ":star:": "โญ",
+ ":zap:": "โšก",
+ ":art:": "๐ŸŽจ",
+ ":lipstick:": "๐Ÿ’„",
+ ":globe_with_meridians:": "๐ŸŒ",
+ };
+
+ processed = processed.replace(
+ /:[a-z0-9_]+:/g,
+ (match) => emojiMap[match] || match,
+ );
+
+ processed = processed.replace(/`([0-9a-f]{7,40})`/g, (_match, hash) => {
+ return `<a href="https://github.com/HydroRoll-Team/DropOut/commit/${hash}" target="_blank" class="text-emerald-500 hover:underline font-mono bg-emerald-500/10 px-1 rounded text-[10px] py-0.5 transition-colors border border-emerald-500/20 hover:border-emerald-500/50">${hash.substring(
+ 0,
+ 7,
+ )}</a>`;
+ });
+
+ processed = processed.replace(
+ /@([a-zA-Z0-9-]+)/g,
+ '<a href="https://github.com/$1" target="_blank" class="text-zinc-300 hover:text-white hover:underline font-medium">@$1</a>',
+ );
+
+ return processed
+ .split("\n")
+ .map((line) => {
+ line = line.trim();
+
+ const formatLine = (text: string) =>
+ text
+ .replace(
+ /\*\*(.*?)\*\*/g,
+ '<strong class="text-zinc-200">$1</strong>',
+ )
+ .replace(
+ /(?<!\*)\*([^*]+)\*(?!\*)/g,
+ '<em class="text-zinc-400 italic">$1</em>',
+ )
+ .replace(
+ /`([^`]+)`/g,
+ '<code class="bg-zinc-800 px-1 py-0.5 rounded text-xs text-zinc-300 font-mono border border-white/5 break-all whitespace-normal">$1</code>',
+ )
+ .replace(
+ /\[(.*?)\]\((.*?)\)/g,
+ '<a href="$2" target="_blank" class="text-indigo-400 hover:text-indigo-300 hover:underline decoration-indigo-400/30 break-all">$1</a>',
+ );
+
+ if (line.startsWith("- ") || line.startsWith("* ")) {
+ return `<li class="ml-4 list-disc marker:text-zinc-600 mb-1 pl-1 text-zinc-400">${formatLine(
+ line.substring(2),
+ )}</li>`;
+ }
+
+ if (line.startsWith("##")) {
+ return `<h3 class="text-sm font-bold mt-6 mb-3 text-zinc-100 flex items-center gap-2 border-b border-white/5 pb-2 uppercase tracking-wide">${line.replace(
+ /^#+\s+/,
+ "",
+ )}</h3>`;
+ }
+
+ if (line.startsWith("#")) {
+ return `<h3 class="text-base font-bold mt-6 mb-3 text-white">${line.replace(
+ /^#+\s+/,
+ "",
+ )}</h3>`;
+ }
+
+ if (line.startsWith("> ")) {
+ return `<blockquote class="border-l-2 border-zinc-700 pl-4 py-1 my-2 text-zinc-500 italic bg-white/5 rounded-r-sm">${formatLine(
+ line.substring(2),
+ )}</blockquote>`;
+ }
+
+ if (line === "") return '<div class="h-2"></div>';
+
+ return `<p class="mb-1.5 leading-relaxed">${formatLine(line)}</p>`;
+ })
+ .join("");
+ };
+
+ return (
+ <div
+ className="relative z-10 h-full overflow-y-auto custom-scrollbar scroll-smooth"
+ style={{
+ overflow: releasesStore.isLoading ? "hidden" : "auto",
+ }}
+ >
+ {/* Hero Section (Full Height) - Interactive area */}
+ <div
+ role="tab"
+ className="min-h-full flex flex-col justify-end p-12 pb-32 cursor-grab active:cursor-grabbing select-none"
+ onMouseDown={handleSaturnMouseDown}
+ onMouseMove={handleMouseMove}
+ onMouseUp={handleSaturnMouseUp}
+ onMouseLeave={handleSaturnMouseLeave}
+ onTouchStart={handleSaturnTouchStart}
+ onTouchMove={handleSaturnTouchMove}
+ onTouchEnd={handleSaturnTouchEnd}
+ tabIndex={0}
+ >
+ {/* 3D Floating Hero Text */}
+ <div
+ className="transition-transform duration-200 ease-out origin-bottom-left"
+ style={{
+ transform: `perspective(1000px) rotateX(${mouseY * -1}deg) rotateY(${mouseX * 1}deg)`,
+ }}
+ >
+ <div className="flex items-center gap-3 mb-6">
+ <div className="h-px w-12 bg-white/50"></div>
+ <span className="text-xs font-mono font-bold tracking-[0.2em] text-white/50 uppercase">
+ Launcher Active
+ </span>
+ </div>
+
+ <h1 className="text-8xl font-black tracking-tighter text-white mb-6 leading-none">
+ MINECRAFT
+ </h1>
+
+ <div className="flex items-center gap-4">
+ <div className="bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-sm text-xs font-bold uppercase tracking-widest text-white shadow-sm">
+ Java Edition
+ </div>
+ <div className="h-4 w-px bg-white/20"></div>
+ <div className="text-sm text-zinc-400">
+ Latest Release{" "}
+ <span className="text-white font-medium">
+ {gameStore.latestRelease?.id || "..."}
+ </span>
+ </div>
+ </div>
+ </div>
+
+ {/* Action Area */}
+ <div className="mt-8 flex gap-4">
+ <div className="text-zinc-500 text-sm font-mono">
+ &gt; Ready to launch session.
+ </div>
+ </div>
+
+ {/* Scroll Hint */}
+ {!releasesStore.isLoading && releasesStore.releases.length > 0 && (
+ <div className="absolute bottom-10 left-12 animate-bounce text-zinc-600 flex flex-col items-center gap-2 w-fit opacity-50 hover:opacity-100 transition-opacity">
+ <span className="text-[10px] font-mono uppercase tracking-widest">
+ Scroll for Updates
+ </span>
+ <svg
+ width="20"
+ height="20"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <title>Scroll for Updates</title>
+ <path d="M7 13l5 5 5-5M7 6l5 5 5-5" />
+ </svg>
+ </div>
+ )}
+ </div>
+
+ {/* Changelog / Updates Section */}
+ <div className="bg-[#09090b] relative z-20 px-12 pb-24 pt-12 border-t border-white/5 min-h-[50vh]">
+ <div className="max-w-4xl">
+ <h2 className="text-2xl font-bold text-white mb-10 flex items-center gap-3">
+ <span className="w-1.5 h-8 bg-emerald-500 rounded-sm"></span>
+ LATEST UPDATES
+ </h2>
+
+ {releasesStore.isLoading ? (
+ <div className="flex flex-col gap-8">
+ {Array(3)
+ .fill(0)
+ .map((_, i) => (
+ <div
+ key={`release_skeleton_${i.toString()}`}
+ className="h-48 bg-white/5 rounded-sm animate-pulse border border-white/5"
+ ></div>
+ ))}
+ </div>
+ ) : releasesStore.error ? (
+ <div className="p-6 border border-red-500/20 bg-red-500/10 text-red-400 rounded-sm">
+ Failed to load updates: {releasesStore.error}
+ </div>
+ ) : releasesStore.releases.length === 0 ? (
+ <div className="text-zinc-500 italic">No releases found.</div>
+ ) : (
+ <div className="space-y-12">
+ {releasesStore.releases.map((release, index) => (
+ <div
+ key={`${release.name}_${index.toString()}`}
+ className="group relative pl-8 border-l border-white/10 pb-4 last:pb-0 last:border-l-0"
+ >
+ {/* Timeline Dot */}
+ <div className="absolute -left-1.25 top-1.5 w-2.5 h-2.5 rounded-full bg-zinc-800 border border-zinc-600 group-hover:bg-emerald-500 group-hover:border-emerald-400 transition-colors"></div>
+
+ <div className="flex items-baseline gap-4 mb-3">
+ <h3 className="text-xl font-bold text-white group-hover:text-emerald-400 transition-colors">
+ {release.name || release.tagName}
+ </h3>
+ <div className="text-xs font-mono text-zinc-500 flex items-center gap-2">
+ <Calendar size={12} />
+ {formatDate(release.publishedAt)}
+ </div>
+ </div>
+
+ <div className="bg-zinc-900/50 border border-white/5 hover:border-white/10 rounded-sm p-6 text-zinc-400 text-sm leading-relaxed transition-colors overflow-hidden">
+ <div
+ className="prose prose-invert prose-sm max-w-none prose-p:text-zinc-400 prose-headings:text-zinc-200 prose-ul:my-2 prose-li:my-0 break-words whitespace-normal"
+ dangerouslySetInnerHTML={{
+ __html: formatBody(release.body),
+ }}
+ />
+ </div>
+
+ <a
+ href={release.htmlUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="inline-flex items-center gap-2 mt-3 text-[10px] font-bold uppercase tracking-wider text-zinc-600 hover:text-white transition-colors"
+ >
+ View full changelog on GitHub <ExternalLink size={10} />
+ </a>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/pages/index.tsx b/packages/ui-new/src/pages/index.tsx
new file mode 100644
index 0000000..180cf0c
--- /dev/null
+++ b/packages/ui-new/src/pages/index.tsx
@@ -0,0 +1,189 @@
+import { useEffect } from "react";
+import { Outlet } from "react-router";
+import { BottomBar } from "@/components/bottom-bar";
+import { DownloadMonitor } from "@/components/download-monitor";
+import { GameConsole } from "@/components/game-console";
+import { LoginModal } from "@/components/login-modal";
+import { ParticleBackground } from "@/components/particle-background";
+import { Sidebar } from "@/components/sidebar";
+
+import { useAuthStore } from "@/stores/auth-store";
+import { useGameStore } from "@/stores/game-store";
+import { useInstancesStore } from "@/stores/instances-store";
+import { useLogsStore } from "@/stores/logs-store";
+import { useSettingsStore } from "@/stores/settings-store";
+import { useUIStore } from "@/stores/ui-store";
+
+export function IndexPage() {
+ const authStore = useAuthStore();
+ const settingsStore = useSettingsStore();
+ const uiStore = useUIStore();
+ const instancesStore = useInstancesStore();
+ const gameStore = useGameStore();
+ const logsStore = useLogsStore();
+ useEffect(() => {
+ // ENFORCE DARK MODE: Always add 'dark' class and attribute
+ document.documentElement.classList.add("dark");
+ document.documentElement.setAttribute("data-theme", "dark");
+ document.documentElement.classList.remove("light");
+
+ // Initialize stores
+ // Include store functions in the dependency array to satisfy hooks lint.
+ // These functions are stable in our store implementation, so listing them
+ // here is safe and prevents lint warnings.
+ authStore.checkAccount();
+ settingsStore.loadSettings();
+ logsStore.init();
+ settingsStore.detectJava();
+ instancesStore.loadInstances();
+ gameStore.loadVersions();
+
+ // Note: getVersion() would need Tauri API setup
+ // getVersion().then((v) => uiStore.setAppVersion(v));
+ }, [
+ authStore.checkAccount,
+ settingsStore.loadSettings,
+ logsStore.init,
+ settingsStore.detectJava,
+ instancesStore.loadInstances,
+ gameStore.loadVersions,
+ ]);
+
+ // Refresh versions when active instance changes
+ useEffect(() => {
+ if (instancesStore.activeInstanceId) {
+ gameStore.loadVersions();
+ } else {
+ gameStore.setVersions([]);
+ }
+ }, [
+ instancesStore.activeInstanceId,
+ gameStore.loadVersions,
+ gameStore.setVersions,
+ ]);
+
+ return (
+ <div className="relative h-screen w-screen overflow-hidden dark:text-white text-gray-900 font-sans selection:bg-indigo-500/30">
+ {/* Modern Animated Background */}
+ <div className="absolute inset-0 z-0 bg-gray-100 dark:bg-[#09090b] overflow-hidden">
+ {settingsStore.settings.customBackgroundPath && (
+ <img
+ src={settingsStore.settings.customBackgroundPath}
+ alt="Background"
+ className="absolute inset-0 w-full h-full object-cover transition-transform duration-[20s] ease-linear hover:scale-105"
+ onError={(e) => console.error("Failed to load main background:", e)}
+ />
+ )}
+
+ {/* Dimming Overlay for readability */}
+ {settingsStore.settings.customBackgroundPath && (
+ <div className="absolute inset-0 bg-black/50"></div>
+ )}
+
+ {!settingsStore.settings.customBackgroundPath && (
+ <>
+ {settingsStore.settings.theme === "dark" ? (
+ <div className="absolute inset-0 opacity-60 bg-linear-to-br from-emerald-900 via-zinc-900 to-indigo-950"></div>
+ ) : (
+ <div className="absolute inset-0 opacity-100 bg-linear-to-br from-emerald-100 via-gray-100 to-indigo-100"></div>
+ )}
+
+ {uiStore.currentView === "home" && <ParticleBackground />}
+
+ <div className="absolute inset-0 bg-linear-to-t from-zinc-900 via-transparent to-black/50 dark:from-zinc-900 dark:to-black/50"></div>
+ </>
+ )}
+
+ {/* Subtle Grid Overlay */}
+ <div
+ className="absolute inset-0 z-0 dark:opacity-10 opacity-30 pointer-events-none"
+ style={{
+ backgroundImage: `linear-gradient(${
+ settingsStore.settings.theme === "dark" ? "#ffffff" : "#000000"
+ } 1px, transparent 1px), linear-gradient(90deg, ${
+ settingsStore.settings.theme === "dark" ? "#ffffff" : "#000000"
+ } 1px, transparent 1px)`,
+ backgroundSize: "40px 40px",
+ maskImage:
+ "radial-gradient(circle at 50% 50%, black 30%, transparent 70%)",
+ }}
+ ></div>
+ </div>
+
+ {/* Content Wrapper */}
+ <div className="relative z-10 flex h-full p-4 gap-4 text-gray-900 dark:text-white">
+ {/* Floating Sidebar */}
+ <Sidebar />
+
+ {/* Main Content Area - Transparent & Flat */}
+ <main className="flex-1 flex flex-col relative min-w-0 overflow-hidden transition-all duration-300">
+ {/* Window Drag Region */}
+ <div
+ className="h-8 w-full absolute top-0 left-0 z-50 drag-region"
+ data-tauri-drag-region
+ ></div>
+
+ {/* App Content */}
+ <div className="flex-1 relative overflow-hidden flex flex-col">
+ {/* Views Container */}
+ <div className="flex-1 relative overflow-hidden">
+ <Outlet />
+ </div>
+
+ {/* Download Monitor Overlay */}
+ <div className="absolute bottom-20 left-4 right-4 pointer-events-none z-20">
+ <div className="pointer-events-auto">
+ <DownloadMonitor />
+ </div>
+ </div>
+
+ {/* Bottom Bar */}
+ {uiStore.currentView === "home" && <BottomBar />}
+ </div>
+ </main>
+ </div>
+
+ <LoginModal />
+
+ {/* Logout Confirmation Dialog */}
+ {authStore.isLogoutConfirmOpen && (
+ <div className="fixed inset-0 z-200 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
+ <div className="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200">
+ <h3 className="text-lg font-bold text-white mb-2">Logout</h3>
+ <p className="text-zinc-400 text-sm mb-6">
+ Are you sure you want to logout{" "}
+ <span className="text-white font-medium">
+ {authStore.currentAccount?.username}
+ </span>
+ ?
+ </p>
+ <div className="flex gap-3 justify-end">
+ <button
+ type="button"
+ onClick={() => authStore.cancelLogout()}
+ className="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ type="button"
+ onClick={() => authStore.confirmLogout()}
+ className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors"
+ >
+ Logout
+ </button>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {uiStore.showConsole && (
+ <div className="fixed inset-0 z-100 bg-black/80 backdrop-blur-sm flex items-center justify-center p-8">
+ <div className="w-full h-full max-w-6xl max-h-[85vh] bg-[#1e1e1e] rounded-lg overflow-hidden border border-zinc-700 shadow-2xl relative flex flex-col">
+ <GameConsole />
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/pages/instances-view.tsx b/packages/ui-new/src/pages/instances-view.tsx
new file mode 100644
index 0000000..0c511a1
--- /dev/null
+++ b/packages/ui-new/src/pages/instances-view.tsx
@@ -0,0 +1,370 @@
+import { Copy, Edit2, Plus, Trash2 } from "lucide-react";
+import { useEffect, useState } from "react";
+import InstanceEditorModal from "@/components/instance-editor-modal";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { toNumber } from "@/lib/tsrs-utils";
+import { useInstancesStore } from "@/stores/instances-store";
+import type { Instance } from "../types/bindings/instance";
+
+export function InstancesView() {
+ const instancesStore = useInstancesStore();
+
+ // Modal / UI state
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [showDuplicateModal, setShowDuplicateModal] = useState(false);
+
+ // Selected / editing instance state
+ const [selectedInstance, setSelectedInstance] = useState<Instance | null>(
+ null,
+ );
+ const [editingInstance, setEditingInstance] = useState<Instance | null>(null);
+
+ // Form fields
+ const [newInstanceName, setNewInstanceName] = useState("");
+ const [duplicateName, setDuplicateName] = useState("");
+
+ // Load instances on mount (matches Svelte onMount behavior)
+ useEffect(() => {
+ instancesStore.loadInstances();
+ // instancesStore methods are stable (Zustand); do not add to deps to avoid spurious runs
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [instancesStore.loadInstances]);
+
+ // Handlers to open modals
+ const openCreate = () => {
+ setNewInstanceName("");
+ setShowCreateModal(true);
+ };
+
+ const openEdit = (instance: Instance) => {
+ setEditingInstance({ ...instance });
+ setShowEditModal(true);
+ };
+
+ const openDelete = (instance: Instance) => {
+ setSelectedInstance(instance);
+ setShowDeleteConfirm(true);
+ };
+
+ const openDuplicate = (instance: Instance) => {
+ setSelectedInstance(instance);
+ setDuplicateName(`${instance.name} (Copy)`);
+ setShowDuplicateModal(true);
+ };
+
+ // Confirm actions
+ const confirmCreate = async () => {
+ const name = newInstanceName.trim();
+ if (!name) return;
+ await instancesStore.createInstance(name);
+ setShowCreateModal(false);
+ setNewInstanceName("");
+ };
+
+ const confirmEdit = async () => {
+ if (!editingInstance) return;
+ await instancesStore.updateInstance(editingInstance);
+ setEditingInstance(null);
+ setShowEditModal(false);
+ };
+
+ const confirmDelete = async () => {
+ if (!selectedInstance) return;
+ await instancesStore.deleteInstance(selectedInstance.id);
+ setSelectedInstance(null);
+ setShowDeleteConfirm(false);
+ };
+
+ const confirmDuplicate = async () => {
+ if (!selectedInstance) return;
+ const name = duplicateName.trim();
+ if (!name) return;
+ await instancesStore.duplicateInstance(selectedInstance.id, name);
+ setSelectedInstance(null);
+ setDuplicateName("");
+ setShowDuplicateModal(false);
+ };
+
+ const setActiveInstance = async (id: string) => {
+ await instancesStore.setActiveInstance(id);
+ };
+
+ const formatDate = (timestamp: number): string =>
+ new Date(timestamp * 1000).toLocaleDateString();
+
+ const formatLastPlayed = (timestamp: number): string => {
+ const date = new Date(timestamp * 1000);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days === 0) return "Today";
+ if (days === 1) return "Yesterday";
+ if (days < 7) return `${days} days ago`;
+ return date.toLocaleDateString();
+ };
+
+ return (
+ <div className="h-full flex flex-col gap-4 p-6 overflow-y-auto">
+ <div className="flex items-center justify-between">
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
+ Instances
+ </h1>
+ <Button
+ type="button"
+ onClick={openCreate}
+ className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
+ >
+ <Plus size={18} />
+ Create Instance
+ </Button>
+ </div>
+
+ {instancesStore.instances.length === 0 ? (
+ <div className="flex-1 flex items-center justify-center">
+ <div className="text-center text-gray-500 dark:text-gray-400">
+ <p className="text-lg mb-2">No instances yet</p>
+ <p className="text-sm">Create your first instance to get started</p>
+ </div>
+ </div>
+ ) : (
+ <ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ {instancesStore.instances.map((instance) => {
+ const isActive = instancesStore.activeInstanceId === instance.id;
+
+ return (
+ <li
+ key={instance.id}
+ onClick={() => setActiveInstance(instance.id)}
+ onKeyDown={(e) =>
+ e.key === "Enter" && setActiveInstance(instance.id)
+ }
+ className={`relative p-4 text-left rounded-lg border-2 transition-all cursor-pointer hover:border-blue-500 ${
+ isActive ? "border-blue-500" : "border-transparent"
+ } bg-gray-100 dark:bg-gray-800`}
+ >
+ {/* Instance Icon */}
+ {instance.iconPath ? (
+ <div className="w-12 h-12 mb-3 rounded overflow-hidden">
+ <img
+ src={instance.iconPath}
+ alt={instance.name}
+ className="w-full h-full object-cover"
+ />
+ </div>
+ ) : (
+ <div className="w-12 h-12 mb-3 rounded bg-linear-to-br from-blue-500 to-purple-600 flex items-center justify-center">
+ <span className="text-white font-bold text-lg">
+ {instance.name.charAt(0).toUpperCase()}
+ </span>
+ </div>
+ )}
+
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
+ {instance.name}
+ </h3>
+
+ <div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
+ {instance.versionId ? (
+ <p className="truncate">Version: {instance.versionId}</p>
+ ) : (
+ <p className="text-gray-400">No version selected</p>
+ )}
+
+ {instance.modLoader && (
+ <p className="truncate">
+ Mod Loader:{" "}
+ <span className="capitalize">{instance.modLoader}</span>
+ </p>
+ )}
+
+ <p className="truncate">
+ Created: {formatDate(toNumber(instance.createdAt))}
+ </p>
+
+ {instance.lastPlayed && (
+ <p className="truncate">
+ Last played:{" "}
+ {formatLastPlayed(toNumber(instance.lastPlayed))}
+ </p>
+ )}
+ </div>
+
+ {/* Action Buttons */}
+ <div className="mt-4 flex gap-2">
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openEdit(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-sm transition-colors"
+ >
+ <Edit2 size={14} />
+ Edit
+ </button>
+
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openDuplicate(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-sm transition-colors"
+ >
+ <Copy size={14} />
+ Duplicate
+ </button>
+
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openDelete(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded text-sm transition-colors"
+ >
+ <Trash2 size={14} />
+ Delete
+ </button>
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ )}
+
+ {/* Create Modal */}
+ <Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create Instance</DialogTitle>
+ <DialogDescription>
+ Enter a name for the new instance.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="mt-4">
+ <Input
+ value={newInstanceName}
+ onChange={(e) => setNewInstanceName(e.target.value)}
+ placeholder="Instance name"
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setShowCreateModal(false)}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmCreate}
+ disabled={!newInstanceName.trim()}
+ >
+ Create
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ <InstanceEditorModal
+ open={showEditModal}
+ instance={editingInstance}
+ onOpenChange={(open) => {
+ setShowEditModal(open);
+ if (!open) setEditingInstance(null);
+ }}
+ />
+
+ {/* Delete Confirmation */}
+ <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Instance</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete "{selectedInstance?.name}"? This
+ action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowDeleteConfirm(false);
+ setSelectedInstance(null);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmDelete}
+ className="bg-red-600 text-white hover:bg-red-500"
+ >
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Duplicate Modal */}
+ <Dialog open={showDuplicateModal} onOpenChange={setShowDuplicateModal}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Duplicate Instance</DialogTitle>
+ <DialogDescription>
+ Provide a name for the duplicated instance.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="mt-4">
+ <Input
+ value={duplicateName}
+ onChange={(e) => setDuplicateName(e.target.value)}
+ placeholder="New instance name"
+ onKeyDown={(e) => e.key === "Enter" && confirmDuplicate()}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowDuplicateModal(false);
+ setSelectedInstance(null);
+ setDuplicateName("");
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmDuplicate}
+ disabled={!duplicateName.trim()}
+ >
+ Duplicate
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/pages/settings-view.tsx b/packages/ui-new/src/pages/settings-view.tsx
new file mode 100644
index 0000000..ac43d9b
--- /dev/null
+++ b/packages/ui-new/src/pages/settings-view.tsx
@@ -0,0 +1,1158 @@
+import { open } from "@tauri-apps/plugin-dialog";
+import {
+ Coffee,
+ Download,
+ FileJson,
+ Loader2,
+ RefreshCw,
+ Upload,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Textarea } from "@/components/ui/textarea";
+import { useSettingsStore } from "../stores/settings-store";
+
+const effectOptions = [
+ { value: "saturn", label: "Saturn" },
+ { value: "constellation", label: "Network (Constellation)" },
+];
+
+const logServiceOptions = [
+ { value: "paste.rs", label: "paste.rs (Free, No Account)" },
+ { value: "pastebin.com", label: "pastebin.com (Requires API Key)" },
+];
+
+const llmProviderOptions = [
+ { value: "ollama", label: "Ollama (Local)" },
+ { value: "openai", label: "OpenAI (Remote)" },
+];
+
+const languageOptions = [
+ { value: "auto", label: "Auto (Match User)" },
+ { value: "English", label: "English" },
+ { value: "Chinese", label: "ไธญๆ–‡" },
+ { value: "Japanese", label: "ๆ—ฅๆœฌ่ชž" },
+ { value: "Korean", label: "ํ•œ๊ตญ์–ด" },
+ { value: "Spanish", label: "Espaรฑol" },
+ { value: "French", label: "Franรงais" },
+ { value: "German", label: "Deutsch" },
+ { value: "Russian", label: "ะ ัƒััะบะธะน" },
+];
+
+const ttsProviderOptions = [
+ { value: "disabled", label: "Disabled" },
+ { value: "piper", label: "Piper TTS (Local)" },
+ { value: "edge", label: "Edge TTS (Online)" },
+];
+
+const personas = [
+ {
+ value: "default",
+ label: "Minecraft Expert (Default)",
+ prompt:
+ "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.",
+ },
+ {
+ value: "technical",
+ label: "Technical Debugger",
+ prompt:
+ "You are a technical support specialist for Minecraft. Focus strictly on analyzing logs, identifying crash causes, and providing technical solutions. Be precise and avoid conversational filler.",
+ },
+ {
+ value: "concise",
+ label: "Concise Helper",
+ prompt:
+ "You are a direct and concise assistant. Provide answers in as few words as possible while remaining accurate. Use bullet points for lists.",
+ },
+ {
+ value: "explain",
+ label: "Teacher / Explainer",
+ prompt:
+ "You are a patient teacher. Explain Minecraft concepts, redstone mechanics, and mod features in simple, easy-to-understand terms suitable for beginners.",
+ },
+ {
+ value: "pirate",
+ label: "Pirate Captain",
+ prompt:
+ "You are a salty Minecraft Pirate Captain! Yarr! Speak like a pirate while helping the crew (the user) with their blocky adventures. Use terms like 'matey', 'landlubber', and 'treasure'.",
+ },
+];
+
+export function SettingsView() {
+ const {
+ settings,
+ backgroundUrl,
+ javaInstallations,
+ isDetectingJava,
+ showJavaDownloadModal,
+ selectedDownloadSource,
+ javaCatalog,
+ isLoadingCatalog,
+ catalogError,
+ selectedMajorVersion,
+ selectedImageType,
+ showOnlyRecommended,
+ searchQuery,
+ isDownloadingJava,
+ downloadProgress,
+ javaDownloadStatus,
+ pendingDownloads,
+ ollamaModels,
+ openaiModels,
+ isLoadingOllamaModels,
+ isLoadingOpenaiModels,
+ ollamaModelsError,
+ openaiModelsError,
+ showConfigEditor,
+ rawConfigContent,
+ configFilePath,
+ configEditorError,
+ filteredReleases,
+ availableMajorVersions,
+ installStatus,
+ selectedRelease,
+ currentModelOptions,
+ loadSettings,
+ saveSettings,
+ detectJava,
+ selectJava,
+ openJavaDownloadModal,
+ closeJavaDownloadModal,
+ loadJavaCatalog,
+ refreshCatalog,
+ loadPendingDownloads,
+ selectMajorVersion,
+ downloadJava,
+ cancelDownload,
+ resumeDownloads,
+ openConfigEditor,
+ closeConfigEditor,
+ saveRawConfig,
+ loadOllamaModels,
+ loadOpenaiModels,
+ set,
+ setSetting,
+ setAssistantSetting,
+ setFeatureFlag,
+ } = useSettingsStore();
+
+ // Mark potentially-unused variables as referenced so TypeScript does not report
+ // them as unused in this file (they are part of the store API and used elsewhere).
+ // This is a no-op but satisfies the compiler.
+ void selectedDownloadSource;
+ void javaCatalog;
+ void javaDownloadStatus;
+ void pendingDownloads;
+ void ollamaModels;
+ void openaiModels;
+ void isLoadingOllamaModels;
+ void isLoadingOpenaiModels;
+ void ollamaModelsError;
+ void openaiModelsError;
+ void selectedRelease;
+ void loadJavaCatalog;
+ void loadPendingDownloads;
+ void cancelDownload;
+ void resumeDownloads;
+ void setFeatureFlag;
+ const [selectedPersona, setSelectedPersona] = useState("default");
+ const [migrating, setMigrating] = useState(false);
+ const [activeTab, setActiveTab] = useState("appearance");
+
+ useEffect(() => {
+ loadSettings();
+ detectJava();
+ }, [loadSettings, detectJava]);
+
+ useEffect(() => {
+ if (activeTab === "assistant") {
+ if (settings.assistant.llmProvider === "ollama") {
+ loadOllamaModels();
+ } else if (settings.assistant.llmProvider === "openai") {
+ loadOpenaiModels();
+ }
+ }
+ }, [
+ activeTab,
+ settings.assistant.llmProvider,
+ loadOllamaModels,
+ loadOpenaiModels,
+ ]);
+
+ const handleSelectBackground = async () => {
+ try {
+ const selected = await open({
+ multiple: false,
+ filters: [
+ {
+ name: "Images",
+ extensions: ["png", "jpg", "jpeg", "webp", "gif"],
+ },
+ ],
+ });
+
+ if (selected && typeof selected === "string") {
+ setSetting("customBackgroundPath", selected);
+ saveSettings();
+ }
+ } catch (e) {
+ console.error("Failed to select background:", e);
+ toast.error("Failed to select background");
+ }
+ };
+
+ const handleClearBackground = () => {
+ setSetting("customBackgroundPath", null);
+ saveSettings();
+ };
+
+ const handleApplyPersona = (value: string) => {
+ const persona = personas.find((p) => p.value === value);
+ if (persona) {
+ setAssistantSetting("systemPrompt", persona.prompt);
+ setSelectedPersona(value);
+ saveSettings();
+ }
+ };
+
+ const handleResetSystemPrompt = () => {
+ const defaultPersona = personas.find((p) => p.value === "default");
+ if (defaultPersona) {
+ setAssistantSetting("systemPrompt", defaultPersona.prompt);
+ setSelectedPersona("default");
+ saveSettings();
+ }
+ };
+
+ const handleRunMigration = async () => {
+ if (migrating) return;
+ setMigrating(true);
+ try {
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ toast.success("Migration complete! Files migrated successfully");
+ } catch (e) {
+ console.error("Migration failed:", e);
+ toast.error(`Migration failed: ${e}`);
+ } finally {
+ setMigrating(false);
+ }
+ };
+
+ return (
+ <div className="h-full flex flex-col p-6 overflow-hidden">
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">
+ Settings
+ </h2>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={openConfigEditor}
+ className="gap-2"
+ >
+ <FileJson className="h-4 w-4" />
+ <span className="hidden sm:inline">Open JSON</span>
+ </Button>
+ </div>
+
+ <Tabs
+ value={activeTab}
+ onValueChange={setActiveTab}
+ className="flex-1 overflow-hidden"
+ >
+ <TabsList className="grid grid-cols-4 mb-6">
+ <TabsTrigger value="appearance">Appearance</TabsTrigger>
+ <TabsTrigger value="java">Java</TabsTrigger>
+ <TabsTrigger value="advanced">Advanced</TabsTrigger>
+ <TabsTrigger value="assistant">Assistant</TabsTrigger>
+ </TabsList>
+
+ <ScrollArea className="flex-1 pr-2">
+ <TabsContent value="appearance" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Appearance</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-3">Custom Background Image</Label>
+ <div className="flex items-center gap-6">
+ <div className="w-40 h-24 rounded-xl overflow-hidden bg-secondary border relative group shadow-lg">
+ {backgroundUrl ? (
+ <img
+ src={backgroundUrl}
+ alt="Background Preview"
+ className="w-full h-full object-cover"
+ onError={(e) => {
+ console.error("Failed to load image");
+ e.currentTarget.style.display = "none";
+ }}
+ />
+ ) : (
+ <div className="w-full h-full bg-linear-to-br from-emerald-900 via-zinc-900 to-indigo-950" />
+ )}
+ {!backgroundUrl && (
+ <div className="absolute inset-0 flex items-center justify-center text-xs text-white/50 bg-black/20">
+ Default Gradient
+ </div>
+ )}
+ </div>
+
+ <div className="flex flex-col gap-2">
+ <Button
+ variant="outline"
+ onClick={handleSelectBackground}
+ >
+ Select Image
+ </Button>
+ {backgroundUrl && (
+ <Button
+ variant="ghost"
+ className="text-red-500"
+ onClick={handleClearBackground}
+ >
+ Reset to Default
+ </Button>
+ )}
+ </div>
+ </div>
+ <p className="text-sm text-muted-foreground mt-3">
+ Select an image from your computer to replace the default
+ gradient background.
+ </p>
+ </div>
+
+ <Separator />
+
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Visual Effects</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable particle effects and animated gradients.
+ </p>
+ </div>
+ <Switch
+ checked={settings.enableVisualEffects}
+ onCheckedChange={(checked) => {
+ setSetting("enableVisualEffects", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {settings.enableVisualEffects && (
+ <div className="pl-4 border-l-2 border-border">
+ <div className="space-y-2">
+ <Label>Theme Effect</Label>
+ <Select
+ value={settings.activeEffect}
+ onValueChange={(value) => {
+ setSetting("activeEffect", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger className="w-52">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {effectOptions.map((option) => (
+ <SelectItem
+ key={option.value}
+ value={option.value}
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <p className="text-sm text-muted-foreground">
+ Select the active visual theme.
+ </p>
+ </div>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">GPU Acceleration</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable GPU acceleration for the interface.
+ </p>
+ </div>
+ <Switch
+ checked={settings.enableGpuAcceleration}
+ onCheckedChange={(checked) => {
+ setSetting("enableGpuAcceleration", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="java" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Java Environment</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-2">Java Path</Label>
+ <div className="flex gap-2">
+ <Input
+ value={settings.javaPath}
+ onChange={(e) => setSetting("javaPath", e.target.value)}
+ className="flex-1"
+ placeholder="java or full path to java executable"
+ />
+ <Button
+ variant="outline"
+ onClick={() => detectJava()}
+ disabled={isDetectingJava}
+ >
+ {isDetectingJava ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ "Detect"
+ )}
+ </Button>
+ </div>
+ <p className="text-sm text-muted-foreground mt-2">
+ Path to Java executable.
+ </p>
+ </div>
+
+ <div>
+ <Label className="mb-2">Memory Settings (MB)</Label>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label htmlFor="min-memory" className="text-sm">
+ Minimum Memory
+ </Label>
+ <Input
+ id="min-memory"
+ type="number"
+ value={settings.minMemory}
+ onChange={(e) =>
+ setSetting(
+ "minMemory",
+ parseInt(e.target.value, 10) || 1024,
+ )
+ }
+ min={512}
+ step={256}
+ />
+ </div>
+ <div>
+ <Label htmlFor="max-memory" className="text-sm">
+ Maximum Memory
+ </Label>
+ <Input
+ id="max-memory"
+ type="number"
+ value={settings.maxMemory}
+ onChange={(e) =>
+ setSetting(
+ "maxMemory",
+ parseInt(e.target.value, 10) || 2048,
+ )
+ }
+ min={1024}
+ step={256}
+ />
+ </div>
+ </div>
+ <p className="text-sm text-muted-foreground mt-2">
+ Memory allocation for Minecraft.
+ </p>
+ </div>
+
+ <Separator />
+
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <Label className="text-base">
+ Detected Java Installations
+ </Label>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => detectJava()}
+ disabled={isDetectingJava}
+ >
+ <RefreshCw
+ className={`h-4 w-4 mr-2 ${isDetectingJava ? "animate-spin" : ""}`}
+ />
+ Rescan
+ </Button>
+ </div>
+
+ {javaInstallations.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground border rounded-lg">
+ <Coffee className="h-12 w-12 mx-auto mb-4 opacity-30" />
+ <p>No Java installations detected</p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {javaInstallations.map((installation) => (
+ <Card
+ key={installation.path}
+ className={`p-3 cursor-pointer transition-colors ${
+ settings.javaPath === installation.path
+ ? "border-primary bg-primary/5"
+ : ""
+ }`}
+ onClick={() => selectJava(installation.path)}
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-medium flex items-center gap-2">
+ <Coffee className="h-4 w-4" />
+ {installation.version}
+ </div>
+ <div className="text-sm text-muted-foreground font-mono">
+ {installation.path}
+ </div>
+ </div>
+ {settings.javaPath === installation.path && (
+ <div className="h-5 w-5 text-primary">โœ“</div>
+ )}
+ </div>
+ </Card>
+ ))}
+ </div>
+ )}
+
+ <div className="mt-4">
+ <Button
+ variant="default"
+ className="w-full"
+ onClick={openJavaDownloadModal}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ Download Java
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="advanced" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Advanced Settings</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-2">Download Threads</Label>
+ <Input
+ type="number"
+ value={settings.downloadThreads}
+ onChange={(e) =>
+ setSetting(
+ "downloadThreads",
+ parseInt(e.target.value, 10) || 32,
+ )
+ }
+ min={1}
+ max={64}
+ />
+ <p className="text-sm text-muted-foreground mt-2">
+ Number of concurrent downloads.
+ </p>
+ </div>
+
+ <div>
+ <Label className="mb-2">Log Upload Service</Label>
+ <Select
+ value={settings.logUploadService}
+ onValueChange={(value) => {
+ setSetting("logUploadService", value as any);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {logServiceOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {settings.logUploadService === "pastebin.com" && (
+ <div>
+ <Label className="mb-2">Pastebin API Key</Label>
+ <Input
+ type="password"
+ value={settings.pastebinApiKey || ""}
+ onChange={(e) =>
+ setSetting("pastebinApiKey", e.target.value || null)
+ }
+ placeholder="Enter your Pastebin API key"
+ />
+ </div>
+ )}
+
+ <Separator />
+
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Use Shared Caches</Label>
+ <p className="text-sm text-muted-foreground">
+ Share downloaded assets between instances.
+ </p>
+ </div>
+ <Switch
+ checked={settings.useSharedCaches}
+ onCheckedChange={(checked) => {
+ setSetting("useSharedCaches", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {!settings.useSharedCaches && (
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">
+ Keep Legacy Per-Instance Storage
+ </Label>
+ <p className="text-sm text-muted-foreground">
+ Maintain separate cache folders for compatibility.
+ </p>
+ </div>
+ <Switch
+ checked={settings.keepLegacyPerInstanceStorage}
+ onCheckedChange={(checked) => {
+ setSetting("keepLegacyPerInstanceStorage", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+ )}
+
+ {settings.useSharedCaches && (
+ <div className="mt-4">
+ <Button
+ variant="outline"
+ className="w-full"
+ onClick={handleRunMigration}
+ disabled={migrating}
+ >
+ {migrating ? (
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ ) : (
+ <Upload className="h-4 w-4 mr-2" />
+ )}
+ {migrating
+ ? "Migrating..."
+ : "Migrate to Shared Caches"}
+ </Button>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="assistant" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">AI Assistant</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Enable Assistant</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable the AI assistant for help with Minecraft issues.
+ </p>
+ </div>
+ <Switch
+ checked={settings.assistant.enabled}
+ onCheckedChange={(checked) => {
+ setAssistantSetting("enabled", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {settings.assistant.enabled && (
+ <>
+ <div>
+ <Label className="mb-2">LLM Provider</Label>
+ <Select
+ value={settings.assistant.llmProvider}
+ onValueChange={(value) => {
+ setAssistantSetting("llmProvider", value as any);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {llmProviderOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Model</Label>
+ <Select
+ value={
+ settings.assistant.llmProvider === "ollama"
+ ? settings.assistant.ollamaModel
+ : settings.assistant.openaiModel
+ }
+ onValueChange={(value) => {
+ if (settings.assistant.llmProvider === "ollama") {
+ setAssistantSetting("ollamaModel", value);
+ } else {
+ setAssistantSetting("openaiModel", value);
+ }
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {currentModelOptions.map((model) => (
+ <SelectItem key={model.value} value={model.value}>
+ {model.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {settings.assistant.llmProvider === "ollama" && (
+ <div>
+ <Label className="mb-2">Ollama Endpoint</Label>
+ <Input
+ value={settings.assistant.ollamaEndpoint}
+ onChange={(e) => {
+ setAssistantSetting(
+ "ollamaEndpoint",
+ e.target.value,
+ );
+ saveSettings();
+ }}
+ placeholder="http://localhost:11434"
+ />
+ </div>
+ )}
+
+ {settings.assistant.llmProvider === "openai" && (
+ <>
+ <div>
+ <Label className="mb-2">OpenAI API Key</Label>
+ <Input
+ type="password"
+ value={settings.assistant.openaiApiKey || ""}
+ onChange={(e) => {
+ setAssistantSetting(
+ "openaiApiKey",
+ e.target.value || null,
+ );
+ saveSettings();
+ }}
+ placeholder="Enter your OpenAI API key"
+ />
+ </div>
+ <div>
+ <Label className="mb-2">OpenAI Endpoint</Label>
+ <Input
+ value={settings.assistant.openaiEndpoint}
+ onChange={(e) => {
+ setAssistantSetting(
+ "openaiEndpoint",
+ e.target.value,
+ );
+ saveSettings();
+ }}
+ placeholder="https://api.openai.com/v1"
+ />
+ </div>
+ </>
+ )}
+
+ <div>
+ <Label className="mb-2">Response Language</Label>
+ <Select
+ value={settings.assistant.responseLanguage}
+ onValueChange={(value) => {
+ setAssistantSetting("responseLanguage", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {languageOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Assistant Persona</Label>
+ <Select
+ value={selectedPersona}
+ onValueChange={handleApplyPersona}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {personas.map((persona) => (
+ <SelectItem
+ key={persona.value}
+ value={persona.value}
+ >
+ {persona.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <div className="mt-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleResetSystemPrompt}
+ >
+ Reset to Default
+ </Button>
+ </div>
+ </div>
+
+ <div>
+ <Label className="mb-2">System Prompt</Label>
+
+ <Textarea
+ value={settings.assistant.systemPrompt}
+ onChange={(e) => {
+ setAssistantSetting("systemPrompt", e.target.value);
+ saveSettings();
+ }}
+ rows={6}
+ className="font-mono text-sm"
+ />
+ </div>
+
+ <div>
+ <Label className="mb-2">Text-to-Speech</Label>
+
+ <Select
+ value={settings.assistant.ttsProvider}
+ onValueChange={(value) => {
+ setAssistantSetting("ttsProvider", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+
+ <SelectContent>
+ {ttsProviderOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </ScrollArea>
+ </Tabs>
+
+ {/* Java Download Modal */}
+ <Dialog
+ open={showJavaDownloadModal}
+ onOpenChange={closeJavaDownloadModal}
+ >
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Download Java</DialogTitle>
+ <DialogDescription>
+ Download and install Java for Minecraft.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-4">
+ <div>
+ <Label className="mb-2">Java Version</Label>
+ <Select
+ value={selectedMajorVersion?.toString() || ""}
+ onValueChange={(v) => selectMajorVersion(parseInt(v, 10))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select version" />
+ </SelectTrigger>
+ <SelectContent>
+ {availableMajorVersions.map((version) => (
+ <SelectItem key={version} value={version.toString()}>
+ Java {version}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Type</Label>
+ <Select
+ value={selectedImageType}
+ onValueChange={(v) => set({ selectedImageType: v as any })}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="jre">JRE (Runtime)</SelectItem>
+ <SelectItem value="jdk">JDK (Development)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="recommended"
+ checked={showOnlyRecommended}
+ onCheckedChange={(checked) =>
+ set({ showOnlyRecommended: !!checked })
+ }
+ />
+ <Label htmlFor="recommended">Show only LTS/Recommended</Label>
+ </div>
+
+ <div>
+ <Label className="mb-2">Search</Label>
+ <Input
+ placeholder="Search versions..."
+ value={searchQuery}
+ onChange={(e) => set({ searchQuery: e.target.value })}
+ />
+ </div>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={refreshCatalog}
+ disabled={isLoadingCatalog}
+ >
+ <RefreshCw
+ className={`h-4 w-4 mr-2 ${isLoadingCatalog ? "animate-spin" : ""}`}
+ />
+ Refresh Catalog
+ </Button>
+ </div>
+
+ <div className="md:col-span-2">
+ <ScrollArea className="h-75 pr-4">
+ {isLoadingCatalog ? (
+ <div className="flex items-center justify-center h-full">
+ <Loader2 className="h-8 w-8 animate-spin" />
+ </div>
+ ) : catalogError ? (
+ <div className="text-red-500 p-4">{catalogError}</div>
+ ) : filteredReleases.length === 0 ? (
+ <div className="text-muted-foreground p-4 text-center">
+ No Java versions found
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredReleases.map((release) => {
+ const status = installStatus(
+ release.majorVersion,
+ release.imageType,
+ );
+ return (
+ <Card
+ key={`${release.majorVersion}-${release.imageType}`}
+ className="p-3 cursor-pointer hover:bg-accent"
+ onClick={() =>
+ selectMajorVersion(release.majorVersion)
+ }
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-medium">
+ Java {release.majorVersion}{" "}
+ {release.imageType.toUpperCase()}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {release.releaseName} โ€ข {release.architecture}{" "}
+ {release.architecture}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {release.isLts && (
+ <Badge variant="secondary">LTS</Badge>
+ )}
+ {status === "installed" && (
+ <Badge variant="default">Installed</Badge>
+ )}
+ {status === "available" && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation();
+ selectMajorVersion(release.majorVersion);
+ downloadJava();
+ }}
+ >
+ <Download className="h-3 w-3 mr-1" />
+ Download
+ </Button>
+ )}
+ </div>
+ </div>
+ </Card>
+ );
+ })}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+ </div>
+
+ {isDownloadingJava && downloadProgress && (
+ <div className="mt-4 p-4 border rounded-lg">
+ <div className="flex justify-between items-center mb-2">
+ <span className="text-sm font-medium">
+ {downloadProgress.fileName}
+ </span>
+ <span className="text-sm text-muted-foreground">
+ {Math.round(downloadProgress.percentage)}%
+ </span>
+ </div>
+ <div className="w-full bg-secondary h-2 rounded-full overflow-hidden">
+ <div
+ className="bg-primary h-full transition-all duration-300"
+ style={{ width: `${downloadProgress.percentage}%` }}
+ />
+ </div>
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={closeJavaDownloadModal}
+ disabled={isDownloadingJava}
+ >
+ Cancel
+ </Button>
+ {selectedMajorVersion && (
+ <Button
+ onClick={() => downloadJava()}
+ disabled={isDownloadingJava}
+ >
+ {isDownloadingJava ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Downloading...
+ </>
+ ) : (
+ <>
+ <Download className="mr-2 h-4 w-4" />
+ Download Java {selectedMajorVersion}
+ </>
+ )}
+ </Button>
+ )}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Config Editor Modal */}
+ <Dialog open={showConfigEditor} onOpenChange={closeConfigEditor}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Edit Configuration</DialogTitle>
+ <DialogDescription>
+ Edit the raw JSON configuration file.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="text-sm text-muted-foreground mb-2">
+ File: {configFilePath}
+ </div>
+
+ {configEditorError && (
+ <div className="text-red-500 p-3 bg-red-50 dark:bg-red-950/30 rounded-md">
+ {configEditorError}
+ </div>
+ )}
+
+ <Textarea
+ value={rawConfigContent}
+ onChange={(e) => set({ rawConfigContent: e.target.value })}
+ className="font-mono text-sm h-100 resize-none"
+ spellCheck={false}
+ />
+
+ <DialogFooter>
+ <Button variant="outline" onClick={closeConfigEditor}>
+ Cancel
+ </Button>
+ <Button onClick={() => saveRawConfig()}>Save Changes</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui-new/src/pages/versions-view.tsx b/packages/ui-new/src/pages/versions-view.tsx
new file mode 100644
index 0000000..7f44611
--- /dev/null
+++ b/packages/ui-new/src/pages/versions-view.tsx
@@ -0,0 +1,662 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { Coffee, Loader2, Search, Trash2 } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useGameStore } from "../stores/game-store";
+import { useInstancesStore } from "../stores/instances-store";
+import type { Version } from "../types/bindings/manifest";
+
+interface InstalledModdedVersion {
+ id: string;
+ javaVersion?: number;
+}
+
+type TypeFilter = "all" | "release" | "snapshot" | "installed";
+
+export function VersionsView() {
+ const { versions, selectedVersion, loadVersions, setSelectedVersion } =
+ useGameStore();
+ const { activeInstanceId } = useInstancesStore();
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
+ const [installedModdedVersions, setInstalledModdedVersions] = useState<
+ InstalledModdedVersion[]
+ >([]);
+ const [, setIsLoadingModded] = useState(false);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [selectedVersionMetadata, setSelectedVersionMetadata] = useState<{
+ id: string;
+ javaVersion?: number;
+ isInstalled: boolean;
+ } | null>(null);
+ const [isLoadingMetadata, setIsLoadingMetadata] = useState(false);
+ const [showModLoaderSelector, setShowModLoaderSelector] = useState(false);
+
+ const normalizedQuery = searchQuery.trim().toLowerCase().replace(/ใ€‚/g, ".");
+
+ // Load installed modded versions with Java version info
+ const loadInstalledModdedVersions = useCallback(async () => {
+ if (!activeInstanceId) {
+ setInstalledModdedVersions([]);
+ setIsLoadingModded(false);
+ return;
+ }
+
+ setIsLoadingModded(true);
+ try {
+ const allInstalled = await invoke<Array<{ id: string; type: string }>>(
+ "list_installed_versions",
+ { instanceId: activeInstanceId },
+ );
+
+ const moddedIds = allInstalled
+ .filter((v) => v.type === "fabric" || v.type === "forge")
+ .map((v) => v.id);
+
+ const versionsWithJava = await Promise.all(
+ moddedIds.map(async (id) => {
+ try {
+ const javaVersion = await invoke<number | null>(
+ "get_version_java_version",
+ {
+ instanceId: activeInstanceId,
+ versionId: id,
+ },
+ );
+ return {
+ id,
+ javaVersion: javaVersion ?? undefined,
+ };
+ } catch (e) {
+ console.error(`Failed to get Java version for ${id}:`, e);
+ return { id, javaVersion: undefined };
+ }
+ }),
+ );
+
+ setInstalledModdedVersions(versionsWithJava);
+ } catch (e) {
+ console.error("Failed to load installed modded versions:", e);
+ toast.error("Error loading modded versions");
+ } finally {
+ setIsLoadingModded(false);
+ }
+ }, [activeInstanceId]);
+
+ // Combined versions list (vanilla + modded)
+ const allVersions = (() => {
+ const moddedVersions: Version[] = installedModdedVersions.map((v) => {
+ const versionType = v.id.startsWith("fabric-loader-")
+ ? "fabric"
+ : v.id.includes("-forge-")
+ ? "forge"
+ : "fabric";
+ return {
+ id: v.id,
+ type: versionType,
+ url: "",
+ time: "",
+ releaseTime: new Date().toISOString(),
+ javaVersion: BigInt(v.javaVersion ?? 0),
+ isInstalled: true,
+ };
+ });
+ return [...moddedVersions, ...versions];
+ })();
+
+ // Filter versions based on search and type filter
+ const filteredVersions = allVersions.filter((version) => {
+ if (typeFilter === "release" && version.type !== "release") return false;
+ if (typeFilter === "snapshot" && version.type !== "snapshot") return false;
+ if (typeFilter === "installed" && !version.isInstalled) return false;
+
+ if (
+ normalizedQuery &&
+ !version.id.toLowerCase().includes(normalizedQuery)
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+
+ // Get version badge styling
+ const getVersionBadge = (type: string) => {
+ switch (type) {
+ case "release":
+ return {
+ text: "Release",
+ variant: "default" as const,
+ className: "bg-emerald-500 hover:bg-emerald-600",
+ };
+ case "snapshot":
+ return {
+ text: "Snapshot",
+ variant: "secondary" as const,
+ className: "bg-amber-500 hover:bg-amber-600",
+ };
+ case "fabric":
+ return {
+ text: "Fabric",
+ variant: "outline" as const,
+ className: "border-indigo-500 text-indigo-700 dark:text-indigo-300",
+ };
+ case "forge":
+ return {
+ text: "Forge",
+ variant: "outline" as const,
+ className: "border-orange-500 text-orange-700 dark:text-orange-300",
+ };
+ case "modpack":
+ return {
+ text: "Modpack",
+ variant: "outline" as const,
+ className: "border-purple-500 text-purple-700 dark:text-purple-300",
+ };
+ default:
+ return {
+ text: type,
+ variant: "outline" as const,
+ className: "border-gray-500 text-gray-700 dark:text-gray-300",
+ };
+ }
+ };
+
+ // Load version metadata
+ const loadVersionMetadata = useCallback(
+ async (versionId: string) => {
+ if (!versionId || !activeInstanceId) {
+ setSelectedVersionMetadata(null);
+ return;
+ }
+
+ setIsLoadingMetadata(true);
+ try {
+ const metadata = await invoke<{
+ id: string;
+ javaVersion?: number;
+ isInstalled: boolean;
+ }>("get_version_metadata", {
+ instanceId: activeInstanceId,
+ versionId,
+ });
+ setSelectedVersionMetadata(metadata);
+ } catch (e) {
+ console.error("Failed to load version metadata:", e);
+ setSelectedVersionMetadata(null);
+ } finally {
+ setIsLoadingMetadata(false);
+ }
+ },
+ [activeInstanceId],
+ );
+
+ // Get base version for mod loader selector
+ const selectedBaseVersion = (() => {
+ if (!selectedVersion) return "";
+
+ if (selectedVersion.startsWith("fabric-loader-")) {
+ const parts = selectedVersion.split("-");
+ return parts[parts.length - 1];
+ }
+ if (selectedVersion.includes("-forge-")) {
+ return selectedVersion.split("-forge-")[0];
+ }
+
+ const version = versions.find((v) => v.id === selectedVersion);
+ return version ? selectedVersion : "";
+ })();
+
+ // Handle version deletion
+ const handleDeleteVersion = async () => {
+ if (!versionToDelete || !activeInstanceId) return;
+
+ setIsDeleting(true);
+ try {
+ await invoke("delete_version", {
+ instanceId: activeInstanceId,
+ versionId: versionToDelete,
+ });
+
+ if (selectedVersion === versionToDelete) {
+ setSelectedVersion("");
+ }
+
+ setShowDeleteDialog(false);
+ setVersionToDelete(null);
+ toast.success("Version deleted successfully");
+
+ await loadVersions(activeInstanceId);
+ await loadInstalledModdedVersions();
+ } catch (e) {
+ console.error("Failed to delete version:", e);
+ toast.error(`Failed to delete version: ${e}`);
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ // Show delete confirmation dialog
+ const showDeleteConfirmation = (versionId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ setVersionToDelete(versionId);
+ setShowDeleteDialog(true);
+ };
+
+ // Setup event listeners for version updates
+ useEffect(() => {
+ let unlisteners: UnlistenFn[] = [];
+
+ const setupEventListeners = async () => {
+ try {
+ const versionDeletedUnlisten = await listen(
+ "version-deleted",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const downloadCompleteUnlisten = await listen(
+ "download-complete",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const versionInstalledUnlisten = await listen(
+ "version-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const fabricInstalledUnlisten = await listen(
+ "fabric-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const forgeInstalledUnlisten = await listen(
+ "forge-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ unlisteners = [
+ versionDeletedUnlisten,
+ downloadCompleteUnlisten,
+ versionInstalledUnlisten,
+ fabricInstalledUnlisten,
+ forgeInstalledUnlisten,
+ ];
+ } catch (e) {
+ console.error("Failed to setup event listeners:", e);
+ }
+ };
+
+ setupEventListeners();
+ loadInstalledModdedVersions();
+
+ return () => {
+ unlisteners.forEach((unlisten) => {
+ unlisten();
+ });
+ };
+ }, [activeInstanceId, loadVersions, loadInstalledModdedVersions]);
+
+ // Load metadata when selected version changes
+ useEffect(() => {
+ if (selectedVersion) {
+ loadVersionMetadata(selectedVersion);
+ } else {
+ setSelectedVersionMetadata(null);
+ }
+ }, [selectedVersion, loadVersionMetadata]);
+
+ return (
+ <div className="h-full flex flex-col p-6 overflow-hidden">
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/60">
+ Version Manager
+ </h2>
+ <div className="text-sm dark:text-white/40 text-black/50">
+ Select a version to play or modify
+ </div>
+ </div>
+
+ <div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 overflow-hidden">
+ {/* Left: Version List */}
+ <div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
+ {/* Search and Filters */}
+ <div className="flex gap-3">
+ <div className="relative flex-1">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ type="text"
+ placeholder="Search versions..."
+ className="pl-9 bg-white/60 dark:bg-black/20 border-black/10 dark:border-white/10 dark:text-white text-gray-900 placeholder-black/30 dark:placeholder-white/30 focus:border-indigo-500/50 dark:focus:bg-black/40 focus:bg-white/80 backdrop-blur-sm"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+ </div>
+
+ {/* Type Filter Tabs */}
+ <Tabs
+ value={typeFilter}
+ onValueChange={(v) => setTypeFilter(v as TypeFilter)}
+ className="w-full"
+ >
+ <TabsList className="grid grid-cols-4 bg-white/60 dark:bg-black/20 border-black/5 dark:border-white/5">
+ <TabsTrigger value="all">All</TabsTrigger>
+ <TabsTrigger value="release">Release</TabsTrigger>
+ <TabsTrigger value="snapshot">Snapshot</TabsTrigger>
+ <TabsTrigger value="installed">Installed</TabsTrigger>
+ </TabsList>
+ </Tabs>
+
+ {/* Version List */}
+ <ScrollArea className="flex-1 pr-2">
+ {versions.length === 0 ? (
+ <div className="flex items-center justify-center h-40 dark:text-white/30 text-black/30 italic animate-pulse">
+ Loading versions...
+ </div>
+ ) : filteredVersions.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-40 dark:text-white/30 text-black/30 gap-2">
+ <span className="text-2xl">๐Ÿ‘ป</span>
+ <span>No matching versions found</span>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredVersions.map((version) => {
+ const badge = getVersionBadge(version.type);
+ const isSelected = selectedVersion === version.id;
+
+ return (
+ <Card
+ key={version.id}
+ className={`w-full cursor-pointer transition-all duration-200 relative overflow-hidden group ${
+ isSelected
+ ? "border-indigo-200 dark:border-indigo-500/50 bg-indigo-50 dark:bg-indigo-600/20 shadow-[0_0_20px_rgba(99,102,241,0.2)]"
+ : "border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/10 dark:hover:border-white/10 hover:translate-x-1"
+ }`}
+ onClick={() => setSelectedVersion(version.id)}
+ >
+ {isSelected && (
+ <div className="absolute inset-0 bg-linear-to-r from-indigo-500/10 to-transparent pointer-events-none" />
+ )}
+
+ <CardContent className="p-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4 flex-1">
+ <Badge
+ variant={badge.variant}
+ className={badge.className}
+ >
+ {badge.text}
+ </Badge>
+ <div className="flex-1">
+ <div
+ className={`font-bold font-mono text-lg tracking-tight ${
+ isSelected
+ ? "text-black dark:text-white"
+ : "text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white"
+ }`}
+ >
+ {version.id}
+ </div>
+ <div className="flex items-center gap-2 mt-0.5">
+ {version.releaseTime &&
+ version.type !== "fabric" &&
+ version.type !== "forge" && (
+ <div className="text-xs dark:text-white/30 text-black/30">
+ {new Date(
+ version.releaseTime,
+ ).toLocaleDateString()}
+ </div>
+ )}
+ {version.javaVersion && (
+ <div className="flex items-center gap-1 text-xs dark:text-white/40 text-black/40">
+ <Coffee className="h-3 w-3 opacity-60" />
+ <span className="font-medium">
+ Java {version.javaVersion}
+ </span>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {version.isInstalled && (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="opacity-0 group-hover:opacity-100 transition-opacity text-red-500 dark:text-red-400 hover:bg-red-500/10 dark:hover:bg-red-500/20"
+ onClick={(e) =>
+ showDeleteConfirmation(version.id, e)
+ }
+ title="Delete version"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+ })}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+
+ {/* Right: Version Details */}
+ <div className="flex flex-col gap-6">
+ <Card className="border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 backdrop-blur-sm">
+ <CardHeader>
+ <CardTitle className="text-lg">Version Details</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {selectedVersion ? (
+ <>
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Selected Version
+ </div>
+ <div className="font-mono text-xl font-bold">
+ {selectedVersion}
+ </div>
+ </div>
+
+ {isLoadingMetadata ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="text-sm">Loading metadata...</span>
+ </div>
+ ) : selectedVersionMetadata ? (
+ <div className="space-y-3">
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Installation Status
+ </div>
+ <Badge
+ variant={
+ selectedVersionMetadata.isInstalled
+ ? "default"
+ : "outline"
+ }
+ >
+ {selectedVersionMetadata.isInstalled
+ ? "Installed"
+ : "Not Installed"}
+ </Badge>
+ </div>
+
+ {selectedVersionMetadata.javaVersion && (
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Java Version
+ </div>
+ <div className="flex items-center gap-2">
+ <Coffee className="h-4 w-4" />
+ <span>
+ Java {selectedVersionMetadata.javaVersion}
+ </span>
+ </div>
+ </div>
+ )}
+
+ {!selectedVersionMetadata.isInstalled && (
+ <Button
+ className="w-full"
+ onClick={() => setShowModLoaderSelector(true)}
+ >
+ Install with Mod Loader
+ </Button>
+ )}
+ </div>
+ ) : null}
+ </>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ Select a version to view details
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* Mod Loader Installation */}
+ {showModLoaderSelector && selectedBaseVersion && (
+ <Card className="border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 backdrop-blur-sm">
+ <CardHeader>
+ <CardTitle className="text-lg">Install Mod Loader</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="text-sm text-muted-foreground">
+ Install {selectedBaseVersion} with Fabric or Forge
+ </div>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ className="flex-1"
+ onClick={async () => {
+ if (!activeInstanceId) return;
+ try {
+ await invoke("install_fabric", {
+ instanceId: activeInstanceId,
+ gameVersion: selectedBaseVersion,
+ loaderVersion: "latest",
+ });
+ toast.success("Fabric installation started");
+ setShowModLoaderSelector(false);
+ } catch (e) {
+ console.error("Failed to install Fabric:", e);
+ toast.error(`Failed to install Fabric: ${e}`);
+ }
+ }}
+ >
+ Install Fabric
+ </Button>
+ <Button
+ variant="outline"
+ className="flex-1"
+ onClick={async () => {
+ if (!activeInstanceId) return;
+ try {
+ await invoke("install_forge", {
+ instanceId: activeInstanceId,
+ gameVersion: selectedBaseVersion,
+ installerVersion: "latest",
+ });
+ toast.success("Forge installation started");
+ setShowModLoaderSelector(false);
+ } catch (e) {
+ console.error("Failed to install Forge:", e);
+ toast.error(`Failed to install Forge: ${e}`);
+ }
+ }}
+ >
+ Install Forge
+ </Button>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setShowModLoaderSelector(false)}
+ >
+ Cancel
+ </Button>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ </div>
+
+ {/* Delete Confirmation Dialog */}
+ <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Version</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete version "{versionToDelete}"? This
+ action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setShowDeleteDialog(false);
+ setVersionToDelete(null);
+ }}
+ disabled={isDeleting}
+ >
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDeleteVersion}
+ disabled={isDeleting}
+ >
+ {isDeleting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Deleting...
+ </>
+ ) : (
+ "Delete"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}