diff options
Diffstat (limited to 'packages/ui-new/src/pages')
| -rw-r--r-- | packages/ui-new/src/pages/assistant-view.tsx | 485 | ||||
| -rw-r--r-- | packages/ui-new/src/pages/home-view.tsx | 382 | ||||
| -rw-r--r-- | packages/ui-new/src/pages/index.tsx | 189 | ||||
| -rw-r--r-- | packages/ui-new/src/pages/instances-view.tsx | 370 | ||||
| -rw-r--r-- | packages/ui-new/src/pages/settings-view.tsx | 1158 | ||||
| -rw-r--r-- | packages/ui-new/src/pages/versions-view.tsx | 662 |
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 > 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 > 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, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; + + 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"> + > 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> + ); +} |