diff options
Diffstat (limited to 'packages')
36 files changed, 768 insertions, 456 deletions
diff --git a/packages/docs/app/app.css b/packages/docs/app/app.css index 50b3bc2..dbcc721 100644 --- a/packages/docs/app/app.css +++ b/packages/docs/app/app.css @@ -1,3 +1,3 @@ -@import 'tailwindcss'; -@import 'fumadocs-ui/css/neutral.css'; -@import 'fumadocs-ui/css/preset.css'; +@import "tailwindcss"; +@import "fumadocs-ui/css/neutral.css"; +@import "fumadocs-ui/css/preset.css"; diff --git a/packages/docs/app/components/mermaid.tsx b/packages/docs/app/components/mermaid.tsx index bb25f2d..2df47cc 100644 --- a/packages/docs/app/components/mermaid.tsx +++ b/packages/docs/app/components/mermaid.tsx @@ -1,11 +1,11 @@ -'use client'; +"use client"; -import { useEffect, useRef } from 'react'; -import mermaid from 'mermaid'; +import mermaid from "mermaid"; +import { useEffect, useRef } from "react"; mermaid.initialize({ startOnLoad: false, - theme: 'default', + theme: "default", }); export function Mermaid({ chart }: { chart: string }) { diff --git a/packages/docs/app/docs/page.tsx b/packages/docs/app/docs/page.tsx index 49ad005..6ff6b4a 100644 --- a/packages/docs/app/docs/page.tsx +++ b/packages/docs/app/docs/page.tsx @@ -1,30 +1,36 @@ -import type { Route } from './+types/page'; -import defaultMdxComponents from 'fumadocs-ui/mdx'; -import { DocsLayout } from 'fumadocs-ui/layouts/docs'; -import { DocsPage, DocsBody, DocsDescription, DocsTitle } from 'fumadocs-ui/page'; -import { Card, Cards } from 'fumadocs-ui/components/card'; -import { source } from '@/lib/source'; -import { i18n } from '@/lib/i18n'; -import { baseOptions } from '@/lib/layout.shared'; -import { useFumadocsLoader } from 'fumadocs-core/source/client'; -import browserCollections from 'fumadocs-mdx:collections/browser'; -import { Mermaid } from '@/components/mermaid'; +import browserCollections from "fumadocs-mdx:collections/browser"; +import { useFumadocsLoader } from "fumadocs-core/source/client"; +import { Card, Cards } from "fumadocs-ui/components/card"; +import { DocsLayout } from "fumadocs-ui/layouts/docs"; +import defaultMdxComponents from "fumadocs-ui/mdx"; +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, +} from "fumadocs-ui/page"; +import { Mermaid } from "@/components/mermaid"; +import { i18n } from "@/lib/i18n"; +import { baseOptions } from "@/lib/layout.shared"; +import { source } from "@/lib/source"; +import type { Route } from "./+types/page"; export async function loader({ params }: Route.LoaderArgs) { // 从路由参数获取语言,如果没有则使用默认语言 // URL 格式: /docs/manual/getting-started (默认语言 zh) // URL 格式: /en/docs/manual/getting-started (英语) - const lang = (params.lang && i18n.languages.includes(params.lang as any)) - ? (params.lang as 'zh' | 'en') - : (i18n.defaultLanguage as 'zh' | 'en'); + const lang = + params.lang && i18n.languages.includes(params.lang as any) + ? (params.lang as "zh" | "en") + : (i18n.defaultLanguage as "zh" | "en"); // 获取文档路径 slugs - const slugs = params['*']?.split('/').filter((v) => v.length > 0) || []; + const slugs = params["*"]?.split("/").filter((v) => v.length > 0) || []; const page = source.getPage(slugs, lang); if (!page) { - throw new Response('Not found', { status: 404 }); + throw new Response("Not found", { status: 404 }); } return { @@ -48,11 +54,11 @@ const clientLoader = browserCollections.docs.createClientLoader({ Card: (props: React.ComponentProps<typeof Card>) => ( <Card {...props} - className={`border-blue-600/20 hover:border-blue-600/50 transition-colors ${props.className || ''}`} + className={`border-blue-600/20 hover:border-blue-600/50 transition-colors ${props.className || ""}`} /> ), Cards, - Mermaid + Mermaid, }} /> </DocsBody> diff --git a/packages/docs/app/docs/search.ts b/packages/docs/app/docs/search.ts index a98edd5..47a4f38 100644 --- a/packages/docs/app/docs/search.ts +++ b/packages/docs/app/docs/search.ts @@ -1,11 +1,11 @@ -import type { Route } from './+types/search'; -import { createFromSource } from 'fumadocs-core/search/server'; -import { source } from '@/lib/source'; +import { createFromSource } from "fumadocs-core/search/server"; +import { source } from "@/lib/source"; +import type { Route } from "./+types/search"; const server = createFromSource(source, { localeMap: { zh: { - language: 'english', + language: "english", }, }, }); diff --git a/packages/docs/app/lib/i18n.ts b/packages/docs/app/lib/i18n.ts index a9f18b1..cf81ffe 100644 --- a/packages/docs/app/lib/i18n.ts +++ b/packages/docs/app/lib/i18n.ts @@ -1,8 +1,8 @@ -import { defineI18n } from 'fumadocs-core/i18n'; +import { defineI18n } from "fumadocs-core/i18n"; export const i18n = defineI18n({ - defaultLanguage: 'zh', - languages: ['zh', 'en'], - hideLocale: 'default-locale', - parser: 'dir', // 使用目录结构 (content/zh/*, content/en/*) + defaultLanguage: "zh", + languages: ["zh", "en"], + hideLocale: "default-locale", + parser: "dir", // 使用目录结构 (content/zh/*, content/en/*) }); diff --git a/packages/docs/app/lib/layout.shared.tsx b/packages/docs/app/lib/layout.shared.tsx index b3595eb..805d929 100644 --- a/packages/docs/app/lib/layout.shared.tsx +++ b/packages/docs/app/lib/layout.shared.tsx @@ -1,27 +1,27 @@ -import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'; -import { i18n } from './i18n'; +import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; +import { i18n } from "./i18n"; export function baseOptions(locale: string): BaseLayoutProps { // 默认语言(zh)不显示前缀,其他语言显示前缀 const isDefaultLocale = locale === i18n.defaultLanguage; - const localePrefix = isDefaultLocale ? '' : `/${locale}`; - + const localePrefix = isDefaultLocale ? "" : `/${locale}`; + return { i18n, nav: { - title: 'DropOut', - url: localePrefix || '/', + title: "DropOut", + url: localePrefix || "/", }, - githubUrl: 'https://github.com/HydroRoll-Team/DropOut', + githubUrl: "https://github.com/HydroRoll-Team/DropOut", links: [ { - type: 'main', - text: locale === 'zh' ? '使用文档' : 'Manual', + type: "main", + text: locale === "zh" ? "使用文档" : "Manual", url: `${localePrefix}/docs/manual`, }, { - type: 'main', - text: locale === 'zh' ? '开发文档' : 'Development', + type: "main", + text: locale === "zh" ? "开发文档" : "Development", url: `${localePrefix}/docs/development`, }, ], diff --git a/packages/docs/app/lib/source.ts b/packages/docs/app/lib/source.ts index 4d6cc3a..7fccbc0 100644 --- a/packages/docs/app/lib/source.ts +++ b/packages/docs/app/lib/source.ts @@ -1,10 +1,10 @@ -import { loader } from 'fumadocs-core/source'; -import { docs } from 'fumadocs-mdx:collections/server'; -import { i18n } from './i18n'; +import { docs } from "fumadocs-mdx:collections/server"; +import { loader } from "fumadocs-core/source"; +import { i18n } from "./i18n"; export const source = loader({ source: docs.toFumadocsSource(), - baseUrl: '/docs', + baseUrl: "/docs", i18n, // hideLocale: 'default-locale' 会自动生成正确的 URL: // - 默认语言 (zh): /docs/manual/getting-started diff --git a/packages/docs/app/root.tsx b/packages/docs/app/root.tsx index 9032c80..621175c 100644 --- a/packages/docs/app/root.tsx +++ b/packages/docs/app/root.tsx @@ -1,3 +1,4 @@ +import { RootProvider } from "fumadocs-ui/provider/react-router"; import { isRouteErrorResponse, Link, @@ -7,40 +8,39 @@ import { Scripts, ScrollRestoration, useParams, -} from 'react-router'; -import { RootProvider } from 'fumadocs-ui/provider/react-router'; -import type { Route } from './+types/root'; -import './app.css'; -import { defineI18nUI } from 'fumadocs-ui/i18n'; -import { i18n } from './lib/i18n'; +} from "react-router"; +import type { Route } from "./+types/root"; +import "./app.css"; +import { defineI18nUI } from "fumadocs-ui/i18n"; +import { i18n } from "./lib/i18n"; const { provider } = defineI18nUI(i18n, { translations: { en: { - displayName: 'English', + displayName: "English", }, zh: { - displayName: '中文', - search: '查找文档', + displayName: "中文", + search: "查找文档", }, }, }); export const links: Route.LinksFunction = () => [ - { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { rel: "preconnect", href: "https://fonts.googleapis.com" }, { - rel: 'preconnect', - href: 'https://fonts.gstatic.com', - crossOrigin: 'anonymous', + rel: "preconnect", + href: "https://fonts.gstatic.com", + crossOrigin: "anonymous", }, { - rel: 'stylesheet', - href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", }, ]; export function Layout({ children }: { children: React.ReactNode }) { const { lang = i18n.defaultLanguage } = useParams(); - + return ( <html lang={lang} suppressHydrationWarning> <head> @@ -63,14 +63,16 @@ export default function App() { } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { - let message = 'Oops!'; - let details = 'An unexpected error occurred.'; + let message = "Oops!"; + let details = "An unexpected error occurred."; let stack: string | undefined; if (isRouteErrorResponse(error)) { - message = error.status === 404 ? '404' : 'Error'; + message = error.status === 404 ? "404" : "Error"; details = - error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; + error.status === 404 + ? "The requested page could not be found." + : error.statusText || details; } else if (import.meta.env.DEV && error && error instanceof Error) { details = error.message; stack = error.stack; @@ -83,7 +85,8 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { </h1> <p className="text-2xl font-semibold mb-2">{details}</p> <p className="text-fd-muted-foreground mb-8 max-w-md"> - Sorry, we couldn't find the page you're looking for. It might have been moved or deleted. + Sorry, we couldn't find the page you're looking for. It might have been + moved or deleted. </p> <Link to="/" @@ -93,7 +96,9 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { </Link> {stack && ( <div className="mt-12 w-full max-w-2xl text-left"> - <p className="text-xs font-mono text-fd-muted-foreground mb-2 uppercase tracking-widest">Error Stack</p> + <p className="text-xs font-mono text-fd-muted-foreground mb-2 uppercase tracking-widest"> + Error Stack + </p> <pre className="p-4 overflow-x-auto text-sm bg-fd-muted rounded-xl border border-fd-border font-mono whitespace-pre-wrap break-all shadow-inner"> <code>{stack}</code> </pre> diff --git a/packages/docs/app/routes.ts b/packages/docs/app/routes.ts index 2997ecf..d12d93c 100644 --- a/packages/docs/app/routes.ts +++ b/packages/docs/app/routes.ts @@ -1,16 +1,16 @@ -import { route, type RouteConfig } from '@react-router/dev/routes'; +import { type RouteConfig, route } from "@react-router/dev/routes"; export default [ // Home routes: / and /:lang - route(':lang?', 'routes/home.tsx', { id: 'home' }), + route(":lang?", "routes/home.tsx", { id: "home" }), // Docs routes: /docs/* and /:lang/docs/* - route(':lang?/docs', 'routes/docs.tsx', { id: 'docs' }), - route(':lang?/docs/*', 'docs/page.tsx', { id: 'docs-page' }), + route(":lang?/docs", "routes/docs.tsx", { id: "docs" }), + route(":lang?/docs/*", "docs/page.tsx", { id: "docs-page" }), // API routes - route('api/search', 'docs/search.ts', { id: 'api-search' }), + route("api/search", "docs/search.ts", { id: "api-search" }), // Catch-all 404 - route('*', 'routes/not-found.tsx', { id: 'not-found' }), + route("*", "routes/not-found.tsx", { id: "not-found" }), ] satisfies RouteConfig; diff --git a/packages/docs/app/routes/docs.tsx b/packages/docs/app/routes/docs.tsx index 5090ccf..382a4e5 100644 --- a/packages/docs/app/routes/docs.tsx +++ b/packages/docs/app/routes/docs.tsx @@ -1,16 +1,15 @@ -import type { Route } from './+types/docs'; -import { redirect } from 'react-router'; - -import { i18n } from '@/lib/i18n'; +import { redirect } from "react-router"; +import { i18n } from "@/lib/i18n"; +import type { Route } from "./+types/docs"; export function loader({ params }: Route.LoaderArgs) { const lang = params.lang as string | undefined; - + // 如果没有语言参数或是默认语言,重定向到 /docs/manual/getting-started if (!lang || lang === i18n.defaultLanguage) { - return redirect('/docs/manual/getting-started'); + return redirect("/docs/manual/getting-started"); } - + // 其他语言重定向到 /:lang/docs/manual/getting-started return redirect(`/${lang}/docs/manual/getting-started`); } diff --git a/packages/docs/app/routes/home.tsx b/packages/docs/app/routes/home.tsx index 427bf4e..fe561e4 100644 --- a/packages/docs/app/routes/home.tsx +++ b/packages/docs/app/routes/home.tsx @@ -1,89 +1,133 @@ -import type { Route } from './+types/home'; -import { HomeLayout } from 'fumadocs-ui/layouts/home'; -import { baseOptions } from '@/lib/layout.shared'; -import { i18n } from '@/lib/i18n'; +import { HomeLayout } from "fumadocs-ui/layouts/home"; +import { i18n } from "@/lib/i18n"; +import { baseOptions } from "@/lib/layout.shared"; +import type { Route } from "./+types/home"; const texts = { en: { hero: { - title: 'DropOut Minecraft Launcher', - subtitle: 'Modern. Reproducible. Developer-Grade.', - description: 'Built with Tauri v2 and Rust for native performance and minimal resource usage', - start: 'Get Started', - development: 'Development', + title: "DropOut Minecraft Launcher", + subtitle: "Modern. Reproducible. Developer-Grade.", + description: + "Built with Tauri v2 and Rust for native performance and minimal resource usage", + start: "Get Started", + development: "Development", }, features: { items: [ - { title: 'High Performance', desc: 'Built with Rust and Tauri for minimal resource usage and fast startup times' }, - { title: 'Modern UI', desc: 'Clean, distraction-free interface with Svelte 5 and Tailwind CSS 4' }, - { title: 'Secure Auth', desc: 'Microsoft OAuth 2.0 with device code flow and offline mode support' }, - { title: 'Mod Loaders', desc: 'Built-in support for Fabric and Forge with automatic version management' }, - { title: 'Java Management', desc: 'Auto-detection and integrated downloader for Adoptium JDK/JRE' }, - { title: 'Instance System', desc: 'Isolated game environments with independent configs and mods' }, - ] + { + title: "High Performance", + desc: "Built with Rust and Tauri for minimal resource usage and fast startup times", + }, + { + title: "Modern UI", + desc: "Clean, distraction-free interface with Svelte 5 and Tailwind CSS 4", + }, + { + title: "Secure Auth", + desc: "Microsoft OAuth 2.0 with device code flow and offline mode support", + }, + { + title: "Mod Loaders", + desc: "Built-in support for Fabric and Forge with automatic version management", + }, + { + title: "Java Management", + desc: "Auto-detection and integrated downloader for Adoptium JDK/JRE", + }, + { + title: "Instance System", + desc: "Isolated game environments with independent configs and mods", + }, + ], }, why: { - title: 'Why DropOut?', + title: "Why DropOut?", items: [ - { q: 'Your instance worked yesterday but broke today?', a: '→ DropOut makes it traceable.' }, - { q: 'Sharing a modpack means zipping gigabytes?', a: '→ DropOut shares exact dependency manifests.' }, - { q: 'Java, loader, mods, configs drift out of sync?', a: '→ DropOut locks them together.' }, - ] + { + q: "Your instance worked yesterday but broke today?", + a: "→ DropOut makes it traceable.", + }, + { + q: "Sharing a modpack means zipping gigabytes?", + a: "→ DropOut shares exact dependency manifests.", + }, + { + q: "Java, loader, mods, configs drift out of sync?", + a: "→ DropOut locks them together.", + }, + ], }, cta: { - title: 'Ready to get started?', - desc: 'Check out the documentation to learn more about DropOut', - button: 'Read the Docs', - } + title: "Ready to get started?", + desc: "Check out the documentation to learn more about DropOut", + button: "Read the Docs", + }, }, zh: { hero: { - title: 'DropOut Minecraft 启动器', - subtitle: '现代、可复现、开发者级', - description: '基于 Tauri v2 和 Rust 构建,拥有原生性能和极低的资源占用', - start: '开始使用', - development: '参与开发', + title: "DropOut Minecraft 启动器", + subtitle: "现代、可复现、开发者级", + description: "基于 Tauri v2 和 Rust 构建,拥有原生性能和极低的资源占用", + start: "开始使用", + development: "参与开发", }, features: { items: [ - { title: '高性能', desc: '使用 Rust 和 Tauri 构建,资源占用最小,启动速度极快' }, - { title: '现代化界面', desc: '简洁、无干扰的界面,使用 Svelte 5 和 Tailwind CSS 4' }, - { title: '安全认证', desc: '支持微软 OAuth 2.0 设备代码流和离线模式' }, - { title: '模组支持', desc: '内置 Fabric 和 Forge 支持,自动管理版本' }, - { title: 'Java 管理', desc: '自动检测并集成 Adoptium JDK/JRE 下载器' }, - { title: '实例系统', desc: '独立的游戏环境,独立的配置和模组' }, - ] + { + title: "高性能", + desc: "使用 Rust 和 Tauri 构建,资源占用最小,启动速度极快", + }, + { + title: "现代化界面", + desc: "简洁、无干扰的界面,使用 Svelte 5 和 Tailwind CSS 4", + }, + { title: "安全认证", desc: "支持微软 OAuth 2.0 设备代码流和离线模式" }, + { title: "模组支持", desc: "内置 Fabric 和 Forge 支持,自动管理版本" }, + { title: "Java 管理", desc: "自动检测并集成 Adoptium JDK/JRE 下载器" }, + { title: "实例系统", desc: "独立的游戏环境,独立的配置和模组" }, + ], }, why: { - title: '为什么选择 DropOut?', + title: "为什么选择 DropOut?", items: [ - { q: '你的实例昨天还能用,今天就坏了?', a: '→ DropOut 让它可追溯。' }, - { q: '分享模组包意味着打包数GB的文件?', a: '→ DropOut 分享精确的依赖清单。' }, - { q: 'Java、加载器、模组、配置不同步?', a: '→ DropOut 将它们锁定在一起。' }, - ] + { q: "你的实例昨天还能用,今天就坏了?", a: "→ DropOut 让它可追溯。" }, + { + q: "分享模组包意味着打包数GB的文件?", + a: "→ DropOut 分享精确的依赖清单。", + }, + { + q: "Java、加载器、模组、配置不同步?", + a: "→ DropOut 将它们锁定在一起。", + }, + ], }, cta: { - title: '准备好开始了?', - desc: '查看文档以了解更多关于 DropOut 的信息', - button: '阅读文档', - } - } + title: "准备好开始了?", + desc: "查看文档以了解更多关于 DropOut 的信息", + button: "阅读文档", + }, + }, }; export function meta({ params }: Route.MetaArgs) { return [ - { title: 'DropOut - Modern Minecraft Launcher' }, - { name: 'description', content: 'A modern, reproducible, and developer-grade Minecraft launcher built with Tauri v2 and Rust.' }, + { title: "DropOut - Modern Minecraft Launcher" }, + { + name: "description", + content: + "A modern, reproducible, and developer-grade Minecraft launcher built with Tauri v2 and Rust.", + }, ]; } export default function Home({ params }: Route.ComponentProps) { - const lang = (params.lang as 'en' | 'zh') || i18n.defaultLanguage; + const lang = (params.lang as "en" | "zh") || i18n.defaultLanguage; const t = texts[lang]; - + // 默认语言(zh)不显示前缀,其他语言显示前缀 const isDefaultLocale = lang === i18n.defaultLanguage; - const localePrefix = isDefaultLocale ? '' : `/${lang}`; + const localePrefix = isDefaultLocale ? "" : `/${lang}`; return ( <HomeLayout {...baseOptions(lang)}> @@ -110,7 +154,7 @@ export default function Home({ params }: Route.ComponentProps) { className="bg-fd-secondary hover:bg-fd-secondary/80 text-fd-secondary-foreground font-semibold rounded-lg px-6 py-3 transition-colors cursor-pointer border border-blue-600/50" href={`${localePrefix}/docs/development`} > - {t.hero.development} + {t.hero.development} </a> </div> </div> @@ -118,9 +162,9 @@ export default function Home({ params }: Route.ComponentProps) { {/* Launcher Showcase */} <div className="mb-16"> <div className="rounded-xl overflow-hidden shadow-2xl border border-fd-border"> - <img - src="/image.png" - alt="DropOut Launcher Interface" + <img + src="/image.png" + alt="DropOut Launcher Interface" className="w-full h-auto" /> </div> @@ -129,11 +173,12 @@ export default function Home({ params }: Route.ComponentProps) { {/* Features Grid */} <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-16"> {t.features.items.map((item, i) => ( - <div key={i} className="p-6 rounded-lg border border-blue-600/20 bg-fd-card hover:border-blue-600/50 transition-colors"> + <div + key={i} + className="p-6 rounded-lg border border-blue-600/20 bg-fd-card hover:border-blue-600/50 transition-colors" + > <h3 className="font-semibold text-lg mb-2">{item.title}</h3> - <p className="text-sm text-fd-muted-foreground"> - {item.desc} - </p> + <p className="text-sm text-fd-muted-foreground">{item.desc}</p> </div> ))} </div> @@ -157,9 +202,7 @@ export default function Home({ params }: Route.ComponentProps) { {/* CTA Section */} <div className="text-center py-12 px-6 rounded-xl bg-gradient-to-r from-blue-600/10 to-cyan-500/10 border border-blue-600/20"> <h2 className="text-3xl font-bold mb-4">{t.cta.title}</h2> - <p className="text-lg text-fd-muted-foreground mb-6"> - {t.cta.desc} - </p> + <p className="text-lg text-fd-muted-foreground mb-6">{t.cta.desc}</p> <a className="inline-block bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg px-8 py-3 transition-colors" href={`${localePrefix}/docs/manual/getting-started`} diff --git a/packages/docs/app/routes/not-found.tsx b/packages/docs/app/routes/not-found.tsx index 1d9e041..7be6081 100644 --- a/packages/docs/app/routes/not-found.tsx +++ b/packages/docs/app/routes/not-found.tsx @@ -1,5 +1,5 @@ export function loader() { - throw new Response('Not Found', { status: 404 }); + throw new Response("Not Found", { status: 404 }); } export default function NotFound() { diff --git a/packages/docs/biome.json b/packages/docs/biome.json index a637e58..ea55c8c 100644 --- a/packages/docs/biome.json +++ b/packages/docs/biome.json @@ -1,5 +1,6 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", + "root": false, + "$schema": "https://biomejs.dev/schemas/2.4.5/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -7,11 +8,7 @@ }, "files": { "ignoreUnknown": true, - "includes": [ - "**", - "!node_modules", - "!.source" - ] + "includes": ["**", "!node_modules", "!.source"] }, "formatter": { "enabled": true, diff --git a/packages/docs/content/en/development/meta.json b/packages/docs/content/en/development/meta.json index dc0d5c5..627f2ab 100644 --- a/packages/docs/content/en/development/meta.json +++ b/packages/docs/content/en/development/meta.json @@ -1,8 +1,4 @@ { "title": "Development", - "pages": [ - "guide", - "architecture", - "implementation" - ] + "pages": ["guide", "architecture", "implementation"] } diff --git a/packages/docs/content/en/manual/features/meta.json b/packages/docs/content/en/manual/features/meta.json index 4725321..fcaa9ad 100644 --- a/packages/docs/content/en/manual/features/meta.json +++ b/packages/docs/content/en/manual/features/meta.json @@ -1,9 +1,4 @@ { "title": "Features", - "pages": [ - "index", - "authentication", - "java", - "mod-loaders" - ] + "pages": ["index", "authentication", "java", "mod-loaders"] } diff --git a/packages/docs/content/en/manual/meta.json b/packages/docs/content/en/manual/meta.json index 38506c1..56c5ceb 100644 --- a/packages/docs/content/en/manual/meta.json +++ b/packages/docs/content/en/manual/meta.json @@ -1,9 +1,4 @@ { "title": "User Manual", - "pages": [ - "index", - "getting-started", - "features", - "troubleshooting" - ] + "pages": ["index", "getting-started", "features", "troubleshooting"] } diff --git a/packages/docs/content/en/meta.json b/packages/docs/content/en/meta.json index a877cab..af40af3 100644 --- a/packages/docs/content/en/meta.json +++ b/packages/docs/content/en/meta.json @@ -1,7 +1,4 @@ { "title": "Documentation", - "pages": [ - "manual", - "development" - ] + "pages": ["manual", "development"] } diff --git a/packages/docs/content/zh/development/meta.json b/packages/docs/content/zh/development/meta.json index 69cc009..48afa86 100644 --- a/packages/docs/content/zh/development/meta.json +++ b/packages/docs/content/zh/development/meta.json @@ -1,8 +1,4 @@ { "title": "开发文档", - "pages": [ - "index", - "architecture", - "implementation" - ] + "pages": ["index", "architecture", "implementation"] } diff --git a/packages/docs/content/zh/manual/features/meta.json b/packages/docs/content/zh/manual/features/meta.json index 2fb2ded..3bf3d24 100644 --- a/packages/docs/content/zh/manual/features/meta.json +++ b/packages/docs/content/zh/manual/features/meta.json @@ -1,9 +1,4 @@ { "title": "功能特性", - "pages": [ - "index", - "authentication", - "java", - "mod-loaders" - ] + "pages": ["index", "authentication", "java", "mod-loaders"] } diff --git a/packages/docs/content/zh/meta.json b/packages/docs/content/zh/meta.json index b0825f3..a5aa1d7 100644 --- a/packages/docs/content/zh/meta.json +++ b/packages/docs/content/zh/meta.json @@ -1,7 +1,4 @@ { "title": "文档", - "pages": [ - "development", - "manual" - ] + "pages": ["development", "manual"] } diff --git a/packages/docs/package.json b/packages/docs/package.json index 4ee4baf..08cd10d 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -2,6 +2,7 @@ "name": "@dropout/docs", "private": true, "type": "module", + "version": "0.1.0-alpha", "scripts": { "build": "react-router build", "dev": "react-router dev", diff --git a/packages/docs/react-router.config.ts b/packages/docs/react-router.config.ts index 51e8967..e45e273 100644 --- a/packages/docs/react-router.config.ts +++ b/packages/docs/react-router.config.ts @@ -1,4 +1,4 @@ -import type { Config } from '@react-router/dev/config'; +import type { Config } from "@react-router/dev/config"; export default { ssr: true, diff --git a/packages/docs/source.config.ts b/packages/docs/source.config.ts index 7880853..37480ac 100644 --- a/packages/docs/source.config.ts +++ b/packages/docs/source.config.ts @@ -1,8 +1,8 @@ -import { defineConfig, defineDocs } from 'fumadocs-mdx/config'; -import { remarkMdxMermaid } from 'fumadocs-core/mdx-plugins'; +import { remarkMdxMermaid } from "fumadocs-core/mdx-plugins"; +import { defineConfig, defineDocs } from "fumadocs-mdx/config"; export const docs = defineDocs({ - dir: 'content', + dir: "content", }); export default defineConfig({ diff --git a/packages/docs/tsconfig.json b/packages/docs/tsconfig.json index 717253d..0a4a5b2 100644 --- a/packages/docs/tsconfig.json +++ b/packages/docs/tsconfig.json @@ -1,5 +1,10 @@ { - "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"], + "include": [ + "**/*", + "**/.server/**/*", + "**/.client/**/*", + ".react-router/types/**/*" + ], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], "types": ["node", "vite/client"], diff --git a/packages/docs/vite.config.ts b/packages/docs/vite.config.ts index f408dc5..6ad484b 100644 --- a/packages/docs/vite.config.ts +++ b/packages/docs/vite.config.ts @@ -1,9 +1,9 @@ -import { reactRouter } from '@react-router/dev/vite'; -import tailwindcss from '@tailwindcss/vite'; -import { defineConfig } from 'vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; -import mdx from 'fumadocs-mdx/vite'; -import * as MdxConfig from './source.config'; +import { reactRouter } from "@react-router/dev/vite"; +import tailwindcss from "@tailwindcss/vite"; +import mdx from "fumadocs-mdx/vite"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; +import * as MdxConfig from "./source.config"; export default defineConfig({ plugins: [ diff --git a/packages/ui/package.json b/packages/ui/package.json index 42705f8..9f329e4 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "@base-ui/react": "^1.2.0", + "@hookform/resolvers": "^5.2.2", + "@stepperize/react": "^6.1.0", "@tauri-apps/api": "^2.9.1", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.4.5", @@ -24,6 +26,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-hook-form": "^7.71.2", "react-router": "^7.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.1", diff --git a/packages/ui/src/client.ts b/packages/ui/src/client.ts index 18d2377..6f354d2 100644 --- a/packages/ui/src/client.ts +++ b/packages/ui/src/client.ts @@ -12,6 +12,7 @@ import type { InstalledForgeVersion, InstalledVersion, Instance, + InstanceRepairResult, JavaCatalog, JavaDownloadInfo, JavaInstallation, @@ -119,6 +120,16 @@ export function duplicateInstance( }); } +export function exportInstance( + instanceId: string, + archivePath: string, +): Promise<string> { + return invoke<string>("export_instance", { + instanceId, + archivePath, + }); +} + export function fetchAdoptiumJava( majorVersion: number, imageType: string, @@ -267,6 +278,16 @@ export function installVersion( }); } +export function importInstance( + archivePath: string, + newName?: string, +): Promise<Instance> { + return invoke<Instance>("import_instance", { + archivePath, + newName, + }); +} + export function isFabricInstalled( instanceId: string, gameVersion: string, @@ -351,6 +372,10 @@ export function refreshJavaCatalog(): Promise<JavaCatalog> { return invoke<JavaCatalog>("refresh_java_catalog"); } +export function repairInstances(): Promise<InstanceRepairResult> { + return invoke<InstanceRepairResult>("repair_instances"); +} + export function resumeJavaDownloads(): Promise<JavaInstallation[]> { return invoke<JavaInstallation[]>("resume_java_downloads"); } @@ -383,6 +408,10 @@ export function startGame( }); } +export function stopGame(): Promise<string> { + return invoke<string>("stop_game"); +} + export function startMicrosoftLogin(): Promise<DeviceCodeResponse> { return invoke<DeviceCodeResponse>("start_microsoft_login"); } diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx index 0710c3a..8f70985 100644 --- a/packages/ui/src/components/bottom-bar.tsx +++ b/packages/ui/src/components/bottom-bar.tsx @@ -1,11 +1,10 @@ -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { Play, User, XIcon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import { listInstalledVersions, startGame } from "@/client"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/models/auth"; import { useInstanceStore } from "@/models/instance"; +import { useGameStore } from "@/stores/game-store"; import { LoginModal } from "./login-modal"; import { Button } from "./ui/button"; import { @@ -18,150 +17,74 @@ import { } from "./ui/select"; import { Spinner } from "./ui/spinner"; -interface InstalledVersion { - id: string; - type: string; -} - export function BottomBar() { - const authStore = useAuthStore(); - const instancesStore = useInstanceStore(); + const account = useAuthStore((state) => state.account); + const instances = useInstanceStore((state) => state.instances); + const activeInstance = useInstanceStore((state) => state.activeInstance); + const setActiveInstance = useInstanceStore((state) => state.setActiveInstance); + const selectedVersion = useGameStore((state) => state.selectedVersion); + const setSelectedVersion = useGameStore((state) => state.setSelectedVersion); + const startGame = useGameStore((state) => state.startGame); + const stopGame = useGameStore((state) => state.stopGame); + const runningInstanceId = useGameStore((state) => state.runningInstanceId); + const launchingInstanceId = useGameStore((state) => state.launchingInstanceId); + const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId); - const [isLaunched, setIsLaunched] = useState<boolean>(false); - const gameUnlisten = useRef<UnlistenFn | null>(null); - const [isLaunching, setIsLaunching] = useState<boolean>(false); - const [selectedVersion, setSelectedVersion] = useState<string | null>(null); - const [installedVersions, setInstalledVersions] = useState< - InstalledVersion[] - >([]); - const [isLoadingVersions, setIsLoadingVersions] = useState(true); const [showLoginModal, setShowLoginModal] = useState(false); - const loadInstalledVersions = useCallback(async () => { - if (!instancesStore.activeInstance) { - setInstalledVersions([]); - setIsLoadingVersions(false); + useEffect(() => { + const nextVersion = activeInstance?.versionId ?? ""; + if (selectedVersion === nextVersion) { return; } - setIsLoadingVersions(true); - try { - const versions = await listInstalledVersions( - instancesStore.activeInstance.id, - ); - setInstalledVersions(versions); + setSelectedVersion(nextVersion); + }, [activeInstance?.id, activeInstance?.versionId, selectedVersion, setSelectedVersion]); - // If no version is selected but we have installed versions, select the first one - if (!selectedVersion && versions.length > 0) { - setSelectedVersion(versions[0].id); + const handleInstanceChange = useCallback( + async (instanceId: string) => { + if (activeInstance?.id === instanceId) { + return; } - } catch (error) { - console.error("Failed to load installed versions:", error); - } finally { - setIsLoadingVersions(false); - } - }, [instancesStore.activeInstance, selectedVersion]); - useEffect(() => { - loadInstalledVersions(); - - // Listen for backend events that should refresh installed versions. - let unlistenDownload: UnlistenFn | null = null; - let unlistenVersionDeleted: UnlistenFn | null = null; - - (async () => { - try { - unlistenDownload = await listen("download-complete", () => { - loadInstalledVersions(); - }); - } catch (err) { - // best-effort: do not break UI if listening fails - // eslint-disable-next-line no-console - console.warn("Failed to attach download-complete listener:", err); + const nextInstance = instances.find((instance) => instance.id === instanceId); + if (!nextInstance) { + return; } try { - unlistenVersionDeleted = await listen("version-deleted", () => { - loadInstalledVersions(); - }); - } catch (err) { - // eslint-disable-next-line no-console - console.warn("Failed to attach version-deleted listener:", err); + await setActiveInstance(nextInstance); + } catch (error) { + console.error("Failed to activate instance:", error); + toast.error(`Failed to activate instance: ${String(error)}`); } - })(); - - return () => { - try { - if (unlistenDownload) unlistenDownload(); - } catch { - // ignore - } - try { - if (unlistenVersionDeleted) unlistenVersionDeleted(); - } catch { - // ignore - } - }; - }, [loadInstalledVersions]); + }, + [activeInstance?.id, instances, setActiveInstance], + ); const handleStartGame = async () => { - if (!selectedVersion) { - toast.info("Please select a version!"); - return; - } - - if (!instancesStore.activeInstance) { + if (!activeInstance) { toast.info("Please select an instance first!"); return; } - try { - gameUnlisten.current = await listen("game-exited", () => { - setIsLaunched(false); - }); - } catch (error) { - toast.warning(`Failed to listen to game-exited event: ${error}`); - } - - setIsLaunching(true); - try { - await startGame(instancesStore.activeInstance?.id, selectedVersion); - setIsLaunched(true); - } catch (error) { - console.error(`Failed to start game: ${error}`); - toast.error(`Failed to start game: ${error}`); - } finally { - setIsLaunching(false); - } + await startGame( + account, + () => setShowLoginModal(true), + activeInstance.id, + selectedVersion || activeInstance.versionId, + () => undefined, + ); }; - const getVersionTypeColor = (type: string) => { - switch (type) { - case "release": - return "bg-emerald-500"; - case "snapshot": - return "bg-amber-500"; - case "old_beta": - return "bg-rose-500"; - case "old_alpha": - return "bg-violet-500"; - default: - return "bg-gray-500"; - } + const handleStopGame = async () => { + await stopGame(runningInstanceId); }; - const versionOptions = useMemo( - () => - installedVersions.map((v) => ({ - label: `${v.id}${v.type !== "release" ? ` (${v.type})` : ""}`, - value: v.id, - type: v.type, - })), - [installedVersions], - ); - const renderButton = () => { - if (!authStore.account) { + const isGameRunning = runningInstanceId !== null; + + if (!account) { return ( <Button className="px-4 py-2" @@ -173,20 +96,20 @@ export function BottomBar() { ); } - return isLaunched ? ( - <Button - variant="destructive" - onClick={() => { - toast.warning( - "Minecraft Process will not be terminated, please close it manually.", - ); - setIsLaunched(false); - }} - > - <XIcon /> - Game started - </Button> - ) : ( + if (isGameRunning) { + return ( + <Button + variant="destructive" + onClick={handleStopGame} + disabled={stoppingInstanceId !== null} + > + {stoppingInstanceId ? <Spinner /> : <XIcon />} + Close + </Button> + ); + } + + return ( <Button className={cn( "px-4 py-2 shadow-xl", @@ -194,9 +117,9 @@ export function BottomBar() { )} size="lg" onClick={handleStartGame} - disabled={isLaunching} + disabled={launchingInstanceId === activeInstance?.id} > - {isLaunching ? <Spinner /> : <Play />} + {launchingInstanceId === activeInstance?.id ? <Spinner /> : <Play />} Start </Button> ); @@ -206,40 +129,39 @@ export function BottomBar() { <div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/30 via-transparent to-transparent p-4 z-10"> <div className="max-w-7xl mx-auto"> <div className="flex items-center justify-between bg-white/5 dark:bg-black/20 backdrop-blur-xl border border-white/10 dark:border-white/5 p-3 shadow-lg"> - <div className="flex items-center gap-4"> - <div className="flex flex-col"> - <span className="text-xs font-mono text-zinc-400 uppercase tracking-wider"> - Active Instance - </span> - <span className="text-sm font-medium text-white"> - {instancesStore.activeInstance?.name || "No instance selected"} - </span> - </div> - + <div className="flex items-center gap-4 min-w-0"> <Select - value={selectedVersion} - items={versionOptions} - onValueChange={setSelectedVersion} - disabled={isLoadingVersions} + value={activeInstance?.id ?? null} + items={instances.map((instance) => ({ + label: instance.name, + value: instance.id, + }))} + onValueChange={(value) => { + if (value) { + void handleInstanceChange(value); + } + }} + disabled={instances.length === 0} > - <SelectTrigger className="max-w-48"> + <SelectTrigger className="w-full min-w-64 max-w-80"> <SelectValue placeholder={ - isLoadingVersions - ? "Loading versions..." - : "Please select a version" + instances.length === 0 + ? "No instances available" + : "Please select an instance" } /> </SelectTrigger> <SelectContent alignItemWithTrigger={false}> <SelectGroup> - {versionOptions.map((item) => ( - <SelectItem - key={item.value} - value={item.value} - className={getVersionTypeColor(item.type)} - > - {item.label} + {instances.map((instance) => ( + <SelectItem key={instance.id} value={instance.id}> + <div className="flex min-w-0 flex-col"> + <span className="truncate">{instance.name}</span> + <span className="text-muted-foreground truncate text-[11px]"> + {instance.versionId ?? "No version selected"} + </span> + </div> </SelectItem> ))} </SelectGroup> diff --git a/packages/ui/src/models/auth.ts b/packages/ui/src/models/auth.ts index 10b2a0d..9c814d2 100644 --- a/packages/ui/src/models/auth.ts +++ b/packages/ui/src/models/auth.ts @@ -13,6 +13,10 @@ import { } from "@/client"; import type { Account, DeviceCodeResponse } from "@/types"; +function getAuthErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export interface AuthState { account: Account | null; loginMode: Account["type"] | null; @@ -68,36 +72,78 @@ export const useAuthStore = create<AuthState>((set, get) => ({ toast.warning("Failed to attch auth-progress listener"); } - const deviceCode = await startMicrosoftLogin(); - navigator.clipboard?.writeText(deviceCode.userCode).catch((err) => { - console.error("Failed to copy to clipboard:", err); - }); - open(deviceCode.verificationUri).catch((err) => { - console.error("Failed to open browser:", err); - }); - const ms = Number(deviceCode.interval) * 1000; - const interval = setInterval(() => { - _pollLoginStatus(deviceCode.deviceCode, onSuccess); - }, ms); - set({ _pollingInterval: interval, deviceCode }); + try { + const deviceCode = await startMicrosoftLogin(); + + navigator.clipboard?.writeText(deviceCode.userCode).catch((err) => { + console.error("Failed to copy to clipboard:", err); + }); + open(deviceCode.verificationUri).catch((err) => { + console.error("Failed to open browser:", err); + }); + + const ms = Math.max(1, Number(deviceCode.interval) || 5) * 1000; + const interval = setInterval(() => { + _pollLoginStatus(deviceCode.deviceCode, onSuccess); + }, ms); + + set({ + _pollingInterval: interval, + deviceCode, + statusMessage: deviceCode.message ?? "Waiting for authorization...", + }); + } catch (error) { + const message = getAuthErrorMessage(error); + console.error("Failed to start Microsoft login:", error); + set({ loginMode: null, statusMessage: `Failed to start login: ${message}` }); + toast.error(`Failed to start Microsoft login: ${message}`); + } }, _pollLoginStatus: async (deviceCode, onSuccess) => { const { _pollingInterval, _mutex: mutex, _progressUnlisten } = get(); if (mutex.isLocked) return; - mutex.acquire(); + + await mutex.acquire(); + try { const account = await completeMicrosoftLogin(deviceCode); clearInterval(_pollingInterval ?? undefined); _progressUnlisten?.(); onSuccess?.(); - set({ account, loginMode: "microsoft" }); - } catch (error) { - if (error === "authorization_pending") { - console.log("Authorization pending..."); - } else { - console.error("Failed to poll login status:", error); - toast.error("Failed to poll login status"); + set({ + account, + loginMode: "microsoft", + deviceCode: null, + _pollingInterval: null, + _progressUnlisten: null, + statusMessage: "Login successful", + }); + } catch (error: unknown) { + const message = getAuthErrorMessage(error); + + if (message.includes("authorization_pending")) { + set({ statusMessage: "Waiting for authorization..." }); + return; + } + + if (message.includes("slow_down")) { + set({ statusMessage: "Microsoft asked to slow down polling..." }); + return; } + + clearInterval(_pollingInterval ?? undefined); + _progressUnlisten?.(); + + set({ + loginMode: null, + deviceCode: null, + _pollingInterval: null, + _progressUnlisten: null, + statusMessage: `Login failed: ${message}`, + }); + + console.error("Failed to poll login status:", error); + toast.error(`Microsoft login failed: ${message}`); } finally { mutex.release(); } @@ -111,6 +157,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({ } set({ loginMode: null, + deviceCode: null, _pollingInterval: null, statusMessage: null, _progressUnlisten: null, diff --git a/packages/ui/src/models/instance.ts b/packages/ui/src/models/instance.ts index b1b463e..e1eb7c1 100644 --- a/packages/ui/src/models/instance.ts +++ b/packages/ui/src/models/instance.ts @@ -4,10 +4,13 @@ import { createInstance, deleteInstance, duplicateInstance, + exportInstance, getActiveInstance, getInstance, + importInstance, listInstances, - setActiveInstance, + repairInstances, + setActiveInstance as setActiveInstanceCommand, updateInstance, } from "@/client"; import type { Instance } from "@/types"; @@ -22,6 +25,9 @@ interface InstanceState { update: (instance: Instance) => Promise<void>; setActiveInstance: (instance: Instance) => Promise<void>; duplicate: (id: string, newName: string) => Promise<Instance | null>; + exportArchive: (id: string, archivePath: string) => Promise<void>; + importArchive: (archivePath: string, newName?: string) => Promise<Instance | null>; + repair: () => Promise<void>; get: (id: string) => Promise<Instance | null>; } @@ -30,14 +36,20 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({ activeInstance: null, refresh: async () => { - const { setActiveInstance } = get(); try { const instances = await listInstances(); - const activeInstance = await getActiveInstance(); + let activeInstance = await getActiveInstance(); + + if ( + activeInstance && + !instances.some((instance) => instance.id === activeInstance?.id) + ) { + activeInstance = null; + } if (!activeInstance && instances.length > 0) { - // If no active instance but instances exist, set the first one as active - await setActiveInstance(instances[0]); + await setActiveInstanceCommand(instances[0].id); + activeInstance = instances[0]; } set({ instances, activeInstance }); @@ -51,35 +63,27 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({ const { refresh } = get(); try { const instance = await createInstance(name); + await setActiveInstanceCommand(instance.id); await refresh(); toast.success(`Instance "${name}" created successfully`); return instance; } catch (e) { console.error("Failed to create instance:", e); - toast.error("Error creating instance"); + toast.error(String(e)); return null; } }, delete: async (id) => { - const { refresh, instances, activeInstance, setActiveInstance } = get(); + const { refresh } = get(); try { await deleteInstance(id); await refresh(); - // If deleted instance was active, set another as active - if (activeInstance?.id === id) { - if (instances.length > 0) { - await setActiveInstance(instances[0]); - } else { - set({ activeInstance: null }); - } - } - toast.success("Instance deleted successfully"); } catch (e) { console.error("Failed to delete instance:", e); - toast.error("Error deleting instance"); + toast.error(String(e)); } }, @@ -96,7 +100,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({ }, setActiveInstance: async (instance) => { - await setActiveInstance(instance.id); + await setActiveInstanceCommand(instance.id); set({ activeInstance: instance }); }, @@ -104,16 +108,56 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({ const { refresh } = get(); try { const instance = await duplicateInstance(id, newName); + await setActiveInstanceCommand(instance.id); await refresh(); toast.success(`Instance duplicated as "${newName}"`); return instance; } catch (e) { console.error("Failed to duplicate instance:", e); - toast.error("Error duplicating instance"); + toast.error(String(e)); + return null; + } + }, + + exportArchive: async (id, archivePath) => { + try { + await exportInstance(id, archivePath); + toast.success("Instance exported successfully"); + } catch (e) { + console.error("Failed to export instance:", e); + toast.error(String(e)); + } + }, + + importArchive: async (archivePath, newName) => { + const { refresh } = get(); + try { + const instance = await importInstance(archivePath, newName); + await setActiveInstanceCommand(instance.id); + await refresh(); + toast.success(`Instance "${instance.name}" imported successfully`); + return instance; + } catch (e) { + console.error("Failed to import instance:", e); + toast.error(String(e)); return null; } }, + repair: async () => { + const { refresh } = get(); + try { + const result = await repairInstances(); + await refresh(); + toast.success( + `Repair completed: restored ${result.restoredInstances}, removed ${result.removedStaleEntries}`, + ); + } catch (e) { + console.error("Failed to repair instances:", e); + toast.error(String(e)); + } + }, + get: async (id) => { try { return await getInstance(id); diff --git a/packages/ui/src/pages/index.tsx b/packages/ui/src/pages/index.tsx index 093ccb2..209a1b2 100644 --- a/packages/ui/src/pages/index.tsx +++ b/packages/ui/src/pages/index.tsx @@ -5,11 +5,13 @@ import { Sidebar } from "@/components/sidebar"; import { useAuthStore } from "@/models/auth"; import { useInstanceStore } from "@/models/instance"; import { useSettingsStore } from "@/models/settings"; +import { useGameStore } from "@/stores/game-store"; export function IndexPage() { const authStore = useAuthStore(); const settingsStore = useSettingsStore(); const instanceStore = useInstanceStore(); + const initGameLifecycle = useGameStore((state) => state.initLifecycle); const location = useLocation(); @@ -17,7 +19,10 @@ export function IndexPage() { authStore.init(); settingsStore.refresh(); instanceStore.refresh(); - }, [authStore.init, settingsStore.refresh, instanceStore.refresh]); + void initGameLifecycle().catch((error) => { + console.error("Failed to initialize game lifecycle:", error); + }); + }, [authStore.init, settingsStore.refresh, instanceStore.refresh, initGameLifecycle]); return ( <div className="relative h-screen w-full overflow-hidden bg-background font-sans"> diff --git a/packages/ui/src/pages/instances-view.tsx b/packages/ui/src/pages/instances-view.tsx index e99004c..07a2135 100644 --- a/packages/ui/src/pages/instances-view.tsx +++ b/packages/ui/src/pages/instances-view.tsx @@ -1,7 +1,16 @@ -import { CopyIcon, EditIcon, Plus, RocketIcon, Trash2Icon } from "lucide-react"; +import { open, save } from "@tauri-apps/plugin-dialog"; +import { + CopyIcon, + EditIcon, + FolderOpenIcon, + Plus, + RocketIcon, + Trash2Icon, + XIcon, +} from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; -import { startGame } from "@/client"; +import { openFileExplorer } from "@/client"; import InstanceCreationModal from "@/components/instance-creation-modal"; import InstanceEditorModal from "@/components/instance-editor-modal"; import { Button } from "@/components/ui/button"; @@ -15,11 +24,22 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; +import { useAuthStore } from "@/models/auth"; import { useInstanceStore } from "@/models/instance"; +import { useGameStore } from "@/stores/game-store"; import type { Instance } from "@/types"; export function InstancesView() { + const account = useAuthStore((state) => state.account); const instancesStore = useInstanceStore(); + const startGame = useGameStore((state) => state.startGame); + const stopGame = useGameStore((state) => state.stopGame); + const runningInstanceId = useGameStore((state) => state.runningInstanceId); + const launchingInstanceId = useGameStore((state) => state.launchingInstanceId); + const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId); + const [isImporting, setIsImporting] = useState(false); + const [repairing, setRepairing] = useState(false); + const [exportingId, setExportingId] = useState<string | null>(null); // Modal / UI state const [showCreateModal, setShowCreateModal] = useState(false); @@ -78,20 +98,83 @@ export function InstancesView() { setShowDuplicateModal(false); }; + const handleImport = async () => { + setIsImporting(true); + try { + const selected = await open({ + multiple: false, + filters: [{ name: "Zip Archive", extensions: ["zip"] }], + }); + + if (typeof selected !== "string") { + return; + } + + await instancesStore.importArchive(selected); + } finally { + setIsImporting(false); + } + }; + + const handleRepair = async () => { + setRepairing(true); + try { + await instancesStore.repair(); + } finally { + setRepairing(false); + } + }; + + const handleExport = async (instance: Instance) => { + setExportingId(instance.id); + try { + const filePath = await save({ + defaultPath: `${instance.name.replace(/[\\/:*?"<>|]/g, "_")}.zip`, + filters: [{ name: "Zip Archive", extensions: ["zip"] }], + }); + + if (!filePath) { + return; + } + + await instancesStore.exportArchive(instance.id, filePath); + } finally { + setExportingId(null); + } + }; + return ( <div className="h-full flex flex-col gap-4 p-6 overflow-y-auto"> <div className="flex items-center justify-between"> <h1 className="text-2xl font-bold text-gray-900 dark:text-white"> Instances </h1> - <Button - type="button" - onClick={openCreate} - className="px-4 py-2 transition-colors" - > - <Plus size={18} /> - Create Instance - </Button> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + onClick={handleImport} + disabled={isImporting} + > + {isImporting ? "Importing..." : "Import"} + </Button> + <Button + type="button" + variant="outline" + onClick={handleRepair} + disabled={repairing} + > + {repairing ? "Repairing..." : "Repair Index"} + </Button> + <Button + type="button" + onClick={openCreate} + className="px-4 py-2 transition-colors" + > + <Plus size={18} /> + Create Instance + </Button> + </div> </div> {instancesStore.instances.length === 0 ? ( @@ -105,6 +188,10 @@ export function InstancesView() { <ul className="flex flex-col space-y-3"> {instancesStore.instances.map((instance) => { const isActive = instancesStore.activeInstance?.id === instance.id; + const isRunning = runningInstanceId === instance.id; + const isLaunching = launchingInstanceId === instance.id; + const isStopping = stoppingInstanceId === instance.id; + const otherInstanceRunning = runningInstanceId !== null && !isRunning; return ( <li @@ -164,22 +251,71 @@ export function InstancesView() { <div className="flex items-center"> <div className="flex flex-row space-x-2"> <Button - variant="ghost" + variant={isRunning ? "destructive" : "ghost"} size="icon" - onClick={async () => { + onClick={async (e) => { + e.stopPropagation(); + + try { + await instancesStore.setActiveInstance(instance); + } catch (error) { + console.error("Failed to set active instance:", error); + toast.error("Error setting active instance"); + return; + } + + if (isRunning) { + await stopGame(instance.id); + return; + } + if (!instance.versionId) { toast.error("No version selected or installed"); return; } - try { - await startGame(instance.id, instance.versionId); - } catch (e) { - console.error("Failed to start game:", e); - toast.error("Error starting game"); - } + + await startGame( + account, + () => { + toast.info("Please login first"); + }, + instance.id, + instance.versionId, + () => undefined, + ); }} + disabled={otherInstanceRunning || isLaunching || isStopping} > - <RocketIcon /> + {isLaunching || isStopping ? ( + <span className="text-xs">...</span> + ) : isRunning ? ( + <XIcon /> + ) : ( + <RocketIcon /> + )} + </Button> + <Button + variant="ghost" + size="icon" + onClick={(e) => { + e.stopPropagation(); + void openFileExplorer(instance.gameDir); + }} + > + <FolderOpenIcon /> + </Button> + <Button + variant="ghost" + size="icon" + onClick={(e) => { + e.stopPropagation(); + void handleExport(instance); + }} + disabled={exportingId === instance.id} + > + <span className="text-xs"> + {exportingId === instance.id ? "..." : "ZIP"} + </span> </Button> <Button variant="ghost" diff --git a/packages/ui/src/stores/game-store.ts b/packages/ui/src/stores/game-store.ts index fa0f9f8..1eaf7e7 100644 --- a/packages/ui/src/stores/game-store.ts +++ b/packages/ui/src/stores/game-store.ts @@ -1,49 +1,92 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { toast } from "sonner"; import { create } from "zustand"; -import { getVersions } from "@/client"; +import { + getVersions, + getVersionsOfInstance, + startGame as startGameCommand, + stopGame as stopGameCommand, +} from "@/client"; +import type { Account } from "@/types/bindings/auth"; +import type { GameExitedEvent } from "@/types/bindings/core"; import type { Version } from "@/types/bindings/manifest"; interface GameState { - // State versions: Version[]; selectedVersion: string; + runningInstanceId: string | null; + runningVersionId: string | null; + launchingInstanceId: string | null; + stoppingInstanceId: string | null; + lifecycleUnlisten: UnlistenFn | null; - // Computed property latestRelease: Version | undefined; + isGameRunning: boolean; - // Actions + initLifecycle: () => Promise<void>; loadVersions: (instanceId?: string) => Promise<void>; startGame: ( - currentAccount: any, + currentAccount: Account | null, openLoginModal: () => void, activeInstanceId: string | null, - setView: (view: any) => void, - ) => Promise<void>; + versionId: string | null, + setView: (view: string) => void, + ) => Promise<string | null>; + stopGame: (instanceId?: string | null) => Promise<string | null>; setSelectedVersion: (version: string) => void; setVersions: (versions: Version[]) => void; } export const useGameStore = create<GameState>((set, get) => ({ - // Initial state versions: [], selectedVersion: "", + runningInstanceId: null, + runningVersionId: null, + launchingInstanceId: null, + stoppingInstanceId: null, + lifecycleUnlisten: null, - // Computed property get latestRelease() { return get().versions.find((v) => v.type === "release"); }, - // Actions + get isGameRunning() { + return get().runningInstanceId !== null; + }, + + initLifecycle: async () => { + if (get().lifecycleUnlisten) { + return; + } + + const unlisten = await listen<GameExitedEvent>("game-exited", (event) => { + const { instanceId, versionId, wasStopped } = event.payload; + + set({ + runningInstanceId: null, + runningVersionId: null, + launchingInstanceId: null, + stoppingInstanceId: null, + }); + + if (wasStopped) { + toast.success(`Stopped Minecraft ${versionId} for instance ${instanceId}`); + } else { + toast.info(`Minecraft ${versionId} exited for instance ${instanceId}`); + } + }); + + set({ lifecycleUnlisten: unlisten }); + }, + loadVersions: async (instanceId?: string) => { - console.log("Loading versions for instance:", instanceId); try { - // Ask the backend for known versions (optionally scoped to an instance). - // The Tauri command `get_versions` is expected to return an array of `Version`. - const versions = await getVersions(); + const versions = instanceId + ? await getVersionsOfInstance(instanceId) + : await getVersions(); set({ versions: versions ?? [] }); } catch (e) { console.error("Failed to load versions:", e); - // Keep the store consistent on error by clearing versions. set({ versions: [] }); } }, @@ -52,42 +95,80 @@ export const useGameStore = create<GameState>((set, get) => ({ currentAccount, openLoginModal, activeInstanceId, + versionId, setView, ) => { - const { selectedVersion } = get(); + const { isGameRunning } = get(); + const targetVersion = versionId ?? get().selectedVersion; if (!currentAccount) { - alert("Please login first!"); + toast.info("Please login first"); openLoginModal(); - return; + return null; } - if (!selectedVersion) { - alert("Please select a version!"); - return; + if (!targetVersion) { + toast.info("Please select a version first"); + return null; } if (!activeInstanceId) { - alert("Please select an instance first!"); + toast.info("Please select an instance first"); setView("instances"); - return; + return null; } - toast.info("Preparing to launch " + selectedVersion + "..."); + if (isGameRunning) { + toast.info("A game is already running"); + return null; + } + + set({ + launchingInstanceId: activeInstanceId, + selectedVersion: targetVersion, + }); + toast.info(`Preparing to launch ${targetVersion}...`); try { - // Note: In production, this would call Tauri invoke - // const msg = await invoke<string>("start_game", { - // instanceId: activeInstanceId, - // versionId: selectedVersion, - // }); - - // Simulate success - await new Promise((resolve) => setTimeout(resolve, 1000)); - toast.success("Game started successfully!"); + const message = await startGameCommand(activeInstanceId, targetVersion); + set({ + launchingInstanceId: null, + runningInstanceId: activeInstanceId, + runningVersionId: targetVersion, + }); + toast.success(message); + return message; } catch (e) { console.error(e); + set({ launchingInstanceId: null }); toast.error(`Error: ${e}`); + return null; + } + }, + + stopGame: async (instanceId) => { + const { runningInstanceId } = get(); + + if (!runningInstanceId) { + toast.info("No running game found"); + return null; + } + + if (instanceId && instanceId !== runningInstanceId) { + toast.info("That instance is not the one currently running"); + return null; + } + + set({ stoppingInstanceId: runningInstanceId }); + + try { + return await stopGameCommand(); + } catch (e) { + console.error("Failed to stop game:", e); + toast.error(`Failed to stop game: ${e}`); + return null; + } finally { + set({ stoppingInstanceId: null }); } }, diff --git a/packages/ui/src/types/bindings/core.ts b/packages/ui/src/types/bindings/core.ts index 94e3bde..70cf804 100644 --- a/packages/ui/src/types/bindings/core.ts +++ b/packages/ui/src/types/bindings/core.ts @@ -11,6 +11,13 @@ export type FileInfo = { modified: bigint; }; +export type GameExitedEvent = { + instanceId: string; + versionId: string; + exitCode: number | null; + wasStopped: boolean; +}; + export type GithubRelease = { tagName: string; name: string; diff --git a/packages/ui/src/types/bindings/instance.ts b/packages/ui/src/types/bindings/instance.ts index 2c4f8ae..a8247a9 100644 --- a/packages/ui/src/types/bindings/instance.ts +++ b/packages/ui/src/types/bindings/instance.ts @@ -27,6 +27,13 @@ export type InstanceConfig = { activeInstanceId: string | null; }; +export type InstanceRepairResult = { + restoredInstances: number; + removedStaleEntries: number; + createdDefaultActive: boolean; + activeInstanceId: string | null; +}; + /** * Memory settings override for an instance */ diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 8c90267..241ca8f 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -6,6 +6,10 @@ import { defineConfig } from "vite"; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + server: { + port: 5173, + strictPort: true, + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), |