aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/packages
diff options
context:
space:
mode:
authorNtskwK <natsukawa247@outlook.com>2026-02-28 09:03:19 +0800
committerNtskwK <natsukawa247@outlook.com>2026-02-28 09:03:19 +0800
commitcc53b1cf260e1c67939e50608ef18764da616d55 (patch)
tree119109c62331d4d26612e2df7726cee82d1871f5 /packages
parentee37d044e473217daadd9ce26c7e2e2ad39a0490 (diff)
parent81a62402ef6f8900ff092366121a9b7a4263ba52 (diff)
downloadDropOut-cc53b1cf260e1c67939e50608ef18764da616d55.tar.gz
DropOut-cc53b1cf260e1c67939e50608ef18764da616d55.zip
Merge remote-tracking branch 'upstream/main'
Diffstat (limited to 'packages')
-rw-r--r--packages/docs/app/components/mermaid.tsx29
-rw-r--r--packages/docs/app/docs/page.tsx30
-rw-r--r--packages/docs/app/lib/layout.shared.tsx9
-rw-r--r--packages/docs/app/lib/source.ts4
-rw-r--r--packages/docs/app/routes/docs.tsx8
-rw-r--r--packages/docs/app/routes/home.tsx14
-rw-r--r--packages/docs/content/en/development/architecture.mdx (renamed from packages/docs/content/en/architecture.mdx)134
-rw-r--r--packages/docs/content/en/development/guide.mdx (renamed from packages/docs/content/en/development.mdx)0
-rw-r--r--packages/docs/content/en/development/implementation.mdx351
-rw-r--r--packages/docs/content/en/development/meta.json8
-rw-r--r--packages/docs/content/en/features/authentication.mdx266
-rw-r--r--packages/docs/content/en/manual/features/authentication.mdx131
-rw-r--r--packages/docs/content/en/manual/features/index.mdx (renamed from packages/docs/content/en/features/index.mdx)0
-rw-r--r--packages/docs/content/en/manual/features/java.mdx (renamed from packages/docs/content/en/features/java.mdx)0
-rw-r--r--packages/docs/content/en/manual/features/meta.json (renamed from packages/docs/content/en/features/meta.json)0
-rw-r--r--packages/docs/content/en/manual/features/mod-loaders.mdx (renamed from packages/docs/content/en/features/mod-loaders.mdx)103
-rw-r--r--packages/docs/content/en/manual/getting-started.mdx (renamed from packages/docs/content/en/getting-started.mdx)55
-rw-r--r--packages/docs/content/en/manual/index.mdx (renamed from packages/docs/content/en/index.mdx)0
-rw-r--r--packages/docs/content/en/manual/meta.json9
-rw-r--r--packages/docs/content/en/manual/troubleshooting.mdx (renamed from packages/docs/content/en/troubleshooting.mdx)0
-rw-r--r--packages/docs/content/en/meta.json8
-rw-r--r--packages/docs/content/zh/development/architecture.mdx (renamed from packages/docs/content/zh/architecture.mdx)152
-rw-r--r--packages/docs/content/zh/development/implementation.mdx351
-rw-r--r--packages/docs/content/zh/development/index.mdx (renamed from packages/docs/content/zh/development.mdx)62
-rw-r--r--packages/docs/content/zh/development/meta.json8
-rw-r--r--packages/docs/content/zh/features/authentication.mdx266
-rw-r--r--packages/docs/content/zh/manual/features/authentication.mdx131
-rw-r--r--packages/docs/content/zh/manual/features/index.mdx (renamed from packages/docs/content/zh/features/index.mdx)0
-rw-r--r--packages/docs/content/zh/manual/features/java.mdx (renamed from packages/docs/content/zh/features/java.mdx)122
-rw-r--r--packages/docs/content/zh/manual/features/meta.json (renamed from packages/docs/content/zh/features/meta.json)0
-rw-r--r--packages/docs/content/zh/manual/features/mod-loaders.mdx (renamed from packages/docs/content/zh/features/mod-loaders.mdx)225
-rw-r--r--packages/docs/content/zh/manual/getting-started.mdx (renamed from packages/docs/content/zh/getting-started.mdx)59
-rw-r--r--packages/docs/content/zh/manual/index.mdx (renamed from packages/docs/content/zh/index.mdx)2
-rw-r--r--packages/docs/content/zh/manual/meta.json10
-rw-r--r--packages/docs/content/zh/manual/troubleshooting.mdx (renamed from packages/docs/content/zh/troubleshooting.mdx)10
-rw-r--r--packages/docs/content/zh/meta.json6
-rw-r--r--packages/docs/package.json5
-rw-r--r--packages/docs/source.config.ts7
-rw-r--r--packages/ui/CHANGELOG.md20
-rw-r--r--packages/ui/README.md47
-rw-r--r--packages/ui/components.json23
-rw-r--r--packages/ui/index.html8
-rw-r--r--packages/ui/package.json45
-rw-r--r--packages/ui/pnpm-lock.yaml1363
-rw-r--r--packages/ui/public/icon.svg50
-rw-r--r--packages/ui/public/vite.svg1
-rw-r--r--packages/ui/src/App.svelte217
-rw-r--r--packages/ui/src/app.css167
-rw-r--r--packages/ui/src/assets/svelte.svg1
-rw-r--r--packages/ui/src/client.ts400
-rw-r--r--packages/ui/src/components/AssistantView.svelte436
-rw-r--r--packages/ui/src/components/BottomBar.svelte250
-rw-r--r--packages/ui/src/components/ConfigEditorModal.svelte369
-rw-r--r--packages/ui/src/components/CustomSelect.svelte173
-rw-r--r--packages/ui/src/components/HomeView.svelte271
-rw-r--r--packages/ui/src/components/InstanceCreationModal.svelte485
-rw-r--r--packages/ui/src/components/InstanceEditorModal.svelte439
-rw-r--r--packages/ui/src/components/InstancesView.svelte259
-rw-r--r--packages/ui/src/components/LoginModal.svelte126
-rw-r--r--packages/ui/src/components/ModLoaderSelector.svelte455
-rw-r--r--packages/ui/src/components/ParticleBackground.svelte70
-rw-r--r--packages/ui/src/components/SettingsView.svelte1217
-rw-r--r--packages/ui/src/components/Sidebar.svelte91
-rw-r--r--packages/ui/src/components/StatusToast.svelte42
-rw-r--r--packages/ui/src/components/VersionsView.svelte511
-rw-r--r--packages/ui/src/components/bottom-bar.tsx267
-rw-r--r--packages/ui/src/components/config-editor.tsx111
-rw-r--r--packages/ui/src/components/download-monitor.tsx62
-rw-r--r--packages/ui/src/components/game-console.tsx290
-rw-r--r--packages/ui/src/components/instance-creation-modal.tsx544
-rw-r--r--packages/ui/src/components/instance-editor-modal.tsx548
-rw-r--r--packages/ui/src/components/login-modal.tsx188
-rw-r--r--packages/ui/src/components/particle-background.tsx63
-rw-r--r--packages/ui/src/components/sidebar.tsx185
-rw-r--r--packages/ui/src/components/ui/avatar.tsx107
-rw-r--r--packages/ui/src/components/ui/badge.tsx52
-rw-r--r--packages/ui/src/components/ui/button.tsx56
-rw-r--r--packages/ui/src/components/ui/card.tsx103
-rw-r--r--packages/ui/src/components/ui/checkbox.tsx27
-rw-r--r--packages/ui/src/components/ui/dialog.tsx155
-rw-r--r--packages/ui/src/components/ui/dropdown-menu.tsx269
-rw-r--r--packages/ui/src/components/ui/field.tsx238
-rw-r--r--packages/ui/src/components/ui/input.tsx20
-rw-r--r--packages/ui/src/components/ui/label.tsx19
-rw-r--r--packages/ui/src/components/ui/scroll-area.tsx53
-rw-r--r--packages/ui/src/components/ui/select.tsx199
-rw-r--r--packages/ui/src/components/ui/separator.tsx25
-rw-r--r--packages/ui/src/components/ui/sonner.tsx43
-rw-r--r--packages/ui/src/components/ui/spinner.tsx15
-rw-r--r--packages/ui/src/components/ui/switch.tsx32
-rw-r--r--packages/ui/src/components/ui/tabs.tsx80
-rw-r--r--packages/ui/src/components/ui/textarea.tsx18
-rw-r--r--packages/ui/src/components/user-avatar.tsx23
-rw-r--r--packages/ui/src/index.css126
-rw-r--r--packages/ui/src/lib/Counter.svelte10
-rw-r--r--packages/ui/src/lib/DownloadMonitor.svelte201
-rw-r--r--packages/ui/src/lib/GameConsole.svelte304
-rw-r--r--packages/ui/src/lib/effects/ConstellationEffect.ts162
-rw-r--r--packages/ui/src/lib/effects/SaturnEffect.ts303
-rw-r--r--packages/ui/src/lib/modLoaderApi.ts106
-rw-r--r--packages/ui/src/lib/tsrs-utils.ts67
-rw-r--r--packages/ui/src/lib/utils.ts6
-rw-r--r--packages/ui/src/main.ts9
-rw-r--r--packages/ui/src/main.tsx38
-rw-r--r--packages/ui/src/models/auth.ts142
-rw-r--r--packages/ui/src/models/instance.ts131
-rw-r--r--packages/ui/src/models/settings.ts75
-rw-r--r--packages/ui/src/pages/assistant-view.tsx.bk485
-rw-r--r--packages/ui/src/pages/home-view.tsx174
-rw-r--r--packages/ui/src/pages/index.tsx79
-rw-r--r--packages/ui/src/pages/instances-view.tsx315
-rw-r--r--packages/ui/src/pages/settings-view.tsx.bk1158
-rw-r--r--packages/ui/src/pages/settings.tsx310
-rw-r--r--packages/ui/src/pages/versions-view.tsx.bk662
-rw-r--r--packages/ui/src/stores/assistant-store.ts201
-rw-r--r--packages/ui/src/stores/assistant.svelte.ts166
-rw-r--r--packages/ui/src/stores/auth-store.ts296
-rw-r--r--packages/ui/src/stores/auth.svelte.ts192
-rw-r--r--packages/ui/src/stores/game-store.ts101
-rw-r--r--packages/ui/src/stores/game.svelte.ts78
-rw-r--r--packages/ui/src/stores/instances.svelte.ts109
-rw-r--r--packages/ui/src/stores/logs-store.ts200
-rw-r--r--packages/ui/src/stores/logs.svelte.ts151
-rw-r--r--packages/ui/src/stores/releases-store.ts63
-rw-r--r--packages/ui/src/stores/releases.svelte.ts36
-rw-r--r--packages/ui/src/stores/settings-store.ts568
-rw-r--r--packages/ui/src/stores/settings.svelte.ts570
-rw-r--r--packages/ui/src/stores/ui-store.ts42
-rw-r--r--packages/ui/src/stores/ui.svelte.ts32
-rw-r--r--packages/ui/src/types/bindings/account.ts28
-rw-r--r--packages/ui/src/types/bindings/assistant.ts25
-rw-r--r--packages/ui/src/types/bindings/auth.ts32
-rw-r--r--packages/ui/src/types/bindings/config.ts61
-rw-r--r--packages/ui/src/types/bindings/core.ts47
-rw-r--r--packages/ui/src/types/bindings/downloader.ts73
-rw-r--r--packages/ui/src/types/bindings/fabric.ts74
-rw-r--r--packages/ui/src/types/bindings/forge.ts21
-rw-r--r--packages/ui/src/types/bindings/game-version.ts89
-rw-r--r--packages/ui/src/types/bindings/index.ts12
-rw-r--r--packages/ui/src/types/bindings/instance.ts33
-rw-r--r--packages/ui/src/types/bindings/java/core.ts41
-rw-r--r--packages/ui/src/types/bindings/java/index.ts3
-rw-r--r--packages/ui/src/types/bindings/java/persistence.ts7
-rw-r--r--packages/ui/src/types/bindings/java/providers/adoptium.ts37
-rw-r--r--packages/ui/src/types/bindings/java/providers/index.ts1
-rw-r--r--packages/ui/src/types/bindings/manifest.ts22
-rw-r--r--packages/ui/src/types/index.ts233
-rw-r--r--packages/ui/svelte.config.js8
-rw-r--r--packages/ui/tsconfig.app.json36
-rw-r--r--packages/ui/tsconfig.json11
-rw-r--r--packages/ui/vite.config.ts30
151 files changed, 11738 insertions, 10757 deletions
diff --git a/packages/docs/app/components/mermaid.tsx b/packages/docs/app/components/mermaid.tsx
new file mode 100644
index 0000000..bb25f2d
--- /dev/null
+++ b/packages/docs/app/components/mermaid.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+import mermaid from 'mermaid';
+
+mermaid.initialize({
+ startOnLoad: false,
+ theme: 'default',
+});
+
+export function Mermaid({ chart }: { chart: string }) {
+ const ref = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ if (ref.current) {
+ mermaid.run({
+ nodes: [ref.current],
+ });
+ }
+ }, [chart]);
+
+ return (
+ <div className="not-prose my-6">
+ <div ref={ref} className="mermaid">
+ {chart}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/docs/app/docs/page.tsx b/packages/docs/app/docs/page.tsx
index a9e3433..49ad005 100644
--- a/packages/docs/app/docs/page.tsx
+++ b/packages/docs/app/docs/page.tsx
@@ -8,20 +8,21 @@ 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';
export async function loader({ params }: Route.LoaderArgs) {
// ä»Žè·¯ç”±å‚æ•°èŽ·å–语言,如果没有则使用默认语言
- // URL æ ¼å¼: /docs/getting-started (默认语言 zh)
- // URL æ ¼å¼: /en/docs/getting-started (英语)
+ // 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');
-
+
// èŽ·å–æ–‡æ¡£è·¯å¾„ slugs
const slugs = params['*']?.split('/').filter((v) => v.length > 0) || [];
-
+
const page = source.getPage(slugs, lang);
-
+
if (!page) {
throw new Response('Not found', { status: 404 });
}
@@ -37,10 +38,23 @@ const clientLoader = browserCollections.docs.createClientLoader({
component({ toc, frontmatter, default: Mdx }) {
return (
<DocsPage toc={toc}>
- <DocsTitle>{frontmatter.title}</DocsTitle>
- <DocsDescription>{frontmatter.description}</DocsDescription>
+ {/* è€çŽ‹è¯´ä¸è¦è¿™ä¸ª */}
+ {/* <DocsTitle>{frontmatter.title}</DocsTitle>
+ <DocsDescription>{frontmatter.description}</DocsDescription> */}
<DocsBody>
- <Mdx components={{ ...defaultMdxComponents, Card, Cards }} />
+ <Mdx
+ components={{
+ ...defaultMdxComponents,
+ Card: (props: React.ComponentProps<typeof Card>) => (
+ <Card
+ {...props}
+ className={`border-blue-600/20 hover:border-blue-600/50 transition-colors ${props.className || ''}`}
+ />
+ ),
+ Cards,
+ Mermaid
+ }}
+ />
</DocsBody>
</DocsPage>
);
diff --git a/packages/docs/app/lib/layout.shared.tsx b/packages/docs/app/lib/layout.shared.tsx
index 6e90ba0..b3595eb 100644
--- a/packages/docs/app/lib/layout.shared.tsx
+++ b/packages/docs/app/lib/layout.shared.tsx
@@ -16,8 +16,13 @@ export function baseOptions(locale: string): BaseLayoutProps {
links: [
{
type: 'main',
- text: locale === 'zh' ? '文档' : 'Documentation',
- url: `${localePrefix}/docs`,
+ text: locale === 'zh' ? '使用文档' : 'Manual',
+ url: `${localePrefix}/docs/manual`,
+ },
+ {
+ 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 bce9bf9..4d6cc3a 100644
--- a/packages/docs/app/lib/source.ts
+++ b/packages/docs/app/lib/source.ts
@@ -7,6 +7,6 @@ export const source = loader({
baseUrl: '/docs',
i18n,
// hideLocale: 'default-locale' ä¼šè‡ªåŠ¨ç”Ÿæˆæ­£ç¡®çš„ URL:
- // - 默认语言 (zh): /docs/getting-started
- // - 其他语言 (en): /en/docs/getting-started
+ // - 默认语言 (zh): /docs/manual/getting-started
+ // - 其他语言 (en): /en/docs/manual/getting-started
});
diff --git a/packages/docs/app/routes/docs.tsx b/packages/docs/app/routes/docs.tsx
index 5154d27..5090ccf 100644
--- a/packages/docs/app/routes/docs.tsx
+++ b/packages/docs/app/routes/docs.tsx
@@ -6,11 +6,11 @@ import { i18n } from '@/lib/i18n';
export function loader({ params }: Route.LoaderArgs) {
const lang = params.lang as string | undefined;
- // å¦‚æžœæ²¡æœ‰è¯­è¨€å‚æ•°æˆ–是默认语言,é‡å®šå‘到 /docs/getting-started
+ // å¦‚æžœæ²¡æœ‰è¯­è¨€å‚æ•°æˆ–是默认语言,é‡å®šå‘到 /docs/manual/getting-started
if (!lang || lang === i18n.defaultLanguage) {
- return redirect('/docs/getting-started');
+ return redirect('/docs/manual/getting-started');
}
- // 其他语言é‡å®šå‘到 /:lang/docs/getting-started
- return redirect(`/${lang}/docs/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 66b5333..427bf4e 100644
--- a/packages/docs/app/routes/home.tsx
+++ b/packages/docs/app/routes/home.tsx
@@ -10,7 +10,7 @@ const texts = {
subtitle: 'Modern. Reproducible. Developer-Grade.',
description: 'Built with Tauri v2 and Rust for native performance and minimal resource usage',
start: 'Get Started',
- features: 'Features',
+ development: 'Development',
},
features: {
items: [
@@ -42,7 +42,7 @@ const texts = {
subtitle: '现代ã€å¯å¤çްã€å¼€å‘者级',
description: '基于 Tauri v2 å’Œ Rust 构建,拥有原生性能和æžä½Žçš„资æºå ç”¨',
start: '开始使用',
- features: '功能特性',
+ development: 'å‚与开å‘',
},
features: {
items: [
@@ -107,10 +107,10 @@ export default function Home({ params }: Route.ComponentProps) {
{t.hero.start}
</a>
<a
- 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"
- href={`${localePrefix}/docs/features`}
+ 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.features}
+ {t.hero.development}
</a>
</div>
</div>
@@ -129,7 +129,7 @@ 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-fd-border bg-fd-card">
+ <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}
@@ -162,7 +162,7 @@ export default function Home({ params }: Route.ComponentProps) {
</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/getting-started`}
+ href={`${localePrefix}/docs/manual/getting-started`}
>
{t.cta.button}
</a>
diff --git a/packages/docs/content/en/architecture.mdx b/packages/docs/content/en/development/architecture.mdx
index 5f55c5e..6d59ab7 100644
--- a/packages/docs/content/en/architecture.mdx
+++ b/packages/docs/content/en/development/architecture.mdx
@@ -15,10 +15,12 @@ DropOut is built with a modern tech stack designed for performance, security, an
- **Async Runtime**: Tokio
- **HTTP Client**: reqwest with native-tls
-### Frontend (Svelte)
-- **Framework**: Svelte 5 (with runes)
+### Frontend (React)
+- **Framework**: React 19
+- **State Management**: Zustand
+- **Router**: React Router v7
- **Styling**: Tailwind CSS 4
-- **Build Tool**: Vite with Rolldown
+- **Build Tool**: Vite (with Rolldown)
- **Package Manager**: pnpm
### Documentation
@@ -28,75 +30,85 @@ DropOut is built with a modern tech stack designed for performance, security, an
## System Architecture
-```
-┌─────────────────────────────────────────────────────────â”
-│ Frontend (Svelte 5) │
-│ ┌──────────┠┌──────────┠┌──────────┠┌─────────┠│
-│ │ Stores │ │Components│ │ UI Views │ │Particles│ │
-│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬────┘ │
-│ │ │ │ │ │
-│ └─────────────┴─────────────┴──────────────┘ │
-│ │ │
-│ Tauri Commands │
-│ Events/Emitters │
-└──────────────────────────┬──────────────────────────────┘
- │
-┌──────────────────────────┴──────────────────────────────â”
-│ Backend (Rust/Tauri) │
-│ ┌─────────────────────────────────────────────────┠│
-│ │ main.rs (Commands) │ │
-│ └──────────────┬──────────────────────────────────┘ │
-│ │ │
-│ ┌──────────────┴───────────────────────────────┠│
-│ │ Core Modules │ │
-│ │ ┌──────┠┌────────┠┌──────┠┌──────────┠│ │
-│ │ │ Auth │ │Download│ │ Java │ │ Instance │ │ │
-│ │ └──────┘ └────────┘ └──────┘ └──────────┘ │ │
-│ │ ┌──────┠┌────────┠┌──────┠┌──────────┠│ │
-│ │ │Fabric│ │ Forge │ │Config│ │Manifest │ │ │
-│ │ └──────┘ └────────┘ └──────┘ └──────────┘ │ │
-│ └──────────────────────────────────────────────┘ │
-│ │
-│ ┌─────────────────────────────────────────────────┠│
-│ │ Utils & Helpers │ │
-│ │ • ZIP extraction • Path utilities │ │
-│ └─────────────────────────────────────────────────┘ │
-└──────────────────────────┬──────────────────────────────┘
- │
- External APIs
- │
- ┌──────────────────┼──────────────────â”
- │ │ │
- ┌─────┴────┠┌──────┴─────┠┌──────┴─────â”
- │ Mojang │ │ Fabric │ │ Forge │
- │ APIs │ │ Meta │ │ Maven │
- └──────────┘ └────────────┘ └────────────┘
+```mermaid
+graph TB
+ subgraph Frontend["Frontend (React 19)"]
+ direction LR
+ Stores[Zustand Stores] ~~~ Components[Components] ~~~ Pages[Pages] ~~~ Router[Router] ~~~ Particles[Particle Background]
+ end
+
+ subgraph TauriLayer["Tauri Communication"]
+ direction LR
+ Commands[Commands] ~~~ Events[Events/Emitters]
+ end
+
+ subgraph Backend["Backend (Rust/Tauri)"]
+ direction TB
+ MainRS[main.rs Command Handler]
+
+ subgraph CoreModules["Core Modules"]
+ direction LR
+ Auth[Auth] ~~~ Download[Download] ~~~ Java[Java] ~~~ Instance[Instance]
+ Fabric[Fabric] ~~~ Forge[Forge] ~~~ Config[Config] ~~~ Manifest[Manifest]
+ Auth ~~~ Fabric
+ end
+
+ subgraph Utils["Utils & Helpers"]
+ direction LR
+ ZipExtract[ZIP Extract] ~~~ PathUtils[Path Utils]
+ end
+
+ MainRS --> CoreModules
+ CoreModules --> Utils
+ end
+
+ subgraph ExternalAPIs["External APIs"]
+ direction LR
+ Mojang[Mojang APIs] ~~~ FabricMeta[Fabric Meta] ~~~ ForgeMaven[Forge Maven]
+ end
+
+ Frontend <--> TauriLayer
+ TauriLayer <--> Backend
+ Backend <--> ExternalAPIs
+
+ style Frontend fill:#e3f2fd
+ style Backend fill:#fff3e0
+ style TauriLayer fill:#f3e5f5
+ style ExternalAPIs fill:#e8f5e9
```
## Core Components
### Frontend State Management
-DropOut uses **Svelte 5 runes** for reactive state management:
+DropOut uses **Zustand** for global state management:
```typescript
-// stores/auth.svelte.ts
-export class AuthState {
- currentAccount = $state<Account | null>(null); // Reactive
- isLoginModalOpen = $state(false);
-
- $effect(() => { // Side effects
- // Auto-runs when dependencies change
- });
+// models/auth.ts
+import { create } from "zustand";
+
+interface AuthState {
+ account: Account | null;
+ loginMode: LoginMode | null;
+ // ...
+ setAccount: (account: Account | null) => void;
}
+
+export const useAuthStore = create<AuthState>((set) => ({
+ account: null,
+ loginMode: null,
+ setAccount: (account) => set({ account }),
+ // ...
+}));
```
**Key Stores:**
-- `auth.svelte.ts`: Authentication state and login flow
-- `settings.svelte.ts`: Launcher settings and Java detection
-- `game.svelte.ts`: Game running state and logs
-- `instances.svelte.ts`: Instance management
-- `ui.svelte.ts`: UI state (toasts, modals, active view)
+- `models/auth.ts`: Authentication state and login flow
+- `models/settings.ts`: Launcher settings and Java detection
+- `models/instance.ts`: Instance management
+- `stores/game-store.ts`: Game running state status
+- `stores/logs-store.ts`: Game log stream management
+- `stores/ui-store.ts`: UI state (toasts, modals, active view)
### Backend Architecture
@@ -213,7 +225,7 @@ const result = await invoke("start_game", { versionId: "1.20.4" });
### Game Launch Sequence
1. **Frontend**: User clicks "Launch Game"
-2. **Command**: `start_game(version_id)` invoked
+2. **Command**: `start_game(instance_id, version_id)` invoked
3. **Backend Processing**:
- Load version JSON (with inheritance)
- Resolve all libraries
diff --git a/packages/docs/content/en/development.mdx b/packages/docs/content/en/development/guide.mdx
index 8ff2906..8ff2906 100644
--- a/packages/docs/content/en/development.mdx
+++ b/packages/docs/content/en/development/guide.mdx
diff --git a/packages/docs/content/en/development/implementation.mdx b/packages/docs/content/en/development/implementation.mdx
new file mode 100644
index 0000000..3ecadfe
--- /dev/null
+++ b/packages/docs/content/en/development/implementation.mdx
@@ -0,0 +1,351 @@
+---
+title: Internal Implementation
+description: Detailed implementation and technical specifications of DropOut core functions
+---
+
+# Internal Implementation
+
+This page details the technical implementation details, data structures, and processing flows of the launcher's core modules.
+
+## 1. Authentication System
+
+The authentication chain contains multiple asynchronous steps, and failure at any step will interrupt the entire process. DropOut uses the Microsoft Device Code Flow to achieve secure login without redirection.
+
+### 1.1 Detailed Process
+1. **Device Code Request**:
+ - Call `https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode`
+ - Get user verification code and verification URL.
+2. **Token Exchange**: Poll `/oauth2/v2.0/token` to get the primary Access Token and Refresh Token.
+3. **Xbox Live Authentication**: Get `Token` and `uhs` (User Hash).
+4. **XSTS Authorization**: Redirect to `rp://api.minecraftservices.com/`.
+5. **Minecraft Login**: Exchange for the final game Access Token using the `XBL3.0 x=<uhs>;<xsts_token>` format token.
+
+### 1.2 Account Storage and Security
+Account data is persisted in `accounts.json`, containing account type and encrypted token information:
+```json
+{
+ "active_account_uuid": "...",
+ "accounts": [
+ {
+ "type": "Microsoft",
+ "username": "...",
+ "uuid": "...",
+ "access_token": "...",
+ "refresh_token": "...",
+ "expires_at": 1234567890
+ },
+ { "type": "Offline", "username": "Player", "uuid": "..." }
+ ]
+}
+```
+**Offline UUID**: Uses a username-based deterministic UUID v3 to ensure consistency of local saves and configurations.
+
+```rust
+// core/auth.rs implementation details
+pub fn generate_offline_uuid(username: &str) -> String {
+ let namespace = Uuid::NAMESPACE_OID;
+ Uuid::new_v3(&namespace, username.as_bytes()).to_string()
+}
+```
+
+### 1.3 Authentication Related APIs
+| Command / Event | Type | Description |
+| :--- | :--- | :--- |
+| `start_microsoft_login` | Invoke | Start device flow and return verification code |
+| `complete_microsoft_login` | Invoke | Poll and complete the full authentication chain |
+| `login_offline` | Invoke | Create and switch to offline account |
+| `auth-progress` | Event | Report authentication progress in real-time (e.g., "Xbox Live Auth...") |
+
+---
+
+## 2. Java Runtime Management
+
+### 2.1 Catalogs and Metadata
+The Java Catalog caches available versions and platform-specific links from Adoptium.
+- **Storage**: `java_catalog.json` records SHA256 checksums and file sizes for each version.
+
+```json
+// java_catalog.json structure example
+{
+ "releases": [
+ {
+ "major_version": 17,
+ "image_type": "jdk",
+ "version": "17.0.9+9",
+ "download_url": "...",
+ "checksum": "...",
+ "file_size": 123456789
+ }
+ ],
+ "cached_at": 1700000000
+}
+```
+
+- **Detection**: Executes `java -version` command on candidate paths and parses version strings and 64-Bit identifiers from `stderr` output.
+
+```rust
+// core/java.rs detection logic
+fn check_java_installation(path: &PathBuf) -> Option<JavaInstallation> {
+ let mut cmd = Command::new(path);
+ cmd.arg("-version");
+ // Parse "version" keyword information from stderr output
+}
+```
+
+### 2.2 Automatic Installation Logic
+1. **Download**: Supports resumable downloads; large files are downloaded in parallel segments.
+2. **Extraction**: Handles archives for different operating systems (macOS handles .app structure inside .tar.gz, Windows handles .zip).
+3. **Verification**: Mandatory hash verification after download ensures runtime environment integrity.
+
+### 2.3 Java Management Related APIs
+| Command / Event | Type | Description |
+| :--- | :--- | :--- |
+| `detect_java` | Invoke | Scan system installed Java environments |
+| `download_adoptium_java` | Invoke | Start asynchronous download and automatic extraction/configuration |
+| `cancel_java_download` | Invoke | Cancel current download task via atomic flag |
+| `java-download-progress` | Event | Report file size, speed, percentage, and ETA |
+
+---
+
+## 3. Game Launch Logic & JVM Optimization
+
+### 3.1 Memory Allocation Scheme
+- **Xmx & Xms**: It is recommended to set initial memory and maximum memory to be consistent.
+- **Strategy**: The launcher automatically suggests optimal allocation based on total system memory and Java architecture, and parameters generated by the launcher have the highest priority.
+
+### 3.2 G1GC Optimization Strategy
+For 1.17+ versions and high-load mod environments, DropOut injects a concise G1GC parameter chain by default:
+```bash
+-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200
+-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC
+```
+
+---
+
+## 4. Mod Loader Mechanism
+
+### 4.1 Version Merging (Inheritance)
+Mod loaders link to the vanilla Minecraft version JSON via the `inheritsFrom` field.
+
+```json
+// Typical Fabric version config (partial)
+{
+ "id": "fabric-loader-0.15.0-1.20.4",
+ "inheritsFrom": "1.20.4",
+ "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
+ "libraries": [
+ {
+ "name": "net.fabricmc:fabric-loader:0.15.0",
+ "url": "https://maven.fabricmc.net/"
+ }
+ ]
+}
+```
+
+The following merging is performed during the launch phase:
+1. **Library Priority**: Loader's own libraries (e.g., Fabric Loader) rank before vanilla libraries.
+2. **Main Class Override**: `mainClass` is always taken from the child definition (i.e., the mod loader's entry point, like Fabric's `KnotClient`).
+3. **Argument Chain**: Merge `jvm` and `game` argument arrays, automatically filtering out inapplicable system rules.
+
+```rust
+// core/version_merge.rs merge core implementation
+pub fn merge_versions(child: GameVersion, parent: GameVersion) -> GameVersion {
+ let mut merged_libraries = child.libraries;
+ merged_libraries.extend(parent.libraries);
+ // Merge argument logic and return new GameVersion instance
+}
+```
+
+### 4.2 Library Path Resolution (Maven)
+The launcher implements complete Maven path conversion logic:
+`group:artifact:version` → `group_path/artifact/version/artifact-version.jar`
+
+```rust
+// core/maven.rs coordinate conversion path logic
+pub fn to_path(&self) -> String {
+ let group_path = self.group.replace('.', "/");
+ format!("{}/{}/{}/{}-{}.{}",
+ group_path, self.artifact, self.version,
+ self.artifact, self.version, self.extension
+ )
+}
+```
+
+### 4.3 Mod Installation Interface
+The launcher uses differentiated installation strategies based on different loader characteristics.
+
+#### Forge Installation Logic
+For Forge, DropOut downloads the official Installer and invokes the standard installer via a Java subprocess, ensuring this process is completely consistent with the official flow (including bytecode patch generation).
+
+```rust
+// core/forge.rs invoke official installer (simplified example)
+Command::new(java_path)
+ .args(&[
+ "-jar",
+ installer_path.to_str().unwrap(),
+ "--installClient",
+ game_dir.to_str().unwrap()
+ ])
+ .output()?;
+```
+
+| Command / Event | Type | Description |
+| :--- | :--- | :--- |
+| `install_fabric` | Invoke | Download Fabric libraries and generate inheritance JSON |
+| `install_forge` | Invoke | Run Forge Installer and perform bytecode patching |
+| `mod-loader-progress` | Event | Report installation stage (e.g., "processing", "complete") |
+
+---
+
+## 5. Communication & Monitoring System
+
+### 5.1 Event Bus
+The backend sends asynchronous pulses to the upper layer via Tauri's `Window` instance.
+
+```rust
+// Backend emit_log! macro simplifies log emission
+macro_rules! emit_log {
+ ($window:expr, $msg:expr) => {
+ let _ = $window.emit("launcher-log", $msg);
+ println!("[Launcher] {}", $msg);
+ };
+}
+```
+
+- **`launcher-log`**: General console output stream for global operation trajectory recording.
+- **`game-stdout` / `game-stderr`**: Capture game subprocess standard output and errors, transmitting to frontend in real-time via independent threads.
+
+```typescript
+// Frontend listen example (Svelte 5)
+import { listen } from "@tauri-apps/api/event";
+
+const unlisten = await listen("launcher-log", (event) => {
+ logStore.addLine(event.payload);
+});
+```
+
+- **`version-installed`**: Notify UI to update version list status after asynchronous task completion.
+
+### 5.2 Launch Log Desensitization
+To prevent sensitive information leakage to third-party log platforms, the launcher masks tokens before recording launch commands.
+
+```rust
+// partial argument masking logic based on main.rs
+let masked_args: Vec<String> = args.iter().enumerate().map(|(i, arg)| {
+ if i > 0 && (args[i-1] == "--accessToken" || args[i-1] == "--uuid") {
+ "***".to_string()
+ } else {
+ arg.clone()
+ }
+}).collect();
+```
+
+---
+
+## 6. Architecture & Development Standards
+
+### 6.1 Core Architecture Patterns
+
+#### Backend Command Pattern (Tauri)
+All backend functions are encapsulated as asynchronous Commands and injected with global state via `State`.
+
+1. **Define Command**:
+```rust
+#[tauri::command]
+async fn start_game(
+ window: Window,
+ auth_state: State<'_, AccountState>,
+ config_state: State<'_, ConfigState>
+) -> Result<String, String> {
+ emit_log!(window, "Starting game launch...");
+ // Business logic...
+ Ok("Success".into())
+}
+```
+
+2. **Register Command (main.rs)**:
+```rust
+tauri::Builder::default()
+ .invoke_handler(tauri::generate_handler![start_game, ...])
+```
+
+3. **Frontend Call**:
+```typescript
+import { invoke } from "@tauri-apps/api/core";
+// Argument names must correspond to Rust function parameters in snake_case/camelCase
+const result = await invoke("start_game", { versionId: "1.20.4" });
+```
+
+#### Error Handling Pattern
+All Tauri commands uniformly return `Result<T, String>`, where the Err type is fixed as String, allowing the frontend to display error messages directly.
+
+```rust
+// Uniformly use map_err to convert errors to String
+.await
+.map_err(|e| e.to_string())?;
+
+// Frontend catch
+try {
+ await invoke("...")
+} catch (e) {
+ // e is the error string returned by Rust
+ console.error(e);
+}
+```
+
+#### Frontend State Management (Svelte 5 Runes)
+The frontend fully adopts Svelte 5's `Runes` system (`$state`, `$effect`) replacing the old `writable` stores.
+
+```typescript
+// ui/src/stores/auth.svelte.ts
+export class AuthState {
+ // Use $state to define reactive data
+ currentAccount = $state<Account | null>(null);
+ isLoginModalOpen = $state(false);
+
+ constructor() {
+ // Initialization logic
+ }
+}
+
+// Export singleton for global access
+export const authState = new AuthState();
+```
+
+#### Frontend View Routing (Manual Routing)
+DropOut does not use conventional URL routing, but instead manages the currently active view component via global state.
+
+```typescript
+// ui/src/stores/ui.svelte.ts
+export class UIState {
+ activeView = $state("home"); // "home" | "versions" | "settings"
+
+ setView(view: string) {
+ this.activeView = view;
+ }
+}
+```
+
+```svelte
+<!-- App.svelte -->
+<script>
+ import { uiState } from "./stores/ui.svelte";
+</script>
+
+{#if uiState.activeView === "home"}
+ <HomeView />
+{:else if uiState.activeView === "settings"}
+ <SettingsView />
+{/if}
+```
+
+### 6.2 Development Best Practices
+1. **Silent Refresh**: Check `expires_at` before calling `start_game`, and call `refresh_full_auth` if expired.
+2. **UA Spoofing**: Microsoft auth WAF blocks requests with empty UA, so simulation of real UA in `reqwest` client is mandatory.
+3. **Log Desensitization**: Sensitive parameter values like `--accessToken` and `--uuid` must be masked in launch logs.
+
+### 6.3 Future Roadmap
+- **Multi-device Account Sync**: Explore OS-level encrypted storage solution to replace existing plaintext `accounts.json`.
+- **Automatic Dependency Resolution**: Automatically parse and download prerequisite libraries from Modrinth/CurseForge when installing mods.
+- **Intelligent Conflict Detection**: Scan `mods/` folder before launch to identify potential class conflicts or injection point errors.
+- **Config Sharing**: Support one-click export/import of complete instance configuration packages (Modpacks).
diff --git a/packages/docs/content/en/development/meta.json b/packages/docs/content/en/development/meta.json
new file mode 100644
index 0000000..dc0d5c5
--- /dev/null
+++ b/packages/docs/content/en/development/meta.json
@@ -0,0 +1,8 @@
+{
+ "title": "Development",
+ "pages": [
+ "guide",
+ "architecture",
+ "implementation"
+ ]
+}
diff --git a/packages/docs/content/en/features/authentication.mdx b/packages/docs/content/en/features/authentication.mdx
deleted file mode 100644
index b6fc4ba..0000000
--- a/packages/docs/content/en/features/authentication.mdx
+++ /dev/null
@@ -1,266 +0,0 @@
----
-title: Authentication
-description: Microsoft OAuth and offline authentication in DropOut
----
-
-# Authentication
-
-DropOut supports two authentication methods: Microsoft Account (for official Minecraft) and Offline Mode (for testing and offline play).
-
-## Microsoft Authentication
-
-### Overview
-
-DropOut uses the **Device Code Flow** for Microsoft authentication, which:
-- Doesn't require a redirect URL (no browser integration)
-- Works on any device with a browser
-- Provides a simple code-based authentication
-- Fully compliant with Microsoft OAuth 2.0
-
-### Authentication Process
-
-The authentication chain consists of multiple steps:
-
-1. **Device Code** → User authorization
-2. **MS Token** → Access + refresh tokens
-3. **Xbox Live** → Xbox token + UHS
-4. **XSTS** → Security token
-5. **Minecraft** → Game access token
-6. **Profile** → Username + UUID
-
-#### Step 1: Device Code Request
-1. Click "Login with Microsoft"
-2. DropOut requests a device code from Microsoft
-3. You receive:
- - User code (e.g., `A1B2-C3D4`)
- - Verification URL (usually `https://microsoft.com/link`)
- - Device code (used internally)
-
-#### Step 2: User Authorization
-1. Visit the verification URL in any browser
-2. Enter the user code
-3. Sign in with your Microsoft account
-4. Authorize DropOut to access your Minecraft profile
-
-#### Step 3: Token Exchange
-- DropOut polls Microsoft for authorization completion
-- Once authorized, receives an access token and refresh token
-- Refresh token is stored for future logins
-
-#### Step 4: Xbox Live Authentication
-- Microsoft token is exchanged for Xbox Live token
-- Retrieves User Hash (UHS) for next step
-
-#### Step 5: XSTS Authorization
-- Xbox Live token is used to get XSTS token
-- This token is specific to Minecraft services
-
-#### Step 6: Minecraft Login
-- XSTS token is exchanged for Minecraft access token
-- Uses endpoint: `/launcher/login`
-
-#### Step 7: Profile Fetching
-- Retrieves your Minecraft username
-- Fetches your UUID
-- Checks if you own Minecraft
-
-### Token Management
-
-**Access Token:**
-- Short-lived (typically 1 hour)
-- Used for game authentication
-- Automatically refreshed when expired
-
-**Refresh Token:**
-- Long-lived (typically 90 days)
-- Stored securely in `accounts.json`
-- Used to obtain new access tokens
-
-**Auto-refresh:**
-```rust
-// Automatic refresh when token expires
-if account.expires_at < current_time {
- refresh_full_auth(&account).await?;
-}
-```
-
-### Security Considerations
-
-- Tokens are stored in platform-specific app data directory
-- HTTPS only for all API calls
-- No credentials stored (only tokens)
-- User-Agent header required (bypasses MS WAF)
-
-### Troubleshooting Microsoft Login
-
-**"Device code expired"**
-- Codes expire after 15 minutes
-- Start the login process again
-
-**"Authorization pending"**
-- Normal during the waiting phase
-- Complete authorization in the browser
-
-**"Invalid token"**
-- Token may have expired
-- Log out and log back in
-
-**"You don't own Minecraft"**
-- Verify your Microsoft account owns Minecraft Java Edition
-- Check at https://www.minecraft.net/profile
-
-## Offline Authentication
-
-### Overview
-
-Offline mode creates a local account that doesn't require internet connectivity or a Microsoft account. This is useful for:
-- Testing and development
-- Playing without internet
-- LAN multiplayer
-- Mod development
-
-### Creating an Offline Account
-
-1. Click "Offline Mode" in the login screen
-2. Enter a username (3-16 characters)
-3. Click "Create Account"
-
-### How It Works
-
-**UUID Generation:**
-```rust
-// Deterministic UUID v3 from username
-let uuid = generate_offline_uuid(&username);
-```
-
-- Uses UUID v3 (namespace-based)
-- Deterministic: same username = same UUID
-- No network requests
-
-**Authentication:**
-- Returns `"null"` as access token
-- Minecraft accepts null token in offline mode
-- Username and UUID stored locally
-
-### Limitations
-
-- Cannot join online servers
-- No skin support
-- No cape support
-- No Microsoft account features
-
-### Use Cases
-
-**Development:**
-```bash
-# Testing mod development
-cargo tauri dev
-# Use offline mode to test quickly
-```
-
-**LAN Play:**
-- Join LAN worlds without authentication
-- Host LAN worlds
-
-**Offline Play:**
-- Singleplayer without internet
-- No authentication required
-
-## Account Management
-
-### Switching Accounts
-
-Currently, DropOut supports one active account at a time. Multi-account support is planned.
-
-**To switch accounts:**
-1. Log out of current account
-2. Log in with new account
-
-### Account Storage
-
-Accounts are stored in `accounts.json`:
-
-```json
-{
- "current_account_id": "uuid-here",
- "accounts": [
- {
- "id": "uuid",
- "type": "Microsoft",
- "username": "PlayerName",
- "access_token": "...",
- "refresh_token": "...",
- "expires_at": 1234567890
- }
- ]
-}
-```
-
-### Deleting Accounts
-
-To remove an account:
-1. Open Settings
-2. Navigate to Accounts
-3. Click "Log Out"
-4. Or manually delete `accounts.json`
-
-## API Reference
-
-### Tauri Commands
-
-**Start Microsoft Login:**
-```typescript
-const { user_code, verification_uri } = await invoke('start_microsoft_login');
-```
-
-**Complete Microsoft Login:**
-```typescript
-const account = await invoke('complete_microsoft_login', { deviceCode });
-```
-
-**Offline Login:**
-```typescript
-const account = await invoke('offline_login', { username: 'Player' });
-```
-
-**Logout:**
-```typescript
-await invoke('logout');
-```
-
-**Get Current Account:**
-```typescript
-const account = await invoke('get_current_account');
-```
-
-### Events
-
-**Authentication Status:**
-```typescript
-listen('auth-status', (event) => {
- console.log(event.payload); // "logged_in" | "logged_out"
-});
-```
-
-## Best Practices
-
-### For Players
-
-1. **Use Microsoft Account** for official servers
-2. **Keep tokens secure** - don't share accounts.json
-3. **Refresh tokens regularly** by logging in
-4. **Use offline mode** only for testing
-
-### For Developers
-
-1. **Handle token expiration** gracefully
-2. **Implement retry logic** for network failures
-3. **Cache account data** to reduce API calls
-4. **Validate tokens** before game launch
-
-## Future Enhancements
-
-- **Multi-account support**: Switch between accounts easily
-- **Account profiles**: Save per-account settings
-- **Auto-login**: Remember last account
-- **Token encryption**: Enhanced security for stored tokens
diff --git a/packages/docs/content/en/manual/features/authentication.mdx b/packages/docs/content/en/manual/features/authentication.mdx
new file mode 100644
index 0000000..f7a1f69
--- /dev/null
+++ b/packages/docs/content/en/manual/features/authentication.mdx
@@ -0,0 +1,131 @@
+---
+title: Authentication
+description: Microsoft OAuth and offline authentication in DropOut
+---
+
+# Authentication
+
+DropOut supports two authentication methods: Microsoft Account (for official Minecraft) and Offline Mode (for testing and offline play).
+
+## Microsoft Authentication
+
+### Overview
+
+DropOut uses the **Device Code Flow** for Microsoft authentication, featuring:
+- No redirect URL required (no browser integration needed)
+- Works on any device with a browser
+- Provides simple code-based authentication
+- Fully compliant with Microsoft OAuth 2.0 standards
+
+### Authentication Process
+
+The authentication chain consists of multiple steps. DropOut automatically handles these complex exchange processes, including interactions with Microsoft, Xbox Live, and Minecraft services. If you are interested in detailed API implementation, please refer to [Internal Implementation](/docs/development/implementation#1-authentication-system).
+
+### Token Management
+
+**Access Token:**
+- Short-lived (typically 1 hour)
+- Used for game authentication
+- Automatically refreshed when expired
+
+**Refresh Token:**
+- Long-lived (typically 90 days)
+- Stored securely in `accounts.json`
+- Used to obtain new access tokens
+
+**Auto-refresh:**
+When the token expires, DropOut attempts to automatically update your login status using the refresh token when you launch the game, ensuring a seamless start.
+
+### Security Considerations
+
+- Tokens are stored in the platform-specific application data directory
+- All API calls use HTTPS only
+- No credentials stored (only tokens)
+- User-Agent header required (bypasses MS WAF)
+
+### Troubleshooting Microsoft Login
+
+**"Device code expired"**
+- The code expires after 15 minutes
+- Restart the login process
+
+**"Authorization pending"**
+- Normal during the waiting phase
+- Complete authorization in your browser
+
+**"Invalid token"**
+- The token may have expired
+- Log out and log back in (use "Switch Account" to clear token)
+
+**"You don't own Minecraft"**
+- Verify your Microsoft account owns Minecraft: Java Edition
+- Check at https://www.minecraft.net/profile
+
+## Offline Authentication
+
+### Overview
+
+Offline mode creates a local account that does not require an internet connection or a Microsoft account. This is useful for:
+- Testing and development
+- Playing without internet
+- LAN multiplayer
+- Mod development
+
+### Creating an Offline Account
+
+1. Click "Offline Mode" on the login screen
+2. Enter a username (3-16 characters)
+3. Click "Create Account"
+
+### How It Works
+
+**UUID Generation:**
+Offline mode uses a deterministic UUID generation algorithm based on the username (UUID v3). This means the same username will always get the same UUID in the same launcher instance, maintaining single-player save consistency.
+
+- Deterministic: Same username = Same UUID
+- No network requests needed
+
+**Authentication:**
+- Returns `"null"` as the access token
+- Minecraft accepts empty tokens in offline mode
+- Username and UUID are stored locally
+
+### Limitations
+
+- Cannot join online servers (online-mode=true)
+- No custom skins support
+- No capes support
+- Cannot use Microsoft account features
+
+## Account Management
+
+### Switching Accounts
+
+Currently, DropOut supports only one active account at a time. Multi-account support is planned.
+
+**Steps to switch accounts:**
+1. Log out of the current account
+2. Log in with the new account
+
+### Account Storage
+
+Account data is stored in `accounts.json` within the application folder. This file contains encrypted tokens, expiration times, and basic profile information for logged-in accounts.
+
+### Deleting an Account
+
+Steps to delete an account:
+1. Open Settings
+2. Navigate to Account
+3. Click "Log out"
+4. Or manually delete `accounts.json`
+
+## API Reference
+
+For low-level implementation of authentication, OAuth 2.0 flow details, and related Tauri command interfaces, please refer to the development documentation: [Implementation Details: Authentication](/docs/development/implementation#1-authentication-system).
+
+## Best Practices
+
+1. **Use Microsoft Account for Official Servers**: To join official servers and use official skins, always use a Microsoft account.
+2. **Keep Tokens Secure**: Do not share the `accounts.json` file or its contents with others, as it contains your login credentials.
+3. **Refresh Tokens Regularly**: Long-unused offline accounts or expired Microsoft tokens can be refreshed by re-logging in or launching the game.
+4. **Use Offline Mode Only for Testing**: Offline mode does not support skins and some multiplayer features.
diff --git a/packages/docs/content/en/features/index.mdx b/packages/docs/content/en/manual/features/index.mdx
index 3a463d0..3a463d0 100644
--- a/packages/docs/content/en/features/index.mdx
+++ b/packages/docs/content/en/manual/features/index.mdx
diff --git a/packages/docs/content/en/features/java.mdx b/packages/docs/content/en/manual/features/java.mdx
index 21b95a9..21b95a9 100644
--- a/packages/docs/content/en/features/java.mdx
+++ b/packages/docs/content/en/manual/features/java.mdx
diff --git a/packages/docs/content/en/features/meta.json b/packages/docs/content/en/manual/features/meta.json
index 4725321..4725321 100644
--- a/packages/docs/content/en/features/meta.json
+++ b/packages/docs/content/en/manual/features/meta.json
diff --git a/packages/docs/content/en/features/mod-loaders.mdx b/packages/docs/content/en/manual/features/mod-loaders.mdx
index d6fdf4f..8fdf019 100644
--- a/packages/docs/content/en/features/mod-loaders.mdx
+++ b/packages/docs/content/en/manual/features/mod-loaders.mdx
@@ -28,31 +28,7 @@ Fabric is a lightweight, modular modding toolchain focused on:
### How It Works
-**Meta API Integration:**
-```rust
-// Fetch available Fabric versions
-let url = format!(
- "https://meta.fabricmc.net/v2/versions/loader/{}",
- minecraft_version
-);
-```
-
-**Profile Generation:**
-1. Fetch Fabric loader metadata
-2. Download Fabric libraries
-3. Generate version JSON with `inheritsFrom`
-4. Merge with parent Minecraft version
-5. Add to versions list
-
-**Version Format:**
-```json
-{
- "id": "fabric-loader-0.15.0-1.20.4",
- "inheritsFrom": "1.20.4",
- "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
- "libraries": [...]
-}
-```
+DropOut integrates with the Fabric Meta API to automatically fetch compatible loader versions. In the background, it generates the version JSON, handles library dependencies, and utilizes the inheritance system to ensure perfect compatibility with vanilla Minecraft. Detailed JSON structure can be found in [Technical Details](/docs/development/implementation#fabric-integration).
### Fabric Versions
@@ -68,26 +44,7 @@ let url = format!(
### Library Management
-Fabric libraries are resolved from Maven:
-
-**Main Library:**
-```
-net.fabricmc:fabric-loader:0.15.0
-```
-
-**Dependencies:**
-- `net.fabricmc:tiny-mappings-parser`
-- `net.fabricmc:sponge-mixin`
-- `net.fabricmc:access-widener`
-
-**Download:**
-```rust
-// Maven resolution
-let url = format!(
- "https://maven.fabricmc.net/{}/{}",
- artifact_path, filename
-);
-```
+Fabric's dependencies are typically hosted on its official Maven repository. DropOut automatically resolves and downloads all required library files.
### Fabric API
@@ -164,22 +121,9 @@ java -jar forge-installer.jar --installClient
### Library Management
-Forge has many libraries:
-
-**Core Libraries:**
-- `net.minecraftforge:forge:<version>`
-- `net.minecraftforge:fmlloader:<version>`
-- `org.ow2.asm:asm:<version>`
+Forge's runtime depends on a significant number of library files, including its underlying `fmlloader` and bytecode manipulation libraries. DropOut automatically resolves the complex dependency tree and retrieves these files from both the official Forge Maven repository and Minecraft's official libraries.
-**Resolution:**
-```rust
-// Forge Maven
-"https://maven.minecraftforge.net/"
-
-// Dependencies may use:
-// - Maven Central
-// - Minecraft Libraries
-```
+For detailed analysis logic of Forge libraries, refer to [Implementation Details: Forge Core Library Resolution](../development/implementation.mdx#core-library-resolution).
### Forge Processors
@@ -192,44 +136,9 @@ DropOut handles these automatically.
## Version Inheritance
-Both Fabric and Forge use Minecraft's inheritance system:
+Both Fabric and Forge utilize Minecraft's version inheritance mechanism. In this model, the mod loader's version JSON only contains incremental changes relative to the vanilla version, addressed recursively upwards via the `inheritsFrom` field.
-### Parent Version
-
-```json
-{
- "id": "fabric-loader-0.15.0-1.20.4",
- "inheritsFrom": "1.20.4" // Parent vanilla version
-}
-```
-
-### Merging Process
-
-**Libraries:**
-```rust
-// Merged from both
-parent_libraries + modded_libraries
-// Duplicates removed
-```
-
-**Arguments:**
-```rust
-// Combined
-parent_jvm_args + modded_jvm_args
-parent_game_args + modded_game_args
-```
-
-**Assets:**
-```rust
-// Inherited from parent
-assets = parent.assets
-```
-
-**Main Class:**
-```rust
-// Overridden by modded
-main_class = modded.mainClass
-```
+DropOut's launch engine automatically handles this complex merging of libraries, arguments, and assets. Detailed merging logic and code implementation can be viewed in [Implementation Details: Version Merging](../development/implementation.mdx#version-merging-mechanism).
## Comparison
diff --git a/packages/docs/content/en/getting-started.mdx b/packages/docs/content/en/manual/getting-started.mdx
index 5219f40..c172289 100644
--- a/packages/docs/content/en/getting-started.mdx
+++ b/packages/docs/content/en/manual/getting-started.mdx
@@ -13,17 +13,18 @@ DropOut is a modern, reproducible, and developer-grade Minecraft launcher built
Download the latest release for your platform from the [Releases](https://github.com/HsiangNianian/DropOut/releases) page.
-| Platform | Files |
-| -------------- | ----------------------- |
-| Linux x86_64 | `.deb`, `.AppImage` |
-| Linux ARM64 | `.deb`, `.AppImage` |
-| macOS ARM64 | `.dmg` |
-| Windows x86_64 | `.msi`, `.exe` |
-| Windows ARM64 | `.msi`, `.exe` |
+| Platform | Files |
+| -------------- | ------------------- |
+| Linux x86_64 | `.deb`, `.AppImage` |
+| Linux ARM64 | `.deb`, `.AppImage` |
+| macOS ARM64 | `.dmg` |
+| Windows x86_64 | `.msi`, `.exe` |
+| Windows ARM64 | `.msi`, `.exe` |
### Linux Installation
#### Using .deb Package
+
```bash
sudo dpkg -i dropout_*.deb
# Fix dependencies if needed
@@ -31,6 +32,7 @@ sudo apt-get install -f
```
#### Using AppImage
+
```bash
chmod +x dropout_*.AppImage
./dropout_*.AppImage
@@ -45,11 +47,13 @@ chmod +x dropout_*.AppImage
### Windows Installation
#### Using .msi Installer
+
1. Double-click the `.msi` file
2. Follow the installation wizard
3. Launch DropOut from the Start Menu
#### Using .exe Portable
+
1. Double-click the `.exe` file
2. DropOut will run directly without installation
@@ -74,17 +78,12 @@ When you first launch DropOut, you'll need to:
### 1. Login
<Cards>
- <Card
- title="Microsoft Account"
- description="Login with your official Minecraft account"
- />
- <Card
- title="Offline Mode"
- description="Create a local profile for offline play"
- />
+ <Card title="Microsoft Account" description="Login with your official Minecraft account" />
+ <Card title="Offline Mode" description="Create a local profile for offline play" />
</Cards>
**Microsoft Login:**
+
1. Click "Login with Microsoft"
2. A device code will be displayed
3. Visit the URL shown and enter the code
@@ -92,6 +91,7 @@ When you first launch DropOut, you'll need to:
5. Return to DropOut - you'll be logged in automatically
**Offline Login:**
+
1. Click "Offline Mode"
2. Enter a username
3. Click "Create Account"
@@ -117,23 +117,23 @@ When you first launch DropOut, you'll need to:
## Next Steps
<Cards>
- <Card
- title="Features"
- href="/docs/features"
+ <Card
+ title="Features"
+ href="/docs/manual/features"
description="Learn about all the features DropOut offers"
/>
- <Card
- title="Instances"
- href="/docs/features/instances"
+ <Card
+ title="Instances"
+ href="/docs/manual/features/instances"
description="Create isolated game environments"
/>
- <Card
- title="Mod Loaders"
- href="/docs/features/mod-loaders"
+ <Card
+ title="Mod Loaders"
+ href="/docs/manual/features/mod-loaders"
description="Install and manage Fabric and Forge"
/>
- <Card
- title="Troubleshooting"
+ <Card
+ title="Troubleshooting"
href="/docs/troubleshooting"
description="Common issues and solutions"
/>
@@ -142,12 +142,14 @@ When you first launch DropOut, you'll need to:
## System Requirements
### Minimum Requirements
+
- **OS**: Windows 10+, macOS 11+, or Linux (Debian-based)
- **RAM**: 4GB (8GB recommended for modded Minecraft)
- **Storage**: 2GB for launcher + game files
- **Java**: Auto-installed by DropOut if not found
### Recommended Requirements
+
- **OS**: Latest stable version of your OS
- **RAM**: 16GB for optimal performance with mods
- **Storage**: 10GB+ for multiple versions and mods
@@ -156,6 +158,7 @@ When you first launch DropOut, you'll need to:
## Getting Help
If you encounter issues:
+
- Check the [Troubleshooting Guide](/docs/troubleshooting)
- Report bugs on [GitHub Issues](https://github.com/HsiangNianian/DropOut/issues)
- Join our community discussions
diff --git a/packages/docs/content/en/index.mdx b/packages/docs/content/en/manual/index.mdx
index 9dee19f..9dee19f 100644
--- a/packages/docs/content/en/index.mdx
+++ b/packages/docs/content/en/manual/index.mdx
diff --git a/packages/docs/content/en/manual/meta.json b/packages/docs/content/en/manual/meta.json
new file mode 100644
index 0000000..38506c1
--- /dev/null
+++ b/packages/docs/content/en/manual/meta.json
@@ -0,0 +1,9 @@
+{
+ "title": "User Manual",
+ "pages": [
+ "index",
+ "getting-started",
+ "features",
+ "troubleshooting"
+ ]
+}
diff --git a/packages/docs/content/en/troubleshooting.mdx b/packages/docs/content/en/manual/troubleshooting.mdx
index 6d97819..6d97819 100644
--- a/packages/docs/content/en/troubleshooting.mdx
+++ b/packages/docs/content/en/manual/troubleshooting.mdx
diff --git a/packages/docs/content/en/meta.json b/packages/docs/content/en/meta.json
index 75bf27a..a877cab 100644
--- a/packages/docs/content/en/meta.json
+++ b/packages/docs/content/en/meta.json
@@ -1,11 +1,7 @@
{
"title": "Documentation",
"pages": [
- "index",
- "getting-started",
- "architecture",
- "features",
- "development",
- "troubleshooting"
+ "manual",
+ "development"
]
}
diff --git a/packages/docs/content/zh/architecture.mdx b/packages/docs/content/zh/development/architecture.mdx
index 6a2a2df..4f47115 100644
--- a/packages/docs/content/zh/architecture.mdx
+++ b/packages/docs/content/zh/development/architecture.mdx
@@ -10,97 +10,114 @@ DropOut 采用现代技术栈构建,旨在实现高性能ã€å®‰å…¨æ€§å’Œè·¨å¹³
## 技术栈
### åŽç«¯ï¼ˆRust)
+
- **框架**: Tauri v2
- **语言**: Rust(Edition 2021)
- **异步è¿è¡Œæ—¶**: Tokio
- **HTTP 客户端**: reqwest with native-tls
-### å‰ç«¯ï¼ˆSvelte)
-- **框架**: Svelte 5(with runes)
+### å‰ç«¯ï¼ˆReact)
+
+- **框架**: React 19
+- **状æ€ç®¡ç†**: Zustand
+- **路由**: React Router v7
- **æ ·å¼**: Tailwind CSS 4
-- **构建工具**: Vite with Rolldown
+- **构建工具**: Vite (with Rolldown)
- **包管ç†å™¨**: pnpm
### 文档
+
- **框架**: Fumadocs with React Router v7
- **内容**: MDX 文件
- **æ ·å¼**: Tailwind CSS 4
## 系统架构
-```
-┌─────────────────────────────────────────────────────────â”
-│ å‰ç«¯ï¼ˆSvelte 5) │
-│ ┌──────────┠┌──────────┠┌──────────┠┌─────────┠│
-│ │ Stores │ │Components│ │ UI Views │ │Particles│ │
-│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬────┘ │
-│ │ │ │ │ │
-│ └─────────────┴─────────────┴──────────────┘ │
-│ │ │
-│ Tauri 命令 │
-│ 事件/å‘射器 │
-└──────────────────────────┬──────────────────────────────┘
- │
-┌──────────────────────────┴──────────────────────────────â”
-│ åŽç«¯ï¼ˆRust/Tauri) │
-│ ┌─────────────────────────────────────────────────┠│
-│ │ main.rs(命令) │ │
-│ └──────────────┬──────────────────────────────────┘ │
-│ │ │
-│ ┌──────────────┴───────────────────────────────┠│
-│ │ æ ¸å¿ƒæ¨¡å— â”‚ │
-│ │ ┌──────┠┌────────┠┌──────┠┌──────────┠│ │
-│ │ │ Auth │ │Download│ │ Java │ │ Instance │ │ │
-│ │ └──────┘ └────────┘ └──────┘ └──────────┘ │ │
-│ │ ┌──────┠┌────────┠┌──────┠┌──────────┠│ │
-│ │ │Fabric│ │ Forge │ │Config│ │Manifest │ │ │
-│ │ └──────┘ └────────┘ └──────┘ └──────────┘ │ │
-│ └──────────────────────────────────────────────┘ │
-│ │
-│ ┌─────────────────────────────────────────────────┠│
-│ │ 工具和辅助函数 │ │
-│ │ • ZIP æå– • 路径工具 │ │
-│ └─────────────────────────────────────────────────┘ │
-└──────────────────────────┬──────────────────────────────┘
- │
- 外部 API
- │
- ┌──────────────────┼──────────────────â”
- │ │ │
- ┌─────┴────┠┌──────┴─────┠┌──────┴─────â”
- │ Mojang │ │ Fabric │ │ Forge │
- │ APIs │ │ Meta │ │ Maven │
- └──────────┘ └────────────┘ └────────────┘
+```mermaid
+graph TB
+ subgraph Frontend["å‰ç«¯ (React 19)"]
+ direction LR
+ Stores[Zustand Stores] ~~~ Components[Components] ~~~ Pages[Pages] ~~~ Router[Router] ~~~ Particles[Particle Background]
+ end
+
+ subgraph TauriLayer["Tauri 通信层"]
+ direction LR
+ Commands[命令] ~~~ Events[事件/å‘射器]
+ end
+
+ subgraph Backend["åŽç«¯ (Rust/Tauri)"]
+ direction TB
+ MainRS[main.rs 命令处ç†]
+
+ subgraph CoreModules["核心模å—"]
+ direction LR
+ Auth[Auth] ~~~ Download[Download] ~~~ Java[Java] ~~~ Instance[Instance]
+ Fabric[Fabric] ~~~ Forge[Forge] ~~~ Config[Config] ~~~ Manifest[Manifest]
+ Auth ~~~ Fabric
+ end
+
+ subgraph Utils["工具和辅助函数"]
+ direction LR
+ ZipExtract[ZIP æå–] ~~~ PathUtils[路径工具]
+ end
+
+ MainRS --> CoreModules
+ CoreModules --> Utils
+ end
+
+ subgraph ExternalAPIs["外部 API"]
+ direction LR
+ Mojang[Mojang APIs] ~~~ FabricMeta[Fabric Meta] ~~~ ForgeMaven[Forge Maven]
+ end
+
+ Frontend <--> TauriLayer
+ TauriLayer <--> Backend
+ Backend <--> ExternalAPIs
+
+ style Frontend fill:#e3f2fd
+ style Backend fill:#fff3e0
+ style TauriLayer fill:#f3e5f5
+ style ExternalAPIs fill:#e8f5e9
```
## 核心组件
### å‰ç«¯çжæ€ç®¡ç†
-DropOut 使用 **Svelte 5 runes** 进行å“应å¼çжæ€ç®¡ç†ï¼š
+DropOut 使用 **Zustand** 进行全局状æ€ç®¡ç†ï¼š
```typescript
-// stores/auth.svelte.ts
-export class AuthState {
- currentAccount = $state<Account | null>(null); // å“应å¼
- isLoginModalOpen = $state(false);
-
- $effect(() => { // 副作用
- // ä¾èµ–å˜åŒ–时自动è¿è¡Œ
- });
+// models/auth.ts
+import { create } from "zustand";
+
+interface AuthState {
+ account: Account | null;
+ loginMode: LoginMode | null;
+ // ...
+ setAccount: (account: Account | null) => void;
}
+
+export const useAuthStore = create<AuthState>((set) => ({
+ account: null,
+ loginMode: null,
+ setAccount: (account) => set({ account }),
+ // ...
+}));
```
**关键 Stores:**
-- `auth.svelte.ts`: 认è¯çжæ€å’Œç™»å½•æµç¨‹
-- `settings.svelte.ts`: å¯åŠ¨å™¨è®¾ç½®å’Œ Java 检测
-- `game.svelte.ts`: 游æˆè¿è¡Œçжæ€å’Œæ—¥å¿—
-- `instances.svelte.ts`: 实例管ç†
-- `ui.svelte.ts`: UI 状æ€ï¼ˆæç¤ºã€æ¨¡æ€æ¡†ã€æ´»åŠ¨è§†å›¾ï¼‰
+
+- `models/auth.ts`: 认è¯çжæ€å’Œç™»å½•æµç¨‹
+- `models/settings.ts`: å¯åŠ¨å™¨è®¾ç½®å’Œ Java 检测
+- `models/instance.ts`: 实例管ç†
+- `stores/game-store.ts`: 游æˆè¿è¡Œçжæ€
+- `stores/logs-store.ts`: æ¸¸æˆæ—¥å¿—æµç®¡ç†
+- `stores/ui-store.ts`: UI 状æ€ï¼ˆæç¤ºã€æ¨¡æ€æ¡†ã€æ´»åŠ¨è§†å›¾ï¼‰
### åŽç«¯æž¶æž„
#### 命令模å¼
+
所有 Tauri 命令éµå¾ªæ­¤ç»“构:
```rust
@@ -119,12 +136,14 @@ async fn command_name(
#### 事件通信
**Rust → å‰ç«¯ï¼ˆè¿›åº¦æ›´æ–°ï¼‰ï¼š**
+
```rust
window.emit("launcher-log", "正在下载...")?;
window.emit("download-progress", progress_struct)?;
```
**å‰ç«¯ → Rust(命令):**
+
```typescript
import { invoke } from "@tauri-apps/api/core";
const result = await invoke("start_game", { versionId: "1.20.4" });
@@ -133,12 +152,14 @@ const result = await invoke("start_game", { versionId: "1.20.4" });
### 核心模å—
#### 认è¯ï¼ˆ`core/auth.rs`)
+
- **微软 OAuth 2.0**: è®¾å¤‡ä»£ç æµ
- **离线认è¯**: 本地 UUID 生æˆ
- **令牌管ç†**: 刷新令牌存储和自动刷新
- **Xbox Live 集æˆ**: 完整认è¯é“¾
**è®¤è¯æµç¨‹ï¼š**
+
1. 设备代ç è¯·æ±‚ → MS 令牌
2. Xbox Live 认è¯
3. XSTS 授æƒ
@@ -146,6 +167,7 @@ const result = await invoke("start_game", { versionId: "1.20.4" });
5. é…置文件获å–
#### 下载器(`core/downloader.rs`)
+
- **å¹¶å‘下载**: å¯é…置线程池
- **断点续传**: `.part` 和 `.part.meta` 文件
- **多段下载**: 大文件分割æˆå—
@@ -153,6 +175,7 @@ const result = await invoke("start_game", { versionId: "1.20.4" });
- **进度跟踪**: 实时事件到å‰ç«¯
#### Java 管ç†ï¼ˆ`core/java.rs`)
+
- **自动检测**: 扫æç³»ç»Ÿè·¯å¾„
- **Adoptium 集æˆ**: 按需下载 JDK/JRE
- **目录缓存**: 版本列表 24 å°æ—¶ç¼“å­˜
@@ -160,22 +183,26 @@ const result = await invoke("start_game", { versionId: "1.20.4" });
- **å–æ¶ˆ**: ä¸‹è½½å–æ¶ˆçš„åŽŸå­æ ‡å¿—
#### Fabric 支æŒï¼ˆ`core/fabric.rs`)
+
- **Meta API 集æˆ**: 获å–加载器版本
- **é…置文件生æˆ**: 创建版本 JSON
- **库解æž**: Maven 构件处ç†
#### Forge 支æŒï¼ˆ`core/forge.rs`)
+
- **å®‰è£…ç¨‹åºæ‰§è¡Œ**: è¿è¡Œ Forge 安装程åº
- **é…置文件解æž**: æå–安装é…置文件
- **库管ç†**: å¤„ç† Forge 特定库
#### 实例系统(`core/instance.rs`)
+
- **隔离**: æ¯ä¸ªå®žä¾‹ç‹¬ç«‹ç›®å½•
- **é…ç½®**: æ¯ä¸ªå®žä¾‹çš„设置
- **模组管ç†**: 实例特定模组
- **版本é”定**: å¯å¤çŽ°çŽ¯å¢ƒ
#### 版本管ç†
+
- **清å•è§£æž**(`manifest.rs`): Mojang 版本清å•
- **继承系统**(`version_merge.rs`): 父版本åˆå¹¶
- **游æˆç‰ˆæœ¬**(`game_version.rs`): JSON è§£æžå’ŒéªŒè¯
@@ -213,7 +240,7 @@ const result = await invoke("start_game", { versionId: "1.20.4" });
### 游æˆå¯åЍåºåˆ—
1. **å‰ç«¯**: 用户点击"å¯åŠ¨æ¸¸æˆ"
-2. **命令**: 调用 `start_game(version_id)`
+2. **命令**: 调用 `start_game(instance_id, version_id)`
3. **åŽç«¯å¤„ç†**:
- 加载版本 JSON(带继承)
- è§£æžæ‰€æœ‰åº“
@@ -249,16 +276,19 @@ const result = await invoke("start_game", { versionId: "1.20.4" });
## å¹³å°ç‰¹å®šè€ƒè™‘
### Linux
+
- 使用 GTK WebView(`webkit2gtk`)
- 从 `/usr/lib/jvm` 系统 Java 检测
- æ¡Œé¢æ–‡ä»¶é›†æˆ
### macOS
+
- 使用系统 WebKit
- 应用程åºåŒ…结构
- 钥匙串集æˆç”¨äºŽå®‰å…¨å­˜å‚¨
### Windows
+
- 使用 WebView2 è¿è¡Œæ—¶
- 注册表 Java 检测
- MSI å®‰è£…ç¨‹åºæ”¯æŒ
diff --git a/packages/docs/content/zh/development/implementation.mdx b/packages/docs/content/zh/development/implementation.mdx
new file mode 100644
index 0000000..d7586aa
--- /dev/null
+++ b/packages/docs/content/zh/development/implementation.mdx
@@ -0,0 +1,351 @@
+---
+title: 内部实现
+description: DropOut 核心功能的详细实现和技术规范
+---
+
+# 内部实现
+
+本页详细介ç»äº†å¯åЍ噍å„个核心模å—çš„æŠ€æœ¯å®žçŽ°ç»†èŠ‚ã€æ•°æ®ç»“æž„å’Œå¤„ç†æµç¨‹ã€‚
+
+## 1. 身份验è¯ç³»ç»Ÿ (Authentication)
+
+身份验è¯é“¾åŒ…å«å¤šä¸ªå¼‚步步骤,任何一步失败都会中断整个æµç¨‹ã€‚DropOut 采用 Microsoft Device Code Flow 实现å…é‡å®šå‘的安全登录。
+
+### 1.1 详细æµç¨‹
+1. **设备代ç è¯·æ±‚**:
+ - 调用 `https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode`
+ - 获å–用户验è¯ç åŠéªŒè¯ URL。
+2. **令牌交æ¢**: 轮询 `/oauth2/v2.0/token` 获å–主访问令牌 (Access Token) 和刷新令牌 (Refresh Token)。
+3. **Xbox Live 认è¯**: èŽ·å– `Token` å’Œ `uhs` (User Hash)。
+4. **XSTS 授æƒ**: é‡å®šå‘至 `rp://api.minecraftservices.com/`。
+5. **Minecraft 登录**: 使用 `XBL3.0 x=<uhs>;<xsts_token>` æ ¼å¼ä»¤ç‰Œæ¢å–æœ€ç»ˆçš„æ¸¸æˆ Access Token。
+
+### 1.2 账户存储与安全
+è´¦æˆ·æ•°æ®æŒä¹…化在 `accounts.json` 中,包å«è´¦æˆ·ç±»åž‹åŠåР坆åŽçš„令牌信æ¯ï¼š
+```json
+{
+ "active_account_uuid": "...",
+ "accounts": [
+ {
+ "type": "Microsoft",
+ "username": "...",
+ "uuid": "...",
+ "access_token": "...",
+ "refresh_token": "...",
+ "expires_at": 1234567890
+ },
+ { "type": "Offline", "username": "Player", "uuid": "..." }
+ ]
+}
+```
+**离线 UUID**: 使用基于用户å的确定性 UUID v3ï¼Œç¡®ä¿æœ¬åœ°å­˜æ¡£å’Œé…置的一致性。
+
+```rust
+// core/auth.rs 实现详情
+pub fn generate_offline_uuid(username: &str) -> String {
+ let namespace = Uuid::NAMESPACE_OID;
+ Uuid::new_v3(&namespace, username.as_bytes()).to_string()
+}
+```
+
+### 1.3 身份验è¯ç›¸å…³æŽ¥å£
+| 命令 / 事件 | 类型 | 说明 |
+| :--- | :--- | :--- |
+| `start_microsoft_login` | Invoke | å¯åŠ¨è®¾å¤‡æµå¹¶è¿”回验è¯ç  |
+| `complete_microsoft_login` | Invoke | 轮询并完æˆå…¨å¥—身份验è¯é“¾ |
+| `login_offline` | Invoke | 创建并切æ¢è‡³ç¦»çº¿è´¦æˆ· |
+| `auth-progress` | Event | 实时上报认è¯è¿›åº¦ï¼ˆå¦‚ "Xbox Live Auth...") |
+
+---
+
+## 2. Java è¿è¡ŒçŽ¯å¢ƒç®¡ç† (Java Runtime)
+
+### 2.1 目录与元数æ®
+Java 目录 (Catalog) 缓存了æ¥è‡ª Adoptium çš„å¯ç”¨ç‰ˆæœ¬åŠå…¶å¹³å°ç‰¹å®šé“¾æŽ¥ã€‚
+- **存储**: `java_catalog.json` 记录了å„个版本的 SHA256 æ ¡éªŒå’ŒåŠæ–‡ä»¶å¤§å°ã€‚
+
+```json
+// java_catalog.json 结构示例
+{
+ "releases": [
+ {
+ "major_version": 17,
+ "image_type": "jdk",
+ "version": "17.0.9+9",
+ "download_url": "...",
+ "checksum": "...",
+ "file_size": 123456789
+ }
+ ],
+ "cached_at": 1700000000
+}
+```
+
+- **检测**: 通过对候选路径执行 `java -version` 命令,解æžå…¶ `stderr` 输出中的版本字符串和 64-Bit 标识。
+
+```rust
+// core/java.rs 检测逻辑
+fn check_java_installation(path: &PathBuf) -> Option<JavaInstallation> {
+ let mut cmd = Command::new(path);
+ cmd.arg("-version");
+ // è§£æž stderr 输出中的 "version" 关键字信æ¯
+}
+```
+
+### 2.2 自动安装逻辑
+1. **下载**: æ”¯æŒæ–­ç‚¹ç»­ä¼ ï¼Œå¤§åž‹æ–‡ä»¶é€šè¿‡åˆ†ç‰‡å¹¶è¡Œä¸‹è½½ã€‚
+2. **解压**: 针对ä¸åŒæ“作系统处ç†åŽ‹ç¼©åŒ…ï¼ˆmacOS å¤„ç† .tar.gz 内部的 .app 结构,Windows å¤„ç† .zip)。
+3. **验è¯**: 下载åŽå¼ºåˆ¶è¿›è¡Œå“ˆå¸Œæ ¡éªŒï¼Œç¡®ä¿è¿è¡Œæ—¶çŽ¯å¢ƒçš„å®Œæ•´æ€§ã€‚
+
+### 2.3 Java 管ç†ç›¸å…³æŽ¥å£
+| 命令 / 事件 | 类型 | 说明 |
+| :--- | :--- | :--- |
+| `detect_java` | Invoke | 扫æç³»ç»Ÿå·²å®‰è£…çš„ Java 环境 |
+| `download_adoptium_java` | Invoke | å¯åŠ¨å¼‚æ­¥ä¸‹è½½å¹¶è‡ªåŠ¨è§£åŽ‹é…ç½® |
+| `cancel_java_download` | Invoke | é€šè¿‡åŽŸå­æ ‡å¿—ä½å–消当å‰çš„下载任务 |
+| `java-download-progress` | Event | 报告文件大å°ã€é€Ÿåº¦ã€ç™¾åˆ†æ¯”å’Œ ETA |
+
+---
+
+## 3. 游æˆå¯åŠ¨é€»è¾‘ä¸Ž JVM 优化
+
+### 3.1 å†…å­˜åˆ†é…æ–¹æ¡ˆ
+- **Xmx & Xms**: 建议将åˆå§‹å†…存与最大内存设为一致。
+- **ç­–ç•¥**: å¯åŠ¨å™¨ä¼šè‡ªåŠ¨æ ¹æ®ç³»ç»Ÿæ€»å†…存和 Java æž¶æž„æç¤ºç”¨æˆ·æœ€ä¼˜åˆ†é…,且å¯åŠ¨å™¨ç”Ÿæˆçš„傿•°å…·æœ‰æœ€é«˜ä¼˜å…ˆçº§ã€‚
+
+### 3.2 G1GC 优化策略
+针对 1.17+ 版本åŠé«˜è´Ÿè½½æ¨¡ç»„环境,DropOut 默认注入精简的 G1GC 傿•°é“¾ï¼š
+```bash
+-XX:+UseG1GC -XX:+ParallelRefProcEnabled -XX:MaxGCPauseMillis=200
+-XX:+UnlockExperimentalVMOptions -XX:+DisableExplicitGC
+```
+
+---
+
+## 4. 模组加载机制 (Mod Loaders)
+
+### 4.1 版本åˆå¹¶ (Inheritance)
+模组加载器通过 `inheritsFrom` 字段链接原版 Minecraft 版本 JSON。
+
+```json
+// 典型的 Fabric 版本é…ç½® (部分)
+{
+ "id": "fabric-loader-0.15.0-1.20.4",
+ "inheritsFrom": "1.20.4",
+ "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
+ "libraries": [
+ {
+ "name": "net.fabricmc:fabric-loader:0.15.0",
+ "url": "https://maven.fabricmc.net/"
+ }
+ ]
+}
+```
+
+å¯åŠ¨é˜¶æ®µæ‰§è¡Œä»¥ä¸‹åˆå¹¶ï¼š
+1. **库优先级**: 加载器自身的库(如 Fabric Loader)排在原版库之å‰ã€‚
+2. **主类覆盖**: `mainClass` 始终å–自å­çº§å®šä¹‰ï¼ˆå³æ¨¡ç»„加载器的入å£ï¼Œå¦‚ Fabric çš„ `KnotClient`)。
+3. **傿•°é“¾**: åˆå¹¶ `jvm` å’Œ `game` 傿•°æ•°ç»„,自动剔除ä¸é€‚用的系统规则。
+
+```rust
+// core/version_merge.rs åˆå¹¶æ ¸å¿ƒå®žçް
+pub fn merge_versions(child: GameVersion, parent: GameVersion) -> GameVersion {
+ let mut merged_libraries = child.libraries;
+ merged_libraries.extend(parent.libraries);
+ // åˆå¹¶å‚数逻辑并返回新的 GameVersion 实例
+}
+```
+
+### 4.2 åº“è·¯å¾„è§£æž (Maven)
+å¯åŠ¨å™¨å®žçŽ°äº†å®Œæ•´çš„ Maven 路径转æ¢é€»è¾‘:
+`group:artifact:version` → `group_path/artifact/version/artifact-version.jar`
+
+```rust
+// core/maven.rs åæ ‡è½¬æ¢è·¯å¾„逻辑
+pub fn to_path(&self) -> String {
+ let group_path = self.group.replace('.', "/");
+ format!("{}/{}/{}/{}-{}.{}",
+ group_path, self.artifact, self.version,
+ self.artifact, self.version, self.extension
+ )
+}
+```
+
+### 4.3 模组安装接å£
+å¯åŠ¨å™¨æ ¹æ®ä¸åŒåŠ è½½å™¨çš„ç‰¹æ€§é‡‡ç”¨å·®å¼‚åŒ–å®‰è£…ç­–ç•¥ã€‚
+
+#### Forge 安装逻辑
+对于 Forge,DropOut 会下载官方 Installer 并通过å­è¿›ç¨‹è°ƒç”¨ Java è¿è¡Œæ ‡å‡†å®‰è£…程åºï¼Œç¡®ä¿è¿™ä¸€è¿‡ç¨‹ä¸Žå®˜æ–¹æµç¨‹å®Œå…¨ä¸€è‡´ï¼ˆåŒ…括字节ç è¡¥ä¸ç”Ÿæˆï¼‰ã€‚
+
+```rust
+// core/forge.rs 调用官方安装器 (简化示例)
+Command::new(java_path)
+ .args(&[
+ "-jar",
+ installer_path.to_str().unwrap(),
+ "--installClient",
+ game_dir.to_str().unwrap()
+ ])
+ .output()?;
+```
+
+| 命令 / 事件 | 类型 | 说明 |
+| :--- | :--- | :--- |
+| `install_fabric` | Invoke | 下载 Fabric 库并生æˆç»§æ‰¿ JSON |
+| `install_forge` | Invoke | è¿è¡Œ Forge Installer å¹¶æ‰§è¡Œå­—èŠ‚ç æ‰“æ¡© |
+| `mod-loader-progress` | Event | 报告安装阶段(如 "processing", "complete") |
+
+---
+
+## 5. 通讯与监控系统
+
+### 5.1 事件总线 (Event Bus)
+åŽç«¯é€šè¿‡ Tauri çš„ `Window` 实例å‘上层å‘é€å¼‚步脉冲。
+
+```rust
+// åŽç«¯ emit_log! å®ç®€åŒ–日志外å‘
+macro_rules! emit_log {
+ ($window:expr, $msg:expr) => {
+ let _ = $window.emit("launcher-log", $msg);
+ println!("[Launcher] {}", $msg);
+ };
+}
+```
+
+- **`launcher-log`**: 通用的控制å°è¾“出æµï¼Œç”¨äºŽå…¨å±€æ“作轨迹记录。
+- **`game-stdout` / `game-stderr`**: æ•获游æˆå­è¿›ç¨‹çš„æ ‡å‡†è¾“出与错误,通过独立线程实时回传å‰ç«¯ã€‚
+
+```typescript
+// å‰ç«¯ç›‘å¬ç¤ºä¾‹ (Svelte 5)
+import { listen } from "@tauri-apps/api/event";
+
+const unlisten = await listen("launcher-log", (event) => {
+ logStore.addLine(event.payload);
+});
+```
+
+- **`version-installed`**: 异步任务完æˆåŽé€šçŸ¥ UI 更新版本列表状æ€ã€‚
+
+### 5.2 å¯åŠ¨æ—¥å¿—è„±æ•
+ä¸ºé˜²æ­¢æ•æ„Ÿä¿¡æ¯æ³„露至第三方日志平å°ï¼Œå¯åŠ¨å™¨åœ¨è®°å½•å¯åŠ¨æŒ‡ä»¤å‰ä¼šå¯¹ä»¤ç‰Œè¿›è¡Œé®è”½ã€‚
+
+```rust
+// 基于 main.rs çš„å‚æ•°é®è”½é€»è¾‘ (部分)
+let masked_args: Vec<String> = args.iter().enumerate().map(|(i, arg)| {
+ if i > 0 && (args[i-1] == "--accessToken" || args[i-1] == "--uuid") {
+ "***".to_string()
+ } else {
+ arg.clone()
+ }
+}).collect();
+```
+
+---
+
+## 6. 架构与开å‘规范
+
+### 6.1 核心架构模å¼
+
+#### åŽç«¯å‘½ä»¤æ¨¡å¼ (Tauri)
+所有åŽç«¯åŠŸèƒ½å‡å°è£…为异步 Command,并通过 `State` 注入全局状æ€ã€‚
+
+1. **定义命令**:
+```rust
+#[tauri::command]
+async fn start_game(
+ window: Window,
+ auth_state: State<'_, AccountState>,
+ config_state: State<'_, ConfigState>
+) -> Result<String, String> {
+ emit_log!(window, "Starting game launch...");
+ // 业务逻辑...
+ Ok("Success".into())
+}
+```
+
+2. **注册命令 (main.rs)**:
+```rust
+tauri::Builder::default()
+ .invoke_handler(tauri::generate_handler![start_game, ...])
+```
+
+3. **å‰ç«¯è°ƒç”¨**:
+```typescript
+import { invoke } from "@tauri-apps/api/core";
+// 傿•°å需与 Rust å‡½æ•°å‚æ•°ä¿æŒè›‡å½¢/驼峰对应
+const result = await invoke("start_game", { versionId: "1.20.4" });
+```
+
+#### é”™è¯¯å¤„ç†æ¨¡å¼
+所有 Tauri 命令统一返回 `Result<T, String>`,其中 Err 类型固定为 String,以便å‰ç«¯ç›´æŽ¥å±•示错误信æ¯ã€‚
+
+```rust
+// 统一使用 map_err 将错误转æ¢ä¸º String
+.await
+.map_err(|e| e.to_string())?;
+
+// å‰ç«¯æ•获
+try {
+ await invoke("...")
+} catch (e) {
+ // e å³ä¸º Rust 返回的错误字符串
+ console.error(e);
+}
+```
+
+#### å‰ç«¯çжæ€ç®¡ç† (Svelte 5 Runes)
+å‰ç«¯å…¨çº¿é‡‡ç”¨ Svelte 5 çš„ `Runes` 系统 (`$state`, `$effect`) 替代旧版的 `writable` stores。
+
+```typescript
+// ui/src/stores/auth.svelte.ts
+export class AuthState {
+ // 使用 $state 定义å“åº”å¼æ•°æ®
+ currentAccount = $state<Account | null>(null);
+ isLoginModalOpen = $state(false);
+
+ constructor() {
+ // åˆå§‹åŒ–逻辑
+ }
+}
+
+// 导出å•例用于全局访问
+export const authState = new AuthState();
+```
+
+#### å‰ç«¯è§†å›¾è·¯ç”± (Manual Routing)
+DropOut ä¸ä½¿ç”¨å¸¸è§„çš„ URL 路由,而是通过全局状æ€ç®¡ç†å½“剿¿€æ´»çš„视图组件。
+
+```typescript
+// ui/src/stores/ui.svelte.ts
+export class UIState {
+ activeView = $state("home"); // "home" | "versions" | "settings"
+
+ setView(view: string) {
+ this.activeView = view;
+ }
+}
+```
+
+```svelte
+<!-- App.svelte -->
+<script>
+ import { uiState } from "./stores/ui.svelte";
+</script>
+
+{#if uiState.activeView === "home"}
+ <HomeView />
+{:else if uiState.activeView === "settings"}
+ <SettingsView />
+{/if}
+```
+
+### 6.2 开呿œ€ä½³å®žè·µ
+1. **é™é»˜åˆ·æ–°**: 在调用 `start_game` 剿£€æŸ¥ `expires_at`,若过期则调用 `refresh_full_auth`。
+2. **UA 伪装**: å¾®è½¯è®¤è¯ WAF 会拦截空 UA 请求,务必在 `reqwest` 客户端中模拟真实 UA。
+3. **日志脱æ•**: å¯åŠ¨æ—¥å¿—ä¸­éœ€å±è”½ `--accessToken` å’Œ `--uuid` ç­‰æ•æ„Ÿå‚数值。
+
+### 6.3 未æ¥è·¯çº¿å›¾ (Roadmap)
+- **è´¦æˆ·å¤šç«¯åŒæ­¥**: 探索 OS 级加密存储方案以替代现有明文 `accounts.json`。
+- **自动ä¾èµ–è§£æž**: 安装模组时自动解æžå¹¶ä¸‹è½½ Modrinth/CurseForge 上的å‰ç½®åº“。
+- **æ™ºèƒ½å†²çªæ£€æµ‹**: å¯åЍ剿‰«æ `mods/` æ–‡ä»¶å¤¹ï¼Œè¯†åˆ«æ½œåœ¨çš„ç±»å†²çªæˆ–版本ä¸åŒ¹é…。
+- **é…置文件共享**: 支æŒä¸€é”®å¯¼å‡º/导入完整的实例é…置包(Modpack)。
diff --git a/packages/docs/content/zh/development.mdx b/packages/docs/content/zh/development/index.mdx
index 6ba5b1d..f562196 100644
--- a/packages/docs/content/zh/development.mdx
+++ b/packages/docs/content/zh/development/index.mdx
@@ -12,12 +12,14 @@ description: 从æºä»£ç æž„å»ºã€æµ‹è¯•和贡献 DropOut
### 必需软件
1. **Rust** (最新稳定版)
+
```bash
# 通过 rustup 安装
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
2. **Node.js** (v22+) 和 **pnpm** (v9+)
+
```bash
# 从 https://nodejs.org/ 安装 Node.js
# 安装 pnpm
@@ -29,6 +31,7 @@ description: 从æºä»£ç æž„å»ºã€æµ‹è¯•和贡献 DropOut
按照你的平å°å‚考 [Tauri å‰ç½®è¦æ±‚](https://v2.tauri.app/start/prerequisites/):
**Linux (Debian/Ubuntu):**
+
```bash
sudo apt update
sudo apt install libwebkit2gtk-4.1-dev \
@@ -42,6 +45,7 @@ description: 从æºä»£ç æž„å»ºã€æµ‹è¯•和贡献 DropOut
```
**macOS:**
+
```bash
xcode-select --install
```
@@ -62,6 +66,7 @@ cd DropOut
### 安装ä¾èµ–
**å‰ç«¯ä¾èµ–:**
+
```bash
cd packages/ui
pnpm install
@@ -69,6 +74,7 @@ cd ../..
```
**文档ä¾èµ–:**
+
```bash
cd packages/docs
pnpm install
@@ -84,6 +90,7 @@ cargo tauri dev
```
这将:
+
1. å¯åЍå‰ç«¯å¼€å‘æœåŠ¡å™¨ï¼ˆVite 在 5173 端å£ï¼‰
2. 编译 Rust åŽç«¯
3. 打开 Tauri 窗å£
@@ -91,6 +98,7 @@ cargo tauri dev
5. 在 Rust æ–‡ä»¶æ›´æ”¹æ—¶é‡æ–°ç¼–译
**终端输出:**
+
- æ¥è‡ª Vite çš„å‰ç«¯æ—¥å¿—
- Rust 标准输出/标准错误
- 编译状æ€
@@ -104,6 +112,7 @@ cargo tauri build
```
**输出ä½ç½®ï¼š**
+
- **Linux**: `src-tauri/target/release/bundle/`
- `.deb` 软件包
- `.AppImage` 包
@@ -152,22 +161,26 @@ DropOut/
### å‰ç«¯å¼€å‘
**å¯åЍ开呿œåŠ¡å™¨ï¼š**
+
```bash
cd packages/ui
pnpm dev
```
**类型检查:**
+
```bash
pnpm check
```
**ä»£ç æ£€æŸ¥ï¼š**
+
```bash
pnpm lint
```
**æ ¼å¼åŒ–代ç ï¼š**
+
```bash
pnpm format
```
@@ -175,21 +188,25 @@ pnpm format
### åŽç«¯å¼€å‘
**è¿è¡Œ Rust 测试:**
+
```bash
cargo test
```
**检查代ç ï¼š**
+
```bash
cargo check
```
**æ ¼å¼åŒ–代ç ï¼š**
+
```bash
cargo fmt
```
**ä»£ç æ£€æŸ¥ï¼š**
+
```bash
cargo clippy
```
@@ -197,17 +214,20 @@ cargo clippy
### 文档开å‘
**å¯åŠ¨æ–‡æ¡£å¼€å‘æœåŠ¡å™¨ï¼š**
+
```bash
cd packages/docs
pnpm dev
```
**构建文档:**
+
```bash
pnpm build
```
**类型检查:**
+
```bash
pnpm types:check
```
@@ -217,6 +237,7 @@ pnpm types:check
### Rust
éµå¾ªæ ‡å‡† Rust 约定:
+
- 使用 `cargo fmt` æ ¼å¼åŒ–
- 使用 `cargo clippy` 检查
- 编写文档注释 (`///`)
@@ -224,6 +245,7 @@ pnpm types:check
- 对 I/O 使用 async/await
**示例:**
+
```rust
/// Starts the Microsoft authentication device flow
#[tauri::command]
@@ -231,7 +253,7 @@ async fn start_microsoft_login(
window: Window,
) -> Result<DeviceCodeResponse, String> {
emit_log!(window, "Starting Microsoft login...");
-
+
start_device_flow()
.await
.map_err(|e| e.to_string())
@@ -241,20 +263,22 @@ async fn start_microsoft_login(
### TypeScript/Svelte
éµå¾ªé¡¹ç›®çº¦å®šï¼š
+
- 使用 Svelte 5 runes (`$state`, `$effect`)
- 优先使用 TypeScript è€Œéž JavaScript
- 使用 Biome 进行格å¼åŒ–和检查
- éµå¾ªç»„件结构
**示例:**
+
```typescript
// stores/auth.svelte.ts
export class AuthState {
currentAccount = $state<Account | null>(null);
isLoginModalOpen = $state(false);
-
+
async login(username: string) {
- const account = await invoke('offline_login', { username });
+ const account = await invoke("offline_login", { username });
this.currentAccount = account;
}
}
@@ -265,6 +289,7 @@ export class AuthState {
### å•元测试
**Rust:**
+
```rust
#[cfg(test)]
mod tests {
@@ -279,6 +304,7 @@ mod tests {
```
**è¿è¡Œï¼š**
+
```bash
cargo test
```
@@ -286,6 +312,7 @@ cargo test
### é›†æˆæµ‹è¯•
测试完整应用程åºï¼š
+
1. 以开呿¨¡å¼æž„建:`cargo tauri dev`
2. 手动测试功能
3. 检查控制å°é”™è¯¯
@@ -294,6 +321,7 @@ cargo test
### CI 测试
GitHub Actions 在以下平å°è¿è¡Œæµ‹è¯•:
+
- Ubuntu(最新版)
- Arch Linux(Wayland)
- Windows(最新版)
@@ -313,12 +341,14 @@ GitHub Actions 在以下平å°è¿è¡Œæµ‹è¯•:
### åŽç«¯è°ƒè¯•
**打å°è°ƒè¯•:**
+
```rust
emit_log!(window, format!("Debug: {}", value));
println!("Debug: {}", value);
```
**Rust 调试器:**
+
```bash
# 安装 rust-lldb 或 rust-gdb
cargo install rust-gdb
@@ -330,12 +360,14 @@ rust-gdb target/debug/dropout
### 日志
**å‰ç«¯ï¼š**
+
```typescript
console.log("Info message");
console.error("Error message");
```
**åŽç«¯ï¼š**
+
```rust
emit_log!(window, "Status update");
eprintln!("Error: {}", error);
@@ -367,6 +399,7 @@ eprintln!("Error: {}", error);
éµå¾ª [çº¦å®šå¼æäº¤](https://www.conventionalcommits.org/):
**æ ¼å¼ï¼š**
+
```
<type>[scope]: <description>
@@ -376,6 +409,7 @@ eprintln!("Error: {}", error);
```
**类型:**
+
- `feat`: 新功能
- `fix`: 错误修å¤
- `docs`: 文档
@@ -386,6 +420,7 @@ eprintln!("Error: {}", error);
- `chore`: 维护
**示例:**
+
```bash
feat(auth): add offline authentication support
fix(java): resolve detection on Windows
@@ -396,6 +431,7 @@ refactor(download): simplify progress tracking
### Pull Request 指å—
**æäº¤å‰ï¼š**
+
- [ ] 代ç éµå¾ªé£Žæ ¼æŒ‡å—
- [ ] 测试在本地通过
- [ ] å¿…è¦æ—¶æ›´æ–°æ–‡æ¡£
@@ -403,6 +439,7 @@ refactor(download): simplify progress tracking
- [ ] æäº¤æ¶ˆæ¯æ¸…æ™°
**PR æè¿°ï¼š**
+
- 解释åšäº†ä»€ä¹ˆä»¥åŠä¸ºä»€ä¹ˆ
- 链接相关 issue
- åˆ—å‡ºç ´åæ€§æ›´æ”¹
@@ -411,6 +448,7 @@ refactor(download): simplify progress tracking
### 代ç å®¡æŸ¥
维护者将审查你的 PR:
+
- 代ç è´¨é‡å’Œé£Žæ ¼
- 测试覆盖率
- 文档
@@ -424,6 +462,7 @@ refactor(download): simplify progress tracking
### 添加 Tauri 命令
1. **在 `main.rs` 中定义命令:**
+
```rust
#[tauri::command]
async fn my_command(param: String) -> Result<String, String> {
@@ -432,6 +471,7 @@ refactor(download): simplify progress tracking
```
2. **在构建器中注册:**
+
```rust
.invoke_handler(tauri::generate_handler![
my_command,
@@ -441,12 +481,13 @@ refactor(download): simplify progress tracking
3. **从å‰ç«¯è°ƒç”¨ï¼š**
```typescript
- const result = await invoke('my_command', { param: 'value' });
+ const result = await invoke("my_command", { param: "value" });
```
### 添加 UI 组件
1. **创建组件文件:**
+
```svelte
<!-- packages/ui/src/components/MyComponent.svelte -->
<script lang="ts">
@@ -459,6 +500,7 @@ refactor(download): simplify progress tracking
```
2. **导入并使用:**
+
```svelte
<script>
import MyComponent from './components/MyComponent.svelte';
@@ -470,20 +512,22 @@ refactor(download): simplify progress tracking
### 添加 Store
1. **创建 store 文件:**
+
```typescript
// packages/ui/src/stores/mystore.svelte.ts
export class MyState {
value = $state(0);
-
+
increment() {
this.value++;
}
}
-
+
export const myState = new MyState();
```
2. **在组件中使用:**
+
```svelte
<script>
import { myState } from '../stores/mystore.svelte';
@@ -499,18 +543,21 @@ refactor(download): simplify progress tracking
### 构建失败
**"cannot find -lwebkit2gtk"**
+
```bash
# 安装 WebKit ä¾èµ–
sudo apt install libwebkit2gtk-4.1-dev
```
**"pnpm not found"**
+
```bash
# 安装 pnpm
npm install -g pnpm@9
```
**"Rust version too old"**
+
```bash
# æ›´æ–° Rust
rustup update
@@ -519,15 +566,18 @@ rustup update
### è¿è¡Œæ—¶é—®é¢˜
**"Failed to load dynamic library"**
+
- 釿–°æž„建:`cargo clean && cargo tauri dev`
- 检查库路径
- 验è¯å·²å®‰è£…ä¾èµ–
**"CORS error"**
+
- 开呿¨¡å¼ä¸‹æ­£å¸¸
- Tauri è‡ªåŠ¨å¤„ç† CORS
**"Hot reload not working"**
+
- 检查 Vite é…ç½®
- é‡å¯å¼€å‘æœåС噍
- 清除æµè§ˆå™¨ç¼“å­˜
diff --git a/packages/docs/content/zh/development/meta.json b/packages/docs/content/zh/development/meta.json
new file mode 100644
index 0000000..69cc009
--- /dev/null
+++ b/packages/docs/content/zh/development/meta.json
@@ -0,0 +1,8 @@
+{
+ "title": "开呿–‡æ¡£",
+ "pages": [
+ "index",
+ "architecture",
+ "implementation"
+ ]
+}
diff --git a/packages/docs/content/zh/features/authentication.mdx b/packages/docs/content/zh/features/authentication.mdx
deleted file mode 100644
index e83cc35..0000000
--- a/packages/docs/content/zh/features/authentication.mdx
+++ /dev/null
@@ -1,266 +0,0 @@
----
-title: 身份验è¯
-description: DropOut 中的 Microsoft OAuth 和离线身份验è¯
----
-
-# 身份验è¯
-
-DropOut 支æŒä¸¤ç§èº«ä»½éªŒè¯æ–¹æ³•:Microsoft 账户(用于官方 Minecraft)和离线模å¼ï¼ˆç”¨äºŽæµ‹è¯•和离线游玩)。
-
-## Microsoft 身份验è¯
-
-### 概述
-
-DropOut 使用 **Device Code Flow** 进行 Microsoft 身份验è¯ï¼Œå…·æœ‰ä»¥ä¸‹ç‰¹ç‚¹ï¼š
-- 无需é‡å®šå‘ URL(无需æµè§ˆå™¨é›†æˆï¼‰
-- 适用于任何拥有æµè§ˆå™¨çš„设备
-- æä¾›ç®€å•的基于代ç çš„身份验è¯
-- å®Œå…¨ç¬¦åˆ Microsoft OAuth 2.0 标准
-
-### èº«ä»½éªŒè¯æµç¨‹
-
-身份验è¯é“¾åŒ…å«å¤šä¸ªæ­¥éª¤ï¼š
-
-1. **Device Code** → 用户授æƒ
-2. **MS Token** → 访问令牌 + 刷新令牌
-3. **Xbox Live** → Xbox 令牌 + UHS
-4. **XSTS** → 安全令牌
-5. **Minecraft** → 游æˆè®¿é—®ä»¤ç‰Œ
-6. **Profile** → 用户å + UUID
-
-#### 第 1 步:设备代ç è¯·æ±‚
-1. å•击"使用 Microsoft 登录"
-2. DropOut 从 Microsoft 请求设备代ç 
-3. 您会收到:
- - 用户代ç ï¼ˆä¾‹å¦‚ `A1B2-C3D4`)
- - éªŒè¯ URL(通常为 `https://microsoft.com/link`)
- - 设备代ç ï¼ˆå†…部使用)
-
-#### 第 2 步:用户授æƒ
-1. 在任何æµè§ˆå™¨ä¸­è®¿é—®éªŒè¯ URL
-2. 输入用户代ç 
-3. 使用 Microsoft 账户登录
-4. æŽˆæƒ DropOut 访问您的 Minecraft 个人资料
-
-#### 第 3 步:令牌交æ¢
-- DropOut 轮询 Microsoft 检查授æƒå®Œæˆ
-- 授æƒåŽæŽ¥æ”¶è®¿é—®ä»¤ç‰Œå’Œåˆ·æ–°ä»¤ç‰Œ
-- 刷新令牌被存储以备将æ¥ç™»å½•使用
-
-#### 第 4 步:Xbox Live 身份验è¯
-- Microsoft 令牌被交æ¢ä¸º Xbox Live 令牌
-- 检索用户哈希 (UHS) 用于下一步
-
-#### 第 5 步:XSTS 授æƒ
-- Xbox Live 令牌被用æ¥èŽ·å– XSTS 令牌
-- 此令牌特定于 Minecraft æœåŠ¡
-
-#### 第 6 步:Minecraft 登录
-- XSTS 令牌被交æ¢ä¸º Minecraft 访问令牌
-- 使用端点:`/launcher/login`
-
-#### 第 7 步:个人资料获å–
-- 检索您的 Minecraft 用户å
-- èŽ·å–æ‚¨çš„ UUID
-- æ£€æŸ¥æ‚¨æ˜¯å¦æ‹¥æœ‰ Minecraft
-
-### 令牌管ç†
-
-**访问令牌:**
-- 短期有效(通常为 1 å°æ—¶ï¼‰
-- 用于游æˆèº«ä»½éªŒè¯
-- 过期时自动刷新
-
-**刷新令牌:**
-- 长期有效(通常为 90 天)
-- 安全存储在 `accounts.json` 中
-- ç”¨äºŽèŽ·å–æ–°çš„访问令牌
-
-**自动刷新:**
-```rust
-// Automatic refresh when token expires
-if account.expires_at < current_time {
- refresh_full_auth(&account).await?;
-}
-```
-
-### 安全考虑
-
-- 令牌存储在平å°ç‰¹å®šçš„应用数æ®ç›®å½•中
-- 所有 API 调用仅使用 HTTPS
-- ä¸å­˜å‚¨å‡­æ®ï¼ˆä»…存储令牌)
-- éœ€è¦ User-Agent 标头(绕过 MS WAF)
-
-### Microsoft 登录故障排除
-
-**"Device code expired"(设备代ç å·²è¿‡æœŸï¼‰**
-- 代ç åœ¨ 15 分钟åŽè¿‡æœŸ
-- 釿–°å¼€å§‹ç™»å½•æµç¨‹
-
-**"Authorization pending"(授æƒå¾…处ç†ï¼‰**
-- 在等待阶段很正常
-- 在æµè§ˆå™¨ä¸­å®ŒæˆæŽˆæƒ
-
-**"Invalid token"(无效令牌)**
-- 令牌å¯èƒ½å·²è¿‡æœŸ
-- 登出åŽé‡æ–°ç™»å½•
-
-**"You don't own Minecraft"ï¼ˆæ‚¨ä¸æ‹¥æœ‰ Minecraft)**
-- éªŒè¯æ‚¨çš„ Microsoft 账户拥有 Minecraft Java Edition
-- 在 https://www.minecraft.net/profile 检查
-
-## 离线身份验è¯
-
-### 概述
-
-离线模å¼åˆ›å»ºä¸€ä¸ªä¸éœ€è¦äº’è”网连接或 Microsoft 账户的本地账户。这对以下情况很有用:
-- 测试和开å‘
-- 无网络游玩
-- LAN 多人游æˆ
-- Mod å¼€å‘
-
-### 创建离线账户
-
-1. 在登录å±å¹•å•击"离线模å¼"
-2. 输入用户å(3-16 个字符)
-3. å•击"创建账户"
-
-### 工作原ç†
-
-**UUID 生æˆï¼š**
-```rust
-// Deterministic UUID v3 from username
-let uuid = generate_offline_uuid(&username);
-```
-
-- 使用 UUID v3(基于命å空间)
-- 确定性:相åŒçš„用户å = 相åŒçš„ UUID
-- 无需网络请求
-
-**身份验è¯ï¼š**
-- 返回 `"null"` 作为访问令牌
-- Minecraft 在离线模å¼ä¸‹æŽ¥å—空令牌
-- 用户åå’Œ UUID 本地存储
-
-### é™åˆ¶
-
-- 无法加入在线æœåС噍
-- 䏿”¯æŒçš®è‚¤
-- 䏿”¯æŒæŠ«é£Ž
-- 无法使用 Microsoft 账户功能
-
-### 用例
-
-**å¼€å‘:**
-```bash
-# Testing mod development
-cargo tauri dev
-# Use offline mode to test quickly
-```
-
-**LAN 游玩:**
-- 无需身份验è¯å³å¯åŠ å…¥ LAN 世界
-- 托管 LAN 世界
-
-**离线游玩:**
-- å•äººæ¸¸æˆæ— éœ€ç½‘络
-- 无需身份验è¯
-
-## 账户管ç†
-
-### 切æ¢è´¦æˆ·
-
-ç›®å‰ DropOut ä¸€æ¬¡åªæ”¯æŒä¸€ä¸ªæ´»è·ƒè´¦æˆ·ã€‚å¤šè´¦æˆ·æ”¯æŒæ­£åœ¨è§„划中。
-
-**切æ¢è´¦æˆ·çš„æ­¥éª¤ï¼š**
-1. 登出当å‰è´¦æˆ·
-2. 使用新账户登录
-
-### 账户存储
-
-账户存储在 `accounts.json` 中:
-
-```json
-{
- "current_account_id": "uuid-here",
- "accounts": [
- {
- "id": "uuid",
- "type": "Microsoft",
- "username": "PlayerName",
- "access_token": "...",
- "refresh_token": "...",
- "expires_at": 1234567890
- }
- ]
-}
-```
-
-### 删除账户
-
-删除账户的步骤:
-1. 打开设置
-2. 导航到账户
-3. å•击"登出"
-4. 或手动删除 `accounts.json`
-
-## API å‚考
-
-### Tauri 命令
-
-**å¯åЍ Microsoft 登录:**
-```typescript
-const { user_code, verification_uri } = await invoke('start_microsoft_login');
-```
-
-**å®Œæˆ Microsoft 登录:**
-```typescript
-const account = await invoke('complete_microsoft_login', { deviceCode });
-```
-
-**离线登录:**
-```typescript
-const account = await invoke('offline_login', { username: 'Player' });
-```
-
-**登出:**
-```typescript
-await invoke('logout');
-```
-
-**获å–当å‰è´¦æˆ·ï¼š**
-```typescript
-const account = await invoke('get_current_account');
-```
-
-### 事件
-
-**身份验è¯çжæ€ï¼š**
-```typescript
-listen('auth-status', (event) => {
- console.log(event.payload); // "logged_in" | "logged_out"
-});
-```
-
-## 最佳实践
-
-### 对于玩家
-
-1. **对官方æœåŠ¡å™¨ä½¿ç”¨ Microsoft 账户**
-2. **ä¿æŠ¤ä»¤ç‰Œå®‰å…¨** - ä¸è¦åˆ†äº« accounts.json
-3. **定期刷新令牌** - 通过登录æ¥åˆ·æ–°
-4. **仅在测试时使用离线模å¼**
-
-### 对于开å‘者
-
-1. **优雅地处ç†ä»¤ç‰Œè¿‡æœŸ**
-2. **为网络故障实现é‡è¯•逻辑**
-3. **缓存账户数æ®** 以å‡å°‘ API 调用
-4. **在游æˆå¯åЍå‰éªŒè¯ä»¤ç‰Œ**
-
-## 未æ¥å¢žå¼º
-
-- **多账户支æŒ**:轻æ¾åœ¨è´¦æˆ·ä¹‹é—´åˆ‡æ¢
-- **账户é…置文件**:ä¿å­˜æ¯ä¸ªè´¦æˆ·çš„设置
-- **自动登录**ï¼šè®°ä½æœ€åŽä¸€ä¸ªè´¦æˆ·
-- **令牌加密**:为存储的令牌增强安全性
diff --git a/packages/docs/content/zh/manual/features/authentication.mdx b/packages/docs/content/zh/manual/features/authentication.mdx
new file mode 100644
index 0000000..cd5b622
--- /dev/null
+++ b/packages/docs/content/zh/manual/features/authentication.mdx
@@ -0,0 +1,131 @@
+---
+title: 身份验è¯
+description: DropOut 中的 Microsoft OAuth 和离线身份验è¯
+---
+
+# 身份验è¯
+
+DropOut 支æŒä¸¤ç§èº«ä»½éªŒè¯æ–¹æ³•:Microsoft 账户(用于官方 Minecraft)和离线模å¼ï¼ˆç”¨äºŽæµ‹è¯•和离线游玩)。
+
+## Microsoft 身份验è¯
+
+### 概述
+
+DropOut 使用 **Device Code Flow** 进行 Microsoft 身份验è¯ï¼Œå…·æœ‰ä»¥ä¸‹ç‰¹ç‚¹ï¼š
+- 无需é‡å®šå‘ URL(无需æµè§ˆå™¨é›†æˆï¼‰
+- 适用于任何拥有æµè§ˆå™¨çš„设备
+- æä¾›ç®€å•的基于代ç çš„身份验è¯
+- å®Œå…¨ç¬¦åˆ Microsoft OAuth 2.0 标准
+
+### èº«ä»½éªŒè¯æµç¨‹
+
+身份验è¯é“¾åŒ…å«å¤šä¸ªæ­¥éª¤ã€‚DropOut 自动处ç†è¿™äº›å¤æ‚的交æ¢è¿‡ç¨‹ï¼ŒåŒ…括与 Microsoftã€Xbox Live å’Œ Minecraft æœåŠ¡çš„äº¤äº’ã€‚å¦‚æžœæ‚¨å¯¹è¯¦ç»†çš„ API 实现感兴趣,请å‚阅[内部实现](/docs/development/implementation#1-身份验è¯ç³»ç»Ÿ-authentication)。
+
+### 令牌管ç†
+
+**访问令牌:**
+- 短期有效(通常为 1 å°æ—¶ï¼‰
+- 用于游æˆèº«ä»½éªŒè¯
+- 过期时自动刷新
+
+**刷新令牌:**
+- 长期有效(通常为 90 天)
+- 安全存储在 `accounts.json` 中
+- ç”¨äºŽèŽ·å–æ–°çš„访问令牌
+
+**自动刷新:**
+令牌过期时,DropOut 会在您å¯åŠ¨æ¸¸æˆæ—¶å°è¯•使用刷新令牌自动更新您的登录状æ€ï¼Œç¡®ä¿æ‚¨å¯ä»¥æ— ç¼å¼€å§‹æ¸¸çŽ©ã€‚
+
+### 安全考虑
+
+- 令牌存储在平å°ç‰¹å®šçš„应用数æ®ç›®å½•中
+- 所有 API 调用仅使用 HTTPS
+- ä¸å­˜å‚¨å‡­æ®ï¼ˆä»…存储令牌)
+- éœ€è¦ User-Agent 标头(绕过 MS WAF)
+
+### Microsoft 登录故障排除
+
+**"Device code expired"(设备代ç å·²è¿‡æœŸï¼‰**
+- 代ç åœ¨ 15 分钟åŽè¿‡æœŸ
+- 釿–°å¼€å§‹ç™»å½•æµç¨‹
+
+**"Authorization pending"(授æƒå¾…处ç†ï¼‰**
+- 在等待阶段很正常
+- 在æµè§ˆå™¨ä¸­å®ŒæˆæŽˆæƒ
+
+**"Invalid token"(无效令牌)**
+- 令牌å¯èƒ½å·²è¿‡æœŸ
+- 登出åŽé‡æ–°ç™»å½•
+
+**"You don't own Minecraft"ï¼ˆæ‚¨ä¸æ‹¥æœ‰ Minecraft)**
+- éªŒè¯æ‚¨çš„ Microsoft 账户拥有 Minecraft Java Edition
+- 在 https://www.minecraft.net/profile 检查
+
+## 离线身份验è¯
+
+### 概述
+
+离线模å¼åˆ›å»ºä¸€ä¸ªä¸éœ€è¦äº’è”网连接或 Microsoft 账户的本地账户。这对以下情况很有用:
+- 测试和开å‘
+- 无网络游玩
+- LAN 多人游æˆ
+- Mod å¼€å‘
+
+### 创建离线账户
+
+1. 在登录å±å¹•å•击"离线模å¼"
+2. 输入用户å(3-16 个字符)
+3. å•击"创建账户"
+
+### 工作原ç†
+
+**UUID 生æˆï¼š**
+离线模å¼ä½¿ç”¨åŸºäºŽç”¨æˆ·å的确定性 UUID 生æˆç®—法(UUID v3)。这æ„味ç€åœ¨åŒä¸€ä¸ªå¯åŠ¨å™¨å®žä¾‹ä¸­ï¼Œç›¸åŒçš„用户å始终会获得相åŒçš„ UUIDï¼Œä»Žè€Œä¿æŒå•人游æˆå­˜æ¡£çš„一致性。
+
+- 确定性:相åŒçš„用户å = 相åŒçš„ UUID
+- 无需网络请求
+
+**身份验è¯ï¼š**
+- 返回 `"null"` 作为访问令牌
+- Minecraft 在离线模å¼ä¸‹æŽ¥å—空令牌
+- 用户åå’Œ UUID 本地存储
+
+### é™åˆ¶
+
+- 无法加入在线æœåС噍
+- 䏿”¯æŒçš®è‚¤
+- 䏿”¯æŒæŠ«é£Ž
+- 无法使用 Microsoft 账户功能
+
+## 账户管ç†
+
+### 切æ¢è´¦æˆ·
+
+ç›®å‰ DropOut ä¸€æ¬¡åªæ”¯æŒä¸€ä¸ªæ´»è·ƒè´¦æˆ·ã€‚å¤šè´¦æˆ·æ”¯æŒæ­£åœ¨è§„划中。
+
+**切æ¢è´¦æˆ·çš„æ­¥éª¤ï¼š**
+1. 登出当å‰è´¦æˆ·
+2. 使用新账户登录
+
+### 账户存储
+
+账户数æ®å­˜å‚¨åœ¨åº”用文件夹的 `accounts.json` 中。该文件包å«å·²ç™»å½•账户的加密令牌ã€è¿‡æœŸæ—¶é—´å’ŒåŸºæœ¬çš„个人资料信æ¯ã€‚
+
+### 删除账户
+
+删除账户的步骤:
+1. 打开设置
+2. 导航到账户
+3. å•击"登出"
+4. 或手动删除 `accounts.json`
+
+## API å‚考
+
+关于身份验è¯çš„底层实现ã€OAuth 2.0 æµç¨‹ç»†èЂ以åŠç›¸å…³çš„ Tauri 命令接å£ï¼Œè¯·å‚è€ƒå¼€å‘æ–‡æ¡£ï¼š[实现细节:身份验è¯](../development/implementation.mdx#1-身份验è¯ç³»ç»Ÿ-authentication)。
+
+## 最佳实践
+
+1. **对官方æœåŠ¡å™¨ä½¿ç”¨ Microsoft 账户**:为了能够加入官方æœåŠ¡å™¨å¹¶ä½¿ç”¨æ­£ç‰ˆçš®è‚¤ï¼Œè¯·åŠ¡å¿…ä½¿ç”¨ Microsoft 账户。
+2. **ä¿æŠ¤ä»¤ç‰Œå®‰å…¨**:ä¸è¦å‘他人分享 `accounts.json` æ–‡ä»¶æˆ–å…¶å†…å®¹ï¼Œå› ä¸ºå…¶ä¸­åŒ…å«æ‚¨çš„登录凭æ®ã€‚
+3. **定期刷新令牌**:长时间未使用的离线账户或过期的 Microsoft 令牌å¯ä»¥é€šè¿‡é‡æ–°ç™»å½•或å¯åŠ¨æ¸¸æˆæ¥åˆ·æ–°ã€‚
+4. **仅在测试时使用离线模å¼**:离线模å¼ä¸æ”¯æŒçš®è‚¤å’Œéƒ¨åˆ†å¤šäººæ¸¸æˆåŠŸèƒ½ã€‚
diff --git a/packages/docs/content/zh/features/index.mdx b/packages/docs/content/zh/manual/features/index.mdx
index bb53ce2..bb53ce2 100644
--- a/packages/docs/content/zh/features/index.mdx
+++ b/packages/docs/content/zh/manual/features/index.mdx
diff --git a/packages/docs/content/zh/features/java.mdx b/packages/docs/content/zh/manual/features/java.mdx
index bdc3c15..6894ec1 100644
--- a/packages/docs/content/zh/features/java.mdx
+++ b/packages/docs/content/zh/manual/features/java.mdx
@@ -84,23 +84,7 @@ DropOut 集æˆäº† Eclipse Adoptium API 以下载高质é‡çš„å…è´¹ JDK/JRE æž„å»
### 目录管ç†
-Java 目录缓存 24 å°æ—¶ä»¥æé«˜æ€§èƒ½ï¼š
-
-```rust
-// 目录结构
-{
- "versions": [
- {
- "version": "17.0.9+9",
- "major": 17,
- "url": "https://api.adoptium.net/...",
- "sha256": "...",
- "size": 123456789
- }
- ],
- "last_updated": 1234567890
-}
-```
+DropOut 会缓存å¯ç”¨çš„ Java 版本列表,以加快加载速度,并自动处ç†ä¸åŒæ­¥æ—¶çš„刷新工作。
**刷新:**
- 24 å°æ—¶åŽè‡ªåŠ¨åˆ·æ–°
@@ -166,22 +150,14 @@ java/
### 自定义 JVM 傿•°
-为高级é…置添加自定义 JVM 傿•°ï¼š
-
-**å¸¸ç”¨å‚æ•°ï¼š**
-```bash
-# 垃圾回收
--XX:+UseG1GC
--XX:+UnlockExperimentalVMOptions
+为高级é…置添加自定义 JVM 傿•°ã€‚DropOut 为您æä¾›äº†ç»è¿‡ä¼˜åŒ–的默认é…置,通常无需手动更改。
-# 性能
--XX:G1NewSizePercent=20
--XX:G1ReservePercent=20
--XX:MaxGCPauseMillis=50
+**傿•°è¯´æ˜Žï¼š**
+- **垃圾回收 (GC)**: 默认使用 G1GC,能够大幅å‡å°‘大规模模组环境下的游æˆå¡é¡¿ã€‚
+- **实验性选项**: éƒ¨åˆ†æ€§èƒ½ä¼˜åŒ–å‚æ•°éœ€è¦å¼€å¯æ­¤å¼€å…³æ‰èƒ½ç”Ÿæ•ˆã€‚
+- **内存分å—**: 优化大内存环境下的数æ®è¯»å†™æ•ˆçŽ‡ã€‚
-# 内存
--XX:G1HeapRegionSize=32M
-```
+关于æ¯ä¸ªå‚æ•°çš„å…·ä½“ä½œç”¨ã€æŽ¨èæ•°å€¼ä»¥åŠæŠ€æœ¯å®žçŽ°ï¼Œè¯·å‚考 [实现细节:JVM 傿•°ä¼˜åŒ–](../development/implementation.mdx#傿•°ä¼˜åŒ–与-jvm-é…ç½®)。
### Java 路径选择
@@ -276,56 +252,6 @@ java/
4. 如兼容使用更新的 Java 版本
5. 为整åˆåŒ…åˆ†é… 4-8GB
-## API å‚考
-
-### Tauri 命令
-
-**检测 Java 安装:**
-```typescript
-const javas = await invoke('detect_java_installations');
-// 返回: Array<{ path: string, version: string, major: number }>
-```
-
-**èŽ·å– Java 目录:**
-```typescript
-const catalog = await invoke('get_java_catalog');
-// 返回: { versions: Array<JavaVersion>, last_updated: number }
-```
-
-**下载 Java:**
-```typescript
-await invoke('download_java', {
- version: '17.0.9+9',
- variant: 'jdk' // 或 'jre'
-});
-```
-
-**å–æ¶ˆ Java 下载:**
-```typescript
-await invoke('cancel_java_download');
-```
-
-**设置 Java 路径:**
-```typescript
-await invoke('set_java_path', { path: '/path/to/java' });
-```
-
-### 事件
-
-**下载进度:**
-```typescript
-listen('java-download-progress', (event) => {
- const { percent, speed, eta } = event.payload;
-});
-```
-
-**下载完æˆï¼š**
-```typescript
-listen('java-download-complete', (event) => {
- const { path, version } = event.payload;
-});
-```
-
## 最佳实践
### 对于玩家
@@ -336,14 +262,6 @@ listen('java-download-complete', (event) => {
4. **ä¿æŒ Java æ›´æ–°** - 安全性和性能
5. **使用 64 ä½ Java** - 大内存所需
-### 对于开å‘者
-
-1. **测试多个 Java 版本** - ç¡®ä¿å…¼å®¹æ€§
-2. **记录 Java è¦æ±‚** - 帮助用户
-3. **处ç†ç¼ºå°‘çš„ Java** - 优雅的åŽå¤‡æ–¹æ¡ˆ
-4. **å¯åЍå‰éªŒè¯ Java 路径**
-5. **æä¾›æ¸…晰的错误** - 当 Java 错误时
-
## 高级主题
### 自定义 Java 安装
@@ -357,32 +275,6 @@ listen('java-download-complete', (event) => {
5. æµè§ˆåˆ° Java 坿‰§è¡Œæ–‡ä»¶
6. 验è¯ç‰ˆæœ¬æ­£ç¡®
-### æœåŠ¡å™¨ç”¨ Java
-
-è¿è¡Œ Minecraft æœåŠ¡å™¨æ—¶ï¼š
-
-```bash
-# 推èçš„æœåС噍 JVM 傿•°
--Xms4G -Xmx4G \
--XX:+UseG1GC \
--XX:+ParallelRefProcEnabled \
--XX:MaxGCPauseMillis=200 \
--XX:+UnlockExperimentalVMOptions \
--XX:+DisableExplicitGC \
--XX:G1NewSizePercent=30 \
--XX:G1MaxNewSizePercent=40 \
--XX:G1HeapRegionSize=8M \
--XX:G1ReservePercent=20 \
--XX:G1HeapWastePercent=5 \
--XX:G1MixedGCCountTarget=4 \
--XX:InitiatingHeapOccupancyPercent=15 \
--XX:G1MixedGCLiveThresholdPercent=90 \
--XX:G1RSetUpdatingPauseTimePercent=5 \
--XX:SurvivorRatio=32 \
--XX:+PerfDisableSharedMem \
--XX:MaxTenuringThreshold=1
-```
-
### GraalVM
GraalVM 支æŒé«˜çº§ç”¨æˆ·ï¼š
diff --git a/packages/docs/content/zh/features/meta.json b/packages/docs/content/zh/manual/features/meta.json
index 2fb2ded..2fb2ded 100644
--- a/packages/docs/content/zh/features/meta.json
+++ b/packages/docs/content/zh/manual/features/meta.json
diff --git a/packages/docs/content/zh/features/mod-loaders.mdx b/packages/docs/content/zh/manual/features/mod-loaders.mdx
index 3687230..cbd8148 100644
--- a/packages/docs/content/zh/features/mod-loaders.mdx
+++ b/packages/docs/content/zh/manual/features/mod-loaders.mdx
@@ -28,31 +28,7 @@ Fabric 是一个轻é‡çº§ã€æ¨¡å—化的模组工具链,专注于:
### 工作原ç†
-**Meta API 集æˆï¼š**
-```rust
-// Fetch available Fabric versions
-let url = format!(
- "https://meta.fabricmc.net/v2/versions/loader/{}",
- minecraft_version
-);
-```
-
-**é…置文件生æˆï¼š**
-1. èŽ·å– Fabric 加载器元数æ®
-2. 下载 Fabric 库
-3. 使用 `inheritsFrom` 生æˆç‰ˆæœ¬ JSON
-4. 与父级 Minecraft 版本åˆå¹¶
-5. 添加至版本列表
-
-**版本格å¼ï¼š**
-```json
-{
- "id": "fabric-loader-0.15.0-1.20.4",
- "inheritsFrom": "1.20.4",
- "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
- "libraries": [...]
-}
-```
+DropOut 集æˆäº† Fabric çš„ Meta API,能够自动获å–兼容的加载器版本。在åŽå°ï¼Œå®ƒä¼šç”Ÿæˆç‰ˆæœ¬ JSON,处ç†åº“ä¾èµ–,并利用继承系统确ä¿ä¸ŽåŽŸç‰ˆ Minecraft 的完美兼容。详细的 JSON 结构å¯å‚阅[技术细节](/docs/development/implementation#fabric-集æˆ)。
### Fabric 版本
@@ -68,26 +44,7 @@ let url = format!(
### 库管ç†
-Fabric 库从 Maven è§£æžï¼š
-
-**主库:**
-```
-net.fabricmc:fabric-loader:0.15.0
-```
-
-**ä¾èµ–项:**
-- `net.fabricmc:tiny-mappings-parser`
-- `net.fabricmc:sponge-mixin`
-- `net.fabricmc:access-widener`
-
-**下载:**
-```rust
-// Maven resolution
-let url = format!(
- "https://maven.fabricmc.net/{}/{}",
- artifact_path, filename
-);
-```
+Fabric çš„ä¾èµ–项通常托管在其官方 Maven 仓库中。DropOut 自动解æžå¹¶ä¸‹è½½æ‰€æœ‰å¿…需的库文件。
### Fabric API
@@ -121,34 +78,7 @@ Forge æ˜¯åŽŸå§‹çš„ã€æœ€æµè¡Œçš„ Minecraft 模组加载器:
### 工作原ç†
-**Forge 安装程åºï¼š**
-```rust
-// Download Forge installer
-let installer_url = format!(
- "https://maven.minecraftforge.net/net/minecraftforge/forge/{}/forge-{}-installer.jar",
- full_version, full_version
-);
-
-// Run installer
-java -jar forge-installer.jar --installClient
-```
-
-**é…置文件解æžï¼š**
-1. Forge 安装程åºåˆ›å»ºç‰ˆæœ¬ JSON
-2. DropOut è§£æžå®‰è£…é…置文件
-3. æå–库ä¾èµ–项
-4. 处ç†å¤„ç†å™¨ï¼ˆå¦‚果有)
-5. 生æˆå¯åЍ噍é…置文件
-
-**版本格å¼ï¼š**
-```json
-{
- "id": "1.20.4-forge-49.0.26",
- "inheritsFrom": "1.20.4",
- "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher",
- "libraries": [...]
-}
-```
+DropOut 会为您下载 Forge 安装程åºã€‚通过在åŽå°ä»¥æ— å¤´æ¨¡å¼è¿è¡Œå®‰è£…程åºï¼Œæˆ‘ä»¬èƒ½å¤Ÿç”Ÿæˆæ‰€éœ€çš„版本é…置文件并处ç†å¤æ‚的补ä¸ç¨‹åºã€‚详细实现请å‚考[技术实现](/docs/development/implementation#forge-支æŒ)。
### Forge 版本
@@ -164,22 +94,9 @@ java -jar forge-installer.jar --installClient
### 库管ç†
-Forge 有许多库:
+Forge çš„è¿è¡Œä¾èµ–于大é‡çš„库文件,包括底层的 `fmlloader` å’Œå­—èŠ‚ç æ“作库。DropOut 能够自动解æžå¤æ‚çš„ä¾èµ–树,并从 Forge 官方 Maven ä»“åº“ä»¥åŠ Minecraft 官方库中获å–这些文件。
-**核心库:**
-- `net.minecraftforge:forge:<version>`
-- `net.minecraftforge:fmlloader:<version>`
-- `org.ow2.asm:asm:<version>`
-
-**è§£æžï¼š**
-```rust
-// Forge Maven
-"https://maven.minecraftforge.net/"
-
-// Dependencies may use:
-// - Maven Central
-// - Minecraft Libraries
-```
+关于 Forge 库的详细解æžé€»è¾‘,请å‚考[实现细节:Forge 核心库解æž](../development/implementation.mdx#核心库解æž)。
### Forge 处ç†å™¨
@@ -192,44 +109,14 @@ DropOut 自动处ç†è¿™äº›æ“作。
## 版本继承
-Fabric 和 Forge 都使用 Minecraft 的继承系统:
-
-### 父版本
-
-```json
-{
- "id": "fabric-loader-0.15.0-1.20.4",
- "inheritsFrom": "1.20.4" // Parent vanilla version
-}
-```
-
-### åˆå¹¶è¿‡ç¨‹
-
-**库:**
-```rust
-// Merged from both
-parent_libraries + modded_libraries
-// Duplicates removed
-```
-
-**傿•°ï¼š**
-```rust
-// Combined
-parent_jvm_args + modded_jvm_args
-parent_game_args + modded_game_args
-```
-
-**资æºï¼š**
-```rust
-// Inherited from parent
-assets = parent.assets
-```
-
-**主类:**
-```rust
-// Overridden by modded
-main_class = modded.mainClass
-```
+Fabric å’Œ Forge 都利用了 Minecraft çš„ç‰ˆæœ¬ç»§æ‰¿æœºåˆ¶ã€‚åœ¨è¿™ç§æ¨¡å¼ä¸‹ï¼Œæ¨¡ç»„加载器的版本 JSON 仅包å«ç›¸å¯¹äºŽåŽŸç‰ˆç‰ˆæœ¬çš„å¢žé‡å˜åŒ–,通过 `inheritsFrom` 字段递归å‘上寻å€ã€‚
+
+DropOut çš„å¯åŠ¨å¼•æ“Žèƒ½å¤Ÿè‡ªåŠ¨å¤„ç†è¿™ç§å¤æ‚çš„åˆå¹¶ï¼š
+- **库 (Libraries)**:自动排é‡å¹¶ç¡®ä¿åŠ è½½é¡ºåºã€‚
+- **傿•° (Arguments)**:åˆå¹¶æ¸¸æˆå‚数与 JVM 傿•°ã€‚
+- **主类 (Main Class)**:自动切æ¢è‡³æ¨¡ç»„加载器的入å£ç‚¹ã€‚
+
+具体的åˆå¹¶é€»è¾‘和代ç å®žçŽ°è¯·æŸ¥çœ‹[实现细节:版本åˆå¹¶](../development/implementation.mdx#版本åˆå¹¶æœºåˆ¶)。
## 对比
@@ -334,76 +221,22 @@ main_class = modded.mainClass
## API å‚考
-### Tauri 命令
-
-**安装 Fabric:**
-```typescript
-await invoke('install_fabric', {
- minecraftVersion: '1.20.4',
- loaderVersion: '0.15.0'
-});
-```
-
-**安装 Forge:**
-```typescript
-await invoke('install_forge', {
- minecraftVersion: '1.20.4',
- forgeVersion: '49.0.26'
-});
-```
-
-**列出 Fabric 版本:**
-```typescript
-const versions = await invoke('get_fabric_versions', {
- minecraftVersion: '1.20.4'
-});
-```
-
-**列出 Forge 版本:**
-```typescript
-const versions = await invoke('get_forge_versions', {
- minecraftVersion: '1.20.4'
-});
-```
-
-### 事件
-
-**安装进度:**
-```typescript
-listen('mod-loader-progress', (event) => {
- const { stage, percent } = event.payload;
- // Stages: "downloading", "installing", "processing", "complete"
-});
-```
+如果您正在为 DropOut å¼€å‘自定义主题或进行二次开å‘,å¯ä»¥ä½¿ç”¨æˆ‘们æä¾›çš„ Tauri 命令和事件接å£ã€‚
+
+具体的命令接å£ã€å‚数说明åŠè¿›åº¦æŽ¨é€äº‹ä»¶è¯¦è§å¼€å‘文档:[实现细节:模组加载器 API](../development/implementation.mdx#api-å‚考)。
## 最佳实践
-### 对于玩家
-
-1. **æ¯ä¸ªå®žä¾‹é€‰æ‹©ä¸€ä¸ªæ¨¡ç»„加载器**
-2. **精确匹é…版本** - Minecraft 和加载器
-3. **安装å‰é˜…è¯»æ¨¡ç»„è¦æ±‚**
-4. **å¾ªåºæ¸è¿›** - 逿­¥æ·»åŠ æ¨¡ç»„
-5. **备份世界** - 添加模组å‰å¤‡ä»½
-6. **检查兼容性** 列表
-7. **谨慎更新** - 在å•独的实例中测试
-
-### 对于模组包创建者
-
-1. **记录版本** - MCã€åŠ è½½å™¨ã€æ‰€æœ‰æ¨¡ç»„
-2. **彻底测试** - 所有功能
-3. **列出ä¾èµ–项** - 包括 API
-4. **æä¾›æ›´æ–°æ—¥å¿—** - 用于更新
-5. **版本é”定** - 为了稳定性
-6. **包å«é…ç½®** - 预é…ç½®
-7. **测试更新** - å‘å¸ƒå‰æµ‹è¯•
-
-## 未æ¥åŠŸèƒ½
-
-- **模组æµè§ˆå™¨** - 从å¯åŠ¨å™¨å®‰è£…æ¨¡ç»„
-- **自动更新** - ä¿æŒæ¨¡ç»„最新
-- **ä¾èµ–项解æž** - 自动安装需求
-- **å†²çªæ£€æµ‹** - 警告ä¸å…¼å®¹æ€§
-- **é…置文件导出** - 共享模组包é…ç½®
-- **CurseForge 集æˆ** - 直接模组包导入
-- **Modrinth 集æˆ** - 模组æœç´¢å’Œå®‰è£…
+1. **æ¯ä¸ªå®žä¾‹é€‰æ‹©ä¸€ä¸ªæ¨¡ç»„加载器**:ä¸è¦åœ¨åŒä¸€ä¸ªå®žä¾‹ä¸­æ··ç”¨ Fabric å’Œ Forge。
+2. **精确匹é…版本**ï¼šç¡®ä¿ Minecraft 版本与模组加载器版本高度兼容。
+3. **安装å‰é˜…è¯»è¦æ±‚**:许多模组需è¦é¢å¤–çš„ä¾èµ–库(如 Fabric API 或 Architectury)。
+4. **å¾ªåºæ¸è¿›**:首次构建模组包时,应分批添加模组以便于排查问题。
+5. **å…»æˆå¤‡ä»½ä¹ æƒ¯**:在安装大型模组包或进行版本大更新å‰ï¼Œè¯·å¤‡ä»½æ‚¨çš„存档。
+
+更多é¢å‘å¼€å‘者和模组包创作者的进阶指å—,请å‚阅[开呿–‡æ¡£ï¼šåˆ†å‘最佳实践](../development/implementation.mdx#对于模组包modpack创建者)。
+
+## 规划与展望
+
+我们致力于为 DropOut 打造最便æ·çš„æ¨¡ç»„体验。未æ¥ï¼Œæˆ‘们计划引入模组æµè§ˆå™¨ã€è‡ªåŠ¨æ›´æ–°ç›‘æŽ§ä»¥åŠæ™ºèƒ½å†²çªæ£€æµ‹ç­‰åŠŸèƒ½ã€‚
+
+详è§[å¼€å‘路线图](../development/implementation.mdx#模组管ç†ç³»ç»Ÿ)。
diff --git a/packages/docs/content/zh/getting-started.mdx b/packages/docs/content/zh/manual/getting-started.mdx
index d36eaf5..5d8c8c5 100644
--- a/packages/docs/content/zh/getting-started.mdx
+++ b/packages/docs/content/zh/manual/getting-started.mdx
@@ -5,7 +5,7 @@ description: 使用 DropOut Minecraft å¯åŠ¨å™¨çš„å¿«é€Ÿå…¥é—¨æŒ‡å—
# 快速开始
-DropOut 是一个使用 Tauri v2 å’Œ Rust 构建的现代化ã€å¯å¤çްã€å¼€å‘者级别的 Minecraft å¯åŠ¨å™¨ã€‚æœ¬æŒ‡å—将帮助你开始安装和使用 DropOut。
+DropOut 是一个使用 Tauri v2 构建的现代化ã€å¯å¤çްã€å¼€å‘者级别的 Minecraft å¯åŠ¨å™¨ã€‚æœ¬æŒ‡å—将帮助你开始安装和使用 DropOut。
## 安装
@@ -13,17 +13,18 @@ DropOut 是一个使用 Tauri v2 å’Œ Rust 构建的现代化ã€å¯å¤çްã€å¼€å
从[å‘布页é¢](https://github.com/HsiangNianian/DropOut/releases)下载适åˆä½ å¹³å°çš„æœ€æ–°ç‰ˆæœ¬ã€‚
-| å¹³å° | 文件 |
-| -------------- | ----------------------- |
-| Linux x86_64 | `.deb`, `.AppImage` |
-| Linux ARM64 | `.deb`, `.AppImage` |
-| macOS ARM64 | `.dmg` |
-| Windows x86_64 | `.msi`, `.exe` |
-| Windows ARM64 | `.msi`, `.exe` |
+| å¹³å° | 文件 |
+| -------------- | ------------------- |
+| Linux x86_64 | `.deb`, `.AppImage` |
+| Linux ARM64 | `.deb`, `.AppImage` |
+| macOS ARM64 | `.dmg` |
+| Windows x86_64 | `.msi`, `.exe` |
+| Windows ARM64 | `.msi`, `.exe` |
### Linux 安装
#### 使用 .deb 包
+
```bash
sudo dpkg -i dropout_*.deb
# 如果需è¦ï¼Œä¿®å¤ä¾èµ–
@@ -31,6 +32,7 @@ sudo apt-get install -f
```
#### 使用 AppImage
+
```bash
chmod +x dropout_*.AppImage
./dropout_*.AppImage
@@ -45,11 +47,13 @@ chmod +x dropout_*.AppImage
### Windows 安装
#### 使用 .msi 安装程åº
+
1. åŒå‡» `.msi` 文件
2. 按照安装å‘导æ“作
3. 从开始èœå•å¯åЍ DropOut
#### 使用 .exe 便æºç‰ˆ
+
1. åŒå‡» `.exe` 文件
2. DropOut 将直接è¿è¡Œï¼Œæ— éœ€å®‰è£…
@@ -74,17 +78,12 @@ chmod +x dropout_*.AppImage
### 1. 登录
<Cards>
- <Card
- title="微软账户"
- description="使用你的官方 Minecraft 账户登录"
- />
- <Card
- title="离线模å¼"
- description="创建本地é…置文件进行离线游æˆ"
- />
+ <Card title="微软账户" description="使用你的官方 Minecraft 账户登录" />
+ <Card title="离线模å¼" description="创建本地é…置文件进行离线游æˆ" />
</Cards>
**微软登录:**
+
1. 点击"使用微软登录"
2. 将显示设备代ç 
3. 访问显示的 URL 并输入代ç 
@@ -92,6 +91,7 @@ chmod +x dropout_*.AppImage
5. 返回 DropOut - 你将自动登录
**离线登录:**
+
1. 点击"离线模å¼"
2. 输入用户å
3. 点击"创建账户"
@@ -117,37 +117,27 @@ chmod +x dropout_*.AppImage
## 下一步
<Cards>
- <Card
- title="功能特性"
- href="/docs/features"
- description="了解 DropOut æä¾›çš„æ‰€æœ‰åŠŸèƒ½"
- />
- <Card
- title="实例管ç†"
- href="/docs/features/instances"
- description="创建隔离的游æˆçŽ¯å¢ƒ"
- />
- <Card
- title="模组加载器"
- href="/docs/features/mod-loaders"
+ <Card title="功能特性" href="/docs/manual/features" description="了解 DropOut æä¾›çš„æ‰€æœ‰åŠŸèƒ½" />
+ <Card title="实例管ç†" href="/docs/manual/features/instances" description="创建隔离的游æˆçŽ¯å¢ƒ" />
+ <Card
+ title="模组加载器"
+ href="/docs/manual/features/mod-loaders"
description="å®‰è£…å’Œç®¡ç† Fabric å’Œ Forge"
/>
- <Card
- title="故障排除"
- href="/docs/troubleshooting"
- description="常è§é—®é¢˜å’Œè§£å†³æ–¹æ¡ˆ"
- />
+ <Card title="故障排除" href="/docs/troubleshooting" description="常è§é—®é¢˜å’Œè§£å†³æ–¹æ¡ˆ" />
</Cards>
## ç³»ç»Ÿè¦æ±‚
### æœ€ä½Žè¦æ±‚
+
- **æ“作系统**: Windows 10+ã€macOS 11+ 或 Linux(基于 Debian)
- **内存**: 4GB(推è 8GB 用于模组 Minecraft)
- **存储**: å¯åЍ噍 + æ¸¸æˆæ–‡ä»¶éœ€è¦ 2GB
- **Java**: 如果找ä¸åˆ°ï¼ŒDropOut 会自动安装
### 推èé…ç½®
+
- **æ“作系统**: ä½ æ“作系统的最新稳定版本
- **内存**: 16GB 以获得带模组的最佳性能
- **存储**: 10GB+ 用于多个版本和模组
@@ -156,6 +146,7 @@ chmod +x dropout_*.AppImage
## 获å–帮助
如果é‡åˆ°é—®é¢˜ï¼š
+
- 查看[故障排除指å—](/docs/troubleshooting)
- 在 [GitHub Issues](https://github.com/HsiangNianian/DropOut/issues) 上报告 bug
- 加入我们的社区讨论
diff --git a/packages/docs/content/zh/index.mdx b/packages/docs/content/zh/manual/index.mdx
index b554cca..1b74743 100644
--- a/packages/docs/content/zh/index.mdx
+++ b/packages/docs/content/zh/manual/index.mdx
@@ -5,7 +5,7 @@ description: 现代化ã€å¯å¤çްã€å¼€å‘者级别的 Minecraft å¯åЍ噍
# 欢迎使用 DropOut
-DropOut 是一个使用 Tauri v2 å’Œ Rust 构建的现代 Minecraft å¯åŠ¨å™¨ï¼Œä¸“ä¸ºé‡è§†æŽ§åˆ¶ã€é€æ˜Žåº¦å’Œé•¿æœŸç¨³å®šæ€§çš„玩家设计。
+DropOut 是一个使用 Tauri v2 构建的现代 Minecraft å¯åŠ¨å™¨ï¼Œä¸“ä¸ºé‡è§†æŽ§åˆ¶ã€é€æ˜Žåº¦å’Œé•¿æœŸç¨³å®šæ€§çš„玩家设计。
<div style={{ textAlign: 'center', margin: '2rem 0' }}>
<img src="/image.png" alt="DropOut å¯åЍ噍" style={{ maxWidth: '700px', borderRadius: '8px' }} />
diff --git a/packages/docs/content/zh/manual/meta.json b/packages/docs/content/zh/manual/meta.json
new file mode 100644
index 0000000..cc3767a
--- /dev/null
+++ b/packages/docs/content/zh/manual/meta.json
@@ -0,0 +1,10 @@
+{
+ "title": "使用文档",
+ "pages": [
+ "index",
+ "getting-started",
+ "architecture",
+ "features",
+ "troubleshooting"
+ ]
+}
diff --git a/packages/docs/content/zh/troubleshooting.mdx b/packages/docs/content/zh/manual/troubleshooting.mdx
index c077528..ba0ec66 100644
--- a/packages/docs/content/zh/troubleshooting.mdx
+++ b/packages/docs/content/zh/manual/troubleshooting.mdx
@@ -508,16 +508,16 @@ chmod -R 700 ~/Library/Application\ Support/com.dropout.launcher
### ç›®å‰æ­£åœ¨å¤„ç†
- 多账户切æ¢ï¼ˆè¿›è¡Œä¸­ï¼‰
-- 自定义游æˆç›®å½•选择(计划中)
+- 选择自定义游æˆç›®å½•(计划中)
- å¯åŠ¨å™¨è‡ªåŠ¨æ›´æ–°ï¼ˆè®¡åˆ’ä¸­ï¼‰
-### å¯ç”¨çš„å˜é€šæ–¹æ³•
+### 解决方案
**问题**:无法轻æ¾åˆ‡æ¢è´¦æˆ·
-**å˜é€šæ–¹æ³•**:退出登录并使用ä¸åŒè´¦æˆ·ç™»å½•
+**解决方案**:退出登录并使用ä¸åŒè´¦æˆ·ç™»å½•
**问题**:没有内置模组管ç†å™¨
-**å˜é€šæ–¹æ³•**ï¼šåœ¨å®žä¾‹æ–‡ä»¶å¤¹ä¸­æ‰‹åŠ¨ç®¡ç†æ¨¡ç»„
+**解决方案**ï¼šåœ¨å®žä¾‹æ–‡ä»¶å¤¹ä¸­æ‰‹åŠ¨ç®¡ç†æ¨¡ç»„
**问题**:无法从其他å¯åЍ噍坼入
-**å˜é€šæ–¹æ³•**:手动å¤åˆ¶å®žä¾‹æ–‡ä»¶
+**解决方案**:手动å¤åˆ¶å®žä¾‹æ–‡ä»¶
diff --git a/packages/docs/content/zh/meta.json b/packages/docs/content/zh/meta.json
index 4fd7ad1..b0825f3 100644
--- a/packages/docs/content/zh/meta.json
+++ b/packages/docs/content/zh/meta.json
@@ -1,11 +1,7 @@
{
"title": "文档",
"pages": [
- "index",
- "getting-started",
- "architecture",
- "features",
"development",
- "troubleshooting"
+ "manual"
]
}
diff --git a/packages/docs/package.json b/packages/docs/package.json
index 18a5bf3..4ee4baf 100644
--- a/packages/docs/package.json
+++ b/packages/docs/package.json
@@ -18,11 +18,13 @@
"fumadocs-mdx": "14.2.6",
"fumadocs-ui": "16.4.7",
"isbot": "^5.1.32",
+ "mermaid": "^11.12.3",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router": "^7.12.0"
},
"devDependencies": {
+ "@biomejs/biome": "^2.3.11",
"@react-router/dev": "^7.12.0",
"@tailwindcss/vite": "^4.1.18",
"@types/mdx": "^2.0.13",
@@ -33,7 +35,6 @@
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1",
- "vite-tsconfig-paths": "^6.0.4",
- "@biomejs/biome": "^2.3.11"
+ "vite-tsconfig-paths": "^6.0.4"
}
}
diff --git a/packages/docs/source.config.ts b/packages/docs/source.config.ts
index d67a91b..7880853 100644
--- a/packages/docs/source.config.ts
+++ b/packages/docs/source.config.ts
@@ -1,7 +1,12 @@
import { defineConfig, defineDocs } from 'fumadocs-mdx/config';
+import { remarkMdxMermaid } from 'fumadocs-core/mdx-plugins';
export const docs = defineDocs({
dir: 'content',
});
-export default defineConfig();
+export default defineConfig({
+ mdxOptions: {
+ remarkPlugins: [remarkMdxMermaid],
+ },
+});
diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md
index 4b2d22b..bc780fb 100644
--- a/packages/ui/CHANGELOG.md
+++ b/packages/ui/CHANGELOG.md
@@ -1,7 +1,23 @@
# Changelog
-## v0.2.0-alpha.1
+## v0.1.0-alpha.2
+
+### Chores
+
+- [`2cef6e8`](https://github.com/HydroRoll-Team/DropOut/commit/2cef6e86b4fd45549ee2a4f7ea54a142690117d2): Fix version of `@dropout/ui`.
+
+## v0.0.0-alpha.1
### New Features
-- [`e7ac28c`](https://github.com/HydroRoll-Team/DropOut/commit/e7ac28c6b8467a8fca0a3b61ba498e4742d3a718): Prepare for alpha mode pre-release. ([#62](https://github.com/HydroRoll-Team/DropOut/pull/62) by @fu050409)
+- [`120c0a4`](https://github.com/HydroRoll-Team/DropOut/commit/120c0a460162226446cce4cfbc4c7e5859cd9d09): Listen to `game-exited` event while launching game.
+
+### Refactors
+
+- [`d95ca28`](https://github.com/HydroRoll-Team/DropOut/commit/d95ca2801c19a89a2a845f43b6e0133bf4e9be50): Migrate tauri invokes of instance creation modal to generated client.
+
+## v0.0.0-alpha.0
+
+### Refactors
+
+- [`66668d8`](https://github.com/HydroRoll-Team/DropOut/commit/66668d85d603c5841d755a6023aa1925559fc6d4): Partial rewrite UI to react port. ([#77](https://github.com/HydroRoll-Team/DropOut/pull/77) by @HsiangNianian)
diff --git a/packages/ui/README.md b/packages/ui/README.md
deleted file mode 100644
index a45e2a0..0000000
--- a/packages/ui/README.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Svelte + TS + Vite
-
-This template should help get you started developing with Svelte and TypeScript in Vite.
-
-## Recommended IDE Setup
-
-[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
-
-## Need an official Svelte framework?
-
-Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
-
-## Technical considerations
-
-**Why use this over SvelteKit?**
-
-- It brings its own routing solution which might not be preferable for some users.
-- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
-
-This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
-
-Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
-
-**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
-
-Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
-
-**Why include `.vscode/extensions.json`?**
-
-Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
-
-**Why enable `allowJs` in the TS template?**
-
-While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
-
-**Why is HMR not preserving my local component state?**
-
-HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
-
-If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
-
-```ts
-// store.ts
-// An extremely simple external store
-import { writable } from "svelte/store";
-export default writable(0);
-```
diff --git a/packages/ui/components.json b/packages/ui/components.json
new file mode 100644
index 0000000..f9d4fcd
--- /dev/null
+++ b/packages/ui/components.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "base-lyra",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/index.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "rtl": false,
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "registries": {}
+}
diff --git a/packages/ui/index.html b/packages/ui/index.html
index 4fe68e1..5191e6f 100644
--- a/packages/ui/index.html
+++ b/packages/ui/index.html
@@ -2,12 +2,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
- <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+ <link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>Dropout</title>
+ <title>Dropout Launcher</title>
</head>
<body>
- <div id="app"></div>
- <script type="module" src="/src/main.ts"></script>
+ <div id="root"></div>
+ <script type="module" src="/src/main.tsx"></script>
</body>
</html>
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 943fddc..b85f887 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,41 +1,46 @@
{
"name": "@dropout/ui",
- "version": "0.2.0-alpha.1",
"private": true,
+ "version": "0.1.0-alpha.2",
"type": "module",
"scripts": {
"dev": "vite",
- "build": "vite build",
- "preview": "vite preview",
- "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
- "lint": "oxlint .",
- "lint:fix": "oxlint . --fix",
- "format": "oxfmt . --write"
+ "build": "tsc -b && vite build",
+ "lint": "biome check .",
+ "preview": "vite preview"
},
"dependencies": {
+ "@base-ui/react": "^1.2.0",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-shell": "^2.3.4",
- "lucide-svelte": "^0.562.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "es-toolkit": "^1.44.0",
+ "lucide-react": "^0.562.0",
"marked": "^17.0.1",
- "node-emoji": "^2.2.0",
- "prismjs": "^1.30.0"
+ "next-themes": "^0.4.6",
+ "radix-ui": "^1.4.3",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "react-router": "^7.12.0",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.4.1",
+ "zod": "^4.3.6",
+ "zustand": "^5.0.10"
},
"devDependencies": {
- "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.18",
- "@tsconfig/svelte": "^5.0.6",
"@types/node": "^24.10.1",
- "@types/prismjs": "^1.26.5",
- "autoprefixer": "^10.4.23",
- "oxfmt": "^0.24.0",
- "oxlint": "^1.39.0",
- "postcss": "^8.5.6",
- "svelte": "^5.46.4",
- "svelte-check": "^4.3.4",
+ "@types/react": "^19.2.5",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.1",
+ "globals": "^16.5.0",
+ "shadcn": "^3.8.5",
"tailwindcss": "^4.1.18",
+ "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
- "vite": "npm:rolldown-vite@7.2.5"
+ "vite": "npm:rolldown-vite@^7"
}
}
diff --git a/packages/ui/pnpm-lock.yaml b/packages/ui/pnpm-lock.yaml
deleted file mode 100644
index 465b682..0000000
--- a/packages/ui/pnpm-lock.yaml
+++ /dev/null
@@ -1,1363 +0,0 @@
-lockfileVersion: '9.0'
-
-settings:
- autoInstallPeers: true
- excludeLinksFromLockfile: false
-
-overrides:
- vite: npm:rolldown-vite@7.2.5
-
-importers:
-
- .:
- dependencies:
- '@tauri-apps/api':
- specifier: ^2.9.1
- version: 2.9.1
- '@tauri-apps/plugin-dialog':
- specifier: ^2.6.0
- version: 2.6.0
- '@tauri-apps/plugin-fs':
- specifier: ^2.4.5
- version: 2.4.5
- '@tauri-apps/plugin-shell':
- specifier: ^2.3.4
- version: 2.3.4
- lucide-svelte:
- specifier: ^0.562.0
- version: 0.562.0(svelte@5.46.4)
- marked:
- specifier: ^17.0.1
- version: 17.0.1
- node-emoji:
- specifier: ^2.2.0
- version: 2.2.0
- prismjs:
- specifier: ^1.30.0
- version: 1.30.0
- devDependencies:
- '@sveltejs/vite-plugin-svelte':
- specifier: ^6.2.1
- version: 6.2.4(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4)
- '@tailwindcss/vite':
- specifier: ^4.1.18
- version: 4.1.18(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))
- '@tsconfig/svelte':
- specifier: ^5.0.6
- version: 5.0.6
- '@types/node':
- specifier: ^24.10.1
- version: 24.10.7
- '@types/prismjs':
- specifier: ^1.26.5
- version: 1.26.5
- autoprefixer:
- specifier: ^10.4.23
- version: 10.4.23(postcss@8.5.6)
- oxfmt:
- specifier: ^0.24.0
- version: 0.24.0
- oxlint:
- specifier: ^1.39.0
- version: 1.39.0
- postcss:
- specifier: ^8.5.6
- version: 8.5.6
- svelte:
- specifier: ^5.46.4
- version: 5.46.4
- svelte-check:
- specifier: ^4.3.4
- version: 4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3)
- tailwindcss:
- specifier: ^4.1.18
- version: 4.1.18
- typescript:
- specifier: ~5.9.3
- version: 5.9.3
- vite:
- specifier: npm:rolldown-vite@7.2.5
- version: rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1)
-
-packages:
-
- '@emnapi/core@1.8.1':
- resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
-
- '@emnapi/runtime@1.8.1':
- resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
-
- '@emnapi/wasi-threads@1.1.0':
- resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
-
- '@jridgewell/gen-mapping@0.3.13':
- resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
-
- '@jridgewell/remapping@2.3.5':
- resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
-
- '@jridgewell/resolve-uri@3.1.2':
- resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
- engines: {node: '>=6.0.0'}
-
- '@jridgewell/sourcemap-codec@1.5.5':
- resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
-
- '@jridgewell/trace-mapping@0.3.31':
- resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
-
- '@napi-rs/wasm-runtime@1.1.1':
- resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
-
- '@oxc-project/runtime@0.97.0':
- resolution: {integrity: sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==}
- engines: {node: ^20.19.0 || >=22.12.0}
-
- '@oxc-project/types@0.97.0':
- resolution: {integrity: sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==}
-
- '@oxfmt/darwin-arm64@0.24.0':
- resolution: {integrity: sha512-aYXuGf/yq8nsyEcHindGhiz9I+GEqLkVq8CfPbd+6VE259CpPEH+CaGHEO1j6vIOmNr8KHRq+IAjeRO2uJpb8A==}
- cpu: [arm64]
- os: [darwin]
-
- '@oxfmt/darwin-x64@0.24.0':
- resolution: {integrity: sha512-vs3b8Bs53hbiNvcNeBilzE/+IhDTWKjSBB3v/ztr664nZk65j0xr+5IHMBNz3CFppmX7o/aBta2PxY+t+4KoPg==}
- cpu: [x64]
- os: [darwin]
-
- '@oxfmt/linux-arm64-gnu@0.24.0':
- resolution: {integrity: sha512-ItPDOPoQ0wLj/s8osc5ch57uUcA1Wk8r0YdO8vLRpXA3UNg7KPOm1vdbkIZRRiSUphZcuX5ioOEetEK8H7RlTw==}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@oxfmt/linux-arm64-musl@0.24.0':
- resolution: {integrity: sha512-JkQO3WnQjQTJONx8nxdgVBfl6BBFfpp9bKhChYhWeakwJdr7QPOAWJ/v3FGZfr0TbqINwnNR74aVZayDDRyXEA==}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@oxfmt/linux-x64-gnu@0.24.0':
- resolution: {integrity: sha512-N/SXlFO+2kak5gMt0oxApi0WXQDhwA0PShR0UbkY0PwtHjfSiDqJSOumyNqgQVoroKr1GNnoRmUqjZIz6DKIcw==}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@oxfmt/linux-x64-musl@0.24.0':
- resolution: {integrity: sha512-WM0pek5YDCQf50XQ7GLCE9sMBCMPW/NPAEPH/Hx6Qyir37lEsP4rUmSECo/QFNTU6KBc9NnsviAyJruWPpCMXw==}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@oxfmt/win32-arm64@0.24.0':
- resolution: {integrity: sha512-vFCseli1KWtwdHrVlT/jWfZ8jP8oYpnPPEjI23mPLW8K/6GEJmmvy0PZP5NpWUFNTzX0lqie58XnrATJYAe9Xw==}
- cpu: [arm64]
- os: [win32]
-
- '@oxfmt/win32-x64@0.24.0':
- resolution: {integrity: sha512-0tmlNzcyewAnauNeBCq0xmAkmiKzl+H09p0IdHy+QKrTQdtixtf+AOjDAADbRfihkS+heF15Pjc4IyJMdAAJjw==}
- cpu: [x64]
- os: [win32]
-
- '@oxlint/darwin-arm64@1.39.0':
- resolution: {integrity: sha512-lT3hNhIa02xCujI6YGgjmYGg3Ht/X9ag5ipUVETaMpx5Rd4BbTNWUPif1WN1YZHxt3KLCIqaAe7zVhatv83HOQ==}
- cpu: [arm64]
- os: [darwin]
-
- '@oxlint/darwin-x64@1.39.0':
- resolution: {integrity: sha512-UT+rfTWd+Yr7iJeSLd/7nF8X4gTYssKh+n77hxl6Oilp3NnG1CKRHxZDy3o3lIBnwgzJkdyUAiYWO1bTMXQ1lA==}
- cpu: [x64]
- os: [darwin]
-
- '@oxlint/linux-arm64-gnu@1.39.0':
- resolution: {integrity: sha512-qocBkvS2V6rH0t9AT3DfQunMnj3xkM7srs5/Ycj2j5ZqMoaWd/FxHNVJDFP++35roKSvsRJoS0mtA8/77jqm6Q==}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@oxlint/linux-arm64-musl@1.39.0':
- resolution: {integrity: sha512-arZzAc1PPcz9epvGBBCMHICeyQloKtHX3eoOe62B3Dskn7gf6Q14wnDHr1r9Vp4vtcBATNq6HlKV14smdlC/qA==}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@oxlint/linux-x64-gnu@1.39.0':
- resolution: {integrity: sha512-ZVt5qsECpuNprdWxAPpDBwoixr1VTcZ4qAEQA2l/wmFyVPDYFD3oBY/SWACNnWBddMrswjTg9O8ALxYWoEpmXw==}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@oxlint/linux-x64-musl@1.39.0':
- resolution: {integrity: sha512-pB0hlGyKPbxr9NMIV783lD6cWL3MpaqnZRM9MWni4yBdHPTKyFNYdg5hGD0Bwg+UP4S2rOevq/+OO9x9Bi7E6g==}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@oxlint/win32-arm64@1.39.0':
- resolution: {integrity: sha512-Gg2SFaJohI9+tIQVKXlPw3FsPQFi/eCSWiCgwPtPn5uzQxHRTeQEZKuluz1fuzR5U70TXubb2liZi4Dgl8LJQA==}
- cpu: [arm64]
- os: [win32]
-
- '@oxlint/win32-x64@1.39.0':
- resolution: {integrity: sha512-sbi25lfj74hH+6qQtb7s1wEvd1j8OQbTaH8v3xTcDjrwm579Cyh0HBv1YSZ2+gsnVwfVDiCTL1D0JsNqYXszVA==}
- cpu: [x64]
- os: [win32]
-
- '@rolldown/binding-android-arm64@1.0.0-beta.50':
- resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [android]
-
- '@rolldown/binding-darwin-arm64@1.0.0-beta.50':
- resolution: {integrity: sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [darwin]
-
- '@rolldown/binding-darwin-x64@1.0.0-beta.50':
- resolution: {integrity: sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [darwin]
-
- '@rolldown/binding-freebsd-x64@1.0.0-beta.50':
- resolution: {integrity: sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [freebsd]
-
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50':
- resolution: {integrity: sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm]
- os: [linux]
-
- '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50':
- resolution: {integrity: sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50':
- resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50':
- resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@rolldown/binding-linux-x64-musl@1.0.0-beta.50':
- resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@rolldown/binding-openharmony-arm64@1.0.0-beta.50':
- resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [openharmony]
-
- '@rolldown/binding-wasm32-wasi@1.0.0-beta.50':
- resolution: {integrity: sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==}
- engines: {node: '>=14.0.0'}
- cpu: [wasm32]
-
- '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50':
- resolution: {integrity: sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [arm64]
- os: [win32]
-
- '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50':
- resolution: {integrity: sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [ia32]
- os: [win32]
-
- '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50':
- resolution: {integrity: sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==}
- engines: {node: ^20.19.0 || >=22.12.0}
- cpu: [x64]
- os: [win32]
-
- '@rolldown/pluginutils@1.0.0-beta.50':
- resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==}
-
- '@sindresorhus/is@4.6.0':
- resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
- engines: {node: '>=10'}
-
- '@sveltejs/acorn-typescript@1.0.8':
- resolution: {integrity: sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==}
- peerDependencies:
- acorn: ^8.9.0
-
- '@sveltejs/vite-plugin-svelte-inspector@5.0.2':
- resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==}
- engines: {node: ^20.19 || ^22.12 || >=24}
- peerDependencies:
- '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0
- svelte: ^5.0.0
- vite: ^6.3.0 || ^7.0.0
-
- '@sveltejs/vite-plugin-svelte@6.2.4':
- resolution: {integrity: sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==}
- engines: {node: ^20.19 || ^22.12 || >=24}
- peerDependencies:
- svelte: ^5.0.0
- vite: ^6.3.0 || ^7.0.0
-
- '@tailwindcss/node@4.1.18':
- resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
-
- '@tailwindcss/oxide-android-arm64@4.1.18':
- resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [android]
-
- '@tailwindcss/oxide-darwin-arm64@4.1.18':
- resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [darwin]
-
- '@tailwindcss/oxide-darwin-x64@4.1.18':
- resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [darwin]
-
- '@tailwindcss/oxide-freebsd-x64@4.1.18':
- resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [freebsd]
-
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
- resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==}
- engines: {node: '>= 10'}
- cpu: [arm]
- os: [linux]
-
- '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
- resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
- resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
- resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- '@tailwindcss/oxide-linux-x64-musl@4.1.18':
- resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- '@tailwindcss/oxide-wasm32-wasi@4.1.18':
- resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
- engines: {node: '>=14.0.0'}
- cpu: [wasm32]
- bundledDependencies:
- - '@napi-rs/wasm-runtime'
- - '@emnapi/core'
- - '@emnapi/runtime'
- - '@tybys/wasm-util'
- - '@emnapi/wasi-threads'
- - tslib
-
- '@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
- resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==}
- engines: {node: '>= 10'}
- cpu: [arm64]
- os: [win32]
-
- '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
- resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==}
- engines: {node: '>= 10'}
- cpu: [x64]
- os: [win32]
-
- '@tailwindcss/oxide@4.1.18':
- resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==}
- engines: {node: '>= 10'}
-
- '@tailwindcss/vite@4.1.18':
- resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==}
- peerDependencies:
- vite: ^5.2.0 || ^6 || ^7
-
- '@tauri-apps/api@2.9.1':
- resolution: {integrity: sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==}
-
- '@tauri-apps/plugin-dialog@2.6.0':
- resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
-
- '@tauri-apps/plugin-fs@2.4.5':
- resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==}
-
- '@tauri-apps/plugin-shell@2.3.4':
- resolution: {integrity: sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==}
-
- '@tsconfig/svelte@5.0.6':
- resolution: {integrity: sha512-yGxYL0I9eETH1/DR9qVJey4DAsCdeau4a9wYPKuXfEhm8lFO8wg+LLYJjIpAm6Fw7HSlhepPhYPDop75485yWQ==}
-
- '@tybys/wasm-util@0.10.1':
- resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
-
- '@types/estree@1.0.8':
- resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
-
- '@types/node@24.10.7':
- resolution: {integrity: sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ==}
-
- '@types/prismjs@1.26.5':
- resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
-
- acorn@8.15.0:
- resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
- engines: {node: '>=0.4.0'}
- hasBin: true
-
- aria-query@5.3.2:
- resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
- engines: {node: '>= 0.4'}
-
- autoprefixer@10.4.23:
- resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==}
- engines: {node: ^10 || ^12 || >=14}
- hasBin: true
- peerDependencies:
- postcss: ^8.1.0
-
- axobject-query@4.1.0:
- resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
- engines: {node: '>= 0.4'}
-
- baseline-browser-mapping@2.9.14:
- resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
- hasBin: true
-
- browserslist@4.28.1:
- resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
- engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
- hasBin: true
-
- caniuse-lite@1.0.30001764:
- resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==}
-
- char-regex@1.0.2:
- resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
- engines: {node: '>=10'}
-
- chokidar@4.0.3:
- resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
- engines: {node: '>= 14.16.0'}
-
- clsx@2.1.1:
- resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
- engines: {node: '>=6'}
-
- deepmerge@4.3.1:
- resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
- engines: {node: '>=0.10.0'}
-
- detect-libc@2.1.2:
- resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
- engines: {node: '>=8'}
-
- devalue@5.6.2:
- resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==}
-
- electron-to-chromium@1.5.267:
- resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
-
- emojilib@2.4.0:
- resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==}
-
- enhanced-resolve@5.18.4:
- resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
- engines: {node: '>=10.13.0'}
-
- escalade@3.2.0:
- resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
- engines: {node: '>=6'}
-
- esm-env@1.2.2:
- resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
-
- esrap@2.2.1:
- resolution: {integrity: sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==}
-
- fdir@6.5.0:
- resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
- engines: {node: '>=12.0.0'}
- peerDependencies:
- picomatch: ^3 || ^4
- peerDependenciesMeta:
- picomatch:
- optional: true
-
- fraction.js@5.3.4:
- resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
-
- fsevents@2.3.3:
- resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
- engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
- os: [darwin]
-
- graceful-fs@4.2.11:
- resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
-
- is-reference@3.0.3:
- resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
-
- jiti@2.6.1:
- resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
- hasBin: true
-
- lightningcss-android-arm64@1.30.2:
- resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [android]
-
- lightningcss-darwin-arm64@1.30.2:
- resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [darwin]
-
- lightningcss-darwin-x64@1.30.2:
- resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [darwin]
-
- lightningcss-freebsd-x64@1.30.2:
- resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [freebsd]
-
- lightningcss-linux-arm-gnueabihf@1.30.2:
- resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm]
- os: [linux]
-
- lightningcss-linux-arm64-gnu@1.30.2:
- resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [linux]
- libc: [glibc]
-
- lightningcss-linux-arm64-musl@1.30.2:
- resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [linux]
- libc: [musl]
-
- lightningcss-linux-x64-gnu@1.30.2:
- resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
- lightningcss-linux-x64-musl@1.30.2:
- resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [linux]
- libc: [musl]
-
- lightningcss-win32-arm64-msvc@1.30.2:
- resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
- engines: {node: '>= 12.0.0'}
- cpu: [arm64]
- os: [win32]
-
- lightningcss-win32-x64-msvc@1.30.2:
- resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
- engines: {node: '>= 12.0.0'}
- cpu: [x64]
- os: [win32]
-
- lightningcss@1.30.2:
- resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
- engines: {node: '>= 12.0.0'}
-
- locate-character@3.0.0:
- resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
-
- lucide-svelte@0.562.0:
- resolution: {integrity: sha512-kSJDH/55lf0mun/o4nqWBXOcq0fWYzPeIjbTD97ywoeumAB9kWxtM06gC7oynqjtK3XhAljWSz5RafIzPEYIQA==}
- peerDependencies:
- svelte: ^3 || ^4 || ^5.0.0-next.42
-
- magic-string@0.30.21:
- resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
-
- marked@17.0.1:
- resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
- engines: {node: '>= 20'}
- hasBin: true
-
- mri@1.2.0:
- resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
- engines: {node: '>=4'}
-
- nanoid@3.3.11:
- resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
- hasBin: true
-
- node-emoji@2.2.0:
- resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==}
- engines: {node: '>=18'}
-
- node-releases@2.0.27:
- resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
-
- obug@2.1.1:
- resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
-
- oxfmt@0.24.0:
- resolution: {integrity: sha512-UjeM3Peez8Tl7IJ9s5UwAoZSiDRMww7BEc21gDYxLq3S3/KqJnM3mjNxsoSHgmBvSeX6RBhoVc2MfC/+96RdSw==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
-
- oxlint@1.39.0:
- resolution: {integrity: sha512-wSiLr0wjG+KTU6c1LpVoQk7JZ7l8HCKlAkVDVTJKWmCGazsNxexxnOXl7dsar92mQcRnzko5g077ggP3RINSjA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
- peerDependencies:
- oxlint-tsgolint: '>=0.10.0'
- peerDependenciesMeta:
- oxlint-tsgolint:
- optional: true
-
- picocolors@1.1.1:
- resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
-
- picomatch@4.0.3:
- resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
- engines: {node: '>=12'}
-
- postcss-value-parser@4.2.0:
- resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
-
- postcss@8.5.6:
- resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
- engines: {node: ^10 || ^12 || >=14}
-
- prismjs@1.30.0:
- resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
- engines: {node: '>=6'}
-
- readdirp@4.1.2:
- resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
- engines: {node: '>= 14.18.0'}
-
- rolldown-vite@7.2.5:
- resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
- peerDependencies:
- '@types/node': ^20.19.0 || >=22.12.0
- esbuild: ^0.25.0
- jiti: '>=1.21.0'
- less: ^4.0.0
- sass: ^1.70.0
- sass-embedded: ^1.70.0
- stylus: '>=0.54.8'
- sugarss: ^5.0.0
- terser: ^5.16.0
- tsx: ^4.8.1
- yaml: ^2.4.2
- peerDependenciesMeta:
- '@types/node':
- optional: true
- esbuild:
- optional: true
- jiti:
- optional: true
- less:
- optional: true
- sass:
- optional: true
- sass-embedded:
- optional: true
- stylus:
- optional: true
- sugarss:
- optional: true
- terser:
- optional: true
- tsx:
- optional: true
- yaml:
- optional: true
-
- rolldown@1.0.0-beta.50:
- resolution: {integrity: sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==}
- engines: {node: ^20.19.0 || >=22.12.0}
- hasBin: true
-
- sade@1.8.1:
- resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
- engines: {node: '>=6'}
-
- skin-tone@2.0.0:
- resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==}
- engines: {node: '>=8'}
-
- source-map-js@1.2.1:
- resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
- engines: {node: '>=0.10.0'}
-
- svelte-check@4.3.5:
- resolution: {integrity: sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==}
- engines: {node: '>= 18.0.0'}
- hasBin: true
- peerDependencies:
- svelte: ^4.0.0 || ^5.0.0-next.0
- typescript: '>=5.0.0'
-
- svelte@5.46.4:
- resolution: {integrity: sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==}
- engines: {node: '>=18'}
-
- tailwindcss@4.1.18:
- resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
-
- tapable@2.3.0:
- resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
- engines: {node: '>=6'}
-
- tinyglobby@0.2.15:
- resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
- engines: {node: '>=12.0.0'}
-
- tinypool@2.0.0:
- resolution: {integrity: sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg==}
- engines: {node: ^20.0.0 || >=22.0.0}
-
- tslib@2.8.1:
- resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
-
- typescript@5.9.3:
- resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
- engines: {node: '>=14.17'}
- hasBin: true
-
- undici-types@7.16.0:
- resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
-
- unicode-emoji-modifier-base@1.0.0:
- resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==}
- engines: {node: '>=4'}
-
- update-browserslist-db@1.2.3:
- resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
- hasBin: true
- peerDependencies:
- browserslist: '>= 4.21.0'
-
- vitefu@1.1.1:
- resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==}
- peerDependencies:
- vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0
- peerDependenciesMeta:
- vite:
- optional: true
-
- zimmerframe@1.1.4:
- resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
-
-snapshots:
-
- '@emnapi/core@1.8.1':
- dependencies:
- '@emnapi/wasi-threads': 1.1.0
- tslib: 2.8.1
- optional: true
-
- '@emnapi/runtime@1.8.1':
- dependencies:
- tslib: 2.8.1
- optional: true
-
- '@emnapi/wasi-threads@1.1.0':
- dependencies:
- tslib: 2.8.1
- optional: true
-
- '@jridgewell/gen-mapping@0.3.13':
- dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
- '@jridgewell/trace-mapping': 0.3.31
-
- '@jridgewell/remapping@2.3.5':
- dependencies:
- '@jridgewell/gen-mapping': 0.3.13
- '@jridgewell/trace-mapping': 0.3.31
-
- '@jridgewell/resolve-uri@3.1.2': {}
-
- '@jridgewell/sourcemap-codec@1.5.5': {}
-
- '@jridgewell/trace-mapping@0.3.31':
- dependencies:
- '@jridgewell/resolve-uri': 3.1.2
- '@jridgewell/sourcemap-codec': 1.5.5
-
- '@napi-rs/wasm-runtime@1.1.1':
- dependencies:
- '@emnapi/core': 1.8.1
- '@emnapi/runtime': 1.8.1
- '@tybys/wasm-util': 0.10.1
- optional: true
-
- '@oxc-project/runtime@0.97.0': {}
-
- '@oxc-project/types@0.97.0': {}
-
- '@oxfmt/darwin-arm64@0.24.0':
- optional: true
-
- '@oxfmt/darwin-x64@0.24.0':
- optional: true
-
- '@oxfmt/linux-arm64-gnu@0.24.0':
- optional: true
-
- '@oxfmt/linux-arm64-musl@0.24.0':
- optional: true
-
- '@oxfmt/linux-x64-gnu@0.24.0':
- optional: true
-
- '@oxfmt/linux-x64-musl@0.24.0':
- optional: true
-
- '@oxfmt/win32-arm64@0.24.0':
- optional: true
-
- '@oxfmt/win32-x64@0.24.0':
- optional: true
-
- '@oxlint/darwin-arm64@1.39.0':
- optional: true
-
- '@oxlint/darwin-x64@1.39.0':
- optional: true
-
- '@oxlint/linux-arm64-gnu@1.39.0':
- optional: true
-
- '@oxlint/linux-arm64-musl@1.39.0':
- optional: true
-
- '@oxlint/linux-x64-gnu@1.39.0':
- optional: true
-
- '@oxlint/linux-x64-musl@1.39.0':
- optional: true
-
- '@oxlint/win32-arm64@1.39.0':
- optional: true
-
- '@oxlint/win32-x64@1.39.0':
- optional: true
-
- '@rolldown/binding-android-arm64@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-darwin-arm64@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-darwin-x64@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-freebsd-x64@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-linux-x64-musl@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-openharmony-arm64@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-wasm32-wasi@1.0.0-beta.50':
- dependencies:
- '@napi-rs/wasm-runtime': 1.1.1
- optional: true
-
- '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50':
- optional: true
-
- '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50':
- optional: true
-
- '@rolldown/pluginutils@1.0.0-beta.50': {}
-
- '@sindresorhus/is@4.6.0': {}
-
- '@sveltejs/acorn-typescript@1.0.8(acorn@8.15.0)':
- dependencies:
- acorn: 8.15.0
-
- '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4))(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4)':
- dependencies:
- '@sveltejs/vite-plugin-svelte': 6.2.4(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4)
- obug: 2.1.1
- svelte: 5.46.4
- vite: rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1)
-
- '@sveltejs/vite-plugin-svelte@6.2.4(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4)':
- dependencies:
- '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4))(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))(svelte@5.46.4)
- deepmerge: 4.3.1
- magic-string: 0.30.21
- obug: 2.1.1
- svelte: 5.46.4
- vite: rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1)
- vitefu: 1.1.1(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))
-
- '@tailwindcss/node@4.1.18':
- dependencies:
- '@jridgewell/remapping': 2.3.5
- enhanced-resolve: 5.18.4
- jiti: 2.6.1
- lightningcss: 1.30.2
- magic-string: 0.30.21
- source-map-js: 1.2.1
- tailwindcss: 4.1.18
-
- '@tailwindcss/oxide-android-arm64@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-darwin-arm64@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-darwin-x64@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-freebsd-x64@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-linux-arm64-musl@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-linux-x64-gnu@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-linux-x64-musl@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-wasm32-wasi@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
- optional: true
-
- '@tailwindcss/oxide-win32-x64-msvc@4.1.18':
- optional: true
-
- '@tailwindcss/oxide@4.1.18':
- optionalDependencies:
- '@tailwindcss/oxide-android-arm64': 4.1.18
- '@tailwindcss/oxide-darwin-arm64': 4.1.18
- '@tailwindcss/oxide-darwin-x64': 4.1.18
- '@tailwindcss/oxide-freebsd-x64': 4.1.18
- '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18
- '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18
- '@tailwindcss/oxide-linux-arm64-musl': 4.1.18
- '@tailwindcss/oxide-linux-x64-gnu': 4.1.18
- '@tailwindcss/oxide-linux-x64-musl': 4.1.18
- '@tailwindcss/oxide-wasm32-wasi': 4.1.18
- '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
- '@tailwindcss/oxide-win32-x64-msvc': 4.1.18
-
- '@tailwindcss/vite@4.1.18(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1))':
- dependencies:
- '@tailwindcss/node': 4.1.18
- '@tailwindcss/oxide': 4.1.18
- tailwindcss: 4.1.18
- vite: rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1)
-
- '@tauri-apps/api@2.9.1': {}
-
- '@tauri-apps/plugin-dialog@2.6.0':
- dependencies:
- '@tauri-apps/api': 2.9.1
-
- '@tauri-apps/plugin-fs@2.4.5':
- dependencies:
- '@tauri-apps/api': 2.9.1
-
- '@tauri-apps/plugin-shell@2.3.4':
- dependencies:
- '@tauri-apps/api': 2.9.1
-
- '@tsconfig/svelte@5.0.6': {}
-
- '@tybys/wasm-util@0.10.1':
- dependencies:
- tslib: 2.8.1
- optional: true
-
- '@types/estree@1.0.8': {}
-
- '@types/node@24.10.7':
- dependencies:
- undici-types: 7.16.0
-
- '@types/prismjs@1.26.5': {}
-
- acorn@8.15.0: {}
-
- aria-query@5.3.2: {}
-
- autoprefixer@10.4.23(postcss@8.5.6):
- dependencies:
- browserslist: 4.28.1
- caniuse-lite: 1.0.30001764
- fraction.js: 5.3.4
- picocolors: 1.1.1
- postcss: 8.5.6
- postcss-value-parser: 4.2.0
-
- axobject-query@4.1.0: {}
-
- baseline-browser-mapping@2.9.14: {}
-
- browserslist@4.28.1:
- dependencies:
- baseline-browser-mapping: 2.9.14
- caniuse-lite: 1.0.30001764
- electron-to-chromium: 1.5.267
- node-releases: 2.0.27
- update-browserslist-db: 1.2.3(browserslist@4.28.1)
-
- caniuse-lite@1.0.30001764: {}
-
- char-regex@1.0.2: {}
-
- chokidar@4.0.3:
- dependencies:
- readdirp: 4.1.2
-
- clsx@2.1.1: {}
-
- deepmerge@4.3.1: {}
-
- detect-libc@2.1.2: {}
-
- devalue@5.6.2: {}
-
- electron-to-chromium@1.5.267: {}
-
- emojilib@2.4.0: {}
-
- enhanced-resolve@5.18.4:
- dependencies:
- graceful-fs: 4.2.11
- tapable: 2.3.0
-
- escalade@3.2.0: {}
-
- esm-env@1.2.2: {}
-
- esrap@2.2.1:
- dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
-
- fdir@6.5.0(picomatch@4.0.3):
- optionalDependencies:
- picomatch: 4.0.3
-
- fraction.js@5.3.4: {}
-
- fsevents@2.3.3:
- optional: true
-
- graceful-fs@4.2.11: {}
-
- is-reference@3.0.3:
- dependencies:
- '@types/estree': 1.0.8
-
- jiti@2.6.1: {}
-
- lightningcss-android-arm64@1.30.2:
- optional: true
-
- lightningcss-darwin-arm64@1.30.2:
- optional: true
-
- lightningcss-darwin-x64@1.30.2:
- optional: true
-
- lightningcss-freebsd-x64@1.30.2:
- optional: true
-
- lightningcss-linux-arm-gnueabihf@1.30.2:
- optional: true
-
- lightningcss-linux-arm64-gnu@1.30.2:
- optional: true
-
- lightningcss-linux-arm64-musl@1.30.2:
- optional: true
-
- lightningcss-linux-x64-gnu@1.30.2:
- optional: true
-
- lightningcss-linux-x64-musl@1.30.2:
- optional: true
-
- lightningcss-win32-arm64-msvc@1.30.2:
- optional: true
-
- lightningcss-win32-x64-msvc@1.30.2:
- optional: true
-
- lightningcss@1.30.2:
- dependencies:
- detect-libc: 2.1.2
- optionalDependencies:
- lightningcss-android-arm64: 1.30.2
- lightningcss-darwin-arm64: 1.30.2
- lightningcss-darwin-x64: 1.30.2
- lightningcss-freebsd-x64: 1.30.2
- lightningcss-linux-arm-gnueabihf: 1.30.2
- lightningcss-linux-arm64-gnu: 1.30.2
- lightningcss-linux-arm64-musl: 1.30.2
- lightningcss-linux-x64-gnu: 1.30.2
- lightningcss-linux-x64-musl: 1.30.2
- lightningcss-win32-arm64-msvc: 1.30.2
- lightningcss-win32-x64-msvc: 1.30.2
-
- locate-character@3.0.0: {}
-
- lucide-svelte@0.562.0(svelte@5.46.4):
- dependencies:
- svelte: 5.46.4
-
- magic-string@0.30.21:
- dependencies:
- '@jridgewell/sourcemap-codec': 1.5.5
-
- marked@17.0.1: {}
-
- mri@1.2.0: {}
-
- nanoid@3.3.11: {}
-
- node-emoji@2.2.0:
- dependencies:
- '@sindresorhus/is': 4.6.0
- char-regex: 1.0.2
- emojilib: 2.4.0
- skin-tone: 2.0.0
-
- node-releases@2.0.27: {}
-
- obug@2.1.1: {}
-
- oxfmt@0.24.0:
- dependencies:
- tinypool: 2.0.0
- optionalDependencies:
- '@oxfmt/darwin-arm64': 0.24.0
- '@oxfmt/darwin-x64': 0.24.0
- '@oxfmt/linux-arm64-gnu': 0.24.0
- '@oxfmt/linux-arm64-musl': 0.24.0
- '@oxfmt/linux-x64-gnu': 0.24.0
- '@oxfmt/linux-x64-musl': 0.24.0
- '@oxfmt/win32-arm64': 0.24.0
- '@oxfmt/win32-x64': 0.24.0
-
- oxlint@1.39.0:
- optionalDependencies:
- '@oxlint/darwin-arm64': 1.39.0
- '@oxlint/darwin-x64': 1.39.0
- '@oxlint/linux-arm64-gnu': 1.39.0
- '@oxlint/linux-arm64-musl': 1.39.0
- '@oxlint/linux-x64-gnu': 1.39.0
- '@oxlint/linux-x64-musl': 1.39.0
- '@oxlint/win32-arm64': 1.39.0
- '@oxlint/win32-x64': 1.39.0
-
- picocolors@1.1.1: {}
-
- picomatch@4.0.3: {}
-
- postcss-value-parser@4.2.0: {}
-
- postcss@8.5.6:
- dependencies:
- nanoid: 3.3.11
- picocolors: 1.1.1
- source-map-js: 1.2.1
-
- prismjs@1.30.0: {}
-
- readdirp@4.1.2: {}
-
- rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1):
- dependencies:
- '@oxc-project/runtime': 0.97.0
- fdir: 6.5.0(picomatch@4.0.3)
- lightningcss: 1.30.2
- picomatch: 4.0.3
- postcss: 8.5.6
- rolldown: 1.0.0-beta.50
- tinyglobby: 0.2.15
- optionalDependencies:
- '@types/node': 24.10.7
- fsevents: 2.3.3
- jiti: 2.6.1
-
- rolldown@1.0.0-beta.50:
- dependencies:
- '@oxc-project/types': 0.97.0
- '@rolldown/pluginutils': 1.0.0-beta.50
- optionalDependencies:
- '@rolldown/binding-android-arm64': 1.0.0-beta.50
- '@rolldown/binding-darwin-arm64': 1.0.0-beta.50
- '@rolldown/binding-darwin-x64': 1.0.0-beta.50
- '@rolldown/binding-freebsd-x64': 1.0.0-beta.50
- '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.50
- '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.50
- '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.50
- '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.50
- '@rolldown/binding-linux-x64-musl': 1.0.0-beta.50
- '@rolldown/binding-openharmony-arm64': 1.0.0-beta.50
- '@rolldown/binding-wasm32-wasi': 1.0.0-beta.50
- '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.50
- '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.50
- '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.50
-
- sade@1.8.1:
- dependencies:
- mri: 1.2.0
-
- skin-tone@2.0.0:
- dependencies:
- unicode-emoji-modifier-base: 1.0.0
-
- source-map-js@1.2.1: {}
-
- svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3):
- dependencies:
- '@jridgewell/trace-mapping': 0.3.31
- chokidar: 4.0.3
- fdir: 6.5.0(picomatch@4.0.3)
- picocolors: 1.1.1
- sade: 1.8.1
- svelte: 5.46.4
- typescript: 5.9.3
- transitivePeerDependencies:
- - picomatch
-
- svelte@5.46.4:
- dependencies:
- '@jridgewell/remapping': 2.3.5
- '@jridgewell/sourcemap-codec': 1.5.5
- '@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0)
- '@types/estree': 1.0.8
- acorn: 8.15.0
- aria-query: 5.3.2
- axobject-query: 4.1.0
- clsx: 2.1.1
- devalue: 5.6.2
- esm-env: 1.2.2
- esrap: 2.2.1
- is-reference: 3.0.3
- locate-character: 3.0.0
- magic-string: 0.30.21
- zimmerframe: 1.1.4
-
- tailwindcss@4.1.18: {}
-
- tapable@2.3.0: {}
-
- tinyglobby@0.2.15:
- dependencies:
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
-
- tinypool@2.0.0: {}
-
- tslib@2.8.1:
- optional: true
-
- typescript@5.9.3: {}
-
- undici-types@7.16.0: {}
-
- unicode-emoji-modifier-base@1.0.0: {}
-
- update-browserslist-db@1.2.3(browserslist@4.28.1):
- dependencies:
- browserslist: 4.28.1
- escalade: 3.2.0
- picocolors: 1.1.1
-
- vitefu@1.1.1(rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1)):
- optionalDependencies:
- vite: rolldown-vite@7.2.5(@types/node@24.10.7)(jiti@2.6.1)
-
- zimmerframe@1.1.4: {}
diff --git a/packages/ui/public/icon.svg b/packages/ui/public/icon.svg
new file mode 100644
index 0000000..0baf00f
--- /dev/null
+++ b/packages/ui/public/icon.svg
@@ -0,0 +1,50 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
+ <!-- Background -->
+ <rect width="100%" height="100%" fill="#23272a"/>
+
+ <!-- Grid Pattern -->
+ <defs>
+ <pattern id="smallGrid" width="40" height="40" patternUnits="userSpaceOnUse">
+ <path d="M 40 0 L 0 0 0 40" fill="none" stroke="#2c2f33" stroke-width="2"/>
+ </pattern>
+ <!-- Glow filter for active connections -->
+ <filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
+ <feGaussianBlur stdDeviation="3" result="blur" />
+ <feComposite in="SourceGraphic" in2="blur" operator="over" />
+ </filter>
+ </defs>
+ <rect width="100%" height="100%" fill="url(#smallGrid)" />
+
+ <!-- Neural Network Connections (Lines) -->
+ <!-- Only lines between ACTIVE nodes are drawn normally -->
+
+ <!-- Input (Left) to Hidden (Middle Active) -->
+ <path d="M 100 128 L 256 256" stroke="#43b581" stroke-width="8" stroke-linecap="round" opacity="0.8"/> <!-- Top to Center -->
+ <path d="M 100 256 L 256 256" stroke="#43b581" stroke-width="8" stroke-linecap="round" opacity="1.0" filter="url(#glow)"/> <!-- Mid to Center (Strongest) -->
+ <path d="M 100 384 L 256 256" stroke="#43b581" stroke-width="8" stroke-linecap="round" opacity="0.8"/> <!-- Bot to Center -->
+
+ <!-- Hidden (Middle Active) to Output (Right) -->
+ <path d="M 256 256 L 412 256" stroke="#43b581" stroke-width="8" stroke-linecap="round" opacity="1.0" filter="url(#glow)"/>
+
+ <!-- Disconnected "Ghost" Lines (Optional: faint traces, or just omit to emphasize dropout) -->
+ <!-- Let's omit them to keep it clean and high-contrast, representing true dropout -->
+
+ <!-- Nodes -->
+
+ <!-- Layer 1: Input (All Active) - x=100 -->
+ <circle cx="100" cy="128" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
+ <circle cx="100" cy="256" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
+ <circle cx="100" cy="384" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
+
+ <!-- Layer 2: Hidden (Dropout Layer) - x=256 -->
+ <!-- Node 1: DROPPED (Ghost) -->
+ <circle cx="256" cy="128" r="28" fill="none" stroke="#4f545c" stroke-width="4" stroke-dasharray="8,6"/>
+ <!-- Node 2: ACTIVE -->
+ <circle cx="256" cy="256" r="32" fill="#43b581" stroke="#ffffff" stroke-width="4"/>
+ <!-- Node 3: DROPPED (Ghost) -->
+ <circle cx="256" cy="384" r="28" fill="none" stroke="#4f545c" stroke-width="4" stroke-dasharray="8,6"/>
+
+ <!-- Layer 3: Output - x=412 -->
+ <circle cx="412" cy="256" r="30" fill="#7289da" stroke="#ffffff" stroke-width="4"/>
+
+</svg>
diff --git a/packages/ui/public/vite.svg b/packages/ui/public/vite.svg
deleted file mode 100644
index ee9fada..0000000
--- a/packages/ui/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
diff --git a/packages/ui/src/App.svelte b/packages/ui/src/App.svelte
deleted file mode 100644
index f73e0a2..0000000
--- a/packages/ui/src/App.svelte
+++ /dev/null
@@ -1,217 +0,0 @@
-<script lang="ts">
- import { getVersion } from "@tauri-apps/api/app";
- // import { convertFileSrc } from "@tauri-apps/api/core"; // Removed duplicate, handled by import below or inline
- import { onDestroy, onMount } from "svelte";
- import DownloadMonitor from "./lib/DownloadMonitor.svelte";
- import GameConsole from "./lib/GameConsole.svelte";
-// Components
- import BottomBar from "./components/BottomBar.svelte";
- import HomeView from "./components/HomeView.svelte";
- import LoginModal from "./components/LoginModal.svelte";
- import ParticleBackground from "./components/ParticleBackground.svelte";
- import SettingsView from "./components/SettingsView.svelte";
- import AssistantView from "./components/AssistantView.svelte";
- import InstancesView from "./components/InstancesView.svelte";
- import Sidebar from "./components/Sidebar.svelte";
- import StatusToast from "./components/StatusToast.svelte";
- import VersionsView from "./components/VersionsView.svelte";
-// Stores
- import { authState } from "./stores/auth.svelte";
- import { gameState } from "./stores/game.svelte";
- import { instancesState } from "./stores/instances.svelte";
- import { settingsState } from "./stores/settings.svelte";
- import { uiState } from "./stores/ui.svelte";
- import { logsState } from "./stores/logs.svelte";
- import { convertFileSrc } from "@tauri-apps/api/core";
-
- let mouseX = $state(0);
- let mouseY = $state(0);
-
- function handleMouseMove(e: MouseEvent) {
- mouseX = (e.clientX / window.innerWidth) * 2 - 1;
- mouseY = (e.clientY / window.innerHeight) * 2 - 1;
- }
-
- onMount(async () => {
- // 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');
-
- authState.checkAccount();
- await settingsState.loadSettings();
- logsState.init();
- await settingsState.detectJava();
- await instancesState.loadInstances();
- gameState.loadVersions();
- getVersion().then((v) => (uiState.appVersion = v));
- window.addEventListener("mousemove", handleMouseMove);
- });
-
- // Refresh versions when active instance changes
- $effect(() => {
- if (instancesState.activeInstanceId) {
- gameState.loadVersions();
- } else {
- gameState.versions = [];
- }
- });
-
- onDestroy(() => {
- if (typeof window !== 'undefined')
- window.removeEventListener("mousemove", handleMouseMove);
- });
-</script>
-
-<div
- class="relative h-screen w-screen overflow-hidden dark:text-white text-gray-900 font-sans selection:bg-indigo-500/30"
->
- <!-- Modern Animated Background -->
- <div class="absolute inset-0 z-0 bg-[#09090b] dark:bg-[#09090b] bg-gray-100 overflow-hidden">
- {#if settingsState.settings.custom_background_path}
- <img
- src={convertFileSrc(settingsState.settings.custom_background_path)}
- alt="Background"
- class="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 -->
- <div class="absolute inset-0 bg-black/50 "></div>
- {:else if settingsState.settings.enable_visual_effects}
- <!-- Original Gradient (Dark Only / or Adjusted for Light) -->
- {#if settingsState.settings.theme === 'dark'}
- <div
- class="absolute inset-0 opacity-60 bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950"
- ></div>
- {:else}
- <!-- Light Mode Gradient -->
- <div
- class="absolute inset-0 opacity-100 bg-gradient-to-br from-emerald-100 via-gray-100 to-indigo-100"
- ></div>
- {/if}
-
- {#if uiState.currentView === "home"}
- <ParticleBackground />
- {/if}
-
- <div
- class="absolute inset-0 bg-gradient-to-t from-zinc-900 via-transparent to-black/50 dark:from-zinc-900 dark:to-black/50 from-gray-100 to-transparent"
- ></div>
- {/if}
-
- <!-- Subtle Grid Overlay -->
- <div class="absolute inset-0 z-0 opacity-10 dark:opacity-10 opacity-30 pointer-events-none"
- style="background-image: linear-gradient({settingsState.settings.theme === 'dark' ? '#ffffff' : '#000000'} 1px, transparent 1px), linear-gradient(90deg, {settingsState.settings.theme === 'dark' ? '#ffffff' : '#000000'} 1px, transparent 1px); background-size: 40px 40px; mask-image: radial-gradient(circle at 50% 50%, black 30%, transparent 70%);">
- </div>
- </div>
-
- <!-- Content Wrapper -->
- <div class="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 class="flex-1 flex flex-col relative min-w-0 overflow-hidden transition-all duration-300">
-
- <!-- Window Drag Region -->
- <div
- class="h-8 w-full absolute top-0 left-0 z-50 drag-region"
- data-tauri-drag-region
- ></div>
-
- <!-- App Content -->
- <div class="flex-1 relative overflow-hidden flex flex-col">
- <!-- Views Container -->
- <div class="flex-1 relative overflow-hidden">
- {#if uiState.currentView === "home"}
- <HomeView mouseX={mouseX} mouseY={mouseY} />
- {:else if uiState.currentView === "instances"}
- <InstancesView />
- {:else if uiState.currentView === "versions"}
- <VersionsView />
- {:else if uiState.currentView === "settings"}
- <SettingsView />
- {:else if uiState.currentView === "guide"}
- <AssistantView />
- {/if}
- </div>
-
- <!-- Download Monitor Overlay -->
- <div class="absolute bottom-20 left-4 right-4 pointer-events-none z-20">
- <div class="pointer-events-auto">
- <DownloadMonitor />
- </div>
- </div>
-
- <!-- Bottom Bar -->
- {#if uiState.currentView === "home"}
- <BottomBar />
- {/if}
- </div>
- </main>
- </div>
-
- <LoginModal />
- <StatusToast />
-
- <!-- Logout Confirmation Dialog -->
- {#if authState.isLogoutConfirmOpen}
- <div class="fixed inset-0 z-[200] bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
- <div class="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 class="text-lg font-bold text-white mb-2">Logout</h3>
- <p class="text-zinc-400 text-sm mb-6">
- Are you sure you want to logout <span class="text-white font-medium">{authState.currentAccount?.username}</span>?
- </p>
- <div class="flex gap-3 justify-end">
- <button
- onclick={() => authState.cancelLogout()}
- class="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
- onclick={() => authState.confirmLogout()}
- class="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>
- {/if}
-
- {#if uiState.showConsole}
- <div class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-8">
- <div class="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>
- {/if}
-</div>
-
-<style>
- :global(body) {
- margin: 0;
- padding: 0;
- background: #000;
- }
-
- /* Modern Scrollbar */
- :global(*::-webkit-scrollbar) {
- width: 6px;
- height: 6px;
- }
-
- :global(*::-webkit-scrollbar-track) {
- background: transparent;
- }
-
- :global(*::-webkit-scrollbar-thumb) {
- background: rgba(255, 255, 255, 0.1);
- border-radius: 999px;
- }
-
- :global(*::-webkit-scrollbar-thumb:hover) {
- background: rgba(255, 255, 255, 0.25);
- }
-</style>
diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css
deleted file mode 100644
index 63449b7..0000000
--- a/packages/ui/src/app.css
+++ /dev/null
@@ -1,167 +0,0 @@
-@import "tailwindcss";
-
-@variant dark (&:where(.dark, .dark *));
-
-/* ==================== Custom Select/Dropdown Styles ==================== */
-
-/* Base select styling */
-select {
- appearance: none;
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2371717a'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
- background-repeat: no-repeat;
- background-position: right 0.5rem center;
- background-size: 1rem;
- padding-right: 2rem;
-}
-
-/* Option styling - works in WebView/Chromium */
-select option {
- background-color: #18181b;
- color: #e4e4e7;
- padding: 12px 16px;
- font-size: 13px;
- border: none;
-}
-
-select option:hover,
-select option:focus {
- background-color: #3730a3 !important;
- color: white !important;
-}
-
-select option:checked {
- background: linear-gradient(0deg, #4f46e5 0%, #4f46e5 100%);
- color: white;
- font-weight: 500;
-}
-
-select option:disabled {
- color: #52525b;
- background-color: #18181b;
-}
-
-/* Optgroup styling */
-select optgroup {
- background-color: #18181b;
- color: #a1a1aa;
- font-weight: 600;
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- padding: 8px 12px 4px;
-}
-
-/* Select focus state */
-select:focus {
- outline: none;
- border-color: #6366f1;
- box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
-}
-
-/* ==================== Custom Scrollbar (Global) ==================== */
-
-/* Firefox */
-* {
- scrollbar-width: thin;
- scrollbar-color: #3f3f46 transparent;
-}
-
-/* Webkit browsers */
-::-webkit-scrollbar {
- width: 8px;
- height: 8px;
-}
-
-::-webkit-scrollbar-track {
- background: transparent;
-}
-
-::-webkit-scrollbar-thumb {
- background-color: #3f3f46;
- border-radius: 4px;
- border: 2px solid transparent;
- background-clip: content-box;
-}
-
-::-webkit-scrollbar-thumb:hover {
- background-color: #52525b;
-}
-
-::-webkit-scrollbar-corner {
- background: transparent;
-}
-
-/* ==================== Input/Form Element Consistency ==================== */
-
-input[type="text"],
-input[type="number"],
-input[type="password"],
-input[type="email"],
-textarea {
- background-color: rgba(0, 0, 0, 0.4);
- border: 1px solid rgba(255, 255, 255, 0.1);
- transition:
- border-color 0.2s ease,
- box-shadow 0.2s ease;
-}
-
-input[type="text"]:focus,
-input[type="number"]:focus,
-input[type="password"]:focus,
-input[type="email"]:focus,
-textarea:focus {
- border-color: rgba(99, 102, 241, 0.5);
- box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
- outline: none;
-}
-
-/* Number input - hide spinner */
-input[type="number"]::-webkit-outer-spin-button,
-input[type="number"]::-webkit-inner-spin-button {
- -webkit-appearance: none;
- margin: 0;
-}
-
-input[type="number"] {
- -moz-appearance: textfield;
-}
-
-/* ==================== Checkbox Styling ==================== */
-
-input[type="checkbox"] {
- appearance: none;
- width: 16px;
- height: 16px;
- border: 1px solid #3f3f46;
- border-radius: 4px;
- background-color: #18181b;
- cursor: pointer;
- position: relative;
- transition: all 0.15s ease;
-}
-
-input[type="checkbox"]:hover {
- border-color: #52525b;
-}
-
-input[type="checkbox"]:checked {
- background-color: #4f46e5;
- border-color: #4f46e5;
-}
-
-input[type="checkbox"]:checked::after {
- content: "";
- position: absolute;
- left: 5px;
- top: 2px;
- width: 4px;
- height: 8px;
- border: solid white;
- border-width: 0 2px 2px 0;
- transform: rotate(45deg);
-}
-
-input[type="checkbox"]:focus {
- outline: none;
- box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3);
-}
diff --git a/packages/ui/src/assets/svelte.svg b/packages/ui/src/assets/svelte.svg
deleted file mode 100644
index 8c056ce..0000000
--- a/packages/ui/src/assets/svelte.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
diff --git a/packages/ui/src/client.ts b/packages/ui/src/client.ts
new file mode 100644
index 0000000..18d2377
--- /dev/null
+++ b/packages/ui/src/client.ts
@@ -0,0 +1,400 @@
+import { invoke } from "@tauri-apps/api/core";
+import type {
+ Account,
+ DeviceCodeResponse,
+ FabricGameVersion,
+ FabricLoaderEntry,
+ FabricLoaderVersion,
+ FileInfo,
+ ForgeVersion,
+ GithubRelease,
+ InstalledFabricVersion,
+ InstalledForgeVersion,
+ InstalledVersion,
+ Instance,
+ JavaCatalog,
+ JavaDownloadInfo,
+ JavaInstallation,
+ LauncherConfig,
+ Message,
+ MigrationResult,
+ ModelInfo,
+ PastebinResponse,
+ PendingJavaDownload,
+ Version,
+ VersionMetadata,
+} from "@/types";
+
+export function assistantChat(messages: Message[]): Promise<Message> {
+ return invoke<Message>("assistant_chat", {
+ messages,
+ });
+}
+
+export function assistantChatStream(messages: Message[]): Promise<string> {
+ return invoke<string>("assistant_chat_stream", {
+ messages,
+ });
+}
+
+export function assistantCheckHealth(): Promise<boolean> {
+ return invoke<boolean>("assistant_check_health");
+}
+
+export function cancelJavaDownload(): Promise<void> {
+ return invoke<void>("cancel_java_download");
+}
+
+export function checkVersionInstalled(
+ instanceId: string,
+ versionId: string,
+): Promise<boolean> {
+ return invoke<boolean>("check_version_installed", {
+ instanceId,
+ versionId,
+ });
+}
+
+export function completeMicrosoftLogin(deviceCode: string): Promise<Account> {
+ return invoke<Account>("complete_microsoft_login", {
+ deviceCode,
+ });
+}
+
+export function createInstance(name: string): Promise<Instance> {
+ return invoke<Instance>("create_instance", {
+ name,
+ });
+}
+
+export function deleteInstance(instanceId: string): Promise<void> {
+ return invoke<void>("delete_instance", {
+ instanceId,
+ });
+}
+
+export function deleteInstanceFile(path: string): Promise<void> {
+ return invoke<void>("delete_instance_file", {
+ path,
+ });
+}
+
+export function deleteVersion(
+ instanceId: string,
+ versionId: string,
+): Promise<void> {
+ return invoke<void>("delete_version", {
+ instanceId,
+ versionId,
+ });
+}
+
+export function detectAllJavaInstallations(): Promise<JavaInstallation[]> {
+ return invoke<JavaInstallation[]>("detect_all_java_installations");
+}
+
+export function detectJava(): Promise<JavaInstallation[]> {
+ return invoke<JavaInstallation[]>("detect_java");
+}
+
+export function downloadAdoptiumJava(
+ majorVersion: number,
+ imageType: string,
+ customPath: string | null,
+): Promise<JavaInstallation> {
+ return invoke<JavaInstallation>("download_adoptium_java", {
+ majorVersion,
+ imageType,
+ customPath,
+ });
+}
+
+export function duplicateInstance(
+ instanceId: string,
+ newName: string,
+): Promise<Instance> {
+ return invoke<Instance>("duplicate_instance", {
+ instanceId,
+ newName,
+ });
+}
+
+export function fetchAdoptiumJava(
+ majorVersion: number,
+ imageType: string,
+): Promise<JavaDownloadInfo> {
+ return invoke<JavaDownloadInfo>("fetch_adoptium_java", {
+ majorVersion,
+ imageType,
+ });
+}
+
+export function fetchAvailableJavaVersions(): Promise<number[]> {
+ return invoke<number[]>("fetch_available_java_versions");
+}
+
+export function fetchJavaCatalog(): Promise<JavaCatalog> {
+ return invoke<JavaCatalog>("fetch_java_catalog");
+}
+
+export function getActiveAccount(): Promise<Account | null> {
+ return invoke<Account | null>("get_active_account");
+}
+
+export function getActiveInstance(): Promise<Instance | null> {
+ return invoke<Instance | null>("get_active_instance");
+}
+
+export function getConfigPath(): Promise<string> {
+ return invoke<string>("get_config_path");
+}
+
+export function getFabricGameVersions(): Promise<FabricGameVersion[]> {
+ return invoke<FabricGameVersion[]>("get_fabric_game_versions");
+}
+
+export function getFabricLoaderVersions(): Promise<FabricLoaderVersion[]> {
+ return invoke<FabricLoaderVersion[]>("get_fabric_loader_versions");
+}
+
+export function getFabricLoadersForVersion(
+ gameVersion: string,
+): Promise<FabricLoaderEntry[]> {
+ return invoke<FabricLoaderEntry[]>("get_fabric_loaders_for_version", {
+ gameVersion,
+ });
+}
+
+export function getForgeGameVersions(): Promise<string[]> {
+ return invoke<string[]>("get_forge_game_versions");
+}
+
+export function getForgeVersionsForGame(
+ gameVersion: string,
+): Promise<ForgeVersion[]> {
+ return invoke<ForgeVersion[]>("get_forge_versions_for_game", {
+ gameVersion,
+ });
+}
+
+export function getGithubReleases(): Promise<GithubRelease[]> {
+ return invoke<GithubRelease[]>("get_github_releases");
+}
+
+export function getInstance(instanceId: string): Promise<Instance> {
+ return invoke<Instance>("get_instance", {
+ instanceId,
+ });
+}
+
+export function getPendingJavaDownloads(): Promise<PendingJavaDownload[]> {
+ return invoke<PendingJavaDownload[]>("get_pending_java_downloads");
+}
+
+export function getRecommendedJava(
+ requiredMajorVersion: number | null,
+): Promise<JavaInstallation | null> {
+ return invoke<JavaInstallation | null>("get_recommended_java", {
+ requiredMajorVersion,
+ });
+}
+
+export function getSettings(): Promise<LauncherConfig> {
+ return invoke<LauncherConfig>("get_settings");
+}
+
+export function getVersionJavaVersion(
+ instanceId: string,
+ versionId: string,
+): Promise<number | null> {
+ return invoke<number | null>("get_version_java_version", {
+ instanceId,
+ versionId,
+ });
+}
+
+export function getVersionMetadata(
+ instanceId: string,
+ versionId: string,
+): Promise<VersionMetadata> {
+ return invoke<VersionMetadata>("get_version_metadata", {
+ instanceId,
+ versionId,
+ });
+}
+
+export function getVersions(): Promise<Version[]> {
+ return invoke<Version[]>("get_versions");
+}
+
+export function getVersionsOfInstance(instanceId: string): Promise<Version[]> {
+ return invoke<Version[]>("get_versions_of_instance", {
+ instanceId,
+ });
+}
+
+export function installFabric(
+ instanceId: string,
+ gameVersion: string,
+ loaderVersion: string,
+): Promise<InstalledFabricVersion> {
+ return invoke<InstalledFabricVersion>("install_fabric", {
+ instanceId,
+ gameVersion,
+ loaderVersion,
+ });
+}
+
+export function installForge(
+ instanceId: string,
+ gameVersion: string,
+ forgeVersion: string,
+): Promise<InstalledForgeVersion> {
+ return invoke<InstalledForgeVersion>("install_forge", {
+ instanceId,
+ gameVersion,
+ forgeVersion,
+ });
+}
+
+export function installVersion(
+ instanceId: string,
+ versionId: string,
+): Promise<void> {
+ return invoke<void>("install_version", {
+ instanceId,
+ versionId,
+ });
+}
+
+export function isFabricInstalled(
+ instanceId: string,
+ gameVersion: string,
+ loaderVersion: string,
+): Promise<boolean> {
+ return invoke<boolean>("is_fabric_installed", {
+ instanceId,
+ gameVersion,
+ loaderVersion,
+ });
+}
+
+export function listInstalledFabricVersions(
+ instanceId: string,
+): Promise<string[]> {
+ return invoke<string[]>("list_installed_fabric_versions", {
+ instanceId,
+ });
+}
+
+export function listInstalledVersions(
+ instanceId: string,
+): Promise<InstalledVersion[]> {
+ return invoke<InstalledVersion[]>("list_installed_versions", {
+ instanceId,
+ });
+}
+
+export function listInstanceDirectory(
+ instanceId: string,
+ folder: string,
+): Promise<FileInfo[]> {
+ return invoke<FileInfo[]>("list_instance_directory", {
+ instanceId,
+ folder,
+ });
+}
+
+export function listInstances(): Promise<Instance[]> {
+ return invoke<Instance[]>("list_instances");
+}
+
+export function listOllamaModels(endpoint: string): Promise<ModelInfo[]> {
+ return invoke<ModelInfo[]>("list_ollama_models", {
+ endpoint,
+ });
+}
+
+export function listOpenaiModels(): Promise<ModelInfo[]> {
+ return invoke<ModelInfo[]>("list_openai_models");
+}
+
+export function loginOffline(username: string): Promise<Account> {
+ return invoke<Account>("login_offline", {
+ username,
+ });
+}
+
+export function logout(): Promise<void> {
+ return invoke<void>("logout");
+}
+
+export function migrateSharedCaches(): Promise<MigrationResult> {
+ return invoke<MigrationResult>("migrate_shared_caches");
+}
+
+export function openFileExplorer(path: string): Promise<void> {
+ return invoke<void>("open_file_explorer", {
+ path,
+ });
+}
+
+export function readRawConfig(): Promise<string> {
+ return invoke<string>("read_raw_config");
+}
+
+export function refreshAccount(): Promise<Account> {
+ return invoke<Account>("refresh_account");
+}
+
+export function refreshJavaCatalog(): Promise<JavaCatalog> {
+ return invoke<JavaCatalog>("refresh_java_catalog");
+}
+
+export function resumeJavaDownloads(): Promise<JavaInstallation[]> {
+ return invoke<JavaInstallation[]>("resume_java_downloads");
+}
+
+export function saveRawConfig(content: string): Promise<void> {
+ return invoke<void>("save_raw_config", {
+ content,
+ });
+}
+
+export function saveSettings(config: LauncherConfig): Promise<void> {
+ return invoke<void>("save_settings", {
+ config,
+ });
+}
+
+export function setActiveInstance(instanceId: string): Promise<void> {
+ return invoke<void>("set_active_instance", {
+ instanceId,
+ });
+}
+
+export function startGame(
+ instanceId: string,
+ versionId: string,
+): Promise<string> {
+ return invoke<string>("start_game", {
+ instanceId,
+ versionId,
+ });
+}
+
+export function startMicrosoftLogin(): Promise<DeviceCodeResponse> {
+ return invoke<DeviceCodeResponse>("start_microsoft_login");
+}
+
+export function updateInstance(instance: Instance): Promise<void> {
+ return invoke<void>("update_instance", {
+ instance,
+ });
+}
+
+export function uploadToPastebin(content: string): Promise<PastebinResponse> {
+ return invoke<PastebinResponse>("upload_to_pastebin", {
+ content,
+ });
+}
diff --git a/packages/ui/src/components/AssistantView.svelte b/packages/ui/src/components/AssistantView.svelte
deleted file mode 100644
index 54509a5..0000000
--- a/packages/ui/src/components/AssistantView.svelte
+++ /dev/null
@@ -1,436 +0,0 @@
-<script lang="ts">
- import { assistantState } from '../stores/assistant.svelte';
- import { settingsState } from '../stores/settings.svelte';
- import { Send, Bot, RefreshCw, Trash2, AlertTriangle, Settings, Brain, ChevronDown } from 'lucide-svelte';
- import { uiState } from '../stores/ui.svelte';
- import { marked } from 'marked';
- import { onMount } from 'svelte';
-
- let input = $state('');
- let messagesContainer: HTMLDivElement | undefined = undefined;
-
- function parseMessageContent(content: string) {
- 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
- // We extract the thinking part and keep the rest (before and after)
- 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 {
- // marked.parse returns string synchronously when async is false (default)
- return marked(content, { breaks: true, gfm: true }) as string;
- } catch {
- return content;
- }
- }
-
- function scrollToBottom() {
- if (messagesContainer) {
- setTimeout(() => {
- if (messagesContainer) {
- messagesContainer.scrollTop = messagesContainer.scrollHeight;
- }
- }, 0);
- }
- }
-
- onMount(() => {
- assistantState.init();
- });
-
- // Scroll to bottom when messages change
- $effect(() => {
- // Access reactive state
- const _len = assistantState.messages.length;
- const _processing = assistantState.isProcessing;
- // Scroll on next tick
- if (_len > 0 || _processing) {
- scrollToBottom();
- }
- });
-
- async function handleSubmit() {
- if (!input.trim() || assistantState.isProcessing) return;
- const text = input;
- input = '';
- const provider = settingsState.settings.assistant.llm_provider;
- const endpoint = provider === 'ollama'
- ? settingsState.settings.assistant.ollama_endpoint
- : settingsState.settings.assistant.openai_endpoint;
- await assistantState.sendMessage(
- text,
- settingsState.settings.assistant.enabled,
- provider,
- endpoint
- );
- }
-
- function handleKeydown(e: KeyboardEvent) {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSubmit();
- }
- }
-
- function getProviderName(): string {
- const provider = settingsState.settings.assistant.llm_provider;
- if (provider === 'ollama') {
- return `Ollama (${settingsState.settings.assistant.ollama_model})`;
- } else if (provider === 'openai') {
- return `OpenAI (${settingsState.settings.assistant.openai_model})`;
- }
- return provider;
- }
-
- function getProviderHelpText(): string {
- const provider = settingsState.settings.assistant.llm_provider;
- if (provider === 'ollama') {
- return `Please ensure Ollama is installed and running at ${settingsState.settings.assistant.ollama_endpoint}.`;
- } else if (provider === 'openai') {
- return "Please check your OpenAI API key in Settings > AI Assistant.";
- }
- return "";
- }
-</script>
-
-<div class="h-full w-full flex flex-col gap-4 p-4 lg:p-8 animate-in fade-in zoom-in-95 duration-300">
- <div class="flex items-center justify-between mb-2">
- <div class="flex items-center gap-3">
- <div class="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
- <Bot size={24} />
- </div>
- <div>
- <h2 class="text-2xl font-bold">Game Assistant</h2>
- <p class="text-zinc-400 text-sm">Powered by {getProviderName()}</p>
- </div>
- </div>
-
- <div class="flex items-center gap-2">
- {#if !settingsState.settings.assistant.enabled}
- <div class="flex items-center gap-2 px-3 py-1.5 bg-zinc-500/10 text-zinc-400 rounded-full text-xs font-medium border border-zinc-500/20">
- <AlertTriangle size={14} />
- <span>Disabled</span>
- </div>
- {:else if !assistantState.isProviderHealthy}
- <div class="flex items-center gap-2 px-3 py-1.5 bg-red-500/10 text-red-400 rounded-full text-xs font-medium border border-red-500/20">
- <AlertTriangle size={14} />
- <span>Offline</span>
- </div>
- {:else}
- <div class="flex items-center gap-2 px-3 py-1.5 bg-emerald-500/10 text-emerald-400 rounded-full text-xs font-medium border border-emerald-500/20">
- <div class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
- <span>Online</span>
- </div>
- {/if}
-
- <button
- onclick={() => assistantState.checkHealth()}
- class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
- title="Check Connection"
- >
- <RefreshCw size={18} class={assistantState.isProcessing ? "animate-spin" : ""} />
- </button>
-
- <button
- onclick={() => assistantState.clearHistory()}
- class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
- title="Clear History"
- >
- <Trash2 size={18} />
- </button>
-
- <button
- onclick={() => uiState.setView('settings')}
- class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
- title="Settings"
- >
- <Settings size={18} />
- </button>
- </div>
- </div>
-
- <!-- Chat Area -->
- <div class="flex-1 bg-black/20 border border-white/5 rounded-xl overflow-hidden flex flex-col relative">
- {#if assistantState.messages.length === 0}
- <div class="absolute inset-0 flex flex-col items-center justify-center text-zinc-500 gap-4 p-8 text-center">
- <Bot size={48} class="opacity-20" />
- <div class="max-w-md">
- <p class="text-lg font-medium text-zinc-300 mb-2">How can I help you today?</p>
- <p class="text-sm opacity-70">I can analyze your game logs, diagnose crashes, or explain mod features.</p>
- </div>
- {#if !settingsState.settings.assistant.enabled}
- <div class="bg-zinc-500/10 border border-zinc-500/20 rounded-lg p-4 text-sm text-zinc-400 mt-4 max-w-sm">
- Assistant is disabled. Enable it in <button onclick={() => uiState.setView('settings')} class="text-indigo-400 hover:underline">Settings > AI Assistant</button>.
- </div>
- {:else if !assistantState.isProviderHealthy}
- <div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-sm text-red-400 mt-4 max-w-sm">
- {getProviderHelpText()}
- </div>
- {/if}
- </div>
- {/if}
-
- <div
- bind:this={messagesContainer}
- class="flex-1 overflow-y-auto p-4 space-y-4 scroll-smooth"
- >
- {#each assistantState.messages as msg, idx}
- <div class="flex gap-3 {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
- {#if msg.role === 'assistant'}
- <div class="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-400 shrink-0 mt-1">
- <Bot size={16} />
- </div>
- {/if}
-
- <div class="max-w-[80%] p-3 rounded-2xl {msg.role === 'user' ? 'bg-indigo-600 text-white rounded-tr-none' : 'bg-zinc-800/50 text-zinc-200 rounded-tl-none border border-white/5'}">
- {#if msg.role === 'user'}
- <div class="break-words whitespace-pre-wrap">
- {msg.content}
- </div>
- {:else}
- {@const parsed = parseMessageContent(msg.content)}
-
- <!-- Thinking Block -->
- {#if parsed.thinking}
- <div class="mb-3 max-w-full overflow-hidden">
- <details class="group" open={parsed.isThinking}>
- <summary class="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 size={14} />
- <span>Thinking Process</span>
- <ChevronDown size={14} class="transition-transform duration-200 group-open:rotate-180" />
- </summary>
- <div class="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}
- {#if parsed.isThinking}
- <span class="inline-block w-1.5 h-3 bg-zinc-500 ml-1 animate-pulse align-middle"></span>
- {/if}
- </div>
- </details>
- </div>
- {/if}
-
- <!-- Markdown rendered content for assistant -->
- <div class="markdown-content prose prose-invert prose-sm max-w-none">
- {#if parsed.content}
- {@html renderMarkdown(parsed.content)}
- {:else if assistantState.isProcessing && idx === assistantState.messages.length - 1 && !parsed.isThinking}
- <span class="inline-flex items-center gap-1">
- <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse"></span>
- <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse" style="animation-delay: 0.2s"></span>
- <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse" style="animation-delay: 0.4s"></span>
- </span>
- {/if}
- </div>
-
- <!-- Generation Stats -->
- {#if msg.stats}
- <div class="mt-3 pt-3 border-t border-white/5 text-[10px] text-zinc-500 font-mono flex flex-wrap gap-x-4 gap-y-1 opacity-70 hover:opacity-100 transition-opacity select-none">
- <div class="flex gap-1" title="Tokens generated">
- <span>Eval:</span>
- <span class="text-zinc-400">{msg.stats.eval_count} tokens</span>
- </div>
- <div class="flex gap-1" title="Total duration">
- <span>Time:</span>
- <span class="text-zinc-400">{(msg.stats.total_duration / 1e9).toFixed(2)}s</span>
- </div>
- {#if msg.stats.eval_duration > 0}
- <div class="flex gap-1" title="Generation speed">
- <span>Speed:</span>
- <span class="text-zinc-400">{(msg.stats.eval_count / (msg.stats.eval_duration / 1e9)).toFixed(1)} t/s</span>
- </div>
- {/if}
- </div>
- {/if}
- {/if}
- </div>
- </div>
- {/each}
- </div>
-
- <!-- Input Area -->
- <div class="p-4 bg-zinc-900/50 border-t border-white/5">
- <div class="relative">
- <textarea
- bind:value={input}
- onkeydown={handleKeydown}
- placeholder={settingsState.settings.assistant.enabled ? "Ask about your game..." : "Assistant is disabled..."}
- class="w-full bg-black/20 border border-white/10 rounded-xl py-3 pl-4 pr-12 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 resize-none h-[52px] max-h-32 transition-all text-white disabled:opacity-50"
- disabled={assistantState.isProcessing || !settingsState.settings.assistant.enabled}
- ></textarea>
-
- <button
- onclick={handleSubmit}
- disabled={!input.trim() || assistantState.isProcessing || !settingsState.settings.assistant.enabled}
- class="absolute right-2 top-2 p-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:hover:bg-indigo-600 text-white rounded-lg transition-colors"
- >
- <Send size={16} />
- </button>
- </div>
- </div>
- </div>
-</div>
-
-<style>
- /* Markdown content styles */
- .markdown-content :global(p) {
- margin-bottom: 0.5rem;
- }
-
- .markdown-content :global(p:last-child) {
- margin-bottom: 0;
- }
-
- .markdown-content :global(pre) {
- background-color: rgba(0, 0, 0, 0.4);
- border-radius: 0.5rem;
- padding: 0.75rem;
- overflow-x: auto;
- margin: 0.5rem 0;
- }
-
- .markdown-content :global(code) {
- font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
- font-size: 0.85em;
- }
-
- .markdown-content :global(pre code) {
- background: none;
- padding: 0;
- }
-
- .markdown-content :global(:not(pre) > code) {
- background-color: rgba(0, 0, 0, 0.3);
- padding: 0.15rem 0.4rem;
- border-radius: 0.25rem;
- }
-
- .markdown-content :global(ul),
- .markdown-content :global(ol) {
- margin: 0.5rem 0;
- padding-left: 1.5rem;
- }
-
- .markdown-content :global(li) {
- margin: 0.25rem 0;
- }
-
- .markdown-content :global(blockquote) {
- border-left: 3px solid rgba(99, 102, 241, 0.5);
- padding-left: 1rem;
- margin: 0.5rem 0;
- color: rgba(255, 255, 255, 0.7);
- }
-
- .markdown-content :global(h1),
- .markdown-content :global(h2),
- .markdown-content :global(h3),
- .markdown-content :global(h4) {
- font-weight: 600;
- margin: 0.75rem 0 0.5rem 0;
- }
-
- .markdown-content :global(h1) {
- font-size: 1.25rem;
- }
-
- .markdown-content :global(h2) {
- font-size: 1.125rem;
- }
-
- .markdown-content :global(h3) {
- font-size: 1rem;
- }
-
- .markdown-content :global(a) {
- color: rgb(129, 140, 248);
- text-decoration: underline;
- }
-
- .markdown-content :global(a:hover) {
- color: rgb(165, 180, 252);
- }
-
- .markdown-content :global(table) {
- border-collapse: collapse;
- margin: 0.5rem 0;
- width: 100%;
- }
-
- .markdown-content :global(th),
- .markdown-content :global(td) {
- border: 1px solid rgba(255, 255, 255, 0.1);
- padding: 0.5rem;
- text-align: left;
- }
-
- .markdown-content :global(th) {
- background-color: rgba(0, 0, 0, 0.3);
- font-weight: 600;
- }
-
- .markdown-content :global(hr) {
- border: none;
- border-top: 1px solid rgba(255, 255, 255, 0.1);
- margin: 1rem 0;
- }
-
- .markdown-content :global(img) {
- max-width: 100%;
- border-radius: 0.5rem;
- }
-
- .markdown-content :global(strong) {
- font-weight: 600;
- }
-
- .markdown-content :global(em) {
- font-style: italic;
- }
-</style>
diff --git a/packages/ui/src/components/BottomBar.svelte b/packages/ui/src/components/BottomBar.svelte
deleted file mode 100644
index 19cf35d..0000000
--- a/packages/ui/src/components/BottomBar.svelte
+++ /dev/null
@@ -1,250 +0,0 @@
-<script lang="ts">
- import { invoke } from "@tauri-apps/api/core";
- import { listen, type UnlistenFn } from "@tauri-apps/api/event";
- import { authState } from "../stores/auth.svelte";
- import { gameState } from "../stores/game.svelte";
- import { uiState } from "../stores/ui.svelte";
- import { instancesState } from "../stores/instances.svelte";
- import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte';
-
- interface InstalledVersion {
- id: string;
- type: string;
- }
-
- let isVersionDropdownOpen = $state(false);
- let dropdownRef: HTMLDivElement;
- let installedVersions = $state<InstalledVersion[]>([]);
- let isLoadingVersions = $state(true);
- let downloadCompleteUnlisten: UnlistenFn | null = null;
- let versionDeletedUnlisten: UnlistenFn | null = null;
-
- // Load installed versions on mount
- $effect(() => {
- loadInstalledVersions();
- setupEventListeners();
- return () => {
- if (downloadCompleteUnlisten) {
- downloadCompleteUnlisten();
- }
- if (versionDeletedUnlisten) {
- versionDeletedUnlisten();
- }
- };
- });
-
- async function setupEventListeners() {
- // Refresh list when a download completes
- downloadCompleteUnlisten = await listen("download-complete", () => {
- loadInstalledVersions();
- });
- // Refresh list when a version is deleted
- versionDeletedUnlisten = await listen("version-deleted", () => {
- loadInstalledVersions();
- });
- }
-
- async function loadInstalledVersions() {
- if (!instancesState.activeInstanceId) {
- installedVersions = [];
- isLoadingVersions = false;
- return;
- }
- isLoadingVersions = true;
- try {
- installedVersions = await invoke<InstalledVersion[]>("list_installed_versions", {
- instanceId: instancesState.activeInstanceId,
- });
- // If no version is selected but we have installed versions, select the first one
- if (!gameState.selectedVersion && installedVersions.length > 0) {
- gameState.selectedVersion = installedVersions[0].id;
- }
- } catch (e) {
- console.error("Failed to load installed versions:", e);
- } finally {
- isLoadingVersions = false;
- }
- }
-
- let versionOptions = $derived(
- isLoadingVersions
- ? [{ id: "loading", type: "loading", label: "Loading..." }]
- : installedVersions.length === 0
- ? [{ id: "empty", type: "empty", label: "No versions installed" }]
- : installedVersions.map(v => ({
- ...v,
- label: `${v.id}${v.type !== 'release' ? ` (${v.type})` : ''}`
- }))
- );
-
- function selectVersion(id: string) {
- if (id !== "loading" && id !== "empty") {
- gameState.selectedVersion = id;
- isVersionDropdownOpen = false;
- }
- }
-
- function handleClickOutside(e: MouseEvent) {
- if (dropdownRef && !dropdownRef.contains(e.target as Node)) {
- isVersionDropdownOpen = false;
- }
- }
-
- $effect(() => {
- if (isVersionDropdownOpen) {
- document.addEventListener('click', handleClickOutside);
- return () => document.removeEventListener('click', handleClickOutside);
- }
- });
-
- function getVersionTypeColor(type: string) {
- switch (type) {
- case 'fabric': return 'text-indigo-400';
- case 'forge': return 'text-orange-400';
- case 'snapshot': return 'text-amber-400';
- case 'modpack': return 'text-purple-400';
- default: return 'text-emerald-400';
- }
- }
-</script>
-
-<div
- class="h-20 bg-white/80 dark:bg-[#09090b]/90 border-t dark:border-white/10 border-black/5 flex items-center px-8 justify-between z-20 backdrop-blur-md"
->
- <!-- Account Area -->
- <div class="flex items-center gap-6">
- <div
- class="group flex items-center gap-4 cursor-pointer"
- onclick={() => authState.openLoginModal()}
- role="button"
- tabindex="0"
- onkeydown={(e) => e.key === "Enter" && authState.openLoginModal()}
- >
- <div
- class="w-10 h-10 rounded-sm bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 flex items-center justify-center overflow-hidden transition-all group-hover:border-zinc-400 dark:group-hover:border-zinc-500"
- >
- {#if authState.currentAccount}
- <img
- src={`https://minotar.net/avatar/${authState.currentAccount.username}/48`}
- alt={authState.currentAccount.username}
- class="w-full h-full"
- />
- {:else}
- <User size={20} class="text-zinc-400" />
- {/if}
- </div>
- <div>
- <div class="font-bold dark:text-white text-gray-900 text-sm group-hover:text-black dark:group-hover:text-zinc-200 transition-colors">
- {authState.currentAccount ? authState.currentAccount.username : "Login Account"}
- </div>
- <div class="text-[10px] uppercase tracking-wider dark:text-zinc-500 text-gray-500 flex items-center gap-2">
- {#if authState.currentAccount}
- {#if authState.currentAccount.type === "Microsoft"}
- {#if authState.currentAccount.expires_at && authState.currentAccount.expires_at * 1000 < Date.now()}
- <span class="w-1.5 h-1.5 rounded-full bg-red-500"></span>
- <span class="text-red-400">Expired</span>
- {:else}
- <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
- Online
- {/if}
- {:else}
- <span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span>
- Offline
- {/if}
- {:else}
- <span class="w-1.5 h-1.5 rounded-full bg-zinc-400"></span>
- Guest
- {/if}
- </div>
- </div>
- </div>
-
- <div class="h-8 w-px dark:bg-white/10 bg-black/10"></div>
-
- <!-- Console Toggle -->
- <button
- class="text-xs font-mono dark:text-zinc-500 text-gray-500 dark:hover:text-white hover:text-black transition-colors flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-black/5 dark:hover:bg-white/5"
- onclick={() => uiState.toggleConsole()}
- >
- <Terminal size={14} />
- {uiState.showConsole ? "HIDE LOGS" : "SHOW LOGS"}
- </button>
- </div>
-
- <!-- Action Area -->
- <div class="flex items-center gap-4">
- <div class="flex flex-col items-end mr-2">
- <!-- Custom Version Dropdown -->
- <div class="relative" bind:this={dropdownRef}>
- <button
- type="button"
- onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen}
- disabled={installedVersions.length === 0 && !isLoadingVersions}
- class="flex items-center justify-between gap-2 w-56 px-4 py-2.5 text-left
- dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md
- text-sm font-mono dark:text-white text-gray-900
- dark:hover:border-zinc-600 hover:border-zinc-400
- focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
- transition-colors cursor-pointer outline-none
- disabled:opacity-50 disabled:cursor-not-allowed"
- >
- <span class="truncate">
- {#if isLoadingVersions}
- Loading...
- {:else if installedVersions.length === 0}
- No versions installed
- {:else}
- {gameState.selectedVersion || "Select version"}
- {/if}
- </span>
- <ChevronDown
- size={14}
- class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}"
- />
- </button>
-
- {#if isVersionDropdownOpen && installedVersions.length > 0}
- <div
- class="absolute z-50 w-full mt-1 py-1 dark:bg-zinc-900 bg-white border dark:border-zinc-700 border-zinc-300 rounded-md shadow-xl
- max-h-72 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 bottom-full mb-1 right-0"
- >
- {#each versionOptions as version}
- <button
- type="button"
- onclick={() => selectVersion(version.id)}
- disabled={version.id === "loading" || version.id === "empty"}
- class="w-full flex items-center justify-between px-3 py-2 text-sm font-mono text-left
- transition-colors outline-none
- {version.id === gameState.selectedVersion
- ? 'bg-indigo-600 text-white'
- : 'dark:text-zinc-300 text-gray-700 dark:hover:bg-zinc-800 hover:bg-zinc-100'}
- {version.id === 'loading' || version.id === 'empty' ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}"
- >
- <span class="truncate flex items-center gap-2">
- {version.id}
- {#if version.type && version.type !== 'release' && version.type !== 'loading' && version.type !== 'empty'}
- <span class="text-[10px] uppercase {version.id === gameState.selectedVersion ? 'text-white/70' : getVersionTypeColor(version.type)}">
- {version.type}
- </span>
- {/if}
- </span>
- {#if version.id === gameState.selectedVersion}
- <Check size={14} class="shrink-0 ml-2" />
- {/if}
- </button>
- {/each}
- </div>
- {/if}
- </div>
- </div>
-
- <button
- onclick={() => gameState.startGame()}
- disabled={installedVersions.length === 0 || !gameState.selectedVersion}
- class="bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white h-14 px-10 rounded-sm transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-lg shadow-emerald-500/20 flex items-center gap-3 font-bold text-lg tracking-widest uppercase"
- >
- <Play size={24} fill="currentColor" />
- <span>Launch</span>
- </button>
- </div>
-</div>
diff --git a/packages/ui/src/components/ConfigEditorModal.svelte b/packages/ui/src/components/ConfigEditorModal.svelte
deleted file mode 100644
index dd866ee..0000000
--- a/packages/ui/src/components/ConfigEditorModal.svelte
+++ /dev/null
@@ -1,369 +0,0 @@
-<script lang="ts">
- import { settingsState } from "../stores/settings.svelte";
- import { Save, X, FileJson, AlertCircle, Undo, Redo, Settings } from "lucide-svelte";
- import Prism from 'prismjs';
- import 'prismjs/components/prism-json';
- import 'prismjs/themes/prism-tomorrow.css';
-
- let content = $state(settingsState.rawConfigContent);
- let isSaving = $state(false);
- let localError = $state("");
-
- let textareaRef: HTMLTextAreaElement | undefined = $state();
- let preRef: HTMLPreElement | undefined = $state();
- let lineNumbersRef: HTMLDivElement | undefined = $state();
-
- // Textarea attributes that TypeScript doesn't recognize but are valid HTML
- const textareaAttrs = {
- autocorrect: "off",
- autocapitalize: "off"
- } as Record<string, string>;
-
- // History State
- let history = $state([settingsState.rawConfigContent]);
- let historyIndex = $state(0);
- let debounceTimer: ReturnType<typeof setTimeout> | undefined;
-
- // Editor Settings
- let showLineNumbers = $state(localStorage.getItem('editor_showLineNumbers') !== 'false');
- let showStatusBar = $state(localStorage.getItem('editor_showStatusBar') !== 'false');
- let showSettings = $state(false);
-
- // Cursor Status
- let cursorLine = $state(1);
- let cursorCol = $state(1);
-
- let lines = $derived(content.split('\n'));
-
- $effect(() => {
- localStorage.setItem('editor_showLineNumbers', String(showLineNumbers));
- localStorage.setItem('editor_showStatusBar', String(showStatusBar));
- });
-
- // Cleanup timer on destroy
- $effect(() => {
- return () => {
- if (debounceTimer) clearTimeout(debounceTimer);
- };
- });
-
- // Initial validation
- $effect(() => {
- validate(content);
- });
-
- function validate(text: string) {
- try {
- JSON.parse(text);
- localError = "";
- } catch (e: any) {
- localError = e.message;
- }
- }
-
- function pushHistory(newContent: string, immediate = false) {
- if (debounceTimer) clearTimeout(debounceTimer);
-
- const commit = () => {
- if (newContent === history[historyIndex]) return;
- const next = history.slice(0, historyIndex + 1);
- next.push(newContent);
- history = next;
- historyIndex = next.length - 1;
- };
-
- if (immediate) {
- commit();
- } else {
- debounceTimer = setTimeout(commit, 500);
- }
- }
-
- function handleUndo() {
- if (historyIndex > 0) {
- historyIndex--;
- content = history[historyIndex];
- validate(content);
- }
- }
-
- function handleRedo() {
- if (historyIndex < history.length - 1) {
- historyIndex++;
- content = history[historyIndex];
- validate(content);
- }
- }
-
- function updateCursor() {
- if (!textareaRef) return;
- const pos = textareaRef.selectionStart;
- const text = textareaRef.value.substring(0, pos);
- const lines = text.split('\n');
- cursorLine = lines.length;
- cursorCol = lines[lines.length - 1].length + 1;
- }
-
- function handleInput(e: Event) {
- const target = e.target as HTMLTextAreaElement;
- content = target.value;
- validate(content);
- pushHistory(content);
- updateCursor();
- }
-
- function handleScroll() {
- if (textareaRef) {
- if (preRef) {
- preRef.scrollTop = textareaRef.scrollTop;
- preRef.scrollLeft = textareaRef.scrollLeft;
- }
- if (lineNumbersRef) {
- lineNumbersRef.scrollTop = textareaRef.scrollTop;
- }
- }
- }
-
- let highlightedCode = $derived(
- Prism.highlight(content, Prism.languages.json, 'json') + '\n'
- );
-
- async function handleSave(close = false) {
- if (localError) return;
- isSaving = true;
- await settingsState.saveRawConfig(content, close);
- isSaving = false;
- }
-
- function handleKeydown(e: KeyboardEvent) {
- // Save
- if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
- e.preventDefault();
- handleSave(false); // Keep open on shortcut save
- }
- // Undo
- else if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
- e.preventDefault();
- handleUndo();
- }
- // Redo (Ctrl+Shift+Z or Ctrl+Y)
- else if (
- (e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey) ||
- (e.key === 'y' && (e.ctrlKey || e.metaKey))
- ) {
- e.preventDefault();
- handleRedo();
- }
- // Close
- else if (e.key === 'Escape') {
- settingsState.closeConfigEditor();
- }
- // Tab
- else if (e.key === 'Tab') {
- e.preventDefault();
- const target = e.target as HTMLTextAreaElement;
- const start = target.selectionStart;
- const end = target.selectionEnd;
-
- pushHistory(content, true);
-
- const newContent = content.substring(0, start) + " " + content.substring(end);
- content = newContent;
-
- pushHistory(content, true);
-
- setTimeout(() => {
- target.selectionStart = target.selectionEnd = start + 2;
- updateCursor();
- }, 0);
- validate(content);
- }
- }
-</script>
-
-<div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70 animate-in fade-in duration-200">
- <div
- class="bg-[#1d1f21] rounded-xl border border-zinc-700 shadow-2xl w-[900px] max-w-[95vw] h-[85vh] flex flex-col overflow-hidden"
- role="dialog"
- aria-modal="true"
- >
- <!-- Header -->
- <div class="flex items-center justify-between p-4 border-b border-zinc-700 bg-[#1d1f21] z-20 relative">
- <div class="flex items-center gap-3">
- <div class="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
- <FileJson size={20} />
- </div>
- <div class="flex flex-col">
- <h3 class="text-lg font-bold text-white leading-none">Configuration Editor</h3>
- <span class="text-[10px] text-zinc-500 font-mono mt-1 break-all">{settingsState.configFilePath}</span>
- </div>
- </div>
- <div class="flex items-center gap-2">
- <!-- Undo/Redo Buttons -->
- <div class="flex items-center bg-zinc-800 rounded-lg p-0.5 mr-2 border border-zinc-700">
- <button
- onclick={handleUndo}
- disabled={historyIndex === 0}
- class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
- title="Undo (Ctrl+Z)"
- >
- <Undo size={16} />
- </button>
- <button
- onclick={handleRedo}
- disabled={historyIndex === history.length - 1}
- class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
- title="Redo (Ctrl+Y)"
- >
- <Redo size={16} />
- </button>
- </div>
-
- <!-- Settings Toggle -->
- <div class="relative">
- <button
- onclick={() => showSettings = !showSettings}
- class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg {showSettings ? 'bg-white/10 text-white' : ''}"
- title="Editor Settings"
- >
- <Settings size={20} />
- </button>
-
- {#if showSettings}
- <div class="absolute right-0 top-full mt-2 w-48 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl p-2 z-50 flex flex-col gap-1">
- <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer">
- <input type="checkbox" bind:checked={showLineNumbers} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" />
- <span class="text-sm text-zinc-300">Line Numbers</span>
- </label>
- <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer">
- <input type="checkbox" bind:checked={showStatusBar} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" />
- <span class="text-sm text-zinc-300">Cursor Status</span>
- </label>
- </div>
- {/if}
- </div>
-
- <button
- onclick={() => settingsState.closeConfigEditor()}
- class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg"
- title="Close (Esc)"
- >
- <X size={20} />
- </button>
- </div>
- </div>
-
- <!-- Error Banner -->
- {#if localError || settingsState.configEditorError}
- <div class="bg-red-500/10 border-b border-red-500/20 p-3 flex items-start gap-3 animate-in slide-in-from-top-2 z-10 relative">
- <AlertCircle size={16} class="text-red-400 mt-0.5 shrink-0" />
- <p class="text-xs text-red-300 font-mono whitespace-pre-wrap">{localError || settingsState.configEditorError}</p>
- </div>
- {/if}
-
- <!-- Editor Body (Flex row for line numbers + code) -->
- <div class="flex-1 flex overflow-hidden relative bg-[#1d1f21]">
- <!-- Line Numbers -->
- {#if showLineNumbers}
- <div
- bind:this={lineNumbersRef}
- class="pt-4 pb-4 pr-3 pl-2 text-right text-zinc-600 bg-[#1d1f21] border-r border-zinc-700/50 font-mono select-none overflow-hidden min-w-[3rem]"
- aria-hidden="true"
- >
- {#each lines as _, i}
- <div class="leading-[20px] text-[13px]">{i + 1}</div>
- {/each}
- </div>
- {/if}
-
- <!-- Code Area -->
- <div class="flex-1 relative overflow-hidden group">
- <!-- Highlighted Code (Background) -->
- <pre
- bind:this={preRef}
- aria-hidden="true"
- class="absolute inset-0 w-full h-full p-4 m-0 bg-transparent pointer-events-none overflow-hidden whitespace-pre font-mono text-sm leading-relaxed"
- ><code class="language-json">{@html highlightedCode}</code></pre>
-
- <!-- Textarea (Foreground) -->
- <textarea
- bind:this={textareaRef}
- bind:value={content}
- oninput={handleInput}
- onkeydown={handleKeydown}
- onscroll={handleScroll}
- onmouseup={updateCursor}
- onkeyup={updateCursor}
- onclick={() => showSettings = false}
- class="absolute inset-0 w-full h-full p-4 bg-transparent text-transparent caret-white font-mono text-sm leading-relaxed resize-none focus:outline-none whitespace-pre overflow-auto z-10 selection:bg-indigo-500/30"
- spellcheck="false"
- {...textareaAttrs}
- ></textarea>
- </div>
- </div>
-
- <!-- Footer -->
- <div class="p-3 border-t border-zinc-700 bg-[#1d1f21] flex justify-between items-center z-20 relative">
- <div class="text-xs text-zinc-500 flex gap-4 items-center">
- {#if showStatusBar}
- <div class="flex gap-3 font-mono border-r border-zinc-700 pr-4 mr-1">
- <span>Ln {cursorLine}</span>
- <span>Col {cursorCol}</span>
- </div>
- {/if}
- <span class="hidden sm:inline"><span class="bg-white/10 px-1.5 py-0.5 rounded text-zinc-300">Ctrl+S</span> save</span>
- </div>
- <div class="flex gap-3">
- <button
- onclick={() => settingsState.closeConfigEditor()}
- class="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg text-sm font-medium transition-colors"
- >
- Cancel
- </button>
- <button
- onclick={() => handleSave(false)}
- disabled={isSaving || !!localError}
- class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
- title={localError ? "Fix errors before saving" : "Save changes"}
- >
- {#if isSaving}
- <div class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
- Saving...
- {:else}
- <Save size={16} />
- Save
- {/if}
- </button>
- </div>
- </div>
- </div>
-</div>
-
-<style>
- /* Ensure exact font match */
- pre, textarea {
- font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
- font-size: 13px !important;
- line-height: 20px !important;
- letter-spacing: 0px !important;
- tab-size: 2;
- }
-
- /* Hide scrollbar for pre but keep it functional for textarea */
- pre::-webkit-scrollbar {
- display: none;
- }
-
- /* Override Prism background and font weights for alignment */
- :global(pre[class*="language-"]), :global(code[class*="language-"]) {
- background: transparent !important;
- text-shadow: none !important;
- box-shadow: none !important;
- }
-
- /* CRITICAL: Force normal weight to match textarea */
- :global(.token) {
- font-weight: normal !important;
- font-style: normal !important;
- }
-</style>
diff --git a/packages/ui/src/components/CustomSelect.svelte b/packages/ui/src/components/CustomSelect.svelte
deleted file mode 100644
index 0767471..0000000
--- a/packages/ui/src/components/CustomSelect.svelte
+++ /dev/null
@@ -1,173 +0,0 @@
-<script lang="ts">
- import { ChevronDown, Check } from 'lucide-svelte';
-
- interface Option {
- value: string;
- label: string;
- disabled?: boolean;
- }
-
- interface Props {
- options: Option[];
- value: string;
- placeholder?: string;
- disabled?: boolean;
- class?: string;
- allowCustom?: boolean; // New prop to allow custom input
- onchange?: (value: string) => void;
- }
-
- let {
- options,
- value = $bindable(),
- placeholder = "Select...",
- disabled = false,
- class: className = "",
- allowCustom = false,
- onchange
- }: Props = $props();
-
- let isOpen = $state(false);
- let containerRef: HTMLDivElement;
- let customInput = $state(""); // State for custom input
-
- let selectedOption = $derived(options.find(o => o.value === value));
- // Display label: if option exists use its label, otherwise if custom is allowed use raw value, else placeholder
- let displayLabel = $derived(selectedOption ? selectedOption.label : (allowCustom && value ? value : placeholder));
-
- function toggle() {
- if (!disabled) {
- isOpen = !isOpen;
- // When opening, if current value is custom (not in options), pre-fill input
- if (isOpen && allowCustom && !selectedOption) {
- customInput = value;
- }
- }
- }
-
- function select(option: Option) {
- if (option.disabled) return;
- value = option.value;
- isOpen = false;
- onchange?.(option.value);
- }
-
- function handleCustomSubmit() {
- if (!customInput.trim()) return;
- value = customInput.trim();
- isOpen = false;
- onchange?.(value);
- }
-
- function handleKeydown(e: KeyboardEvent) {
- if (disabled) return;
-
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- toggle();
- } else if (e.key === 'Escape') {
- isOpen = false;
- } else if (e.key === 'ArrowDown' && isOpen) {
- e.preventDefault();
- const currentIndex = options.findIndex(o => o.value === value);
- const nextIndex = Math.min(currentIndex + 1, options.length - 1);
- if (!options[nextIndex].disabled) {
- value = options[nextIndex].value;
- }
- } else if (e.key === 'ArrowUp' && isOpen) {
- e.preventDefault();
- const currentIndex = options.findIndex(o => o.value === value);
- const prevIndex = Math.max(currentIndex - 1, 0);
- if (!options[prevIndex].disabled) {
- value = options[prevIndex].value;
- }
- }
- }
-
- function handleClickOutside(e: MouseEvent) {
- if (containerRef && !containerRef.contains(e.target as Node)) {
- isOpen = false;
- }
- }
-
- $effect(() => {
- if (isOpen) {
- document.addEventListener('click', handleClickOutside);
- return () => document.removeEventListener('click', handleClickOutside);
- }
- });
-</script>
-
-<div
- bind:this={containerRef}
- class="relative {className}"
->
- <!-- Trigger Button -->
- <button
- type="button"
- onclick={toggle}
- onkeydown={handleKeydown}
- {disabled}
- class="w-full flex items-center justify-between gap-2 px-3 py-2 pr-8 text-left
- bg-zinc-900 border border-zinc-700 rounded-md text-sm text-zinc-200
- hover:border-zinc-600 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
- transition-colors cursor-pointer outline-none
- disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-700"
- >
- <span class="truncate {(!selectedOption && !value) ? 'text-zinc-500' : ''}">
- {displayLabel}
- </span>
- <ChevronDown
- size={14}
- class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}"
- />
- </button>
-
- <!-- Dropdown Menu -->
- {#if isOpen}
- <div
- class="absolute z-50 w-full mt-1 py-1 bg-zinc-900 border border-zinc-700 rounded-md shadow-xl
- max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 flex flex-col"
- >
- {#if allowCustom}
- <div class="px-2 py-2 border-b border-zinc-700/50 mb-1">
- <div class="flex gap-2">
- <input
- type="text"
- bind:value={customInput}
- placeholder="Custom value..."
- class="flex-1 bg-black/30 border border-zinc-700 rounded px-2 py-1 text-xs text-white focus:border-indigo-500 outline-none"
- onkeydown={(e) => e.key === 'Enter' && handleCustomSubmit()}
- onclick={(e) => e.stopPropagation()}
- />
- <button
- onclick={(e) => { e.stopPropagation(); handleCustomSubmit(); }}
- class="px-2 py-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded text-xs transition-colors"
- >
- Set
- </button>
- </div>
- </div>
- {/if}
-
- {#each options as option}
- <button
- type="button"
- onclick={() => select(option)}
- disabled={option.disabled}
- class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
- transition-colors outline-none
- {option.value === value
- ? 'bg-indigo-600 text-white'
- : 'text-zinc-300 hover:bg-zinc-800'}
- {option.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}"
- >
- <span class="truncate">{option.label}</span>
- {#if option.value === value}
- <Check size={14} class="shrink-0 ml-2" />
- {/if}
- </button>
- {/each}
- </div>
- {/if}
-</div>
diff --git a/packages/ui/src/components/HomeView.svelte b/packages/ui/src/components/HomeView.svelte
deleted file mode 100644
index 573d9da..0000000
--- a/packages/ui/src/components/HomeView.svelte
+++ /dev/null
@@ -1,271 +0,0 @@
-<script lang="ts">
- import { onMount } from 'svelte';
- import { gameState } from '../stores/game.svelte';
- import { releasesState } from '../stores/releases.svelte';
- import { Calendar, ExternalLink } from 'lucide-svelte';
- import { getSaturnEffect } from './ParticleBackground.svelte';
-
- type Props = {
- mouseX: number;
- mouseY: number;
- };
- let { mouseX = 0, mouseY = 0 }: Props = $props();
-
- // Saturn effect mouse interaction handlers
- function handleSaturnMouseDown(e: MouseEvent) {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleMouseDown(e.clientX);
- }
- }
-
- function handleSaturnMouseMove(e: MouseEvent) {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleMouseMove(e.clientX);
- }
- }
-
- function handleSaturnMouseUp() {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleMouseUp();
- }
- }
-
- function handleSaturnMouseLeave() {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleMouseUp();
- }
- }
-
- function handleSaturnTouchStart(e: TouchEvent) {
- if (e.touches.length === 1) {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleTouchStart(e.touches[0].clientX);
- }
- }
- }
-
- function handleSaturnTouchMove(e: TouchEvent) {
- if (e.touches.length === 1) {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleTouchMove(e.touches[0].clientX);
- }
- }
- }
-
- function handleSaturnTouchEnd() {
- const effect = getSaturnEffect();
- if (effect) {
- effect.handleTouchEnd();
- }
- }
-
- onMount(() => {
- releasesState.loadReleases();
- });
-
- function formatDate(dateString: string) {
- return new Date(dateString).toLocaleDateString(undefined, {
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- });
- }
-
- function escapeHtml(unsafe: string) {
- return unsafe
- .replace(/&/g, "&amp;")
- .replace(/</g, "&lt;")
- .replace(/>/g, "&gt;")
- .replace(/"/g, "&quot;")
- .replace(/'/g, "&#039;");
- }
-
- // Enhanced markdown parser with Emoji and GitHub specific features
- function formatBody(body: string) {
- if (!body) return '';
-
- // Escape HTML first to prevent XSS
- let processed = escapeHtml(body);
-
- // Emoji map (common GitHub emojis)
- 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:': 'ðŸŒ'
- };
-
- // Replace emojis
- processed = processed.replace(/:[a-z0-9_]+:/g, (match) => emojiMap[match] || match);
-
- // GitHub commit hash linking (simple version for 7-40 hex chars inside backticks)
- 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>`;
- });
-
- // Auto-link users (@user)
- 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();
-
- // Formatting helper
- 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>');
-
- // Lists
- 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>`;
- }
-
- // Headers
- 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>`;
- }
-
- // Blockquotes
- 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>`;
- }
-
- // Empty lines
- if (line === '') return '<div class="h-2"></div>';
-
- // Paragraphs
- return `<p class="mb-1.5 leading-relaxed">${formatLine(line)}</p>`;
- }).join('');
- }
-</script>
-
-<div class="absolute inset-0 z-0 overflow-hidden pointer-events-none">
- <!-- Fixed Background -->
- <div class="absolute inset-0 bg-gradient-to-t from-[#09090b] via-[#09090b]/60 to-transparent"></div>
-</div>
-
-<!-- Scrollable Container -->
-<div class="relative z-10 h-full {releasesState.isLoading ? 'overflow-hidden' : 'overflow-y-auto custom-scrollbar scroll-smooth'}">
-
- <!-- Hero Section (Full Height) - Interactive area for Saturn rotation -->
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="min-h-full flex flex-col justify-end p-12 pb-32 cursor-grab active:cursor-grabbing select-none"
- onmousedown={handleSaturnMouseDown}
- onmousemove={handleSaturnMouseMove}
- onmouseup={handleSaturnMouseUp}
- onmouseleave={handleSaturnMouseLeave}
- ontouchstart={handleSaturnTouchStart}
- ontouchmove={handleSaturnTouchMove}
- ontouchend={handleSaturnTouchEnd}
- >
- <!-- 3D Floating Hero Text -->
- <div
- class="transition-transform duration-200 ease-out origin-bottom-left"
- style:transform={`perspective(1000px) rotateX(${mouseY * -1}deg) rotateY(${mouseX * 1}deg)`}
- >
- <div class="flex items-center gap-3 mb-6">
- <div class="h-px w-12 bg-white/50"></div>
- <span class="text-xs font-mono font-bold tracking-[0.2em] text-white/50 uppercase">Launcher Active</span>
- </div>
-
- <h1
- class="text-8xl font-black tracking-tighter text-white mb-6 leading-none"
- >
- MINECRAFT
- </h1>
-
- <div class="flex items-center gap-4">
- <div
- class="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 class="h-4 w-px bg-white/20"></div>
- <div class="text-xl font-light text-zinc-400">
- Latest Release <span class="text-white font-medium">{gameState.latestRelease?.id || '...'}</span>
- </div>
- </div>
- </div>
-
- <!-- Action Area -->
- <div class="mt-8 flex gap-4">
- <div class="text-zinc-500 text-sm font-mono">
- > Ready to launch session.
- </div>
- </div>
-
- <!-- Scroll Hint -->
- {#if !releasesState.isLoading && releasesState.releases.length > 0}
- <div class="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 class="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" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 13l5 5 5-5M7 6l5 5 5-5"/></svg>
- </div>
- {/if}
- </div>
-
- <!-- Changelog / Updates Section -->
- <div class="bg-[#09090b] relative z-20 px-12 pb-24 pt-12 border-t border-white/5 min-h-[50vh]">
- <div class="max-w-4xl">
- <h2 class="text-2xl font-bold text-white mb-10 flex items-center gap-3">
- <span class="w-1.5 h-8 bg-emerald-500 rounded-sm"></span>
- LATEST UPDATES
- </h2>
-
- {#if releasesState.isLoading}
- <div class="flex flex-col gap-8">
- {#each Array(3) as _}
- <div class="h-48 bg-white/5 rounded-sm animate-pulse border border-white/5"></div>
- {/each}
- </div>
- {:else if releasesState.error}
- <div class="p-6 border border-red-500/20 bg-red-500/10 text-red-400 rounded-sm">
- Failed to load updates: {releasesState.error}
- </div>
- {:else if releasesState.releases.length === 0}
- <div class="text-zinc-500 italic">No releases found.</div>
- {:else}
- <div class="space-y-12">
- {#each releasesState.releases as release}
- <div class="group relative pl-8 border-l border-white/10 pb-4 last:pb-0 last:border-l-0">
- <!-- Timeline Dot -->
- <div class="absolute -left-[5px] 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 class="flex items-baseline gap-4 mb-3">
- <h3 class="text-xl font-bold text-white group-hover:text-emerald-400 transition-colors">
- {release.name || release.tag_name}
- </h3>
- <div class="text-xs font-mono text-zinc-500 flex items-center gap-2">
- <Calendar size={12} />
- {formatDate(release.published_at)}
- </div>
- </div>
-
- <div class="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 class="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">
- {@html formatBody(release.body)}
- </div>
- </div>
-
- <a href={release.html_url} target="_blank" class="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>
- {/each}
- </div>
- {/if}
- </div>
- </div>
-</div>
diff --git a/packages/ui/src/components/InstanceCreationModal.svelte b/packages/ui/src/components/InstanceCreationModal.svelte
deleted file mode 100644
index c54cb98..0000000
--- a/packages/ui/src/components/InstanceCreationModal.svelte
+++ /dev/null
@@ -1,485 +0,0 @@
-<script lang="ts">
- import { invoke } from "@tauri-apps/api/core";
- import { X, ChevronLeft, ChevronRight, Loader2, Search } from "lucide-svelte";
- import { instancesState } from "../stores/instances.svelte";
- import { gameState } from "../stores/game.svelte";
- import type { Version, Instance, FabricLoaderEntry, ForgeVersion } from "../types";
-
- interface Props {
- isOpen: boolean;
- onClose: () => void;
- }
-
- let { isOpen, onClose }: Props = $props();
-
- // Wizard steps: 1 = Name, 2 = Version, 3 = Mod Loader
- let currentStep = $state(1);
- let instanceName = $state("");
- let selectedVersion = $state<Version | null>(null);
- let modLoaderType = $state<"vanilla" | "fabric" | "forge">("vanilla");
- let selectedFabricLoader = $state("");
- let selectedForgeLoader = $state("");
- let creating = $state(false);
- let errorMessage = $state("");
-
- // Mod loader lists
- let fabricLoaders = $state<FabricLoaderEntry[]>([]);
- let forgeVersions = $state<ForgeVersion[]>([]);
- let loadingLoaders = $state(false);
-
- // Version list filtering
- let versionSearch = $state("");
- let versionFilter = $state<"all" | "release" | "snapshot">("release");
-
- // Filtered versions
- let filteredVersions = $derived(() => {
- let versions = gameState.versions || [];
-
- // Filter by type
- if (versionFilter !== "all") {
- versions = versions.filter((v) => v.type === versionFilter);
- }
-
- // Search filter
- if (versionSearch) {
- versions = versions.filter((v) =>
- v.id.toLowerCase().includes(versionSearch.toLowerCase())
- );
- }
-
- return versions;
- });
-
- // Fetch mod loaders when entering step 3
- async function loadModLoaders() {
- if (!selectedVersion) return;
-
- loadingLoaders = true;
- try {
- if (modLoaderType === "fabric") {
- const loaders = await invoke<FabricLoaderEntry[]>("get_fabric_loaders_for_version", {
- gameVersion: selectedVersion.id,
- });
- fabricLoaders = loaders;
- if (loaders.length > 0) {
- selectedFabricLoader = loaders[0].loader.version;
- }
- } else if (modLoaderType === "forge") {
- const versions = await invoke<ForgeVersion[]>("get_forge_versions_for_game", {
- gameVersion: selectedVersion.id,
- });
- forgeVersions = versions;
- if (versions.length > 0) {
- selectedForgeLoader = versions[0].version;
- }
- }
- } catch (err) {
- errorMessage = `Failed to load ${modLoaderType} versions: ${err}`;
- } finally {
- loadingLoaders = false;
- }
- }
-
- // Watch for mod loader type changes and load loaders
- $effect(() => {
- if (currentStep === 3 && modLoaderType !== "vanilla") {
- loadModLoaders();
- }
- });
-
- // Reset modal state
- function resetModal() {
- currentStep = 1;
- instanceName = "";
- selectedVersion = null;
- modLoaderType = "vanilla";
- selectedFabricLoader = "";
- selectedForgeLoader = "";
- creating = false;
- errorMessage = "";
- versionSearch = "";
- versionFilter = "release";
- }
-
- function handleClose() {
- if (!creating) {
- resetModal();
- onClose();
- }
- }
-
- function goToStep(step: number) {
- errorMessage = "";
- currentStep = step;
- }
-
- function validateStep1() {
- if (!instanceName.trim()) {
- errorMessage = "Please enter an instance name";
- return false;
- }
- return true;
- }
-
- function validateStep2() {
- if (!selectedVersion) {
- errorMessage = "Please select a Minecraft version";
- return false;
- }
- return true;
- }
-
- async function handleNext() {
- errorMessage = "";
-
- if (currentStep === 1) {
- if (validateStep1()) {
- goToStep(2);
- }
- } else if (currentStep === 2) {
- if (validateStep2()) {
- goToStep(3);
- }
- }
- }
-
- async function handleCreate() {
- if (!validateStep1() || !validateStep2()) return;
-
- creating = true;
- errorMessage = "";
-
- try {
- // Step 1: Create instance
- const instance: Instance = await invoke("create_instance", {
- name: instanceName.trim(),
- });
-
- // Step 2: Install vanilla version
- await invoke("install_version", {
- instanceId: instance.id,
- versionId: selectedVersion!.id,
- });
-
- // Step 3: Install mod loader if selected
- if (modLoaderType === "fabric" && selectedFabricLoader) {
- await invoke("install_fabric", {
- instanceId: instance.id,
- gameVersion: selectedVersion!.id,
- loaderVersion: selectedFabricLoader,
- });
- } else if (modLoaderType === "forge" && selectedForgeLoader) {
- await invoke("install_forge", {
- instanceId: instance.id,
- gameVersion: selectedVersion!.id,
- forgeVersion: selectedForgeLoader,
- });
- } else {
- // Update instance with vanilla version_id
- await invoke("update_instance", {
- instance: { ...instance, version_id: selectedVersion!.id },
- });
- }
-
- // Reload instances
- await instancesState.loadInstances();
-
- // Success! Close modal
- resetModal();
- onClose();
- } catch (error) {
- errorMessage = String(error);
- creating = false;
- }
- }
-</script>
-
-{#if isOpen}
- <div
- class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
- role="dialog"
- aria-modal="true"
- >
- <div
- class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col"
- >
- <!-- Header -->
- <div
- class="flex items-center justify-between p-6 border-b border-zinc-700"
- >
- <div>
- <h2 class="text-xl font-bold text-white">Create New Instance</h2>
- <p class="text-sm text-zinc-400 mt-1">
- Step {currentStep} of 3
- </p>
- </div>
- <button
- onclick={handleClose}
- disabled={creating}
- class="p-2 rounded-lg hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors disabled:opacity-50"
- >
- <X size={20} />
- </button>
- </div>
-
- <!-- Progress indicator -->
- <div class="flex gap-2 px-6 pt-4">
- <div
- class="flex-1 h-1 rounded-full transition-colors {currentStep >= 1
- ? 'bg-indigo-500'
- : 'bg-zinc-700'}"
- ></div>
- <div
- class="flex-1 h-1 rounded-full transition-colors {currentStep >= 2
- ? 'bg-indigo-500'
- : 'bg-zinc-700'}"
- ></div>
- <div
- class="flex-1 h-1 rounded-full transition-colors {currentStep >= 3
- ? 'bg-indigo-500'
- : 'bg-zinc-700'}"
- ></div>
- </div>
-
- <!-- Content -->
- <div class="flex-1 overflow-y-auto p-6">
- {#if currentStep === 1}
- <!-- Step 1: Name -->
- <div class="space-y-4">
- <div>
- <label
- for="instance-name"
- class="block text-sm font-medium text-white/90 mb-2"
- >Instance Name</label
- >
- <input
- id="instance-name"
- type="text"
- bind:value={instanceName}
- placeholder="My Minecraft Instance"
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
- disabled={creating}
- />
- </div>
- <p class="text-xs text-zinc-400">
- Give your instance a memorable name
- </p>
- </div>
- {:else if currentStep === 2}
- <!-- Step 2: Version Selection -->
- <div class="space-y-4">
- <div class="flex gap-4">
- <div class="flex-1 relative">
- <Search
- size={16}
- class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
- />
- <input
- type="text"
- bind:value={versionSearch}
- placeholder="Search versions..."
- class="w-full pl-10 pr-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
- />
- </div>
- <div class="flex gap-2">
- {#each [
- { value: "all", label: "All" },
- { value: "release", label: "Release" },
- { value: "snapshot", label: "Snapshot" },
- ] as filter}
- <button
- onclick={() => {
- versionFilter = filter.value as "all" | "release" | "snapshot";
- }}
- class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {versionFilter ===
- filter.value
- ? 'bg-indigo-600 text-white'
- : 'bg-zinc-800 text-zinc-400 hover:text-white'}"
- >
- {filter.label}
- </button>
- {/each}
- </div>
- </div>
-
- <div class="max-h-96 overflow-y-auto space-y-2">
- {#each filteredVersions() as version}
- <button
- onclick={() => (selectedVersion = version)}
- class="w-full p-3 rounded-lg border transition-colors text-left {selectedVersion?.id ===
- version.id
- ? 'bg-indigo-600/20 border-indigo-500 text-white'
- : 'bg-zinc-800 border-zinc-700 text-zinc-300 hover:border-zinc-600'}"
- >
- <div class="flex items-center justify-between">
- <span class="font-medium">{version.id}</span>
- <span
- class="text-xs px-2 py-1 rounded-full {version.type ===
- 'release'
- ? 'bg-green-500/20 text-green-400'
- : 'bg-yellow-500/20 text-yellow-400'}"
- >
- {version.type}
- </span>
- </div>
- </button>
- {/each}
-
- {#if filteredVersions().length === 0}
- <div class="text-center py-8 text-zinc-500">
- No versions found
- </div>
- {/if}
- </div>
- </div>
- {:else if currentStep === 3}
- <!-- Step 3: Mod Loader -->
- <div class="space-y-4">
- <div>
- <div class="text-sm font-medium text-white/90 mb-3">
- Mod Loader Type
- </div>
- <div class="flex gap-3">
- {#each [
- { value: "vanilla", label: "Vanilla" },
- { value: "fabric", label: "Fabric" },
- { value: "forge", label: "Forge" },
- ] as loader}
- <button
- onclick={() => {
- modLoaderType = loader.value as "vanilla" | "fabric" | "forge";
- }}
- class="flex-1 px-4 py-3 rounded-lg text-sm font-medium transition-colors {modLoaderType ===
- loader.value
- ? 'bg-indigo-600 text-white'
- : 'bg-zinc-800 text-zinc-400 hover:text-white'}"
- >
- {loader.label}
- </button>
- {/each}
- </div>
- </div>
-
- {#if modLoaderType === "fabric"}
- <div>
- <label for="fabric-loader" class="block text-sm font-medium text-white/90 mb-2">
- Fabric Loader Version
- </label>
- {#if loadingLoaders}
- <div class="flex items-center gap-2 text-zinc-400">
- <Loader2 size={16} class="animate-spin" />
- Loading Fabric versions...
- </div>
- {:else if fabricLoaders.length > 0}
- <select
- id="fabric-loader"
- bind:value={selectedFabricLoader}
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
- >
- {#each fabricLoaders as loader}
- <option value={loader.loader.version}>
- {loader.loader.version} {loader.loader.stable ? "(Stable)" : "(Beta)"}
- </option>
- {/each}
- </select>
- {:else}
- <p class="text-sm text-red-400">No Fabric loaders available for this version</p>
- {/if}
- </div>
- {:else if modLoaderType === "forge"}
- <div>
- <label for="forge-version" class="block text-sm font-medium text-white/90 mb-2">
- Forge Version
- </label>
- {#if loadingLoaders}
- <div class="flex items-center gap-2 text-zinc-400">
- <Loader2 size={16} class="animate-spin" />
- Loading Forge versions...
- </div>
- {:else if forgeVersions.length > 0}
- <select
- id="forge-version"
- bind:value={selectedForgeLoader}
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
- >
- {#each forgeVersions as version}
- <option value={version.version}>
- {version.version}
- </option>
- {/each}
- </select>
- {:else}
- <p class="text-sm text-red-400">No Forge versions available for this version</p>
- {/if}
- </div>
- {:else if modLoaderType === "vanilla"}
- <p class="text-sm text-zinc-400">
- Create a vanilla Minecraft instance without any mod loaders
- </p>
- {/if}
- </div>
- {/if}
-
- {#if errorMessage}
- <div
- class="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm"
- >
- {errorMessage}
- </div>
- {/if}
- </div>
-
- <!-- Footer -->
- <div
- class="flex items-center justify-between gap-3 p-6 border-t border-zinc-700"
- >
- <button
- onclick={() => goToStep(currentStep - 1)}
- disabled={currentStep === 1 || creating}
- class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
- >
- <ChevronLeft size={16} />
- Back
- </button>
-
- <div class="flex gap-3">
- <button
- onclick={handleClose}
- disabled={creating}
- class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white transition-colors disabled:opacity-50"
- >
- Cancel
- </button>
-
- {#if currentStep < 3}
- <button
- onclick={handleNext}
- disabled={creating}
- class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
- >
- Next
- <ChevronRight size={16} />
- </button>
- {:else}
- <button
- onclick={handleCreate}
- disabled={creating ||
- !instanceName.trim() ||
- !selectedVersion ||
- (modLoaderType === "fabric" && !selectedFabricLoader) ||
- (modLoaderType === "forge" && !selectedForgeLoader)}
- class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
- >
- {#if creating}
- <Loader2 size={16} class="animate-spin" />
- Creating...
- {:else}
- Create Instance
- {/if}
- </button>
- {/if}
- </div>
- </div>
- </div>
- </div>
-{/if}
diff --git a/packages/ui/src/components/InstanceEditorModal.svelte b/packages/ui/src/components/InstanceEditorModal.svelte
deleted file mode 100644
index 0856d93..0000000
--- a/packages/ui/src/components/InstanceEditorModal.svelte
+++ /dev/null
@@ -1,439 +0,0 @@
-<script lang="ts">
- import { invoke } from "@tauri-apps/api/core";
- import { X, Save, Loader2, Trash2, FolderOpen } from "lucide-svelte";
- import { instancesState } from "../stores/instances.svelte";
- import { gameState } from "../stores/game.svelte";
- import { settingsState } from "../stores/settings.svelte";
- import type { Instance, FileInfo, FabricLoaderEntry, ForgeVersion } from "../types";
- import ModLoaderSelector from "./ModLoaderSelector.svelte";
-
- interface Props {
- isOpen: boolean;
- instance: Instance | null;
- onClose: () => void;
- }
-
- let { isOpen, instance, onClose }: Props = $props();
-
- // Tabs: "info" | "version" | "files" | "settings"
- let activeTab = $state<"info" | "version" | "files" | "settings">("info");
- let saving = $state(false);
- let errorMessage = $state("");
-
- // Info tab state
- let editName = $state("");
- let editNotes = $state("");
-
- // Version tab state
- let fabricLoaders = $state<FabricLoaderEntry[]>([]);
- let forgeVersions = $state<ForgeVersion[]>([]);
- let loadingVersions = $state(false);
-
- // Files tab state
- let selectedFileFolder = $state<"mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots">("mods");
- let fileList = $state<FileInfo[]>([]);
- let loadingFiles = $state(false);
- let deletingPath = $state<string | null>(null);
-
- // Settings tab state
- let editMemoryMin = $state(0);
- let editMemoryMax = $state(0);
- let editJavaArgs = $state("");
-
- // Initialize form when instance changes
- $effect(() => {
- if (isOpen && instance) {
- editName = instance.name;
- editNotes = instance.notes || "";
- editMemoryMin = instance.memory_override?.min || settingsState.settings.min_memory || 512;
- editMemoryMax = instance.memory_override?.max || settingsState.settings.max_memory || 2048;
- editJavaArgs = instance.jvm_args_override || "";
- errorMessage = "";
- }
- });
-
- // Load files when switching to files tab
- $effect(() => {
- if (isOpen && instance && activeTab === "files") {
- loadFileList();
- }
- });
-
- // Load file list for selected folder
- async function loadFileList() {
- if (!instance) return;
-
- loadingFiles = true;
- try {
- const files = await invoke<FileInfo[]>("list_instance_directory", {
- instanceId: instance.id,
- folder: selectedFileFolder,
- });
- fileList = files;
- } catch (err) {
- errorMessage = `Failed to load files: ${err}`;
- fileList = [];
- } finally {
- loadingFiles = false;
- }
- }
-
- // Change selected folder and reload
- async function changeFolder(folder: "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots") {
- selectedFileFolder = folder;
- await loadFileList();
- }
-
- // Delete a file or directory
- async function deleteFile(filePath: string) {
- if (!confirm(`Are you sure you want to delete "${filePath.split("/").pop()}"?`)) {
- return;
- }
-
- deletingPath = filePath;
- try {
- await invoke("delete_instance_file", { path: filePath });
- // Reload file list
- await loadFileList();
- } catch (err) {
- errorMessage = `Failed to delete file: ${err}`;
- } finally {
- deletingPath = null;
- }
- }
-
- // Open file in system explorer
- async function openInExplorer(filePath: string) {
- try {
- await invoke("open_file_explorer", { path: filePath });
- } catch (err) {
- errorMessage = `Failed to open file explorer: ${err}`;
- }
- }
-
- // Save instance changes
- async function saveChanges() {
- if (!instance) return;
- if (!editName.trim()) {
- errorMessage = "Instance name cannot be empty";
- return;
- }
-
- saving = true;
- errorMessage = "";
-
- try {
- const updatedInstance: Instance = {
- ...instance,
- name: editName.trim(),
- notes: editNotes.trim() || undefined,
- memory_override: {
- min: editMemoryMin,
- max: editMemoryMax,
- },
- jvm_args_override: editJavaArgs.trim() || undefined,
- };
-
- await instancesState.updateInstance(updatedInstance);
- onClose();
- } catch (err) {
- errorMessage = `Failed to save instance: ${err}`;
- } finally {
- saving = false;
- }
- }
-
- function formatFileSize(bytes: number): string {
- if (bytes === 0) return "0 B";
- const k = 1024;
- const sizes = ["B", "KB", "MB", "GB"];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
- }
-
- function formatDate(timestamp: number): string {
- return new Date(timestamp * 1000).toLocaleDateString();
- }
-</script>
-
-{#if isOpen && instance}
- <div
- class="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
- role="dialog"
- aria-modal="true"
- >
- <div
- class="bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"
- >
- <!-- Header -->
- <div class="flex items-center justify-between p-6 border-b border-zinc-700">
- <div>
- <h2 class="text-xl font-bold text-white">Edit Instance</h2>
- <p class="text-sm text-zinc-400 mt-1">{instance.name}</p>
- </div>
- <button
- onclick={onClose}
- disabled={saving}
- class="p-2 rounded-lg hover:bg-zinc-800 text-zinc-400 hover:text-white transition-colors disabled:opacity-50"
- >
- <X size={20} />
- </button>
- </div>
-
- <!-- Tab Navigation -->
- <div class="flex gap-1 px-6 pt-4 border-b border-zinc-700">
- {#each [
- { id: "info", label: "Info" },
- { id: "version", label: "Version" },
- { id: "files", label: "Files" },
- { id: "settings", label: "Settings" },
- ] as tab}
- <button
- onclick={() => (activeTab = tab.id as any)}
- class="px-4 py-2 text-sm font-medium transition-colors rounded-t-lg {activeTab === tab.id
- ? 'bg-indigo-600 text-white'
- : 'bg-zinc-800 text-zinc-400 hover:text-white'}"
- >
- {tab.label}
- </button>
- {/each}
- </div>
-
- <!-- Content Area -->
- <div class="flex-1 overflow-y-auto p-6">
- {#if activeTab === "info"}
- <!-- Info Tab -->
- <div class="space-y-4">
- <div>
- <label for="instance-name" class="block text-sm font-medium text-white/90 mb-2">
- Instance Name
- </label>
- <input
- id="instance-name"
- type="text"
- bind:value={editName}
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
- disabled={saving}
- />
- </div>
-
- <div>
- <label for="instance-notes" class="block text-sm font-medium text-white/90 mb-2">
- Notes
- </label>
- <textarea
- id="instance-notes"
- bind:value={editNotes}
- rows="4"
- placeholder="Add notes about this instance..."
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none"
- disabled={saving}
- ></textarea>
- </div>
-
- <div class="grid grid-cols-2 gap-4 text-sm">
- <div class="p-3 bg-zinc-800 rounded-lg">
- <p class="text-zinc-400">Created</p>
- <p class="text-white font-medium">{formatDate(instance.created_at)}</p>
- </div>
- <div class="p-3 bg-zinc-800 rounded-lg">
- <p class="text-zinc-400">Last Played</p>
- <p class="text-white font-medium">
- {instance.last_played ? formatDate(instance.last_played) : "Never"}
- </p>
- </div>
- <div class="p-3 bg-zinc-800 rounded-lg">
- <p class="text-zinc-400">Game Directory</p>
- <p class="text-white font-medium text-xs truncate" title={instance.game_dir}>
- {instance.game_dir.split("/").pop()}
- </p>
- </div>
- <div class="p-3 bg-zinc-800 rounded-lg">
- <p class="text-zinc-400">Current Version</p>
- <p class="text-white font-medium">{instance.version_id || "None"}</p>
- </div>
- </div>
- </div>
- {:else if activeTab === "version"}
- <!-- Version Tab -->
- <div class="space-y-4">
- {#if instance.version_id}
- <div class="p-4 bg-indigo-500/10 border border-indigo-500/30 rounded-lg">
- <p class="text-sm text-indigo-400">
- Currently playing: <span class="font-medium">{instance.version_id}</span>
- {#if instance.mod_loader}
- with <span class="capitalize">{instance.mod_loader}</span>
- {instance.mod_loader_version && `${instance.mod_loader_version}`}
- {/if}
- </p>
- </div>
- {/if}
-
- <div>
- <h3 class="text-sm font-medium text-white/90 mb-4">Change Version or Mod Loader</h3>
- <ModLoaderSelector
- selectedGameVersion={instance.version_id || ""}
- onInstall={(versionId) => {
- // Version installed, update instance version_id
- instance.version_id = versionId;
- saveChanges();
- }}
- />
- </div>
- </div>
- {:else if activeTab === "files"}
- <!-- Files Tab -->
- <div class="space-y-4">
- <div class="flex gap-2 flex-wrap">
- {#each ["mods", "resourcepacks", "shaderpacks", "saves", "screenshots"] as folder}
- <button
- onclick={() => changeFolder(folder as any)}
- class="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors {selectedFileFolder ===
- folder
- ? "bg-indigo-600 text-white"
- : "bg-zinc-800 text-zinc-400 hover:text-white"}"
- >
- {folder}
- </button>
- {/each}
- </div>
-
- {#if loadingFiles}
- <div class="flex items-center gap-2 text-zinc-400 py-8 justify-center">
- <Loader2 size={16} class="animate-spin" />
- Loading files...
- </div>
- {:else if fileList.length === 0}
- <div class="text-center py-8 text-zinc-500">
- No files in this folder
- </div>
- {:else}
- <div class="space-y-2">
- {#each fileList as file}
- <div
- class="flex items-center justify-between p-3 bg-zinc-800 rounded-lg hover:bg-zinc-700 transition-colors"
- >
- <div class="flex-1 min-w-0">
- <p class="font-medium text-white truncate">{file.name}</p>
- <p class="text-xs text-zinc-400">
- {file.is_directory ? "Folder" : formatFileSize(file.size)}
- • {formatDate(file.modified)}
- </p>
- </div>
- <div class="flex gap-2 ml-4">
- <button
- onclick={() => openInExplorer(file.path)}
- title="Open in explorer"
- class="p-2 rounded-lg hover:bg-zinc-600 text-zinc-400 hover:text-white transition-colors"
- >
- <FolderOpen size={16} />
- </button>
- <button
- onclick={() => deleteFile(file.path)}
- disabled={deletingPath === file.path}
- title="Delete"
- class="p-2 rounded-lg hover:bg-red-600/20 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
- >
- {#if deletingPath === file.path}
- <Loader2 size={16} class="animate-spin" />
- {:else}
- <Trash2 size={16} />
- {/if}
- </button>
- </div>
- </div>
- {/each}
- </div>
- {/if}
- </div>
- {:else if activeTab === "settings"}
- <!-- Settings Tab -->
- <div class="space-y-4">
- <div>
- <label for="min-memory" class="block text-sm font-medium text-white/90 mb-2">
- Minimum Memory (MB)
- </label>
- <input
- id="min-memory"
- type="number"
- bind:value={editMemoryMin}
- min="256"
- max={editMemoryMax}
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
- disabled={saving}
- />
- <p class="text-xs text-zinc-400 mt-1">
- Default: {settingsState.settings.min_memory}MB
- </p>
- </div>
-
- <div>
- <label for="max-memory" class="block text-sm font-medium text-white/90 mb-2">
- Maximum Memory (MB)
- </label>
- <input
- id="max-memory"
- type="number"
- bind:value={editMemoryMax}
- min={editMemoryMin}
- max="16384"
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
- disabled={saving}
- />
- <p class="text-xs text-zinc-400 mt-1">
- Default: {settingsState.settings.max_memory}MB
- </p>
- </div>
-
- <div>
- <label for="java-args" class="block text-sm font-medium text-white/90 mb-2">
- JVM Arguments (Advanced)
- </label>
- <textarea
- id="java-args"
- bind:value={editJavaArgs}
- rows="4"
- placeholder="-XX:+UnlockExperimentalVMOptions -XX:G1NewCollectionPercentage=20..."
- class="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 font-mono text-sm resize-none"
- disabled={saving}
- ></textarea>
- <p class="text-xs text-zinc-400 mt-1">
- Leave empty to use global Java arguments
- </p>
- </div>
- </div>
- {/if}
-
- {#if errorMessage}
- <div class="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
- {errorMessage}
- </div>
- {/if}
- </div>
-
- <!-- Footer -->
- <div class="flex items-center justify-end gap-3 p-6 border-t border-zinc-700">
- <button
- onclick={onClose}
- disabled={saving}
- class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white transition-colors disabled:opacity-50"
- >
- Cancel
- </button>
- <button
- onclick={saveChanges}
- disabled={saving || !editName.trim()}
- class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
- >
- {#if saving}
- <Loader2 size={16} class="animate-spin" />
- Saving...
- {:else}
- <Save size={16} />
- Save Changes
- {/if}
- </button>
- </div>
- </div>
- </div>
-{/if}
diff --git a/packages/ui/src/components/InstancesView.svelte b/packages/ui/src/components/InstancesView.svelte
deleted file mode 100644
index 5334f9e..0000000
--- a/packages/ui/src/components/InstancesView.svelte
+++ /dev/null
@@ -1,259 +0,0 @@
-<script lang="ts">
- import { onMount } from "svelte";
- import { instancesState } from "../stores/instances.svelte";
- import { Plus, Trash2, Edit2, Copy, Check, X } from "lucide-svelte";
- import type { Instance } from "../types";
- import InstanceCreationModal from "./InstanceCreationModal.svelte";
- import InstanceEditorModal from "./InstanceEditorModal.svelte";
-
- let showCreateModal = $state(false);
- let showEditModal = $state(false);
- let showDeleteConfirm = $state(false);
- let showDuplicateModal = $state(false);
- let selectedInstance: Instance | null = $state(null);
- let editingInstance: Instance | null = $state(null);
- let newInstanceName = $state("");
- let duplicateName = $state("");
-
- onMount(() => {
- instancesState.loadInstances();
- });
-
- function handleCreate() {
- showCreateModal = true;
- }
-
- function handleEdit(instance: Instance) {
- editingInstance = instance;
- }
-
- function handleDelete(instance: Instance) {
- selectedInstance = instance;
- showDeleteConfirm = true;
- }
-
- function handleDuplicate(instance: Instance) {
- selectedInstance = instance;
- duplicateName = `${instance.name} (Copy)`;
- showDuplicateModal = true;
- }
-
- async function confirmDelete() {
- if (!selectedInstance) return;
- await instancesState.deleteInstance(selectedInstance.id);
- showDeleteConfirm = false;
- selectedInstance = null;
- }
-
- async function confirmDuplicate() {
- if (!selectedInstance || !duplicateName.trim()) return;
- await instancesState.duplicateInstance(selectedInstance.id, duplicateName.trim());
- showDuplicateModal = false;
- selectedInstance = null;
- duplicateName = "";
- }
-
- function formatDate(timestamp: number): string {
- return new Date(timestamp * 1000).toLocaleDateString();
- }
-
- function 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();
- }
-</script>
-
-<div class="h-full flex flex-col gap-4 p-6 overflow-y-auto">
- <div class="flex items-center justify-between">
- <h1 class="text-2xl font-bold text-gray-900 dark:text-white">Instances</h1>
- <button
- onclick={handleCreate}
- class="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>
-
- {#if instancesState.instances.length === 0}
- <div class="flex-1 flex items-center justify-center">
- <div class="text-center text-gray-500 dark:text-gray-400">
- <p class="text-lg mb-2">No instances yet</p>
- <p class="text-sm">Create your first instance to get started</p>
- </div>
- </div>
- {:else}
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
- {#each instancesState.instances as instance (instance.id)}
- <div
- role="button"
- tabindex="0"
- class="relative p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 transition-all cursor-pointer hover:border-blue-500 {instancesState.activeInstanceId === instance.id
- ? 'border-blue-500'
- : 'border-transparent'}"
- onclick={() => instancesState.setActiveInstance(instance.id)}
- onkeydown={(e) => e.key === "Enter" && instancesState.setActiveInstance(instance.id)}
- >
- {#if instancesState.activeInstanceId === instance.id}
- <div class="absolute top-2 right-2">
- <div class="w-3 h-3 bg-blue-500 rounded-full"></div>
- </div>
- {/if}
-
- <div class="flex items-start justify-between mb-2">
- <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
- {instance.name}
- </h3>
- <div class="flex gap-1">
- <button
- type="button"
- onclick={(e) => {
- e.stopPropagation();
- handleEdit(instance);
- }}
- class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
- title="Edit"
- >
- <Edit2 size={16} class="text-gray-600 dark:text-gray-400" />
- </button>
- <button
- type="button"
- onclick={(e) => {
- e.stopPropagation();
- handleDuplicate(instance);
- }}
- class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
- title="Duplicate"
- >
- <Copy size={16} class="text-gray-600 dark:text-gray-400" />
- </button>
- <button
- type="button"
- onclick={(e) => {
- e.stopPropagation();
- handleDelete(instance);
- }}
- class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
- title="Delete"
- >
- <Trash2 size={16} class="text-red-600 dark:text-red-400" />
- </button>
- </div>
- </div>
-
- <div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
- {#if instance.version_id}
- <p>Version: <span class="font-medium">{instance.version_id}</span></p>
- {:else}
- <p class="text-gray-400">No version selected</p>
- {/if}
-
- {#if instance.mod_loader && instance.mod_loader !== "vanilla"}
- <p>
- Mod Loader: <span class="font-medium capitalize">{instance.mod_loader}</span>
- {#if instance.mod_loader_version}
- <span class="text-gray-500">({instance.mod_loader_version})</span>
- {/if}
- </p>
- {/if}
-
- <p>Created: {formatDate(instance.created_at)}</p>
-
- {#if instance.last_played}
- <p>Last played: {formatLastPlayed(instance.last_played)}</p>
- {/if}
- </div>
-
- {#if instance.notes}
- <p class="mt-2 text-sm text-gray-500 dark:text-gray-500 italic">
- {instance.notes}
- </p>
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-</div>
-
-<!-- Create Modal -->
-<InstanceCreationModal isOpen={showCreateModal} onClose={() => (showCreateModal = false)} />
-
-<!-- Instance Editor Modal -->
-<InstanceEditorModal
- isOpen={editingInstance !== null}
- instance={editingInstance}
- onClose={() => {
- editingInstance = null;
- }}
-/>
-
-<!-- Delete Confirmation -->
-{#if showDeleteConfirm && selectedInstance}
- <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
- <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96">
- <h2 class="text-xl font-bold mb-4 text-red-600 dark:text-red-400">Delete Instance</h2>
- <p class="mb-4 text-gray-700 dark:text-gray-300">
- Are you sure you want to delete "{selectedInstance.name}"? This action cannot be undone and will delete all game data for this instance.
- </p>
- <div class="flex gap-2 justify-end">
- <button
- onclick={() => {
- showDeleteConfirm = false;
- selectedInstance = null;
- }}
- class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
- >
- Cancel
- </button>
- <button
- onclick={confirmDelete}
- class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
- >
- Delete
- </button>
- </div>
- </div>
- </div>
-{/if}
-
-<!-- Duplicate Modal -->
-{#if showDuplicateModal && selectedInstance}
- <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
- <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96">
- <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Duplicate Instance</h2>
- <input
- type="text"
- bind:value={duplicateName}
- placeholder="New instance name"
- class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4"
- onkeydown={(e) => e.key === "Enter" && confirmDuplicate()}
- />
- <div class="flex gap-2 justify-end">
- <button
- onclick={() => {
- showDuplicateModal = false;
- selectedInstance = null;
- duplicateName = "";
- }}
- class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
- >
- Cancel
- </button>
- <button
- onclick={confirmDuplicate}
- disabled={!duplicateName.trim()}
- class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
- >
- Duplicate
- </button>
- </div>
- </div>
- </div>
-{/if}
diff --git a/packages/ui/src/components/LoginModal.svelte b/packages/ui/src/components/LoginModal.svelte
deleted file mode 100644
index 1886cd9..0000000
--- a/packages/ui/src/components/LoginModal.svelte
+++ /dev/null
@@ -1,126 +0,0 @@
-<script lang="ts">
- import { open } from "@tauri-apps/plugin-shell";
- import { authState } from "../stores/auth.svelte";
-
- function openLink(url: string) {
- open(url);
- }
-</script>
-
-{#if authState.isLoginModalOpen}
- <div
- class="fixed inset-0 z-[60] flex items-center justify-center bg-white/80 dark:bg-black/80 backdrop-blur-sm p-4"
- >
- <div
- class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-2xl p-6 w-full max-w-md animate-in fade-in zoom-in-0 duration-200"
- >
- <div class="flex justify-between items-center mb-6">
- <h2 class="text-2xl font-bold text-gray-900 dark:text-white">Login</h2>
- <button
- onclick={() => authState.closeLoginModal()}
- class="text-zinc-500 hover:text-black dark:hover:text-white transition group"
- >
- ✕
- </button>
- </div>
-
- {#if authState.loginMode === "select"}
- <div class="space-y-4">
- <button
- onclick={() => authState.startMicrosoftLogin()}
- class="w-full flex items-center justify-center gap-3 bg-gray-100 hover:bg-gray-200 dark:bg-[#2F2F2F] dark:hover:bg-[#3F3F3F] text-gray-900 dark:text-white p-4 rounded-lg font-bold border border-transparent hover:border-zinc-400 dark:hover:border-zinc-500 transition-all group"
- >
- <!-- Microsoft Logo SVG -->
- <svg
- class="w-5 h-5"
- viewBox="0 0 23 23"
- fill="none"
- xmlns="http://www.w3.org/2000/svg"
- ><path fill="#f35325" d="M1 1h10v10H1z" /><path
- fill="#81bc06"
- d="M12 1h10v10H12z"
- /><path fill="#05a6f0" d="M1 12h10v10H1z" /><path
- fill="#ffba08"
- d="M12 12h10v10H12z"
- /></svg
- >
- Microsoft Account
- </button>
-
- <div class="relative py-2">
- <div class="absolute inset-0 flex items-center">
- <div class="w-full border-t border-zinc-200 dark:border-zinc-700"></div>
- </div>
- <div class="relative flex justify-center text-xs uppercase">
- <span class="bg-white dark:bg-zinc-900 px-2 text-zinc-400 dark:text-zinc-500">OR</span>
- </div>
- </div>
-
- <div class="space-y-2">
- <input
- type="text"
- bind:value={authState.offlineUsername}
- placeholder="Offline Username"
- class="w-full bg-gray-50 border-zinc-200 dark:bg-zinc-950 dark:border-zinc-700 rounded p-3 text-gray-900 dark:text-white focus:border-indigo-500 outline-none"
- onkeydown={(e) => e.key === "Enter" && authState.performOfflineLogin()}
- />
- <button
- onclick={() => authState.performOfflineLogin()}
- class="w-full bg-gray-200 hover:bg-gray-300 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300 p-3 rounded font-medium transition-colors"
- >
- Offline Login
- </button>
- </div>
- </div>
- {:else if authState.loginMode === "microsoft"}
- <div class="text-center">
- {#if authState.msLoginLoading && !authState.deviceCodeData}
- <div class="py-8 text-zinc-400 animate-pulse">
- Starting login flow...
- </div>
- {:else if authState.deviceCodeData}
- <div class="space-y-4">
- <p class="text-sm text-gray-500 dark:text-zinc-400">1. Go to this URL:</p>
- <button
- onclick={() =>
- authState.deviceCodeData && openLink(authState.deviceCodeData.verification_uri)}
- class="text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300 underline break-all font-mono text-sm"
- >
- {authState.deviceCodeData.verification_uri}
- </button>
-
- <p class="text-sm text-gray-500 dark:text-zinc-400 mt-2">2. Enter this code:</p>
- <div
- class="bg-gray-50 dark:bg-zinc-950 p-4 rounded border border-zinc-200 dark:border-zinc-700 font-mono text-2xl tracking-widest text-center select-all cursor-pointer hover:border-indigo-500 transition-colors dark:text-white text-gray-900"
- role="button"
- tabindex="0"
- onkeydown={(e) => e.key === 'Enter' && navigator.clipboard.writeText(authState.deviceCodeData?.user_code || "")}
- onclick={() =>
- navigator.clipboard.writeText(
- authState.deviceCodeData?.user_code || ""
- )}
- >
- {authState.deviceCodeData.user_code}
- </div>
- <p class="text-xs text-zinc-500">Click code to copy</p>
-
- <div class="pt-6 space-y-3">
- <div class="flex flex-col items-center gap-3">
- <div class="animate-spin rounded-full h-6 w-6 border-2 border-zinc-300 dark:border-zinc-600 border-t-indigo-500"></div>
- <span class="text-sm text-gray-600 dark:text-zinc-400 font-medium break-all text-center">{authState.msLoginStatus}</span>
- </div>
- <p class="text-xs text-zinc-600">This window will update automatically.</p>
- </div>
-
- <button
- onclick={() => { authState.stopPolling(); authState.loginMode = "select"; }}
- class="text-xs text-zinc-500 hover:text-zinc-300 mt-6 underline"
- >Cancel</button
- >
- </div>
- {/if}
- </div>
- {/if}
- </div>
- </div>
-{/if}
diff --git a/packages/ui/src/components/ModLoaderSelector.svelte b/packages/ui/src/components/ModLoaderSelector.svelte
deleted file mode 100644
index 50caa8c..0000000
--- a/packages/ui/src/components/ModLoaderSelector.svelte
+++ /dev/null
@@ -1,455 +0,0 @@
-<script lang="ts">
- import { invoke } from "@tauri-apps/api/core";
- import { listen, type UnlistenFn } from "@tauri-apps/api/event";
- import type {
- FabricGameVersion,
- FabricLoaderVersion,
- ForgeVersion,
- ModLoaderType,
- } from "../types";
- import { Loader2, Download, AlertCircle, Check, ChevronDown, CheckCircle } from 'lucide-svelte';
- import { logsState } from "../stores/logs.svelte";
- import { instancesState } from "../stores/instances.svelte";
-
- interface Props {
- selectedGameVersion: string;
- onInstall: (versionId: string) => void;
- }
-
- let { selectedGameVersion, onInstall }: Props = $props();
-
- // State
- let selectedLoader = $state<ModLoaderType>("vanilla");
- let isLoading = $state(false);
- let isInstalling = $state(false);
- let error = $state<string | null>(null);
- let isVersionInstalled = $state(false);
-
- // Fabric state
- let fabricLoaders = $state<FabricLoaderVersion[]>([]);
- let selectedFabricLoader = $state("");
- let isFabricDropdownOpen = $state(false);
-
- // Forge state
- let forgeVersions = $state<ForgeVersion[]>([]);
- let selectedForgeVersion = $state("");
- let isForgeDropdownOpen = $state(false);
-
- let fabricDropdownRef = $state<HTMLDivElement | null>(null);
- let forgeDropdownRef = $state<HTMLDivElement | null>(null);
-
- // Check if version is installed when game version changes
- $effect(() => {
- if (selectedGameVersion) {
- checkInstallStatus();
- }
- });
-
- // Load mod loader versions when game version or loader type changes
- $effect(() => {
- if (selectedGameVersion && selectedLoader !== "vanilla") {
- loadModLoaderVersions();
- }
- });
-
- async function checkInstallStatus() {
- if (!selectedGameVersion || !instancesState.activeInstanceId) {
- isVersionInstalled = false;
- return;
- }
- try {
- isVersionInstalled = await invoke<boolean>("check_version_installed", {
- instanceId: instancesState.activeInstanceId,
- versionId: selectedGameVersion,
- });
- } catch (e) {
- console.error("Failed to check install status:", e);
- isVersionInstalled = false;
- }
- }
-
- async function loadModLoaderVersions() {
- isLoading = true;
- error = null;
-
- try {
- if (selectedLoader === "fabric") {
- const loaders = await invoke<any[]>("get_fabric_loaders_for_version", {
- gameVersion: selectedGameVersion,
- });
- fabricLoaders = loaders.map((l) => l.loader);
- if (fabricLoaders.length > 0) {
- const stable = fabricLoaders.find((l) => l.stable);
- selectedFabricLoader = stable?.version || fabricLoaders[0].version;
- }
- } else if (selectedLoader === "forge") {
- forgeVersions = await invoke<ForgeVersion[]>(
- "get_forge_versions_for_game",
- {
- gameVersion: selectedGameVersion,
- }
- );
- if (forgeVersions.length > 0) {
- const recommended = forgeVersions.find((v) => v.recommended);
- const latest = forgeVersions.find((v) => v.latest);
- selectedForgeVersion =
- recommended?.version || latest?.version || forgeVersions[0].version;
- }
- }
- } catch (e) {
- error = `Failed to load ${selectedLoader} versions: ${e}`;
- console.error(e);
- } finally {
- isLoading = false;
- }
- }
-
- async function installVanilla() {
- if (!selectedGameVersion) {
- error = "Please select a Minecraft version first";
- return;
- }
-
- isInstalling = true;
- error = null;
- logsState.addLog("info", "Installer", `Starting installation of ${selectedGameVersion}...`);
-
- if (!instancesState.activeInstanceId) {
- error = "Please select an instance first";
- return;
- }
- try {
- await invoke("install_version", {
- instanceId: instancesState.activeInstanceId,
- versionId: selectedGameVersion,
- });
- logsState.addLog("info", "Installer", `Successfully installed ${selectedGameVersion}`);
- isVersionInstalled = true;
- onInstall(selectedGameVersion);
- } catch (e) {
- error = `Failed to install: ${e}`;
- logsState.addLog("error", "Installer", `Installation failed: ${e}`);
- console.error(e);
- } finally {
- isInstalling = false;
- }
- }
-
- async function installModLoader() {
- if (!selectedGameVersion) {
- error = "Please select a Minecraft version first";
- return;
- }
-
- if (!instancesState.activeInstanceId) {
- error = "Please select an instance first";
- isInstalling = false;
- return;
- }
-
- isInstalling = true;
- error = null;
-
- try {
- // First install the base game if not installed
- if (!isVersionInstalled) {
- logsState.addLog("info", "Installer", `Installing base game ${selectedGameVersion} first...`);
- await invoke("install_version", {
- instanceId: instancesState.activeInstanceId,
- versionId: selectedGameVersion,
- });
- isVersionInstalled = true;
- }
-
- // Then install the mod loader
- if (selectedLoader === "fabric" && selectedFabricLoader) {
- logsState.addLog("info", "Installer", `Installing Fabric ${selectedFabricLoader} for ${selectedGameVersion}...`);
- const result = await invoke<any>("install_fabric", {
- instanceId: instancesState.activeInstanceId,
- gameVersion: selectedGameVersion,
- loaderVersion: selectedFabricLoader,
- });
- logsState.addLog("info", "Installer", `Fabric installed successfully: ${result.id}`);
- onInstall(result.id);
- } else if (selectedLoader === "forge" && selectedForgeVersion) {
- logsState.addLog("info", "Installer", `Installing Forge ${selectedForgeVersion} for ${selectedGameVersion}...`);
- const result = await invoke<any>("install_forge", {
- instanceId: instancesState.activeInstanceId,
- gameVersion: selectedGameVersion,
- forgeVersion: selectedForgeVersion,
- });
- logsState.addLog("info", "Installer", `Forge installed successfully: ${result.id}`);
- onInstall(result.id);
- }
- } catch (e) {
- error = `Failed to install ${selectedLoader}: ${e}`;
- logsState.addLog("error", "Installer", `Installation failed: ${e}`);
- console.error(e);
- } finally {
- isInstalling = false;
- }
- }
-
- function onLoaderChange(loader: ModLoaderType) {
- selectedLoader = loader;
- error = null;
- if (loader !== "vanilla" && selectedGameVersion) {
- loadModLoaderVersions();
- }
- }
-
- function handleFabricClickOutside(e: MouseEvent) {
- if (fabricDropdownRef && !fabricDropdownRef.contains(e.target as Node)) {
- isFabricDropdownOpen = false;
- }
- }
-
- function handleForgeClickOutside(e: MouseEvent) {
- if (forgeDropdownRef && !forgeDropdownRef.contains(e.target as Node)) {
- isForgeDropdownOpen = false;
- }
- }
-
- $effect(() => {
- if (isFabricDropdownOpen) {
- document.addEventListener('click', handleFabricClickOutside);
- return () => document.removeEventListener('click', handleFabricClickOutside);
- }
- });
-
- $effect(() => {
- if (isForgeDropdownOpen) {
- document.addEventListener('click', handleForgeClickOutside);
- return () => document.removeEventListener('click', handleForgeClickOutside);
- }
- });
-
- let selectedFabricLabel = $derived(
- fabricLoaders.find(l => l.version === selectedFabricLoader)
- ? `${selectedFabricLoader}${fabricLoaders.find(l => l.version === selectedFabricLoader)?.stable ? ' (stable)' : ''}`
- : selectedFabricLoader || 'Select version'
- );
-
- let selectedForgeLabel = $derived(
- forgeVersions.find(v => v.version === selectedForgeVersion)
- ? `${selectedForgeVersion}${forgeVersions.find(v => v.version === selectedForgeVersion)?.recommended ? ' (Recommended)' : ''}`
- : selectedForgeVersion || 'Select version'
- );
-</script>
-
-<div class="space-y-4">
- <div class="flex items-center justify-between">
- <h3 class="text-xs font-bold uppercase tracking-widest text-zinc-500">Loader Type</h3>
- </div>
-
- <!-- Loader Type Tabs - Simple Clean Style -->
- <div class="flex p-1 bg-zinc-100 dark:bg-zinc-900/50 rounded-sm border border-zinc-200 dark:border-white/5">
- {#each ['vanilla', 'fabric', 'forge'] as loader}
- <button
- class="flex-1 px-3 py-2 rounded-sm text-sm font-medium transition-all duration-200 capitalize
- {selectedLoader === loader
- ? 'bg-white dark:bg-white/10 text-black dark:text-white shadow-sm'
- : 'text-zinc-500 dark:text-zinc-500 hover:text-black dark:hover:text-white'}"
- onclick={() => onLoaderChange(loader as ModLoaderType)}
- disabled={isInstalling}
- >
- {loader}
- </button>
- {/each}
- </div>
-
- <!-- Content Area -->
- <div class="min-h-[100px] flex flex-col justify-center">
- {#if !selectedGameVersion}
- <div class="flex items-center gap-3 p-4 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/20 text-amber-700 dark:text-amber-200 rounded-sm text-sm">
- <AlertCircle size={16} />
- <span>Please select a Minecraft version first.</span>
- </div>
-
- {:else if selectedLoader === "vanilla"}
- <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
- <div class="text-center p-4 border border-dashed border-zinc-200 dark:border-white/10 rounded-sm text-zinc-500 text-sm">
- Standard Minecraft experience. No modifications.
- </div>
-
- {#if isVersionInstalled}
- <div class="flex items-center justify-center gap-2 p-3 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20 text-emerald-700 dark:text-emerald-300 rounded-sm text-sm">
- <CheckCircle size={16} />
- <span>Version {selectedGameVersion} is installed</span>
- </div>
- {:else}
- <button
- class="w-full bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
- onclick={installVanilla}
- disabled={isInstalling}
- >
- {#if isInstalling}
- <Loader2 class="animate-spin" size={16} />
- Installing...
- {:else}
- <Download size={16} />
- Install {selectedGameVersion}
- {/if}
- </button>
- {/if}
- </div>
-
- {:else if isLoading}
- <div class="flex flex-col items-center gap-3 text-sm text-zinc-500 py-6">
- <Loader2 class="animate-spin" size={20} />
- <span>Fetching {selectedLoader} manifest...</span>
- </div>
-
- {:else if error}
- <div class="p-4 bg-red-50 border border-red-200 text-red-700 dark:bg-red-500/10 dark:border-red-500/20 dark:text-red-300 rounded-sm text-sm break-words">
- {error}
- </div>
-
- {:else if selectedLoader === "fabric"}
- <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
- {#if fabricLoaders.length === 0}
- <div class="text-center p-4 text-sm text-zinc-500 italic">
- No Fabric versions available for {selectedGameVersion}
- </div>
- {:else}
- <div>
- <label for="fabric-loader-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2"
- >Loader Version</label
- >
- <!-- Custom Fabric Dropdown -->
- <div class="relative" bind:this={fabricDropdownRef}>
- <button
- type="button"
- onclick={() => isFabricDropdownOpen = !isFabricDropdownOpen}
- disabled={isInstalling}
- class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left
- bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md
- text-sm text-gray-900 dark:text-white
- hover:border-zinc-400 dark:hover:border-zinc-600
- focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
- transition-colors cursor-pointer outline-none disabled:opacity-50"
- >
- <span class="truncate">{selectedFabricLabel}</span>
- <ChevronDown
- size={14}
- class="shrink-0 text-zinc-400 dark:text-zinc-500 transition-transform duration-200 {isFabricDropdownOpen ? 'rotate-180' : ''}"
- />
- </button>
-
- {#if isFabricDropdownOpen}
- <div
- class="absolute z-50 w-full mt-1 py-1 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md shadow-xl
- max-h-48 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
- >
- {#each fabricLoaders as loader}
- <button
- type="button"
- onclick={() => { selectedFabricLoader = loader.version; isFabricDropdownOpen = false; }}
- class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
- transition-colors outline-none cursor-pointer
- {loader.version === selectedFabricLoader
- ? 'bg-indigo-600 text-white'
- : 'text-gray-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'}"
- >
- <span class="truncate">{loader.version} {loader.stable ? "(stable)" : ""}</span>
- {#if loader.version === selectedFabricLoader}
- <Check size={14} class="shrink-0 ml-2" />
- {/if}
- </button>
- {/each}
- </div>
- {/if}
- </div>
- </div>
-
- <button
- class="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
- onclick={installModLoader}
- disabled={isInstalling || !selectedFabricLoader}
- >
- {#if isInstalling}
- <Loader2 class="animate-spin" size={16} />
- Installing...
- {:else}
- <Download size={16} />
- Install Fabric {selectedFabricLoader}
- {/if}
- </button>
- {/if}
- </div>
-
- {:else if selectedLoader === "forge"}
- <div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
- {#if forgeVersions.length === 0}
- <div class="text-center p-4 text-sm text-zinc-500 italic">
- No Forge versions available for {selectedGameVersion}
- </div>
- {:else}
- <div>
- <label for="forge-version-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2"
- >Forge Version</label
- >
- <!-- Custom Forge Dropdown -->
- <div class="relative" bind:this={forgeDropdownRef}>
- <button
- type="button"
- onclick={() => isForgeDropdownOpen = !isForgeDropdownOpen}
- disabled={isInstalling}
- class="w-full flex items-center justify-between gap-2 px-4 py-2.5 text-left
- bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md
- text-sm text-gray-900 dark:text-white
- hover:border-zinc-400 dark:hover:border-zinc-600
- focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
- transition-colors cursor-pointer outline-none disabled:opacity-50"
- >
- <span class="truncate">{selectedForgeLabel}</span>
- <ChevronDown
- size={14}
- class="shrink-0 text-zinc-400 dark:text-zinc-500 transition-transform duration-200 {isForgeDropdownOpen ? 'rotate-180' : ''}"
- />
- </button>
-
- {#if isForgeDropdownOpen}
- <div
- class="absolute z-50 w-full mt-1 py-1 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md shadow-xl
- max-h-48 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
- >
- {#each forgeVersions as version}
- <button
- type="button"
- onclick={() => { selectedForgeVersion = version.version; isForgeDropdownOpen = false; }}
- class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
- transition-colors outline-none cursor-pointer
- {version.version === selectedForgeVersion
- ? 'bg-indigo-600 text-white'
- : 'text-gray-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800'}"
- >
- <span class="truncate">{version.version} {version.recommended ? "(Recommended)" : ""}</span>
- {#if version.version === selectedForgeVersion}
- <Check size={14} class="shrink-0 ml-2" />
- {/if}
- </button>
- {/each}
- </div>
- {/if}
- </div>
- </div>
-
- <button
- class="w-full bg-orange-600 hover:bg-orange-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
- onclick={installModLoader}
- disabled={isInstalling || !selectedForgeVersion}
- >
- {#if isInstalling}
- <Loader2 class="animate-spin" size={16} />
- Installing...
- {:else}
- <Download size={16} />
- Install Forge {selectedForgeVersion}
- {/if}
- </button>
- {/if}
- </div>
- {/if}
- </div>
-</div>
diff --git a/packages/ui/src/components/ParticleBackground.svelte b/packages/ui/src/components/ParticleBackground.svelte
deleted file mode 100644
index 7644b1a..0000000
--- a/packages/ui/src/components/ParticleBackground.svelte
+++ /dev/null
@@ -1,70 +0,0 @@
-<script lang="ts" module>
- import { SaturnEffect } from "../lib/effects/SaturnEffect";
-
- // Global reference to the active Saturn effect for external control
- let globalSaturnEffect: SaturnEffect | null = null;
-
- export function getSaturnEffect(): SaturnEffect | null {
- return globalSaturnEffect;
- }
-</script>
-
-<script lang="ts">
- import { onMount, onDestroy } from "svelte";
- import { ConstellationEffect } from "../lib/effects/ConstellationEffect";
- import { settingsState } from "../stores/settings.svelte";
-
- let canvas: HTMLCanvasElement;
- let activeEffect: any;
-
- function loadEffect() {
- if (activeEffect) {
- activeEffect.destroy();
- }
-
- if (!canvas) return;
-
- if (settingsState.settings.active_effect === "saturn") {
- activeEffect = new SaturnEffect(canvas);
- globalSaturnEffect = activeEffect;
- } else {
- activeEffect = new ConstellationEffect(canvas);
- globalSaturnEffect = null;
- }
-
- // Ensure correct size immediately
- activeEffect.resize(window.innerWidth, window.innerHeight);
- }
-
- $effect(() => {
- const _ = settingsState.settings.active_effect;
- if (canvas) {
- loadEffect();
- }
- });
-
- onMount(() => {
- const resizeObserver = new ResizeObserver(() => {
- if (canvas && activeEffect) {
- activeEffect.resize(window.innerWidth, window.innerHeight);
- }
- });
-
- resizeObserver.observe(document.body);
-
- return () => {
- resizeObserver.disconnect();
- if (activeEffect) activeEffect.destroy();
- };
- });
-
- onDestroy(() => {
- if (activeEffect) activeEffect.destroy();
- globalSaturnEffect = null;
- });
-</script>
-
-<canvas
- bind:this={canvas}
- class="absolute inset-0 z-0 pointer-events-none"
-></canvas>
diff --git a/packages/ui/src/components/SettingsView.svelte b/packages/ui/src/components/SettingsView.svelte
deleted file mode 100644
index 0020506..0000000
--- a/packages/ui/src/components/SettingsView.svelte
+++ /dev/null
@@ -1,1217 +0,0 @@
-<script lang="ts">
- import { open } from "@tauri-apps/plugin-dialog";
- import { settingsState } from "../stores/settings.svelte";
- import CustomSelect from "./CustomSelect.svelte";
- import ConfigEditorModal from "./ConfigEditorModal.svelte";
- import { onMount } from "svelte";
- import { RefreshCw, FileJson } from "lucide-svelte";
-
- // Use convertFileSrc directly from settingsState.backgroundUrl for cleaner approach
- // or use the imported one if passing raw path.
- import { convertFileSrc } from "@tauri-apps/api/core";
-
- 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'."
- }
- ];
-
- let selectedPersona = $state("");
-
- function applyPersona(value: string) {
- const persona = personas.find(p => p.value === value);
- if (persona) {
- settingsState.settings.assistant.system_prompt = persona.prompt;
- selectedPersona = value; // Keep selected to show what's active
- }
- }
-
- function resetSystemPrompt() {
- const defaultPersona = personas.find(p => p.value === "default");
- if (defaultPersona) {
- settingsState.settings.assistant.system_prompt = defaultPersona.prompt;
- selectedPersona = "default";
- }
- }
-
- // Load models when assistant settings are shown
- function loadModelsForProvider() {
- if (settingsState.settings.assistant.llm_provider === "ollama") {
- settingsState.loadOllamaModels();
- } else if (settingsState.settings.assistant.llm_provider === "openai") {
- settingsState.loadOpenaiModels();
- }
- }
-
- async function selectBackground() {
- try {
- const selected = await open({
- multiple: false,
- filters: [
- {
- name: "Images",
- extensions: ["png", "jpg", "jpeg", "webp", "gif"],
- },
- ],
- });
-
- if (selected && typeof selected === "string") {
- settingsState.settings.custom_background_path = selected;
- settingsState.saveSettings();
- }
- } catch (e) {
- console.error("Failed to select background:", e);
- }
- }
-
- function clearBackground() {
- settingsState.settings.custom_background_path = undefined;
- settingsState.saveSettings();
- }
-
- let migrating = $state(false);
- async function runMigrationToSharedCaches() {
- if (migrating) return;
- migrating = true;
- try {
- const { invoke } = await import("@tauri-apps/api/core");
- const result = await invoke<{
- moved_files: number;
- hardlinks: number;
- copies: number;
- saved_mb: number;
- }>("migrate_shared_caches");
-
- // Reload settings to reflect changes
- await settingsState.loadSettings();
-
- // Show success message
- const msg = `Migration complete! ${result.moved_files} files (${result.hardlinks} hardlinks, ${result.copies} copies), ${result.saved_mb.toFixed(2)} MB saved.`;
- console.log(msg);
- alert(msg);
- } catch (e) {
- console.error("Migration failed:", e);
- alert(`Migration failed: ${e}`);
- } finally {
- migrating = false;
- }
- }
-</script>
-
-<div class="h-full flex flex-col p-6 overflow-hidden">
- <div class="flex items-center justify-between mb-6">
- <h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">Settings</h2>
-
- <button
- onclick={() => settingsState.openConfigEditor()}
- class="p-2 hover:bg-white/10 rounded-lg text-zinc-400 hover:text-white transition-colors flex items-center gap-2 text-sm border border-transparent hover:border-white/5"
- title="Open Settings JSON"
- >
- <FileJson size={18} />
- <span class="hidden sm:inline">Open JSON</span>
- </button>
- </div>
-
- <div class="flex-1 overflow-y-auto pr-2 space-y-6 custom-scrollbar pb-10">
-
- <!-- Appearance / Background -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-6 flex items-center gap-2">
- Appearance
- </h3>
-
- <div class="space-y-4">
- <div>
- <label class="block text-sm font-medium dark:text-white/70 text-black/70 mb-3">Custom Background Image</label>
-
- <div class="flex items-center gap-6">
- <!-- Preview -->
- <div class="w-40 h-24 rounded-xl overflow-hidden dark:bg-black/50 bg-gray-200 border dark:border-white/10 border-black/10 relative group shadow-lg">
- {#if settingsState.settings.custom_background_path}
- <img
- src={convertFileSrc(settingsState.settings.custom_background_path)}
- alt="Background Preview"
- class="w-full h-full object-cover"
- onerror={(e) => {
- console.error("Failed to load image:", settingsState.settings.custom_background_path, e);
- // e.currentTarget.style.display = 'none';
- }}
- />
- {:else}
- <div class="w-full h-full bg-gradient-to-br from-emerald-900 via-zinc-900 to-indigo-950 opacity-100"></div>
- <div class="absolute inset-0 flex items-center justify-center text-[10px] text-white/50 bg-black/20 ">Default Gradient</div>
- {/if}
- </div>
-
- <!-- Actions -->
- <div class="flex flex-col gap-2">
- <button
- onclick={selectBackground}
- class="dark:bg-white/10 dark:hover:bg-white/20 bg-black/5 hover:bg-black/10 dark:text-white text-black px-4 py-2 rounded-lg text-sm transition-colors border dark:border-white/5 border-black/5"
- >
- Select Image
- </button>
-
- {#if settingsState.settings.custom_background_path}
- <button
- onclick={clearBackground}
- class="text-red-400 hover:text-red-300 text-sm px-4 py-1 text-left transition-colors"
- >
- Reset to Default
- </button>
- {/if}
- </div>
- </div>
- <p class="text-xs dark:text-white/30 text-black/40 mt-3">
- Select an image from your computer to replace the default gradient background.
- Supported formats: PNG, JPG, WEBP, GIF.
- </p>
- </div>
-
- <!-- Visual Settings -->
- <div class="pt-4 border-t dark:border-white/5 border-black/5 space-y-4">
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="visual-effects-label">Visual Effects</h4>
- <p class="text-xs dark:text-white/40 text-black/50 mt-1">Enable particle effects and animated gradients. (Default: On)</p>
- </div>
- <button
- aria-labelledby="visual-effects-label"
- onclick={() => { settingsState.settings.enable_visual_effects = !settingsState.settings.enable_visual_effects; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.enable_visual_effects ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.enable_visual_effects ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- {#if settingsState.settings.enable_visual_effects}
- <div class="flex items-center justify-between pl-2 border-l-2 dark:border-white/5 border-black/5 ml-1">
- <div>
- <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="theme-effect-label">Theme Effect</h4>
- <p class="text-xs dark:text-white/40 text-black/50 mt-1">Select the active visual theme.</p>
- </div>
- <CustomSelect
- options={effectOptions}
- bind:value={settingsState.settings.active_effect}
- onchange={() => settingsState.saveSettings()}
- class="w-52"
- />
- </div>
- {/if}
-
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="gpu-acceleration-label">GPU Acceleration</h4>
- <p class="text-xs dark:text-white/40 text-black/50 mt-1">Enable GPU acceleration for the interface. (Default: Off, Requires Restart)</p>
- </div>
- <button
- aria-labelledby="gpu-acceleration-label"
- onclick={() => { settingsState.settings.enable_gpu_acceleration = !settingsState.settings.enable_gpu_acceleration; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.enable_gpu_acceleration ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.enable_gpu_acceleration ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- <!-- Color Theme Switcher -->
- <div class="flex items-center justify-between pt-4 border-t dark:border-white/5 border-black/5 opacity-50 cursor-not-allowed">
- <div>
- <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="color-theme-label">Color Theme</h4>
- <p class="text-xs dark:text-white/40 text-black/50 mt-1">Interface color mode. (Locked to Dark)</p>
- </div>
- <div class="flex items-center bg-black/5 dark:bg-white/5 rounded-lg p-1 pointer-events-none">
- <button
- disabled
- class="px-3 py-1 rounded-md text-xs font-medium transition-all text-gray-500 dark:text-gray-600"
- >
- Light
- </button>
- <button
- disabled
- class="px-3 py-1 rounded-md text-xs font-medium transition-all bg-indigo-500 text-white shadow-sm"
- >
- Dark
- </button>
- </div>
- </div>
- </div>
- </div>
- </div>
-
- <!-- Java Path -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
- Java Environment
- </h3>
- <div class="space-y-4">
- <div>
- <label for="java-path" class="block text-sm font-medium text-white/70 mb-2">Java Executable Path</label>
- <div class="flex gap-2">
- <input
- id="java-path"
- bind:value={settingsState.settings.java_path}
- type="text"
- class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
- placeholder="e.g. java, /usr/bin/java"
- />
- <button
- onclick={() => settingsState.detectJava()}
- disabled={settingsState.isDetectingJava}
- class="bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white px-4 py-2 rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium"
- >
- {settingsState.isDetectingJava ? "Detecting..." : "Auto Detect"}
- </button>
- <button
- onclick={() => settingsState.openJavaDownloadModal()}
- class="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-xl transition-colors whitespace-nowrap text-sm font-medium"
- >
- Download Java
- </button>
- </div>
- </div>
-
- {#if settingsState.javaInstallations.length > 0}
- <div class="mt-4 space-y-2">
- <p class="text-[10px] text-white/30 uppercase font-bold tracking-wider">Detected Installations</p>
- {#each settingsState.javaInstallations as java}
- <button
- onclick={() => settingsState.selectJava(java.path)}
- class="w-full text-left p-3 rounded-lg border transition-all duration-200 group
- {settingsState.settings.java_path === java.path
- ? 'bg-indigo-500/20 border-indigo-500/30'
- : 'bg-black/20 border-white/5 hover:bg-white/5 hover:border-white/10'}"
- >
- <div class="flex justify-between items-center">
- <div>
- <span class="text-white font-mono text-xs font-bold">{java.version}</span>
- <span class="text-white/40 text-[10px] ml-2">{java.is_64bit ? "64-bit" : "32-bit"}</span>
- </div>
- {#if settingsState.settings.java_path === java.path}
- <span class="text-indigo-300 text-[10px] font-bold uppercase tracking-wider">Selected</span>
- {/if}
- </div>
- <div class="text-white/30 text-[10px] font-mono truncate mt-1 group-hover:text-white/50">{java.path}</div>
- </button>
- {/each}
- </div>
- {/if}
- </div>
- </div>
-
- <!-- Memory -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
- Memory Allocation (RAM)
- </h3>
- <div class="grid grid-cols-2 gap-6">
- <div>
- <label for="min-memory" class="block text-sm font-medium text-white/70 mb-2">Minimum (MB)</label>
- <input
- id="min-memory"
- bind:value={settingsState.settings.min_memory}
- type="number"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
- />
- </div>
- <div>
- <label for="max-memory" class="block text-sm font-medium text-white/70 mb-2">Maximum (MB)</label>
- <input
- id="max-memory"
- bind:value={settingsState.settings.max_memory}
- type="number"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
- />
- </div>
- </div>
- </div>
-
- <!-- Resolution -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
- Game Window Size
- </h3>
- <div class="grid grid-cols-2 gap-6">
- <div>
- <label for="window-width" class="block text-sm font-medium text-white/70 mb-2">Width</label>
- <input
- id="window-width"
- bind:value={settingsState.settings.width}
- type="number"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
- />
- </div>
- <div>
- <label for="window-height" class="block text-sm font-medium text-white/70 mb-2">Height</label>
- <input
- id="window-height"
- bind:value={settingsState.settings.height}
- type="number"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
- />
- </div>
- </div>
- </div>
-
- <!-- Download Settings -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
- Network
- </h3>
- <div>
- <label for="download-threads" class="block text-sm font-medium text-white/70 mb-2">Concurrent Download Threads</label>
- <input
- id="download-threads"
- bind:value={settingsState.settings.download_threads}
- type="number"
- min="1"
- max="128"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none transition-colors"
- />
- <p class="text-xs text-white/30 mt-2">Higher values usually mean faster downloads but use more CPU/Network.</p>
- </div>
- </div>
-
- <!-- Storage & Caches -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">Storage & Version Caches</h3>
- <div class="space-y-4">
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium text-white/90" id="shared-caches-label">Use Shared Caches</h4>
- <p class="text-xs text-white/40 mt-1">Store versions/libraries/assets in a global cache shared by all instances.</p>
- </div>
- <button
- aria-labelledby="shared-caches-label"
- onclick={() => { settingsState.settings.use_shared_caches = !settingsState.settings.use_shared_caches; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.use_shared_caches ? 'bg-indigo-500' : 'bg-white/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.use_shared_caches ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium text-white/90" id="legacy-storage-label">Keep Legacy Per-Instance Storage</h4>
- <p class="text-xs text-white/40 mt-1">Do not migrate existing instance caches; keep current layout.</p>
- </div>
- <button
- aria-labelledby="legacy-storage-label"
- onclick={() => { settingsState.settings.keep_legacy_per_instance_storage = !settingsState.settings.keep_legacy_per_instance_storage; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.keep_legacy_per_instance_storage ? 'bg-indigo-500' : 'bg-white/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.keep_legacy_per_instance_storage ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- <div class="flex items-center justify-between pt-2 border-t border-white/10">
- <div>
- <h4 class="text-sm font-medium text-white/90">Run Migration</h4>
- <p class="text-xs text-white/40 mt-1">Hard-link or copy existing per-instance caches into the shared cache.</p>
- </div>
- <button
- onclick={runMigrationToSharedCaches}
- disabled={migrating}
- class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm disabled:opacity-50 disabled:cursor-not-allowed"
- >
- {migrating ? "Migrating..." : "Migrate Now"}
- </button>
- </div>
- </div>
- </div>
-
- <!-- Feature Flags -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">Feature Flags (Launcher Arguments)</h3>
- <div class="space-y-4">
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium text-white/90" id="demo-user-label">Demo User</h4>
- <p class="text-xs text-white/40 mt-1">Enable demo-related arguments when rules require them.</p>
- </div>
- <button
- aria-labelledby="demo-user-label"
- onclick={() => { settingsState.settings.feature_flags.demo_user = !settingsState.settings.feature_flags.demo_user; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.demo_user ? 'bg-indigo-500' : 'bg-white/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.feature_flags.demo_user ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium text-white/90" id="quick-play-label">Quick Play</h4>
- <p class="text-xs text-white/40 mt-1">Enable quick play singleplayer/multiplayer arguments.</p>
- </div>
- <button
- aria-labelledby="quick-play-label"
- onclick={() => { settingsState.settings.feature_flags.quick_play_enabled = !settingsState.settings.feature_flags.quick_play_enabled; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.quick_play_enabled ? 'bg-indigo-500' : 'bg-white/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.feature_flags.quick_play_enabled ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- {#if settingsState.settings.feature_flags.quick_play_enabled}
- <div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-2 border-l-2 border-white/10">
- <div>
- <label class="block text-sm font-medium text-white/70 mb-2">Singleplayer World Path</label>
- <input
- type="text"
- bind:value={settingsState.settings.feature_flags.quick_play_path}
- placeholder="/path/to/saves/MyWorld"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
- />
- </div>
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium text-white/90" id="qp-singleplayer-label">Prefer Singleplayer</h4>
- <p class="text-xs text-white/40 mt-1">If enabled, use singleplayer quick play path.</p>
- </div>
- <button
- aria-labelledby="qp-singleplayer-label"
- onclick={() => { settingsState.settings.feature_flags.quick_play_singleplayer = !settingsState.settings.feature_flags.quick_play_singleplayer; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.quick_play_singleplayer ? 'bg-indigo-500' : 'bg-white/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.feature_flags.quick_play_singleplayer ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
- <div>
- <label class="block text-sm font-medium text-white/70 mb-2">Multiplayer Server Address</label>
- <input
- type="text"
- bind:value={settingsState.settings.feature_flags.quick_play_multiplayer_server}
- placeholder="example.org:25565"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
- />
- </div>
- </div>
- {/if}
- </div>
- </div>
-
- <!-- Debug / Logs -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
- Debug & Logs
- </h3>
- <div class="space-y-4">
- <div>
- <label for="log-service" class="block text-sm font-medium text-white/70 mb-2">Log Upload Service</label>
- <CustomSelect
- options={logServiceOptions}
- bind:value={settingsState.settings.log_upload_service}
- class="w-full"
- />
- </div>
-
- {#if settingsState.settings.log_upload_service === 'pastebin.com'}
- <div>
- <label for="pastebin-key" class="block text-sm font-medium text-white/70 mb-2">Pastebin Dev API Key</label>
- <input
- id="pastebin-key"
- type="password"
- bind:value={settingsState.settings.pastebin_api_key}
- placeholder="Enter your API Key"
- class="dark:bg-zinc-900 bg-white dark:text-white text-black w-full px-4 py-3 rounded-xl border dark:border-zinc-700 border-gray-300 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30 outline-none transition-colors placeholder:text-zinc-500"
- />
- <p class="text-xs text-white/30 mt-2">
- Get your API key from <a href="https://pastebin.com/doc_api#1" target="_blank" class="text-indigo-400 hover:underline">Pastebin API Documentation</a>.
- </p>
- </div>
- {/if}
- </div>
- </div>
-
- <!-- AI Assistant -->
- <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
- <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
- <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <rect x="3" y="11" width="18" height="10" rx="2"/>
- <circle cx="12" cy="5" r="2"/>
- <path d="M12 7v4"/>
- <circle cx="8" cy="16" r="1" fill="currentColor"/>
- <circle cx="16" cy="16" r="1" fill="currentColor"/>
- </svg>
- AI Assistant
- </h3>
- <div class="space-y-6">
- <!-- Enable/Disable -->
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="assistant-enabled-label">Enable Assistant</h4>
- <p class="text-xs dark:text-white/40 text-black/50 mt-1">Toggle the AI assistant feature on or off.</p>
- </div>
- <button
- aria-labelledby="assistant-enabled-label"
- onclick={() => { settingsState.settings.assistant.enabled = !settingsState.settings.assistant.enabled; settingsState.saveSettings(); }}
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.assistant.enabled ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.assistant.enabled ? 'translate-x-5' : 'translate-x-0'}"></div>
- </button>
- </div>
-
- {#if settingsState.settings.assistant.enabled}
- <!-- LLM Provider Section -->
- <div class="pt-4 border-t dark:border-white/5 border-black/5">
- <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Language Model</h4>
-
- <div class="space-y-4">
- <div>
- <label for="llm-provider" class="block text-sm font-medium text-white/70 mb-2">Provider</label>
- <CustomSelect
- options={llmProviderOptions}
- bind:value={settingsState.settings.assistant.llm_provider}
- onchange={() => settingsState.saveSettings()}
- class="w-full"
- />
- </div>
-
- {#if settingsState.settings.assistant.llm_provider === 'ollama'}
- <!-- Ollama Settings -->
- <div class="pl-4 border-l-2 border-indigo-500/30 space-y-4">
- <div>
- <label for="ollama-endpoint" class="block text-sm font-medium text-white/70 mb-2">API Endpoint</label>
- <div class="flex gap-2">
- <input
- id="ollama-endpoint"
- type="text"
- bind:value={settingsState.settings.assistant.ollama_endpoint}
- placeholder="http://localhost:11434"
- class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
- />
- <button
- onclick={() => settingsState.loadOllamaModels()}
- disabled={settingsState.isLoadingOllamaModels}
- class="px-4 py-2 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium flex items-center gap-2"
- title="Refresh models"
- >
- <RefreshCw size={14} class={settingsState.isLoadingOllamaModels ? "animate-spin" : ""} />
- <span class="hidden sm:inline">Refresh</span>
- </button>
- </div>
- <p class="text-xs text-white/30 mt-2">
- Default: http://localhost:11434. Make sure Ollama is running.
- </p>
- </div>
-
- <div>
- <div class="flex items-center justify-between mb-2">
- <label for="ollama-model" class="block text-sm font-medium text-white/70">Model</label>
- {#if settingsState.ollamaModels.length > 0}
- <span class="text-[10px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
- {settingsState.ollamaModels.length} installed
- </span>
- {/if}
- </div>
-
- {#if settingsState.isLoadingOllamaModels}
- <div class="bg-black/40 text-white/50 w-full px-4 py-3 rounded-xl border border-white/10 text-sm flex items-center gap-2">
- <RefreshCw size={14} class="animate-spin" />
- Loading models...
- </div>
- {:else if settingsState.ollamaModelsError}
- <div class="bg-red-500/10 text-red-400 w-full px-4 py-3 rounded-xl border border-red-500/20 text-sm">
- {settingsState.ollamaModelsError}
- </div>
- <CustomSelect
- options={settingsState.currentModelOptions}
- bind:value={settingsState.settings.assistant.ollama_model}
- onchange={() => settingsState.saveSettings()}
- class="w-full mt-2"
- allowCustom={true}
- />
- {:else if settingsState.ollamaModels.length === 0}
- <div class="bg-amber-500/10 text-amber-400 w-full px-4 py-3 rounded-xl border border-amber-500/20 text-sm">
- No models found. Click Refresh to load installed models.
- </div>
- <CustomSelect
- options={settingsState.currentModelOptions}
- bind:value={settingsState.settings.assistant.ollama_model}
- onchange={() => settingsState.saveSettings()}
- class="w-full mt-2"
- allowCustom={true}
- />
- {:else}
- <CustomSelect
- options={settingsState.currentModelOptions}
- bind:value={settingsState.settings.assistant.ollama_model}
- onchange={() => settingsState.saveSettings()}
- class="w-full"
- allowCustom={true}
- />
- {/if}
-
- <p class="text-xs text-white/30 mt-2">
- Run <code class="bg-black/30 px-1 rounded">ollama pull {'<model>'}</code> to download new models. Or type a custom model name above.
- </p>
- </div>
- </div>
- {:else if settingsState.settings.assistant.llm_provider === 'openai'}
- <!-- OpenAI Settings -->
- <div class="pl-4 border-l-2 border-emerald-500/30 space-y-4">
- <div>
- <label for="openai-key" class="block text-sm font-medium text-white/70 mb-2">API Key</label>
- <div class="flex gap-2">
- <input
- id="openai-key"
- type="password"
- bind:value={settingsState.settings.assistant.openai_api_key}
- placeholder="sk-..."
- class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
- />
- <button
- onclick={() => settingsState.loadOpenaiModels()}
- disabled={settingsState.isLoadingOpenaiModels || !settingsState.settings.assistant.openai_api_key}
- class="px-4 py-2 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium flex items-center gap-2"
- title="Refresh models"
- >
- <RefreshCw size={14} class={settingsState.isLoadingOpenaiModels ? "animate-spin" : ""} />
- <span class="hidden sm:inline">Load</span>
- </button>
- </div>
- <p class="text-xs text-white/30 mt-2">
- Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank" class="text-indigo-400 hover:underline">OpenAI Dashboard</a>.
- </p>
- </div>
-
- <div>
- <label for="openai-endpoint" class="block text-sm font-medium text-white/70 mb-2">API Endpoint</label>
- <input
- id="openai-endpoint"
- type="text"
- bind:value={settingsState.settings.assistant.openai_endpoint}
- placeholder="https://api.openai.com/v1"
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
- />
- <p class="text-xs text-white/30 mt-2">
- Use custom endpoint for Azure OpenAI or other compatible APIs.
- </p>
- </div>
-
- <div>
- <div class="flex items-center justify-between mb-2">
- <label for="openai-model" class="block text-sm font-medium text-white/70">Model</label>
- {#if settingsState.openaiModels.length > 0}
- <span class="text-[10px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
- {settingsState.openaiModels.length} available
- </span>
- {/if}
- </div>
-
- {#if settingsState.isLoadingOpenaiModels}
- <div class="bg-black/40 text-white/50 w-full px-4 py-3 rounded-xl border border-white/10 text-sm flex items-center gap-2">
- <RefreshCw size={14} class="animate-spin" />
- Loading models...
- </div>
- {:else if settingsState.openaiModelsError}
- <div class="bg-red-500/10 text-red-400 w-full px-4 py-3 rounded-xl border border-red-500/20 text-sm mb-2">
- {settingsState.openaiModelsError}
- </div>
- <CustomSelect
- options={settingsState.currentModelOptions}
- bind:value={settingsState.settings.assistant.openai_model}
- onchange={() => settingsState.saveSettings()}
- class="w-full"
- allowCustom={true}
- />
- {:else}
- <CustomSelect
- options={settingsState.currentModelOptions}
- bind:value={settingsState.settings.assistant.openai_model}
- onchange={() => settingsState.saveSettings()}
- class="w-full"
- allowCustom={true}
- />
- {/if}
- </div>
- </div>
- {/if}
- </div>
- </div>
-
- <!-- Response Settings -->
- <div class="pt-4 border-t dark:border-white/5 border-black/5">
- <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Response Settings</h4>
-
- <div class="space-y-4">
- <div>
- <label for="response-lang" class="block text-sm font-medium text-white/70 mb-2">Response Language</label>
- <CustomSelect
- options={languageOptions}
- bind:value={settingsState.settings.assistant.response_language}
- onchange={() => settingsState.saveSettings()}
- class="w-full"
- />
- </div>
-
- <div>
- <div class="flex items-center justify-between mb-2">
- <label for="system-prompt" class="block text-sm font-medium text-white/70">System Prompt</label>
- <button
- onclick={resetSystemPrompt}
- class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors flex items-center gap-1 opacity-80 hover:opacity-100"
- title="Reset to default prompt"
- >
- <RefreshCw size={10} />
- Reset
- </button>
- </div>
-
- <div class="mb-3">
- <CustomSelect
- options={personas.map(p => ({ value: p.value, label: p.label }))}
- bind:value={selectedPersona}
- placeholder="Load a preset persona..."
- onchange={applyPersona}
- class="w-full"
- />
- </div>
-
- <textarea
- id="system-prompt"
- bind:value={settingsState.settings.assistant.system_prompt}
- oninput={() => selectedPersona = ""}
- rows="4"
- placeholder="You are a helpful Minecraft expert assistant..."
- class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none text-sm transition-colors resize-none"
- ></textarea>
- <p class="text-xs text-white/30 mt-2">
- Customize how the assistant behaves and responds.
- </p>
- </div>
- </div>
- </div>
-
- <!-- TTS Settings -->
- <div class="pt-4 border-t dark:border-white/5 border-black/5">
- <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Text-to-Speech (Coming Soon)</h4>
-
- <div class="space-y-4 opacity-50 pointer-events-none">
- <div class="flex items-center justify-between">
- <div>
- <h4 class="text-sm font-medium dark:text-white/90 text-black/80">Enable TTS</h4>
- <p class="text-xs dark:text-white/40 text-black/50 mt-1">Read assistant responses aloud.</p>
- </div>
- <button
- disabled
- class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none dark:bg-white/10 bg-black/10"
- >
- <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out translate-x-0"></div>
- </button>
- </div>
-
- <div>
- <label class="block text-sm font-medium text-white/70 mb-2">TTS Provider</label>
- <CustomSelect
- options={ttsProviderOptions}
- value="disabled"
- class="w-full"
- />
- </div>
- </div>
- </div>
- {/if}
- </div>
- </div>
-
- <div class="pt-4 flex justify-end">
- <button
- onclick={() => settingsState.saveSettings()}
- class="bg-gradient-to-r from-emerald-600 to-green-600 hover:from-emerald-500 hover:to-green-500 text-white font-bold py-3 px-8 rounded-xl shadow-lg shadow-emerald-500/20 transition-all hover:scale-105 active:scale-95"
- >
- Save Settings
- </button>
- </div>
- </div>
-</div>
-
-{#if settingsState.showConfigEditor}
- <ConfigEditorModal />
-{/if}
-
-<!-- Java Download Modal -->
-{#if settingsState.showJavaDownloadModal}
- <div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70">
- <div class="bg-zinc-900 rounded-2xl border border-white/10 shadow-2xl w-[900px] max-w-[95vw] h-[600px] max-h-[90vh] flex flex-col overflow-hidden">
- <!-- Header -->
- <div class="flex items-center justify-between p-5 border-b border-white/10">
- <h3 class="text-xl font-bold text-white">Download Java</h3>
- <button
- aria-label="Close dialog"
- onclick={() => settingsState.closeJavaDownloadModal()}
- disabled={settingsState.isDownloadingJava}
- class="text-white/40 hover:text-white/80 disabled:opacity-50 transition-colors p-1"
- >
- <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
- </svg>
- </button>
- </div>
-
- <!-- Main Content Area -->
- <div class="flex flex-1 overflow-hidden">
- <!-- Left Sidebar: Sources -->
- <div class="w-40 border-r border-white/10 p-3 flex flex-col gap-1">
- <span class="text-[10px] font-bold uppercase tracking-widest text-white/30 px-2 mb-2">Sources</span>
-
- <button
- disabled
- class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm opacity-40 cursor-not-allowed text-white/50"
- >
- <div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center text-[10px]">M</div>
- Mojang
- </button>
-
- <button
- class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm bg-indigo-500/20 border border-indigo-500/40 text-white"
- >
- <div class="w-5 h-5 rounded bg-indigo-500 flex items-center justify-center text-[10px] font-bold">A</div>
- Adoptium
- </button>
-
- <button
- disabled
- class="flex items-center gap-2 px-3 py-2.5 rounded-lg text-left text-sm opacity-40 cursor-not-allowed text-white/50"
- >
- <div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center text-[10px]">Z</div>
- Azul Zulu
- </button>
- </div>
-
- <!-- Center: Version Selection -->
- <div class="flex-1 flex flex-col overflow-hidden">
- <!-- Toolbar -->
- <div class="flex items-center gap-3 p-4 border-b border-white/5">
- <!-- Search -->
- <div class="relative flex-1 max-w-xs">
- <input
- type="text"
- bind:value={settingsState.searchQuery}
- placeholder="Search versions..."
- class="w-full bg-black/30 text-white text-sm px-4 py-2 pl-9 rounded-lg border border-white/10 focus:border-indigo-500/50 outline-none"
- />
- <svg class="absolute left-3 top-2.5 w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
- </svg>
- </div>
-
- <!-- Recommended Filter -->
- <label class="flex items-center gap-2 text-sm text-white/60 cursor-pointer select-none">
- <input
- type="checkbox"
- bind:checked={settingsState.showOnlyRecommended}
- class="w-4 h-4 rounded border-white/20 bg-black/30 text-indigo-500 focus:ring-indigo-500/30"
- />
- LTS Only
- </label>
-
- <!-- Image Type Toggle -->
- <div class="flex items-center bg-black/30 rounded-lg p-0.5 border border-white/10">
- <button
- onclick={() => settingsState.selectedImageType = "jre"}
- class="px-3 py-1.5 rounded-md text-xs font-medium transition-all {settingsState.selectedImageType === 'jre' ? 'bg-indigo-500 text-white shadow' : 'text-white/50 hover:text-white/80'}"
- >
- JRE
- </button>
- <button
- onclick={() => settingsState.selectedImageType = "jdk"}
- class="px-3 py-1.5 rounded-md text-xs font-medium transition-all {settingsState.selectedImageType === 'jdk' ? 'bg-indigo-500 text-white shadow' : 'text-white/50 hover:text-white/80'}"
- >
- JDK
- </button>
- </div>
- </div>
-
- <!-- Loading State -->
- {#if settingsState.isLoadingCatalog}
- <div class="flex-1 flex items-center justify-center text-white/50">
- <div class="flex flex-col items-center gap-3">
- <div class="w-8 h-8 border-2 border-indigo-500/30 border-t-indigo-500 rounded-full animate-spin"></div>
- <span class="text-sm">Loading Java versions...</span>
- </div>
- </div>
- {:else if settingsState.catalogError}
- <div class="flex-1 flex items-center justify-center text-red-400">
- <div class="flex flex-col items-center gap-3 text-center px-8">
- <svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
- </svg>
- <span class="text-sm">{settingsState.catalogError}</span>
- <button
- onclick={() => settingsState.refreshCatalog()}
- class="mt-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-sm text-white transition-colors"
- >
- Retry
- </button>
- </div>
- </div>
- {:else}
- <!-- Version List -->
- <div class="flex-1 overflow-auto p-4">
- <div class="space-y-2">
- {#each settingsState.availableMajorVersions as version}
- {@const isLts = settingsState.javaCatalog?.lts_versions.includes(version)}
- {@const isSelected = settingsState.selectedMajorVersion === version}
- {@const releaseInfo = settingsState.javaCatalog?.releases.find(r => r.major_version === version && r.image_type === settingsState.selectedImageType)}
- {@const isAvailable = releaseInfo?.is_available ?? false}
- {@const installStatus = releaseInfo ? settingsState.getInstallStatus(releaseInfo) : 'download'}
-
- <button
- onclick={() => settingsState.selectMajorVersion(version)}
- disabled={!isAvailable}
- class="w-full flex items-center gap-4 p-3 rounded-xl border transition-all text-left
- {isSelected
- ? 'bg-indigo-500/20 border-indigo-500/50 ring-2 ring-indigo-500/30'
- : isAvailable
- ? 'bg-black/20 border-white/10 hover:bg-white/5 hover:border-white/20'
- : 'bg-black/10 border-white/5 opacity-40 cursor-not-allowed'}"
- >
- <!-- Version Number -->
- <div class="w-14 text-center">
- <span class="text-xl font-bold {isSelected ? 'text-white' : 'text-white/80'}">{version}</span>
- </div>
-
- <!-- Version Details -->
- <div class="flex-1 min-w-0">
- <div class="flex items-center gap-2">
- <span class="text-sm text-white/70 font-mono truncate">{releaseInfo?.version ?? '--'}</span>
- {#if isLts}
- <span class="px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 text-[9px] font-bold rounded uppercase shrink-0">LTS</span>
- {/if}
- </div>
- {#if releaseInfo}
- <div class="text-[10px] text-white/40 truncate mt-0.5">
- {releaseInfo.release_name} • {settingsState.formatBytes(releaseInfo.file_size)}
- </div>
- {/if}
- </div>
-
- <!-- Install Status Badge -->
- <div class="shrink-0">
- {#if installStatus === 'installed'}
- <span class="px-2 py-1 bg-emerald-500/20 text-emerald-400 text-[10px] font-bold rounded uppercase">Installed</span>
- {:else if isAvailable}
- <span class="px-2 py-1 bg-white/10 text-white/50 text-[10px] font-medium rounded">Download</span>
- {:else}
- <span class="px-2 py-1 bg-red-500/10 text-red-400/60 text-[10px] font-medium rounded">N/A</span>
- {/if}
- </div>
- </button>
- {/each}
- </div>
- </div>
- {/if}
- </div>
-
- <!-- Right Sidebar: Details -->
- <div class="w-64 border-l border-white/10 flex flex-col">
- <div class="p-4 border-b border-white/5">
- <span class="text-[10px] font-bold uppercase tracking-widest text-white/30">Details</span>
- </div>
-
- {#if settingsState.selectedRelease}
- <div class="flex-1 p-4 space-y-4 overflow-auto">
- <div>
- <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Version</div>
- <div class="text-sm text-white font-mono">{settingsState.selectedRelease.version}</div>
- </div>
-
- <div>
- <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Release Name</div>
- <div class="text-sm text-white">{settingsState.selectedRelease.release_name}</div>
- </div>
-
- <div>
- <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Release Date</div>
- <div class="text-sm text-white">{settingsState.formatDate(settingsState.selectedRelease.release_date)}</div>
- </div>
-
- <div>
- <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Size</div>
- <div class="text-sm text-white">{settingsState.formatBytes(settingsState.selectedRelease.file_size)}</div>
- </div>
-
- <div>
- <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Type</div>
- <div class="flex items-center gap-2">
- <span class="text-sm text-white uppercase">{settingsState.selectedRelease.image_type}</span>
- {#if settingsState.selectedRelease.is_lts}
- <span class="px-1.5 py-0.5 bg-emerald-500/20 text-emerald-400 text-[9px] font-bold rounded">LTS</span>
- {/if}
- </div>
- </div>
-
- <div>
- <div class="text-[10px] text-white/40 uppercase tracking-wider mb-1">Architecture</div>
- <div class="text-sm text-white">{settingsState.selectedRelease.architecture}</div>
- </div>
-
- {#if !settingsState.selectedRelease.is_available}
- <div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
- <div class="text-xs text-red-400">Not available for your platform</div>
- </div>
- {/if}
- </div>
- {:else}
- <div class="flex-1 flex items-center justify-center text-white/30 text-sm p-4 text-center">
- Select a Java version to view details
- </div>
- {/if}
- </div>
- </div>
-
- <!-- Download Progress (MC Style) -->
- {#if settingsState.isDownloadingJava && settingsState.downloadProgress}
- <div class="border-t border-white/10 p-4 bg-zinc-900/90">
- <div class="flex items-center justify-between mb-2">
- <h3 class="text-white font-bold text-sm">Downloading Java</h3>
- <span class="text-xs text-zinc-400">{settingsState.downloadProgress.status}</span>
- </div>
-
- <!-- Progress Bar -->
- <div class="mb-2">
- <div class="flex justify-between text-[10px] text-zinc-400 mb-1">
- <span>{settingsState.downloadProgress.file_name}</span>
- <span>{Math.round(settingsState.downloadProgress.percentage)}%</span>
- </div>
- <div class="w-full bg-zinc-800 rounded-full h-2.5 overflow-hidden">
- <div
- class="bg-gradient-to-r from-blue-500 to-cyan-400 h-2.5 rounded-full transition-all duration-200"
- style="width: {settingsState.downloadProgress.percentage}%"
- ></div>
- </div>
- </div>
-
- <!-- Speed & Stats -->
- <div class="flex justify-between text-[10px] text-zinc-500 font-mono">
- <span>
- {settingsState.formatBytes(settingsState.downloadProgress.speed_bytes_per_sec)}/s ·
- ETA: {settingsState.formatTime(settingsState.downloadProgress.eta_seconds)}
- </span>
- <span>
- {settingsState.formatBytes(settingsState.downloadProgress.downloaded_bytes)} /
- {settingsState.formatBytes(settingsState.downloadProgress.total_bytes)}
- </span>
- </div>
- </div>
- {/if}
-
- <!-- Pending Downloads Alert -->
- {#if settingsState.pendingDownloads.length > 0 && !settingsState.isDownloadingJava}
- <div class="border-t border-amber-500/30 p-4 bg-amber-500/10">
- <div class="flex items-center justify-between">
- <div class="flex items-center gap-3">
- <svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
- </svg>
- <span class="text-sm text-amber-200">
- {settingsState.pendingDownloads.length} pending download(s) can be resumed
- </span>
- </div>
- <button
- onclick={() => settingsState.resumeDownloads()}
- class="px-4 py-2 bg-amber-500/20 hover:bg-amber-500/30 text-amber-200 rounded-lg text-sm font-medium transition-colors"
- >
- Resume All
- </button>
- </div>
- </div>
- {/if}
-
- <!-- Footer Actions -->
- <div class="flex items-center justify-between p-4 border-t border-white/10 bg-black/20">
- <button
- onclick={() => settingsState.refreshCatalog()}
- disabled={settingsState.isLoadingCatalog || settingsState.isDownloadingJava}
- class="flex items-center gap-2 px-4 py-2 bg-white/5 hover:bg-white/10 disabled:opacity-50 text-white/70 rounded-lg text-sm transition-colors"
- >
- <svg class="w-4 h-4 {settingsState.isLoadingCatalog ? 'animate-spin' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
- </svg>
- Refresh
- </button>
-
- <div class="flex gap-3">
- {#if settingsState.isDownloadingJava}
- <button
- onclick={() => settingsState.cancelDownload()}
- class="px-5 py-2.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-colors"
- >
- Cancel Download
- </button>
- {:else}
- {@const isInstalled = settingsState.selectedRelease ? settingsState.getInstallStatus(settingsState.selectedRelease) === 'installed' : false}
- <button
- onclick={() => settingsState.closeJavaDownloadModal()}
- class="px-5 py-2.5 bg-white/10 hover:bg-white/20 text-white rounded-lg text-sm font-medium transition-colors"
- >
- Close
- </button>
- <button
- onclick={() => settingsState.downloadJava()}
- disabled={!settingsState.selectedRelease?.is_available || settingsState.isLoadingCatalog || isInstalled}
- class="px-5 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors"
- >
- {isInstalled ? 'Already Installed' : 'Download & Install'}
- </button>
- {/if}
- </div>
- </div>
- </div>
- </div>
-{/if}
diff --git a/packages/ui/src/components/Sidebar.svelte b/packages/ui/src/components/Sidebar.svelte
deleted file mode 100644
index 83f4ac6..0000000
--- a/packages/ui/src/components/Sidebar.svelte
+++ /dev/null
@@ -1,91 +0,0 @@
-<script lang="ts">
- import { uiState } from '../stores/ui.svelte';
- import { Home, Package, Settings, Bot, Folder } from 'lucide-svelte';
-</script>
-
-<aside
- class="w-20 lg:w-64 dark:bg-[#09090b] bg-white border-r dark:border-white/10 border-gray-200 flex flex-col items-center lg:items-start transition-all duration-300 shrink-0 py-6 z-20"
->
- <!-- Logo Area -->
- <div
- class="h-16 w-full flex items-center justify-center lg:justify-start lg:px-6 mb-6"
- >
- <!-- Icon Logo (Small) -->
- <div class="lg:hidden text-black dark:text-white">
- <svg width="32" height="32" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
- <path d="M25 25 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
- <path d="M25 75 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
- <path d="M50 50 L75 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
- <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
- <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
- <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
- <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
- <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
- </svg>
- </div>
- <!-- Full Logo (Large) -->
- <div
- class="hidden lg:flex items-center gap-3 font-bold text-xl tracking-tighter dark:text-white text-black"
- >
- <!-- Neural Network Dropout Logo -->
- <svg width="42" height="42" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" class="shrink-0">
- <!-- Lines -->
- <path d="M25 25 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
- <path d="M25 75 L50 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
- <path d="M50 50 L75 50" stroke="currentColor" stroke-width="4" stroke-linecap="round" />
-
- <!-- Input Layer (Left) -->
- <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
- <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
- <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
-
- <!-- Hidden Layer (Middle) - Dropout visualization -->
- <!-- Dropped units (dashed) -->
- <circle cx="50" cy="25" r="7" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2" fill="none" class="opacity-30" />
- <circle cx="50" cy="75" r="7" stroke="currentColor" stroke-width="2" stroke-dasharray="4 2" fill="none" class="opacity-30" />
- <!-- Active unit -->
- <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
-
- <!-- Output Layer (Right) -->
- <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
- </svg>
-
- <span>DROPOUT</span>
- </div>
- </div>
-
- <!-- Navigation -->
- <nav class="flex-1 w-full flex flex-col gap-1 px-3">
- <!-- Nav Item Helper -->
- {#snippet navItem(view: any, Icon: any, label: string)}
- <button
- class="group flex items-center lg:gap-3 justify-center lg:justify-start w-full px-0 lg:px-4 py-2.5 rounded-sm transition-all duration-200 relative
- {uiState.currentView === view
- ? 'bg-black/5 dark:bg-white/10 dark:text-white text-black font-medium'
- : 'dark:text-zinc-400 text-zinc-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
- onclick={() => uiState.setView(view)}
- >
- <Icon size={20} strokeWidth={uiState.currentView === view ? 2.5 : 2} />
- <span class="hidden lg:block text-sm relative z-10">{label}</span>
-
- <!-- Active Indicator -->
- {#if uiState.currentView === view}
- <div class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-black dark:bg-white rounded-r-full hidden lg:block"></div>
- {/if}
- </button>
- {/snippet}
-
- {@render navItem('home', Home, 'Overview')}
- {@render navItem('instances', Folder, 'Instances')}
- {@render navItem('versions', Package, 'Versions')}
- {@render navItem('guide', Bot, 'Assistant')}
- {@render navItem('settings', Settings, 'Settings')}
- </nav>
-
- <!-- Footer Info -->
- <div
- class="p-4 w-full flex justify-center lg:justify-start lg:px-6 opacity-40 hover:opacity-100 transition-opacity"
- >
- <div class="text-[10px] font-mono text-zinc-500 uppercase tracking-wider">v{uiState.appVersion}</div>
- </div>
-</aside>
diff --git a/packages/ui/src/components/StatusToast.svelte b/packages/ui/src/components/StatusToast.svelte
deleted file mode 100644
index 4c981c7..0000000
--- a/packages/ui/src/components/StatusToast.svelte
+++ /dev/null
@@ -1,42 +0,0 @@
-<script lang="ts">
- import { uiState } from "../stores/ui.svelte";
-</script>
-
-{#if uiState.status !== "Ready"}
- {#key uiState.status}
- <div
- class="absolute top-12 right-12 bg-white/90 dark:bg-zinc-800/90 backdrop-blur border border-zinc-200 dark:border-zinc-600 p-4 rounded-lg shadow-2xl max-w-sm animate-in fade-in slide-in-from-top-4 duration-300 z-50 group"
- >
- <div class="flex justify-between items-start mb-1">
- <div class="text-xs text-zinc-500 dark:text-zinc-400 uppercase font-bold">Status</div>
- <button
- onclick={() => uiState.setStatus("Ready")}
- class="text-zinc-400 hover:text-black dark:text-zinc-500 dark:hover:text-white transition -mt-1 -mr-1 p-1"
- >
- ✕
- </button>
- </div>
- <div class="font-mono text-sm whitespace-pre-wrap mb-2 text-gray-900 dark:text-gray-100">{uiState.status}</div>
- <div class="w-full bg-gray-200 dark:bg-zinc-700/50 h-1 rounded-full overflow-hidden">
- <div
- class="h-full bg-indigo-500 origin-left w-full progress-bar"
- ></div>
- </div>
- </div>
- {/key}
-{/if}
-
-<style>
- .progress-bar {
- animation: progress 5s linear forwards;
- }
-
- @keyframes progress {
- from {
- transform: scaleX(1);
- }
- to {
- transform: scaleX(0);
- }
- }
-</style>
diff --git a/packages/ui/src/components/VersionsView.svelte b/packages/ui/src/components/VersionsView.svelte
deleted file mode 100644
index f1474d9..0000000
--- a/packages/ui/src/components/VersionsView.svelte
+++ /dev/null
@@ -1,511 +0,0 @@
-<script lang="ts">
- import { invoke } from "@tauri-apps/api/core";
- import { listen, type UnlistenFn } from "@tauri-apps/api/event";
- import { gameState } from "../stores/game.svelte";
- import { instancesState } from "../stores/instances.svelte";
- import ModLoaderSelector from "./ModLoaderSelector.svelte";
-
- let searchQuery = $state("");
- let normalizedQuery = $derived(
- searchQuery.trim().toLowerCase().replace(/。/g, ".")
- );
-
- // Filter by version type
- let typeFilter = $state<"all" | "release" | "snapshot" | "installed">("all");
-
- // Installed modded versions with Java version info (Fabric + Forge)
- let installedFabricVersions = $state<Array<{ id: string; javaVersion?: number }>>([]);
- let isLoadingModded = $state(false);
-
- // Load installed modded versions with Java version info (both Fabric and Forge)
- async function loadInstalledModdedVersions() {
- if (!instancesState.activeInstanceId) {
- installedFabricVersions = [];
- isLoadingModded = false;
- return;
- }
- isLoadingModded = true;
- try {
- // Get all installed versions and filter for modded ones (Fabric and Forge)
- const allInstalled = await invoke<Array<{ id: string; type: string }>>(
- "list_installed_versions",
- { instanceId: instancesState.activeInstanceId }
- );
-
- // Filter for Fabric and Forge versions
- const moddedIds = allInstalled
- .filter(v => v.type === "fabric" || v.type === "forge")
- .map(v => v.id);
-
- // Load Java version for each installed modded version
- const versionsWithJava = await Promise.all(
- moddedIds.map(async (id) => {
- try {
- const javaVersion = await invoke<number | null>(
- "get_version_java_version",
- {
- instanceId: instancesState.activeInstanceId!,
- versionId: id,
- }
- );
- return {
- id,
- javaVersion: javaVersion ?? undefined,
- };
- } catch (e) {
- console.error(`Failed to get Java version for ${id}:`, e);
- return { id, javaVersion: undefined };
- }
- })
- );
-
- installedFabricVersions = versionsWithJava;
- } catch (e) {
- console.error("Failed to load installed modded versions:", e);
- } finally {
- isLoadingModded = false;
- }
- }
-
- let versionDeletedUnlisten: UnlistenFn | null = null;
- let downloadCompleteUnlisten: UnlistenFn | null = null;
- let versionInstalledUnlisten: UnlistenFn | null = null;
- let fabricInstalledUnlisten: UnlistenFn | null = null;
- let forgeInstalledUnlisten: UnlistenFn | null = null;
-
- // Load on mount and setup event listeners
- $effect(() => {
- loadInstalledModdedVersions();
- setupEventListeners();
- return () => {
- if (versionDeletedUnlisten) {
- versionDeletedUnlisten();
- }
- if (downloadCompleteUnlisten) {
- downloadCompleteUnlisten();
- }
- if (versionInstalledUnlisten) {
- versionInstalledUnlisten();
- }
- if (fabricInstalledUnlisten) {
- fabricInstalledUnlisten();
- }
- if (forgeInstalledUnlisten) {
- forgeInstalledUnlisten();
- }
- };
- });
-
- async function setupEventListeners() {
- // Refresh versions when a version is deleted
- versionDeletedUnlisten = await listen("version-deleted", async () => {
- await gameState.loadVersions();
- await loadInstalledModdedVersions();
- });
-
- // Refresh versions when a download completes (version installed)
- downloadCompleteUnlisten = await listen("download-complete", async () => {
- await gameState.loadVersions();
- await loadInstalledModdedVersions();
- });
-
- // Refresh when a version is installed
- versionInstalledUnlisten = await listen("version-installed", async () => {
- await gameState.loadVersions();
- await loadInstalledModdedVersions();
- });
-
- // Refresh when Fabric is installed
- fabricInstalledUnlisten = await listen("fabric-installed", async () => {
- await gameState.loadVersions();
- await loadInstalledModdedVersions();
- });
-
- // Refresh when Forge is installed
- forgeInstalledUnlisten = await listen("forge-installed", async () => {
- await gameState.loadVersions();
- await loadInstalledModdedVersions();
- });
- }
-
- // Combined versions list (vanilla + modded)
- let allVersions = $derived(() => {
- const moddedVersions = installedFabricVersions.map((v) => {
- // Determine type based on version ID
- 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: v.javaVersion,
- isInstalled: true, // Modded versions in the list are always installed
- };
- });
- return [...moddedVersions, ...gameState.versions];
- });
-
- let filteredVersions = $derived(() => {
- let versions = allVersions();
-
- // Apply type filter
- if (typeFilter === "release") {
- versions = versions.filter((v) => v.type === "release");
- } else if (typeFilter === "snapshot") {
- versions = versions.filter((v) => v.type === "snapshot");
- } else if (typeFilter === "installed") {
- versions = versions.filter((v) => v.isInstalled === true);
- }
-
- // Apply search filter
- if (normalizedQuery.length > 0) {
- versions = versions.filter((v) =>
- v.id.toLowerCase().includes(normalizedQuery)
- );
- }
-
- return versions;
- });
-
- function getVersionBadge(type: string) {
- switch (type) {
- case "release":
- return { text: "Release", class: "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/20 dark:text-emerald-300 dark:border-emerald-500/30" };
- case "snapshot":
- return { text: "Snapshot", class: "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/20 dark:text-amber-300 dark:border-amber-500/30" };
- case "fabric":
- return { text: "Fabric", class: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-300 dark:border-indigo-500/30" };
- case "forge":
- return { text: "Forge", class: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-500/20 dark:text-orange-300 dark:border-orange-500/30" };
- case "modpack":
- return { text: "Modpack", class: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-500/20 dark:text-purple-300 dark:border-purple-500/30" };
- default:
- return { text: type, class: "bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-zinc-500/20 dark:text-zinc-300 dark:border-zinc-500/30" };
- }
- }
-
- function handleModLoaderInstall(versionId: string) {
- // Refresh the installed versions list
- loadInstalledModdedVersions();
- // Refresh vanilla versions to update isInstalled status
- gameState.loadVersions();
- // Select the newly installed version
- gameState.selectedVersion = versionId;
- }
-
- // Delete confirmation dialog state
- let showDeleteDialog = $state(false);
- let versionToDelete = $state<string | null>(null);
-
- // Show delete confirmation dialog
- function showDeleteConfirmation(versionId: string, event: MouseEvent) {
- event.stopPropagation(); // Prevent version selection
- versionToDelete = versionId;
- showDeleteDialog = true;
- }
-
- // Cancel delete
- function cancelDelete() {
- showDeleteDialog = false;
- versionToDelete = null;
- }
-
- // Confirm and delete version
- async function confirmDelete() {
- if (!versionToDelete) return;
-
- try {
- await invoke("delete_version", {
- instanceId: instancesState.activeInstanceId,
- versionId: versionToDelete
- });
- // Clear selection if deleted version was selected
- if (gameState.selectedVersion === versionToDelete) {
- gameState.selectedVersion = "";
- }
- // Close dialog
- showDeleteDialog = false;
- versionToDelete = null;
- // Versions will be refreshed automatically via event listener
- } catch (e) {
- console.error("Failed to delete version:", e);
- alert(`Failed to delete version: ${e}`);
- // Keep dialog open on error so user can retry
- }
- }
-
- // Version metadata for the selected version
- interface VersionMetadata {
- id: string;
- javaVersion?: number;
- isInstalled: boolean;
- }
-
- let selectedVersionMetadata = $state<VersionMetadata | null>(null);
- let isLoadingMetadata = $state(false);
-
- // Load metadata when version is selected
- async function loadVersionMetadata(versionId: string) {
- if (!versionId) {
- selectedVersionMetadata = null;
- return;
- }
-
- isLoadingMetadata = true;
- try {
- const metadata = await invoke<VersionMetadata>("get_version_metadata", {
- instanceId: instancesState.activeInstanceId,
- versionId,
- });
- selectedVersionMetadata = metadata;
- } catch (e) {
- console.error("Failed to load version metadata:", e);
- selectedVersionMetadata = null;
- } finally {
- isLoadingMetadata = false;
- }
- }
-
- // Watch for selected version changes
- $effect(() => {
- if (gameState.selectedVersion) {
- loadVersionMetadata(gameState.selectedVersion);
- } else {
- selectedVersionMetadata = null;
- }
- });
-
- // Get the base Minecraft version from selected version (for mod loader selector)
- let selectedBaseVersion = $derived(() => {
- const selected = gameState.selectedVersion;
- if (!selected) return "";
-
- // If it's a modded version, extract the base version
- if (selected.startsWith("fabric-loader-")) {
- // Format: fabric-loader-X.X.X-1.20.4
- const parts = selected.split("-");
- return parts[parts.length - 1];
- }
- if (selected.includes("-forge-")) {
- // Format: 1.20.4-forge-49.0.38
- return selected.split("-forge-")[0];
- }
-
- // Check if it's a valid vanilla version
- const version = gameState.versions.find((v) => v.id === selected);
- return version ? selected : "";
- });
-</script>
-
-<div class="h-full flex flex-col p-6 overflow-hidden">
- <div class="flex items-center justify-between mb-6">
- <h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/60">Version Manager</h2>
- <div class="text-sm dark:text-white/40 text-black/50">Select a version to play or modify</div>
- </div>
-
- <div class="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 overflow-hidden">
- <!-- Left: Version List -->
- <div class="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
- <!-- Search and Filters (Glass Bar) -->
- <div class="flex gap-3">
- <div class="relative flex-1">
- <span class="absolute left-3 top-1/2 -translate-y-1/2 dark:text-white/30 text-black/30">ðŸ”</span>
- <input
- type="text"
- placeholder="Search versions..."
- class="w-full pl-9 pr-4 py-3 bg-white/60 dark:bg-black/20 border border-black/10 dark:border-white/10 rounded-xl dark:text-white text-gray-900 placeholder-black/30 dark:placeholder-white/30 focus:outline-none focus:border-indigo-500/50 dark:focus:bg-black/40 focus:bg-white/80 transition-all backdrop-blur-sm"
- bind:value={searchQuery}
- />
- </div>
- </div>
-
- <!-- Type Filter Tabs (Glass Caps) -->
- <div class="flex p-1 bg-white/60 dark:bg-black/20 rounded-xl border border-black/5 dark:border-white/5">
- {#each ['all', 'release', 'snapshot', 'installed'] as filter}
- <button
- class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 capitalize
- {typeFilter === filter
- ? 'bg-white shadow-sm border border-black/5 dark:bg-white/10 dark:text-white dark:shadow-lg dark:border-white/10 text-black'
- : 'text-black/40 dark:text-white/40 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5'}"
- onclick={() => (typeFilter = filter as any)}
- >
- {filter}
- </button>
- {/each}
- </div>
-
- <!-- Version List SCROLL -->
- <div class="flex-1 overflow-y-auto pr-2 space-y-2 custom-scrollbar">
- {#if gameState.versions.length === 0}
- <div class="flex items-center justify-center h-40 dark:text-white/30 text-black/30 italic animate-pulse">
- Fetching manifest...
- </div>
- {:else if filteredVersions().length === 0}
- <div class="flex flex-col items-center justify-center h-40 dark:text-white/30 text-black/30 gap-2">
- <span class="text-2xl">👻</span>
- <span>No matching versions found</span>
- </div>
- {:else}
- {#each filteredVersions() as version}
- {@const badge = getVersionBadge(version.type)}
- {@const isSelected = gameState.selectedVersion === version.id}
- <button
- class="w-full group flex items-center justify-between p-4 rounded-xl text-left border transition-all duration-200 relative overflow-hidden
- {isSelected
- ? 'bg-indigo-50 border-indigo-200 dark:bg-indigo-600/20 dark:border-indigo-500/50 shadow-[0_0_20px_rgba(99,102,241,0.2)]'
- : 'bg-white/40 dark:bg-white/5 border-black/5 dark:border-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={() => (gameState.selectedVersion = version.id)}
- >
- <!-- Selection Glow -->
- {#if isSelected}
- <div class="absolute inset-0 bg-gradient-to-r from-indigo-500/10 to-transparent pointer-events-none"></div>
- {/if}
-
- <div class="relative z-10 flex items-center gap-4 flex-1">
- <span
- class="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border {badge.class}"
- >
- {badge.text}
- </span>
- <div class="flex-1">
- <div class="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 class="flex items-center gap-2 mt-0.5">
- {#if version.releaseTime && version.type !== "fabric" && version.type !== "forge"}
- <div class="text-xs dark:text-white/30 text-black/30">
- {new Date(version.releaseTime).toLocaleDateString()}
- </div>
- {/if}
- {#if version.javaVersion}
- <div class="flex items-center gap-1 text-xs dark:text-white/40 text-black/40">
- <span class="opacity-60">☕</span>
- <span class="font-medium">Java {version.javaVersion}</span>
- </div>
- {/if}
- </div>
- </div>
- </div>
-
- <div class="relative z-10 flex items-center gap-2">
- {#if version.isInstalled === true}
- <button
- onclick={(e) => showDeleteConfirmation(version.id, e)}
- class="p-2 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-500/10 dark:hover:bg-red-500/20 transition-colors opacity-0 group-hover:opacity-100"
- title="Delete version"
- >
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M3 6h18"></path>
- <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
- <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
- </svg>
- </button>
- {/if}
- {#if isSelected}
- <div class="text-indigo-500 dark:text-indigo-400">
- <span class="text-lg">Selected</span>
- </div>
- {/if}
- </div>
- </button>
- {/each}
- {/if}
- </div>
- </div>
-
- <!-- Right: Mod Loader Panel -->
- <div class="flex flex-col gap-4">
- <!-- Selected Version Info Card -->
- <div class="bg-gradient-to-br from-white/40 to-white/20 dark:from-white/10 dark:to-white/5 p-6 rounded-2xl border border-black/5 dark:border-white/10 backdrop-blur-md relative overflow-hidden group">
- <div class="absolute top-0 right-0 p-8 bg-indigo-500/20 blur-[60px] rounded-full group-hover:bg-indigo-500/30 transition-colors"></div>
-
- <h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-2 relative z-10">Current Selection</h3>
- {#if gameState.selectedVersion}
- <p class="font-mono text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/70 relative z-10 truncate mb-4">
- {gameState.selectedVersion}
- </p>
-
- <!-- Version Metadata -->
- {#if isLoadingMetadata}
- <div class="space-y-3 relative z-10">
- <div class="animate-pulse space-y-2">
- <div class="h-4 bg-black/10 dark:bg-white/10 rounded w-3/4"></div>
- <div class="h-4 bg-black/10 dark:bg-white/10 rounded w-1/2"></div>
- </div>
- </div>
- {:else if selectedVersionMetadata}
- <div class="space-y-3 relative z-10">
- <!-- Java Version -->
- {#if selectedVersionMetadata.javaVersion}
- <div>
- <div class="text-[10px] font-bold uppercase tracking-wider dark:text-white/40 text-black/40 mb-1">Java Version</div>
- <div class="flex items-center gap-2">
- <span class="text-lg opacity-60">☕</span>
- <span class="text-sm dark:text-white text-black font-medium">
- Java {selectedVersionMetadata.javaVersion}
- </span>
- </div>
- </div>
- {/if}
-
- <!-- Installation Status -->
- <div>
- <div class="text-[10px] font-bold uppercase tracking-wider dark:text-white/40 text-black/40 mb-1">Status</div>
- <div class="flex items-center gap-2">
- {#if selectedVersionMetadata.isInstalled === true}
- <span class="px-2 py-0.5 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-[10px] font-bold rounded border border-emerald-500/30">
- Installed
- </span>
- {:else if selectedVersionMetadata.isInstalled === false}
- <span class="px-2 py-0.5 bg-zinc-500/20 text-zinc-600 dark:text-zinc-400 text-[10px] font-bold rounded border border-zinc-500/30">
- Not Installed
- </span>
- {/if}
- </div>
- </div>
- </div>
- {/if}
- {:else}
- <p class="dark:text-white/20 text-black/20 italic relative z-10">None selected</p>
- {/if}
- </div>
-
- <!-- Mod Loader Selector Card -->
- <div class="bg-white/60 dark:bg-black/20 p-4 rounded-2xl border border-black/5 dark:border-white/5 backdrop-blur-sm flex-1 flex flex-col">
- <ModLoaderSelector
- selectedGameVersion={selectedBaseVersion()}
- onInstall={handleModLoaderInstall}
- />
- </div>
-
- </div>
- </div>
-
- <!-- Delete Version Confirmation Dialog -->
- {#if showDeleteDialog && versionToDelete}
- <div class="fixed inset-0 z-[200] bg-black/70 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
- <div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200">
- <h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Delete Version</h3>
- <p class="text-zinc-600 dark:text-zinc-400 text-sm mb-6">
- Are you sure you want to delete version <span class="text-gray-900 dark:text-white font-mono font-medium">{versionToDelete}</span>? This action cannot be undone.
- </p>
- <div class="flex gap-3 justify-end">
- <button
- onclick={cancelDelete}
- class="px-4 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-colors"
- >
- Cancel
- </button>
- <button
- onclick={confirmDelete}
- class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors"
- >
- Delete
- </button>
- </div>
- </div>
- </div>
- {/if}
-</div>
diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx
new file mode 100644
index 0000000..5489675
--- /dev/null
+++ b/packages/ui/src/components/bottom-bar.tsx
@@ -0,0 +1,267 @@
+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 { 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 {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "./ui/select";
+import { Spinner } from "./ui/spinner";
+
+interface InstalledVersion {
+ id: string;
+ type: string;
+}
+
+export function BottomBar() {
+ const authStore = useAuthStore();
+ const gameStore = useGameStore();
+ const instancesStore = useInstanceStore();
+
+ 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);
+ return;
+ }
+
+ setIsLoadingVersions(true);
+ try {
+ const versions = await listInstalledVersions(
+ instancesStore.activeInstance.id,
+ );
+
+ const installed = versions || [];
+ setInstalledVersions(installed);
+
+ // If no version is selected but we have installed versions, select the first one
+ if (!gameStore.selectedVersion && installed.length > 0) {
+ gameStore.setSelectedVersion(installed[0].id);
+ }
+ } catch (error) {
+ console.error("Failed to load installed versions:", error);
+ } finally {
+ setIsLoadingVersions(false);
+ }
+ }, [
+ instancesStore.activeInstance,
+ gameStore.selectedVersion,
+ gameStore.setSelectedVersion,
+ ]);
+
+ 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);
+ }
+
+ try {
+ unlistenVersionDeleted = await listen("version-deleted", () => {
+ loadInstalledVersions();
+ });
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn("Failed to attach version-deleted listener:", err);
+ }
+ })();
+
+ return () => {
+ try {
+ if (unlistenDownload) unlistenDownload();
+ } catch {
+ // ignore
+ }
+ try {
+ if (unlistenVersionDeleted) unlistenVersionDeleted();
+ } catch {
+ // ignore
+ }
+ };
+ }, [loadInstalledVersions]);
+
+ const handleStartGame = async () => {
+ if (!selectedVersion) {
+ toast.info("Please select a version!");
+ return;
+ }
+
+ if (!instancesStore.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);
+ }
+ };
+
+ const getVersionTypeColor = (type: string) => {
+ switch (type) {
+ case "release":
+ return "bg-emerald-500";
+ case "snapshot":
+ return "bg-amber-500";
+ case "old_beta":
+ return "bg-rose-500";
+ case "old_alpha":
+ return "bg-violet-500";
+ default:
+ return "bg-gray-500";
+ }
+ };
+
+ const versionOptions = useMemo(
+ () =>
+ installedVersions.map((v) => ({
+ label: `${v.id}${v.type !== "release" ? ` (${v.type})` : ""}`,
+ value: v.id,
+ type: v.type,
+ })),
+ [installedVersions],
+ );
+
+ const renderButton = () => {
+ if (!authStore.account) {
+ return (
+ <Button
+ className="px-4 py-2"
+ size="lg"
+ onClick={() => setShowLoginModal(true)}
+ >
+ <User /> Login
+ </Button>
+ );
+ }
+
+ return isLaunched ? (
+ <Button
+ variant="destructive"
+ onClick={() => {
+ toast.warning(
+ "Minecraft Process will not be terminated, please close it manually.",
+ );
+ setIsLaunched(false);
+ }}
+ >
+ <XIcon />
+ Game started
+ </Button>
+ ) : (
+ <Button
+ className={cn(
+ "px-4 py-2 shadow-xl",
+ "bg-emerald-600! hover:bg-emerald-500!",
+ )}
+ size="lg"
+ onClick={handleStartGame}
+ disabled={isLaunching}
+ >
+ {isLaunching ? <Spinner /> : <Play />}
+ Start
+ </Button>
+ );
+ };
+
+ return (
+ <div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/30 via-transparent to-transparent p-4 z-10">
+ <div className="max-w-7xl mx-auto">
+ <div className="flex items-center justify-between bg-white/5 dark:bg-black/20 backdrop-blur-xl 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>
+
+ <Select
+ items={versionOptions}
+ onValueChange={setSelectedVersion}
+ disabled={isLoadingVersions}
+ >
+ <SelectTrigger className="max-w-48">
+ <SelectValue
+ placeholder={
+ isLoadingVersions
+ ? "Loading versions..."
+ : "Please select a version"
+ }
+ />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectGroup>
+ {versionOptions.map((item) => (
+ <SelectItem
+ key={item.value}
+ value={item.value}
+ className={getVersionTypeColor(item.type)}
+ >
+ {item.label}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="flex items-center gap-3">{renderButton()}</div>
+ </div>
+ </div>
+
+ <LoginModal
+ open={showLoginModal}
+ onOpenChange={() => setShowLoginModal(false)}
+ />
+ </div>
+ );
+}
diff --git a/packages/ui/src/components/config-editor.tsx b/packages/ui/src/components/config-editor.tsx
new file mode 100644
index 0000000..129b8f7
--- /dev/null
+++ b/packages/ui/src/components/config-editor.tsx
@@ -0,0 +1,111 @@
+import type React from "react";
+import { useEffect, useState } from "react";
+import { type ZodType, z } from "zod";
+import { useSettingsStore } from "@/models/settings";
+import type { LauncherConfig } from "@/types";
+import { Button } from "./ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "./ui/dialog";
+import { FieldError } from "./ui/field";
+import { Spinner } from "./ui/spinner";
+import { Textarea } from "./ui/textarea";
+
+const launcherConfigSchema: ZodType<LauncherConfig> = z.object({
+ minMemory: z.number(),
+ maxMemory: z.number(),
+ javaPath: z.string(),
+ width: z.number(),
+ height: z.number(),
+ downloadThreads: z.number(),
+ customBackgroundPath: z.string().nullable(),
+ enableGpuAcceleration: z.boolean(),
+ enableVisualEffects: z.boolean(),
+ activeEffect: z.string(),
+ theme: z.string(),
+ logUploadService: z.string(),
+ pastebinApiKey: z.string().nullable(),
+ assistant: z.any(), // TODO: AssistantConfig schema
+ useSharedCaches: z.boolean(),
+ keepLegacyPerInstanceStorage: z.boolean(),
+ featureFlags: z.any(), // TODO: FeatureFlags schema
+});
+
+export interface ConfigEditorProps
+ extends Omit<React.ComponentPropsWithoutRef<typeof Dialog>, "onOpenChange"> {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function ConfigEditor({ onOpenChange, ...props }: ConfigEditorProps) {
+ const settings = useSettingsStore();
+
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
+ const [rawConfigContent, setRawConfigContent] = useState(
+ JSON.stringify(settings.config, null, 2),
+ );
+ const [isSaving, setIsSaving] = useState(false);
+
+ useEffect(() => {
+ setRawConfigContent(JSON.stringify(settings.config, null, 2));
+ }, [settings.config]);
+
+ const handleSave = async () => {
+ setIsSaving(true);
+ setErrorMessage(null);
+ try {
+ const validatedConfig = launcherConfigSchema.parse(
+ JSON.parse(rawConfigContent),
+ );
+ settings.config = validatedConfig;
+ await settings.save();
+ onOpenChange?.(false);
+ } catch (error) {
+ setErrorMessage(error instanceof Error ? error.message : String(error));
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+ <Dialog onOpenChange={onOpenChange} {...props}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Edit Configuration</DialogTitle>
+ <DialogDescription>
+ Edit the raw JSON configuration file.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Textarea
+ value={rawConfigContent}
+ onChange={(e) => setRawConfigContent(e.target.value)}
+ className="font-mono text-sm h-100 resize-none"
+ spellCheck={false}
+ aria-invalid={!!errorMessage}
+ />
+
+ {errorMessage && <FieldError errors={[{ message: errorMessage }]} />}
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange?.(false)}
+ disabled={isSaving}
+ >
+ Cancel
+ </Button>
+ <Button onClick={handleSave} disabled={isSaving}>
+ {isSaving && <Spinner />}
+ Save Changes
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/packages/ui/src/components/download-monitor.tsx b/packages/ui/src/components/download-monitor.tsx
new file mode 100644
index 0000000..f3902d9
--- /dev/null
+++ b/packages/ui/src/components/download-monitor.tsx
@@ -0,0 +1,62 @@
+import { X } from "lucide-react";
+import { useState } from "react";
+
+export function DownloadMonitor() {
+ const [isVisible, setIsVisible] = useState(true);
+
+ if (!isVisible) return null;
+
+ return (
+ <div className="bg-zinc-900/80 backdrop-blur-md border border-zinc-700 rounded-lg shadow-2xl overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between px-4 py-3 bg-zinc-800/50 border-b border-zinc-700">
+ <div className="flex items-center gap-2">
+ <div className="w-2 h-2 bg-emerald-500 rounded-full animate-pulse"></div>
+ <span className="text-sm font-medium text-white">Downloads</span>
+ </div>
+ <button
+ type="button"
+ onClick={() => setIsVisible(false)}
+ className="text-zinc-400 hover:text-white transition-colors p-1"
+ >
+ <X size={16} />
+ </button>
+ </div>
+
+ {/* Content */}
+ <div className="p-4">
+ <div className="space-y-3">
+ {/* Download Item */}
+ <div className="space-y-1">
+ <div className="flex justify-between text-xs">
+ <span className="text-zinc-300">Minecraft 1.20.4</span>
+ <span className="text-zinc-400">65%</span>
+ </div>
+ <div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
+ <div
+ className="h-full bg-emerald-500 rounded-full transition-all duration-300"
+ style={{ width: "65%" }}
+ ></div>
+ </div>
+ <div className="flex justify-between text-[10px] text-zinc-500">
+ <span>142 MB / 218 MB</span>
+ <span>2.1 MB/s • 36s remaining</span>
+ </div>
+ </div>
+
+ {/* Download Item */}
+ <div className="space-y-1">
+ <div className="flex justify-between text-xs">
+ <span className="text-zinc-300">Java 17</span>
+ <span className="text-zinc-400">100%</span>
+ </div>
+ <div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
+ <div className="h-full bg-emerald-500 rounded-full"></div>
+ </div>
+ <div className="text-[10px] text-emerald-400">Completed</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui/src/components/game-console.tsx b/packages/ui/src/components/game-console.tsx
new file mode 100644
index 0000000..6980c8c
--- /dev/null
+++ b/packages/ui/src/components/game-console.tsx
@@ -0,0 +1,290 @@
+import { Copy, Download, Filter, Search, Trash2, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { useLogsStore } from "@/stores/logs-store";
+import { useUIStore } from "@/stores/ui-store";
+
+export function GameConsole() {
+ const uiStore = useUIStore();
+ const logsStore = useLogsStore();
+
+ const [searchTerm, setSearchTerm] = useState("");
+ const [selectedLevels, setSelectedLevels] = useState<Set<string>>(
+ new Set(["info", "warn", "error", "debug", "fatal"]),
+ );
+ const [autoScroll, setAutoScroll] = useState(true);
+ const consoleEndRef = useRef<HTMLDivElement>(null);
+ const logsContainerRef = useRef<HTMLDivElement>(null);
+
+ const levelColors: Record<string, string> = {
+ info: "text-blue-400",
+ warn: "text-amber-400",
+ error: "text-red-400",
+ debug: "text-purple-400",
+ fatal: "text-rose-400",
+ };
+
+ const levelBgColors: Record<string, string> = {
+ info: "bg-blue-400/10",
+ warn: "bg-amber-400/10",
+ error: "bg-red-400/10",
+ debug: "bg-purple-400/10",
+ fatal: "bg-rose-400/10",
+ };
+
+ // Filter logs based on search term and selected levels
+ const filteredLogs = logsStore.logs.filter((log) => {
+ const matchesSearch =
+ searchTerm === "" ||
+ log.message.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ log.source.toLowerCase().includes(searchTerm.toLowerCase());
+
+ const matchesLevel = selectedLevels.has(log.level);
+
+ return matchesSearch && matchesLevel;
+ });
+
+ // Auto-scroll to bottom when new logs arrive or autoScroll is enabled
+ useEffect(() => {
+ if (autoScroll && consoleEndRef.current && filteredLogs.length > 0) {
+ consoleEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [filteredLogs, autoScroll]);
+
+ // Handle keyboard shortcuts
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Ctrl/Cmd + K to focus search
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
+ e.preventDefault();
+ // Focus search input
+ const searchInput = document.querySelector(
+ 'input[type="text"]',
+ ) as HTMLInputElement;
+ if (searchInput) searchInput.focus();
+ }
+ // Escape to close console
+ if (e.key === "Escape") {
+ uiStore.toggleConsole();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [uiStore.toggleConsole]);
+
+ const toggleLevel = (level: string) => {
+ const newLevels = new Set(selectedLevels);
+ if (newLevels.has(level)) {
+ newLevels.delete(level);
+ } else {
+ newLevels.add(level);
+ }
+ setSelectedLevels(newLevels);
+ };
+
+ const handleCopyAll = () => {
+ const logsText = logsStore.exportLogs(filteredLogs);
+ navigator.clipboard.writeText(logsText);
+ };
+
+ const handleExport = () => {
+ const logsText = logsStore.exportLogs(filteredLogs);
+ const blob = new Blob([logsText], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `dropout_logs_${new Date().toISOString().replace(/[:.]/g, "-")}.txt`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ const handleClear = () => {
+ logsStore.clear();
+ };
+
+ return (
+ <>
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 border-b border-zinc-700 bg-[#252526]">
+ <div className="flex items-center gap-3">
+ <h2 className="text-lg font-bold text-white">Game Console</h2>
+ <div className="flex items-center gap-1">
+ <span className="text-xs text-zinc-400">Logs:</span>
+ <span className="text-xs font-medium text-emerald-400">
+ {filteredLogs.length}
+ </span>
+ <span className="text-xs text-zinc-400">/</span>
+ <span className="text-xs text-zinc-400">
+ {logsStore.logs.length}
+ </span>
+ </div>
+ </div>
+ <button
+ type="button"
+ onClick={() => uiStore.toggleConsole()}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ >
+ <X size={20} />
+ </button>
+ </div>
+
+ {/* Toolbar */}
+ <div className="flex items-center gap-3 p-3 border-b border-zinc-700 bg-[#2D2D30]">
+ {/* Search */}
+ <div className="relative flex-1">
+ <Search
+ className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-500"
+ size={16}
+ />
+ <input
+ type="text"
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ placeholder="Search logs..."
+ className="w-full pl-10 pr-4 py-2 bg-[#3E3E42] border border-zinc-600 rounded text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
+ />
+ {searchTerm && (
+ <button
+ type="button"
+ onClick={() => setSearchTerm("")}
+ className="absolute right-3 top-1/2 transform -translate-y-1/2 text-zinc-400 hover:text-white"
+ >
+ ×
+ </button>
+ )}
+ </div>
+
+ {/* Level Filters */}
+ <div className="flex items-center gap-1">
+ {Object.entries(levelColors).map(([level, colorClass]) => (
+ <button
+ type="button"
+ key={level}
+ onClick={() => toggleLevel(level)}
+ className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
+ selectedLevels.has(level)
+ ? `${levelBgColors[level]} ${colorClass}`
+ : "bg-[#3E3E42] text-zinc-400 hover:text-white"
+ }`}
+ >
+ {level.toUpperCase()}
+ </button>
+ ))}
+ </div>
+
+ {/* Actions */}
+ <div className="flex items-center gap-1">
+ <button
+ type="button"
+ onClick={handleCopyAll}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ title="Copy all logs"
+ >
+ <Copy size={16} />
+ </button>
+ <button
+ type="button"
+ onClick={handleExport}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ title="Export logs"
+ >
+ <Download size={16} />
+ </button>
+ <button
+ type="button"
+ onClick={handleClear}
+ className="p-2 text-zinc-400 hover:text-white transition-colors"
+ title="Clear logs"
+ >
+ <Trash2 size={16} />
+ </button>
+ </div>
+
+ {/* Auto-scroll Toggle */}
+ <div className="flex items-center gap-2 pl-2 border-l border-zinc-700">
+ <label className="inline-flex items-center cursor-pointer">
+ <input
+ type="checkbox"
+ checked={autoScroll}
+ onChange={(e) => setAutoScroll(e.target.checked)}
+ className="sr-only peer"
+ />
+ <div className="relative w-9 h-5 bg-zinc-700 peer-focus:outline-none peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
+ <span className="ml-2 text-xs text-zinc-400">Auto-scroll</span>
+ </label>
+ </div>
+ </div>
+
+ {/* Logs Container */}
+ <div
+ ref={logsContainerRef}
+ className="flex-1 overflow-y-auto font-mono text-sm bg-[#1E1E1E]"
+ style={{ fontFamily: "'Cascadia Code', 'Consolas', monospace" }}
+ >
+ {filteredLogs.length === 0 ? (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center text-zinc-500">
+ <Filter className="mx-auto mb-2" size={24} />
+ <p>No logs match the current filters</p>
+ </div>
+ </div>
+ ) : (
+ <div className="p-4 space-y-1">
+ {filteredLogs.map((log) => (
+ <div
+ key={log.id}
+ className="group hover:bg-white/5 p-2 rounded transition-colors"
+ >
+ <div className="flex items-start gap-3">
+ <div
+ className={`px-2 py-0.5 rounded text-xs font-medium ${levelBgColors[log.level]} ${levelColors[log.level]}`}
+ >
+ {log.level.toUpperCase()}
+ </div>
+ <div className="text-zinc-400 text-xs shrink-0">
+ {log.timestamp}
+ </div>
+ <div className="text-amber-300 text-xs shrink-0">
+ [{log.source}]
+ </div>
+ <div className="text-gray-300 flex-1">{log.message}</div>
+ </div>
+ </div>
+ ))}
+ <div ref={consoleEndRef} />
+ </div>
+ )}
+ </div>
+
+ {/* Footer */}
+ <div className="flex items-center justify-between p-3 border-t border-zinc-700 bg-[#252526] text-xs text-zinc-400">
+ <div className="flex items-center gap-4">
+ <div>
+ <span>Total: </span>
+ <span className="text-white">{logsStore.logs.length}</span>
+ <span> | Filtered: </span>
+ <span className="text-emerald-400">{filteredLogs.length}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <kbd className="px-1.5 py-0.5 bg-[#3E3E42] rounded text-xs">
+ Ctrl+K
+ </kbd>
+ <span>to search</span>
+ </div>
+ </div>
+ <div>
+ <span>Updated: </span>
+ <span>
+ {new Date().toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ })}
+ </span>
+ </div>
+ </div>
+ </>
+ );
+}
diff --git a/packages/ui/src/components/instance-creation-modal.tsx b/packages/ui/src/components/instance-creation-modal.tsx
new file mode 100644
index 0000000..7c46d0f
--- /dev/null
+++ b/packages/ui/src/components/instance-creation-modal.tsx
@@ -0,0 +1,544 @@
+import { Loader2, Search } from "lucide-react";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
+import {
+ getFabricLoadersForVersion,
+ getForgeVersionsForGame,
+ installFabric,
+ installForge,
+ installVersion,
+} from "@/client";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { useInstanceStore } from "@/models/instance";
+import { useGameStore } from "@/stores/game-store";
+import type {
+ FabricLoaderEntry,
+ ForgeVersion as ForgeVersionEntry,
+ Version,
+} from "@/types";
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function InstanceCreationModal({ open, onOpenChange }: Props) {
+ const gameStore = useGameStore();
+ const instancesStore = useInstanceStore();
+
+ // Steps: 1 = name, 2 = version, 3 = mod loader
+ const [step, setStep] = useState<number>(1);
+
+ // Step 1
+ const [instanceName, setInstanceName] = useState<string>("");
+
+ // Step 2
+ const [versionSearch, setVersionSearch] = useState<string>("");
+ const [versionFilter, setVersionFilter] = useState<
+ "all" | "release" | "snapshot"
+ >("release");
+ const [selectedVersionUI, setSelectedVersionUI] = useState<Version | null>(
+ null,
+ );
+
+ // Step 3
+ const [modLoaderType, setModLoaderType] = useState<
+ "vanilla" | "fabric" | "forge"
+ >("vanilla");
+ const [fabricLoaders, setFabricLoaders] = useState<FabricLoaderEntry[]>([]);
+ const [forgeVersions, setForgeVersions] = useState<ForgeVersionEntry[]>([]);
+ const [selectedFabricLoader, setSelectedFabricLoader] = useState<string>("");
+ const [selectedForgeLoader, setSelectedForgeLoader] = useState<string>("");
+ const [loadingLoaders, setLoadingLoaders] = useState(false);
+
+ const loadModLoaders = useCallback(async () => {
+ if (!selectedVersionUI) return;
+ setLoadingLoaders(true);
+ setFabricLoaders([]);
+ setForgeVersions([]);
+ try {
+ if (modLoaderType === "fabric") {
+ const loaders = await getFabricLoadersForVersion(selectedVersionUI.id);
+ setFabricLoaders(loaders || []);
+ if (loaders && loaders.length > 0) {
+ setSelectedFabricLoader(loaders[0].loader.version);
+ } else {
+ setSelectedFabricLoader("");
+ }
+ } else if (modLoaderType === "forge") {
+ const versions = await getForgeVersionsForGame(selectedVersionUI.id);
+ setForgeVersions(versions || []);
+ if (versions && versions.length > 0) {
+ // Binding `ForgeVersion` uses `version` (not `id`) — use `.version` here.
+ setSelectedForgeLoader(versions[0].version);
+ } else {
+ setSelectedForgeLoader("");
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load mod loaders:", e);
+ toast.error("Failed to fetch mod loader versions");
+ } finally {
+ setLoadingLoaders(false);
+ }
+ }, [modLoaderType, selectedVersionUI]);
+
+ // When entering step 3 and a base version exists, fetch loaders if needed
+ useEffect(() => {
+ if (step === 3 && modLoaderType !== "vanilla" && selectedVersionUI) {
+ loadModLoaders();
+ }
+ }, [step, modLoaderType, selectedVersionUI, loadModLoaders]);
+
+ // Creating state
+ const [creating, setCreating] = useState(false);
+ const [errorMessage, setErrorMessage] = useState<string>("");
+
+ // Derived filtered versions
+ const filteredVersions = useMemo(() => {
+ const all = gameStore.versions || [];
+ let list = all.slice();
+ if (versionFilter !== "all") {
+ list = list.filter((v) => v.type === versionFilter);
+ }
+ if (versionSearch.trim()) {
+ const q = versionSearch.trim().toLowerCase().replace(/。/g, ".");
+ list = list.filter((v) => v.id.toLowerCase().includes(q));
+ }
+ return list;
+ }, [gameStore.versions, versionFilter, versionSearch]);
+
+ // Reset when opened/closed
+ useEffect(() => {
+ if (open) {
+ // ensure versions are loaded
+ gameStore.loadVersions();
+ setStep(1);
+ setInstanceName("");
+ setVersionSearch("");
+ setVersionFilter("release");
+ setSelectedVersionUI(null);
+ setModLoaderType("vanilla");
+ setFabricLoaders([]);
+ setForgeVersions([]);
+ setSelectedFabricLoader("");
+ setSelectedForgeLoader("");
+ setErrorMessage("");
+ setCreating(false);
+ }
+ }, [open, gameStore.loadVersions]);
+
+ function validateStep1(): boolean {
+ if (!instanceName.trim()) {
+ setErrorMessage("Please enter an instance name");
+ return false;
+ }
+ setErrorMessage("");
+ return true;
+ }
+
+ function validateStep2(): boolean {
+ if (!selectedVersionUI) {
+ setErrorMessage("Please select a Minecraft version");
+ return false;
+ }
+ setErrorMessage("");
+ return true;
+ }
+
+ async function handleNext() {
+ setErrorMessage("");
+ if (step === 1) {
+ if (!validateStep1()) return;
+ setStep(2);
+ } else if (step === 2) {
+ if (!validateStep2()) return;
+ setStep(3);
+ }
+ }
+
+ function handleBack() {
+ setErrorMessage("");
+ setStep((s) => Math.max(1, s - 1));
+ }
+
+ async function handleCreate() {
+ if (!validateStep1() || !validateStep2()) return;
+ setCreating(true);
+ setErrorMessage("");
+
+ try {
+ // Step 1: create instance
+ const instance = await instancesStore.create(instanceName.trim());
+
+ // If selectedVersion provided, install it
+ if (selectedVersionUI && instance) {
+ try {
+ await installVersion(instance?.id, selectedVersionUI.id);
+ } catch (err) {
+ console.error("Failed to install base version:", err);
+ // continue - instance created but version install failed
+ toast.error(
+ `Failed to install version ${selectedVersionUI.id}: ${String(err)}`,
+ );
+ }
+ }
+
+ // If mod loader selected, install it
+ if (modLoaderType === "fabric" && selectedFabricLoader && instance) {
+ try {
+ await installFabric(
+ instance?.id,
+ selectedVersionUI?.id ?? "",
+ selectedFabricLoader,
+ );
+ } catch (err) {
+ console.error("Failed to install Fabric:", err);
+ toast.error(`Failed to install Fabric: ${String(err)}`);
+ }
+ } else if (modLoaderType === "forge" && selectedForgeLoader && instance) {
+ try {
+ await installForge(
+ instance?.id,
+ selectedVersionUI?.id ?? "",
+ selectedForgeLoader,
+ );
+ } catch (err) {
+ console.error("Failed to install Forge:", err);
+ toast.error(`Failed to install Forge: ${String(err)}`);
+ }
+ }
+
+ // Refresh instances list
+ await instancesStore.refresh();
+
+ toast.success("Instance created successfully");
+ onOpenChange(false);
+ } catch (e) {
+ console.error("Failed to create instance:", e);
+ setErrorMessage(String(e));
+ toast.error(`Failed to create instance: ${e}`);
+ } finally {
+ setCreating(false);
+ }
+ }
+
+ // UI pieces
+ const StepIndicator = () => (
+ <div className="flex gap-2 w-full">
+ <div
+ className={`flex-1 h-1 rounded ${step >= 1 ? "bg-indigo-500" : "bg-zinc-700"}`}
+ />
+ <div
+ className={`flex-1 h-1 rounded ${step >= 2 ? "bg-indigo-500" : "bg-zinc-700"}`}
+ />
+ <div
+ className={`flex-1 h-1 rounded ${step >= 3 ? "bg-indigo-500" : "bg-zinc-700"}`}
+ />
+ </div>
+ );
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="w-full max-w-3xl max-h-[90vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Create New Instance</DialogTitle>
+ <DialogDescription>
+ Multi-step wizard — create an instance and optionally install a
+ version or mod loader.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="px-6">
+ <div className="pt-4 pb-6">
+ <StepIndicator />
+ </div>
+
+ {/* Step 1 - Name */}
+ {step === 1 && (
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="instance-name"
+ className="block text-sm font-medium mb-2"
+ >
+ Instance Name
+ </label>
+ <Input
+ id="instance-name"
+ placeholder="My Minecraft Instance"
+ value={instanceName}
+ onChange={(e) => setInstanceName(e.target.value)}
+ disabled={creating}
+ />
+ </div>
+ <p className="text-xs text-muted-foreground">
+ Give your instance a memorable name.
+ </p>
+ </div>
+ )}
+
+ {/* Step 2 - Version selection */}
+ {step === 2 && (
+ <div className="space-y-4">
+ <div className="flex gap-3">
+ <div className="relative flex-1">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
+ <Input
+ value={versionSearch}
+ onChange={(e) => setVersionSearch(e.target.value)}
+ placeholder="Search versions..."
+ className="pl-9"
+ />
+ </div>
+
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant={versionFilter === "all" ? "default" : "outline"}
+ onClick={() => setVersionFilter("all")}
+ >
+ All
+ </Button>
+ <Button
+ type="button"
+ variant={
+ versionFilter === "release" ? "default" : "outline"
+ }
+ onClick={() => setVersionFilter("release")}
+ >
+ Release
+ </Button>
+ <Button
+ type="button"
+ variant={
+ versionFilter === "snapshot" ? "default" : "outline"
+ }
+ onClick={() => setVersionFilter("snapshot")}
+ >
+ Snapshot
+ </Button>
+ </div>
+ </div>
+
+ <ScrollArea className="max-h-[36vh]">
+ <div className="space-y-2 py-2">
+ {gameStore.versions.length === 0 ? (
+ <div className="flex items-center justify-center py-8 text-muted-foreground">
+ <Loader2 className="animate-spin mr-2" />
+ Loading versions...
+ </div>
+ ) : filteredVersions.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ No matching versions found
+ </div>
+ ) : (
+ filteredVersions.map((v) => {
+ const isSelected = selectedVersionUI?.id === v.id;
+ return (
+ <button
+ key={v.id}
+ type="button"
+ onClick={() => setSelectedVersionUI(v)}
+ className={`w-full text-left p-3 rounded-lg border transition-colors ${
+ isSelected
+ ? "bg-indigo-50 dark:bg-indigo-600/20 border-indigo-200"
+ : "bg-white/40 dark:bg-white/5 border-black/5 dark:border-white/5 hover:bg-white/60"
+ }`}
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-mono font-bold">{v.id}</div>
+ <div className="text-xs text-muted-foreground mt-1">
+ {v.type}{" "}
+ {v.releaseTime
+ ? ` • ${new Date(v.releaseTime).toLocaleDateString()}`
+ : ""}
+ </div>
+ </div>
+ {v.javaVersion && (
+ <div className="text-sm">
+ Java {v.javaVersion}
+ </div>
+ )}
+ </div>
+ </button>
+ );
+ })
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ )}
+
+ {/* Step 3 - Mod loader */}
+ {step === 3 && (
+ <div className="space-y-4">
+ <div>
+ <div className="text-sm font-medium mb-2">Mod Loader Type</div>
+ <div className="flex gap-3">
+ <Button
+ type="button"
+ variant={
+ modLoaderType === "vanilla" ? "default" : "outline"
+ }
+ onClick={() => setModLoaderType("vanilla")}
+ >
+ Vanilla
+ </Button>
+ <Button
+ type="button"
+ variant={modLoaderType === "fabric" ? "default" : "outline"}
+ onClick={() => setModLoaderType("fabric")}
+ >
+ Fabric
+ </Button>
+ <Button
+ type="button"
+ variant={modLoaderType === "forge" ? "default" : "outline"}
+ onClick={() => setModLoaderType("forge")}
+ >
+ Forge
+ </Button>
+ </div>
+ </div>
+
+ {modLoaderType === "fabric" && (
+ <div>
+ {loadingLoaders ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="animate-spin" />
+ Loading Fabric versions...
+ </div>
+ ) : fabricLoaders.length > 0 ? (
+ <div className="space-y-2">
+ <select
+ value={selectedFabricLoader}
+ onChange={(e) =>
+ setSelectedFabricLoader(e.target.value)
+ }
+ className="w-full px-3 py-2 rounded border bg-transparent"
+ >
+ {fabricLoaders.map((f) => (
+ <option
+ key={f.loader.version}
+ value={f.loader.version}
+ >
+ {f.loader.version}{" "}
+ {f.loader.stable ? "(Stable)" : "(Beta)"}
+ </option>
+ ))}
+ </select>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ No Fabric loaders available for this version
+ </p>
+ )}
+ </div>
+ )}
+
+ {modLoaderType === "forge" && (
+ <div>
+ {loadingLoaders ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="animate-spin" />
+ Loading Forge versions...
+ </div>
+ ) : forgeVersions.length > 0 ? (
+ <div className="space-y-2">
+ <select
+ value={selectedForgeLoader}
+ onChange={(e) => setSelectedForgeLoader(e.target.value)}
+ className="w-full px-3 py-2 rounded border bg-transparent"
+ >
+ {forgeVersions.map((f) => (
+ // binding ForgeVersion uses `version` as the identifier
+ <option key={f.version} value={f.version}>
+ {f.version}
+ </option>
+ ))}
+ </select>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ No Forge versions available for this version
+ </p>
+ )}
+ </div>
+ )}
+ </div>
+ )}
+
+ {errorMessage && (
+ <div className="text-sm text-red-400 mt-3">{errorMessage}</div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <div className="w-full flex justify-between items-center">
+ <div>
+ <Button
+ type="button"
+ variant="ghost"
+ onClick={() => {
+ // cancel
+ onOpenChange(false);
+ }}
+ disabled={creating}
+ >
+ Cancel
+ </Button>
+ </div>
+
+ <div className="flex gap-2">
+ {step > 1 && (
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleBack}
+ disabled={creating}
+ >
+ Back
+ </Button>
+ )}
+
+ {step < 3 ? (
+ <Button type="button" onClick={handleNext} disabled={creating}>
+ Next
+ </Button>
+ ) : (
+ <Button
+ type="button"
+ onClick={handleCreate}
+ disabled={creating}
+ >
+ {creating ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Creating...
+ </>
+ ) : (
+ "Create"
+ )}
+ </Button>
+ )}
+ </div>
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+export default InstanceCreationModal;
diff --git a/packages/ui/src/components/instance-editor-modal.tsx b/packages/ui/src/components/instance-editor-modal.tsx
new file mode 100644
index 0000000..d964185
--- /dev/null
+++ b/packages/ui/src/components/instance-editor-modal.tsx
@@ -0,0 +1,548 @@
+import { invoke } from "@tauri-apps/api/core";
+import { Folder, Loader2, Save, Trash2, X } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+
+import { toNumber } from "@/lib/tsrs-utils";
+import { useInstanceStore } from "@/models/instance";
+import { useSettingsStore } from "@/models/settings";
+import type { FileInfo } from "../types/bindings/core";
+import type { Instance } from "../types/bindings/instance";
+
+type Props = {
+ open: boolean;
+ instance: Instance | null;
+ onOpenChange: (open: boolean) => void;
+};
+
+export function InstanceEditorModal({ open, instance, onOpenChange }: Props) {
+ const instancesStore = useInstanceStore();
+ const { config } = useSettingsStore();
+
+ const [activeTab, setActiveTab] = useState<
+ "info" | "version" | "files" | "settings"
+ >("info");
+ const [saving, setSaving] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
+
+ // Info tab fields
+ const [editName, setEditName] = useState("");
+ const [editNotes, setEditNotes] = useState("");
+
+ // Files tab state
+ const [selectedFileFolder, setSelectedFileFolder] = useState<
+ "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots"
+ >("mods");
+ const [fileList, setFileList] = useState<FileInfo[]>([]);
+ const [loadingFiles, setLoadingFiles] = useState(false);
+ const [deletingPath, setDeletingPath] = useState<string | null>(null);
+
+ // Version tab state (placeholder - the Svelte implementation used a ModLoaderSelector component)
+ // React versions-view/instance-creation handle mod loader installs; here we show basic current info.
+
+ // Settings tab fields
+ const [editMemoryMin, setEditMemoryMin] = useState<number>(0);
+ const [editMemoryMax, setEditMemoryMax] = useState<number>(0);
+ const [editJavaArgs, setEditJavaArgs] = useState<string>("");
+
+ // initialize when open & instance changes
+ useEffect(() => {
+ if (open && instance) {
+ setActiveTab("info");
+ setSaving(false);
+ setErrorMessage("");
+ setEditName(instance.name || "");
+ setEditNotes(instance.notes ?? "");
+ setEditMemoryMin(
+ (instance.memoryOverride && toNumber(instance.memoryOverride.min)) ??
+ config?.minMemory ??
+ 512,
+ );
+ setEditMemoryMax(
+ (instance.memoryOverride && toNumber(instance.memoryOverride.max)) ??
+ config?.maxMemory ??
+ 2048,
+ );
+ setEditJavaArgs(instance.jvmArgsOverride ?? "");
+ setFileList([]);
+ setSelectedFileFolder("mods");
+ }
+ }, [open, instance, config?.minMemory, config?.maxMemory]);
+
+ // load files when switching to files tab
+ const loadFileList = useCallback(
+ async (
+ folder:
+ | "mods"
+ | "resourcepacks"
+ | "shaderpacks"
+ | "saves"
+ | "screenshots",
+ ) => {
+ if (!instance) return;
+ setLoadingFiles(true);
+ try {
+ const files = await invoke<FileInfo[]>("list_instance_directory", {
+ instanceId: instance.id,
+ folder,
+ });
+ setFileList(files || []);
+ } catch (err) {
+ console.error("Failed to load files:", err);
+ toast.error("Failed to load files: " + String(err));
+ setFileList([]);
+ } finally {
+ setLoadingFiles(false);
+ }
+ },
+ [instance],
+ );
+
+ useEffect(() => {
+ if (open && instance && activeTab === "files") {
+ // explicitly pass the selected folder so loadFileList doesn't rely on stale closures
+ loadFileList(selectedFileFolder);
+ }
+ }, [activeTab, open, instance, selectedFileFolder, loadFileList]);
+
+ async function changeFolder(
+ folder: "mods" | "resourcepacks" | "shaderpacks" | "saves" | "screenshots",
+ ) {
+ setSelectedFileFolder(folder);
+ // reload the list for the newly selected folder
+ if (open && instance) await loadFileList(folder);
+ }
+
+ async function deleteFile(filePath: string) {
+ if (
+ !confirm(
+ `Are you sure you want to delete "${filePath.split("/").pop()}"?`,
+ )
+ ) {
+ return;
+ }
+ setDeletingPath(filePath);
+ try {
+ await invoke("delete_instance_file", { path: filePath });
+ // refresh the currently selected folder
+ await loadFileList(selectedFileFolder);
+ toast.success("Deleted");
+ } catch (err) {
+ console.error("Failed to delete file:", err);
+ toast.error("Failed to delete file: " + String(err));
+ } finally {
+ setDeletingPath(null);
+ }
+ }
+
+ async function openInExplorer(filePath: string) {
+ try {
+ await invoke("open_file_explorer", { path: filePath });
+ } catch (err) {
+ console.error("Failed to open in explorer:", err);
+ toast.error("Failed to open file explorer: " + String(err));
+ }
+ }
+
+ async function saveChanges() {
+ if (!instance) return;
+ if (!editName.trim()) {
+ setErrorMessage("Instance name cannot be empty");
+ return;
+ }
+ setSaving(true);
+ setErrorMessage("");
+ try {
+ // Build updated instance shape compatible with backend
+ const updatedInstance: Instance = {
+ ...instance,
+ name: editName.trim(),
+ // some bindings may use camelCase; set optional string fields to null when empty
+ notes: editNotes.trim() ? editNotes.trim() : null,
+ memoryOverride: {
+ min: editMemoryMin,
+ max: editMemoryMax,
+ },
+ jvmArgsOverride: editJavaArgs.trim() ? editJavaArgs.trim() : null,
+ };
+
+ await instancesStore.update(updatedInstance as Instance);
+ toast.success("Instance saved");
+ onOpenChange(false);
+ } catch (err) {
+ console.error("Failed to save instance:", err);
+ setErrorMessage(String(err));
+ toast.error("Failed to save instance: " + String(err));
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function formatFileSize(bytesBig: FileInfo["size"]): string {
+ const bytes = Number(bytesBig ?? 0);
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${Math.round((bytes / k ** i) * 100) / 100} ${sizes[i]}`;
+ }
+
+ function formatDate(
+ tsBig?:
+ | FileInfo["modified"]
+ | Instance["createdAt"]
+ | Instance["lastPlayed"],
+ ) {
+ if (tsBig === undefined || tsBig === null) return "";
+ const n = toNumber(tsBig);
+ // tsrs bindings often use seconds for createdAt/lastPlayed; if value looks like seconds use *1000
+ const maybeMs = n > 1e12 ? n : n * 1000;
+ return new Date(maybeMs).toLocaleDateString();
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="w-full max-w-4xl max-h-[90vh] overflow-hidden">
+ <DialogHeader>
+ <div className="flex items-center justify-between gap-4">
+ <div>
+ <DialogTitle>Edit Instance</DialogTitle>
+ <DialogDescription>{instance?.name ?? ""}</DialogDescription>
+ </div>
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={() => onOpenChange(false)}
+ disabled={saving}
+ className="p-2 rounded hover:bg-zinc-800 text-zinc-400"
+ aria-label="Close"
+ >
+ <X />
+ </button>
+ </div>
+ </div>
+ </DialogHeader>
+
+ {/* Tab Navigation */}
+ <div className="flex gap-1 px-6 pt-2 border-b border-zinc-700">
+ {[
+ { id: "info", label: "Info" },
+ { id: "version", label: "Version" },
+ { id: "files", label: "Files" },
+ { id: "settings", label: "Settings" },
+ ].map((tab) => (
+ <button
+ type="button"
+ key={tab.id}
+ onClick={() =>
+ setActiveTab(
+ tab.id as "info" | "version" | "files" | "settings",
+ )
+ }
+ className={`px-4 py-2 text-sm font-medium transition-colors rounded-t-lg ${
+ activeTab === tab.id
+ ? "bg-indigo-600 text-white"
+ : "bg-zinc-800 text-zinc-400 hover:text-white"
+ }`}
+ >
+ {tab.label}
+ </button>
+ ))}
+ </div>
+
+ {/* Content */}
+ <div className="p-6 overflow-y-auto max-h-[60vh]">
+ {activeTab === "info" && (
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="instance-name-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Instance Name
+ </label>
+ <Input
+ id="instance-name-edit"
+ value={editName}
+ onChange={(e) => setEditName(e.target.value)}
+ disabled={saving}
+ />
+ </div>
+
+ <div>
+ <label
+ htmlFor="instance-notes-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Notes
+ </label>
+ <Textarea
+ id="instance-notes-edit"
+ value={editNotes}
+ onChange={(e) => setEditNotes(e.target.value)}
+ rows={4}
+ disabled={saving}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Created</p>
+ <p className="text-white font-medium">
+ {instance?.createdAt ? formatDate(instance.createdAt) : "-"}
+ </p>
+ </div>
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Last Played</p>
+ <p className="text-white font-medium">
+ {instance?.lastPlayed
+ ? formatDate(instance.lastPlayed)
+ : "Never"}
+ </p>
+ </div>
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Game Directory</p>
+ <p
+ className="text-white font-medium text-xs truncate"
+ title={instance?.gameDir ?? ""}
+ >
+ {instance?.gameDir
+ ? String(instance.gameDir).split("/").pop()
+ : ""}
+ </p>
+ </div>
+ <div className="p-3 bg-zinc-800 rounded-lg">
+ <p className="text-zinc-400">Current Version</p>
+ <p className="text-white font-medium">
+ {instance?.versionId ?? "None"}
+ </p>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {activeTab === "version" && (
+ <div className="space-y-4">
+ {instance?.versionId ? (
+ <div className="p-4 bg-indigo-500/10 border border-indigo-500/30 rounded-lg">
+ <p className="text-sm text-indigo-400">
+ Currently playing:{" "}
+ <span className="font-medium">{instance.versionId}</span>
+ {instance.modLoader && (
+ <>
+ {" "}
+ with{" "}
+ <span className="capitalize">{instance.modLoader}</span>
+ {instance.modLoaderVersion
+ ? ` ${instance.modLoaderVersion}`
+ : ""}
+ </>
+ )}
+ </p>
+ </div>
+ ) : (
+ <div className="text-sm text-zinc-400">
+ No version selected for this instance
+ </div>
+ )}
+
+ <div>
+ <p className="text-sm font-medium mb-2">
+ Change Version / Mod Loader
+ </p>
+ <p className="text-xs text-zinc-400">
+ Use the Versions page to install new game versions or mod
+ loaders, then set them here.
+ </p>
+ </div>
+ </div>
+ )}
+
+ {activeTab === "files" && (
+ <div className="space-y-4">
+ <div className="flex gap-2 flex-wrap">
+ {(
+ [
+ "mods",
+ "resourcepacks",
+ "shaderpacks",
+ "saves",
+ "screenshots",
+ ] as const
+ ).map((folder) => (
+ <button
+ type="button"
+ key={folder}
+ onClick={() => changeFolder(folder)}
+ className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
+ selectedFileFolder === folder
+ ? "bg-indigo-600 text-white"
+ : "bg-zinc-800 text-zinc-400 hover:text-white"
+ }`}
+ >
+ {folder}
+ </button>
+ ))}
+ </div>
+
+ {loadingFiles ? (
+ <div className="flex items-center gap-2 text-zinc-400 py-8 justify-center">
+ <Loader2 className="animate-spin" />
+ Loading files...
+ </div>
+ ) : fileList.length === 0 ? (
+ <div className="text-center py-8 text-zinc-500">
+ No files in this folder
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {fileList.map((file) => (
+ <div
+ key={file.path}
+ className="flex items-center justify-between p-3 bg-zinc-800 rounded-lg hover:bg-zinc-700 transition-colors"
+ >
+ <div className="flex-1 min-w-0">
+ <p className="font-medium text-white truncate">
+ {file.name}
+ </p>
+ <p className="text-xs text-zinc-400">
+ {file.isDirectory
+ ? "Folder"
+ : formatFileSize(file.size)}{" "}
+ • {formatDate(file.modified)}
+ </p>
+ </div>
+ <div className="flex gap-2 ml-4">
+ <button
+ type="button"
+ onClick={() => openInExplorer(file.path)}
+ title="Open in explorer"
+ className="p-2 rounded-lg hover:bg-zinc-600 text-zinc-400 hover:text-white transition-colors"
+ >
+ <Folder />
+ </button>
+ <button
+ type="button"
+ onClick={() => deleteFile(file.path)}
+ disabled={deletingPath === file.path}
+ title="Delete"
+ className="p-2 rounded-lg hover:bg-red-600/20 text-red-400 hover:text-red-300 transition-colors disabled:opacity-50"
+ >
+ {deletingPath === file.path ? (
+ <Loader2 className="animate-spin" />
+ ) : (
+ <Trash2 />
+ )}
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+
+ {activeTab === "settings" && (
+ <div className="space-y-4">
+ <div>
+ <label
+ htmlFor="min-memory-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Minimum Memory (MB)
+ </label>
+ <Input
+ id="min-memory-edit"
+ type="number"
+ value={String(editMemoryMin)}
+ onChange={(e) => setEditMemoryMin(Number(e.target.value))}
+ disabled={saving}
+ />
+ <p className="text-xs text-zinc-400 mt-1">
+ Default: {config?.minMemory} MB
+ </p>
+ </div>
+
+ <div>
+ <label
+ htmlFor="max-memory-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ Maximum Memory (MB)
+ </label>
+ <Input
+ id="max-memory-edit"
+ type="number"
+ value={String(editMemoryMax)}
+ onChange={(e) => setEditMemoryMax(Number(e.target.value))}
+ disabled={saving}
+ />
+ <p className="text-xs text-zinc-400 mt-1">
+ Default: {config?.maxMemory} MB
+ </p>
+ </div>
+
+ <div>
+ <label
+ htmlFor="jvm-args-edit"
+ className="block text-sm font-medium mb-2"
+ >
+ JVM Arguments (Advanced)
+ </label>
+ <Textarea
+ id="jvm-args-edit"
+ value={editJavaArgs}
+ onChange={(e) => setEditJavaArgs(e.target.value)}
+ rows={4}
+ disabled={saving}
+ />
+ </div>
+ </div>
+ )}
+ </div>
+
+ {errorMessage && (
+ <div className="px-6 text-sm text-red-400">{errorMessage}</div>
+ )}
+
+ <DialogFooter>
+ <div className="flex items-center justify-between w-full">
+ <div />
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ onOpenChange(false);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button onClick={saveChanges} disabled={saving}>
+ {saving ? (
+ <Loader2 className="animate-spin mr-2" />
+ ) : (
+ <Save className="mr-2" />
+ )}
+ Save
+ </Button>
+ </div>
+ </div>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+export default InstanceEditorModal;
diff --git a/packages/ui/src/components/login-modal.tsx b/packages/ui/src/components/login-modal.tsx
new file mode 100644
index 0000000..49596da
--- /dev/null
+++ b/packages/ui/src/components/login-modal.tsx
@@ -0,0 +1,188 @@
+import { Mail, User } from "lucide-react";
+import { useCallback, useState } from "react";
+import { toast } from "sonner";
+import { useAuthStore } from "@/models/auth";
+import { Button } from "./ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "./ui/dialog";
+import {
+ Field,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLabel,
+} from "./ui/field";
+import { Input } from "./ui/input";
+
+export interface LoginModalProps
+ extends Omit<React.ComponentPropsWithoutRef<typeof Dialog>, "onOpenChange"> {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function LoginModal({ onOpenChange, ...props }: LoginModalProps) {
+ const authStore = useAuthStore();
+
+ const [offlineUsername, setOfflineUsername] = useState<string>("");
+ const [errorMessage, setErrorMessage] = useState<string>("");
+ const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
+
+ const handleMicrosoftLogin = useCallback(async () => {
+ setIsLoggingIn(true);
+ authStore.setLoginMode("microsoft");
+ try {
+ await authStore.loginOnline(() => onOpenChange?.(false));
+ } catch (error) {
+ const err = error as Error;
+ console.error("Failed to login with Microsoft:", err);
+ setErrorMessage(err.message);
+ } finally {
+ setIsLoggingIn(false);
+ }
+ }, [authStore.loginOnline, authStore.setLoginMode, onOpenChange]);
+
+ const handleOfflineLogin = useCallback(async () => {
+ setIsLoggingIn(true);
+ try {
+ await authStore.loginOffline(offlineUsername);
+ toast.success("Logged in offline successfully");
+ onOpenChange?.(false);
+ } catch (error) {
+ const err = error as Error;
+ console.error("Failed to login offline:", err);
+ setErrorMessage(err.message);
+ } finally {
+ setIsLoggingIn(false);
+ }
+ }, [authStore, offlineUsername, onOpenChange]);
+
+ return (
+ <Dialog onOpenChange={onOpenChange} {...props}>
+ <DialogContent className="md:max-w-md">
+ <DialogHeader>
+ <DialogTitle>Login</DialogTitle>
+ <DialogDescription>
+ Login to your Minecraft account or play offline
+ </DialogDescription>
+ </DialogHeader>
+ <div className="p-4 w-full overflow-hidden">
+ {!authStore.loginMode && (
+ <div className="flex flex-col space-y-4">
+ <Button size="lg" onClick={handleMicrosoftLogin}>
+ <Mail />
+ Login with Microsoft
+ </Button>
+ <Button
+ variant="secondary"
+ onClick={() => authStore.setLoginMode("offline")}
+ size="lg"
+ >
+ <User />
+ Login Offline
+ </Button>
+ </div>
+ )}
+ {authStore.loginMode === "microsoft" && (
+ <div className="flex flex-col space-y-4">
+ <button
+ type="button"
+ className="text-4xl font-bold text-center bg-accent p-4 cursor-pointer"
+ onClick={() => {
+ if (authStore.deviceCode?.userCode) {
+ navigator.clipboard?.writeText(
+ authStore.deviceCode?.userCode,
+ );
+ toast.success("Copied to clipboard");
+ }
+ }}
+ >
+ {authStore.deviceCode?.userCode}
+ </button>
+ <span className="text-muted-foreground w-full overflow-hidden text-ellipsis">
+ To sign in, use a web browser to open the page{" "}
+ <a href={authStore.deviceCode?.verificationUri}>
+ {authStore.deviceCode?.verificationUri}
+ </a>{" "}
+ and enter the code{" "}
+ <code
+ className="font-semibold cursor-pointer"
+ onClick={() => {
+ if (authStore.deviceCode?.userCode) {
+ navigator.clipboard?.writeText(
+ authStore.deviceCode?.userCode,
+ );
+ }
+ }}
+ onKeyDown={() => {
+ if (authStore.deviceCode?.userCode) {
+ navigator.clipboard?.writeText(
+ authStore.deviceCode?.userCode,
+ );
+ }
+ }}
+ >
+ {authStore.deviceCode?.userCode}
+ </code>{" "}
+ to authenticate, this code will be expired in{" "}
+ {authStore.deviceCode?.expiresIn} seconds.
+ </span>
+ <FieldError>{errorMessage}</FieldError>
+ </div>
+ )}
+ {authStore.loginMode === "offline" && (
+ <FieldGroup>
+ <Field>
+ <FieldLabel>Username</FieldLabel>
+ <FieldDescription>
+ Enter a username to play offline
+ </FieldDescription>
+ <Input
+ value={offlineUsername}
+ onChange={(e) => {
+ setOfflineUsername(e.target.value);
+ setErrorMessage("");
+ }}
+ aria-invalid={!!errorMessage}
+ />
+ <FieldError>{errorMessage}</FieldError>
+ </Field>
+ </FieldGroup>
+ )}
+ </div>
+ <DialogFooter>
+ <div className="flex flex-col justify-center items-center">
+ <span className="text-xs text-muted-foreground ">
+ {authStore.statusMessage}
+ </span>
+ </div>
+ <Button
+ variant="outline"
+ onClick={() => {
+ if (authStore.loginMode) {
+ if (authStore.loginMode === "microsoft") {
+ authStore.cancelLoginOnline();
+ }
+ authStore.setLoginMode(null);
+ } else {
+ onOpenChange?.(false);
+ }
+ }}
+ >
+ Cancel
+ </Button>
+ {authStore.loginMode === "offline" && (
+ <Button onClick={handleOfflineLogin} disabled={isLoggingIn}>
+ Login
+ </Button>
+ )}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/packages/ui/src/components/particle-background.tsx b/packages/ui/src/components/particle-background.tsx
new file mode 100644
index 0000000..2e0b15a
--- /dev/null
+++ b/packages/ui/src/components/particle-background.tsx
@@ -0,0 +1,63 @@
+import { useEffect, useRef } from "react";
+import { SaturnEffect } from "../lib/effects/SaturnEffect";
+
+export function ParticleBackground() {
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
+ const effectRef = useRef<SaturnEffect | null>(null);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ // Instantiate SaturnEffect and attach to canvas
+ let effect: SaturnEffect | null = null;
+ try {
+ effect = new SaturnEffect(canvas);
+ effectRef.current = effect;
+ } catch (err) {
+ // If effect fails, silently degrade (keep background blank)
+ // eslint-disable-next-line no-console
+ console.warn("SaturnEffect initialization failed:", err);
+ }
+
+ const resizeHandler = () => {
+ if (effectRef.current) {
+ try {
+ effectRef.current.resize(window.innerWidth, window.innerHeight);
+ } catch {
+ // ignore
+ }
+ }
+ };
+
+ window.addEventListener("resize", resizeHandler);
+
+ // Expose getter for HomeView interactions (getSaturnEffect)
+ // HomeView will call window.getSaturnEffect()?.handleMouseDown/Move/Up
+ (
+ window as unknown as { getSaturnEffect?: () => SaturnEffect | null }
+ ).getSaturnEffect = () => effectRef.current;
+
+ return () => {
+ window.removeEventListener("resize", resizeHandler);
+ if (effectRef.current) {
+ try {
+ effectRef.current.destroy();
+ } catch {
+ // ignore
+ }
+ }
+ effectRef.current = null;
+ (
+ window as unknown as { getSaturnEffect?: () => SaturnEffect | null }
+ ).getSaturnEffect = undefined;
+ };
+ }, []);
+
+ return (
+ <canvas
+ ref={canvasRef}
+ className="absolute inset-0 z-0 pointer-events-none"
+ />
+ );
+}
diff --git a/packages/ui/src/components/sidebar.tsx b/packages/ui/src/components/sidebar.tsx
new file mode 100644
index 0000000..0147b0a
--- /dev/null
+++ b/packages/ui/src/components/sidebar.tsx
@@ -0,0 +1,185 @@
+import { Folder, Home, LogOutIcon, Settings } from "lucide-react";
+import { useLocation, useNavigate } from "react-router";
+import { cn } from "@/lib/utils";
+import { useAuthStore } from "@/models/auth";
+import { Button } from "./ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "./ui/dropdown-menu";
+import { UserAvatar } from "./user-avatar";
+
+interface NavItemProps {
+ Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
+ label: string;
+ to: string;
+}
+
+function NavItem({ Icon, label, to }: NavItemProps) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const isActive = location.pathname === to;
+
+ const handleClick = () => {
+ navigate(to);
+ };
+
+ return (
+ <Button
+ variant="ghost"
+ className={cn(
+ "w-fit lg:w-full justify-center lg:justify-start",
+ isActive && "relative bg-accent",
+ )}
+ size="lg"
+ onClick={handleClick}
+ >
+ <Icon className="size-5" strokeWidth={isActive ? 2.5 : 2} />
+ <span className="hidden lg:block text-sm relative z-10">{label}</span>
+ {isActive && (
+ <div className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-black dark:bg-white rounded-r-full hidden lg:block"></div>
+ )}
+ </Button>
+ );
+}
+
+export function Sidebar() {
+ const authStore = useAuthStore();
+
+ return (
+ <aside
+ className={cn(
+ "flex flex-col items-center lg:items-start",
+ "bg-sidebar transition-all duration-300",
+ "w-20 lg:w-64 shrink-0 py-6 h-full",
+ )}
+ >
+ {/* Logo Area */}
+ <div className="h-16 w-full flex items-center justify-center lg:justify-start lg:px-6 mb-6">
+ {/* Icon Logo (Small) */}
+ <div className="lg:hidden text-black dark:text-white">
+ <svg
+ width="32"
+ height="32"
+ viewBox="0 0 100 100"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Logo</title>
+ <path
+ d="M25 25 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M25 75 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M50 50 L75 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
+ <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
+ <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
+ </svg>
+ </div>
+ {/* Full Logo (Large) */}
+ <div className="hidden lg:flex items-center gap-3 font-bold text-xl tracking-tighter dark:text-white text-black">
+ <svg
+ width="42"
+ height="42"
+ viewBox="0 0 100 100"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ className="shrink-0"
+ >
+ <title>Logo</title>
+ <path
+ d="M25 25 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M25 75 L50 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+ <path
+ d="M50 50 L75 50"
+ stroke="currentColor"
+ strokeWidth="4"
+ strokeLinecap="round"
+ />
+
+ <circle cx="25" cy="25" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="50" r="8" fill="currentColor" stroke="none" />
+ <circle cx="25" cy="75" r="8" fill="currentColor" stroke="none" />
+
+ <circle
+ cx="50"
+ cy="25"
+ r="7"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeDasharray="4 2"
+ fill="none"
+ className="opacity-30"
+ />
+ <circle
+ cx="50"
+ cy="75"
+ r="7"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeDasharray="4 2"
+ fill="none"
+ className="opacity-30"
+ />
+ <circle cx="50" cy="50" r="10" fill="currentColor" stroke="none" />
+ <circle cx="75" cy="50" r="8" fill="currentColor" stroke="none" />
+ </svg>
+
+ <span>DROPOUT</span>
+ </div>
+ </div>
+
+ <nav className="w-full flex flex-col space-y-1 px-3 items-center">
+ <NavItem Icon={Home} label="Overview" to="/" />
+ <NavItem Icon={Folder} label="Instances" to="/instances" />
+ <NavItem Icon={Settings} label="Settings" to="/settings" />
+ </nav>
+
+ <div className="flex-1 flex flex-col justify-end">
+ <DropdownMenu>
+ <DropdownMenuTrigger render={<UserAvatar />}>
+ Open
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" side="right" sideOffset={20}>
+ <DropdownMenuGroup>
+ <DropdownMenuItem
+ variant="destructive"
+ onClick={authStore.logout}
+ >
+ <LogOutIcon />
+ Logout
+ </DropdownMenuItem>
+ </DropdownMenuGroup>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ </aside>
+ );
+}
diff --git a/packages/ui/src/components/ui/avatar.tsx b/packages/ui/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..9fd72a2
--- /dev/null
+++ b/packages/ui/src/components/ui/avatar.tsx
@@ -0,0 +1,107 @@
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Avatar({
+ className,
+ size = "default",
+ ...props
+}: AvatarPrimitive.Root.Props & {
+ size?: "default" | "sm" | "lg";
+}) {
+ return (
+ <AvatarPrimitive.Root
+ data-slot="avatar"
+ data-size={size}
+ className={cn(
+ "size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
+ return (
+ <AvatarPrimitive.Image
+ data-slot="avatar-image"
+ className={cn(
+ "rounded-full aspect-square size-full object-cover",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: AvatarPrimitive.Fallback.Props) {
+ return (
+ <AvatarPrimitive.Fallback
+ data-slot="avatar-fallback"
+ className={cn(
+ "bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="avatar-badge"
+ className={cn(
+ "bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
+ "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
+ "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
+ "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="avatar-group"
+ className={cn(
+ "*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function AvatarGroupCount({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="avatar-group-count"
+ className={cn(
+ "bg-muted text-muted-foreground size-8 rounded-full text-xs group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+};
diff --git a/packages/ui/src/components/ui/badge.tsx b/packages/ui/src/components/ui/badge.tsx
new file mode 100644
index 0000000..425ab9e
--- /dev/null
+++ b/packages/ui/src/components/ui/badge.tsx
@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props";
+import { useRender } from "@base-ui/react/use-render";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "h-5 gap-1 rounded-none border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ secondary:
+ "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+ destructive:
+ "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
+ outline:
+ "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+ ghost:
+ "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant = "default",
+ render,
+ ...props
+}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props,
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ });
+}
+
+export { Badge, badgeVariants };
diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx
new file mode 100644
index 0000000..7dee494
--- /dev/null
+++ b/packages/ui/src/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import { Button as ButtonPrimitive } from "@base-ui/react/button";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-none border border-transparent bg-clip-padding text-xs font-medium focus-visible:ring-1 aria-invalid:ring-1 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+ outline:
+ "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+ ghost:
+ "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
+ destructive:
+ "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default:
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+ xs: "h-6 gap-1 rounded-none px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+ sm: "h-7 gap-1 rounded-none px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
+ icon: "size-8",
+ "icon-xs": "size-6 rounded-none [&_svg:not([class*='size-'])]:size-3",
+ "icon-sm": "size-7 rounded-none",
+ "icon-lg": "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
+ return (
+ <ButtonPrimitive
+ data-slot="button"
+ className={cn(buttonVariants({ variant, size, className }))}
+ {...props}
+ />
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/packages/ui/src/components/ui/card.tsx b/packages/ui/src/components/ui/card.tsx
new file mode 100644
index 0000000..b7084a0
--- /dev/null
+++ b/packages/ui/src/components/ui/card.tsx
@@ -0,0 +1,103 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+ return (
+ <div
+ data-slot="card"
+ data-size={size}
+ className={cn(
+ "ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none group/card flex flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-header"
+ className={cn(
+ "gap-1 rounded-none px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-title"
+ className={cn(
+ "text-sm font-medium group-data-[size=sm]/card:text-sm",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-description"
+ className={cn("text-muted-foreground text-xs/relaxed", className)}
+ {...props}
+ />
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-action"
+ className={cn(
+ "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-content"
+ className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
+ {...props}
+ />
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="card-footer"
+ className={cn(
+ "rounded-none border-t p-4 group-data-[size=sm]/card:p-3 flex items-center",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+};
diff --git a/packages/ui/src/components/ui/checkbox.tsx b/packages/ui/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..9f22cea
--- /dev/null
+++ b/packages/ui/src/components/ui/checkbox.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
+import { CheckIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
+ return (
+ <CheckboxPrimitive.Root
+ data-slot="checkbox"
+ className={cn(
+ "border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-none border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-1 aria-invalid:ring-1 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <CheckboxPrimitive.Indicator
+ data-slot="checkbox-indicator"
+ className="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
+ >
+ <CheckIcon />
+ </CheckboxPrimitive.Indicator>
+ </CheckboxPrimitive.Root>
+ );
+}
+
+export { Checkbox };
diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..033b47c
--- /dev/null
+++ b/packages/ui/src/components/ui/dialog.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
+import { XIcon } from "lucide-react";
+import type * as React from "react";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return <DialogPrimitive.Root data-slot="dialog" {...props} />;
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+ <DialogPrimitive.Backdrop
+ data-slot="dialog-overlay"
+ className={cn(
+ "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean;
+}) {
+ return (
+ <DialogPortal>
+ <DialogOverlay />
+ <DialogPrimitive.Popup
+ data-slot="dialog-content"
+ className={cn(
+ "bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-none p-4 text-xs/relaxed ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ {showCloseButton && (
+ <DialogPrimitive.Close
+ data-slot="dialog-close"
+ render={
+ <Button
+ variant="ghost"
+ className="absolute top-2 right-2"
+ size="icon-sm"
+ />
+ }
+ >
+ <XIcon />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ )}
+ </DialogPrimitive.Popup>
+ </DialogPortal>
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="dialog-header"
+ className={cn("gap-1 text-left flex flex-col", className)}
+ {...props}
+ />
+ );
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean;
+}) {
+ return (
+ <div
+ data-slot="dialog-footer"
+ className={cn(
+ "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ {showCloseButton && (
+ <DialogPrimitive.Close render={<Button variant="outline" />}>
+ Close
+ </DialogPrimitive.Close>
+ )}
+ </div>
+ );
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+ <DialogPrimitive.Title
+ data-slot="dialog-title"
+ className={cn("text-sm font-medium", className)}
+ {...props}
+ />
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+ <DialogPrimitive.Description
+ data-slot="dialog-description"
+ className={cn(
+ "text-muted-foreground *:[a]:hover:text-foreground text-xs/relaxed *:[a]:underline *:[a]:underline-offset-3",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..ee97374
--- /dev/null
+++ b/packages/ui/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,269 @@
+import { Menu as MenuPrimitive } from "@base-ui/react/menu";
+import { CheckIcon, ChevronRightIcon } from "lucide-react";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+ <MenuPrimitive.Portal>
+ <MenuPrimitive.Positioner
+ className="isolate z-50 outline-none"
+ align={align}
+ alignOffset={alignOffset}
+ side={side}
+ sideOffset={sideOffset}
+ >
+ <MenuPrimitive.Popup
+ data-slot="dropdown-menu-content"
+ className={cn(
+ "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-none shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden",
+ className,
+ )}
+ {...props}
+ />
+ </MenuPrimitive.Positioner>
+ </MenuPrimitive.Portal>
+ );
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.GroupLabel
+ data-slot="dropdown-menu-label"
+ data-inset={inset}
+ className={cn(
+ "text-muted-foreground px-2 py-2 text-xs data-inset:pl-7",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+ <MenuPrimitive.Item
+ data-slot="dropdown-menu-item"
+ data-inset={inset}
+ data-variant={variant}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none px-2 py-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.SubmenuTrigger
+ data-slot="dropdown-menu-sub-trigger"
+ data-inset={inset}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none px-2 py-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 data-popup-open:bg-accent data-popup-open:text-accent-foreground flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronRightIcon className="ml-auto" />
+ </MenuPrimitive.SubmenuTrigger>
+ );
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuContent>) {
+ return (
+ <DropdownMenuContent
+ data-slot="dropdown-menu-sub-content"
+ className={cn(
+ "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-24 rounded-none shadow-lg ring-1 duration-100 w-auto",
+ className,
+ )}
+ align={align}
+ alignOffset={alignOffset}
+ side={side}
+ sideOffset={sideOffset}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.CheckboxItem
+ data-slot="dropdown-menu-checkbox-item"
+ data-inset={inset}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ checked={checked}
+ {...props}
+ >
+ <span
+ className="absolute right-2 flex items-center justify-center pointer-events-none"
+ data-slot="dropdown-menu-checkbox-item-indicator"
+ >
+ <MenuPrimitive.CheckboxItemIndicator>
+ <CheckIcon />
+ </MenuPrimitive.CheckboxItemIndicator>
+ </span>
+ {children}
+ </MenuPrimitive.CheckboxItem>
+ );
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+ <MenuPrimitive.RadioGroup
+ data-slot="dropdown-menu-radio-group"
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean;
+}) {
+ return (
+ <MenuPrimitive.RadioItem
+ data-slot="dropdown-menu-radio-item"
+ data-inset={inset}
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ <span
+ className="absolute right-2 flex items-center justify-center pointer-events-none"
+ data-slot="dropdown-menu-radio-item-indicator"
+ >
+ <MenuPrimitive.RadioItemIndicator>
+ <CheckIcon />
+ </MenuPrimitive.RadioItemIndicator>
+ </span>
+ {children}
+ </MenuPrimitive.RadioItem>
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+ <MenuPrimitive.Separator
+ data-slot="dropdown-menu-separator"
+ className={cn("bg-border -mx-1 h-px", className)}
+ {...props}
+ />
+ );
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="dropdown-menu-shortcut"
+ className={cn(
+ "text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+};
diff --git a/packages/ui/src/components/ui/field.tsx b/packages/ui/src/components/ui/field.tsx
new file mode 100644
index 0000000..ab9fb71
--- /dev/null
+++ b/packages/ui/src/components/ui/field.tsx
@@ -0,0 +1,238 @@
+import { cva, type VariantProps } from "class-variance-authority";
+import { useMemo } from "react";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/lib/utils";
+
+function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
+ return (
+ <fieldset
+ data-slot="field-set"
+ className={cn(
+ "gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldLegend({
+ className,
+ variant = "legend",
+ ...props
+}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
+ return (
+ <legend
+ data-slot="field-legend"
+ data-variant={variant}
+ className={cn(
+ "mb-2.5 font-medium data-[variant=label]:text-xs data-[variant=legend]:text-sm",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-group"
+ className={cn(
+ "gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4 group/field-group @container/field-group flex w-full flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+const fieldVariants = cva(
+ "data-[invalid=true]:text-destructive gap-2 group/field flex w-full",
+ {
+ variants: {
+ orientation: {
+ vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
+ horizontal:
+ "flex-row items-center *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ responsive:
+ "flex-col *:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:*:data-[slot=field-label]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ },
+ },
+ defaultVariants: {
+ orientation: "vertical",
+ },
+ },
+);
+
+function Field({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
+ return (
+ <div
+ data-slot="field"
+ data-orientation={orientation}
+ className={cn(fieldVariants({ orientation }), className)}
+ {...props}
+ />
+ );
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-content"
+ className={cn(
+ "gap-0.5 group/field-content flex flex-1 flex-col leading-snug",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps<typeof Label>) {
+ return (
+ <Label
+ data-slot="field-label"
+ className={cn(
+ "has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-none has-[>[data-slot=field]]:border *:data-[slot=field]:p-2 group/field-label peer/field-label flex w-fit leading-snug",
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ <div
+ data-slot="field-label"
+ className={cn(
+ "gap-2 text-xs/relaxed group-data-[disabled=true]/field:opacity-50 flex w-fit items-center leading-snug",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ <p
+ data-slot="field-description"
+ className={cn(
+ "text-muted-foreground text-left text-xs/relaxed [[data-variant=legend]+&]:-mt-1.5 leading-normal font-normal group-has-data-horizontal/field:text-balance",
+ "last:mt-0 nth-last-2:-mt-1",
+ "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode;
+}) {
+ return (
+ <div
+ data-slot="field-separator"
+ data-content={!!children}
+ className={cn(
+ "-my-2 h-5 text-xs group-data-[variant=outline]/field-group:-mb-2 relative",
+ className,
+ )}
+ {...props}
+ >
+ <Separator className="absolute inset-0 top-1/2" />
+ {children && (
+ <span
+ className="text-muted-foreground px-2 bg-background relative mx-auto block w-fit"
+ data-slot="field-separator-content"
+ >
+ {children}
+ </span>
+ )}
+ </div>
+ );
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: Array<{ message?: string } | undefined>;
+}) {
+ const content = useMemo(() => {
+ if (children) {
+ return children;
+ }
+
+ if (!errors?.length) {
+ return null;
+ }
+
+ const uniqueErrors = [
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
+ ];
+
+ if (uniqueErrors?.length === 1) {
+ return uniqueErrors[0]?.message;
+ }
+
+ return (
+ <ul className="ml-4 flex list-disc flex-col gap-1">
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message && (
+ <li key={`${error.message.slice(6)}-${index}`}>
+ {error.message}
+ </li>
+ ),
+ )}
+ </ul>
+ );
+ }, [children, errors]);
+
+ if (!content) {
+ return null;
+ }
+
+ return (
+ <div
+ role="alert"
+ data-slot="field-error"
+ className={cn("text-destructive text-xs font-normal", className)}
+ {...props}
+ >
+ {content}
+ </div>
+ );
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+};
diff --git a/packages/ui/src/components/ui/input.tsx b/packages/ui/src/components/ui/input.tsx
new file mode 100644
index 0000000..bb0390a
--- /dev/null
+++ b/packages/ui/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import { Input as InputPrimitive } from "@base-ui/react/input";
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+ <InputPrimitive
+ type={type}
+ data-slot="input"
+ className={cn(
+ "dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-none border bg-transparent px-2.5 py-1 text-xs transition-colors file:h-6 file:text-xs file:font-medium focus-visible:ring-1 aria-invalid:ring-1 md:text-xs file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Input };
diff --git a/packages/ui/src/components/ui/label.tsx b/packages/ui/src/components/ui/label.tsx
new file mode 100644
index 0000000..9a998c7
--- /dev/null
+++ b/packages/ui/src/components/ui/label.tsx
@@ -0,0 +1,19 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Label({ className, ...props }: React.ComponentProps<"label">) {
+ return (
+ // biome-ignore lint/a11y/noLabelWithoutControl: shadcn component
+ <label
+ data-slot="label"
+ className={cn(
+ "gap-2 text-xs leading-none group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Label };
diff --git a/packages/ui/src/components/ui/scroll-area.tsx b/packages/ui/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..4a68eb2
--- /dev/null
+++ b/packages/ui/src/components/ui/scroll-area.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
+import { cn } from "@/lib/utils";
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: ScrollAreaPrimitive.Root.Props) {
+ return (
+ <ScrollAreaPrimitive.Root
+ data-slot="scroll-area"
+ className={cn("relative", className)}
+ {...props}
+ >
+ <ScrollAreaPrimitive.Viewport
+ data-slot="scroll-area-viewport"
+ className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
+ >
+ {children}
+ </ScrollAreaPrimitive.Viewport>
+ <ScrollBar />
+ <ScrollAreaPrimitive.Corner />
+ </ScrollAreaPrimitive.Root>
+ );
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ScrollAreaPrimitive.Scrollbar.Props) {
+ return (
+ <ScrollAreaPrimitive.Scrollbar
+ data-slot="scroll-area-scrollbar"
+ data-orientation={orientation}
+ orientation={orientation}
+ className={cn(
+ "data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none",
+ className,
+ )}
+ {...props}
+ >
+ <ScrollAreaPrimitive.Thumb
+ data-slot="scroll-area-thumb"
+ className="rounded-none bg-border relative flex-1"
+ />
+ </ScrollAreaPrimitive.Scrollbar>
+ );
+}
+
+export { ScrollArea, ScrollBar };
diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx
new file mode 100644
index 0000000..210adba
--- /dev/null
+++ b/packages/ui/src/components/ui/select.tsx
@@ -0,0 +1,199 @@
+import { Select as SelectPrimitive } from "@base-ui/react/select";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+ <SelectPrimitive.Group
+ data-slot="select-group"
+ className={cn("scroll-my-1", className)}
+ {...props}
+ />
+ );
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+ <SelectPrimitive.Value
+ data-slot="select-value"
+ className={cn("flex flex-1 text-left", className)}
+ {...props}
+ />
+ );
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default";
+}) {
+ return (
+ <SelectPrimitive.Trigger
+ data-slot="select-trigger"
+ data-size={size}
+ className={cn(
+ "border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-none border bg-transparent py-2 pr-2 pl-2.5 text-xs transition-colors select-none focus-visible:ring-1 aria-invalid:ring-1 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-none *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <SelectPrimitive.Icon
+ render={
+ <ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
+ }
+ />
+ </SelectPrimitive.Trigger>
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+ <SelectPrimitive.Portal>
+ <SelectPrimitive.Positioner
+ side={side}
+ sideOffset={sideOffset}
+ align={align}
+ alignOffset={alignOffset}
+ alignItemWithTrigger={alignItemWithTrigger}
+ className="isolate z-50"
+ >
+ <SelectPrimitive.Popup
+ data-slot="select-content"
+ data-align-trigger={alignItemWithTrigger}
+ className={cn(
+ "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-none shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none",
+ className,
+ )}
+ {...props}
+ >
+ <SelectScrollUpButton />
+ <SelectPrimitive.List>{children}</SelectPrimitive.List>
+ <SelectScrollDownButton />
+ </SelectPrimitive.Popup>
+ </SelectPrimitive.Positioner>
+ </SelectPrimitive.Portal>
+ );
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+ <SelectPrimitive.GroupLabel
+ data-slot="select-label"
+ className={cn("text-muted-foreground px-2 py-2 text-xs", className)}
+ {...props}
+ />
+ );
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+ <SelectPrimitive.Item
+ data-slot="select-item"
+ className={cn(
+ "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-none py-2 pr-8 pl-2 text-xs [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ className,
+ )}
+ {...props}
+ >
+ <SelectPrimitive.ItemText className="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
+ {children}
+ </SelectPrimitive.ItemText>
+ <SelectPrimitive.ItemIndicator
+ render={
+ <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
+ }
+ >
+ <CheckIcon className="pointer-events-none" />
+ </SelectPrimitive.ItemIndicator>
+ </SelectPrimitive.Item>
+ );
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+ <SelectPrimitive.Separator
+ data-slot="select-separator"
+ className={cn("bg-border -mx-1 h-px pointer-events-none", className)}
+ {...props}
+ />
+ );
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
+ return (
+ <SelectPrimitive.ScrollUpArrow
+ data-slot="select-scroll-up-button"
+ className={cn(
+ "bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 top-0 w-full",
+ className,
+ )}
+ {...props}
+ >
+ <ChevronUpIcon />
+ </SelectPrimitive.ScrollUpArrow>
+ );
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
+ return (
+ <SelectPrimitive.ScrollDownArrow
+ data-slot="select-scroll-down-button"
+ className={cn(
+ "bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full",
+ className,
+ )}
+ {...props}
+ >
+ <ChevronDownIcon />
+ </SelectPrimitive.ScrollDownArrow>
+ );
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+};
diff --git a/packages/ui/src/components/ui/separator.tsx b/packages/ui/src/components/ui/separator.tsx
new file mode 100644
index 0000000..e91a862
--- /dev/null
+++ b/packages/ui/src/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
+
+import { cn } from "@/lib/utils";
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+ <SeparatorPrimitive
+ data-slot="separator"
+ orientation={orientation}
+ className={cn(
+ "bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Separator };
diff --git a/packages/ui/src/components/ui/sonner.tsx b/packages/ui/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..d6e293d
--- /dev/null
+++ b/packages/ui/src/components/ui/sonner.tsx
@@ -0,0 +1,43 @@
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+} from "lucide-react";
+import { useTheme } from "next-themes";
+import { Toaster as Sonner, type ToasterProps } from "sonner";
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme();
+
+ return (
+ <Sonner
+ theme={theme as ToasterProps["theme"]}
+ className="toaster group"
+ icons={{
+ success: <CircleCheckIcon className="size-4" />,
+ info: <InfoIcon className="size-4" />,
+ warning: <TriangleAlertIcon className="size-4" />,
+ error: <OctagonXIcon className="size-4" />,
+ loading: <Loader2Icon className="size-4 animate-spin" />,
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ toastOptions={{
+ classNames: {
+ toast: "cn-toast",
+ },
+ }}
+ {...props}
+ />
+ );
+};
+
+export { Toaster };
diff --git a/packages/ui/src/components/ui/spinner.tsx b/packages/ui/src/components/ui/spinner.tsx
new file mode 100644
index 0000000..3753252
--- /dev/null
+++ b/packages/ui/src/components/ui/spinner.tsx
@@ -0,0 +1,15 @@
+import { Loader2Icon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+ return (
+ <Loader2Icon
+ role="status"
+ aria-label="Loading"
+ className={cn("size-4 animate-spin", className)}
+ {...props}
+ />
+ );
+}
+
+export { Spinner };
diff --git a/packages/ui/src/components/ui/switch.tsx b/packages/ui/src/components/ui/switch.tsx
new file mode 100644
index 0000000..fef14e3
--- /dev/null
+++ b/packages/ui/src/components/ui/switch.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
+
+import { cn } from "@/lib/utils";
+
+function Switch({
+ className,
+ size = "default",
+ ...props
+}: SwitchPrimitive.Root.Props & {
+ size?: "sm" | "default";
+}) {
+ return (
+ <SwitchPrimitive.Root
+ data-slot="switch"
+ data-size={size}
+ className={cn(
+ "data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent focus-visible:ring-1 aria-invalid:ring-1 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <SwitchPrimitive.Thumb
+ data-slot="switch-thumb"
+ className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
+ />
+ </SwitchPrimitive.Root>
+ );
+}
+
+export { Switch };
diff --git a/packages/ui/src/components/ui/tabs.tsx b/packages/ui/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..c66893f
--- /dev/null
+++ b/packages/ui/src/components/ui/tabs.tsx
@@ -0,0 +1,80 @@
+import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+function Tabs({
+ className,
+ orientation = "horizontal",
+ ...props
+}: TabsPrimitive.Root.Props) {
+ return (
+ <TabsPrimitive.Root
+ data-slot="tabs"
+ data-orientation={orientation}
+ className={cn(
+ "gap-2 group/tabs flex data-horizontal:flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+const tabsListVariants = cva(
+ "rounded-none p-0.75 group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
+ {
+ variants: {
+ variant: {
+ default: "bg-muted",
+ line: "gap-1 bg-transparent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function TabsList({
+ className,
+ variant = "default",
+ ...props
+}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
+ return (
+ <TabsPrimitive.List
+ data-slot="tabs-list"
+ data-variant={variant}
+ className={cn(tabsListVariants({ variant }), className)}
+ {...props}
+ />
+ );
+}
+
+function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
+ return (
+ <TabsPrimitive.Tab
+ data-slot="tabs-trigger"
+ className={cn(
+ "gap-1.5 rounded-none border border-transparent px-1.5 py-0.5 text-xs font-medium group-data-vertical/tabs:py-[calc(--spacing(1.25))] [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
+ "data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
+ "after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:-bottom-1.25 group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
+ return (
+ <TabsPrimitive.Panel
+ data-slot="tabs-content"
+ className={cn("text-xs/relaxed flex-1 outline-none", className)}
+ {...props}
+ />
+ );
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
diff --git a/packages/ui/src/components/ui/textarea.tsx b/packages/ui/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..3c3e5d0
--- /dev/null
+++ b/packages/ui/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import type * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+ <textarea
+ data-slot="textarea"
+ className={cn(
+ "border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-none border bg-transparent px-2.5 py-2 text-xs transition-colors focus-visible:ring-1 aria-invalid:ring-1 md:text-xs placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Textarea };
diff --git a/packages/ui/src/components/user-avatar.tsx b/packages/ui/src/components/user-avatar.tsx
new file mode 100644
index 0000000..bbdb84c
--- /dev/null
+++ b/packages/ui/src/components/user-avatar.tsx
@@ -0,0 +1,23 @@
+import { useAuthStore } from "@/models/auth";
+import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "./ui/avatar";
+
+export function UserAvatar({
+ className,
+ ...props
+}: React.ComponentProps<typeof Avatar>) {
+ const authStore = useAuthStore();
+
+ if (!authStore.account) {
+ return null;
+ }
+
+ return (
+ <Avatar {...props}>
+ <AvatarImage
+ src={`https://minotar.net/helm/${authStore.account.username}/100.png`}
+ />
+ <AvatarFallback>{authStore.account.username.slice(0, 2)}</AvatarFallback>
+ <AvatarBadge />
+ </Avatar>
+ );
+}
diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css
new file mode 100644
index 0000000..8803e5e
--- /dev/null
+++ b/packages/ui/src/index.css
@@ -0,0 +1,126 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 8px);
+ --radius-3xl: calc(var(--radius) + 12px);
+ --radius-4xl: calc(var(--radius) + 16px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: #f3f4f6; /* bg-gray-100 */
+ --foreground: #18181b; /* zinc-900 */
+ --card: #ffffff;
+ --card-foreground: #18181b;
+ --popover: #ffffff;
+ --popover-foreground: #18181b;
+ --primary: #4f46e5; /* indigo-600 */
+ --primary-foreground: #ffffff;
+ --secondary: #f4f4f5; /* zinc-100 */
+ --secondary-foreground: #18181b;
+ --muted: #f4f4f5; /* zinc-100 */
+ --muted-foreground: #71717a; /* zinc-500 */
+ --accent: #f4f4f5; /* zinc-100 */
+ --accent-foreground: #18181b;
+ --destructive: #ef4444; /* red-500 */
+ --destructive-foreground: #ffffff;
+ --border: #e4e4e7; /* zinc-200 */
+ --input: #ffffff;
+ --ring: #6366f1; /* indigo-500 */
+ --chart-1: #059669; /* emerald-600 */
+ --chart-2: #0d9488; /* teal-600 */
+ --chart-3: #4f46e5; /* indigo-600 */
+ --chart-4: #7c3aed; /* violet-600 */
+ --chart-5: #dc2626; /* red-600 */
+ --sidebar: #ffffff;
+ --sidebar-foreground: #18181b;
+ --sidebar-primary: #4f46e5; /* indigo-600 */
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: #f4f4f5; /* zinc-100 */
+ --sidebar-accent-foreground: #18181b;
+ --sidebar-border: #e4e4e7; /* zinc-200 */
+ --sidebar-ring: #6366f1; /* indigo-500 */
+}
+
+.dark {
+ --background: #09090b;
+ --foreground: #fafafa; /* zinc-50 */
+ --card: #18181b; /* zinc-900 */
+ --card-foreground: #fafafa;
+ --popover: #18181b;
+ --popover-foreground: #fafafa;
+ --primary: #6366f1; /* indigo-500 */
+ --primary-foreground: #ffffff;
+ --secondary: #27272a; /* zinc-800 */
+ --secondary-foreground: #fafafa;
+ --muted: #27272a; /* zinc-800 */
+ --muted-foreground: #a1a1aa; /* zinc-400 */
+ --accent: #27272a; /* zinc-800 */
+ --accent-foreground: #fafafa;
+ --destructive: #f87171; /* red-400 */
+ --destructive-foreground: #ffffff;
+ --border: #3f3f46; /* zinc-700 */
+ --input: rgba(255, 255, 255, 0.15);
+ --ring: #6366f1; /* indigo-500 */
+ --chart-1: #10b981; /* emerald-500 */
+ --chart-2: #06b6d4; /* cyan-500 */
+ --chart-3: #6366f1; /* indigo-500 */
+ --chart-4: #8b5cf6; /* violet-500 */
+ --chart-5: #f87171; /* red-400 */
+ --sidebar: #09090b;
+ --sidebar-foreground: #fafafa;
+ --sidebar-primary: #6366f1; /* indigo-500 */
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: #27272a; /* zinc-800 */
+ --sidebar-accent-foreground: #fafafa;
+ --sidebar-border: #3f3f46; /* zinc-700 */
+ --sidebar-ring: #6366f1; /* indigo-500 */
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/packages/ui/src/lib/Counter.svelte b/packages/ui/src/lib/Counter.svelte
deleted file mode 100644
index 37d75ce..0000000
--- a/packages/ui/src/lib/Counter.svelte
+++ /dev/null
@@ -1,10 +0,0 @@
-<script lang="ts">
- let count: number = $state(0)
- const increment = () => {
- count += 1
- }
-</script>
-
-<button onclick={increment}>
- count is {count}
-</button>
diff --git a/packages/ui/src/lib/DownloadMonitor.svelte b/packages/ui/src/lib/DownloadMonitor.svelte
deleted file mode 100644
index 860952c..0000000
--- a/packages/ui/src/lib/DownloadMonitor.svelte
+++ /dev/null
@@ -1,201 +0,0 @@
-<script lang="ts">
- import { listen } from "@tauri-apps/api/event";
- import { onMount, onDestroy } from "svelte";
-
- export let visible = false;
-
- interface DownloadEvent {
- file: string;
- downloaded: number; // in bytes
- total: number; // in bytes
- status: string;
- completed_files: number;
- total_files: number;
- total_downloaded_bytes: number;
- }
-
- let currentFile = "";
- let progress = 0; // percentage 0-100 (current file)
- let totalProgress = 0; // percentage 0-100 (all files)
- let totalFiles = 0;
- let completedFiles = 0;
- let statusText = "Preparing...";
- let unlistenProgress: () => void;
- let unlistenStart: () => void;
- let unlistenComplete: () => void;
- let downloadedBytes = 0;
- let totalBytes = 0;
-
- // Speed and ETA tracking
- let downloadSpeed = 0; // bytes per second
- let etaSeconds = 0;
- let startTime = 0;
- let totalDownloadedBytes = 0;
- let lastUpdateTime = 0;
- let lastTotalBytes = 0;
-
- onMount(async () => {
- unlistenStart = await listen<number>("download-start", (event) => {
- visible = true;
- totalFiles = event.payload;
- completedFiles = 0;
- progress = 0;
- totalProgress = 0;
- statusText = "Starting download...";
- currentFile = "";
- // Reset speed tracking
- startTime = Date.now();
- totalDownloadedBytes = 0;
- downloadSpeed = 0;
- etaSeconds = 0;
- lastUpdateTime = Date.now();
- lastTotalBytes = 0;
- });
-
- unlistenProgress = await listen<DownloadEvent>(
- "download-progress",
- (event) => {
- const payload = event.payload;
- currentFile = payload.file;
-
- // Current file progress
- downloadedBytes = payload.downloaded;
- totalBytes = payload.total;
-
- statusText = payload.status;
-
- if (payload.total > 0) {
- progress = (payload.downloaded / payload.total) * 100;
- }
-
- // Total progress (all files)
- completedFiles = payload.completed_files;
- totalFiles = payload.total_files;
- if (totalFiles > 0) {
- const currentFileFraction =
- payload.total > 0 ? payload.downloaded / payload.total : 0;
- totalProgress = ((completedFiles + currentFileFraction) / totalFiles) * 100;
- }
-
- // Calculate download speed (using moving average)
- totalDownloadedBytes = payload.total_downloaded_bytes;
- const now = Date.now();
- const timeDiff = (now - lastUpdateTime) / 1000; // seconds
-
- if (timeDiff >= 0.5) { // Update speed every 0.5 seconds
- const bytesDiff = totalDownloadedBytes - lastTotalBytes;
- const instantSpeed = bytesDiff / timeDiff;
- // Smooth the speed with exponential moving average
- downloadSpeed = downloadSpeed === 0 ? instantSpeed : downloadSpeed * 0.7 + instantSpeed * 0.3;
- lastUpdateTime = now;
- lastTotalBytes = totalDownloadedBytes;
- }
-
- // Estimate remaining time
- if (downloadSpeed > 0 && completedFiles < totalFiles) {
- const remainingFiles = totalFiles - completedFiles;
- let estimatedRemainingBytes: number;
-
- if (completedFiles > 0) {
- // Use average size of completed files to estimate remaining files
- const avgBytesPerCompletedFile = totalDownloadedBytes / completedFiles;
- estimatedRemainingBytes = avgBytesPerCompletedFile * remainingFiles;
- } else {
- // No completed files yet: estimate based only on current file's remaining bytes
- estimatedRemainingBytes = Math.max(totalBytes - downloadedBytes, 0);
- }
- etaSeconds = estimatedRemainingBytes / downloadSpeed;
- } else {
- etaSeconds = 0;
- }
- }
- );
-
- unlistenComplete = await listen("download-complete", () => {
- statusText = "Done!";
- progress = 100;
- totalProgress = 100;
- setTimeout(() => {
- visible = false;
- }, 2000);
- });
- });
-
- onDestroy(() => {
- if (unlistenProgress) unlistenProgress();
- if (unlistenStart) unlistenStart();
- if (unlistenComplete) unlistenComplete();
- });
-
- function formatBytes(bytes: number) {
- if (bytes === 0) return "0 B";
- const k = 1024;
- const sizes = ["B", "KB", "MB", "GB"];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
- }
-
- function formatSpeed(bytesPerSecond: number) {
- if (bytesPerSecond === 0) return "-- /s";
- return formatBytes(bytesPerSecond) + "/s";
- }
-
- function formatTime(seconds: number) {
- if (seconds <= 0 || !isFinite(seconds)) return "--";
- if (seconds < 60) return `${Math.round(seconds)}s`;
- if (seconds < 3600) {
- const mins = Math.floor(seconds / 60);
- const secs = Math.round(seconds % 60);
- return `${mins}m ${secs}s`;
- }
- const hours = Math.floor(seconds / 3600);
- const mins = Math.floor((seconds % 3600) / 60);
- return `${hours}h ${mins}m`;
- }
-</script>
-
-{#if visible}
- <div
- class="fixed bottom-28 right-8 z-50 w-80 bg-zinc-900/90 border border-zinc-700 rounded-lg shadow-2xl p-4 animate-in slide-in-from-right-10 fade-in duration-300"
- >
- <div class="flex items-center justify-between mb-2">
- <h3 class="text-white font-bold text-sm">Downloads</h3>
- <span class="text-xs text-zinc-400">{statusText}</span>
- </div>
-
- <!-- Total Progress Bar -->
- <div class="mb-3">
- <div class="flex justify-between text-[10px] text-zinc-400 mb-1">
- <span>Total Progress</span>
- <span>{completedFiles} / {totalFiles} files</span>
- </div>
- <div class="w-full bg-zinc-800 rounded-full h-2.5 overflow-hidden">
- <div
- class="bg-gradient-to-r from-blue-500 to-cyan-400 h-2.5 rounded-full transition-all duration-200"
- style="width: {totalProgress}%"
- ></div>
- </div>
- <div class="flex justify-between text-[10px] text-zinc-500 font-mono mt-0.5">
- <span>{formatSpeed(downloadSpeed)} · ETA: {formatTime(etaSeconds)}</span>
- <span>{completedFiles < totalFiles ? Math.floor(totalProgress) : 100}%</span>
- </div>
- </div>
-
- <div class="text-xs text-zinc-300 truncate mb-1" title={currentFile}>
- {currentFile || "Waiting..."}
- </div>
-
- <!-- Current File Progress Bar -->
- <div class="w-full bg-zinc-800 rounded-full h-2 mb-2 overflow-hidden">
- <div
- class="bg-gradient-to-r from-green-500 to-emerald-400 h-2 rounded-full transition-all duration-200"
- style="width: {progress}%"
- ></div>
- </div>
-
- <div class="flex justify-between text-[10px] text-zinc-500 font-mono">
- <span>{formatBytes(downloadedBytes)} / {formatBytes(totalBytes)}</span>
- <span>{Math.round(progress)}%</span>
- </div>
- </div>
-{/if}
diff --git a/packages/ui/src/lib/GameConsole.svelte b/packages/ui/src/lib/GameConsole.svelte
deleted file mode 100644
index bc5edbc..0000000
--- a/packages/ui/src/lib/GameConsole.svelte
+++ /dev/null
@@ -1,304 +0,0 @@
-<script lang="ts">
- import { logsState, type LogEntry } from "../stores/logs.svelte";
- import { uiState } from "../stores/ui.svelte";
- import { save } from "@tauri-apps/plugin-dialog";
- import { writeTextFile } from "@tauri-apps/plugin-fs";
- import { invoke } from "@tauri-apps/api/core";
- import { open } from "@tauri-apps/plugin-shell";
- import { onMount, tick } from "svelte";
- import CustomSelect from "../components/CustomSelect.svelte";
- import { ChevronDown, Check } from 'lucide-svelte';
-
- let consoleElement: HTMLDivElement;
- let autoScroll = $state(true);
-
- // Search & Filter
- let searchQuery = $state("");
- let showInfo = $state(true);
- let showWarn = $state(true);
- let showError = $state(true);
- let showDebug = $state(false);
-
- // Source filter: "all" or specific source name
- let selectedSource = $state("all");
-
- // Get sorted sources for dropdown
- let sourceOptions = $derived([
- { value: "all", label: "All Sources" },
- ...[...logsState.sources].sort().map(s => ({ value: s, label: s }))
- ]);
-
- // Derived filtered logs
- let filteredLogs = $derived(logsState.logs.filter((log) => {
- // Source Filter
- if (selectedSource !== "all" && log.source !== selectedSource) return false;
-
- // Level Filter
- if (!showInfo && log.level === "info") return false;
- if (!showWarn && log.level === "warn") return false;
- if (!showError && (log.level === "error" || log.level === "fatal")) return false;
- if (!showDebug && log.level === "debug") return false;
-
- // Search Filter
- if (searchQuery) {
- const q = searchQuery.toLowerCase();
- return (
- log.message.toLowerCase().includes(q) ||
- log.source.toLowerCase().includes(q)
- );
- }
- return true;
- }));
-
- // Auto-scroll logic
- $effect(() => {
- // Depend on filteredLogs length to trigger scroll
- if (filteredLogs.length && autoScroll && consoleElement) {
- // Use tick to wait for DOM update
- tick().then(() => {
- consoleElement.scrollTop = consoleElement.scrollHeight;
- });
- }
- });
-
- function handleScroll() {
- if (!consoleElement) return;
- const { scrollTop, scrollHeight, clientHeight } = consoleElement;
- // If user scrolls up (more than 50px from bottom), disable auto-scroll
- if (scrollHeight - scrollTop - clientHeight > 50) {
- autoScroll = false;
- } else {
- autoScroll = true;
- }
- }
-
- // Export only currently filtered logs
- async function exportLogs() {
- try {
- const content = logsState.exportLogs(filteredLogs);
- const path = await save({
- filters: [{ name: "Log File", extensions: ["txt", "log"] }],
- defaultPath: `dropout-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.log`,
- });
-
- if (path) {
- await writeTextFile(path, content);
- logsState.addLog("info", "Console", `Exported ${filteredLogs.length} logs to ${path}`);
- }
- } catch (e) {
- console.error("Export failed", e);
- logsState.addLog("error", "Console", `Export failed: ${e}`);
- }
- }
-
- // Upload only currently filtered logs
- async function uploadLogs() {
- try {
- const content = logsState.exportLogs(filteredLogs);
- logsState.addLog("info", "Console", `Uploading ${filteredLogs.length} logs...`);
-
- const response = await invoke<{ url: string }>("upload_to_pastebin", { content });
-
- logsState.addLog("info", "Console", `Logs uploaded successfully: ${response.url}`);
- await open(response.url);
- } catch (e) {
- console.error("Upload failed", e);
- logsState.addLog("error", "Console", `Upload failed: ${e}`);
- }
- }
-
- function highlightText(text: string, query: string) {
- if (!query) return text;
- // Escape regex special chars in query
- const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const parts = text.split(new RegExp(`(${escaped})`, "gi"));
- return parts.map(part =>
- part.toLowerCase() === query.toLowerCase()
- ? `<span class="bg-yellow-500/30 text-yellow-200 font-bold">${part}</span>`
- : part
- ).join("");
- }
-
- function getLevelColor(level: LogEntry["level"]) {
- switch (level) {
- case "info": return "text-blue-400";
- case "warn": return "text-yellow-400";
- case "error":
- case "fatal": return "text-red-400";
- case "debug": return "text-purple-400";
- default: return "text-zinc-400";
- }
- }
-
- function getLevelLabel(level: LogEntry["level"]) {
- switch (level) {
- case "info": return "INFO";
- case "warn": return "WARN";
- case "error": return "ERR";
- case "fatal": return "FATAL";
- case "debug": return "DEBUG";
- }
- }
-
- function getMessageColor(log: LogEntry) {
- if (log.level === "error" || log.level === "fatal") return "text-red-300";
- if (log.level === "warn") return "text-yellow-200";
- if (log.level === "debug") return "text-purple-200/70";
- if (log.source.startsWith("Game")) return "text-emerald-100/80";
- return "";
- }
-</script>
-
-<div class="absolute inset-0 flex flex-col bg-[#1e1e1e] text-zinc-300 font-mono text-xs overflow-hidden">
- <!-- Toolbar -->
- <div class="flex flex-wrap items-center justify-between p-2 bg-[#252526] border-b border-[#3e3e42] gap-2">
- <div class="flex items-center gap-3">
- <h3 class="font-bold text-zinc-100 uppercase tracking-wider px-2">Console</h3>
-
- <!-- Source Dropdown -->
- <CustomSelect
- options={sourceOptions}
- bind:value={selectedSource}
- class="w-36"
- />
-
- <!-- Level Filters -->
- <div class="flex items-center bg-[#1e1e1e] rounded border border-[#3e3e42] overflow-hidden">
- <button
- class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showInfo ? 'text-blue-400' : 'text-zinc-600'}"
- onclick={() => showInfo = !showInfo}
- title="Toggle Info"
- >Info</button>
- <div class="w-px h-3 bg-[#3e3e42]"></div>
- <button
- class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showWarn ? 'text-yellow-400' : 'text-zinc-600'}"
- onclick={() => showWarn = !showWarn}
- title="Toggle Warnings"
- >Warn</button>
- <div class="w-px h-3 bg-[#3e3e42]"></div>
- <button
- class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showError ? 'text-red-400' : 'text-zinc-600'}"
- onclick={() => showError = !showError}
- title="Toggle Errors"
- >Error</button>
- <div class="w-px h-3 bg-[#3e3e42]"></div>
- <button
- class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showDebug ? 'text-purple-400' : 'text-zinc-600'}"
- onclick={() => showDebug = !showDebug}
- title="Toggle Debug"
- >Debug</button>
- </div>
-
- <!-- Search -->
- <div class="relative group">
- <input
- type="text"
- bind:value={searchQuery}
- placeholder="Find..."
- class="bg-[#1e1e1e] border border-[#3e3e42] rounded pl-8 pr-2 py-1 focus:border-indigo-500 focus:outline-none w-40 text-zinc-300 placeholder:text-zinc-600 transition-all focus:w-64"
- />
- <svg class="w-3.5 h-3.5 text-zinc-500 absolute left-2.5 top-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
- {#if searchQuery}
- <button class="absolute right-2 top-1.5 text-zinc-500 hover:text-white" onclick={() => searchQuery = ""}>✕</button>
- {/if}
- </div>
- </div>
-
- <!-- Actions -->
- <div class="flex items-center gap-2">
- <!-- Log count indicator -->
- <span class="text-zinc-500 text-[10px] px-2">{filteredLogs.length} / {logsState.logs.length}</span>
-
- <button
- onclick={() => logsState.clear()}
- class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
- title="Clear Logs"
- >
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
- </button>
- <button
- onclick={exportLogs}
- class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
- title="Export Filtered Logs"
- >
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
- </button>
- <button
- onclick={uploadLogs}
- class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
- title="Upload Filtered Logs"
- >
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
- </button>
- <div class="w-px h-4 bg-[#3e3e42] mx-1"></div>
- <button
- onclick={() => uiState.toggleConsole()}
- class="p-1.5 hover:bg-red-500/20 hover:text-red-400 rounded text-zinc-400 transition-colors"
- title="Close"
- >
- <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
- </button>
- </div>
- </div>
-
- <!-- Log Area -->
- <div
- bind:this={consoleElement}
- onscroll={handleScroll}
- class="flex-1 overflow-y-auto overflow-x-hidden p-2 select-text custom-scrollbar"
- >
- {#each filteredLogs as log (log.id)}
- <div class="flex gap-2 leading-relaxed hover:bg-[#2a2d2e] px-1 rounded-sm group">
- <!-- Timestamp -->
- <span class="text-zinc-500 shrink-0 select-none w-20 text-right opacity-50 group-hover:opacity-100">{log.timestamp.split('.')[0]}</span>
-
- <!-- Source & Level -->
- <div class="flex shrink-0 min-w-[140px] gap-1 justify-end font-bold select-none truncate">
- <span class="text-zinc-500 truncate max-w-[90px]" title={log.source}>{log.source}</span>
- <span class={getLevelColor(log.level)}>{getLevelLabel(log.level)}</span>
- </div>
-
- <!-- Message -->
- <div class="flex-1 break-all whitespace-pre-wrap text-zinc-300 min-w-0 {getMessageColor(log)}">
- {@html highlightText(log.message, searchQuery)}
- </div>
- </div>
- {/each}
-
- {#if filteredLogs.length === 0}
- <div class="text-center text-zinc-600 mt-10 italic select-none">
- {#if logsState.logs.length === 0}
- Waiting for logs...
- {:else}
- No logs match current filters.
- {/if}
- </div>
- {/if}
- </div>
-
- <!-- Auto-scroll status -->
- {#if !autoScroll}
- <button
- onclick={() => { autoScroll = true; consoleElement.scrollTop = consoleElement.scrollHeight; }}
- class="absolute bottom-4 right-6 bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded shadow-lg text-xs font-bold transition-all animate-bounce"
- >
- Resume Auto-scroll ⬇
- </button>
- {/if}
-</div>
-
-<style>
- /* Custom Scrollbar for the log area */
- .custom-scrollbar::-webkit-scrollbar {
- width: 10px;
- background-color: #1e1e1e;
- }
- .custom-scrollbar::-webkit-scrollbar-thumb {
- background-color: #424242;
- border: 2px solid #1e1e1e; /* padding around thumb */
- border-radius: 0;
- }
- .custom-scrollbar::-webkit-scrollbar-thumb:hover {
- background-color: #4f4f4f;
- }
-</style>
diff --git a/packages/ui/src/lib/effects/ConstellationEffect.ts b/packages/ui/src/lib/effects/ConstellationEffect.ts
deleted file mode 100644
index d2db529..0000000
--- a/packages/ui/src/lib/effects/ConstellationEffect.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-export class ConstellationEffect {
- private canvas: HTMLCanvasElement;
- private ctx: CanvasRenderingContext2D;
- private width: number = 0;
- private height: number = 0;
- private particles: Particle[] = [];
- private animationId: number = 0;
- private mouseX: number = -1000;
- private mouseY: number = -1000;
-
- // Configuration
- private readonly particleCount = 100;
- private readonly connectionDistance = 150;
- private readonly particleSpeed = 0.5;
-
- constructor(canvas: HTMLCanvasElement) {
- this.canvas = canvas;
- this.ctx = canvas.getContext("2d", { alpha: true })!;
-
- // Bind methods
- this.animate = this.animate.bind(this);
- this.handleMouseMove = this.handleMouseMove.bind(this);
-
- // Initial setup
- this.resize(window.innerWidth, window.innerHeight);
- this.initParticles();
-
- // Mouse interaction
- window.addEventListener("mousemove", this.handleMouseMove);
-
- // Start animation
- this.animate();
- }
-
- resize(width: number, height: number) {
- const dpr = window.devicePixelRatio || 1;
- this.width = width;
- this.height = height;
-
- this.canvas.width = width * dpr;
- this.canvas.height = height * dpr;
- this.canvas.style.width = `${width}px`;
- this.canvas.style.height = `${height}px`;
-
- this.ctx.scale(dpr, dpr);
-
- // Re-initialize if screen size changes significantly to maintain density
- if (this.particles.length === 0) {
- this.initParticles();
- }
- }
-
- private initParticles() {
- this.particles = [];
- // Adjust density based on screen area
- const area = this.width * this.height;
- const density = Math.floor(area / 15000); // 1 particle per 15000px²
- const count = Math.min(Math.max(density, 50), 200); // Clamp between 50 and 200
-
- for (let i = 0; i < count; i++) {
- this.particles.push(new Particle(this.width, this.height, this.particleSpeed));
- }
- }
-
- private handleMouseMove(e: MouseEvent) {
- const rect = this.canvas.getBoundingClientRect();
- this.mouseX = e.clientX - rect.left;
- this.mouseY = e.clientY - rect.top;
- }
-
- animate() {
- this.ctx.clearRect(0, 0, this.width, this.height);
-
- // Update and draw particles
- this.particles.forEach((p) => {
- p.update(this.width, this.height);
- p.draw(this.ctx);
- });
-
- // Draw lines
- this.drawConnections();
-
- this.animationId = requestAnimationFrame(this.animate);
- }
-
- private drawConnections() {
- this.ctx.lineWidth = 1;
-
- for (let i = 0; i < this.particles.length; i++) {
- const p1 = this.particles[i];
-
- // Connect to mouse if close
- const distMouse = Math.hypot(p1.x - this.mouseX, p1.y - this.mouseY);
- if (distMouse < this.connectionDistance + 50) {
- const alpha = 1 - distMouse / (this.connectionDistance + 50);
- this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.4})`; // Brighter near mouse
- this.ctx.beginPath();
- this.ctx.moveTo(p1.x, p1.y);
- this.ctx.lineTo(this.mouseX, this.mouseY);
- this.ctx.stroke();
-
- // Gently attract to mouse
- if (distMouse > 10) {
- p1.x += (this.mouseX - p1.x) * 0.005;
- p1.y += (this.mouseY - p1.y) * 0.005;
- }
- }
-
- // Connect to other particles
- for (let j = i + 1; j < this.particles.length; j++) {
- const p2 = this.particles[j];
- const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y);
-
- if (dist < this.connectionDistance) {
- const alpha = 1 - dist / this.connectionDistance;
- this.ctx.strokeStyle = `rgba(255, 255, 255, ${alpha * 0.15})`;
- this.ctx.beginPath();
- this.ctx.moveTo(p1.x, p1.y);
- this.ctx.lineTo(p2.x, p2.y);
- this.ctx.stroke();
- }
- }
- }
- }
-
- destroy() {
- cancelAnimationFrame(this.animationId);
- window.removeEventListener("mousemove", this.handleMouseMove);
- }
-}
-
-class Particle {
- x: number;
- y: number;
- vx: number;
- vy: number;
- size: number;
-
- constructor(w: number, h: number, speed: number) {
- this.x = Math.random() * w;
- this.y = Math.random() * h;
- this.vx = (Math.random() - 0.5) * speed;
- this.vy = (Math.random() - 0.5) * speed;
- this.size = Math.random() * 2 + 1;
- }
-
- update(w: number, h: number) {
- this.x += this.vx;
- this.y += this.vy;
-
- // Bounce off walls
- if (this.x < 0 || this.x > w) this.vx *= -1;
- if (this.y < 0 || this.y > h) this.vy *= -1;
- }
-
- draw(ctx: CanvasRenderingContext2D) {
- ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
- ctx.beginPath();
- ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
- ctx.fill();
- }
-}
diff --git a/packages/ui/src/lib/effects/SaturnEffect.ts b/packages/ui/src/lib/effects/SaturnEffect.ts
index 357da9d..497a340 100644
--- a/packages/ui/src/lib/effects/SaturnEffect.ts
+++ b/packages/ui/src/lib/effects/SaturnEffect.ts
@@ -1,46 +1,61 @@
-// Optimized Saturn Effect for low-end hardware
-// Uses TypedArrays for memory efficiency and reduced particle density
+/**
+ * Ported SaturnEffect for the React UI (ui-new).
+ * Adapted from the original Svelte implementation but written as a standalone
+ * TypeScript class that manages a 2D canvas particle effect resembling a
+ * rotating "Saturn" with rings. Designed to be instantiated and controlled
+ * from a React component (e.g. ParticleBackground).
+ *
+ * Usage:
+ * const effect = new SaturnEffect(canvasElement);
+ * effect.handleMouseDown(clientX);
+ * effect.handleMouseMove(clientX);
+ * effect.handleMouseUp();
+ * // on resize:
+ * effect.resize(width, height);
+ * // on unmount:
+ * effect.destroy();
+ */
export class SaturnEffect {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
- private width: number = 0;
- private height: number = 0;
-
- // Data-oriented design for performance
- // xyz: Float32Array where [i*3, i*3+1, i*3+2] corresponds to x, y, z
- private xyz: Float32Array | null = null;
- // types: Uint8Array where 0 = planet, 1 = ring
- private types: Uint8Array | null = null;
- private count: number = 0;
-
- private animationId: number = 0;
- private angle: number = 0;
- private scaleFactor: number = 1;
-
- // Mouse interaction properties
- private isDragging: boolean = false;
- private lastMouseX: number = 0;
- private lastMouseTime: number = 0;
- private mouseVelocities: number[] = []; // Store recent velocities for averaging
-
- // Rotation speed control
- private readonly baseSpeed: number = 0.005; // Original rotation speed
- private currentSpeed: number = 0.005; // Current rotation speed (can be modified by mouse)
- private rotationDirection: number = 1; // 1 for clockwise, -1 for counter-clockwise
- private readonly speedDecayRate: number = 0.992; // How fast speed returns to normal (closer to 1 = slower decay)
- private readonly minSpeedMultiplier: number = 1; // Minimum speed is baseSpeed
- private readonly maxSpeedMultiplier: number = 50; // Maximum speed is 50x baseSpeed
- private isStopped: boolean = false; // Whether the user has stopped the rotation
+ private width = 0;
+ private height = 0;
+
+ // Particle storage
+ private xyz: Float32Array | null = null; // interleaved x,y,z
+ private types: Uint8Array | null = null; // 0 = planet, 1 = ring
+ private count = 0;
+
+ // Animation
+ private animationId = 0;
+ private angle = 0;
+ private scaleFactor = 1;
+
+ // Interaction
+ private isDragging = false;
+ private lastMouseX = 0;
+ private lastMouseTime = 0;
+ private mouseVelocities: number[] = [];
+
+ // Speed control
+ private readonly baseSpeed = 0.005;
+ private currentSpeed = 0.005;
+ private rotationDirection = 1;
+ private readonly speedDecayRate = 0.992;
+ private readonly minSpeedMultiplier = 1;
+ private readonly maxSpeedMultiplier = 50;
+ private isStopped = false;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
- this.ctx = canvas.getContext("2d", {
- alpha: true,
- desynchronized: false, // default is usually fine, 'desynchronized' can help latency but might flicker
- })!;
+ const ctx = canvas.getContext("2d", { alpha: true, desynchronized: false });
+ if (!ctx) {
+ throw new Error("Failed to get 2D context for SaturnEffect");
+ }
+ this.ctx = ctx;
- // Initial resize will set up everything
+ // Initialize size & particles
this.resize(window.innerWidth, window.innerHeight);
this.initParticles();
@@ -48,9 +63,7 @@ export class SaturnEffect {
this.animate();
}
- // Public methods for external mouse event handling
- // These can be called from any element that wants to control the Saturn rotation
-
+ // External interaction handlers (accept clientX)
handleMouseDown(clientX: number) {
this.isDragging = true;
this.lastMouseX = clientX;
@@ -60,26 +73,18 @@ export class SaturnEffect {
handleMouseMove(clientX: number) {
if (!this.isDragging) return;
-
- const currentTime = performance.now();
- const deltaTime = currentTime - this.lastMouseTime;
-
- if (deltaTime > 0) {
- const deltaX = clientX - this.lastMouseX;
- const velocity = deltaX / deltaTime; // pixels per millisecond
-
- // Store recent velocities (keep last 5 for smoothing)
+ const now = performance.now();
+ const dt = now - this.lastMouseTime;
+ if (dt > 0) {
+ const dx = clientX - this.lastMouseX;
+ const velocity = dx / dt;
this.mouseVelocities.push(velocity);
- if (this.mouseVelocities.length > 5) {
- this.mouseVelocities.shift();
- }
-
- // Apply direct rotation while dragging
- this.angle += deltaX * 0.002;
+ if (this.mouseVelocities.length > 5) this.mouseVelocities.shift();
+ // Rotate directly while dragging for immediate feedback
+ this.angle += dx * 0.002;
}
-
this.lastMouseX = clientX;
- this.lastMouseTime = currentTime;
+ this.lastMouseTime = now;
}
handleMouseUp() {
@@ -90,174 +95,130 @@ export class SaturnEffect {
}
handleTouchStart(clientX: number) {
- this.isDragging = true;
- this.lastMouseX = clientX;
- this.lastMouseTime = performance.now();
- this.mouseVelocities = [];
+ this.handleMouseDown(clientX);
}
handleTouchMove(clientX: number) {
- if (!this.isDragging) return;
-
- const currentTime = performance.now();
- const deltaTime = currentTime - this.lastMouseTime;
-
- if (deltaTime > 0) {
- const deltaX = clientX - this.lastMouseX;
- const velocity = deltaX / deltaTime;
-
- this.mouseVelocities.push(velocity);
- if (this.mouseVelocities.length > 5) {
- this.mouseVelocities.shift();
- }
-
- this.angle += deltaX * 0.002;
- }
-
- this.lastMouseX = clientX;
- this.lastMouseTime = currentTime;
+ this.handleMouseMove(clientX);
}
handleTouchEnd() {
- if (this.isDragging && this.mouseVelocities.length > 0) {
- this.applyFlingVelocity();
- }
- this.isDragging = false;
- }
-
- private applyFlingVelocity() {
- // Calculate average velocity from recent samples
- const avgVelocity =
- this.mouseVelocities.reduce((a, b) => a + b, 0) / this.mouseVelocities.length;
-
- // Threshold for considering it a "fling" (pixels per millisecond)
- const flingThreshold = 0.3;
- // Threshold for considering the rotation as "stopped" by user
- const stopThreshold = 0.1;
-
- if (Math.abs(avgVelocity) > flingThreshold) {
- // User flung it - start rotating again
- this.isStopped = false;
-
- // Determine new direction based on fling direction
- const newDirection = avgVelocity > 0 ? 1 : -1;
-
- // If direction changed, update it permanently
- if (newDirection !== this.rotationDirection) {
- this.rotationDirection = newDirection;
- }
-
- // Calculate speed boost based on fling strength
- // Map velocity to speed multiplier (stronger fling = faster rotation)
- const speedMultiplier = Math.min(
- this.maxSpeedMultiplier,
- this.minSpeedMultiplier + Math.abs(avgVelocity) * 10,
- );
-
- this.currentSpeed = this.baseSpeed * speedMultiplier;
- } else if (Math.abs(avgVelocity) < stopThreshold) {
- // User gently released - keep it stopped
- this.isStopped = true;
- this.currentSpeed = 0;
- }
- // If velocity is between stopThreshold and flingThreshold,
- // keep current state (don't change isStopped)
+ this.handleMouseUp();
}
+ // Resize canvas & scale (call on window resize)
resize(width: number, height: number) {
const dpr = window.devicePixelRatio || 1;
this.width = width;
this.height = height;
- this.canvas.width = width * dpr;
- this.canvas.height = height * dpr;
+ // Update canvas pixel size and CSS size
+ this.canvas.width = Math.max(1, Math.floor(width * dpr));
+ this.canvas.height = Math.max(1, Math.floor(height * dpr));
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
+ // Reset transform and scale for devicePixelRatio
+ this.ctx.setTransform(1, 0, 0, 1, 0, 0); // reset
this.ctx.scale(dpr, dpr);
- // Dynamic scaling based on screen size
const minDim = Math.min(width, height);
- this.scaleFactor = minDim * 0.45;
+ this.scaleFactor = Math.max(1, minDim * 0.45);
}
- initParticles() {
- // Significantly reduced particle count for CPU optimization
- // Planet: 1800 -> 1000
- // Rings: 5000 -> 2500
- // Total approx 3500 vs 6800 previously (approx 50% reduction)
+ // Initialize particle arrays with reduced counts to keep performance reasonable
+ private initParticles() {
+ // Tuned particle counts for reasonable performance across platforms
const planetCount = 1000;
const ringCount = 2500;
this.count = planetCount + ringCount;
- // Use TypedArrays for better memory locality
this.xyz = new Float32Array(this.count * 3);
this.types = new Uint8Array(this.count);
let idx = 0;
- // 1. Planet
- for (let i = 0; i < planetCount; i++) {
+ // Planet points
+ for (let i = 0; i < planetCount; i++, idx++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(Math.random() * 2 - 1);
const r = 1.0;
- // x, y, z
this.xyz[idx * 3] = r * Math.sin(phi) * Math.cos(theta);
this.xyz[idx * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
this.xyz[idx * 3 + 2] = r * Math.cos(phi);
- this.types[idx] = 0; // 0 for planet
- idx++;
+ this.types[idx] = 0;
}
- // 2. Rings
+ // Ring points
const ringInner = 1.4;
const ringOuter = 2.3;
-
- for (let i = 0; i < ringCount; i++) {
+ for (let i = 0; i < ringCount; i++, idx++) {
const angle = Math.random() * Math.PI * 2;
const dist = Math.sqrt(
- Math.random() * (ringOuter * ringOuter - ringInner * ringInner) + ringInner * ringInner,
+ Math.random() * (ringOuter * ringOuter - ringInner * ringInner) +
+ ringInner * ringInner,
);
- // x, y, z
this.xyz[idx * 3] = dist * Math.cos(angle);
this.xyz[idx * 3 + 1] = (Math.random() - 0.5) * 0.05;
this.xyz[idx * 3 + 2] = dist * Math.sin(angle);
- this.types[idx] = 1; // 1 for ring
- idx++;
+ this.types[idx] = 1;
}
}
- animate() {
+ // Map fling/velocity samples to a rotation speed and direction
+ private applyFlingVelocity() {
+ if (this.mouseVelocities.length === 0) return;
+ const avg =
+ this.mouseVelocities.reduce((a, b) => a + b, 0) /
+ this.mouseVelocities.length;
+ const flingThreshold = 0.3;
+ const stopThreshold = 0.1;
+
+ if (Math.abs(avg) > flingThreshold) {
+ this.isStopped = false;
+ const newDir = avg > 0 ? 1 : -1;
+ if (newDir !== this.rotationDirection) this.rotationDirection = newDir;
+ const multiplier = Math.min(
+ this.maxSpeedMultiplier,
+ this.minSpeedMultiplier + Math.abs(avg) * 10,
+ );
+ this.currentSpeed = this.baseSpeed * multiplier;
+ } else if (Math.abs(avg) < stopThreshold) {
+ this.isStopped = true;
+ this.currentSpeed = 0;
+ }
+ }
+
+ // Main render loop
+ private animate() {
+ // Clear with full alpha to allow layering over background
this.ctx.clearRect(0, 0, this.width, this.height);
- // Normal blending
+ // Standard composition
this.ctx.globalCompositeOperation = "source-over";
- // Update rotation speed - decay towards base speed while maintaining direction
+ // Update rotation speed (decay)
if (!this.isDragging && !this.isStopped) {
if (this.currentSpeed > this.baseSpeed) {
- // Gradually decay speed back to base speed
this.currentSpeed =
- this.baseSpeed + (this.currentSpeed - this.baseSpeed) * this.speedDecayRate;
-
- // Snap to base speed when close enough
+ this.baseSpeed +
+ (this.currentSpeed - this.baseSpeed) * this.speedDecayRate;
if (this.currentSpeed - this.baseSpeed < 0.00001) {
this.currentSpeed = this.baseSpeed;
}
}
-
- // Apply rotation with current speed and direction
this.angle += this.currentSpeed * this.rotationDirection;
}
+ // Center positions
const cx = this.width * 0.6;
const cy = this.height * 0.5;
- // Pre-calculate rotation matrices
+ // Pre-calc rotations
const rotationY = this.angle;
const rotationX = 0.4;
const rotationZ = 0.15;
@@ -272,29 +233,27 @@ export class SaturnEffect {
const fov = 1500;
const scaleFactor = this.scaleFactor;
- if (!this.xyz || !this.types) return;
+ if (!this.xyz || !this.types) {
+ this.animationId = requestAnimationFrame(this.animate);
+ return;
+ }
+ // Loop particles
for (let i = 0; i < this.count; i++) {
const x = this.xyz[i * 3];
const y = this.xyz[i * 3 + 1];
const z = this.xyz[i * 3 + 2];
- // Apply Scale
+ // Scale to screen
const px = x * scaleFactor;
const py = y * scaleFactor;
const pz = z * scaleFactor;
- // 1. Rotate Y
+ // Rotate Y then X then Z
const x1 = px * cosY - pz * sinY;
const z1 = pz * cosY + px * sinY;
- // y1 = py
-
- // 2. Rotate X
const y2 = py * cosX - z1 * sinX;
const z2 = z1 * cosX + py * sinX;
- // x2 = x1
-
- // 3. Rotate Z
const x3 = x1 * cosZ - y2 * sinZ;
const y3 = y2 * cosZ + x1 * sinZ;
const z3 = z2;
@@ -305,28 +264,23 @@ export class SaturnEffect {
const x2d = cx + x3 * scale;
const y2d = cy + y3 * scale;
- // Size calculation - slightly larger dots to compensate for lower count
- // Previously Planet 2.0 -> 2.4, Ring 1.3 -> 1.5
const type = this.types[i];
const sizeBase = type === 0 ? 2.4 : 1.5;
const size = sizeBase * scale;
- // Opacity
let alpha = scale * scale * scale;
if (alpha > 1) alpha = 1;
- if (alpha < 0.15) continue; // Skip very faint particles for performance
+ if (alpha < 0.15) continue;
- // Optimization: Planet color vs Ring color
if (type === 0) {
- // Planet: Warm White
+ // Planet: warm-ish
this.ctx.fillStyle = `rgba(255, 240, 220, ${alpha})`;
} else {
- // Ring: Cool White
+ // Ring: cool-ish
this.ctx.fillStyle = `rgba(220, 240, 255, ${alpha})`;
}
- // Render as squares (fillRect) instead of circles (arc)
- // This is significantly faster for software rendering and reduces GPU usage.
+ // Render as small rectangles (faster than arc)
this.ctx.fillRect(x2d, y2d, size, size);
}
}
@@ -334,7 +288,12 @@ export class SaturnEffect {
this.animationId = requestAnimationFrame(this.animate);
}
+ // Stop animations and release resources
destroy() {
- cancelAnimationFrame(this.animationId);
+ if (this.animationId) {
+ cancelAnimationFrame(this.animationId);
+ this.animationId = 0;
+ }
+ // Intentionally do not null out arrays to allow reuse if desired.
}
}
diff --git a/packages/ui/src/lib/modLoaderApi.ts b/packages/ui/src/lib/modLoaderApi.ts
deleted file mode 100644
index 75f404a..0000000
--- a/packages/ui/src/lib/modLoaderApi.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * Mod Loader API service for Fabric and Forge integration.
- * This module provides functions to interact with the Tauri backend
- * for mod loader version management.
- */
-
-import { invoke } from "@tauri-apps/api/core";
-import type {
- FabricGameVersion,
- FabricLoaderVersion,
- FabricLoaderEntry,
- InstalledFabricVersion,
- ForgeVersion,
- InstalledForgeVersion,
-} from "../types";
-
-// ==================== Fabric API ====================
-
-/**
- * Get all Minecraft versions supported by Fabric.
- */
-export async function getFabricGameVersions(): Promise<FabricGameVersion[]> {
- return invoke<FabricGameVersion[]>("get_fabric_game_versions");
-}
-
-/**
- * Get all available Fabric loader versions.
- */
-export async function getFabricLoaderVersions(): Promise<FabricLoaderVersion[]> {
- return invoke<FabricLoaderVersion[]>("get_fabric_loader_versions");
-}
-
-/**
- * Get Fabric loaders available for a specific Minecraft version.
- */
-export async function getFabricLoadersForVersion(
- gameVersion: string,
-): Promise<FabricLoaderEntry[]> {
- return invoke<FabricLoaderEntry[]>("get_fabric_loaders_for_version", {
- gameVersion,
- });
-}
-
-/**
- * Install Fabric loader for a specific Minecraft version.
- */
-export async function installFabric(
- gameVersion: string,
- loaderVersion: string,
-): Promise<InstalledFabricVersion> {
- return invoke<InstalledFabricVersion>("install_fabric", {
- gameVersion,
- loaderVersion,
- });
-}
-
-/**
- * List all installed Fabric versions.
- */
-export async function listInstalledFabricVersions(): Promise<string[]> {
- return invoke<string[]>("list_installed_fabric_versions");
-}
-
-/**
- * Check if Fabric is installed for a specific version combination.
- */
-export async function isFabricInstalled(
- gameVersion: string,
- loaderVersion: string,
-): Promise<boolean> {
- return invoke<boolean>("is_fabric_installed", {
- gameVersion,
- loaderVersion,
- });
-}
-
-// ==================== Forge API ====================
-
-/**
- * Get all Minecraft versions supported by Forge.
- */
-export async function getForgeGameVersions(): Promise<string[]> {
- return invoke<string[]>("get_forge_game_versions");
-}
-
-/**
- * Get Forge versions available for a specific Minecraft version.
- */
-export async function getForgeVersionsForGame(gameVersion: string): Promise<ForgeVersion[]> {
- return invoke<ForgeVersion[]>("get_forge_versions_for_game", {
- gameVersion,
- });
-}
-
-/**
- * Install Forge for a specific Minecraft version.
- */
-export async function installForge(
- gameVersion: string,
- forgeVersion: string,
-): Promise<InstalledForgeVersion> {
- return invoke<InstalledForgeVersion>("install_forge", {
- gameVersion,
- forgeVersion,
- });
-}
diff --git a/packages/ui/src/lib/tsrs-utils.ts b/packages/ui/src/lib/tsrs-utils.ts
new file mode 100644
index 0000000..f48f851
--- /dev/null
+++ b/packages/ui/src/lib/tsrs-utils.ts
@@ -0,0 +1,67 @@
+export type Maybe<T> = T | null | undefined;
+
+export function toNumber(
+ value: Maybe<number | bigint | string>,
+ fallback = 0,
+): number {
+ if (value === null || value === undefined) return fallback;
+
+ if (typeof value === "number") {
+ if (Number.isFinite(value)) return value;
+ return fallback;
+ }
+
+ if (typeof value === "bigint") {
+ // safe conversion for typical values (timestamps, sizes). Might overflow for huge bigint.
+ return Number(value);
+ }
+
+ if (typeof value === "string") {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : fallback;
+ }
+
+ return fallback;
+}
+
+/**
+ * Like `toNumber` but ensures non-negative result (clamps at 0).
+ */
+export function toNonNegativeNumber(
+ value: Maybe<number | bigint | string>,
+ fallback = 0,
+): number {
+ const n = toNumber(value, fallback);
+ return n < 0 ? 0 : n;
+}
+
+export function toDate(
+ value: Maybe<number | bigint | string>,
+ opts?: { isSeconds?: boolean },
+): Date | null {
+ if (value === null || value === undefined) return null;
+
+ const isSeconds = opts?.isSeconds ?? true;
+
+ // accept bigint, number, numeric string
+ const n = toNumber(value, NaN);
+ if (Number.isNaN(n)) return null;
+
+ const ms = isSeconds ? Math.floor(n) * 1000 : Math.floor(n);
+ return new Date(ms);
+}
+
+/**
+ * Convert a binding boolean-ish value (0/1, "true"/"false", boolean) to boolean.
+ */
+export function toBoolean(value: unknown, fallback = false): boolean {
+ if (value === null || value === undefined) return fallback;
+ if (typeof value === "boolean") return value;
+ if (typeof value === "number") return value !== 0;
+ if (typeof value === "string") {
+ const s = value.toLowerCase().trim();
+ if (s === "true" || s === "1") return true;
+ if (s === "false" || s === "0") return false;
+ }
+ return fallback;
+}
diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts
new file mode 100644
index 0000000..365058c
--- /dev/null
+++ b/packages/ui/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/packages/ui/src/main.ts b/packages/ui/src/main.ts
deleted file mode 100644
index d47b930..0000000
--- a/packages/ui/src/main.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { mount } from "svelte";
-import "./app.css";
-import App from "./App.svelte";
-
-const app = mount(App, {
- target: document.getElementById("app")!,
-});
-
-export default app;
diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx
new file mode 100644
index 0000000..a3157bd
--- /dev/null
+++ b/packages/ui/src/main.tsx
@@ -0,0 +1,38 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import { createHashRouter, RouterProvider } from "react-router";
+import { Toaster } from "./components/ui/sonner";
+import { HomeView } from "./pages/home-view";
+import { IndexPage } from "./pages/index";
+import { InstancesView } from "./pages/instances-view";
+import { SettingsPage } from "./pages/settings";
+
+const router = createHashRouter([
+ {
+ path: "/",
+ element: <IndexPage />,
+ children: [
+ {
+ index: true,
+ element: <HomeView />,
+ },
+ {
+ path: "instances",
+ element: <InstancesView />,
+ },
+ {
+ path: "settings",
+ element: <SettingsPage />,
+ },
+ ],
+ },
+]);
+
+const root = createRoot(document.getElementById("root") as HTMLElement);
+root.render(
+ <StrictMode>
+ <RouterProvider router={router} />
+ <Toaster />
+ </StrictMode>,
+);
diff --git a/packages/ui/src/models/auth.ts b/packages/ui/src/models/auth.ts
new file mode 100644
index 0000000..10b2a0d
--- /dev/null
+++ b/packages/ui/src/models/auth.ts
@@ -0,0 +1,142 @@
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { open } from "@tauri-apps/plugin-shell";
+import { Mutex } from "es-toolkit";
+import { toString as stringify } from "es-toolkit/compat";
+import { toast } from "sonner";
+import { create } from "zustand";
+import {
+ completeMicrosoftLogin,
+ getActiveAccount,
+ loginOffline,
+ logout,
+ startMicrosoftLogin,
+} from "@/client";
+import type { Account, DeviceCodeResponse } from "@/types";
+
+export interface AuthState {
+ account: Account | null;
+ loginMode: Account["type"] | null;
+ deviceCode: DeviceCodeResponse | null;
+ _pollingInterval: number | null;
+ _mutex: Mutex;
+ statusMessage: string | null;
+ _progressUnlisten: UnlistenFn | null;
+
+ init: () => Promise<void>;
+ setLoginMode: (mode: Account["type"] | null) => void;
+ loginOnline: (onSuccess?: () => void | Promise<void>) => Promise<void>;
+ _pollLoginStatus: (
+ deviceCode: string,
+ onSuccess?: () => void | Promise<void>,
+ ) => Promise<void>;
+ cancelLoginOnline: () => Promise<void>;
+ loginOffline: (username: string) => Promise<void>;
+ logout: () => Promise<void>;
+}
+
+export const useAuthStore = create<AuthState>((set, get) => ({
+ account: null,
+ loginMode: null,
+ deviceCode: null,
+ _pollingInterval: null,
+ statusMessage: null,
+ _progressUnlisten: null,
+ _mutex: new Mutex(),
+
+ init: async () => {
+ try {
+ const account = await getActiveAccount();
+ set({ account });
+ } catch (error) {
+ console.error("Failed to initialize auth store:", error);
+ }
+ },
+ setLoginMode: (mode) => set({ loginMode: mode }),
+ loginOnline: async (onSuccess) => {
+ const { _pollLoginStatus } = get();
+
+ set({ statusMessage: "Waiting for authorization..." });
+
+ try {
+ const unlisten = await listen("auth-progress", (event) => {
+ const message = event.payload;
+ console.log(message);
+ set({ statusMessage: stringify(message), _progressUnlisten: unlisten });
+ });
+ } catch (error) {
+ console.warn("Failed to attch auth-progress listener:", error);
+ 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 });
+ },
+ _pollLoginStatus: async (deviceCode, onSuccess) => {
+ const { _pollingInterval, _mutex: mutex, _progressUnlisten } = get();
+ if (mutex.isLocked) return;
+ 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");
+ }
+ } finally {
+ mutex.release();
+ }
+ },
+ cancelLoginOnline: async () => {
+ const { account, logout, _pollingInterval, _progressUnlisten } = get();
+ clearInterval(_pollingInterval ?? undefined);
+ _progressUnlisten?.();
+ if (account) {
+ await logout();
+ }
+ set({
+ loginMode: null,
+ _pollingInterval: null,
+ statusMessage: null,
+ _progressUnlisten: null,
+ });
+ },
+ loginOffline: async (username: string) => {
+ const trimmedUsername = username.trim();
+ if (trimmedUsername.length === 0) {
+ throw new Error("Username cannot be empty");
+ }
+
+ try {
+ const account = await loginOffline(trimmedUsername);
+ set({ account, loginMode: "offline" });
+ } catch (error) {
+ console.error("Failed to login offline:", error);
+ toast.error("Failed to login offline");
+ }
+ },
+ logout: async () => {
+ try {
+ await logout();
+ set({ account: null });
+ } catch (error) {
+ console.error("Failed to logout:", error);
+ toast.error("Failed to logout");
+ }
+ },
+}));
diff --git a/packages/ui/src/models/instance.ts b/packages/ui/src/models/instance.ts
new file mode 100644
index 0000000..a3fda3d
--- /dev/null
+++ b/packages/ui/src/models/instance.ts
@@ -0,0 +1,131 @@
+import { toast } from "sonner";
+import { create } from "zustand";
+import {
+ createInstance,
+ deleteInstance,
+ duplicateInstance,
+ getActiveInstance,
+ getInstance,
+ listInstances,
+ setActiveInstance,
+ updateInstance,
+} from "@/client";
+import type { Instance } from "@/types";
+
+interface InstanceState {
+ instances: Instance[];
+ activeInstance: Instance | null;
+
+ refresh: () => Promise<void>;
+ create: (name: string) => Promise<Instance | null>;
+ delete: (id: string) => Promise<void>;
+ update: (instance: Instance) => Promise<void>;
+ setActiveInstance: (instance: Instance) => Promise<void>;
+ duplicate: (id: string, newName: string) => Promise<Instance | null>;
+ get: (id: string) => Promise<Instance | null>;
+}
+
+export const useInstanceStore = create<InstanceState>((set, get) => ({
+ instances: [],
+ activeInstance: null,
+
+ refresh: async () => {
+ const { setActiveInstance } = get();
+ try {
+ const instances = await listInstances();
+ const activeInstance = await getActiveInstance();
+
+ if (!activeInstance && instances.length > 0) {
+ // If no active instance but instances exist, set the first one as active
+ await setActiveInstance(instances[0]);
+ }
+
+ set({ instances, activeInstance });
+ } catch (e) {
+ console.error("Failed to load instances:", e);
+ toast.error("Error loading instances");
+ }
+ },
+
+ create: async (name) => {
+ const { refresh } = get();
+ try {
+ const instance = await createInstance(name);
+ await refresh();
+ toast.success(`Instance "${name}" created successfully`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to create instance:", e);
+ toast.error("Error creating instance");
+ return null;
+ }
+ },
+
+ delete: async (id) => {
+ const { refresh, instances, activeInstance, setActiveInstance } = 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");
+ }
+ },
+
+ update: async (instance) => {
+ const { refresh } = get();
+ try {
+ await updateInstance(instance);
+ await refresh();
+ toast.success("Instance updated successfully");
+ } catch (e) {
+ console.error("Failed to update instance:", e);
+ toast.error("Error updating instance");
+ }
+ },
+
+ setActiveInstance: async (instance) => {
+ try {
+ await setActiveInstance(instance.id);
+ set({ activeInstance: instance });
+ toast.success("Active instance changed");
+ } catch (e) {
+ console.error("Failed to set active instance:", e);
+ toast.error("Error setting active instance");
+ }
+ },
+
+ duplicate: async (id, newName) => {
+ const { refresh } = get();
+ try {
+ const instance = await duplicateInstance(id, newName);
+ await refresh();
+ toast.success(`Instance duplicated as "${newName}"`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to duplicate instance:", e);
+ toast.error("Error duplicating instance");
+ return null;
+ }
+ },
+
+ get: async (id) => {
+ try {
+ return await getInstance(id);
+ } catch (e) {
+ console.error("Failed to get instance:", e);
+ return null;
+ }
+ },
+}));
diff --git a/packages/ui/src/models/settings.ts b/packages/ui/src/models/settings.ts
new file mode 100644
index 0000000..9f4119c
--- /dev/null
+++ b/packages/ui/src/models/settings.ts
@@ -0,0 +1,75 @@
+import { toast } from "sonner";
+import { create } from "zustand/react";
+import { getConfigPath, getSettings, saveSettings } from "@/client";
+import type { LauncherConfig } from "@/types";
+
+export interface SettingsState {
+ config: LauncherConfig | null;
+ configPath: string | null;
+
+ /* Theme getter */
+ get theme(): string;
+ /* Apply theme to the document */
+ applyTheme: (theme?: string) => void;
+
+ /* Refresh settings from the backend */
+ refresh: () => Promise<void>;
+ /* Save settings to the backend */
+ save: () => Promise<void>;
+ /* Update settings in the backend */
+ update: (config: LauncherConfig) => Promise<void>;
+ /* Merge settings with the current config without saving */
+ merge: (config: Partial<LauncherConfig>) => void;
+}
+
+export const useSettingsStore = create<SettingsState>((set, get) => ({
+ config: null,
+ configPath: null,
+
+ get theme() {
+ const { config } = get();
+ return config?.theme || "dark";
+ },
+ applyTheme: (theme?: string) => {
+ const { config } = get();
+ if (!config) return;
+ if (!theme) theme = config.theme;
+ let themeValue = theme;
+ if (theme === "system") {
+ themeValue = window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+ }
+ document.documentElement.classList.remove("light", "dark");
+ document.documentElement.setAttribute("data-theme", themeValue);
+ document.documentElement.classList.add(themeValue);
+ set({ config: { ...config, theme } });
+ },
+
+ refresh: async () => {
+ const { applyTheme } = get();
+ try {
+ const settings = await getSettings();
+ const path = await getConfigPath();
+ set({ config: settings, configPath: path });
+ applyTheme(settings.theme);
+ } catch (error) {
+ console.error("Failed to load settings:", error);
+ toast.error("Failed to load settings");
+ }
+ },
+ save: async () => {
+ const { config } = get();
+ if (!config) return;
+ await saveSettings(config);
+ },
+ update: async (config) => {
+ await saveSettings(config);
+ set({ config });
+ },
+ merge: (config) => {
+ const { config: currentConfig } = get();
+ if (!currentConfig) throw new Error("Settings not loaded");
+ set({ config: { ...currentConfig, ...config } });
+ },
+}));
diff --git a/packages/ui/src/pages/assistant-view.tsx.bk b/packages/ui/src/pages/assistant-view.tsx.bk
new file mode 100644
index 0000000..56f827b
--- /dev/null
+++ b/packages/ui/src/pages/assistant-view.tsx.bk
@@ -0,0 +1,485 @@
+import {
+ AlertTriangle,
+ Bot,
+ Brain,
+ ChevronDown,
+ Loader2,
+ RefreshCw,
+ Send,
+ Settings,
+ Trash2,
+} from "lucide-react";
+import { marked } from "marked";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Textarea } from "@/components/ui/textarea";
+import { toNumber } from "@/lib/tsrs-utils";
+import { type Message, useAssistantStore } from "../stores/assistant-store";
+import { useSettingsStore } from "../stores/settings-store";
+import { useUiStore } from "../stores/ui-store";
+
+interface ParsedMessage {
+ thinking: string | null;
+ content: string;
+ isThinking: boolean;
+}
+
+function parseMessageContent(content: string): ParsedMessage {
+ if (!content) return { thinking: null, content: "", isThinking: false };
+
+ // Support both <thinking> and <think> (DeepSeek uses <think>)
+ let startTag = "<thinking>";
+ let endTag = "</thinking>";
+ let startIndex = content.indexOf(startTag);
+
+ if (startIndex === -1) {
+ startTag = "<think>";
+ endTag = "</think>";
+ startIndex = content.indexOf(startTag);
+ }
+
+ // Also check for encoded tags if they weren't decoded properly
+ if (startIndex === -1) {
+ startTag = "\u003cthink\u003e";
+ endTag = "\u003c/think\u003e";
+ startIndex = content.indexOf(startTag);
+ }
+
+ if (startIndex !== -1) {
+ const endIndex = content.indexOf(endTag, startIndex);
+
+ if (endIndex !== -1) {
+ // Completed thinking block
+ const before = content.substring(0, startIndex);
+ const thinking = content
+ .substring(startIndex + startTag.length, endIndex)
+ .trim();
+ const after = content.substring(endIndex + endTag.length);
+
+ return {
+ thinking,
+ content: (before + after).trim(),
+ isThinking: false,
+ };
+ } else {
+ // Incomplete thinking block (still streaming)
+ const before = content.substring(0, startIndex);
+ const thinking = content.substring(startIndex + startTag.length).trim();
+
+ return {
+ thinking,
+ content: before.trim(),
+ isThinking: true,
+ };
+ }
+ }
+
+ return { thinking: null, content, isThinking: false };
+}
+
+function renderMarkdown(content: string): string {
+ if (!content) return "";
+ try {
+ return marked(content, { breaks: true, gfm: true }) as string;
+ } catch {
+ return content;
+ }
+}
+
+export function AssistantView() {
+ const {
+ messages,
+ isProcessing,
+ isProviderHealthy,
+ streamingContent,
+ init,
+ checkHealth,
+ sendMessage,
+ clearHistory,
+ } = useAssistantStore();
+ const { settings } = useSettingsStore();
+ const { setView } = useUiStore();
+
+ const [input, setInput] = useState("");
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+ const messagesContainerRef = useRef<HTMLDivElement>(null);
+
+ const provider = settings.assistant.llmProvider;
+ const endpoint =
+ provider === "ollama"
+ ? settings.assistant.ollamaEndpoint
+ : settings.assistant.openaiEndpoint;
+ const model =
+ provider === "ollama"
+ ? settings.assistant.ollamaModel
+ : settings.assistant.openaiModel;
+
+ const getProviderName = (): string => {
+ if (provider === "ollama") {
+ return `Ollama (${model})`;
+ } else if (provider === "openai") {
+ return `OpenAI (${model})`;
+ }
+ return provider;
+ };
+
+ const getProviderHelpText = (): string => {
+ if (provider === "ollama") {
+ return `Please ensure Ollama is installed and running at ${endpoint}.`;
+ } else if (provider === "openai") {
+ return "Please check your OpenAI API key in Settings > AI Assistant.";
+ }
+ return "";
+ };
+
+ const scrollToBottom = useCallback(() => {
+ if (messagesContainerRef.current) {
+ setTimeout(() => {
+ if (messagesContainerRef.current) {
+ messagesContainerRef.current.scrollTop =
+ messagesContainerRef.current.scrollHeight;
+ }
+ }, 0);
+ }
+ }, []);
+
+ useEffect(() => {
+ init();
+ }, [init]);
+
+ useEffect(() => {
+ if (messages.length > 0 || isProcessing) {
+ scrollToBottom();
+ }
+ }, [messages.length, isProcessing, scrollToBottom]);
+
+ const handleSubmit = async () => {
+ if (!input.trim() || isProcessing) return;
+ const text = input;
+ setInput("");
+ await sendMessage(text, settings.assistant.enabled, provider, endpoint);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ };
+
+ const renderMessage = (message: Message, index: number) => {
+ const isUser = message.role === "user";
+ const parsed = parseMessageContent(message.content);
+
+ return (
+ <div
+ key={index}
+ className={`flex ${isUser ? "justify-end" : "justify-start"} mb-4`}
+ >
+ <div
+ className={`max-w-[80%] rounded-2xl px-4 py-3 ${
+ isUser
+ ? "bg-indigo-500 text-white rounded-br-none"
+ : "bg-zinc-800 text-zinc-100 rounded-bl-none"
+ }`}
+ >
+ {!isUser && parsed.thinking && (
+ <div className="mb-3 max-w-full overflow-hidden">
+ <details className="group" open={parsed.isThinking}>
+ <summary className="list-none cursor-pointer flex items-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors text-xs font-medium select-none bg-black/20 p-2 rounded-lg border border-white/5 w-fit mb-2 outline-none">
+ <Brain className="h-3 w-3" />
+ <span>Thinking Process</span>
+ <ChevronDown className="h-3 w-3 transition-transform duration-200 group-open:rotate-180" />
+ </summary>
+ <div className="pl-3 border-l-2 border-zinc-700 text-zinc-500 text-xs italic leading-relaxed whitespace-pre-wrap font-mono max-h-96 overflow-y-auto custom-scrollbar bg-black/10 p-2 rounded-r-md">
+ {parsed.thinking}
+ {parsed.isThinking && (
+ <span className="inline-block w-1.5 h-3 bg-zinc-500 ml-1 animate-pulse align-middle" />
+ )}
+ </div>
+ </details>
+ </div>
+ )}
+ <div
+ className="prose prose-invert max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: renderMarkdown(parsed.content),
+ }}
+ />
+ {!isUser && message.stats && (
+ <div className="mt-2 pt-2 border-t border-zinc-700/50">
+ <div className="text-xs text-zinc-400">
+ {message.stats.evalCount} tokens ·{" "}
+ {Math.round(toNumber(message.stats.totalDuration) / 1000000)}
+ ms
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ };
+
+ return (
+ <div className="h-full w-full flex flex-col gap-4 p-4 lg:p-8">
+ <div className="flex items-center justify-between mb-2">
+ <div className="flex items-center gap-3">
+ <div className="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
+ <Bot size={24} />
+ </div>
+ <div>
+ <h2 className="text-2xl font-bold">Game Assistant</h2>
+ <p className="text-zinc-400 text-sm">
+ Powered by {getProviderName()}
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {!settings.assistant.enabled ? (
+ <Badge
+ variant="outline"
+ className="bg-zinc-500/10 text-zinc-400 border-zinc-500/20"
+ >
+ <AlertTriangle className="h-3 w-3 mr-1" />
+ Disabled
+ </Badge>
+ ) : !isProviderHealthy ? (
+ <Badge
+ variant="outline"
+ className="bg-red-500/10 text-red-400 border-red-500/20"
+ >
+ <AlertTriangle className="h-3 w-3 mr-1" />
+ Offline
+ </Badge>
+ ) : (
+ <Badge
+ variant="outline"
+ className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
+ >
+ <div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse mr-1" />
+ Online
+ </Badge>
+ )}
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={checkHealth}
+ title="Check Connection"
+ disabled={isProcessing}
+ >
+ <RefreshCw
+ className={`h-4 w-4 ${isProcessing ? "animate-spin" : ""}`}
+ />
+ </Button>
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={clearHistory}
+ title="Clear History"
+ disabled={isProcessing}
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => setView("settings")}
+ title="Settings"
+ >
+ <Settings className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+
+ {/* Chat Area */}
+ <div className="flex-1 bg-black/20 border border-white/5 rounded-xl overflow-hidden flex flex-col relative">
+ {/* Warning when assistant is disabled */}
+ {!settings.assistant.enabled && (
+ <div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-10">
+ <Card className="bg-yellow-500/10 border-yellow-500/20">
+ <CardContent className="p-3 flex items-center gap-2">
+ <AlertTriangle className="h-4 w-4 text-yellow-500" />
+ <span className="text-yellow-500 text-sm font-medium">
+ Assistant is disabled. Enable it in Settings &gt; AI
+ Assistant.
+ </span>
+ </CardContent>
+ </Card>
+ </div>
+ )}
+
+ {/* Provider offline warning */}
+ {settings.assistant.enabled && !isProviderHealthy && (
+ <div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-10">
+ <Card className="bg-red-500/10 border-red-500/20">
+ <CardContent className="p-3 flex items-center gap-2">
+ <AlertTriangle className="h-4 w-4 text-red-500" />
+ <div className="flex flex-col">
+ <span className="text-red-500 text-sm font-medium">
+ Assistant is offline
+ </span>
+ <span className="text-red-400 text-xs">
+ {getProviderHelpText()}
+ </span>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )}
+
+ {/* Messages Container */}
+ <ScrollArea className="flex-1 p-4 lg:p-6" ref={messagesContainerRef}>
+ {messages.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-full text-zinc-400 gap-4 mt-8">
+ <div className="p-4 bg-zinc-800/50 rounded-full">
+ <Bot className="h-12 w-12" />
+ </div>
+ <h3 className="text-xl font-medium">How can I help you today?</h3>
+ <p className="text-center max-w-md text-sm">
+ I can analyze your game logs, diagnose crashes, or explain mod
+ features.
+ {!settings.assistant.enabled && (
+ <span className="block mt-2 text-yellow-500">
+ Assistant is disabled. Enable it in{" "}
+ <button
+ type="button"
+ onClick={() => setView("settings")}
+ className="text-indigo-400 hover:underline"
+ >
+ Settings &gt; AI Assistant
+ </button>
+ .
+ </span>
+ )}
+ </p>
+ <div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-2 max-w-lg">
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput("How do I fix Minecraft crashing on launch?")
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ How do I fix Minecraft crashing on launch?
+ </div>
+ </Button>
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput("What's the best way to improve FPS?")
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ What's the best way to improve FPS?
+ </div>
+ </Button>
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput(
+ "Can you help me install Fabric for Minecraft 1.20.4?",
+ )
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ Can you help me install Fabric for 1.20.4?
+ </div>
+ </Button>
+ <Button
+ variant="outline"
+ className="text-left h-auto py-3"
+ onClick={() =>
+ setInput("What mods do you recommend for performance?")
+ }
+ disabled={isProcessing}
+ >
+ <div className="text-sm">
+ What mods do you recommend for performance?
+ </div>
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <>
+ {messages.map((message, index) => renderMessage(message, index))}
+ {isProcessing && streamingContent && (
+ <div className="flex justify-start mb-4">
+ <div className="max-w-[80%] bg-zinc-800 text-zinc-100 rounded-2xl rounded-bl-none px-4 py-3">
+ <div
+ className="prose prose-invert max-w-none"
+ dangerouslySetInnerHTML={{
+ __html: renderMarkdown(streamingContent),
+ }}
+ />
+ <div className="flex items-center gap-1 mt-2 text-xs text-zinc-400">
+ <Loader2 className="h-3 w-3 animate-spin" />
+ <span>Assistant is typing...</span>
+ </div>
+ </div>
+ </div>
+ )}
+ </>
+ )}
+ <div ref={messagesEndRef} />
+ </ScrollArea>
+
+ <Separator />
+
+ {/* Input Area */}
+ <div className="p-3 lg:p-4">
+ <div className="flex gap-2">
+ <Textarea
+ placeholder={
+ settings.assistant.enabled
+ ? "Ask about your game..."
+ : "Assistant is disabled. Enable it in Settings to use."
+ }
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ className="min-h-11 max-h-50 resize-none border-zinc-700 bg-zinc-900/50 focus:bg-zinc-900/80"
+ disabled={!settings.assistant.enabled || isProcessing}
+ />
+ <Button
+ onClick={handleSubmit}
+ disabled={
+ !settings.assistant.enabled || !input.trim() || isProcessing
+ }
+ className="px-6 bg-indigo-600 hover:bg-indigo-700 text-white"
+ >
+ {isProcessing ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <Send className="h-4 w-4" />
+ )}
+ </Button>
+ </div>
+ <div className="mt-2 flex items-center justify-between">
+ <div className="text-xs text-zinc-500">
+ {settings.assistant.enabled
+ ? "Press Enter to send, Shift+Enter for new line"
+ : "Enable the assistant in Settings to use"}
+ </div>
+ <div className="text-xs text-zinc-500">
+ Model: {model} • Provider: {provider}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/home-view.tsx b/packages/ui/src/pages/home-view.tsx
new file mode 100644
index 0000000..4f80cb0
--- /dev/null
+++ b/packages/ui/src/pages/home-view.tsx
@@ -0,0 +1,174 @@
+import { useEffect, useState } from "react";
+import { BottomBar } from "@/components/bottom-bar";
+import type { SaturnEffect } from "@/lib/effects/SaturnEffect";
+import { useGameStore } from "../stores/game-store";
+import { useReleasesStore } from "../stores/releases-store";
+
+export function HomeView() {
+ const gameStore = useGameStore();
+ const releasesStore = useReleasesStore();
+ const [mouseX, setMouseX] = useState(0);
+ const [mouseY, setMouseY] = useState(0);
+
+ useEffect(() => {
+ releasesStore.loadReleases();
+ }, [releasesStore.loadReleases]);
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ const x = (e.clientX / window.innerWidth) * 2 - 1;
+ const y = (e.clientY / window.innerHeight) * 2 - 1;
+ setMouseX(x);
+ setMouseY(y);
+
+ // Forward mouse move to SaturnEffect (if available) for parallax/rotation interactions
+ try {
+ const saturn = (
+ window as unknown as {
+ getSaturnEffect?: () => SaturnEffect;
+ }
+ ).getSaturnEffect?.();
+ if (saturn?.handleMouseMove) {
+ saturn.handleMouseMove(e.clientX);
+ }
+ } catch {
+ /* best-effort, ignore errors from effect */
+ }
+ };
+
+ const handleSaturnMouseDown = (e: React.MouseEvent) => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseDown) {
+ saturn.handleMouseDown(e.clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnMouseUp = () => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseUp) {
+ saturn.handleMouseUp();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnMouseLeave = () => {
+ // Treat leaving the area as mouse-up for the effect
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleMouseUp) {
+ saturn.handleMouseUp();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const handleSaturnTouchStart = (e: React.TouchEvent) => {
+ if (e.touches && e.touches.length === 1) {
+ try {
+ const clientX = e.touches[0].clientX;
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchStart) {
+ saturn.handleTouchStart(clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ };
+
+ const handleSaturnTouchMove = (e: React.TouchEvent) => {
+ if (e.touches && e.touches.length === 1) {
+ try {
+ const clientX = e.touches[0].clientX;
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchMove) {
+ saturn.handleTouchMove(clientX);
+ }
+ } catch {
+ /* ignore */
+ }
+ }
+ };
+
+ const handleSaturnTouchEnd = () => {
+ try {
+ const saturn = (window as any).getSaturnEffect?.();
+ if (saturn?.handleTouchEnd) {
+ saturn.handleTouchEnd();
+ }
+ } catch {
+ /* ignore */
+ }
+ };
+
+ return (
+ <div
+ className="relative z-10 h-full overflow-y-auto custom-scrollbar scroll-smooth"
+ style={{
+ overflow: releasesStore.isLoading ? "hidden" : "auto",
+ }}
+ >
+ {/* Hero Section (Full Height) - Interactive area */}
+ <div
+ role="tab"
+ className="min-h-full flex flex-col justify-end p-12 pb-32 cursor-grab active:cursor-grabbing select-none"
+ onMouseDown={handleSaturnMouseDown}
+ onMouseMove={handleMouseMove}
+ onMouseUp={handleSaturnMouseUp}
+ onMouseLeave={handleSaturnMouseLeave}
+ onTouchStart={handleSaturnTouchStart}
+ onTouchMove={handleSaturnTouchMove}
+ onTouchEnd={handleSaturnTouchEnd}
+ tabIndex={0}
+ >
+ {/* 3D Floating Hero Text */}
+ <div
+ className="transition-transform duration-200 ease-out origin-bottom-left"
+ style={{
+ transform: `perspective(1000px) rotateX(${mouseY * -1}deg) rotateY(${mouseX * 1}deg)`,
+ }}
+ >
+ <div className="flex items-center gap-3 mb-6">
+ <div className="h-px w-12 bg-white/50"></div>
+ <span className="text-xs font-mono font-bold tracking-[0.2em] text-white/50 uppercase">
+ Launcher Active
+ </span>
+ </div>
+
+ <h1 className="text-8xl font-black tracking-tighter text-white mb-6 leading-none">
+ MINECRAFT
+ </h1>
+
+ <div className="flex items-center gap-4">
+ <div className="bg-white/10 backdrop-blur-md border border-white/10 px-3 py-1 rounded-sm text-xs font-bold uppercase tracking-widest text-white shadow-sm">
+ Java Edition
+ </div>
+ <div className="h-4 w-px bg-white/20"></div>
+ <div className="text-sm text-zinc-400">
+ Latest Release{" "}
+ <span className="text-white font-medium">
+ {gameStore.latestRelease?.id || "..."}
+ </span>
+ </div>
+ </div>
+ </div>
+
+ {/* Action Area */}
+ <div className="mt-8 flex gap-4">
+ <div className="text-zinc-500 text-sm font-mono">
+ &gt; Ready to launch session.
+ </div>
+ </div>
+
+ <BottomBar />
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/index.tsx b/packages/ui/src/pages/index.tsx
new file mode 100644
index 0000000..093ccb2
--- /dev/null
+++ b/packages/ui/src/pages/index.tsx
@@ -0,0 +1,79 @@
+import { useEffect } from "react";
+import { Outlet, useLocation } from "react-router";
+import { ParticleBackground } from "@/components/particle-background";
+import { Sidebar } from "@/components/sidebar";
+import { useAuthStore } from "@/models/auth";
+import { useInstanceStore } from "@/models/instance";
+import { useSettingsStore } from "@/models/settings";
+
+export function IndexPage() {
+ const authStore = useAuthStore();
+ const settingsStore = useSettingsStore();
+ const instanceStore = useInstanceStore();
+
+ const location = useLocation();
+
+ useEffect(() => {
+ authStore.init();
+ settingsStore.refresh();
+ instanceStore.refresh();
+ }, [authStore.init, settingsStore.refresh, instanceStore.refresh]);
+
+ return (
+ <div className="relative h-screen w-full overflow-hidden bg-background font-sans">
+ <div className="absolute inset-0 z-0 bg-gray-100 dark:bg-[#09090b] overflow-hidden">
+ {settingsStore.config?.customBackgroundPath && (
+ <>
+ <img
+ src={settingsStore.config?.customBackgroundPath}
+ alt="Background"
+ className="absolute inset-0 w-full h-full object-cover transition-transform duration-[20s] ease-linear"
+ onError={(e) =>
+ console.error("Failed to load main background:", e)
+ }
+ />
+ {/* Dimming Overlay for readability */}
+ <div className="absolute inset-0 bg-black/50" />
+ </>
+ )}
+
+ {!settingsStore.config?.customBackgroundPath && (
+ <>
+ {settingsStore.theme === "dark" ? (
+ <div className="absolute inset-0 opacity-60 bg-linear-to-br from-emerald-900 via-zinc-900 to-indigo-950"></div>
+ ) : (
+ <div className="absolute inset-0 opacity-100 bg-linear-to-br from-emerald-100 via-gray-100 to-indigo-100"></div>
+ )}
+
+ {location.pathname === "/" && <ParticleBackground />}
+
+ <div className="absolute inset-0 bg-linear-to-t from-zinc-900 via-transparent to-black/50 dark:from-zinc-900 dark:to-black/50"></div>
+ </>
+ )}
+
+ {/* Subtle Grid Overlay */}
+ <div
+ className="absolute inset-0 z-0 dark:opacity-10 opacity-30 pointer-events-none"
+ style={{
+ backgroundImage: `linear-gradient(${
+ settingsStore.config?.theme === "dark" ? "#ffffff" : "#000000"
+ } 1px, transparent 1px), linear-gradient(90deg, ${
+ settingsStore.config?.theme === "dark" ? "#ffffff" : "#000000"
+ } 1px, transparent 1px)`,
+ backgroundSize: "40px 40px",
+ maskImage:
+ "radial-gradient(circle at 50% 50%, black 30%, transparent 70%)",
+ }}
+ />
+ </div>
+
+ <div className="size-full flex flex-row p-4 space-x-4 z-20 relative">
+ <Sidebar />
+
+ <main className="size-full overflow-hidden">
+ <Outlet />
+ </main>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/instances-view.tsx b/packages/ui/src/pages/instances-view.tsx
new file mode 100644
index 0000000..1634905
--- /dev/null
+++ b/packages/ui/src/pages/instances-view.tsx
@@ -0,0 +1,315 @@
+import { Copy, Edit2, Plus, Trash2 } from "lucide-react";
+import { useEffect, useState } from "react";
+import InstanceCreationModal from "@/components/instance-creation-modal";
+import InstanceEditorModal from "@/components/instance-editor-modal";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { toNumber } from "@/lib/tsrs-utils";
+import { useInstanceStore } from "@/models/instance";
+import type { Instance } from "../types/bindings/instance";
+
+export function InstancesView() {
+ const instancesStore = useInstanceStore();
+
+ // Modal / UI state
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [showDuplicateModal, setShowDuplicateModal] = useState(false);
+
+ // Selected / editing instance state
+ const [selectedInstance, setSelectedInstance] = useState<Instance | null>(
+ null,
+ );
+ const [editingInstance, setEditingInstance] = useState<Instance | null>(null);
+
+ // Form fields
+ const [duplicateName, setDuplicateName] = useState("");
+
+ useEffect(() => {
+ instancesStore.refresh();
+ }, [instancesStore.refresh]);
+
+ // Handlers to open modals
+ const openCreate = () => {
+ setShowCreateModal(true);
+ };
+
+ const openEdit = (instance: Instance) => {
+ setEditingInstance({ ...instance });
+ setShowEditModal(true);
+ };
+
+ const openDelete = (instance: Instance) => {
+ setSelectedInstance(instance);
+ setShowDeleteConfirm(true);
+ };
+
+ const openDuplicate = (instance: Instance) => {
+ setSelectedInstance(instance);
+ setDuplicateName(`${instance.name} (Copy)`);
+ setShowDuplicateModal(true);
+ };
+
+ const confirmDelete = async () => {
+ if (!selectedInstance) return;
+ await instancesStore.delete(selectedInstance.id);
+ setSelectedInstance(null);
+ setShowDeleteConfirm(false);
+ };
+
+ const confirmDuplicate = async () => {
+ if (!selectedInstance) return;
+ const name = duplicateName.trim();
+ if (!name) return;
+ await instancesStore.duplicate(selectedInstance.id, name);
+ setSelectedInstance(null);
+ setDuplicateName("");
+ setShowDuplicateModal(false);
+ };
+
+ const formatDate = (timestamp: number): string =>
+ new Date(timestamp * 1000).toLocaleDateString();
+
+ const formatLastPlayed = (timestamp: number): string => {
+ const date = new Date(timestamp * 1000);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days === 0) return "Today";
+ if (days === 1) return "Yesterday";
+ if (days < 7) return `${days} days ago`;
+ return date.toLocaleDateString();
+ };
+
+ return (
+ <div className="h-full flex flex-col gap-4 p-6 overflow-y-auto">
+ <div className="flex items-center justify-between">
+ <h1 className="text-2xl font-bold text-gray-900 dark:text-white">
+ Instances
+ </h1>
+ <Button
+ type="button"
+ onClick={openCreate}
+ className="px-4 py-2 transition-colors"
+ >
+ <Plus size={18} />
+ Create Instance
+ </Button>
+ </div>
+
+ {instancesStore.instances.length === 0 ? (
+ <div className="flex-1 flex items-center justify-center">
+ <div className="text-center text-gray-500 dark:text-gray-400">
+ <p className="text-lg mb-2">No instances yet</p>
+ <p className="text-sm">Create your first instance to get started</p>
+ </div>
+ </div>
+ ) : (
+ <ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ {instancesStore.instances.map((instance) => {
+ const isActive = instancesStore.activeInstance?.id === instance.id;
+
+ return (
+ <li
+ key={instance.id}
+ onClick={() => instancesStore.setActiveInstance(instance)}
+ onKeyDown={(e) =>
+ e.key === "Enter" &&
+ instancesStore.setActiveInstance(instance)
+ }
+ className={`relative p-4 text-left border-2 transition-all cursor-pointer hover:border-blue-500 ${
+ isActive ? "border-blue-500" : "border-transparent"
+ } bg-gray-100 dark:bg-gray-800`}
+ >
+ {/* Instance Icon */}
+ {instance.iconPath ? (
+ <div className="w-12 h-12 mb-3 rounded overflow-hidden">
+ <img
+ src={instance.iconPath}
+ alt={instance.name}
+ className="w-full h-full object-cover"
+ />
+ </div>
+ ) : (
+ <div className="w-12 h-12 mb-3 rounded bg-linear-to-br from-blue-500 to-purple-600 flex items-center justify-center">
+ <span className="text-white font-bold text-lg">
+ {instance.name.charAt(0).toUpperCase()}
+ </span>
+ </div>
+ )}
+
+ <h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
+ {instance.name}
+ </h3>
+
+ <div className="space-y-1 text-sm text-gray-600 dark:text-gray-400">
+ {instance.versionId ? (
+ <p className="truncate">Version: {instance.versionId}</p>
+ ) : (
+ <p className="text-gray-400">No version selected</p>
+ )}
+
+ {instance.modLoader && (
+ <p className="truncate">
+ Mod Loader:{" "}
+ <span className="capitalize">{instance.modLoader}</span>
+ </p>
+ )}
+
+ <p className="truncate">
+ Created: {formatDate(toNumber(instance.createdAt))}
+ </p>
+
+ {instance.lastPlayed && (
+ <p className="truncate">
+ Last played:{" "}
+ {formatLastPlayed(toNumber(instance.lastPlayed))}
+ </p>
+ )}
+ </div>
+
+ {/* Action Buttons */}
+ <div className="mt-4 flex gap-2">
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openEdit(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-sm transition-colors"
+ >
+ <Edit2 size={14} />
+ Edit
+ </button>
+
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openDuplicate(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded text-sm transition-colors"
+ >
+ <Copy size={14} />
+ Duplicate
+ </button>
+
+ <button
+ type="button"
+ onClick={(e) => {
+ e.stopPropagation();
+ openDelete(instance);
+ }}
+ className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded text-sm transition-colors"
+ >
+ <Trash2 size={14} />
+ Delete
+ </button>
+ </div>
+ </li>
+ );
+ })}
+ </ul>
+ )}
+
+ <InstanceCreationModal
+ open={showCreateModal}
+ onOpenChange={setShowCreateModal}
+ />
+
+ <InstanceEditorModal
+ open={showEditModal}
+ instance={editingInstance}
+ onOpenChange={(open) => {
+ setShowEditModal(open);
+ if (!open) setEditingInstance(null);
+ }}
+ />
+
+ {/* Delete Confirmation */}
+ <Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Instance</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete "{selectedInstance?.name}"? This
+ action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowDeleteConfirm(false);
+ setSelectedInstance(null);
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmDelete}
+ className="bg-red-600 text-white hover:bg-red-500"
+ >
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Duplicate Modal */}
+ <Dialog open={showDuplicateModal} onOpenChange={setShowDuplicateModal}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Duplicate Instance</DialogTitle>
+ <DialogDescription>
+ Provide a name for the duplicated instance.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="mt-4">
+ <Input
+ value={duplicateName}
+ onChange={(e) => setDuplicateName(e.target.value)}
+ placeholder="New instance name"
+ onKeyDown={(e) => e.key === "Enter" && confirmDuplicate()}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ setShowDuplicateModal(false);
+ setSelectedInstance(null);
+ setDuplicateName("");
+ }}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="button"
+ onClick={confirmDuplicate}
+ disabled={!duplicateName.trim()}
+ >
+ Duplicate
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/settings-view.tsx.bk b/packages/ui/src/pages/settings-view.tsx.bk
new file mode 100644
index 0000000..ac43d9b
--- /dev/null
+++ b/packages/ui/src/pages/settings-view.tsx.bk
@@ -0,0 +1,1158 @@
+import { open } from "@tauri-apps/plugin-dialog";
+import {
+ Coffee,
+ Download,
+ FileJson,
+ Loader2,
+ RefreshCw,
+ Upload,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Separator } from "@/components/ui/separator";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Textarea } from "@/components/ui/textarea";
+import { useSettingsStore } from "../stores/settings-store";
+
+const effectOptions = [
+ { value: "saturn", label: "Saturn" },
+ { value: "constellation", label: "Network (Constellation)" },
+];
+
+const logServiceOptions = [
+ { value: "paste.rs", label: "paste.rs (Free, No Account)" },
+ { value: "pastebin.com", label: "pastebin.com (Requires API Key)" },
+];
+
+const llmProviderOptions = [
+ { value: "ollama", label: "Ollama (Local)" },
+ { value: "openai", label: "OpenAI (Remote)" },
+];
+
+const languageOptions = [
+ { value: "auto", label: "Auto (Match User)" },
+ { value: "English", label: "English" },
+ { value: "Chinese", label: "中文" },
+ { value: "Japanese", label: "日本語" },
+ { value: "Korean", label: "한국어" },
+ { value: "Spanish", label: "Español" },
+ { value: "French", label: "Français" },
+ { value: "German", label: "Deutsch" },
+ { value: "Russian", label: "РуÑÑкий" },
+];
+
+const ttsProviderOptions = [
+ { value: "disabled", label: "Disabled" },
+ { value: "piper", label: "Piper TTS (Local)" },
+ { value: "edge", label: "Edge TTS (Online)" },
+];
+
+const personas = [
+ {
+ value: "default",
+ label: "Minecraft Expert (Default)",
+ prompt:
+ "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.",
+ },
+ {
+ value: "technical",
+ label: "Technical Debugger",
+ prompt:
+ "You are a technical support specialist for Minecraft. Focus strictly on analyzing logs, identifying crash causes, and providing technical solutions. Be precise and avoid conversational filler.",
+ },
+ {
+ value: "concise",
+ label: "Concise Helper",
+ prompt:
+ "You are a direct and concise assistant. Provide answers in as few words as possible while remaining accurate. Use bullet points for lists.",
+ },
+ {
+ value: "explain",
+ label: "Teacher / Explainer",
+ prompt:
+ "You are a patient teacher. Explain Minecraft concepts, redstone mechanics, and mod features in simple, easy-to-understand terms suitable for beginners.",
+ },
+ {
+ value: "pirate",
+ label: "Pirate Captain",
+ prompt:
+ "You are a salty Minecraft Pirate Captain! Yarr! Speak like a pirate while helping the crew (the user) with their blocky adventures. Use terms like 'matey', 'landlubber', and 'treasure'.",
+ },
+];
+
+export function SettingsView() {
+ const {
+ settings,
+ backgroundUrl,
+ javaInstallations,
+ isDetectingJava,
+ showJavaDownloadModal,
+ selectedDownloadSource,
+ javaCatalog,
+ isLoadingCatalog,
+ catalogError,
+ selectedMajorVersion,
+ selectedImageType,
+ showOnlyRecommended,
+ searchQuery,
+ isDownloadingJava,
+ downloadProgress,
+ javaDownloadStatus,
+ pendingDownloads,
+ ollamaModels,
+ openaiModels,
+ isLoadingOllamaModels,
+ isLoadingOpenaiModels,
+ ollamaModelsError,
+ openaiModelsError,
+ showConfigEditor,
+ rawConfigContent,
+ configFilePath,
+ configEditorError,
+ filteredReleases,
+ availableMajorVersions,
+ installStatus,
+ selectedRelease,
+ currentModelOptions,
+ loadSettings,
+ saveSettings,
+ detectJava,
+ selectJava,
+ openJavaDownloadModal,
+ closeJavaDownloadModal,
+ loadJavaCatalog,
+ refreshCatalog,
+ loadPendingDownloads,
+ selectMajorVersion,
+ downloadJava,
+ cancelDownload,
+ resumeDownloads,
+ openConfigEditor,
+ closeConfigEditor,
+ saveRawConfig,
+ loadOllamaModels,
+ loadOpenaiModels,
+ set,
+ setSetting,
+ setAssistantSetting,
+ setFeatureFlag,
+ } = useSettingsStore();
+
+ // Mark potentially-unused variables as referenced so TypeScript does not report
+ // them as unused in this file (they are part of the store API and used elsewhere).
+ // This is a no-op but satisfies the compiler.
+ void selectedDownloadSource;
+ void javaCatalog;
+ void javaDownloadStatus;
+ void pendingDownloads;
+ void ollamaModels;
+ void openaiModels;
+ void isLoadingOllamaModels;
+ void isLoadingOpenaiModels;
+ void ollamaModelsError;
+ void openaiModelsError;
+ void selectedRelease;
+ void loadJavaCatalog;
+ void loadPendingDownloads;
+ void cancelDownload;
+ void resumeDownloads;
+ void setFeatureFlag;
+ const [selectedPersona, setSelectedPersona] = useState("default");
+ const [migrating, setMigrating] = useState(false);
+ const [activeTab, setActiveTab] = useState("appearance");
+
+ useEffect(() => {
+ loadSettings();
+ detectJava();
+ }, [loadSettings, detectJava]);
+
+ useEffect(() => {
+ if (activeTab === "assistant") {
+ if (settings.assistant.llmProvider === "ollama") {
+ loadOllamaModels();
+ } else if (settings.assistant.llmProvider === "openai") {
+ loadOpenaiModels();
+ }
+ }
+ }, [
+ activeTab,
+ settings.assistant.llmProvider,
+ loadOllamaModels,
+ loadOpenaiModels,
+ ]);
+
+ const handleSelectBackground = async () => {
+ try {
+ const selected = await open({
+ multiple: false,
+ filters: [
+ {
+ name: "Images",
+ extensions: ["png", "jpg", "jpeg", "webp", "gif"],
+ },
+ ],
+ });
+
+ if (selected && typeof selected === "string") {
+ setSetting("customBackgroundPath", selected);
+ saveSettings();
+ }
+ } catch (e) {
+ console.error("Failed to select background:", e);
+ toast.error("Failed to select background");
+ }
+ };
+
+ const handleClearBackground = () => {
+ setSetting("customBackgroundPath", null);
+ saveSettings();
+ };
+
+ const handleApplyPersona = (value: string) => {
+ const persona = personas.find((p) => p.value === value);
+ if (persona) {
+ setAssistantSetting("systemPrompt", persona.prompt);
+ setSelectedPersona(value);
+ saveSettings();
+ }
+ };
+
+ const handleResetSystemPrompt = () => {
+ const defaultPersona = personas.find((p) => p.value === "default");
+ if (defaultPersona) {
+ setAssistantSetting("systemPrompt", defaultPersona.prompt);
+ setSelectedPersona("default");
+ saveSettings();
+ }
+ };
+
+ const handleRunMigration = async () => {
+ if (migrating) return;
+ setMigrating(true);
+ try {
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ toast.success("Migration complete! Files migrated successfully");
+ } catch (e) {
+ console.error("Migration failed:", e);
+ toast.error(`Migration failed: ${e}`);
+ } finally {
+ setMigrating(false);
+ }
+ };
+
+ return (
+ <div className="h-full flex flex-col p-6 overflow-hidden">
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">
+ Settings
+ </h2>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={openConfigEditor}
+ className="gap-2"
+ >
+ <FileJson className="h-4 w-4" />
+ <span className="hidden sm:inline">Open JSON</span>
+ </Button>
+ </div>
+
+ <Tabs
+ value={activeTab}
+ onValueChange={setActiveTab}
+ className="flex-1 overflow-hidden"
+ >
+ <TabsList className="grid grid-cols-4 mb-6">
+ <TabsTrigger value="appearance">Appearance</TabsTrigger>
+ <TabsTrigger value="java">Java</TabsTrigger>
+ <TabsTrigger value="advanced">Advanced</TabsTrigger>
+ <TabsTrigger value="assistant">Assistant</TabsTrigger>
+ </TabsList>
+
+ <ScrollArea className="flex-1 pr-2">
+ <TabsContent value="appearance" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Appearance</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-3">Custom Background Image</Label>
+ <div className="flex items-center gap-6">
+ <div className="w-40 h-24 rounded-xl overflow-hidden bg-secondary border relative group shadow-lg">
+ {backgroundUrl ? (
+ <img
+ src={backgroundUrl}
+ alt="Background Preview"
+ className="w-full h-full object-cover"
+ onError={(e) => {
+ console.error("Failed to load image");
+ e.currentTarget.style.display = "none";
+ }}
+ />
+ ) : (
+ <div className="w-full h-full bg-linear-to-br from-emerald-900 via-zinc-900 to-indigo-950" />
+ )}
+ {!backgroundUrl && (
+ <div className="absolute inset-0 flex items-center justify-center text-xs text-white/50 bg-black/20">
+ Default Gradient
+ </div>
+ )}
+ </div>
+
+ <div className="flex flex-col gap-2">
+ <Button
+ variant="outline"
+ onClick={handleSelectBackground}
+ >
+ Select Image
+ </Button>
+ {backgroundUrl && (
+ <Button
+ variant="ghost"
+ className="text-red-500"
+ onClick={handleClearBackground}
+ >
+ Reset to Default
+ </Button>
+ )}
+ </div>
+ </div>
+ <p className="text-sm text-muted-foreground mt-3">
+ Select an image from your computer to replace the default
+ gradient background.
+ </p>
+ </div>
+
+ <Separator />
+
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Visual Effects</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable particle effects and animated gradients.
+ </p>
+ </div>
+ <Switch
+ checked={settings.enableVisualEffects}
+ onCheckedChange={(checked) => {
+ setSetting("enableVisualEffects", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {settings.enableVisualEffects && (
+ <div className="pl-4 border-l-2 border-border">
+ <div className="space-y-2">
+ <Label>Theme Effect</Label>
+ <Select
+ value={settings.activeEffect}
+ onValueChange={(value) => {
+ setSetting("activeEffect", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger className="w-52">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {effectOptions.map((option) => (
+ <SelectItem
+ key={option.value}
+ value={option.value}
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <p className="text-sm text-muted-foreground">
+ Select the active visual theme.
+ </p>
+ </div>
+ </div>
+ )}
+
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">GPU Acceleration</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable GPU acceleration for the interface.
+ </p>
+ </div>
+ <Switch
+ checked={settings.enableGpuAcceleration}
+ onCheckedChange={(checked) => {
+ setSetting("enableGpuAcceleration", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="java" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Java Environment</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-2">Java Path</Label>
+ <div className="flex gap-2">
+ <Input
+ value={settings.javaPath}
+ onChange={(e) => setSetting("javaPath", e.target.value)}
+ className="flex-1"
+ placeholder="java or full path to java executable"
+ />
+ <Button
+ variant="outline"
+ onClick={() => detectJava()}
+ disabled={isDetectingJava}
+ >
+ {isDetectingJava ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ "Detect"
+ )}
+ </Button>
+ </div>
+ <p className="text-sm text-muted-foreground mt-2">
+ Path to Java executable.
+ </p>
+ </div>
+
+ <div>
+ <Label className="mb-2">Memory Settings (MB)</Label>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <Label htmlFor="min-memory" className="text-sm">
+ Minimum Memory
+ </Label>
+ <Input
+ id="min-memory"
+ type="number"
+ value={settings.minMemory}
+ onChange={(e) =>
+ setSetting(
+ "minMemory",
+ parseInt(e.target.value, 10) || 1024,
+ )
+ }
+ min={512}
+ step={256}
+ />
+ </div>
+ <div>
+ <Label htmlFor="max-memory" className="text-sm">
+ Maximum Memory
+ </Label>
+ <Input
+ id="max-memory"
+ type="number"
+ value={settings.maxMemory}
+ onChange={(e) =>
+ setSetting(
+ "maxMemory",
+ parseInt(e.target.value, 10) || 2048,
+ )
+ }
+ min={1024}
+ step={256}
+ />
+ </div>
+ </div>
+ <p className="text-sm text-muted-foreground mt-2">
+ Memory allocation for Minecraft.
+ </p>
+ </div>
+
+ <Separator />
+
+ <div>
+ <div className="flex items-center justify-between mb-4">
+ <Label className="text-base">
+ Detected Java Installations
+ </Label>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => detectJava()}
+ disabled={isDetectingJava}
+ >
+ <RefreshCw
+ className={`h-4 w-4 mr-2 ${isDetectingJava ? "animate-spin" : ""}`}
+ />
+ Rescan
+ </Button>
+ </div>
+
+ {javaInstallations.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground border rounded-lg">
+ <Coffee className="h-12 w-12 mx-auto mb-4 opacity-30" />
+ <p>No Java installations detected</p>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {javaInstallations.map((installation) => (
+ <Card
+ key={installation.path}
+ className={`p-3 cursor-pointer transition-colors ${
+ settings.javaPath === installation.path
+ ? "border-primary bg-primary/5"
+ : ""
+ }`}
+ onClick={() => selectJava(installation.path)}
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-medium flex items-center gap-2">
+ <Coffee className="h-4 w-4" />
+ {installation.version}
+ </div>
+ <div className="text-sm text-muted-foreground font-mono">
+ {installation.path}
+ </div>
+ </div>
+ {settings.javaPath === installation.path && (
+ <div className="h-5 w-5 text-primary">✓</div>
+ )}
+ </div>
+ </Card>
+ ))}
+ </div>
+ )}
+
+ <div className="mt-4">
+ <Button
+ variant="default"
+ className="w-full"
+ onClick={openJavaDownloadModal}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ Download Java
+ </Button>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="advanced" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">Advanced Settings</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div>
+ <Label className="mb-2">Download Threads</Label>
+ <Input
+ type="number"
+ value={settings.downloadThreads}
+ onChange={(e) =>
+ setSetting(
+ "downloadThreads",
+ parseInt(e.target.value, 10) || 32,
+ )
+ }
+ min={1}
+ max={64}
+ />
+ <p className="text-sm text-muted-foreground mt-2">
+ Number of concurrent downloads.
+ </p>
+ </div>
+
+ <div>
+ <Label className="mb-2">Log Upload Service</Label>
+ <Select
+ value={settings.logUploadService}
+ onValueChange={(value) => {
+ setSetting("logUploadService", value as any);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {logServiceOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {settings.logUploadService === "pastebin.com" && (
+ <div>
+ <Label className="mb-2">Pastebin API Key</Label>
+ <Input
+ type="password"
+ value={settings.pastebinApiKey || ""}
+ onChange={(e) =>
+ setSetting("pastebinApiKey", e.target.value || null)
+ }
+ placeholder="Enter your Pastebin API key"
+ />
+ </div>
+ )}
+
+ <Separator />
+
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Use Shared Caches</Label>
+ <p className="text-sm text-muted-foreground">
+ Share downloaded assets between instances.
+ </p>
+ </div>
+ <Switch
+ checked={settings.useSharedCaches}
+ onCheckedChange={(checked) => {
+ setSetting("useSharedCaches", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {!settings.useSharedCaches && (
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">
+ Keep Legacy Per-Instance Storage
+ </Label>
+ <p className="text-sm text-muted-foreground">
+ Maintain separate cache folders for compatibility.
+ </p>
+ </div>
+ <Switch
+ checked={settings.keepLegacyPerInstanceStorage}
+ onCheckedChange={(checked) => {
+ setSetting("keepLegacyPerInstanceStorage", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+ )}
+
+ {settings.useSharedCaches && (
+ <div className="mt-4">
+ <Button
+ variant="outline"
+ className="w-full"
+ onClick={handleRunMigration}
+ disabled={migrating}
+ >
+ {migrating ? (
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ ) : (
+ <Upload className="h-4 w-4 mr-2" />
+ )}
+ {migrating
+ ? "Migrating..."
+ : "Migrate to Shared Caches"}
+ </Button>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ <TabsContent value="assistant" className="space-y-6">
+ <Card className="border-border">
+ <CardHeader>
+ <CardTitle className="text-lg">AI Assistant</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="flex items-center justify-between">
+ <div>
+ <Label className="text-base">Enable Assistant</Label>
+ <p className="text-sm text-muted-foreground">
+ Enable the AI assistant for help with Minecraft issues.
+ </p>
+ </div>
+ <Switch
+ checked={settings.assistant.enabled}
+ onCheckedChange={(checked) => {
+ setAssistantSetting("enabled", checked);
+ saveSettings();
+ }}
+ />
+ </div>
+
+ {settings.assistant.enabled && (
+ <>
+ <div>
+ <Label className="mb-2">LLM Provider</Label>
+ <Select
+ value={settings.assistant.llmProvider}
+ onValueChange={(value) => {
+ setAssistantSetting("llmProvider", value as any);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {llmProviderOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Model</Label>
+ <Select
+ value={
+ settings.assistant.llmProvider === "ollama"
+ ? settings.assistant.ollamaModel
+ : settings.assistant.openaiModel
+ }
+ onValueChange={(value) => {
+ if (settings.assistant.llmProvider === "ollama") {
+ setAssistantSetting("ollamaModel", value);
+ } else {
+ setAssistantSetting("openaiModel", value);
+ }
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {currentModelOptions.map((model) => (
+ <SelectItem key={model.value} value={model.value}>
+ {model.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {settings.assistant.llmProvider === "ollama" && (
+ <div>
+ <Label className="mb-2">Ollama Endpoint</Label>
+ <Input
+ value={settings.assistant.ollamaEndpoint}
+ onChange={(e) => {
+ setAssistantSetting(
+ "ollamaEndpoint",
+ e.target.value,
+ );
+ saveSettings();
+ }}
+ placeholder="http://localhost:11434"
+ />
+ </div>
+ )}
+
+ {settings.assistant.llmProvider === "openai" && (
+ <>
+ <div>
+ <Label className="mb-2">OpenAI API Key</Label>
+ <Input
+ type="password"
+ value={settings.assistant.openaiApiKey || ""}
+ onChange={(e) => {
+ setAssistantSetting(
+ "openaiApiKey",
+ e.target.value || null,
+ );
+ saveSettings();
+ }}
+ placeholder="Enter your OpenAI API key"
+ />
+ </div>
+ <div>
+ <Label className="mb-2">OpenAI Endpoint</Label>
+ <Input
+ value={settings.assistant.openaiEndpoint}
+ onChange={(e) => {
+ setAssistantSetting(
+ "openaiEndpoint",
+ e.target.value,
+ );
+ saveSettings();
+ }}
+ placeholder="https://api.openai.com/v1"
+ />
+ </div>
+ </>
+ )}
+
+ <div>
+ <Label className="mb-2">Response Language</Label>
+ <Select
+ value={settings.assistant.responseLanguage}
+ onValueChange={(value) => {
+ setAssistantSetting("responseLanguage", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {languageOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Assistant Persona</Label>
+ <Select
+ value={selectedPersona}
+ onValueChange={handleApplyPersona}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {personas.map((persona) => (
+ <SelectItem
+ key={persona.value}
+ value={persona.value}
+ >
+ {persona.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <div className="mt-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleResetSystemPrompt}
+ >
+ Reset to Default
+ </Button>
+ </div>
+ </div>
+
+ <div>
+ <Label className="mb-2">System Prompt</Label>
+
+ <Textarea
+ value={settings.assistant.systemPrompt}
+ onChange={(e) => {
+ setAssistantSetting("systemPrompt", e.target.value);
+ saveSettings();
+ }}
+ rows={6}
+ className="font-mono text-sm"
+ />
+ </div>
+
+ <div>
+ <Label className="mb-2">Text-to-Speech</Label>
+
+ <Select
+ value={settings.assistant.ttsProvider}
+ onValueChange={(value) => {
+ setAssistantSetting("ttsProvider", value);
+ saveSettings();
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+
+ <SelectContent>
+ {ttsProviderOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </ScrollArea>
+ </Tabs>
+
+ {/* Java Download Modal */}
+ <Dialog
+ open={showJavaDownloadModal}
+ onOpenChange={closeJavaDownloadModal}
+ >
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Download Java</DialogTitle>
+ <DialogDescription>
+ Download and install Java for Minecraft.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ <div className="space-y-4">
+ <div>
+ <Label className="mb-2">Java Version</Label>
+ <Select
+ value={selectedMajorVersion?.toString() || ""}
+ onValueChange={(v) => selectMajorVersion(parseInt(v, 10))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select version" />
+ </SelectTrigger>
+ <SelectContent>
+ {availableMajorVersions.map((version) => (
+ <SelectItem key={version} value={version.toString()}>
+ Java {version}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label className="mb-2">Type</Label>
+ <Select
+ value={selectedImageType}
+ onValueChange={(v) => set({ selectedImageType: v as any })}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="jre">JRE (Runtime)</SelectItem>
+ <SelectItem value="jdk">JDK (Development)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="recommended"
+ checked={showOnlyRecommended}
+ onCheckedChange={(checked) =>
+ set({ showOnlyRecommended: !!checked })
+ }
+ />
+ <Label htmlFor="recommended">Show only LTS/Recommended</Label>
+ </div>
+
+ <div>
+ <Label className="mb-2">Search</Label>
+ <Input
+ placeholder="Search versions..."
+ value={searchQuery}
+ onChange={(e) => set({ searchQuery: e.target.value })}
+ />
+ </div>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={refreshCatalog}
+ disabled={isLoadingCatalog}
+ >
+ <RefreshCw
+ className={`h-4 w-4 mr-2 ${isLoadingCatalog ? "animate-spin" : ""}`}
+ />
+ Refresh Catalog
+ </Button>
+ </div>
+
+ <div className="md:col-span-2">
+ <ScrollArea className="h-75 pr-4">
+ {isLoadingCatalog ? (
+ <div className="flex items-center justify-center h-full">
+ <Loader2 className="h-8 w-8 animate-spin" />
+ </div>
+ ) : catalogError ? (
+ <div className="text-red-500 p-4">{catalogError}</div>
+ ) : filteredReleases.length === 0 ? (
+ <div className="text-muted-foreground p-4 text-center">
+ No Java versions found
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredReleases.map((release) => {
+ const status = installStatus(
+ release.majorVersion,
+ release.imageType,
+ );
+ return (
+ <Card
+ key={`${release.majorVersion}-${release.imageType}`}
+ className="p-3 cursor-pointer hover:bg-accent"
+ onClick={() =>
+ selectMajorVersion(release.majorVersion)
+ }
+ >
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="font-medium">
+ Java {release.majorVersion}{" "}
+ {release.imageType.toUpperCase()}
+ </div>
+ <div className="text-sm text-muted-foreground">
+ {release.releaseName} • {release.architecture}{" "}
+ {release.architecture}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {release.isLts && (
+ <Badge variant="secondary">LTS</Badge>
+ )}
+ {status === "installed" && (
+ <Badge variant="default">Installed</Badge>
+ )}
+ {status === "available" && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={(e) => {
+ e.stopPropagation();
+ selectMajorVersion(release.majorVersion);
+ downloadJava();
+ }}
+ >
+ <Download className="h-3 w-3 mr-1" />
+ Download
+ </Button>
+ )}
+ </div>
+ </div>
+ </Card>
+ );
+ })}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+ </div>
+
+ {isDownloadingJava && downloadProgress && (
+ <div className="mt-4 p-4 border rounded-lg">
+ <div className="flex justify-between items-center mb-2">
+ <span className="text-sm font-medium">
+ {downloadProgress.fileName}
+ </span>
+ <span className="text-sm text-muted-foreground">
+ {Math.round(downloadProgress.percentage)}%
+ </span>
+ </div>
+ <div className="w-full bg-secondary h-2 rounded-full overflow-hidden">
+ <div
+ className="bg-primary h-full transition-all duration-300"
+ style={{ width: `${downloadProgress.percentage}%` }}
+ />
+ </div>
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={closeJavaDownloadModal}
+ disabled={isDownloadingJava}
+ >
+ Cancel
+ </Button>
+ {selectedMajorVersion && (
+ <Button
+ onClick={() => downloadJava()}
+ disabled={isDownloadingJava}
+ >
+ {isDownloadingJava ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Downloading...
+ </>
+ ) : (
+ <>
+ <Download className="mr-2 h-4 w-4" />
+ Download Java {selectedMajorVersion}
+ </>
+ )}
+ </Button>
+ )}
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+
+ {/* Config Editor Modal */}
+ <Dialog open={showConfigEditor} onOpenChange={closeConfigEditor}>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Edit Configuration</DialogTitle>
+ <DialogDescription>
+ Edit the raw JSON configuration file.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="text-sm text-muted-foreground mb-2">
+ File: {configFilePath}
+ </div>
+
+ {configEditorError && (
+ <div className="text-red-500 p-3 bg-red-50 dark:bg-red-950/30 rounded-md">
+ {configEditorError}
+ </div>
+ )}
+
+ <Textarea
+ value={rawConfigContent}
+ onChange={(e) => set({ rawConfigContent: e.target.value })}
+ className="font-mono text-sm h-100 resize-none"
+ spellCheck={false}
+ />
+
+ <DialogFooter>
+ <Button variant="outline" onClick={closeConfigEditor}>
+ Cancel
+ </Button>
+ <Button onClick={() => saveRawConfig()}>Save Changes</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/settings.tsx b/packages/ui/src/pages/settings.tsx
new file mode 100644
index 0000000..440a5dc
--- /dev/null
+++ b/packages/ui/src/pages/settings.tsx
@@ -0,0 +1,310 @@
+import { toNumber } from "es-toolkit/compat";
+import { FileJsonIcon } from "lucide-react";
+import { useEffect, useState } from "react";
+import { migrateSharedCaches } from "@/client";
+import { ConfigEditor } from "@/components/config-editor";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+ FieldLegend,
+ FieldSet,
+} from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
+import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useSettingsStore } from "@/models/settings";
+
+export type SettingsTab = "general" | "appearance" | "advanced";
+
+export function SettingsPage() {
+ const { config, ...settings } = useSettingsStore();
+ const [showConfigEditor, setShowConfigEditor] = useState<boolean>(false);
+ const [activeTab, setActiveTab] = useState<SettingsTab>("general");
+
+ useEffect(() => {
+ if (!config) settings.refresh();
+ }, [config, settings.refresh]);
+
+ const renderScrollArea = () => {
+ if (!config) {
+ return (
+ <div className="size-full justify-center items-center">
+ <Spinner />
+ </div>
+ );
+ }
+ return (
+ <ScrollArea className="size-full pr-2">
+ <TabsContent value="general" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">General</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FieldGroup>
+ <FieldSet>
+ <FieldLegend>Window Options</FieldLegend>
+ <FieldDescription>
+ May not work on some platforms like Linux Niri.
+ </FieldDescription>
+ <FieldGroup>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <Field>
+ <FieldLabel htmlFor="width">
+ Window Default Width
+ </FieldLabel>
+ <Input
+ type="number"
+ name="width"
+ value={config?.width}
+ onChange={(e) => {
+ settings.merge({
+ width: toNumber(e.target.value),
+ });
+ }}
+ onBlur={() => {
+ settings.save();
+ }}
+ min={800}
+ max={3840}
+ />
+ </Field>
+ <Field>
+ <FieldLabel htmlFor="height">
+ Window Default Height
+ </FieldLabel>
+ <Input
+ type="number"
+ name="height"
+ value={config?.height}
+ onChange={(e) => {
+ settings.merge({
+ height: toNumber(e.target.value),
+ });
+ }}
+ onBlur={() => {
+ settings.save();
+ }}
+ min={600}
+ max={2160}
+ />
+ </Field>
+ </div>
+ <Field className="flex flex-row items-center justify-between">
+ <FieldContent>
+ <FieldLabel htmlFor="gpu-acceleration">
+ GPU Acceleration
+ </FieldLabel>
+ <FieldDescription>
+ Enable GPU acceleration for the interface.
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ checked={config?.enableGpuAcceleration}
+ onCheckedChange={(checked) => {
+ settings.merge({
+ enableGpuAcceleration: checked,
+ });
+ settings.save();
+ }}
+ />
+ </Field>
+ </FieldGroup>
+ </FieldSet>
+ <FieldSet>
+ <FieldLegend>Network Options</FieldLegend>
+ <Field>
+ <Label htmlFor="download-threads">Download Threads</Label>
+ <Input
+ type="number"
+ name="download-threads"
+ value={config?.downloadThreads}
+ onChange={(e) => {
+ settings.merge({
+ downloadThreads: toNumber(e.target.value),
+ });
+ }}
+ onBlur={() => {
+ settings.save();
+ }}
+ min={1}
+ max={64}
+ />
+ </Field>
+ </FieldSet>
+ </FieldGroup>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ <TabsContent value="java" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">
+ Java Installations
+ </CardTitle>
+ <CardContent></CardContent>
+ </CardHeader>
+ </Card>
+ </TabsContent>
+ <TabsContent value="appearance" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">Appearance</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FieldGroup>
+ <Field className="flex flex-row">
+ <FieldContent>
+ <FieldLabel htmlFor="theme">Theme</FieldLabel>
+ <FieldDescription>
+ Select your prefered theme.
+ </FieldDescription>
+ </FieldContent>
+ <Select
+ items={[
+ { label: "Dark", value: "dark" },
+ { label: "Light", value: "light" },
+ { label: "System", value: "system" },
+ ]}
+ value={config.theme}
+ onValueChange={async (value) => {
+ if (
+ value === "system" ||
+ value === "light" ||
+ value === "dark"
+ ) {
+ settings.merge({ theme: value });
+ await settings.save();
+ settings.applyTheme(value);
+ }
+ }}
+ >
+ <SelectTrigger className="w-full max-w-48">
+ <SelectValue placeholder="Please select a prefered theme" />
+ </SelectTrigger>
+ <SelectContent alignItemWithTrigger={false}>
+ <SelectGroup>
+ <SelectItem value="system">System</SelectItem>
+ <SelectItem value="light">Light</SelectItem>
+ <SelectItem value="dark">Dark</SelectItem>
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ </Field>
+ </FieldGroup>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ <TabsContent value="advanced" className="size-full">
+ <Card className="size-full">
+ <CardHeader>
+ <CardTitle className="font-bold text-xl">Advanced</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FieldGroup>
+ <FieldSet>
+ <FieldLegend>Advanced Options</FieldLegend>
+ <FieldGroup>
+ <Field className="flex flex-row items-center justify-between">
+ <FieldContent>
+ <FieldLabel htmlFor="use-shared-caches">
+ Use Shared Caches
+ </FieldLabel>
+ <FieldDescription>
+ Share downloaded assets between instances.
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ checked={config?.useSharedCaches}
+ onCheckedChange={async (checked) => {
+ checked && (await migrateSharedCaches());
+ settings.merge({
+ useSharedCaches: checked,
+ });
+ settings.save();
+ }}
+ />
+ </Field>
+ <Field className="flex flex-row items-center justify-between">
+ <FieldContent>
+ <FieldLabel htmlFor="keep-per-instance-storage">
+ Keep Legacy Per-Instance Storage
+ </FieldLabel>
+ <FieldDescription>
+ Maintain separate cache folders for compatibility.
+ </FieldDescription>
+ </FieldContent>
+ <Switch
+ checked={config?.keepLegacyPerInstanceStorage}
+ onCheckedChange={(checked) => {
+ settings.merge({
+ keepLegacyPerInstanceStorage: checked,
+ });
+ settings.save();
+ }}
+ />
+ </Field>
+ </FieldGroup>
+ </FieldSet>
+ </FieldGroup>
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </ScrollArea>
+ );
+ };
+
+ return (
+ <div className="size-full flex flex-col p-6 space-y-6">
+ <div className="flex items-center justify-between">
+ <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">
+ Settings
+ </h2>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setShowConfigEditor(true)}
+ >
+ <FileJsonIcon />
+ <span className="hidden sm:inline">Open JSON</span>
+ </Button>
+ </div>
+
+ <Tabs
+ value={activeTab}
+ onValueChange={setActiveTab}
+ className="size-full flex flex-col gap-6"
+ >
+ <TabsList>
+ <TabsTrigger value="general">General</TabsTrigger>
+ <TabsTrigger value="java">Java</TabsTrigger>
+ <TabsTrigger value="appearance">Appearance</TabsTrigger>
+ <TabsTrigger value="advanced">Advanced</TabsTrigger>
+ </TabsList>
+ {renderScrollArea()}
+ </Tabs>
+
+ <ConfigEditor
+ open={showConfigEditor}
+ onOpenChange={() => setShowConfigEditor(false)}
+ />
+ </div>
+ );
+}
diff --git a/packages/ui/src/pages/versions-view.tsx.bk b/packages/ui/src/pages/versions-view.tsx.bk
new file mode 100644
index 0000000..d54596d
--- /dev/null
+++ b/packages/ui/src/pages/versions-view.tsx.bk
@@ -0,0 +1,662 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { Coffee, Loader2, Search, Trash2 } from "lucide-react";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useInstancesStore } from "../models/instances";
+import { useGameStore } from "../stores/game-store";
+import type { Version } from "../types/bindings/manifest";
+
+interface InstalledModdedVersion {
+ id: string;
+ javaVersion?: number;
+}
+
+type TypeFilter = "all" | "release" | "snapshot" | "installed";
+
+export function VersionsView() {
+ const { versions, selectedVersion, loadVersions, setSelectedVersion } =
+ useGameStore();
+ const { activeInstance } = useInstancesStore();
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
+ const [installedModdedVersions, setInstalledModdedVersions] = useState<
+ InstalledModdedVersion[]
+ >([]);
+ const [, setIsLoadingModded] = useState(false);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ const [versionToDelete, setVersionToDelete] = useState<string | null>(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [selectedVersionMetadata, setSelectedVersionMetadata] = useState<{
+ id: string;
+ javaVersion?: number;
+ isInstalled: boolean;
+ } | null>(null);
+ const [isLoadingMetadata, setIsLoadingMetadata] = useState(false);
+ const [showModLoaderSelector, setShowModLoaderSelector] = useState(false);
+
+ const normalizedQuery = searchQuery.trim().toLowerCase().replace(/。/g, ".");
+
+ // Load installed modded versions with Java version info
+ const loadInstalledModdedVersions = useCallback(async () => {
+ if (!activeInstance) {
+ setInstalledModdedVersions([]);
+ setIsLoadingModded(false);
+ return;
+ }
+
+ setIsLoadingModded(true);
+ try {
+ const allInstalled = await invoke<Array<{ id: string; type: string }>>(
+ "list_installed_versions",
+ { instanceId: activeInstanceId },
+ );
+
+ const moddedIds = allInstalled
+ .filter((v) => v.type === "fabric" || v.type === "forge")
+ .map((v) => v.id);
+
+ const versionsWithJava = await Promise.all(
+ moddedIds.map(async (id) => {
+ try {
+ const javaVersion = await invoke<number | null>(
+ "get_version_java_version",
+ {
+ instanceId: activeInstanceId,
+ versionId: id,
+ },
+ );
+ return {
+ id,
+ javaVersion: javaVersion ?? undefined,
+ };
+ } catch (e) {
+ console.error(`Failed to get Java version for ${id}:`, e);
+ return { id, javaVersion: undefined };
+ }
+ }),
+ );
+
+ setInstalledModdedVersions(versionsWithJava);
+ } catch (e) {
+ console.error("Failed to load installed modded versions:", e);
+ toast.error("Error loading modded versions");
+ } finally {
+ setIsLoadingModded(false);
+ }
+ }, [activeInstanceId]);
+
+ // Combined versions list (vanilla + modded)
+ const allVersions = (() => {
+ const moddedVersions: Version[] = installedModdedVersions.map((v) => {
+ const versionType = v.id.startsWith("fabric-loader-")
+ ? "fabric"
+ : v.id.includes("-forge-")
+ ? "forge"
+ : "fabric";
+ return {
+ id: v.id,
+ type: versionType,
+ url: "",
+ time: "",
+ releaseTime: new Date().toISOString(),
+ javaVersion: BigInt(v.javaVersion ?? 0),
+ isInstalled: true,
+ };
+ });
+ return [...moddedVersions, ...versions];
+ })();
+
+ // Filter versions based on search and type filter
+ const filteredVersions = allVersions.filter((version) => {
+ if (typeFilter === "release" && version.type !== "release") return false;
+ if (typeFilter === "snapshot" && version.type !== "snapshot") return false;
+ if (typeFilter === "installed" && !version.isInstalled) return false;
+
+ if (
+ normalizedQuery &&
+ !version.id.toLowerCase().includes(normalizedQuery)
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+
+ // Get version badge styling
+ const getVersionBadge = (type: string) => {
+ switch (type) {
+ case "release":
+ return {
+ text: "Release",
+ variant: "default" as const,
+ className: "bg-emerald-500 hover:bg-emerald-600",
+ };
+ case "snapshot":
+ return {
+ text: "Snapshot",
+ variant: "secondary" as const,
+ className: "bg-amber-500 hover:bg-amber-600",
+ };
+ case "fabric":
+ return {
+ text: "Fabric",
+ variant: "outline" as const,
+ className: "border-indigo-500 text-indigo-700 dark:text-indigo-300",
+ };
+ case "forge":
+ return {
+ text: "Forge",
+ variant: "outline" as const,
+ className: "border-orange-500 text-orange-700 dark:text-orange-300",
+ };
+ case "modpack":
+ return {
+ text: "Modpack",
+ variant: "outline" as const,
+ className: "border-purple-500 text-purple-700 dark:text-purple-300",
+ };
+ default:
+ return {
+ text: type,
+ variant: "outline" as const,
+ className: "border-gray-500 text-gray-700 dark:text-gray-300",
+ };
+ }
+ };
+
+ // Load version metadata
+ const loadVersionMetadata = useCallback(
+ async (versionId: string) => {
+ if (!versionId || !activeInstanceId) {
+ setSelectedVersionMetadata(null);
+ return;
+ }
+
+ setIsLoadingMetadata(true);
+ try {
+ const metadata = await invoke<{
+ id: string;
+ javaVersion?: number;
+ isInstalled: boolean;
+ }>("get_version_metadata", {
+ instanceId: activeInstanceId,
+ versionId,
+ });
+ setSelectedVersionMetadata(metadata);
+ } catch (e) {
+ console.error("Failed to load version metadata:", e);
+ setSelectedVersionMetadata(null);
+ } finally {
+ setIsLoadingMetadata(false);
+ }
+ },
+ [activeInstanceId],
+ );
+
+ // Get base version for mod loader selector
+ const selectedBaseVersion = (() => {
+ if (!selectedVersion) return "";
+
+ if (selectedVersion.startsWith("fabric-loader-")) {
+ const parts = selectedVersion.split("-");
+ return parts[parts.length - 1];
+ }
+ if (selectedVersion.includes("-forge-")) {
+ return selectedVersion.split("-forge-")[0];
+ }
+
+ const version = versions.find((v) => v.id === selectedVersion);
+ return version ? selectedVersion : "";
+ })();
+
+ // Handle version deletion
+ const handleDeleteVersion = async () => {
+ if (!versionToDelete || !activeInstanceId) return;
+
+ setIsDeleting(true);
+ try {
+ await invoke("delete_version", {
+ instanceId: activeInstanceId,
+ versionId: versionToDelete,
+ });
+
+ if (selectedVersion === versionToDelete) {
+ setSelectedVersion("");
+ }
+
+ setShowDeleteDialog(false);
+ setVersionToDelete(null);
+ toast.success("Version deleted successfully");
+
+ await loadVersions(activeInstanceId);
+ await loadInstalledModdedVersions();
+ } catch (e) {
+ console.error("Failed to delete version:", e);
+ toast.error(`Failed to delete version: ${e}`);
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ // Show delete confirmation dialog
+ const showDeleteConfirmation = (versionId: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ setVersionToDelete(versionId);
+ setShowDeleteDialog(true);
+ };
+
+ // Setup event listeners for version updates
+ useEffect(() => {
+ let unlisteners: UnlistenFn[] = [];
+
+ const setupEventListeners = async () => {
+ try {
+ const versionDeletedUnlisten = await listen(
+ "version-deleted",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const downloadCompleteUnlisten = await listen(
+ "download-complete",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const versionInstalledUnlisten = await listen(
+ "version-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const fabricInstalledUnlisten = await listen(
+ "fabric-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ const forgeInstalledUnlisten = await listen(
+ "forge-installed",
+ async () => {
+ await loadVersions(activeInstanceId ?? undefined);
+ await loadInstalledModdedVersions();
+ },
+ );
+
+ unlisteners = [
+ versionDeletedUnlisten,
+ downloadCompleteUnlisten,
+ versionInstalledUnlisten,
+ fabricInstalledUnlisten,
+ forgeInstalledUnlisten,
+ ];
+ } catch (e) {
+ console.error("Failed to setup event listeners:", e);
+ }
+ };
+
+ setupEventListeners();
+ loadInstalledModdedVersions();
+
+ return () => {
+ unlisteners.forEach((unlisten) => {
+ unlisten();
+ });
+ };
+ }, [activeInstanceId, loadVersions, loadInstalledModdedVersions]);
+
+ // Load metadata when selected version changes
+ useEffect(() => {
+ if (selectedVersion) {
+ loadVersionMetadata(selectedVersion);
+ } else {
+ setSelectedVersionMetadata(null);
+ }
+ }, [selectedVersion, loadVersionMetadata]);
+
+ return (
+ <div className="h-full flex flex-col p-6 overflow-hidden">
+ <div className="flex items-center justify-between mb-6">
+ <h2 className="text-3xl font-black bg-clip-text text-transparent bg-linear-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/60">
+ Version Manager
+ </h2>
+ <div className="text-sm dark:text-white/40 text-black/50">
+ Select a version to play or modify
+ </div>
+ </div>
+
+ <div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 overflow-hidden">
+ {/* Left: Version List */}
+ <div className="lg:col-span-2 flex flex-col gap-4 overflow-hidden">
+ {/* Search and Filters */}
+ <div className="flex gap-3">
+ <div className="relative flex-1">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ type="text"
+ placeholder="Search versions..."
+ className="pl-9 bg-white/60 dark:bg-black/20 border-black/10 dark:border-white/10 dark:text-white text-gray-900 placeholder-black/30 dark:placeholder-white/30 focus:border-indigo-500/50 dark:focus:bg-black/40 focus:bg-white/80 backdrop-blur-sm"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
+ </div>
+ </div>
+
+ {/* Type Filter Tabs */}
+ <Tabs
+ value={typeFilter}
+ onValueChange={(v) => setTypeFilter(v as TypeFilter)}
+ className="w-full"
+ >
+ <TabsList className="grid grid-cols-4 bg-white/60 dark:bg-black/20 border-black/5 dark:border-white/5">
+ <TabsTrigger value="all">All</TabsTrigger>
+ <TabsTrigger value="release">Release</TabsTrigger>
+ <TabsTrigger value="snapshot">Snapshot</TabsTrigger>
+ <TabsTrigger value="installed">Installed</TabsTrigger>
+ </TabsList>
+ </Tabs>
+
+ {/* Version List */}
+ <ScrollArea className="flex-1 pr-2">
+ {versions.length === 0 ? (
+ <div className="flex items-center justify-center h-40 dark:text-white/30 text-black/30 italic animate-pulse">
+ Loading versions...
+ </div>
+ ) : filteredVersions.length === 0 ? (
+ <div className="flex flex-col items-center justify-center h-40 dark:text-white/30 text-black/30 gap-2">
+ <span className="text-2xl">👻</span>
+ <span>No matching versions found</span>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {filteredVersions.map((version) => {
+ const badge = getVersionBadge(version.type);
+ const isSelected = selectedVersion === version.id;
+
+ return (
+ <Card
+ key={version.id}
+ className={`w-full cursor-pointer transition-all duration-200 relative overflow-hidden group ${
+ isSelected
+ ? "border-indigo-200 dark:border-indigo-500/50 bg-indigo-50 dark:bg-indigo-600/20 shadow-[0_0_20px_rgba(99,102,241,0.2)]"
+ : "border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 hover:bg-white/60 dark:hover:bg-white/10 hover:border-black/10 dark:hover:border-white/10 hover:translate-x-1"
+ }`}
+ onClick={() => setSelectedVersion(version.id)}
+ >
+ {isSelected && (
+ <div className="absolute inset-0 bg-linear-to-r from-indigo-500/10 to-transparent pointer-events-none" />
+ )}
+
+ <CardContent className="p-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4 flex-1">
+ <Badge
+ variant={badge.variant}
+ className={badge.className}
+ >
+ {badge.text}
+ </Badge>
+ <div className="flex-1">
+ <div
+ className={`font-bold font-mono text-lg tracking-tight ${
+ isSelected
+ ? "text-black dark:text-white"
+ : "text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white"
+ }`}
+ >
+ {version.id}
+ </div>
+ <div className="flex items-center gap-2 mt-0.5">
+ {version.releaseTime &&
+ version.type !== "fabric" &&
+ version.type !== "forge" && (
+ <div className="text-xs dark:text-white/30 text-black/30">
+ {new Date(
+ version.releaseTime,
+ ).toLocaleDateString()}
+ </div>
+ )}
+ {version.javaVersion && (
+ <div className="flex items-center gap-1 text-xs dark:text-white/40 text-black/40">
+ <Coffee className="h-3 w-3 opacity-60" />
+ <span className="font-medium">
+ Java {version.javaVersion}
+ </span>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {version.isInstalled && (
+ <Button
+ variant="ghost"
+ size="icon"
+ className="opacity-0 group-hover:opacity-100 transition-opacity text-red-500 dark:text-red-400 hover:bg-red-500/10 dark:hover:bg-red-500/20"
+ onClick={(e) =>
+ showDeleteConfirmation(version.id, e)
+ }
+ title="Delete version"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+ })}
+ </div>
+ )}
+ </ScrollArea>
+ </div>
+
+ {/* Right: Version Details */}
+ <div className="flex flex-col gap-6">
+ <Card className="border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 backdrop-blur-sm">
+ <CardHeader>
+ <CardTitle className="text-lg">Version Details</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {selectedVersion ? (
+ <>
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Selected Version
+ </div>
+ <div className="font-mono text-xl font-bold">
+ {selectedVersion}
+ </div>
+ </div>
+
+ {isLoadingMetadata ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="text-sm">Loading metadata...</span>
+ </div>
+ ) : selectedVersionMetadata ? (
+ <div className="space-y-3">
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Installation Status
+ </div>
+ <Badge
+ variant={
+ selectedVersionMetadata.isInstalled
+ ? "default"
+ : "outline"
+ }
+ >
+ {selectedVersionMetadata.isInstalled
+ ? "Installed"
+ : "Not Installed"}
+ </Badge>
+ </div>
+
+ {selectedVersionMetadata.javaVersion && (
+ <div>
+ <div className="text-sm text-muted-foreground mb-1">
+ Java Version
+ </div>
+ <div className="flex items-center gap-2">
+ <Coffee className="h-4 w-4" />
+ <span>
+ Java {selectedVersionMetadata.javaVersion}
+ </span>
+ </div>
+ </div>
+ )}
+
+ {!selectedVersionMetadata.isInstalled && (
+ <Button
+ className="w-full"
+ onClick={() => setShowModLoaderSelector(true)}
+ >
+ Install with Mod Loader
+ </Button>
+ )}
+ </div>
+ ) : null}
+ </>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ Select a version to view details
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* Mod Loader Installation */}
+ {showModLoaderSelector && selectedBaseVersion && (
+ <Card className="border-black/5 dark:border-white/5 bg-white/40 dark:bg-white/5 backdrop-blur-sm">
+ <CardHeader>
+ <CardTitle className="text-lg">Install Mod Loader</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="text-sm text-muted-foreground">
+ Install {selectedBaseVersion} with Fabric or Forge
+ </div>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ className="flex-1"
+ onClick={async () => {
+ if (!activeInstanceId) return;
+ try {
+ await invoke("install_fabric", {
+ instanceId: activeInstanceId,
+ gameVersion: selectedBaseVersion,
+ loaderVersion: "latest",
+ });
+ toast.success("Fabric installation started");
+ setShowModLoaderSelector(false);
+ } catch (e) {
+ console.error("Failed to install Fabric:", e);
+ toast.error(`Failed to install Fabric: ${e}`);
+ }
+ }}
+ >
+ Install Fabric
+ </Button>
+ <Button
+ variant="outline"
+ className="flex-1"
+ onClick={async () => {
+ if (!activeInstanceId) return;
+ try {
+ await invoke("install_forge", {
+ instanceId: activeInstanceId,
+ gameVersion: selectedBaseVersion,
+ installerVersion: "latest",
+ });
+ toast.success("Forge installation started");
+ setShowModLoaderSelector(false);
+ } catch (e) {
+ console.error("Failed to install Forge:", e);
+ toast.error(`Failed to install Forge: ${e}`);
+ }
+ }}
+ >
+ Install Forge
+ </Button>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setShowModLoaderSelector(false)}
+ >
+ Cancel
+ </Button>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ </div>
+
+ {/* Delete Confirmation Dialog */}
+ <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Version</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete version "{versionToDelete}"? This
+ action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => {
+ setShowDeleteDialog(false);
+ setVersionToDelete(null);
+ }}
+ disabled={isDeleting}
+ >
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDeleteVersion}
+ disabled={isDeleting}
+ >
+ {isDeleting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Deleting...
+ </>
+ ) : (
+ "Delete"
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+ );
+}
diff --git a/packages/ui/src/stores/assistant-store.ts b/packages/ui/src/stores/assistant-store.ts
new file mode 100644
index 0000000..180031b
--- /dev/null
+++ b/packages/ui/src/stores/assistant-store.ts
@@ -0,0 +1,201 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { create } from "zustand";
+import type { GenerationStats, StreamChunk } from "@/types/bindings/assistant";
+
+export interface Message {
+ role: "user" | "assistant" | "system";
+ content: string;
+ stats?: GenerationStats;
+}
+
+interface AssistantState {
+ // State
+ messages: Message[];
+ isProcessing: boolean;
+ isProviderHealthy: boolean | undefined;
+ streamingContent: string;
+ initialized: boolean;
+ streamUnlisten: UnlistenFn | null;
+
+ // Actions
+ init: () => Promise<void>;
+ checkHealth: () => Promise<void>;
+ sendMessage: (
+ content: string,
+ isEnabled: boolean,
+ provider: string,
+ endpoint: string,
+ ) => Promise<void>;
+ finishStreaming: () => void;
+ clearHistory: () => void;
+ setMessages: (messages: Message[]) => void;
+ setIsProcessing: (isProcessing: boolean) => void;
+ setIsProviderHealthy: (isProviderHealthy: boolean | undefined) => void;
+ setStreamingContent: (streamingContent: string) => void;
+}
+
+export const useAssistantStore = create<AssistantState>((set, get) => ({
+ // Initial state
+ messages: [],
+ isProcessing: false,
+ isProviderHealthy: false,
+ streamingContent: "",
+ initialized: false,
+ streamUnlisten: null,
+
+ // Actions
+ init: async () => {
+ const { initialized } = get();
+ if (initialized) return;
+ set({ initialized: true });
+ await get().checkHealth();
+ },
+
+ checkHealth: async () => {
+ try {
+ const isHealthy = await invoke<boolean>("assistant_check_health");
+ set({ isProviderHealthy: isHealthy });
+ } catch (e) {
+ console.error("Failed to check provider health:", e);
+ set({ isProviderHealthy: false });
+ }
+ },
+
+ finishStreaming: () => {
+ const { streamUnlisten } = get();
+ set({ isProcessing: false, streamingContent: "" });
+
+ if (streamUnlisten) {
+ streamUnlisten();
+ set({ streamUnlisten: null });
+ }
+ },
+
+ sendMessage: async (content, isEnabled, provider, endpoint) => {
+ if (!content.trim()) return;
+
+ const { messages } = get();
+
+ if (!isEnabled) {
+ const newMessage: Message = {
+ role: "assistant",
+ content: "Assistant is disabled. Enable it in Settings > AI Assistant.",
+ };
+ set({ messages: [...messages, { role: "user", content }, newMessage] });
+ return;
+ }
+
+ // Add user message
+ const userMessage: Message = { role: "user", content };
+ const updatedMessages = [...messages, userMessage];
+ set({
+ messages: updatedMessages,
+ isProcessing: true,
+ streamingContent: "",
+ });
+
+ // Add empty assistant message for streaming
+ const assistantMessage: Message = { role: "assistant", content: "" };
+ const withAssistantMessage = [...updatedMessages, assistantMessage];
+ set({ messages: withAssistantMessage });
+
+ try {
+ // Set up stream listener
+ const unlisten = await listen<StreamChunk>(
+ "assistant-stream",
+ (event) => {
+ const chunk = event.payload;
+ const currentState = get();
+
+ if (chunk.content) {
+ const newStreamingContent =
+ currentState.streamingContent + chunk.content;
+ const currentMessages = [...currentState.messages];
+ const lastIdx = currentMessages.length - 1;
+
+ if (lastIdx >= 0 && currentMessages[lastIdx].role === "assistant") {
+ currentMessages[lastIdx] = {
+ ...currentMessages[lastIdx],
+ content: newStreamingContent,
+ };
+ set({
+ streamingContent: newStreamingContent,
+ messages: currentMessages,
+ });
+ }
+ }
+
+ if (chunk.done) {
+ const finalMessages = [...currentState.messages];
+ const lastIdx = finalMessages.length - 1;
+
+ if (
+ chunk.stats &&
+ lastIdx >= 0 &&
+ finalMessages[lastIdx].role === "assistant"
+ ) {
+ finalMessages[lastIdx] = {
+ ...finalMessages[lastIdx],
+ stats: chunk.stats,
+ };
+ set({ messages: finalMessages });
+ }
+
+ get().finishStreaming();
+ }
+ },
+ );
+
+ set({ streamUnlisten: unlisten });
+
+ // Start streaming chat
+ await invoke<string>("assistant_chat_stream", {
+ messages: withAssistantMessage.slice(0, -1), // Exclude the empty assistant message
+ });
+ } catch (e) {
+ console.error("Failed to send message:", e);
+ const errorMessage = e instanceof Error ? e.message : String(e);
+
+ let helpText = "";
+ if (provider === "ollama") {
+ helpText = `\n\nPlease ensure Ollama is running at ${endpoint}.`;
+ } else if (provider === "openai") {
+ helpText = "\n\nPlease check your OpenAI API key in Settings.";
+ }
+
+ // Update the last message with error
+ const currentMessages = [...get().messages];
+ const lastIdx = currentMessages.length - 1;
+ if (lastIdx >= 0 && currentMessages[lastIdx].role === "assistant") {
+ currentMessages[lastIdx] = {
+ role: "assistant",
+ content: `Error: ${errorMessage}${helpText}`,
+ };
+ set({ messages: currentMessages });
+ }
+
+ get().finishStreaming();
+ }
+ },
+
+ clearHistory: () => {
+ set({ messages: [], streamingContent: "" });
+ },
+
+ setMessages: (messages) => {
+ set({ messages });
+ },
+
+ setIsProcessing: (isProcessing) => {
+ set({ isProcessing });
+ },
+
+ setIsProviderHealthy: (isProviderHealthy) => {
+ set({ isProviderHealthy });
+ },
+
+ setStreamingContent: (streamingContent) => {
+ set({ streamingContent });
+ },
+}));
diff --git a/packages/ui/src/stores/assistant.svelte.ts b/packages/ui/src/stores/assistant.svelte.ts
deleted file mode 100644
index a3f47ea..0000000
--- a/packages/ui/src/stores/assistant.svelte.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-import { listen, type UnlistenFn } from "@tauri-apps/api/event";
-
-export interface GenerationStats {
- total_duration: number;
- load_duration: number;
- prompt_eval_count: number;
- prompt_eval_duration: number;
- eval_count: number;
- eval_duration: number;
-}
-
-export interface Message {
- role: "user" | "assistant" | "system";
- content: string;
- stats?: GenerationStats;
-}
-
-interface StreamChunk {
- content: string;
- done: boolean;
- stats?: GenerationStats;
-}
-
-// Module-level state using $state
-let messages = $state<Message[]>([]);
-let isProcessing = $state(false);
-let isProviderHealthy = $state(false);
-let streamingContent = "";
-let initialized = false;
-let streamUnlisten: UnlistenFn | null = null;
-
-async function init() {
- if (initialized) return;
- initialized = true;
- await checkHealth();
-}
-
-async function checkHealth() {
- try {
- isProviderHealthy = await invoke("assistant_check_health");
- } catch (e) {
- console.error("Failed to check provider health:", e);
- isProviderHealthy = false;
- }
-}
-
-function finishStreaming() {
- isProcessing = false;
- streamingContent = "";
- if (streamUnlisten) {
- streamUnlisten();
- streamUnlisten = null;
- }
-}
-
-async function sendMessage(
- content: string,
- isEnabled: boolean,
- provider: string,
- endpoint: string,
-) {
- if (!content.trim()) return;
- if (!isEnabled) {
- messages = [
- ...messages,
- {
- role: "assistant",
- content: "Assistant is disabled. Enable it in Settings > AI Assistant.",
- },
- ];
- return;
- }
-
- // Add user message
- messages = [...messages, { role: "user", content }];
- isProcessing = true;
- streamingContent = "";
-
- // Add empty assistant message for streaming
- messages = [...messages, { role: "assistant", content: "" }];
-
- try {
- // Set up stream listener
- streamUnlisten = await listen<StreamChunk>("assistant-stream", (event) => {
- const chunk = event.payload;
-
- if (chunk.content) {
- streamingContent += chunk.content;
- // Update the last message (assistant's response)
- const lastIdx = messages.length - 1;
- if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
- messages[lastIdx] = {
- ...messages[lastIdx],
- content: streamingContent,
- };
- // Trigger reactivity
- messages = [...messages];
- }
- }
-
- if (chunk.done) {
- if (chunk.stats) {
- const lastIdx = messages.length - 1;
- if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
- messages[lastIdx] = {
- ...messages[lastIdx],
- stats: chunk.stats,
- };
- messages = [...messages];
- }
- }
- finishStreaming();
- }
- });
-
- // Start streaming chat
- await invoke<string>("assistant_chat_stream", {
- messages: messages.slice(0, -1), // Exclude the empty assistant message
- });
- } catch (e) {
- console.error("Failed to send message:", e);
- const errorMessage = e instanceof Error ? e.message : String(e);
-
- let helpText = "";
- if (provider === "ollama") {
- helpText = `\n\nPlease ensure Ollama is running at ${endpoint}.`;
- } else if (provider === "openai") {
- helpText = "\n\nPlease check your OpenAI API key in Settings.";
- }
-
- // Update the last message with error
- const lastIdx = messages.length - 1;
- if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
- messages[lastIdx] = {
- role: "assistant",
- content: `Error: ${errorMessage}${helpText}`,
- };
- messages = [...messages];
- }
-
- finishStreaming();
- }
-}
-
-function clearHistory() {
- messages = [];
- streamingContent = "";
-}
-
-// Export as an object with getters for reactive access
-export const assistantState = {
- get messages() {
- return messages;
- },
- get isProcessing() {
- return isProcessing;
- },
- get isProviderHealthy() {
- return isProviderHealthy;
- },
- init,
- checkHealth,
- sendMessage,
- clearHistory,
-};
diff --git a/packages/ui/src/stores/auth-store.ts b/packages/ui/src/stores/auth-store.ts
new file mode 100644
index 0000000..bf7e3c5
--- /dev/null
+++ b/packages/ui/src/stores/auth-store.ts
@@ -0,0 +1,296 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { open } from "@tauri-apps/plugin-shell";
+import { toast } from "sonner";
+import { create } from "zustand";
+import type { Account, DeviceCodeResponse } from "../types/bindings/auth";
+
+interface AuthState {
+ // State
+ currentAccount: Account | null;
+ isLoginModalOpen: boolean;
+ isLogoutConfirmOpen: boolean;
+ loginMode: "select" | "offline" | "microsoft";
+ offlineUsername: string;
+ deviceCodeData: DeviceCodeResponse | null;
+ msLoginLoading: boolean;
+ msLoginStatus: string;
+
+ // Private state
+ pollInterval: ReturnType<typeof setInterval> | null;
+ isPollingRequestActive: boolean;
+ authProgressUnlisten: UnlistenFn | null;
+
+ // Actions
+ checkAccount: () => Promise<void>;
+ openLoginModal: () => void;
+ openLogoutConfirm: () => void;
+ cancelLogout: () => void;
+ confirmLogout: () => Promise<void>;
+ closeLoginModal: () => void;
+ resetLoginState: () => void;
+ performOfflineLogin: () => Promise<void>;
+ startMicrosoftLogin: () => Promise<void>;
+ checkLoginStatus: (deviceCode: string) => Promise<void>;
+ stopPolling: () => void;
+ cancelMicrosoftLogin: () => void;
+ setLoginMode: (mode: "select" | "offline" | "microsoft") => void;
+ setOfflineUsername: (username: string) => void;
+}
+
+export const useAuthStore = create<AuthState>((set, get) => ({
+ // Initial state
+ currentAccount: null,
+ isLoginModalOpen: false,
+ isLogoutConfirmOpen: false,
+ loginMode: "select",
+ offlineUsername: "",
+ deviceCodeData: null,
+ msLoginLoading: false,
+ msLoginStatus: "Waiting for authorization...",
+
+ // Private state
+ pollInterval: null,
+ isPollingRequestActive: false,
+ authProgressUnlisten: null,
+
+ // Actions
+ checkAccount: async () => {
+ try {
+ const acc = await invoke<Account | null>("get_active_account");
+ set({ currentAccount: acc });
+ } catch (error) {
+ console.error("Failed to check account:", error);
+ }
+ },
+
+ openLoginModal: () => {
+ const { currentAccount } = get();
+ if (currentAccount) {
+ // Show custom logout confirmation dialog
+ set({ isLogoutConfirmOpen: true });
+ return;
+ }
+ get().resetLoginState();
+ set({ isLoginModalOpen: true });
+ },
+
+ openLogoutConfirm: () => {
+ set({ isLogoutConfirmOpen: true });
+ },
+
+ cancelLogout: () => {
+ set({ isLogoutConfirmOpen: false });
+ },
+
+ confirmLogout: async () => {
+ set({ isLogoutConfirmOpen: false });
+ try {
+ await invoke("logout");
+ set({ currentAccount: null });
+ } catch (error) {
+ console.error("Logout failed:", error);
+ }
+ },
+
+ closeLoginModal: () => {
+ get().stopPolling();
+ set({ isLoginModalOpen: false });
+ },
+
+ resetLoginState: () => {
+ set({
+ loginMode: "select",
+ offlineUsername: "",
+ deviceCodeData: null,
+ msLoginLoading: false,
+ msLoginStatus: "Waiting for authorization...",
+ });
+ },
+
+ performOfflineLogin: async () => {
+ const { offlineUsername } = get();
+ if (!offlineUsername.trim()) return;
+
+ try {
+ const account = await invoke<Account>("login_offline", {
+ username: offlineUsername,
+ });
+ set({
+ currentAccount: account,
+ isLoginModalOpen: false,
+ offlineUsername: "",
+ });
+ } catch (error) {
+ // Keep UI-friendly behavior consistent with prior code
+ alert("Login failed: " + String(error));
+ }
+ },
+
+ startMicrosoftLogin: async () => {
+ // Prepare UI state
+ set({
+ msLoginLoading: true,
+ msLoginStatus: "Waiting for authorization...",
+ loginMode: "microsoft",
+ deviceCodeData: null,
+ });
+
+ // Listen to general launcher logs so we can display progress to the user.
+ // The backend emits logs via "launcher-log"; using that keeps this store decoupled
+ // from a dedicated auth event channel (backend may reuse launcher-log).
+ try {
+ const unlisten = await listen("launcher-log", (event) => {
+ const payload = event.payload;
+ // Normalize payload to string if possible
+ const message =
+ typeof payload === "string"
+ ? payload
+ : (payload?.toString?.() ?? JSON.stringify(payload));
+ set({ msLoginStatus: message });
+ });
+ set({ authProgressUnlisten: unlisten });
+ } catch (err) {
+ console.warn("Failed to attach launcher-log listener:", err);
+ }
+
+ try {
+ const deviceCodeData = await invoke<DeviceCodeResponse>(
+ "start_microsoft_login",
+ );
+ set({ deviceCodeData });
+
+ if (deviceCodeData) {
+ // Try to copy user code to clipboard for convenience (best-effort)
+ try {
+ await navigator.clipboard?.writeText(deviceCodeData.userCode ?? "");
+ } catch (err) {
+ // ignore clipboard errors
+ console.debug("Clipboard copy failed:", err);
+ }
+
+ // Open verification URI in default browser
+ try {
+ if (deviceCodeData.verificationUri) {
+ await open(deviceCodeData.verificationUri);
+ }
+ } catch (err) {
+ console.debug("Failed to open verification URI:", err);
+ }
+
+ // Start polling for completion
+ // `interval` from the bindings is a bigint (seconds). Convert safely to number.
+ const intervalSeconds =
+ deviceCodeData.interval !== undefined &&
+ deviceCodeData.interval !== null
+ ? Number(deviceCodeData.interval)
+ : 5;
+ const intervalMs = intervalSeconds * 1000;
+ const pollInterval = setInterval(
+ () => get().checkLoginStatus(deviceCodeData.deviceCode),
+ intervalMs,
+ );
+ set({ pollInterval });
+ }
+ } catch (error) {
+ toast.error(`Failed to start Microsoft login: ${error}`);
+ set({ loginMode: "select" });
+ // cleanup listener if present
+ const { authProgressUnlisten } = get();
+ if (authProgressUnlisten) {
+ authProgressUnlisten();
+ set({ authProgressUnlisten: null });
+ }
+ } finally {
+ set({ msLoginLoading: false });
+ }
+ },
+
+ checkLoginStatus: async (deviceCode: string) => {
+ const { isPollingRequestActive } = get();
+ if (isPollingRequestActive) return;
+
+ set({ isPollingRequestActive: true });
+
+ try {
+ const account = await invoke<Account>("complete_microsoft_login", {
+ deviceCode,
+ });
+
+ // On success, stop polling and cleanup listener
+ get().stopPolling();
+ const { authProgressUnlisten } = get();
+ if (authProgressUnlisten) {
+ authProgressUnlisten();
+ set({ authProgressUnlisten: null });
+ }
+
+ set({
+ currentAccount: account,
+ isLoginModalOpen: false,
+ });
+ } catch (error: unknown) {
+ const errStr = String(error);
+ if (errStr.includes("authorization_pending")) {
+ // Still waiting — keep polling
+ } else {
+ set({ msLoginStatus: "Error: " + errStr });
+
+ if (
+ errStr.includes("expired_token") ||
+ errStr.includes("access_denied")
+ ) {
+ // Terminal errors — stop polling and reset state
+ get().stopPolling();
+ const { authProgressUnlisten } = get();
+ if (authProgressUnlisten) {
+ authProgressUnlisten();
+ set({ authProgressUnlisten: null });
+ }
+ alert("Login failed: " + errStr);
+ set({ loginMode: "select" });
+ }
+ }
+ } finally {
+ set({ isPollingRequestActive: false });
+ }
+ },
+
+ stopPolling: () => {
+ const { pollInterval, authProgressUnlisten } = get();
+ if (pollInterval) {
+ try {
+ clearInterval(pollInterval);
+ } catch (err) {
+ console.debug("Failed to clear poll interval:", err);
+ }
+ set({ pollInterval: null });
+ }
+ if (authProgressUnlisten) {
+ try {
+ authProgressUnlisten();
+ } catch (err) {
+ console.debug("Failed to unlisten auth progress:", err);
+ }
+ set({ authProgressUnlisten: null });
+ }
+ },
+
+ cancelMicrosoftLogin: () => {
+ get().stopPolling();
+ set({
+ deviceCodeData: null,
+ msLoginLoading: false,
+ msLoginStatus: "",
+ loginMode: "select",
+ });
+ },
+
+ setLoginMode: (mode: "select" | "offline" | "microsoft") => {
+ set({ loginMode: mode });
+ },
+
+ setOfflineUsername: (username: string) => {
+ set({ offlineUsername: username });
+ },
+}));
diff --git a/packages/ui/src/stores/auth.svelte.ts b/packages/ui/src/stores/auth.svelte.ts
deleted file mode 100644
index 1b613a7..0000000
--- a/packages/ui/src/stores/auth.svelte.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-import { open } from "@tauri-apps/plugin-shell";
-import { listen, type UnlistenFn } from "@tauri-apps/api/event";
-import type { Account, DeviceCodeResponse } from "../types";
-import { uiState } from "./ui.svelte";
-import { logsState } from "./logs.svelte";
-
-export class AuthState {
- currentAccount = $state<Account | null>(null);
- isLoginModalOpen = $state(false);
- isLogoutConfirmOpen = $state(false);
- loginMode = $state<"select" | "offline" | "microsoft">("select");
- offlineUsername = $state("");
- deviceCodeData = $state<DeviceCodeResponse | null>(null);
- msLoginLoading = $state(false);
- msLoginStatus = $state("Waiting for authorization...");
-
- private pollInterval: ReturnType<typeof setInterval> | null = null;
- private isPollingRequestActive = false;
- private authProgressUnlisten: UnlistenFn | null = null;
-
- async checkAccount() {
- try {
- const acc = await invoke("get_active_account");
- this.currentAccount = acc as Account | null;
- } catch (e) {
- console.error("Failed to check account:", e);
- }
- }
-
- openLoginModal() {
- if (this.currentAccount) {
- // Show custom logout confirmation dialog
- this.isLogoutConfirmOpen = true;
- return;
- }
- this.resetLoginState();
- this.isLoginModalOpen = true;
- }
-
- cancelLogout() {
- this.isLogoutConfirmOpen = false;
- }
-
- async confirmLogout() {
- this.isLogoutConfirmOpen = false;
- try {
- await invoke("logout");
- this.currentAccount = null;
- uiState.setStatus("Logged out successfully");
- } catch (e) {
- console.error("Logout failed:", e);
- }
- }
-
- closeLoginModal() {
- this.stopPolling();
- this.isLoginModalOpen = false;
- }
-
- resetLoginState() {
- this.loginMode = "select";
- this.offlineUsername = "";
- this.deviceCodeData = null;
- this.msLoginLoading = false;
- }
-
- async performOfflineLogin() {
- if (!this.offlineUsername) return;
- try {
- this.currentAccount = (await invoke("login_offline", {
- username: this.offlineUsername,
- })) as Account;
- this.isLoginModalOpen = false;
- } catch (e) {
- alert("Login failed: " + e);
- }
- }
-
- async startMicrosoftLogin() {
- this.loginMode = "microsoft";
- this.msLoginLoading = true;
- this.msLoginStatus = "Waiting for authorization...";
- this.stopPolling();
-
- // Setup auth progress listener
- this.setupAuthProgressListener();
-
- try {
- this.deviceCodeData = (await invoke("start_microsoft_login")) as DeviceCodeResponse;
-
- if (this.deviceCodeData) {
- try {
- await navigator.clipboard.writeText(this.deviceCodeData.user_code);
- } catch (e) {
- console.error("Clipboard failed", e);
- }
-
- open(this.deviceCodeData.verification_uri);
- logsState.addLog(
- "info",
- "Auth",
- "Microsoft login started, waiting for browser authorization...",
- );
-
- console.log("Starting polling for token...");
- const intervalMs = (this.deviceCodeData.interval || 5) * 1000;
- this.pollInterval = setInterval(
- () => this.checkLoginStatus(this.deviceCodeData!.device_code),
- intervalMs,
- );
- }
- } catch (e) {
- logsState.addLog("error", "Auth", `Failed to start Microsoft login: ${e}`);
- alert("Failed to start Microsoft login: " + e);
- this.loginMode = "select";
- } finally {
- this.msLoginLoading = false;
- }
- }
-
- private async setupAuthProgressListener() {
- // Clean up previous listener if exists
- if (this.authProgressUnlisten) {
- this.authProgressUnlisten();
- this.authProgressUnlisten = null;
- }
-
- this.authProgressUnlisten = await listen<string>("auth-progress", (event) => {
- const message = event.payload;
- this.msLoginStatus = message;
- logsState.addLog("info", "Auth", message);
- });
- }
-
- private cleanupAuthListener() {
- if (this.authProgressUnlisten) {
- this.authProgressUnlisten();
- this.authProgressUnlisten = null;
- }
- }
-
- stopPolling() {
- if (this.pollInterval) {
- clearInterval(this.pollInterval);
- this.pollInterval = null;
- }
- }
-
- async checkLoginStatus(deviceCode: string) {
- if (this.isPollingRequestActive) return;
- this.isPollingRequestActive = true;
-
- console.log("Polling Microsoft API...");
- try {
- this.currentAccount = (await invoke("complete_microsoft_login", {
- deviceCode,
- })) as Account;
-
- console.log("Login Successful!", this.currentAccount);
- this.stopPolling();
- this.cleanupAuthListener();
- this.isLoginModalOpen = false;
- logsState.addLog(
- "info",
- "Auth",
- `Login successful! Welcome, ${this.currentAccount.username}`,
- );
- uiState.setStatus("Welcome back, " + this.currentAccount.username);
- } catch (e: any) {
- const errStr = e.toString();
- if (errStr.includes("authorization_pending")) {
- console.log("Status: Waiting for user to authorize...");
- } else {
- console.error("Polling Error:", errStr);
- this.msLoginStatus = "Error: " + errStr;
- logsState.addLog("error", "Auth", `Login error: ${errStr}`);
-
- if (errStr.includes("expired_token") || errStr.includes("access_denied")) {
- this.stopPolling();
- this.cleanupAuthListener();
- alert("Login failed: " + errStr);
- this.loginMode = "select";
- }
- }
- } finally {
- this.isPollingRequestActive = false;
- }
- }
-}
-
-export const authState = new AuthState();
diff --git a/packages/ui/src/stores/game-store.ts b/packages/ui/src/stores/game-store.ts
new file mode 100644
index 0000000..fa0f9f8
--- /dev/null
+++ b/packages/ui/src/stores/game-store.ts
@@ -0,0 +1,101 @@
+import { toast } from "sonner";
+import { create } from "zustand";
+import { getVersions } from "@/client";
+import type { Version } from "@/types/bindings/manifest";
+
+interface GameState {
+ // State
+ versions: Version[];
+ selectedVersion: string;
+
+ // Computed property
+ latestRelease: Version | undefined;
+
+ // Actions
+ loadVersions: (instanceId?: string) => Promise<void>;
+ startGame: (
+ currentAccount: any,
+ openLoginModal: () => void,
+ activeInstanceId: string | null,
+ setView: (view: any) => void,
+ ) => Promise<void>;
+ setSelectedVersion: (version: string) => void;
+ setVersions: (versions: Version[]) => void;
+}
+
+export const useGameStore = create<GameState>((set, get) => ({
+ // Initial state
+ versions: [],
+ selectedVersion: "",
+
+ // Computed property
+ get latestRelease() {
+ return get().versions.find((v) => v.type === "release");
+ },
+
+ // Actions
+ 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();
+ set({ versions: versions ?? [] });
+ } catch (e) {
+ console.error("Failed to load versions:", e);
+ // Keep the store consistent on error by clearing versions.
+ set({ versions: [] });
+ }
+ },
+
+ startGame: async (
+ currentAccount,
+ openLoginModal,
+ activeInstanceId,
+ setView,
+ ) => {
+ const { selectedVersion } = get();
+
+ if (!currentAccount) {
+ alert("Please login first!");
+ openLoginModal();
+ return;
+ }
+
+ if (!selectedVersion) {
+ alert("Please select a version!");
+ return;
+ }
+
+ if (!activeInstanceId) {
+ alert("Please select an instance first!");
+ setView("instances");
+ return;
+ }
+
+ toast.info("Preparing to launch " + selectedVersion + "...");
+
+ 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!");
+ } catch (e) {
+ console.error(e);
+ toast.error(`Error: ${e}`);
+ }
+ },
+
+ setSelectedVersion: (version: string) => {
+ set({ selectedVersion: version });
+ },
+
+ setVersions: (versions: Version[]) => {
+ set({ versions });
+ },
+}));
diff --git a/packages/ui/src/stores/game.svelte.ts b/packages/ui/src/stores/game.svelte.ts
deleted file mode 100644
index 504d108..0000000
--- a/packages/ui/src/stores/game.svelte.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-import type { Version } from "../types";
-import { uiState } from "./ui.svelte";
-import { authState } from "./auth.svelte";
-import { instancesState } from "./instances.svelte";
-
-export class GameState {
- versions = $state<Version[]>([]);
- selectedVersion = $state("");
-
- constructor() {
- // Constructor intentionally empty
- // Instance switching handled in App.svelte with $effect
- }
-
- get latestRelease() {
- return this.versions.find((v) => v.type === "release");
- }
-
- async loadVersions(instanceId?: string) {
- const id = instanceId || instancesState.activeInstanceId;
- if (!id) {
- this.versions = [];
- return;
- }
-
- try {
- this.versions = await invoke<Version[]>("get_versions", {
- instanceId: id,
- });
- // Don't auto-select version here - let BottomBar handle version selection
- // based on installed versions only
- } catch (e) {
- console.error("Failed to fetch versions:", e);
- uiState.setStatus("Error fetching versions: " + e);
- }
- }
-
- async startGame() {
- if (!authState.currentAccount) {
- alert("Please login first!");
- authState.openLoginModal();
- return;
- }
-
- if (!this.selectedVersion) {
- alert("Please select a version!");
- return;
- }
-
- if (!instancesState.activeInstanceId) {
- alert("Please select an instance first!");
- uiState.setView("instances");
- return;
- }
-
- uiState.setStatus("Preparing to launch " + this.selectedVersion + "...");
- console.log(
- "Invoking start_game for version:",
- this.selectedVersion,
- "instance:",
- instancesState.activeInstanceId,
- );
- try {
- const msg = await invoke<string>("start_game", {
- instanceId: instancesState.activeInstanceId,
- versionId: this.selectedVersion,
- });
- console.log("Response:", msg);
- uiState.setStatus(msg);
- } catch (e) {
- console.error(e);
- uiState.setStatus("Error: " + e);
- }
- }
-}
-
-export const gameState = new GameState();
diff --git a/packages/ui/src/stores/instances.svelte.ts b/packages/ui/src/stores/instances.svelte.ts
deleted file mode 100644
index f4ac4e9..0000000
--- a/packages/ui/src/stores/instances.svelte.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-import type { Instance } from "../types";
-import { uiState } from "./ui.svelte";
-
-export class InstancesState {
- instances = $state<Instance[]>([]);
- activeInstanceId = $state<string | null>(null);
- get activeInstance(): Instance | null {
- if (!this.activeInstanceId) return null;
- return this.instances.find((i) => i.id === this.activeInstanceId) || null;
- }
-
- async loadInstances() {
- try {
- this.instances = await invoke<Instance[]>("list_instances");
- const active = await invoke<Instance | null>("get_active_instance");
- if (active) {
- this.activeInstanceId = active.id;
- } else if (this.instances.length > 0) {
- // If no active instance but instances exist, set the first one as active
- await this.setActiveInstance(this.instances[0].id);
- }
- } catch (e) {
- console.error("Failed to load instances:", e);
- uiState.setStatus("Error loading instances: " + e);
- }
- }
-
- async createInstance(name: string): Promise<Instance | null> {
- try {
- const instance = await invoke<Instance>("create_instance", { name });
- await this.loadInstances();
- uiState.setStatus(`Instance "${name}" created successfully`);
- return instance;
- } catch (e) {
- console.error("Failed to create instance:", e);
- uiState.setStatus("Error creating instance: " + e);
- return null;
- }
- }
-
- async deleteInstance(id: string) {
- try {
- await invoke("delete_instance", { instanceId: id });
- await this.loadInstances();
- // If deleted instance was active, set another as active
- if (this.activeInstanceId === id) {
- if (this.instances.length > 0) {
- await this.setActiveInstance(this.instances[0].id);
- } else {
- this.activeInstanceId = null;
- }
- }
- uiState.setStatus("Instance deleted successfully");
- } catch (e) {
- console.error("Failed to delete instance:", e);
- uiState.setStatus("Error deleting instance: " + e);
- }
- }
-
- async updateInstance(instance: Instance) {
- try {
- await invoke("update_instance", { instance });
- await this.loadInstances();
- uiState.setStatus("Instance updated successfully");
- } catch (e) {
- console.error("Failed to update instance:", e);
- uiState.setStatus("Error updating instance: " + e);
- }
- }
-
- async setActiveInstance(id: string) {
- try {
- await invoke("set_active_instance", { instanceId: id });
- this.activeInstanceId = id;
- uiState.setStatus("Active instance changed");
- } catch (e) {
- console.error("Failed to set active instance:", e);
- uiState.setStatus("Error setting active instance: " + e);
- }
- }
-
- async duplicateInstance(id: string, newName: string): Promise<Instance | null> {
- try {
- const instance = await invoke<Instance>("duplicate_instance", {
- instanceId: id,
- newName,
- });
- await this.loadInstances();
- uiState.setStatus(`Instance duplicated as "${newName}"`);
- return instance;
- } catch (e) {
- console.error("Failed to duplicate instance:", e);
- uiState.setStatus("Error duplicating instance: " + e);
- return null;
- }
- }
-
- async getInstance(id: string): Promise<Instance | null> {
- try {
- return await invoke<Instance>("get_instance", { instanceId: id });
- } catch (e) {
- console.error("Failed to get instance:", e);
- return null;
- }
- }
-}
-
-export const instancesState = new InstancesState();
diff --git a/packages/ui/src/stores/logs-store.ts b/packages/ui/src/stores/logs-store.ts
new file mode 100644
index 0000000..b19f206
--- /dev/null
+++ b/packages/ui/src/stores/logs-store.ts
@@ -0,0 +1,200 @@
+import { listen } from "@tauri-apps/api/event";
+import { create } from "zustand";
+
+export interface LogEntry {
+ id: number;
+ timestamp: string;
+ level: "info" | "warn" | "error" | "debug" | "fatal";
+ source: string;
+ message: string;
+}
+
+// Parse Minecraft/Java log format: [HH:MM:SS] [Thread/LEVEL]: message
+// or: [HH:MM:SS] [Thread/LEVEL] [Source]: message
+const GAME_LOG_REGEX =
+ /^\[[\d:]+\]\s*\[([^\]]+)\/(\w+)\](?:\s*\[([^\]]+)\])?:\s*(.*)$/;
+
+function parseGameLogLevel(levelStr: string): LogEntry["level"] {
+ const upper = levelStr.toUpperCase();
+ if (upper === "INFO") return "info";
+ if (upper === "WARN" || upper === "WARNING") return "warn";
+ if (upper === "ERROR" || upper === "SEVERE") return "error";
+ if (
+ upper === "DEBUG" ||
+ upper === "TRACE" ||
+ upper === "FINE" ||
+ upper === "FINER" ||
+ upper === "FINEST"
+ )
+ return "debug";
+ if (upper === "FATAL") return "fatal";
+ return "info";
+}
+
+interface LogsState {
+ // State
+ logs: LogEntry[];
+ sources: Set<string>;
+ nextId: number;
+ maxLogs: number;
+ initialized: boolean;
+
+ // Actions
+ addLog: (level: LogEntry["level"], source: string, message: string) => void;
+ addGameLog: (rawLine: string, isStderr: boolean) => void;
+ clear: () => void;
+ exportLogs: (filteredLogs: LogEntry[]) => string;
+ init: () => Promise<void>;
+ setLogs: (logs: LogEntry[]) => void;
+ setSources: (sources: Set<string>) => void;
+}
+
+export const useLogsStore = create<LogsState>((set, get) => ({
+ // Initial state
+ logs: [],
+ sources: new Set(["Launcher"]),
+ nextId: 0,
+ maxLogs: 5000,
+ initialized: false,
+
+ // Actions
+ addLog: (level, source, message) => {
+ const { nextId, logs, maxLogs, sources } = get();
+ const now = new Date();
+ const timestamp =
+ now.toLocaleTimeString() +
+ "." +
+ now.getMilliseconds().toString().padStart(3, "0");
+
+ const newLog: LogEntry = {
+ id: nextId,
+ timestamp,
+ level,
+ source,
+ message,
+ };
+
+ const newLogs = [...logs, newLog];
+ const newSources = new Set(sources);
+
+ // Track source
+ if (!newSources.has(source)) {
+ newSources.add(source);
+ }
+
+ // Trim logs if exceeding max
+ const trimmedLogs =
+ newLogs.length > maxLogs ? newLogs.slice(-maxLogs) : newLogs;
+
+ set({
+ logs: trimmedLogs,
+ sources: newSources,
+ nextId: nextId + 1,
+ });
+ },
+
+ addGameLog: (rawLine, isStderr) => {
+ const match = rawLine.match(GAME_LOG_REGEX);
+
+ if (match) {
+ const [, thread, levelStr, extraSource, message] = match;
+ const level = parseGameLogLevel(levelStr);
+ // Use extraSource if available, otherwise use thread name as source hint
+ const source = extraSource || `Game/${thread.split("-")[0]}`;
+ get().addLog(level, source, message);
+ } else {
+ // Fallback: couldn't parse, use stderr as error indicator
+ const level = isStderr ? "error" : "info";
+ get().addLog(level, "Game", rawLine);
+ }
+ },
+
+ clear: () => {
+ set({
+ logs: [],
+ sources: new Set(["Launcher"]),
+ });
+ get().addLog("info", "Launcher", "Logs cleared");
+ },
+
+ exportLogs: (filteredLogs) => {
+ return filteredLogs
+ .map(
+ (l) =>
+ `[${l.timestamp}] [${l.source}/${l.level.toUpperCase()}] ${l.message}`,
+ )
+ .join("\n");
+ },
+
+ init: async () => {
+ const { initialized } = get();
+ if (initialized) return;
+
+ set({ initialized: true });
+
+ // Initial log
+ get().addLog("info", "Launcher", "Logs initialized");
+
+ // General Launcher Logs
+ await listen<string>("launcher-log", (e) => {
+ get().addLog("info", "Launcher", e.payload);
+ });
+
+ // Game Stdout - parse log level
+ await listen<string>("game-stdout", (e) => {
+ get().addGameLog(e.payload, false);
+ });
+
+ // Game Stderr - parse log level, default to error
+ await listen<string>("game-stderr", (e) => {
+ get().addGameLog(e.payload, true);
+ });
+
+ // Download Events (Summarized)
+ await listen("download-start", (e: any) => {
+ get().addLog(
+ "info",
+ "Downloader",
+ `Starting batch download of ${e.payload} files...`,
+ );
+ });
+
+ await listen("download-complete", () => {
+ get().addLog("info", "Downloader", "All downloads completed.");
+ });
+
+ // Listen to file download progress to log finished files
+ await listen<any>("download-progress", (e) => {
+ const p = e.payload;
+ if (p.status === "Finished") {
+ if (p.file.endsWith(".jar")) {
+ get().addLog("info", "Downloader", `Downloaded ${p.file}`);
+ }
+ }
+ });
+
+ // Java Download
+ await listen<any>("java-download-progress", (e) => {
+ const p = e.payload;
+ if (p.status === "Downloading" && p.percentage === 0) {
+ get().addLog(
+ "info",
+ "JavaInstaller",
+ `Downloading Java: ${p.file_name}`,
+ );
+ } else if (p.status === "Completed") {
+ get().addLog("info", "JavaInstaller", `Java installed: ${p.file_name}`);
+ } else if (p.status === "Error") {
+ get().addLog("error", "JavaInstaller", `Java download error`);
+ }
+ });
+ },
+
+ setLogs: (logs) => {
+ set({ logs });
+ },
+
+ setSources: (sources) => {
+ set({ sources });
+ },
+}));
diff --git a/packages/ui/src/stores/logs.svelte.ts b/packages/ui/src/stores/logs.svelte.ts
deleted file mode 100644
index c9d4acc..0000000
--- a/packages/ui/src/stores/logs.svelte.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { listen } from "@tauri-apps/api/event";
-
-export interface LogEntry {
- id: number;
- timestamp: string;
- level: "info" | "warn" | "error" | "debug" | "fatal";
- source: string;
- message: string;
-}
-
-// Parse Minecraft/Java log format: [HH:MM:SS] [Thread/LEVEL]: message
-// or: [HH:MM:SS] [Thread/LEVEL] [Source]: message
-const GAME_LOG_REGEX = /^\[[\d:]+\]\s*\[([^\]]+)\/(\w+)\](?:\s*\[([^\]]+)\])?:\s*(.*)$/;
-
-function parseGameLogLevel(levelStr: string): LogEntry["level"] {
- const upper = levelStr.toUpperCase();
- if (upper === "INFO") return "info";
- if (upper === "WARN" || upper === "WARNING") return "warn";
- if (upper === "ERROR" || upper === "SEVERE") return "error";
- if (
- upper === "DEBUG" ||
- upper === "TRACE" ||
- upper === "FINE" ||
- upper === "FINER" ||
- upper === "FINEST"
- )
- return "debug";
- if (upper === "FATAL") return "fatal";
- return "info";
-}
-
-export class LogsState {
- logs = $state<LogEntry[]>([]);
- private nextId = 0;
- private maxLogs = 5000;
-
- // Track all unique sources for filtering
- sources = $state<Set<string>>(new Set(["Launcher"]));
-
- constructor() {
- this.addLog("info", "Launcher", "Logs initialized");
- }
-
- addLog(level: LogEntry["level"], source: string, message: string) {
- const now = new Date();
- const timestamp =
- now.toLocaleTimeString() + "." + now.getMilliseconds().toString().padStart(3, "0");
-
- this.logs.push({
- id: this.nextId++,
- timestamp,
- level,
- source,
- message,
- });
-
- // Track source
- if (!this.sources.has(source)) {
- this.sources = new Set([...this.sources, source]);
- }
-
- if (this.logs.length > this.maxLogs) {
- this.logs.shift();
- }
- }
-
- // Parse game output and extract level/source
- addGameLog(rawLine: string, isStderr: boolean) {
- const match = rawLine.match(GAME_LOG_REGEX);
-
- if (match) {
- const [, thread, levelStr, extraSource, message] = match;
- const level = parseGameLogLevel(levelStr);
- // Use extraSource if available, otherwise use thread name as source hint
- const source = extraSource || `Game/${thread.split("-")[0]}`;
- this.addLog(level, source, message);
- } else {
- // Fallback: couldn't parse, use stderr as error indicator
- const level = isStderr ? "error" : "info";
- this.addLog(level, "Game", rawLine);
- }
- }
-
- clear() {
- this.logs = [];
- this.sources = new Set(["Launcher"]);
- this.addLog("info", "Launcher", "Logs cleared");
- }
-
- // Export with filter support
- exportLogs(filteredLogs: LogEntry[]): string {
- return filteredLogs
- .map((l) => `[${l.timestamp}] [${l.source}/${l.level.toUpperCase()}] ${l.message}`)
- .join("\n");
- }
-
- private initialized = false;
-
- async init() {
- if (this.initialized) return;
- this.initialized = true;
-
- // General Launcher Logs
- await listen<string>("launcher-log", (e) => {
- this.addLog("info", "Launcher", e.payload);
- });
-
- // Game Stdout - parse log level
- await listen<string>("game-stdout", (e) => {
- this.addGameLog(e.payload, false);
- });
-
- // Game Stderr - parse log level, default to error
- await listen<string>("game-stderr", (e) => {
- this.addGameLog(e.payload, true);
- });
-
- // Download Events (Summarized)
- await listen("download-start", (e) => {
- this.addLog("info", "Downloader", `Starting batch download of ${e.payload} files...`);
- });
-
- await listen("download-complete", () => {
- this.addLog("info", "Downloader", "All downloads completed.");
- });
-
- // Listen to file download progress to log finished files
- await listen<any>("download-progress", (e) => {
- const p = e.payload;
- if (p.status === "Finished") {
- if (p.file.endsWith(".jar")) {
- this.addLog("info", "Downloader", `Downloaded ${p.file}`);
- }
- }
- });
-
- // Java Download
- await listen<any>("java-download-progress", (e) => {
- const p = e.payload;
- if (p.status === "Downloading" && p.percentage === 0) {
- this.addLog("info", "JavaInstaller", `Downloading Java: ${p.file_name}`);
- } else if (p.status === "Completed") {
- this.addLog("info", "JavaInstaller", `Java installed: ${p.file_name}`);
- } else if (p.status === "Error") {
- this.addLog("error", "JavaInstaller", `Java download error`);
- }
- });
- }
-}
-
-export const logsState = new LogsState();
diff --git a/packages/ui/src/stores/releases-store.ts b/packages/ui/src/stores/releases-store.ts
new file mode 100644
index 0000000..56afa08
--- /dev/null
+++ b/packages/ui/src/stores/releases-store.ts
@@ -0,0 +1,63 @@
+import { invoke } from "@tauri-apps/api/core";
+import { create } from "zustand";
+import type { GithubRelease } from "@/types/bindings/core";
+
+interface ReleasesState {
+ // State
+ releases: GithubRelease[];
+ isLoading: boolean;
+ isLoaded: boolean;
+ error: string | null;
+
+ // Actions
+ loadReleases: () => Promise<void>;
+ setReleases: (releases: GithubRelease[]) => void;
+ setIsLoading: (isLoading: boolean) => void;
+ setIsLoaded: (isLoaded: boolean) => void;
+ setError: (error: string | null) => void;
+}
+
+export const useReleasesStore = create<ReleasesState>((set, get) => ({
+ // Initial state
+ releases: [],
+ isLoading: false,
+ isLoaded: false,
+ error: null,
+
+ // Actions
+ loadReleases: async () => {
+ const { isLoaded, isLoading } = get();
+
+ // If already loaded or currently loading, skip to prevent duplicate requests
+ if (isLoaded || isLoading) return;
+
+ set({ isLoading: true, error: null });
+
+ try {
+ const releases = await invoke<GithubRelease[]>("get_github_releases");
+ set({ releases, isLoaded: true });
+ } catch (e) {
+ const error = e instanceof Error ? e.message : String(e);
+ console.error("Failed to load releases:", e);
+ set({ error });
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ setReleases: (releases) => {
+ set({ releases });
+ },
+
+ setIsLoading: (isLoading) => {
+ set({ isLoading });
+ },
+
+ setIsLoaded: (isLoaded) => {
+ set({ isLoaded });
+ },
+
+ setError: (error) => {
+ set({ error });
+ },
+}));
diff --git a/packages/ui/src/stores/releases.svelte.ts b/packages/ui/src/stores/releases.svelte.ts
deleted file mode 100644
index c858abb..0000000
--- a/packages/ui/src/stores/releases.svelte.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-
-export interface GithubRelease {
- tag_name: string;
- name: string;
- published_at: string;
- body: string;
- html_url: string;
-}
-
-export class ReleasesState {
- releases = $state<GithubRelease[]>([]);
- isLoading = $state(false);
- isLoaded = $state(false);
- error = $state<string | null>(null);
-
- async loadReleases() {
- // If already loaded or currently loading, skip to prevent duplicate requests
- if (this.isLoaded || this.isLoading) return;
-
- this.isLoading = true;
- this.error = null;
-
- try {
- this.releases = await invoke<GithubRelease[]>("get_github_releases");
- this.isLoaded = true;
- } catch (e) {
- console.error("Failed to load releases:", e);
- this.error = String(e);
- } finally {
- this.isLoading = false;
- }
- }
-}
-
-export const releasesState = new ReleasesState();
diff --git a/packages/ui/src/stores/settings-store.ts b/packages/ui/src/stores/settings-store.ts
new file mode 100644
index 0000000..0bfc1e1
--- /dev/null
+++ b/packages/ui/src/stores/settings-store.ts
@@ -0,0 +1,568 @@
+import { convertFileSrc, invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+import { toast } from "sonner";
+import { create } from "zustand";
+import { downloadAdoptiumJava } from "@/client";
+import type { ModelInfo } from "../types/bindings/assistant";
+import type { LauncherConfig } from "../types/bindings/config";
+import type {
+ JavaDownloadProgress,
+ PendingJavaDownload,
+} from "../types/bindings/downloader";
+import type {
+ JavaCatalog,
+ JavaInstallation,
+ JavaReleaseInfo,
+} from "../types/bindings/java";
+
+type JavaDownloadSource = "adoptium" | "mojang" | "azul";
+
+/**
+ * State shape for settings store.
+ *
+ * Note: Uses camelCase naming to match ts-rs generated bindings (which now use
+ * `serde(rename_all = "camelCase")`). When reading raw binding objects from
+ * invoke, convert/mapping should be applied where necessary.
+ */
+interface SettingsState {
+ // State
+ settings: LauncherConfig;
+ javaInstallations: JavaInstallation[];
+ isDetectingJava: boolean;
+ showJavaDownloadModal: boolean;
+ selectedDownloadSource: JavaDownloadSource;
+ javaCatalog: JavaCatalog | null;
+ isLoadingCatalog: boolean;
+ catalogError: string;
+ selectedMajorVersion: number | null;
+ selectedImageType: "jre" | "jdk";
+ showOnlyRecommended: boolean;
+ searchQuery: string;
+ isDownloadingJava: boolean;
+ downloadProgress: JavaDownloadProgress | null;
+ javaDownloadStatus: string;
+ pendingDownloads: PendingJavaDownload[];
+ ollamaModels: ModelInfo[];
+ openaiModels: ModelInfo[];
+ isLoadingOllamaModels: boolean;
+ isLoadingOpenaiModels: boolean;
+ ollamaModelsError: string;
+ openaiModelsError: string;
+ showConfigEditor: boolean;
+ rawConfigContent: string;
+ configFilePath: string;
+ configEditorError: string;
+
+ // Computed / derived
+ backgroundUrl: string | undefined;
+ filteredReleases: JavaReleaseInfo[];
+ availableMajorVersions: number[];
+ installStatus: (
+ version: number,
+ imageType: string,
+ ) => "installed" | "downloading" | "available";
+ selectedRelease: JavaReleaseInfo | null;
+ currentModelOptions: Array<{
+ value: string;
+ label: string;
+ details?: string;
+ }>;
+
+ // Actions
+ loadSettings: () => Promise<void>;
+ saveSettings: () => Promise<void>;
+ // compatibility helper to mirror the older set({ key: value }) usage
+ set: (patch: Partial<Record<string, unknown>>) => void;
+
+ detectJava: () => Promise<void>;
+ selectJava: (path: string) => void;
+
+ openJavaDownloadModal: () => Promise<void>;
+ closeJavaDownloadModal: () => void;
+ loadJavaCatalog: (forceRefresh: boolean) => Promise<void>;
+ refreshCatalog: () => Promise<void>;
+ loadPendingDownloads: () => Promise<void>;
+ selectMajorVersion: (version: number) => void;
+ downloadJava: () => Promise<void>;
+ cancelDownload: () => Promise<void>;
+ resumeDownloads: () => Promise<void>;
+
+ openConfigEditor: () => Promise<void>;
+ closeConfigEditor: () => void;
+ saveRawConfig: () => Promise<void>;
+
+ loadOllamaModels: () => Promise<void>;
+ loadOpenaiModels: () => Promise<void>;
+
+ setSetting: <K extends keyof LauncherConfig>(
+ key: K,
+ value: LauncherConfig[K],
+ ) => void;
+ setAssistantSetting: <K extends keyof LauncherConfig["assistant"]>(
+ key: K,
+ value: LauncherConfig["assistant"][K],
+ ) => void;
+ setFeatureFlag: <K extends keyof LauncherConfig["featureFlags"]>(
+ key: K,
+ value: LauncherConfig["featureFlags"][K],
+ ) => void;
+
+ // Private
+ progressUnlisten: UnlistenFn | null;
+}
+
+/**
+ * Default settings (camelCase) — lightweight defaults used until `get_settings`
+ * returns real values.
+ */
+const defaultSettings: LauncherConfig = {
+ minMemory: 1024,
+ maxMemory: 2048,
+ javaPath: "java",
+ width: 854,
+ height: 480,
+ downloadThreads: 32,
+ enableGpuAcceleration: false,
+ enableVisualEffects: true,
+ activeEffect: "constellation",
+ theme: "dark",
+ customBackgroundPath: null,
+ logUploadService: "paste.rs",
+ pastebinApiKey: null,
+ assistant: {
+ enabled: true,
+ llmProvider: "ollama",
+ ollamaEndpoint: "http://localhost:11434",
+ ollamaModel: "llama3",
+ openaiApiKey: null,
+ openaiEndpoint: "https://api.openai.com/v1",
+ openaiModel: "gpt-3.5-turbo",
+ systemPrompt:
+ "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.",
+ responseLanguage: "auto",
+ ttsEnabled: false,
+ ttsProvider: "disabled",
+ },
+ useSharedCaches: false,
+ keepLegacyPerInstanceStorage: true,
+ featureFlags: {
+ demoUser: false,
+ quickPlayEnabled: false,
+ quickPlayPath: null,
+ quickPlaySingleplayer: true,
+ quickPlayMultiplayerServer: null,
+ },
+};
+
+export const useSettingsStore = create<SettingsState>((set, get) => ({
+ // initial state
+ settings: defaultSettings,
+ javaInstallations: [],
+ isDetectingJava: false,
+ showJavaDownloadModal: false,
+ selectedDownloadSource: "adoptium",
+ javaCatalog: null,
+ isLoadingCatalog: false,
+ catalogError: "",
+ selectedMajorVersion: null,
+ selectedImageType: "jre",
+ showOnlyRecommended: true,
+ searchQuery: "",
+ isDownloadingJava: false,
+ downloadProgress: null,
+ javaDownloadStatus: "",
+ pendingDownloads: [],
+ ollamaModels: [],
+ openaiModels: [],
+ isLoadingOllamaModels: false,
+ isLoadingOpenaiModels: false,
+ ollamaModelsError: "",
+ openaiModelsError: "",
+ showConfigEditor: false,
+ rawConfigContent: "",
+ configFilePath: "",
+ configEditorError: "",
+ progressUnlisten: null,
+
+ // derived getters
+ get backgroundUrl() {
+ const { settings } = get();
+ if (settings.customBackgroundPath) {
+ return convertFileSrc(settings.customBackgroundPath);
+ }
+ return undefined;
+ },
+
+ get filteredReleases() {
+ const {
+ javaCatalog,
+ selectedMajorVersion,
+ selectedImageType,
+ showOnlyRecommended,
+ searchQuery,
+ } = get();
+
+ if (!javaCatalog) return [];
+
+ let releases = javaCatalog.releases;
+
+ if (selectedMajorVersion !== null) {
+ releases = releases.filter(
+ (r) => r.majorVersion === selectedMajorVersion,
+ );
+ }
+
+ releases = releases.filter((r) => r.imageType === selectedImageType);
+
+ if (showOnlyRecommended) {
+ releases = releases.filter((r) => r.isLts);
+ }
+
+ if (searchQuery.trim() !== "") {
+ const q = searchQuery.toLowerCase();
+ releases = releases.filter(
+ (r) =>
+ r.version.toLowerCase().includes(q) ||
+ (r.releaseName ?? "").toLowerCase().includes(q),
+ );
+ }
+
+ // sort newest-first by parsed version number
+ return releases.sort((a, b) => {
+ const aVer = parseFloat(a.version.split("-")[0]);
+ const bVer = parseFloat(b.version.split("-")[0]);
+ return bVer - aVer;
+ });
+ },
+
+ get availableMajorVersions() {
+ return get().javaCatalog?.availableMajorVersions || [];
+ },
+
+ installStatus: (version: number, imageType: string) => {
+ const {
+ javaInstallations,
+ pendingDownloads,
+ isDownloadingJava,
+ downloadProgress,
+ } = get();
+
+ const installed = javaInstallations.some(
+ (inst) => parseInt(inst.version.split(".")[0], 10) === version,
+ );
+ if (installed) return "installed";
+
+ if (
+ isDownloadingJava &&
+ downloadProgress?.fileName?.includes(`${version}`)
+ ) {
+ return "downloading";
+ }
+
+ const pending = pendingDownloads.some(
+ (d) => d.majorVersion === version && d.imageType === imageType,
+ );
+ if (pending) return "downloading";
+
+ return "available";
+ },
+
+ get selectedRelease() {
+ const { javaCatalog, selectedMajorVersion, selectedImageType } = get();
+ if (!javaCatalog || selectedMajorVersion === null) return null;
+ return (
+ javaCatalog.releases.find(
+ (r) =>
+ r.majorVersion === selectedMajorVersion &&
+ r.imageType === selectedImageType,
+ ) || null
+ );
+ },
+
+ get currentModelOptions() {
+ const { settings, ollamaModels, openaiModels } = get();
+ const provider = settings.assistant.llmProvider;
+ if (provider === "ollama") {
+ return ollamaModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.details || m.size || "",
+ }));
+ } else {
+ return openaiModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.details || "",
+ }));
+ }
+ },
+
+ // actions
+ loadSettings: async () => {
+ try {
+ const result = await invoke<LauncherConfig>("get_settings");
+ // result already uses camelCase fields from bindings
+ set({ settings: result });
+
+ // enforce dark theme at app-level if necessary
+ if (result.theme !== "dark") {
+ const updated = { ...result, theme: "dark" } as LauncherConfig;
+ set({ settings: updated });
+ await invoke("save_settings", { config: updated });
+ }
+
+ // ensure customBackgroundPath is undefined rather than null for reactiveness
+ if (!result.customBackgroundPath) {
+ set((s) => ({
+ settings: { ...s.settings, customBackgroundPath: null },
+ }));
+ }
+ } catch (e) {
+ console.error("Failed to load settings:", e);
+ }
+ },
+
+ saveSettings: async () => {
+ try {
+ const { settings } = get();
+
+ // Clean up empty strings to null where appropriate
+ if ((settings.customBackgroundPath ?? "") === "") {
+ set((state) => ({
+ settings: { ...state.settings, customBackgroundPath: null },
+ }));
+ }
+
+ await invoke("save_settings", { config: settings });
+ toast.success("Settings saved!");
+ } catch (e) {
+ console.error("Failed to save settings:", e);
+ toast.error(`Error saving settings: ${String(e)}`);
+ }
+ },
+
+ set: (patch: Partial<Record<string, unknown>>) => {
+ set(patch);
+ },
+
+ detectJava: async () => {
+ set({ isDetectingJava: true });
+ try {
+ const installs = await invoke<JavaInstallation[]>("detect_java");
+ set({ javaInstallations: installs });
+ if (installs.length === 0) toast.info("No Java installations found");
+ else toast.success(`Found ${installs.length} Java installation(s)`);
+ } catch (e) {
+ console.error("Failed to detect Java:", e);
+ toast.error(`Error detecting Java: ${String(e)}`);
+ } finally {
+ set({ isDetectingJava: false });
+ }
+ },
+
+ selectJava: (path: string) => {
+ set((s) => ({ settings: { ...s.settings, javaPath: path } }));
+ },
+
+ openJavaDownloadModal: async () => {
+ set({
+ showJavaDownloadModal: true,
+ javaDownloadStatus: "",
+ catalogError: "",
+ downloadProgress: null,
+ });
+
+ // attach event listener for download progress
+ const state = get();
+ if (state.progressUnlisten) {
+ state.progressUnlisten();
+ }
+
+ const unlisten = await listen<JavaDownloadProgress>(
+ "java-download-progress",
+ (event) => {
+ set({ downloadProgress: event.payload });
+ },
+ );
+
+ set({ progressUnlisten: unlisten });
+
+ // load catalog and pending downloads
+ await get().loadJavaCatalog(false);
+ await get().loadPendingDownloads();
+ },
+
+ closeJavaDownloadModal: () => {
+ const { isDownloadingJava, progressUnlisten } = get();
+
+ if (!isDownloadingJava) {
+ set({ showJavaDownloadModal: false });
+ if (progressUnlisten) {
+ try {
+ progressUnlisten();
+ } catch {
+ // ignore
+ }
+ set({ progressUnlisten: null });
+ }
+ }
+ },
+
+ loadJavaCatalog: async (forceRefresh: boolean) => {
+ set({ isLoadingCatalog: true, catalogError: "" });
+ try {
+ const cmd = forceRefresh ? "refresh_java_catalog" : "get_java_catalog";
+ const result = await invoke<JavaCatalog>(cmd);
+ set({ javaCatalog: result, isLoadingCatalog: false });
+ } catch (e) {
+ console.error("Failed to load Java catalog:", e);
+ set({ catalogError: String(e), isLoadingCatalog: false });
+ }
+ },
+
+ refreshCatalog: async () => {
+ await get().loadJavaCatalog(true);
+ },
+
+ loadPendingDownloads: async () => {
+ try {
+ const pending = await invoke<PendingJavaDownload[]>(
+ "get_pending_java_downloads",
+ );
+ set({ pendingDownloads: pending });
+ } catch (e) {
+ console.error("Failed to load pending downloads:", e);
+ }
+ },
+
+ selectMajorVersion: (version: number) => {
+ set({ selectedMajorVersion: version });
+ },
+
+ downloadJava: async () => {
+ const { selectedMajorVersion, selectedImageType, selectedDownloadSource } =
+ get();
+ if (!selectedMajorVersion) return;
+ set({ isDownloadingJava: true, javaDownloadStatus: "Starting..." });
+ try {
+ const result = await downloadAdoptiumJava(
+ selectedMajorVersion,
+ selectedImageType,
+ selectedDownloadSource,
+ );
+ set({
+ javaDownloadStatus: `Java ${selectedMajorVersion} download started: ${result.path}`,
+ });
+ toast.success("Download started");
+ } catch (e) {
+ console.error("Failed to download Java:", e);
+ toast.error(`Failed to start Java download: ${String(e)}`);
+ } finally {
+ set({ isDownloadingJava: false });
+ }
+ },
+
+ cancelDownload: async () => {
+ try {
+ await invoke("cancel_java_download");
+ toast.success("Cancelled Java download");
+ set({ isDownloadingJava: false, javaDownloadStatus: "" });
+ } catch (e) {
+ console.error("Failed to cancel download:", e);
+ toast.error(`Failed to cancel download: ${String(e)}`);
+ }
+ },
+
+ resumeDownloads: async () => {
+ try {
+ const installed = await invoke<boolean>("resume_java_downloads");
+ if (installed) toast.success("Resumed Java downloads");
+ else toast.info("No downloads to resume");
+ } catch (e) {
+ console.error("Failed to resume downloads:", e);
+ toast.error(`Failed to resume downloads: ${String(e)}`);
+ }
+ },
+
+ openConfigEditor: async () => {
+ try {
+ const path = await invoke<string>("get_config_path");
+ const content = await invoke<string>("read_config_raw");
+ set({
+ configFilePath: path,
+ rawConfigContent: content,
+ showConfigEditor: true,
+ });
+ } catch (e) {
+ console.error("Failed to open config editor:", e);
+ set({ configEditorError: String(e) });
+ }
+ },
+
+ closeConfigEditor: () => {
+ set({
+ showConfigEditor: false,
+ rawConfigContent: "",
+ configFilePath: "",
+ configEditorError: "",
+ });
+ },
+
+ saveRawConfig: async () => {
+ try {
+ await invoke("write_config_raw", { content: get().rawConfigContent });
+ toast.success("Config saved");
+ set({ showConfigEditor: false });
+ } catch (e) {
+ console.error("Failed to save config:", e);
+ set({ configEditorError: String(e) });
+ toast.error(`Failed to save config: ${String(e)}`);
+ }
+ },
+
+ loadOllamaModels: async () => {
+ set({ isLoadingOllamaModels: true, ollamaModelsError: "" });
+ try {
+ const models = await invoke<ModelInfo[]>("get_ollama_models");
+ set({ ollamaModels: models, isLoadingOllamaModels: false });
+ } catch (e) {
+ console.error("Failed to load Ollama models:", e);
+ set({ isLoadingOllamaModels: false, ollamaModelsError: String(e) });
+ }
+ },
+
+ loadOpenaiModels: async () => {
+ set({ isLoadingOpenaiModels: true, openaiModelsError: "" });
+ try {
+ const models = await invoke<ModelInfo[]>("get_openai_models");
+ set({ openaiModels: models, isLoadingOpenaiModels: false });
+ } catch (e) {
+ console.error("Failed to load OpenAI models:", e);
+ set({ isLoadingOpenaiModels: false, openaiModelsError: String(e) });
+ }
+ },
+
+ setSetting: (key, value) => {
+ set((s) => ({
+ settings: { ...s.settings, [key]: value } as unknown as LauncherConfig,
+ }));
+ },
+
+ setAssistantSetting: (key, value) => {
+ set((s) => ({
+ settings: {
+ ...s.settings,
+ assistant: { ...s.settings.assistant, [key]: value },
+ } as LauncherConfig,
+ }));
+ },
+
+ setFeatureFlag: (key, value) => {
+ set((s) => ({
+ settings: {
+ ...s.settings,
+ featureFlags: { ...s.settings.featureFlags, [key]: value },
+ } as LauncherConfig,
+ }));
+ },
+}));
diff --git a/packages/ui/src/stores/settings.svelte.ts b/packages/ui/src/stores/settings.svelte.ts
deleted file mode 100644
index 5d20050..0000000
--- a/packages/ui/src/stores/settings.svelte.ts
+++ /dev/null
@@ -1,570 +0,0 @@
-import { invoke } from "@tauri-apps/api/core";
-import { convertFileSrc } from "@tauri-apps/api/core";
-import { listen, type UnlistenFn } from "@tauri-apps/api/event";
-import type {
- JavaCatalog,
- JavaDownloadProgress,
- JavaDownloadSource,
- JavaInstallation,
- JavaReleaseInfo,
- LauncherConfig,
- ModelInfo,
- PendingJavaDownload,
-} from "../types";
-import { uiState } from "./ui.svelte";
-
-export class SettingsState {
- settings = $state<LauncherConfig>({
- min_memory: 1024,
- max_memory: 2048,
- java_path: "java",
- width: 854,
- height: 480,
- download_threads: 32,
- enable_gpu_acceleration: false,
- enable_visual_effects: true,
- active_effect: "constellation",
- theme: "dark",
- custom_background_path: undefined,
- log_upload_service: "paste.rs",
- pastebin_api_key: undefined,
- assistant: {
- enabled: true,
- llm_provider: "ollama",
- ollama_endpoint: "http://localhost:11434",
- ollama_model: "llama3",
- openai_api_key: undefined,
- openai_endpoint: "https://api.openai.com/v1",
- openai_model: "gpt-3.5-turbo",
- system_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.",
- response_language: "auto",
- tts_enabled: false,
- tts_provider: "disabled",
- },
- use_shared_caches: false,
- keep_legacy_per_instance_storage: true,
- feature_flags: {
- demo_user: false,
- quick_play_enabled: false,
- quick_play_path: undefined,
- quick_play_singleplayer: true,
- quick_play_multiplayer_server: undefined,
- },
- });
-
- // Convert background path to proper asset URL
- get backgroundUrl(): string | undefined {
- if (this.settings.custom_background_path) {
- return convertFileSrc(this.settings.custom_background_path);
- }
- return undefined;
- }
- javaInstallations = $state<JavaInstallation[]>([]);
- isDetectingJava = $state(false);
-
- // Java download modal state
- showJavaDownloadModal = $state(false);
- selectedDownloadSource = $state<JavaDownloadSource>("adoptium");
-
- // Java catalog state
- javaCatalog = $state<JavaCatalog | null>(null);
- isLoadingCatalog = $state(false);
- catalogError = $state("");
-
- // Version selection state
- selectedMajorVersion = $state<number | null>(null);
- selectedImageType = $state<"jre" | "jdk">("jre");
- showOnlyRecommended = $state(true);
- searchQuery = $state("");
-
- // Download progress state
- isDownloadingJava = $state(false);
- downloadProgress = $state<JavaDownloadProgress | null>(null);
- javaDownloadStatus = $state("");
-
- // Pending downloads
- pendingDownloads = $state<PendingJavaDownload[]>([]);
-
- // AI Model lists
- ollamaModels = $state<ModelInfo[]>([]);
- openaiModels = $state<ModelInfo[]>([]);
- isLoadingOllamaModels = $state(false);
- isLoadingOpenaiModels = $state(false);
- ollamaModelsError = $state("");
- openaiModelsError = $state("");
-
- // Config Editor state
- showConfigEditor = $state(false);
- rawConfigContent = $state("");
- configFilePath = $state("");
- configEditorError = $state("");
-
- // Event listener cleanup
- private progressUnlisten: UnlistenFn | null = null;
-
- async openConfigEditor() {
- this.configEditorError = "";
- try {
- const path = await invoke<string>("get_config_path");
- const content = await invoke<string>("read_raw_config");
- this.configFilePath = path;
- this.rawConfigContent = content;
- this.showConfigEditor = true;
- } catch (e) {
- console.error("Failed to open config editor:", e);
- uiState.setStatus(`Failed to open config: ${e}`);
- }
- }
-
- async saveRawConfig(content: string, closeAfterSave = true) {
- try {
- await invoke("save_raw_config", { content });
- // Reload settings to ensure UI is in sync
- await this.loadSettings();
- if (closeAfterSave) {
- this.showConfigEditor = false;
- }
- uiState.setStatus("Configuration saved successfully!");
- } catch (e) {
- console.error("Failed to save config:", e);
- this.configEditorError = String(e);
- }
- }
-
- closeConfigEditor() {
- this.showConfigEditor = false;
- this.rawConfigContent = "";
- this.configEditorError = "";
- }
-
- // Computed: filtered releases based on selection
- get filteredReleases(): JavaReleaseInfo[] {
- if (!this.javaCatalog) return [];
-
- let releases = this.javaCatalog.releases;
-
- // Filter by major version if selected
- if (this.selectedMajorVersion !== null) {
- releases = releases.filter((r) => r.major_version === this.selectedMajorVersion);
- }
-
- // Filter by image type
- releases = releases.filter((r) => r.image_type === this.selectedImageType);
-
- // Filter by recommended (LTS) versions
- if (this.showOnlyRecommended) {
- releases = releases.filter((r) => r.is_lts);
- }
-
- // Filter by search query
- if (this.searchQuery.trim()) {
- const query = this.searchQuery.toLowerCase();
- releases = releases.filter(
- (r) =>
- r.release_name.toLowerCase().includes(query) ||
- r.version.toLowerCase().includes(query) ||
- r.major_version.toString().includes(query),
- );
- }
-
- return releases;
- }
-
- // Computed: available major versions for display
- get availableMajorVersions(): number[] {
- if (!this.javaCatalog) return [];
- let versions = [...this.javaCatalog.available_major_versions];
-
- // Filter by LTS if showOnlyRecommended is enabled
- if (this.showOnlyRecommended) {
- versions = versions.filter((v) => this.javaCatalog!.lts_versions.includes(v));
- }
-
- // Sort descending (newest first)
- return versions.sort((a, b) => b - a);
- }
-
- // Get installation status for a release: 'installed' | 'download'
- getInstallStatus(release: JavaReleaseInfo): "installed" | "download" {
- // Find installed Java that matches the major version and image type (by path pattern)
- const matchingInstallations = this.javaInstallations.filter((inst) => {
- // Check if this is a DropOut-managed Java (path contains temurin-XX-jre/jdk pattern)
- const pathLower = inst.path.toLowerCase();
- const pattern = `temurin-${release.major_version}-${release.image_type}`;
- return pathLower.includes(pattern);
- });
-
- // If any matching installation exists, it's installed
- return matchingInstallations.length > 0 ? "installed" : "download";
- }
-
- // Computed: selected release details
- get selectedRelease(): JavaReleaseInfo | null {
- if (!this.javaCatalog || this.selectedMajorVersion === null) return null;
- return (
- this.javaCatalog.releases.find(
- (r) =>
- r.major_version === this.selectedMajorVersion && r.image_type === this.selectedImageType,
- ) || null
- );
- }
-
- async loadSettings() {
- try {
- const result = await invoke<LauncherConfig>("get_settings");
- this.settings = result;
- // Force dark mode
- if (this.settings.theme !== "dark") {
- this.settings.theme = "dark";
- this.saveSettings();
- }
- // Ensure custom_background_path is reactive
- if (!this.settings.custom_background_path) {
- this.settings.custom_background_path = undefined;
- }
- } catch (e) {
- console.error("Failed to load settings:", e);
- }
- }
-
- async saveSettings() {
- try {
- // Ensure we clean up any invalid paths before saving
- if (this.settings.custom_background_path === "") {
- this.settings.custom_background_path = undefined;
- }
-
- await invoke("save_settings", { config: this.settings });
- uiState.setStatus("Settings saved!");
- } catch (e) {
- console.error("Failed to save settings:", e);
- uiState.setStatus("Error saving settings: " + e);
- }
- }
-
- async detectJava() {
- this.isDetectingJava = true;
- try {
- this.javaInstallations = await invoke("detect_java");
- if (this.javaInstallations.length === 0) {
- uiState.setStatus("No Java installations found");
- } else {
- uiState.setStatus(`Found ${this.javaInstallations.length} Java installation(s)`);
- }
- } catch (e) {
- console.error("Failed to detect Java:", e);
- uiState.setStatus("Error detecting Java: " + e);
- } finally {
- this.isDetectingJava = false;
- }
- }
-
- selectJava(path: string) {
- this.settings.java_path = path;
- }
-
- async openJavaDownloadModal() {
- this.showJavaDownloadModal = true;
- this.javaDownloadStatus = "";
- this.catalogError = "";
- this.downloadProgress = null;
-
- // Setup progress event listener
- await this.setupProgressListener();
-
- // Load catalog
- await this.loadJavaCatalog(false);
-
- // Check for pending downloads
- await this.loadPendingDownloads();
- }
-
- async closeJavaDownloadModal() {
- if (!this.isDownloadingJava) {
- this.showJavaDownloadModal = false;
- // Cleanup listener
- if (this.progressUnlisten) {
- this.progressUnlisten();
- this.progressUnlisten = null;
- }
- }
- }
-
- private async setupProgressListener() {
- if (this.progressUnlisten) {
- this.progressUnlisten();
- }
-
- this.progressUnlisten = await listen<JavaDownloadProgress>(
- "java-download-progress",
- (event) => {
- this.downloadProgress = event.payload;
- this.javaDownloadStatus = event.payload.status;
-
- if (event.payload.status === "Completed") {
- this.isDownloadingJava = false;
- setTimeout(async () => {
- await this.detectJava();
- uiState.setStatus(`Java installed successfully!`);
- }, 500);
- } else if (event.payload.status === "Error") {
- this.isDownloadingJava = false;
- }
- },
- );
- }
-
- async loadJavaCatalog(forceRefresh: boolean) {
- this.isLoadingCatalog = true;
- this.catalogError = "";
-
- try {
- const command = forceRefresh ? "refresh_java_catalog" : "fetch_java_catalog";
- this.javaCatalog = await invoke<JavaCatalog>(command);
-
- // Auto-select first LTS version
- if (this.selectedMajorVersion === null && this.javaCatalog.lts_versions.length > 0) {
- // Select most recent LTS (21 or highest)
- const ltsVersions = [...this.javaCatalog.lts_versions].sort((a, b) => b - a);
- this.selectedMajorVersion = ltsVersions[0];
- }
- } catch (e) {
- console.error("Failed to load Java catalog:", e);
- this.catalogError = `Failed to load Java catalog: ${e}`;
- } finally {
- this.isLoadingCatalog = false;
- }
- }
-
- async refreshCatalog() {
- await this.loadJavaCatalog(true);
- uiState.setStatus("Java catalog refreshed");
- }
-
- async loadPendingDownloads() {
- try {
- this.pendingDownloads = await invoke<PendingJavaDownload[]>("get_pending_java_downloads");
- } catch (e) {
- console.error("Failed to load pending downloads:", e);
- }
- }
-
- selectMajorVersion(version: number) {
- this.selectedMajorVersion = version;
- }
-
- async downloadJava() {
- if (!this.selectedRelease || !this.selectedRelease.is_available) {
- uiState.setStatus("Selected Java version is not available for this platform");
- return;
- }
-
- this.isDownloadingJava = true;
- this.javaDownloadStatus = "Starting download...";
- this.downloadProgress = null;
-
- try {
- const result: JavaInstallation = await invoke("download_adoptium_java", {
- majorVersion: this.selectedMajorVersion,
- imageType: this.selectedImageType,
- customPath: null,
- });
-
- this.settings.java_path = result.path;
- await this.detectJava();
-
- setTimeout(() => {
- this.showJavaDownloadModal = false;
- uiState.setStatus(`Java ${this.selectedMajorVersion} is ready to use!`);
- }, 1500);
- } catch (e) {
- console.error("Failed to download Java:", e);
- this.javaDownloadStatus = `Download failed: ${e}`;
- } finally {
- this.isDownloadingJava = false;
- }
- }
-
- async cancelDownload() {
- try {
- await invoke("cancel_java_download");
- this.isDownloadingJava = false;
- this.javaDownloadStatus = "Download cancelled";
- this.downloadProgress = null;
- await this.loadPendingDownloads();
- } catch (e) {
- console.error("Failed to cancel download:", e);
- }
- }
-
- async resumeDownloads() {
- if (this.pendingDownloads.length === 0) return;
-
- this.isDownloadingJava = true;
- this.javaDownloadStatus = "Resuming download...";
-
- try {
- const installed = await invoke<JavaInstallation[]>("resume_java_downloads");
- if (installed.length > 0) {
- this.settings.java_path = installed[0].path;
- await this.detectJava();
- uiState.setStatus(`Resumed and installed ${installed.length} Java version(s)`);
- }
- await this.loadPendingDownloads();
- } catch (e) {
- console.error("Failed to resume downloads:", e);
- this.javaDownloadStatus = `Resume failed: ${e}`;
- } finally {
- this.isDownloadingJava = false;
- }
- }
-
- // Format bytes to human readable
- formatBytes(bytes: number): string {
- if (bytes === 0) return "0 B";
- const k = 1024;
- const sizes = ["B", "KB", "MB", "GB"];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
- }
-
- // Format seconds to human readable
- formatTime(seconds: number): string {
- if (seconds === 0 || !isFinite(seconds)) return "--";
- if (seconds < 60) return `${Math.round(seconds)}s`;
- if (seconds < 3600) {
- const mins = Math.floor(seconds / 60);
- const secs = Math.round(seconds % 60);
- return `${mins}m ${secs}s`;
- }
- const hours = Math.floor(seconds / 3600);
- const mins = Math.floor((seconds % 3600) / 60);
- return `${hours}h ${mins}m`;
- }
-
- // Format date string
- formatDate(dateStr: string | null): string {
- if (!dateStr) return "--";
- try {
- const date = new Date(dateStr);
- return date.toLocaleDateString("en-US", {
- year: "2-digit",
- month: "2-digit",
- day: "2-digit",
- });
- } catch {
- return "--";
- }
- }
-
- // Legacy compatibility
- get availableJavaVersions(): number[] {
- return this.availableMajorVersions;
- }
-
- // AI Model loading methods
- async loadOllamaModels() {
- this.isLoadingOllamaModels = true;
- this.ollamaModelsError = "";
-
- try {
- const models = await invoke<ModelInfo[]>("list_ollama_models", {
- endpoint: this.settings.assistant.ollama_endpoint,
- });
- this.ollamaModels = models;
-
- // If no model is selected or selected model isn't available, select the first one
- if (models.length > 0) {
- const currentModel = this.settings.assistant.ollama_model;
- const modelExists = models.some((m) => m.id === currentModel);
- if (!modelExists) {
- this.settings.assistant.ollama_model = models[0].id;
- }
- }
- } catch (e) {
- console.error("Failed to load Ollama models:", e);
- this.ollamaModelsError = String(e);
- this.ollamaModels = [];
- } finally {
- this.isLoadingOllamaModels = false;
- }
- }
-
- async loadOpenaiModels() {
- if (!this.settings.assistant.openai_api_key) {
- this.openaiModelsError = "API key required";
- this.openaiModels = [];
- return;
- }
-
- this.isLoadingOpenaiModels = true;
- this.openaiModelsError = "";
-
- try {
- const models = await invoke<ModelInfo[]>("list_openai_models");
- this.openaiModels = models;
-
- // If no model is selected or selected model isn't available, select the first one
- if (models.length > 0) {
- const currentModel = this.settings.assistant.openai_model;
- const modelExists = models.some((m) => m.id === currentModel);
- if (!modelExists) {
- this.settings.assistant.openai_model = models[0].id;
- }
- }
- } catch (e) {
- console.error("Failed to load OpenAI models:", e);
- this.openaiModelsError = String(e);
- this.openaiModels = [];
- } finally {
- this.isLoadingOpenaiModels = false;
- }
- }
-
- // Computed: get model options for current provider
- get currentModelOptions(): { value: string; label: string; details?: string }[] {
- const provider = this.settings.assistant.llm_provider;
-
- if (provider === "ollama") {
- if (this.ollamaModels.length === 0) {
- // Return fallback options if no models loaded
- return [
- { value: "llama3", label: "Llama 3" },
- { value: "llama3.1", label: "Llama 3.1" },
- { value: "llama3.2", label: "Llama 3.2" },
- { value: "mistral", label: "Mistral" },
- { value: "gemma2", label: "Gemma 2" },
- { value: "qwen2.5", label: "Qwen 2.5" },
- { value: "phi3", label: "Phi-3" },
- { value: "codellama", label: "Code Llama" },
- ];
- }
- return this.ollamaModels.map((m) => ({
- value: m.id,
- label: m.name,
- details: m.size ? `${m.size}${m.details ? ` - ${m.details}` : ""}` : m.details,
- }));
- } else if (provider === "openai") {
- if (this.openaiModels.length === 0) {
- // Return fallback options if no models loaded
- return [
- { value: "gpt-4o", label: "GPT-4o" },
- { value: "gpt-4o-mini", label: "GPT-4o Mini" },
- { value: "gpt-4-turbo", label: "GPT-4 Turbo" },
- { value: "gpt-4", label: "GPT-4" },
- { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
- ];
- }
- return this.openaiModels.map((m) => ({
- value: m.id,
- label: m.name,
- details: m.details,
- }));
- }
-
- return [];
- }
-}
-
-export const settingsState = new SettingsState();
diff --git a/packages/ui/src/stores/ui-store.ts b/packages/ui/src/stores/ui-store.ts
new file mode 100644
index 0000000..89b9191
--- /dev/null
+++ b/packages/ui/src/stores/ui-store.ts
@@ -0,0 +1,42 @@
+import { create } from "zustand";
+
+export type ViewType = "home" | "versions" | "settings" | "guide" | "instances";
+
+interface UIState {
+ // State
+ currentView: ViewType;
+ showConsole: boolean;
+ appVersion: string;
+
+ // Actions
+ toggleConsole: () => void;
+ setView: (view: ViewType) => void;
+ setAppVersion: (version: string) => void;
+}
+
+export const useUIStore = create<UIState>((set) => ({
+ // Initial state
+ currentView: "home",
+ showConsole: false,
+ appVersion: "...",
+
+ // Actions
+ toggleConsole: () => {
+ set((state) => ({ showConsole: !state.showConsole }));
+ },
+
+ setView: (view: ViewType) => {
+ set({ currentView: view });
+ },
+
+ setAppVersion: (version: string) => {
+ set({ appVersion: version });
+ },
+}));
+
+// Provide lowercase alias for compatibility with existing imports.
+// Use a function wrapper to ensure the named export exists as a callable value
+// at runtime (some bundlers/tree-shakers may remove simple aliases).
+export function useUiStore() {
+ return useUIStore();
+}
diff --git a/packages/ui/src/stores/ui.svelte.ts b/packages/ui/src/stores/ui.svelte.ts
deleted file mode 100644
index e88f6b4..0000000
--- a/packages/ui/src/stores/ui.svelte.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { type ViewType } from "../types";
-
-export class UIState {
- currentView: ViewType = $state("home");
- status = $state("Ready");
- showConsole = $state(false);
- appVersion = $state("...");
-
- private statusTimeout: ReturnType<typeof setTimeout> | null = null;
-
- setStatus(msg: string) {
- if (this.statusTimeout) clearTimeout(this.statusTimeout);
-
- this.status = msg;
-
- if (msg !== "Ready") {
- this.statusTimeout = setTimeout(() => {
- this.status = "Ready";
- }, 5000);
- }
- }
-
- toggleConsole() {
- this.showConsole = !this.showConsole;
- }
-
- setView(view: ViewType) {
- this.currentView = view;
- }
-}
-
-export const uiState = new UIState();
diff --git a/packages/ui/src/types/bindings/account.ts b/packages/ui/src/types/bindings/account.ts
new file mode 100644
index 0000000..168d138
--- /dev/null
+++ b/packages/ui/src/types/bindings/account.ts
@@ -0,0 +1,28 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+import type { OfflineAccount } from "./auth";
+
+export type AccountStorage = { file_path: string };
+
+/**
+ * Stored account data for persistence
+ */
+export type AccountStore = {
+ accounts: Array<StoredAccount>;
+ active_account_id: string | null;
+};
+
+export type StoredAccount =
+ | ({ type: "Offline" } & OfflineAccount)
+ | ({ type: "Microsoft" } & StoredMicrosoftAccount);
+
+/**
+ * Microsoft account with refresh token for persistence
+ */
+export type StoredMicrosoftAccount = {
+ username: string;
+ uuid: string;
+ access_token: string;
+ refresh_token: string | null;
+ ms_refresh_token: string | null;
+ expires_at: bigint;
+};
diff --git a/packages/ui/src/types/bindings/assistant.ts b/packages/ui/src/types/bindings/assistant.ts
new file mode 100644
index 0000000..827f008
--- /dev/null
+++ b/packages/ui/src/types/bindings/assistant.ts
@@ -0,0 +1,25 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type GenerationStats = {
+ totalDuration: bigint;
+ loadDuration: bigint;
+ promptEvalCount: bigint;
+ promptEvalDuration: bigint;
+ evalCount: bigint;
+ evalDuration: bigint;
+};
+
+export type Message = { role: string; content: string };
+
+export type ModelInfo = {
+ id: string;
+ name: string;
+ size: string | null;
+ details: string | null;
+};
+
+export type StreamChunk = {
+ content: string;
+ done: boolean;
+ stats: GenerationStats | null;
+};
diff --git a/packages/ui/src/types/bindings/auth.ts b/packages/ui/src/types/bindings/auth.ts
new file mode 100644
index 0000000..563a924
--- /dev/null
+++ b/packages/ui/src/types/bindings/auth.ts
@@ -0,0 +1,32 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type Account =
+ | ({ type: "offline" } & OfflineAccount)
+ | ({ type: "microsoft" } & MicrosoftAccount);
+
+export type DeviceCodeResponse = {
+ userCode: string;
+ deviceCode: string;
+ verificationUri: string;
+ expiresIn: bigint;
+ interval: bigint;
+ message: string | null;
+};
+
+export type MicrosoftAccount = {
+ username: string;
+ uuid: string;
+ accessToken: string;
+ refreshToken: string | null;
+ expiresAt: bigint;
+};
+
+export type MinecraftProfile = { id: string; name: string };
+
+export type OfflineAccount = { username: string; uuid: string };
+
+export type TokenResponse = {
+ access_token: string;
+ refresh_token: string | null;
+ expires_in: bigint;
+};
diff --git a/packages/ui/src/types/bindings/config.ts b/packages/ui/src/types/bindings/config.ts
new file mode 100644
index 0000000..e9de4f5
--- /dev/null
+++ b/packages/ui/src/types/bindings/config.ts
@@ -0,0 +1,61 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type AssistantConfig = {
+ enabled: boolean;
+ llmProvider: string;
+ ollamaEndpoint: string;
+ ollamaModel: string;
+ openaiApiKey: string | null;
+ openaiEndpoint: string;
+ openaiModel: string;
+ systemPrompt: string;
+ responseLanguage: string;
+ ttsEnabled: boolean;
+ ttsProvider: string;
+};
+
+/**
+ * Feature-gated arguments configuration
+ */
+export type FeatureFlags = {
+ /**
+ * Demo user: enables demo-related arguments when rules require it
+ */
+ demoUser: boolean;
+ /**
+ * Quick Play: enable quick play arguments
+ */
+ quickPlayEnabled: boolean;
+ /**
+ * Quick Play singleplayer world path (if provided)
+ */
+ quickPlayPath: string | null;
+ /**
+ * Quick Play singleplayer flag
+ */
+ quickPlaySingleplayer: boolean;
+ /**
+ * Quick Play multiplayer server address (optional)
+ */
+ quickPlayMultiplayerServer: string | null;
+};
+
+export type LauncherConfig = {
+ minMemory: number;
+ maxMemory: number;
+ javaPath: string;
+ width: number;
+ height: number;
+ downloadThreads: number;
+ customBackgroundPath: string | null;
+ enableGpuAcceleration: boolean;
+ enableVisualEffects: boolean;
+ activeEffect: string;
+ theme: string;
+ logUploadService: string;
+ pastebinApiKey: string | null;
+ assistant: AssistantConfig;
+ useSharedCaches: boolean;
+ keepLegacyPerInstanceStorage: boolean;
+ featureFlags: FeatureFlags;
+};
diff --git a/packages/ui/src/types/bindings/core.ts b/packages/ui/src/types/bindings/core.ts
new file mode 100644
index 0000000..94e3bde
--- /dev/null
+++ b/packages/ui/src/types/bindings/core.ts
@@ -0,0 +1,47 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * File information for instance file browser
+ */
+export type FileInfo = {
+ name: string;
+ path: string;
+ isDirectory: boolean;
+ size: bigint;
+ modified: bigint;
+};
+
+export type GithubRelease = {
+ tagName: string;
+ name: string;
+ publishedAt: string;
+ body: string;
+ htmlUrl: string;
+};
+
+/**
+ * Installed version info
+ */
+export type InstalledVersion = { id: string; type: string };
+
+/**
+ * Migrate instance caches to shared global caches
+ */
+export type MigrationResult = {
+ movedFiles: number;
+ hardlinks: number;
+ copies: number;
+ savedBytes: bigint;
+ savedMb: number;
+};
+
+export type PastebinResponse = { url: string };
+
+/**
+ * Version metadata for display in the UI
+ */
+export type VersionMetadata = {
+ id: string;
+ javaVersion: bigint | null;
+ isInstalled: boolean;
+};
diff --git a/packages/ui/src/types/bindings/downloader.ts b/packages/ui/src/types/bindings/downloader.ts
new file mode 100644
index 0000000..f2be278
--- /dev/null
+++ b/packages/ui/src/types/bindings/downloader.ts
@@ -0,0 +1,73 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Metadata for resumable downloads stored in .part.meta file
+ */
+export type DownloadMetadata = {
+ url: string;
+ fileName: string;
+ totalSize: bigint;
+ downloadedBytes: bigint;
+ checksum: string | null;
+ timestamp: bigint;
+ segments: Array<DownloadSegment>;
+};
+
+/**
+ * Download queue for persistence
+ */
+export type DownloadQueue = { pendingDownloads: Array<PendingJavaDownload> };
+
+/**
+ * A download segment for multi-segment parallel downloading
+ */
+export type DownloadSegment = {
+ start: bigint;
+ end: bigint;
+ downloaded: bigint;
+ completed: boolean;
+};
+
+export type DownloadTask = {
+ url: string;
+ path: string;
+ sha1: string | null;
+ sha256: string | null;
+};
+
+/**
+ * Progress event for Java download
+ */
+export type JavaDownloadProgress = {
+ fileName: string;
+ downloadedBytes: bigint;
+ totalBytes: bigint;
+ speedBytesPerSec: bigint;
+ etaSeconds: bigint;
+ status: string;
+ percentage: number;
+};
+
+/**
+ * Pending download task for queue persistence
+ */
+export type PendingJavaDownload = {
+ majorVersion: number;
+ imageType: string;
+ downloadUrl: string;
+ fileName: string;
+ fileSize: bigint;
+ checksum: string | null;
+ installPath: string;
+ createdAt: bigint;
+};
+
+export type ProgressEvent = {
+ file: string;
+ downloaded: bigint;
+ total: bigint;
+ status: string;
+ completedFiles: number;
+ totalFiles: number;
+ totalDownloadedBytes: bigint;
+};
diff --git a/packages/ui/src/types/bindings/fabric.ts b/packages/ui/src/types/bindings/fabric.ts
new file mode 100644
index 0000000..181f8be
--- /dev/null
+++ b/packages/ui/src/types/bindings/fabric.ts
@@ -0,0 +1,74 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Represents a Minecraft version supported by Fabric.
+ */
+export type FabricGameVersion = { version: string; stable: boolean };
+
+/**
+ * Represents a Fabric intermediary mapping version.
+ */
+export type FabricIntermediaryVersion = {
+ maven: string;
+ version: string;
+ stable: boolean;
+};
+
+/**
+ * Launcher metadata from Fabric Meta API.
+ */
+export type FabricLauncherMeta = {
+ version: number;
+ libraries: FabricLibraries;
+ mainClass: FabricMainClass;
+};
+
+/**
+ * Libraries required by Fabric loader.
+ */
+export type FabricLibraries = {
+ client: Array<FabricLibrary>;
+ common: Array<FabricLibrary>;
+ server: Array<FabricLibrary>;
+};
+
+/**
+ * A single Fabric library dependency.
+ */
+export type FabricLibrary = { name: string; url: string | null };
+
+/**
+ * Represents a combined loader + intermediary version entry.
+ */
+export type FabricLoaderEntry = {
+ loader: FabricLoaderVersion;
+ intermediary: FabricIntermediaryVersion;
+ launcherMeta: FabricLauncherMeta;
+};
+
+/**
+ * Represents a Fabric loader version from the Meta API.
+ */
+export type FabricLoaderVersion = {
+ separator: string;
+ build: number;
+ maven: string;
+ version: string;
+ stable: boolean;
+};
+
+/**
+ * Main class configuration for Fabric.
+ * Can be either a struct with client/server fields or a simple string.
+ */
+export type FabricMainClass = { client: string; server: string } | string;
+
+/**
+ * Information about an installed Fabric version.
+ */
+export type InstalledFabricVersion = {
+ id: string;
+ minecraftVersion: string;
+ loaderVersion: string;
+ path: string;
+};
diff --git a/packages/ui/src/types/bindings/forge.ts b/packages/ui/src/types/bindings/forge.ts
new file mode 100644
index 0000000..a9790e7
--- /dev/null
+++ b/packages/ui/src/types/bindings/forge.ts
@@ -0,0 +1,21 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Represents a Forge version entry.
+ */
+export type ForgeVersion = {
+ version: string;
+ minecraftVersion: string;
+ recommended: boolean;
+ latest: boolean;
+};
+
+/**
+ * Information about an installed Forge version.
+ */
+export type InstalledForgeVersion = {
+ id: string;
+ minecraftVersion: string;
+ forgeVersion: string;
+ path: string;
+};
diff --git a/packages/ui/src/types/bindings/game-version.ts b/packages/ui/src/types/bindings/game-version.ts
new file mode 100644
index 0000000..1b1c395
--- /dev/null
+++ b/packages/ui/src/types/bindings/game-version.ts
@@ -0,0 +1,89 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type Arguments = {
+ game: Record<string, unknown>;
+ jvm: Record<string, unknown>;
+};
+
+export type AssetIndex = {
+ id: string;
+ sha1: string;
+ size: bigint;
+ url: string;
+ totalSize: bigint | null;
+};
+
+export type DownloadArtifact = {
+ sha1: string | null;
+ size: bigint | null;
+ url: string;
+ path: string | null;
+};
+
+export type Downloads = {
+ client: DownloadArtifact;
+ server: DownloadArtifact | null;
+};
+
+/**
+ * Represents a Minecraft version JSON, supporting both vanilla and modded (Fabric/Forge) formats.
+ * Modded versions use `inheritsFrom` to reference a parent vanilla version.
+ */
+export type GameVersion = {
+ id: string;
+ /**
+ * Optional for mod loaders that inherit from vanilla
+ */
+ downloads: Downloads | null;
+ /**
+ * Optional for mod loaders that inherit from vanilla
+ */
+ assetIndex: AssetIndex | null;
+ libraries: Array<Library>;
+ mainClass: string;
+ minecraftArguments: string | null;
+ arguments: Arguments | null;
+ javaVersion: JavaVersion | null;
+ /**
+ * For mod loaders: the vanilla version this inherits from
+ */
+ inheritsFrom: string | null;
+ /**
+ * Fabric/Forge may specify a custom assets version
+ */
+ assets: string | null;
+ /**
+ * Release type (release, snapshot, old_beta, etc.)
+ */
+ type: string | null;
+};
+
+export type JavaVersion = { component: string; majorVersion: bigint };
+
+export type Library = {
+ downloads: LibraryDownloads | null;
+ name: string;
+ rules: Array<Rule> | null;
+ natives: Record<string, unknown>;
+ /**
+ * Maven repository URL for mod loader libraries
+ */
+ url: string | null;
+};
+
+export type LibraryDownloads = {
+ artifact: DownloadArtifact | null;
+ classifiers: Record<string, unknown>;
+};
+
+export type OsRule = {
+ name: string | null;
+ version: string | null;
+ arch: string | null;
+};
+
+export type Rule = {
+ action: string;
+ os: OsRule | null;
+ features: Record<string, unknown>;
+};
diff --git a/packages/ui/src/types/bindings/index.ts b/packages/ui/src/types/bindings/index.ts
new file mode 100644
index 0000000..9bde037
--- /dev/null
+++ b/packages/ui/src/types/bindings/index.ts
@@ -0,0 +1,12 @@
+export * from "./account";
+export * from "./assistant";
+export * from "./auth";
+export * from "./config";
+export * from "./core";
+export * from "./downloader";
+export * from "./fabric";
+export * from "./forge";
+export * from "./game-version";
+export * from "./instance";
+export * from "./java";
+export * from "./manifest";
diff --git a/packages/ui/src/types/bindings/instance.ts b/packages/ui/src/types/bindings/instance.ts
new file mode 100644
index 0000000..2c4f8ae
--- /dev/null
+++ b/packages/ui/src/types/bindings/instance.ts
@@ -0,0 +1,33 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+/**
+ * Represents a game instance/profile
+ */
+export type Instance = {
+ id: string;
+ name: string;
+ gameDir: string;
+ versionId: string | null;
+ createdAt: bigint;
+ lastPlayed: bigint | null;
+ iconPath: string | null;
+ notes: string | null;
+ modLoader: string | null;
+ modLoaderVersion: string | null;
+ jvmArgsOverride: string | null;
+ memoryOverride: MemoryOverride | null;
+ javaPathOverride: string | null;
+};
+
+/**
+ * Configuration for all instances
+ */
+export type InstanceConfig = {
+ instances: Array<Instance>;
+ activeInstanceId: string | null;
+};
+
+/**
+ * Memory settings override for an instance
+ */
+export type MemoryOverride = { min: number; max: number };
diff --git a/packages/ui/src/types/bindings/java/core.ts b/packages/ui/src/types/bindings/java/core.ts
new file mode 100644
index 0000000..099dea9
--- /dev/null
+++ b/packages/ui/src/types/bindings/java/core.ts
@@ -0,0 +1,41 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type JavaCatalog = {
+ releases: Array<JavaReleaseInfo>;
+ availableMajorVersions: Array<number>;
+ ltsVersions: Array<number>;
+ cachedAt: bigint;
+};
+
+export type JavaDownloadInfo = {
+ version: string;
+ release_name: string;
+ download_url: string;
+ file_name: string;
+ file_size: bigint;
+ checksum: string | null;
+ image_type: string;
+};
+
+export type JavaInstallation = {
+ path: string;
+ version: string;
+ arch: string;
+ vendor: string;
+ source: string;
+ is64bit: boolean;
+};
+
+export type JavaReleaseInfo = {
+ majorVersion: number;
+ imageType: string;
+ version: string;
+ releaseName: string;
+ releaseDate: string | null;
+ fileSize: bigint;
+ checksum: string | null;
+ downloadUrl: string;
+ isLts: boolean;
+ isAvailable: boolean;
+ architecture: string;
+};
diff --git a/packages/ui/src/types/bindings/java/index.ts b/packages/ui/src/types/bindings/java/index.ts
new file mode 100644
index 0000000..2f2754c
--- /dev/null
+++ b/packages/ui/src/types/bindings/java/index.ts
@@ -0,0 +1,3 @@
+export * from "./core";
+export * from "./persistence";
+export * from "./providers";
diff --git a/packages/ui/src/types/bindings/java/persistence.ts b/packages/ui/src/types/bindings/java/persistence.ts
new file mode 100644
index 0000000..7a2b576
--- /dev/null
+++ b/packages/ui/src/types/bindings/java/persistence.ts
@@ -0,0 +1,7 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type JavaConfig = {
+ user_defined_paths: Array<string>;
+ preferred_java_path: string | null;
+ last_detection_time: bigint;
+};
diff --git a/packages/ui/src/types/bindings/java/providers/adoptium.ts b/packages/ui/src/types/bindings/java/providers/adoptium.ts
new file mode 100644
index 0000000..65fc42b
--- /dev/null
+++ b/packages/ui/src/types/bindings/java/providers/adoptium.ts
@@ -0,0 +1,37 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type AdoptiumAsset = {
+ binary: AdoptiumBinary;
+ release_name: string;
+ version: AdoptiumVersionData;
+};
+
+export type AdoptiumBinary = {
+ os: string;
+ architecture: string;
+ image_type: string;
+ package: AdoptiumPackage;
+ updated_at: string | null;
+};
+
+export type AdoptiumPackage = {
+ name: string;
+ link: string;
+ size: bigint;
+ checksum: string | null;
+};
+
+export type AdoptiumVersionData = {
+ major: number;
+ minor: number;
+ security: number;
+ semver: string;
+ openjdk_version: string;
+};
+
+export type AvailableReleases = {
+ available_releases: Array<number>;
+ available_lts_releases: Array<number>;
+ most_recent_lts: number | null;
+ most_recent_feature_release: number | null;
+};
diff --git a/packages/ui/src/types/bindings/java/providers/index.ts b/packages/ui/src/types/bindings/java/providers/index.ts
new file mode 100644
index 0000000..3e28711
--- /dev/null
+++ b/packages/ui/src/types/bindings/java/providers/index.ts
@@ -0,0 +1 @@
+export * from "./adoptium";
diff --git a/packages/ui/src/types/bindings/manifest.ts b/packages/ui/src/types/bindings/manifest.ts
new file mode 100644
index 0000000..2180962
--- /dev/null
+++ b/packages/ui/src/types/bindings/manifest.ts
@@ -0,0 +1,22 @@
+// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
+
+export type Latest = { release: string; snapshot: string };
+
+export type Version = {
+ id: string;
+ type: string;
+ url: string;
+ time: string;
+ releaseTime: string;
+ /**
+ * Java version requirement (major version number)
+ * This is populated from the version JSON file if the version is installed locally
+ */
+ javaVersion: bigint | null;
+ /**
+ * Whether this version is installed locally
+ */
+ isInstalled: boolean | null;
+};
+
+export type VersionManifest = { latest: Latest; versions: Array<Version> };
diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts
index b4412b8..9e592d7 100644
--- a/packages/ui/src/types/index.ts
+++ b/packages/ui/src/types/index.ts
@@ -1,232 +1 @@
-export type ViewType = "home" | "versions" | "settings" | "guide" | "instances";
-
-export interface Version {
- id: string;
- type: string;
- url: string;
- time: string;
- releaseTime: string;
- javaVersion?: number; // Java major version requirement (e.g., 8, 17, 21)
- isInstalled?: boolean; // Whether this version is installed locally
-}
-
-export interface Account {
- type: "Offline" | "Microsoft";
- username: string;
- uuid: string;
- access_token?: string;
- refresh_token?: string;
- expires_at?: number; // Unix timestamp for Microsoft accounts
-}
-
-export interface DeviceCodeResponse {
- user_code: string;
- device_code: string;
- verification_uri: string;
- expires_in: number;
- interval: number;
- message?: string;
-}
-
-export interface AssistantConfig {
- enabled: boolean;
- llm_provider: "ollama" | "openai";
- // Ollama settings
- ollama_endpoint: string;
- ollama_model: string;
- // OpenAI settings
- openai_api_key?: string;
- openai_endpoint: string;
- openai_model: string;
- // Common settings
- system_prompt: string;
- response_language: string;
- // TTS settings
- tts_enabled: boolean;
- tts_provider: string;
-}
-
-export interface ModelInfo {
- id: string;
- name: string;
- size?: string;
- details?: string;
-}
-
-export interface LauncherConfig {
- min_memory: number;
- max_memory: number;
- java_path: string;
- width: number;
- height: number;
- download_threads: number;
- custom_background_path?: string;
- enable_gpu_acceleration: boolean;
- enable_visual_effects: boolean;
- active_effect: string;
- theme: string;
- log_upload_service: "paste.rs" | "pastebin.com";
- pastebin_api_key?: string;
- assistant: AssistantConfig;
- // Storage management
- use_shared_caches: boolean;
- keep_legacy_per_instance_storage: boolean;
- // Feature-gated argument flags
- feature_flags: FeatureFlags;
-}
-
-export interface FeatureFlags {
- demo_user: boolean;
- quick_play_enabled: boolean;
- quick_play_path?: string;
- quick_play_singleplayer: boolean;
- quick_play_multiplayer_server?: string;
-}
-
-export interface JavaInstallation {
- path: string;
- version: string;
- is_64bit: boolean;
-}
-
-export interface JavaDownloadInfo {
- version: string;
- release_name: string;
- download_url: string;
- file_name: string;
- file_size: number;
- checksum: string | null;
- image_type: string;
-}
-
-export interface JavaReleaseInfo {
- major_version: number;
- image_type: string;
- version: string;
- release_name: string;
- release_date: string | null;
- file_size: number;
- checksum: string | null;
- download_url: string;
- is_lts: boolean;
- is_available: boolean;
- architecture: string;
-}
-
-export interface JavaCatalog {
- releases: JavaReleaseInfo[];
- available_major_versions: number[];
- lts_versions: number[];
- cached_at: number;
-}
-
-export interface JavaDownloadProgress {
- file_name: string;
- downloaded_bytes: number;
- total_bytes: number;
- speed_bytes_per_sec: number;
- eta_seconds: number;
- status: string;
- percentage: number;
-}
-
-export interface PendingJavaDownload {
- major_version: number;
- image_type: string;
- download_url: string;
- file_name: string;
- file_size: number;
- checksum: string | null;
- install_path: string;
- created_at: number;
-}
-
-export type JavaDownloadSource = "adoptium" | "mojang" | "azul";
-
-// ==================== Fabric Types ====================
-
-export interface FabricGameVersion {
- version: string;
- stable: boolean;
-}
-
-export interface FabricLoaderVersion {
- separator: string;
- build: number;
- maven: string;
- version: string;
- stable: boolean;
-}
-
-export interface FabricLoaderEntry {
- loader: FabricLoaderVersion;
- intermediary: {
- maven: string;
- version: string;
- stable: boolean;
- };
- launcherMeta: {
- version: number;
- mainClass: {
- client: string;
- server: string;
- };
- };
-}
-
-export interface InstalledFabricVersion {
- id: string;
- minecraft_version: string;
- loader_version: string;
- path: string;
-}
-
-// ==================== Forge Types ====================
-
-export interface ForgeVersion {
- version: string;
- minecraft_version: string;
- recommended: boolean;
- latest: boolean;
-}
-
-export interface InstalledForgeVersion {
- id: string;
- minecraft_version: string;
- forge_version: string;
- path: string;
-}
-
-// ==================== Mod Loader Type ====================
-
-export type ModLoaderType = "vanilla" | "fabric" | "forge";
-
-// ==================== Instance Types ====================
-
-export interface Instance {
- id: string;
- name: string;
- game_dir: string;
- version_id?: string;
- created_at: number;
- last_played?: number;
- icon_path?: string;
- notes?: string;
- mod_loader?: string;
- mod_loader_version?: string;
- jvm_args_override?: string;
- memory_override?: MemoryOverride;
-}
-
-export interface MemoryOverride {
- min: number; // MB
- max: number; // MB
-}
-
-export interface FileInfo {
- name: string;
- path: string;
- is_directory: boolean;
- size: number;
- modified: number;
-}
+export * from "./bindings";
diff --git a/packages/ui/svelte.config.js b/packages/ui/svelte.config.js
deleted file mode 100644
index a710f1b..0000000
--- a/packages/ui/svelte.config.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
-
-/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
-export default {
- // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
- // for more information about preprocessors
- preprocess: vitePreprocess(),
-};
diff --git a/packages/ui/tsconfig.app.json b/packages/ui/tsconfig.app.json
index addb46d..54f0bdf 100644
--- a/packages/ui/tsconfig.app.json
+++ b/packages/ui/tsconfig.app.json
@@ -1,22 +1,34 @@
{
- "extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
+ "types": ["vite/client"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
"moduleResolution": "bundler",
- "types": ["svelte", "vite/client"],
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
"noEmit": true,
- /**
- * Typecheck JS in `.svelte` and `.js` files by default.
- * Disable checkJs if you'd like to use dynamic types in JS.
- * Note that setting allowJs false does not prevent the use
- * of JS in `.svelte` files.
- */
- "allowJs": true,
- "checkJs": true,
- "moduleDetection": "force"
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ /* Paths */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
},
- "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
+ "include": ["src"]
}
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
index d32ff68..fec8c8e 100644
--- a/packages/ui/tsconfig.json
+++ b/packages/ui/tsconfig.json
@@ -1,4 +1,13 @@
{
"files": [],
- "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
}
diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts
index 32610e2..27ce1ff 100644
--- a/packages/ui/vite.config.ts
+++ b/packages/ui/vite.config.ts
@@ -1,26 +1,18 @@
-import { defineConfig } from "vite";
-import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import path from "path";
+import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
- plugins: [tailwindcss(), svelte()],
-
- // Fix for Tauri + Vite HMR
- server: {
- host: true,
- strictPort: true,
- hmr: {
- protocol: "ws",
- host: "localhost",
- port: 5173,
- },
- watch: {
- usePolling: true,
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ "@components": path.resolve(__dirname, "./src/components"),
+ "@stores": path.resolve(__dirname, "./src/stores"),
+ "@types": path.resolve(__dirname, "./src/types"),
+ "@pages": path.resolve(__dirname, "./src/pages"),
},
},
-
- // Ensure compatibility with Tauri
- clearScreen: false,
- envPrefix: ["VITE_", "TAURI_"],
});