init commit

This commit is contained in:
munimunigamer 2026-01-21 20:10:19 -06:00
parent bbdc6901c6
commit 2948b9c061
68 changed files with 3859 additions and 1659 deletions

View file

@ -37,6 +37,37 @@ npm test -- -t "test name" # Run a single test (if using Vitest/Jest)
- **Database**: SQLite via `@tauri-apps/plugin-sql`
- **AI**: OpenAI-compatible APIs (OpenRouter, custom providers)
## UI Design & Component System
### Component Library
- **Library**: [shadcn-svelte](https://www.shadcn-svelte.com/)
- **Location**: `src/lib/components/ui/`
- **Installation**: Use `npx shadcn-svelte@latest add [component]` to add new components.
- **Icons**: [Lucide Svelte](https://lucide.dev/icons/) (`lucide-svelte`)
### Theming System
The application uses a sophisticated CSS variable-based theming system defined in `src/app.css`. It supports multiple distinct visual themes that override standard Tailwind/Shadcn tokens.
**Available Themes**:
- **Default (Dark)**: Modern slate/blue dark mode.
- **Light (Paper)**: Warm, high-contrast, paper-like aesthetic.
- **Light (Solarized)**: Classic solarized light palette.
- **Retro Console**: CRT terminal aesthetic (green/amber on black) with scanline effects.
- **Fallen Down**: Undertale/Deltarune inspired high-contrast pixel art aesthetic (black/white/yellow).
### CSS Variables & Tokens
- **Shadcn Tokens**: Standard tokens (`--background`, `--foreground`, `--primary`, `--muted`, etc.) are mapped to theme-specific colors in `app.css`.
- **Surface System**: Custom `surface-*` (50-950) and `accent-*` (50-950) scales are used for fine-grained control across themes.
- **Typography**:
- UI Font: System sans-serif.
- Story Text: Configurable via `--font-story` (Serif for default, Monospace for Retro/Fallen Down).
### Usage Guidelines
1. **Prefer Shadcn Components**: Use components from `$lib/components/ui` whenever possible (e.g., `Button`, `Input`, `Card`).
2. **Tailwind Classes**: Use standard Tailwind classes. They will automatically adapt to the active theme via the CSS variables.
3. **Custom Styling**: If custom CSS is needed, use the CSS variables defined in `app.css` to ensure theme compatibility (e.g., `var(--bg-secondary)` instead of hardcoded hex).
4. **Icons**: Import icons from `lucide-svelte` (e.g., `import { Save } from 'lucide-svelte';`).
## Current Refactor: Preset-Based Service Configuration
**Status**: In Progress - Phase 3 (AgentProfiles UI) Complete

16
components.json Normal file
View file

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils/cn",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://tw3.shadcn-svelte.com/registry/default"
}

217
package-lock.json generated
View file

@ -17,24 +17,30 @@
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-sql": "^2",
"@tauri-apps/plugin-updater": "^2.9.0",
"clsx": "^2.1.1",
"gpt-tokenizer": "^3.4.0",
"harper.js": "^1.2.0",
"html5-qrcode": "^2.3.8",
"jsonrepair": "^3.13.2",
"lucide-svelte": "^0.468.0",
"marked": "^17.0.1"
"marked": "^17.0.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@lucide/svelte": "^0.482.0",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2",
"@types/marked": "^5.0.2",
"autoprefixer": "^10.4.20",
"bits-ui": "^1.8.0",
"postcss": "^8.4.49",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwind-variants": "^0.2.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.6.2",
"vite": "^6.0.3"
}
@ -494,6 +500,44 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@internationalized/date": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz",
"integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -539,6 +583,16 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lucide/svelte": {
"version": "0.482.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.482.0.tgz",
"integrity": "sha512-n2ycHU9cNcleRDwwpEHBJ6pYzVhHIaL3a+9dQa8kns9hB2g05bY+v2p2KP8v0pZwtNhYTHk/F2o2uZ1bVtQGhw==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"svelte": "^5"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -997,6 +1051,16 @@
"vite": "^6.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.18",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
@ -1438,6 +1502,33 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bits-ui": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-1.8.0.tgz",
"integrity": "sha512-CXD6Orp7l8QevNDcRPLXc/b8iMVgxDWT2LyTwsdLzJKh9CxesOmPuNePSPqAxKoT59FIdU4aFPS1k7eBdbaCxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.4",
"@floating-ui/dom": "^1.6.7",
"@internationalized/date": "^3.5.6",
"css.escape": "^1.5.1",
"esm-env": "^1.1.2",
"runed": "^0.23.2",
"svelte-toolbelt": "^0.7.1",
"tabbable": "^6.2.0"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"funding": {
"url": "https://github.com/sponsors/huntabyte"
},
"peerDependencies": {
"svelte": "^5.11.0"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@ -1561,6 +1652,13 @@
"node": ">= 0.6"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true,
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -1850,6 +1948,13 @@
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
"license": "Apache-2.0"
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"dev": true,
"license": "MIT"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -2491,6 +2596,22 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/runed": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
"integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@ -2536,6 +2657,16 @@
"node": ">=0.10.0"
}
},
"node_modules/style-to-object": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"inline-style-parser": "0.2.7"
}
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@ -2622,12 +2753,79 @@
"typescript": ">=5.0.0"
}
},
"node_modules/svelte-toolbelt": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
"integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.23.2",
"style-to-object": "^1.0.8"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwind-variants": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-0.2.1.tgz",
"integrity": "sha512-2xmhAf4UIc3PijOUcJPA1LP4AbxhpcHuHM2C26xM0k81r0maAO6uoUSHl3APmvHZcY5cZCY/bYuJdfFa4eGoaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tailwind-merge": "^2.2.0"
},
"engines": {
"node": ">=16.x",
"pnpm": ">=7.x"
},
"peerDependencies": {
"tailwindcss": "*"
}
},
"node_modules/tailwind-variants/node_modules/tailwind-merge": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
"integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@ -2660,6 +2858,16 @@
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/tailwindcss/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@ -2794,6 +3002,13 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",

View file

@ -21,24 +21,30 @@
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-sql": "^2",
"@tauri-apps/plugin-updater": "^2.9.0",
"clsx": "^2.1.1",
"gpt-tokenizer": "^3.4.0",
"harper.js": "^1.2.0",
"html5-qrcode": "^2.3.8",
"jsonrepair": "^3.13.2",
"lucide-svelte": "^0.468.0",
"marked": "^17.0.1"
"marked": "^17.0.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@lucide/svelte": "^0.482.0",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2",
"@types/marked": "^5.0.2",
"autoprefixer": "^10.4.20",
"bits-ui": "^1.8.0",
"postcss": "^8.4.49",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwind-variants": "^0.2.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.6.2",
"vite": "^6.0.3"
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,376 @@
<script lang="ts">
import { settings, type ProviderPreset } from "$lib/stores/settings.svelte";
import { Check, ExternalLink, ArrowLeft, Server, Zap, Globe, Palette, Type, Languages, ArrowRight } from "lucide-svelte";
import { fade, blur, slide } from "svelte/transition";
import { quintOut } from "svelte/easing";
import { getSupportedLanguages } from "$lib/services/ai/translation";
import type { ThemeId } from "$lib/types";
import * as Card from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Switch } from "$lib/components/ui/switch";
import * as Select from "$lib/components/ui/select";
interface Props {
onComplete: () => void;
}
let { onComplete }: Props = $props();
// State
let step = $state<"interface" | "select" | "configure">("interface");
let selectedProvider = $state<ProviderPreset | null>(null);
let apiKey = $state("");
let showApiKey = $state(false);
let isSubmitting = $state(false);
let error = $state<string | null>(null);
// Provider info
const providers = [
{
id: "openrouter" as ProviderPreset,
name: "OpenRouter",
description: "Access multiple models via one API.",
icon: Globe,
signupUrl: "https://openrouter.ai/keys",
keyPrefix: "sk-or-",
requiresKey: true,
color: "border-blue-500/20 hover:border-blue-500/50 bg-blue-500/5 hover:bg-blue-500/10",
iconColor: "text-blue-500"
},
{
id: "nanogpt" as ProviderPreset,
name: "NanoGPT",
description: "Affordable access to models like Deepseek.",
icon: Zap,
signupUrl: "https://nano-gpt.com/api",
keyPrefix: "sk-nano-",
requiresKey: true,
note: "Append :thinking to model names for reasoning",
color: "border-yellow-500/20 hover:border-yellow-500/50 bg-yellow-500/5 hover:bg-yellow-500/10",
iconColor: "text-yellow-500"
},
{
id: "custom" as ProviderPreset,
name: "Custom / Self-Hosted",
description: "Configure your own OpenAI-compatible endpoint.",
icon: Server,
signupUrl: "",
keyPrefix: "",
requiresKey: false,
note: "Configure endpoint in settings later.",
color: "border-purple-500/20 hover:border-purple-500/50 bg-purple-500/5 hover:bg-purple-500/10",
iconColor: "text-purple-500"
},
];
const themes: { value: ThemeId; label: string }[] = [
{ value: "dark", label: "Dark" },
{ value: "light", label: "Light (Paper)" },
{ value: "light-solarized", label: "Light (Solarized)" },
{ value: "retro-console", label: "Retro Console" },
{ value: "fallen-down", label: "Fallen Down" },
];
function selectProvider(id: ProviderPreset) {
selectedProvider = id;
error = null;
apiKey = "";
step = "configure";
}
function goBack() {
if (step === "configure") {
step = "select";
} else if (step === "select") {
step = "interface";
}
error = null;
}
function goToSelect() {
step = "select";
}
function getSelectedProviderInfo() {
return providers.find((p) => p.id === selectedProvider);
}
async function handleSubmit() {
const providerInfo = getSelectedProviderInfo();
if (!providerInfo) return;
if (providerInfo.requiresKey && !apiKey.trim()) {
error = "Please enter your API key";
return;
}
isSubmitting = true;
error = null;
try {
await settings.initializeWithProvider(selectedProvider!, apiKey.trim());
onComplete();
} catch (e) {
console.error("Failed to initialize with provider:", e);
error = e instanceof Error ? e.message : "Failed to initialize. Please try again.";
} finally {
isSubmitting = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && apiKey.trim()) {
handleSubmit();
}
}
</script>
<div class="fixed inset-0 z-50 flex h-full w-full flex-col items-center justify-center bg-background p-4 text-center overflow-y-auto overflow-x-hidden" out:fade={{ duration: 300 }}>
<div class="mb-8 space-y-2">
<h1 class="text-4xl font-bold tracking-tight text-foreground">Welcome to Aventuras</h1>
<p class="text-lg text-muted-foreground">
{#if step === "interface"}
Customize your reading environment
{:else if step === "select"}
Choose your AI provider to get started
{:else}
{@const p = getSelectedProviderInfo()}
Configure {p?.name ?? 'Provider'}
{/if}
</p>
</div>
<div class="w-full max-w-2xl flex justify-center">
{#if step === "interface"}
<div
class="w-full max-w-md"
in:blur={{ amount: 10, duration: 400, easing: quintOut, delay: 100 }}
out:blur={{ amount: 10, duration: 200, easing: quintOut }}
>
<Card.Root class="w-full text-left backdrop-blur-sm bg-card/95 border-border shadow-2xl">
<Card.Content class="p-6 space-y-6">
<!-- Theme -->
<div class="space-y-3">
<Label class="flex items-center gap-2 text-base">
<Palette size={18} class="text-primary" />
Theme
</Label>
<Select.Root type="single" value={settings.uiSettings.theme} onValueChange={(v) => settings.setTheme(v as ThemeId)}>
<Select.Trigger class="w-full">
{themes.find((t) => t.value === settings.uiSettings.theme)?.label ?? "Select a theme"}
</Select.Trigger>
<Select.Content>
{#each themes as theme}
<Select.Item value={theme.value}>{theme.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Font Size -->
<div class="space-y-3">
<Label class="flex items-center gap-2 text-base">
<Type size={18} class="text-primary" />
Font Size
</Label>
<div class="grid grid-cols-3 gap-2">
{#each ["small", "medium", "large"] as size}
<Button
variant={settings.uiSettings.fontSize === size ? "default" : "outline"}
class="w-full capitalize"
onclick={() => settings.setFontSize(size as "small" | "medium" | "large")}
>
{size}
</Button>
{/each}
</div>
</div>
<!-- Translation -->
<div class="space-y-4 pt-4 border-t border-border">
<div class="flex items-center justify-between">
<Label class="flex items-center gap-2 text-base">
<Languages size={18} class="text-primary" />
Translation
</Label>
<Switch
checked={settings.translationSettings.enabled}
onCheckedChange={(v) => settings.updateTranslationSettings({ enabled: v })}
/>
</div>
{#if settings.translationSettings.enabled}
<div class="space-y-4 rounded-xl bg-muted/50 p-4" transition:slide={{ duration: 200, axis: 'y' }}>
<div class="space-y-2">
<Label for="lang-select" class="text-sm">Target Language</Label>
<Select.Root type="single" value={settings.translationSettings.targetLanguage} onValueChange={(v) => settings.updateTranslationSettings({ targetLanguage: v })}>
<Select.Trigger id="lang-select" class="w-full">
{getSupportedLanguages().find(l => l.code === settings.translationSettings.targetLanguage)?.name ?? "Select language"}
</Select.Trigger>
<Select.Content class="max-h-[200px] overflow-y-auto">
{#each getSupportedLanguages() as lang}
<Select.Item value={lang.code}>{lang.name}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="flex items-center justify-between pt-2">
<Label class="text-sm font-normal text-muted-foreground">Translate my input</Label>
<Switch
checked={settings.translationSettings.translateUserInput}
onCheckedChange={(v) => settings.updateTranslationSettings({ translateUserInput: v })}
/>
</div>
<div class="flex items-center justify-between">
<Label class="text-sm font-normal text-muted-foreground">Translate World State</Label>
<Switch
checked={settings.translationSettings.translateWorldState}
onCheckedChange={(v) => settings.updateTranslationSettings({ translateWorldState: v })}
/>
</div>
</div>
{/if}
</div>
</Card.Content>
<Card.Footer>
<Button class="w-full" size="lg" onclick={goToSelect}>
Next Step <ArrowRight class="ml-2 h-4 w-4" />
</Button>
</Card.Footer>
</Card.Root>
</div>
{:else if step === "select"}
<div
class="w-full"
in:blur={{ amount: 10, duration: 400, easing: quintOut, delay: 100 }}
out:blur={{ amount: 10, duration: 200, easing: quintOut }}
>
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-3">
{#each providers as provider}
<button
onclick={() => selectProvider(provider.id)}
class="group flex flex-col items-center gap-4 rounded-xl border p-6 text-center transition-all duration-200 hover:-translate-y-1 hover:shadow-lg {provider.color} bg-card text-card-foreground"
>
<div class="rounded-full bg-muted p-4 ring-1 ring-background transition-colors group-hover:bg-background">
<provider.icon size={32} class={provider.iconColor} />
</div>
<div class="space-y-1">
<h3 class="font-semibold text-foreground">{provider.name}</h3>
<p class="text-xs leading-relaxed text-muted-foreground">{provider.description}</p>
</div>
</button>
{/each}
</div>
<div class="mt-8 flex justify-center">
<Button variant="ghost" onclick={() => step = "interface"} class="text-muted-foreground hover:text-foreground">
<ArrowLeft size={16} class="mr-2" /> Back to Customization
</Button>
</div>
</div>
{:else}
<!-- Configure Step -->
{@const provider = getSelectedProviderInfo()}
{#if provider}
<div
class="w-full max-w-md"
in:blur={{ amount: 10, duration: 400, easing: quintOut, delay: 100 }}
out:blur={{ amount: 10, duration: 200, easing: quintOut }}
>
<Card.Root class="w-full backdrop-blur-sm bg-card/95 border-border shadow-2xl">
<Card.Header>
<div class="flex items-center gap-4">
<Button variant="ghost" size="icon" onclick={goBack} title="Go back">
<ArrowLeft size={20} />
</Button>
<div class="flex items-center gap-3">
<provider.icon size={24} class={provider.iconColor} />
<Card.Title>Setup</Card.Title>
</div>
</div>
</Card.Header>
<Card.Content class="space-y-4 text-left">
{#if provider.requiresKey}
<div class="space-y-2">
<div class="flex justify-between items-center">
<Label for="api-key">API Key</Label>
{#if provider.signupUrl}
<a
href={provider.signupUrl}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1 text-xs text-primary hover:underline"
>
Get a key <ExternalLink size={10} />
</a>
{/if}
</div>
<div class="relative">
<!-- svelte-ignore a11y_autofocus -->
<Input
id="api-key"
type={showApiKey ? "text" : "password"}
placeholder={provider.keyPrefix ? `${provider.keyPrefix}...` : "Enter your API key"}
bind:value={apiKey}
onkeydown={handleKeyDown}
autofocus
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground hover:text-foreground"
onclick={() => (showApiKey = !showApiKey)}
>
{showApiKey ? "Hide" : "Show"}
</button>
</div>
</div>
{:else}
<p class="text-sm text-muted-foreground">
{provider.description}
</p>
{/if}
{#if provider.note}
<div class="rounded-lg bg-muted p-3 text-xs text-muted-foreground">
<span class="font-semibold text-primary">Note:</span> {provider.note}
</div>
{/if}
{#if error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive border border-destructive/20">
{error}
</div>
{/if}
<Button
class="w-full"
size="lg"
onclick={handleSubmit}
disabled={(provider.requiresKey && !apiKey.trim()) || isSubmitting}
>
{#if isSubmitting}
<div class="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground"></div>
Setting up...
{:else}
<Check class="mr-2 h-4 w-4" />
Get Started
{/if}
</Button>
</Card.Content>
</Card.Root>
</div>
{/if}
{/if}
</div>
</div>

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte';
import { story } from '$lib/stores/story.svelte';
import { Users, MapPin, Backpack, Scroll, Clock, GitBranch, BookOpen, BookMarked, Brain } from 'lucide-svelte';
import CharacterPanel from '$lib/components/world/CharacterPanel.svelte';
@ -11,6 +10,9 @@
import BranchPanel from '$lib/components/branch/BranchPanel.svelte';
import { swipe } from '$lib/utils/swipe';
import * as Tabs from "$lib/components/ui/tabs";
import { Button } from "$lib/components/ui/button";
const tabs = [
{ id: 'characters' as const, icon: Users, label: 'Characters' },
{ id: 'locations' as const, icon: MapPin, label: 'Locations' },
@ -41,82 +43,80 @@
</script>
<aside
class="sidebar flex h-full w-[calc(100vw-3rem)] max-w-72 flex-col border-l border-surface-700 sm:w-72"
class="flex h-full w-[calc(100vw-3rem)] max-w-72 flex-col border-l border-border bg-card/50 sm:w-72 backdrop-blur-[2px]"
use:swipe={{ onSwipeLeft: handleSwipeLeft, onSwipeRight: handleSwipeRight, threshold: 50 }}
>
<!-- Tab navigation -->
<div class="flex border-b border-surface-700">
<Tabs.Root
value={ui.sidebarTab}
onValueChange={(v) => ui.setSidebarTab(v as any)}
class="flex flex-col h-full"
>
<div class="border-b border-border px-0">
<Tabs.List class="w-full flex justify-start rounded-none bg-transparent p-0 h-auto">
{#each tabs as tab}
<button
class="flex flex-1 items-center justify-center gap-1.5 py-3 sm:py-3 min-h-[48px] text-sm transition-colors"
class:text-accent-400={ui.sidebarTab === tab.id}
class:text-surface-400={ui.sidebarTab !== tab.id}
class:border-b-2={ui.sidebarTab === tab.id}
class:border-accent-500={ui.sidebarTab === tab.id}
class:hover:text-surface-200={ui.sidebarTab !== tab.id}
onclick={() => ui.setSidebarTab(tab.id)}
<Tabs.Trigger
value={tab.id}
class="flex-1 py-3 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:text-primary data-[state=active]:shadow-none bg-transparent hover:bg-muted/50 transition-colors"
title={tab.label}
>
<svelte:component this={tab.icon} class="h-5 w-5 sm:h-4 sm:w-4" />
</button>
<svelte:component this={tab.icon} class="h-4 w-4" />
</Tabs.Trigger>
{/each}
</Tabs.List>
</div>
<!-- Panel content -->
<div class="flex-1 overflow-y-auto p-3">
{#if ui.sidebarTab === 'characters'}
<Tabs.Content value="characters" class="mt-0 h-full space-y-4">
<CharacterPanel />
{:else if ui.sidebarTab === 'locations'}
</Tabs.Content>
<Tabs.Content value="locations" class="mt-0 h-full space-y-4">
<LocationPanel />
{:else if ui.sidebarTab === 'inventory'}
</Tabs.Content>
<Tabs.Content value="inventory" class="mt-0 h-full space-y-4">
<InventoryPanel />
{:else if ui.sidebarTab === 'quests'}
</Tabs.Content>
<Tabs.Content value="quests" class="mt-0 h-full space-y-4">
<QuestPanel />
{:else if ui.sidebarTab === 'time'}
</Tabs.Content>
<Tabs.Content value="time" class="mt-0 h-full space-y-4">
<TimePanel />
{:else if ui.sidebarTab === 'branches'}
</Tabs.Content>
<Tabs.Content value="branches" class="mt-0 h-full space-y-4">
<BranchPanel />
{/if}
</Tabs.Content>
</div>
</Tabs.Root>
<!-- Bottom Context Navigation -->
<div class="flex items-center gap-1 border-t border-surface-700 p-2">
<button
class="btn-ghost flex flex-1 flex-col items-center justify-center gap-1 rounded py-2 text-xs"
class:text-accent-400={ui.activePanel === 'story'}
class:text-surface-400={ui.activePanel !== 'story'}
<div class="flex items-center gap-1 border-t border-border p-2 bg-muted/20">
<Button
variant={ui.activePanel === 'story' ? 'secondary' : 'ghost'}
class="flex-1 flex-col h-auto py-2 gap-1 text-xs"
onclick={() => ui.setActivePanel('story')}
title="Story"
>
<BookOpen class="h-4 w-4" />
<span>Story</span>
</button>
<button
class="btn-ghost flex flex-1 flex-col items-center justify-center gap-1 rounded py-2 text-xs"
class:text-accent-400={ui.activePanel === 'lorebook'}
class:text-surface-400={ui.activePanel !== 'lorebook'}
</Button>
<Button
variant={ui.activePanel === 'lorebook' ? 'secondary' : 'ghost'}
class="flex-1 flex-col h-auto py-2 gap-1 text-xs"
onclick={() => ui.setActivePanel('lorebook')}
title="Lorebook"
>
<BookMarked class="h-4 w-4" />
<span>Lorebook</span>
</button>
<button
class="btn-ghost flex flex-1 flex-col items-center justify-center gap-1 rounded py-2 text-xs"
class:text-accent-400={ui.activePanel === 'memory'}
class:text-surface-400={ui.activePanel !== 'memory'}
</Button>
<Button
variant={ui.activePanel === 'memory' ? 'secondary' : 'ghost'}
class="flex-1 flex-col h-auto py-2 gap-1 text-xs"
onclick={() => ui.setActivePanel('memory')}
title="Memory"
>
<Brain class="h-4 w-4" />
<span>Memory</span>
</button>
</Button>
</div>
</aside>
<style>
.sidebar {
background-color: rgb(20 27 37);
}
</style>

View file

@ -1,455 +0,0 @@
<script lang="ts">
import { settings, type ProviderPreset } from "$lib/stores/settings.svelte";
import { Check, ExternalLink } from "lucide-svelte";
interface Props {
isOpen: boolean;
onComplete: () => void;
}
let { isOpen, onComplete }: Props = $props();
// Form state
let selectedProvider = $state<ProviderPreset>("openrouter");
let apiKey = $state("");
let showApiKey = $state(false);
let isSubmitting = $state(false);
let error = $state<string | null>(null);
// Provider info
const providers = [
{
id: "openrouter" as ProviderPreset,
name: "OpenRouter",
description: "Access multiple AI models through a single API.",
url: "https://openrouter.ai",
signupUrl: "https://openrouter.ai/keys",
keyPrefix: "sk-or-",
requiresKey: true,
},
{
id: "nanogpt" as ProviderPreset,
name: "NanoGPT",
description:
"Affordable AI API with access to models like Deepseek and GLM.",
url: "https://nano-gpt.com",
signupUrl: "https://nano-gpt.com/api",
keyPrefix: "sk-nano-",
requiresKey: true,
note: "For thinking models, append :thinking to the model name (e.g., deepseek/deepseek-v3.2:thinking)",
},
{
id: "custom" as ProviderPreset,
name: "Custom / Self-Hosted",
description:
"Configure your own OpenAI-compatible API endpoint in settings.",
url: "",
signupUrl: "",
keyPrefix: "",
requiresKey: false,
note: "You can configure your API endpoint and key later in the Settings → API tab.",
},
];
$effect(() => {
if (isOpen) {
// Reset state when modal opens
selectedProvider = "openrouter";
apiKey = "";
showApiKey = false;
isSubmitting = false;
error = null;
}
});
function requiresApiKey(): boolean {
return getSelectedProvider()?.requiresKey ?? true;
}
async function handleSubmit() {
if (requiresApiKey() && !apiKey.trim()) {
error = "Please enter your API key";
return;
}
isSubmitting = true;
error = null;
try {
await settings.initializeWithProvider(selectedProvider, apiKey.trim());
onComplete();
} catch (e) {
console.error("Failed to initialize with provider:", e);
error =
e instanceof Error
? e.message
: "Failed to initialize. Please try again.";
} finally {
isSubmitting = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && apiKey.trim()) {
handleSubmit();
}
}
function getSelectedProvider() {
return providers.find((p) => p.id === selectedProvider);
}
</script>
{#if isOpen}
<div class="modal-backdrop">
<div class="modal">
<div class="modal-header">
<h1>Welcome to Aventuras</h1>
<p class="subtitle">Choose your AI provider to get started</p>
</div>
<div class="modal-body">
<!-- Provider Selection -->
<div class="providers">
{#each providers as provider}
<button
type="button"
class="provider-card"
class:selected={selectedProvider === provider.id}
onclick={() => (selectedProvider = provider.id)}
>
<div class="provider-header">
<span class="provider-name">{provider.name}</span>
{#if selectedProvider === provider.id}
<Check size={16} class="check-icon" />
{/if}
</div>
<p class="provider-description">{provider.description}</p>
{#if provider.note}
<p class="provider-note">{provider.note}</p>
{/if}
</button>
{/each}
</div>
<!-- API Key Input (only for providers that require it) -->
{#if requiresApiKey()}
<div class="form-group">
<label for="api-key">
{getSelectedProvider()?.name} API Key
{#if getSelectedProvider()?.signupUrl}
<a
href={getSelectedProvider()?.signupUrl}
target="_blank"
rel="noopener noreferrer"
class="get-key-link"
>
Get a key <ExternalLink size={12} />
</a>
{/if}
</label>
<div class="api-key-container">
<input
id="api-key"
type={showApiKey ? "text" : "password"}
class="input"
placeholder={getSelectedProvider()?.keyPrefix
? `${getSelectedProvider()?.keyPrefix}...`
: "Enter your API key"}
bind:value={apiKey}
onkeydown={handleKeyDown}
/>
<button
type="button"
class="toggle-key-btn"
onclick={() => (showApiKey = !showApiKey)}
>
{showApiKey ? "Hide" : "Show"}
</button>
</div>
</div>
{/if}
{#if error}
<div class="error-message">{error}</div>
{/if}
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-primary"
onclick={handleSubmit}
disabled={(requiresApiKey() && !apiKey.trim()) || isSubmitting}
>
{#if isSubmitting}
<span class="spinner"></span>
Setting up...
{:else}
<Check size={16} />
Get Started
{/if}
</button>
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
backdrop-filter: blur(8px);
}
.modal {
background: rgb(25, 25, 30);
border: 1px solid rgb(50, 50, 60);
border-radius: 0.75rem;
width: 100%;
max-width: 480px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow:
0 16px 64px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.05);
}
.modal-header {
padding: 1.5rem 1.5rem 0.75rem;
text-align: center;
}
.modal-header h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.5rem;
}
.subtitle {
font-size: 0.875rem;
color: var(--text-secondary);
margin: 0;
}
.modal-body {
padding: 1rem 1.5rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.providers {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.provider-card {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 1rem;
background: var(--surface-2);
border: 2px solid var(--border);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
min-height: 44px; /* Touch-friendly */
}
.provider-card:hover {
background: var(--surface-3);
border-color: var(--text-secondary);
}
.provider-card.selected {
background: rgba(59, 130, 246, 0.1);
border-color: var(--accent);
}
.provider-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.provider-name {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
:global(.check-icon) {
color: var(--accent);
}
.provider-description {
font-size: 0.8125rem;
color: var(--text-secondary);
margin: 0;
line-height: 1.4;
}
.provider-note {
font-size: 0.75rem;
color: var(--accent);
margin: 0.25rem 0 0;
font-style: italic;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
.get-key-link {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--accent);
text-decoration: none;
font-weight: 400;
}
.get-key-link:hover {
text-decoration: underline;
}
.api-key-container {
display: flex;
gap: 0.5rem;
}
.input {
flex: 1;
padding: 0.625rem 0.875rem;
background-color: var(--surface-2);
border: 1px solid var(--border);
border-radius: 0.375rem;
color: var(--text-primary);
font-size: 0.875rem;
min-height: 44px; /* Touch-friendly */
}
.input:focus {
outline: none;
border-color: var(--accent);
}
.toggle-key-btn {
padding: 0.5rem 0.75rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 0.375rem;
color: var(--text-secondary);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
min-height: 44px; /* Touch-friendly */
}
.toggle-key-btn:hover {
background: var(--surface-3);
color: var(--text-primary);
}
.error-message {
padding: 0.625rem 0.875rem;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 0.375rem;
color: #ef4444;
font-size: 0.8125rem;
}
.modal-footer {
padding: 1rem 1.5rem 1.5rem;
display: flex;
justify-content: center;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 2rem;
border-radius: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
min-height: 48px; /* Touch-friendly */
}
.btn-primary {
background: var(--accent);
border: none;
color: var(--text-on-accent);
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Mobile adjustments */
@media (max-width: 480px) {
.modal-header h1 {
font-size: 1.25rem;
}
.modal-body {
padding: 1rem;
}
.provider-card {
padding: 0.875rem;
}
.modal-footer {
padding: 1rem;
}
}
</style>

View file

@ -7,7 +7,6 @@
BookOpen,
Trash2,
Clock,
Sparkles,
Upload,
RefreshCw,
Archive,
@ -15,6 +14,11 @@
} from "lucide-svelte";
import SetupWizard from "../wizard/SetupWizard.svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { Badge } from "$lib/components/ui/badge";
import { Input } from "$lib/components/ui/input";
// File input for import (HTML-based for mobile compatibility)
let importFileInput: HTMLInputElement;
@ -61,19 +65,19 @@
function getGenreColor(genre: string | null): string {
switch (genre) {
case "Fantasy":
return "bg-purple-500/20 text-purple-400";
return "bg-purple-500/15 text-purple-700 dark:text-purple-400 border-purple-500/20";
case "Sci-Fi":
return "bg-cyan-500/20 text-cyan-400";
return "bg-cyan-500/15 text-cyan-700 dark:text-cyan-400 border-cyan-500/20";
case "Mystery":
return "bg-amber-500/20 text-amber-400";
return "bg-amber-500/15 text-amber-700 dark:text-amber-400 border-amber-500/20";
case "Horror":
return "bg-red-500/20 text-red-400";
return "bg-red-500/15 text-red-700 dark:text-red-400 border-red-500/20";
case "Slice of Life":
return "bg-green-500/20 text-green-400";
return "bg-green-500/15 text-green-700 dark:text-green-400 border-green-500/20";
case "Historical":
return "bg-orange-500/20 text-orange-400";
return "bg-orange-500/15 text-orange-700 dark:text-orange-400 border-orange-500/20";
default:
return "bg-surface-700 text-surface-400";
return "bg-secondary text-secondary-foreground border-border";
}
}
@ -113,42 +117,53 @@
}
</script>
<div class="h-full overflow-y-auto p-4 sm:p-6 relative">
<div class="mx-auto max-w-4xl min-h-full flex flex-col">
<div class="h-full overflow-y-auto p-4 sm:p-6 relative bg-background">
<div class="mx-auto max-w-5xl min-h-full flex flex-col">
<!-- Header -->
<div class="mb-6 sm:mb-8 flex items-start justify-between gap-4 shrink-0">
<div>
<h1 class="text-xl sm:text-2xl font-bold text-surface-100">
<div
class="mb-6 sm:mb-8 flex flex-row items-start justify-between gap-3 sm:gap-4"
>
<div class="flex-1 min-w-0 mr-2">
<h1
class="text-xl sm:text-3xl font-bold tracking-tight text-foreground truncate"
>
Story Library
</h1>
<p class="text-sm sm:text-base text-surface-400">
<p class="text-sm sm:text-base text-muted-foreground truncate">
Your adventures await
</p>
</div>
<div class="flex items-center gap-2 flex-nowrap">
<button
class="btn btn-secondary flex items-center gap-1.5 sm:gap-2 min-h-[44px] px-3 sm:px-4 text-sm"
<div class="flex items-center gap-1.5 sm:gap-2 shrink-0">
<Button
variant="outline"
size="icon"
onclick={() => ui.openSyncModal()}
title="Sync stories between devices"
class="h-10 w-10 sm:w-auto sm:h-10 sm:px-4"
>
<RefreshCw class="h-4 w-4 sm:h-5 sm:w-5" />
<span class="hidden xs:inline">Sync</span>
</button>
<button
class="btn btn-secondary flex items-center gap-1.5 sm:gap-2 min-h-[44px] px-3 sm:px-4 text-sm"
<RefreshCw class="h-5 w-5 sm:h-4 sm:w-4" />
<span class="hidden sm:inline">Sync</span>
</Button>
<Button
variant="outline"
size="icon"
onclick={() => ui.setActivePanel("vault")}
title="Vault - Manage reusable characters and lorebooks"
title="Vault"
class="h-10 w-10 sm:w-auto sm:h-10 sm:px-4"
>
<Archive class="h-4 w-4 sm:h-5 sm:w-5" />
<span class="hidden xs:inline">Vault</span>
</button>
<button
class="btn btn-secondary flex items-center gap-1.5 sm:gap-2 min-h-[44px] px-3 sm:px-4 text-sm"
<Archive class="h-5 w-5 sm:h-4 sm:w-4" />
<span class="hidden sm:inline">Vault</span>
</Button>
<Button
variant="outline"
size="icon"
onclick={triggerImport}
title="Import Story"
class="h-10 w-10 sm:w-auto sm:h-10 sm:px-4"
>
<Upload class="h-4 w-4 sm:h-5 sm:w-5" />
<span class="hidden xs:inline">Import</span>
</button>
<Upload class="h-5 w-5 sm:h-4 sm:w-4" />
<span class="hidden sm:inline">Import</span>
</Button>
<input
type="file"
accept="*/*,.avt,.json,application/json,application/octet-stream"
@ -156,19 +171,24 @@
bind:this={importFileInput}
onchange={handleImportFileSelect}
/>
<button
class="btn btn-primary flex items-center gap-1.5 sm:gap-2 min-h-[44px] px-3 sm:px-4 text-sm"
<Button
variant="default"
size="icon"
onclick={openSetupWizard}
title="New Story"
class="h-10 w-10 sm:w-auto sm:h-10 sm:px-4"
>
<Plus class="h-4 w-4 sm:h-5 sm:w-5" />
<span class="hidden xs:inline">New</span>
</button>
<Plus class="h-5 w-5 sm:h-4 sm:w-4" />
<span class="hidden sm:inline">New Story</span>
</Button>
</div>
</div>
<!-- Import error message -->
{#if importError}
<div class="mb-4 rounded-lg bg-red-500/20 p-3 text-sm text-red-400">
<div
class="mb-4 rounded-lg bg-destructive/15 p-3 text-sm text-destructive border border-destructive/20"
>
{importError}
</div>
{/if}
@ -178,24 +198,22 @@
<div
class="flex flex-col items-center justify-center flex-1 text-center px-4 pb-20"
>
<BookOpen class="mb-2 h-12 w-12 sm:h-16 sm:w-16 text-surface-600" />
<h2 class="text-lg sm:text-xl font-semibold text-surface-300">
No stories yet
</h2>
<p class="mt-1 text-sm sm:text-base text-surface-500">
Create your first adventure to get started
<div class="rounded-full bg-muted p-6 mb-3">
<BookOpen class="h-12 w-12 text-muted-foreground" />
</div>
<h2 class="text-xl font-semibold text-foreground">No stories yet</h2>
<p class="text-muted-foreground max-w-sm">
Create your first adventure to get started. You can also import
existing stories.
</p>
<button
class="btn btn-primary flex items-center justify-center gap-2 min-h-[48px] mt-4"
onclick={openSetupWizard}
>
<Plus class="h-5 w-5" />
<Button variant="default" class="mt-5" onclick={openSetupWizard}>
<Plus class="h-4 w-4" />
Create Story
</button>
</Button>
</div>
{:else}
<div
class="grid gap-3 sm:gap-4 grid-cols-1 xs:grid-cols-2 lg:grid-cols-3"
class="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
{#each story.allStories as s (s.id)}
<div
@ -203,42 +221,57 @@
tabindex="0"
onclick={() => openStory(s.id)}
onkeydown={(e) => e.key === "Enter" && openStory(s.id)}
class="card group cursor-pointer text-left transition-colors hover:border-accent-500/50 hover:bg-surface-700/50 active:bg-surface-700 min-h-[80px]"
class="h-full"
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<h3
class="font-semibold text-surface-100 group-hover:text-accent-400 truncate"
<Card.Root
class="group cursor-pointer h-full transition-all hover:shadow-md hover:border-primary relative overflow-hidden"
>
<Card.Header>
<div class="flex justify-between items-start gap-2">
<Card.Title
class="text-lg font-semibold leading-tight truncate"
>
{s.title}
</h3>
{#if s.genre}
<span
class="mt-1 inline-block rounded-full px-2 py-0.5 text-xs {getGenreColor(
s.genre,
)}"
>
{s.genre}
</span>
{/if}
</div>
<button
</Card.Title>
<Button
variant="link"
size="icon"
class="h-8 w-8 absolute top-4 right-4 text-muted-foreground hover:text-destructive"
onclick={(e) => deleteStory(s.id, e)}
class="rounded p-2 text-surface-500 sm:opacity-0 transition-opacity hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100 min-h-[40px] min-w-[40px] flex items-center justify-center -mr-1 -mt-1"
title="Delete story"
>
<Trash2 class="h-4 w-4" />
</button>
</Button>
</div>
{#if s.genre}
<div>
<Badge
variant="outline"
class="{getGenreColor(s.genre)} border"
>
{s.genre}
</Badge>
</div>
{/if}
</Card.Header>
<Card.Content>
{#if s.description}
<p class="mt-2 line-clamp-2 text-sm text-surface-400">
<p class="text-sm text-muted-foreground line-clamp-3">
{s.description}
</p>
{:else}
<p class="text-sm text-muted-foreground italic">
No description
</p>
{/if}
<div class="mt-3 flex items-center gap-1 text-xs text-surface-500">
</Card.Content>
<Card.Footer class="text-xs text-muted-foreground pt-0 mt-auto">
<div class="flex items-center gap-1">
<Clock class="h-3 w-3" />
<span>Updated {formatDate(s.updatedAt)}</span>
</div>
</Card.Footer>
</Card.Root>
</div>
{/each}
</div>
@ -250,7 +283,7 @@
href="https://discord.gg/DqVzhSPC46"
target="_blank"
rel="noopener noreferrer"
class="hidden sm:flex fixed bottom-safe-4 left-safe-4 items-center gap-2 rounded-lg bg-[#5865F2] px-3 py-2 text-sm text-white shadow-lg transition-all hover:bg-[#4752C4] hover:scale-105"
class="hidden sm:flex fixed bottom-6 left-6 items-center gap-2 rounded-lg bg-secondary px-3 py-2 text-sm text-secondary-foreground shadow-lg transition-all hover:bg-secondary/80 hover:scale-105 z-40"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
class={cn("bg-muted flex size-full items-center justify-center", className)}
{...restProps}
/>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image bind:ref class={cn("aspect-square size-full", className)} {...restProps} />

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
class={cn("relative flex size-10 shrink-0 overflow-hidden rounded-full", className)}
{...restProps}
/>

View file

@ -0,0 +1,13 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
};

View file

@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus:ring-ring inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2",
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/80 border-transparent",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 border-transparent",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/80 border-transparent",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View file

@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View file

@ -0,0 +1,74 @@
<script lang="ts" module>
import type { WithElementRef } from "bits-ui";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
import { cn } from "$lib/utils/cn.js";
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
class={cn(buttonVariants({ variant, size }), className)}
{href}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
class={cn(buttonVariants({ variant, size }), className)}
{type}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View file

@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View file

@ -0,0 +1,16 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} class={cn("px-6 py-3", className)} {...restProps}>
{@render children?.()}
</div>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p bind:this={ref} class={cn("text-muted-foreground text-sm", className)} {...restProps}>
{@render children?.()}
</p>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} class={cn("flex items-center p-6 pt-0", className)} {...restProps}>
{@render children?.()}
</div>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} class={cn("flex flex-col space-y-1.5 p-6 pb-0", className)} {...restProps}>
{@render children?.()}
</div>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
level = 3,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
level?: 1 | 2 | 3 | 4 | 5 | 6;
} = $props();
</script>
<div
role="heading"
aria-level={level}
bind:this={ref}
class={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn(
"bg-card text-card-foreground rounded-lg border border-border shadow-sm",
className,
)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,22 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
};

View file

@ -0,0 +1,38 @@
<script lang="ts">
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import X from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import * as Dialog from "./index.js";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
} = $props();
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
className
)}
{...restProps}
>
{@render children?.()}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
>
<X class="size-4" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</Dialog.Portal>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...restProps}
/>

View file

@ -0,0 +1,37 @@
import { Dialog as DialogPrimitive } from "bits-ui";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
const Root = DialogPrimitive.Root;
const Trigger = DialogPrimitive.Trigger;
const Close = DialogPrimitive.Close;
const Portal = DialogPrimitive.Portal;
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};

View file

@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View file

@ -0,0 +1,46 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
class={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
class={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View file

@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
class={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,10 @@
import Scrollbar from "./scroll-area-scrollbar.svelte";
import Root from "./scroll-area.svelte";
export {
Root,
Scrollbar,
//,
Root as ScrollArea,
Scrollbar as ScrollAreaScrollbar,
};

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive, type WithoutChild } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
orientation = "vertical",
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
</script>
<ScrollAreaPrimitive.Scrollbar
bind:ref
{orientation}
class={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-px",
orientation === "horizontal" && "h-2.5 w-full border-t border-t-transparent p-px",
className
)}
{...restProps}
>
{@render children?.()}
<ScrollAreaPrimitive.Thumb
class={cn("bg-border relative rounded-full", orientation === "vertical" && "flex-1")}
/>
</ScrollAreaPrimitive.Scrollbar>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive, type WithoutChild } from "bits-ui";
import { Scrollbar } from "./index.js";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
orientation = "vertical",
scrollbarXClasses = "",
scrollbarYClasses = "",
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
orientation?: "vertical" | "horizontal" | "both" | undefined;
scrollbarXClasses?: string | undefined;
scrollbarYClasses?: string | undefined;
} = $props();
</script>
<ScrollAreaPrimitive.Root bind:ref {...restProps} class={cn("relative overflow-hidden", className)}>
<ScrollAreaPrimitive.Viewport class="h-full w-full rounded-[inherit]">
{@render children?.()}
</ScrollAreaPrimitive.Viewport>
{#if orientation === "vertical" || orientation === "both"}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
{/if}
{#if orientation === "horizontal" || orientation === "both"}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
{/if}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>

View file

@ -0,0 +1,34 @@
import { Select as SelectPrimitive } from "bits-ui";
import GroupHeading from "./select-group-heading.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
const Root = SelectPrimitive.Root;
const Group = SelectPrimitive.Group;
export {
Root,
Group,
GroupHeading,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
//
Root as Select,
Group as SelectGroup,
GroupHeading as SelectGroupHeading,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
};

View file

@ -0,0 +1,39 @@
<script lang="ts">
import { Select as SelectPrimitive, type WithoutChild } from "bits-ui";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: SelectPrimitive.PortalProps;
} = $props();
</script>
<SelectPrimitive.Portal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=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 bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-[var(--bits-select-anchor-height)] w-full min-w-[var(--bits-select-anchor-width)] p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SelectPrimitive.GroupHeadingProps = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
class={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...restProps}
/>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import Check from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive, type WithoutChild } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if selected}
<Check class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDown class="size-4" />
</SelectPrimitive.ScrollDownButton>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import ChevronUp from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUp class="size-4" />
</SelectPrimitive.ScrollUpButton>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator bind:ref class={cn("bg-muted -mx-1 my-1 h-px", className)} {...restProps} />

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { Select as SelectPrimitive, type WithoutChild } from "bits-ui";
import ChevronDown from "@lucide/svelte/icons/chevron-down";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
class={cn(
"border-input bg-background data-[placeholder]:text-muted-foreground flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm text-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDown class="size-4 opacity-50" />
</SelectPrimitive.Trigger>

View file

@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
orientation = "horizontal",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
class={cn(
"bg-border shrink-0",
orientation === "horizontal" ? "h-[1px] w-full" : "min-h-full w-[1px]",
className
)}
{orientation}
{...restProps}
/>

View file

@ -0,0 +1,7 @@
import Root from "./skeleton.svelte";
export {
Root,
//
Root as Skeleton,
};

View file

@ -0,0 +1,17 @@
<script lang="ts">
import type { WithElementRef, WithoutChildren } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
</script>
<div
bind:this={ref}
class={cn("bg-muted animate-pulse rounded-md", className)}
{...restProps}
></div>

View file

@ -0,0 +1,7 @@
import Root from "./switch.svelte";
export {
Root,
//
Root as Switch,
};

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { Switch as SwitchPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
class={cn(
"focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
class={cn(
"bg-background pointer-events-none block size-5 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>

View file

@ -0,0 +1,18 @@
import { Tabs as TabsPrimitive } from "bits-ui";
import Content from "./tabs-content.svelte";
import List from "./tabs-list.svelte";
import Trigger from "./tabs-trigger.svelte";
const Root = TabsPrimitive.Root;
export {
Root,
Content,
List,
Trigger,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger,
};

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ContentProps = $props();
</script>
<TabsPrimitive.Content
bind:ref
class={cn(
"ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ListProps = $props();
</script>
<TabsPrimitive.List
bind:ref
class={cn(
"bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.TriggerProps = $props();
</script>
<TabsPrimitive.Trigger
bind:ref
class={cn(
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,7 @@
import Root from "./textarea.svelte";
export {
Root,
//
Root as Textarea,
};

View file

@ -0,0 +1,22 @@
<script lang="ts">
import type { WithElementRef, WithoutChildren } from "bits-ui";
import type { HTMLTextareaAttributes } from "svelte/elements";
import { cn } from "$lib/utils/cn.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
</script>
<textarea
bind:this={ref}
class={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-base focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
bind:value
{...restProps}
></textarea>

View file

@ -2,7 +2,11 @@
import { tagStore } from "$lib/stores/tags.svelte";
import type { VaultType } from "$lib/types";
import { Filter, Check, X } from "lucide-svelte";
import { fade, slide } from "svelte/transition";
import { fade } from "svelte/transition";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import { Badge } from "$lib/components/ui/badge";
import { cn } from "$lib/utils/cn";
interface Props {
selectedTags: string[];
@ -61,29 +65,25 @@
</script>
<div class="relative z-20" use:handleClickOutside>
<button
class={`flex items-center gap-2 rounded-lg border px-3 py-2 text-xs transition-colors ${
selectedTags.length > 0
? "border-accent-500 bg-accent-500/10 text-accent-400"
: "border-surface-600 bg-surface-800 text-surface-400 hover:border-surface-500"
}`}
<Button
variant={selectedTags.length > 0 ? "secondary" : "outline"}
size="sm"
class={cn("gap-2", selectedTags.length > 0 && "bg-secondary text-secondary-foreground")}
onclick={() => (isOpen = !isOpen)}
>
<Filter class="h-3 w-3" />
<span class="hidden sm:inline">Tags</span>
{#if selectedTags.length > 0}
<span
class="ml-1 rounded-full bg-accent-500/20 px-1.5 py-0.5 text-[10px] font-bold"
>
<Badge variant="secondary" class="ml-1 h-5 px-1.5 text-[10px]">
{selectedTags.length}
</span>
</Badge>
{/if}
</button>
</Button>
{#if isOpen}
<!-- Mobile Backdrop -->
<div
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm sm:hidden"
class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm sm:hidden"
transition:fade={{ duration: 100 }}
onclick={() => (isOpen = false)}
aria-hidden="true"
@ -92,58 +92,60 @@
<div
transition:fade={{ duration: 100 }}
class="
fixed left-1/2 top-1/2 z-50 w-72 -translate-x-1/2 -translate-y-1/2 shadow-2xl
sm:absolute sm:left-auto sm:right-0 sm:top-full sm:mt-2 sm:w-64 sm:translate-x-0 sm:translate-y-0 sm:shadow-xl
rounded-xl border border-surface-600 bg-surface-800 p-3
fixed left-1/2 top-1/2 z-50 w-72 -translate-x-1/2 -translate-y-1/2 shadow-lg
sm:absolute sm:left-auto sm:right-0 sm:top-full sm:mt-2 sm:w-64 sm:translate-x-0 sm:translate-y-0
rounded-xl border bg-popover p-3 text-popover-foreground
"
>
<!-- Header / Logic Toggle -->
<div class="mb-3 flex items-center justify-between">
<span class="text-xs font-medium text-surface-400">Filter Logic:</span>
<span class="text-xs font-medium text-muted-foreground">Filter Logic:</span>
<div class="flex items-center rounded-md bg-muted p-0.5">
<button
class="flex items-center rounded bg-surface-700 p-0.5"
class={cn(
"rounded-sm px-2 py-0.5 text-[10px] font-bold transition-all",
logic === "AND"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
onclick={toggleLogic}
>
<span
class={`rounded px-2 py-0.5 text-[10px] font-bold transition-all ${
logic === "AND"
? "bg-accent-500 text-white shadow-sm"
: "text-surface-400 hover:text-surface-200"
}`}
>
AND
</span>
<span
class={`rounded px-2 py-0.5 text-[10px] font-bold transition-all ${
</button>
<button
class={cn(
"rounded-sm px-2 py-0.5 text-[10px] font-bold transition-all",
logic === "OR"
? "bg-accent-500 text-white shadow-sm"
: "text-surface-400 hover:text-surface-200"
}`}
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
onclick={toggleLogic}
>
OR
</span>
</button>
</div>
</div>
<!-- Search -->
<div class="mb-2">
<input
<Input
type="text"
bind:value={search}
placeholder="Filter tags..."
class="w-full rounded border border-surface-600 bg-surface-700 px-2 py-1.5 text-xs text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
class="h-8 text-xs"
/>
</div>
<!-- Tag List -->
<div class="max-h-48 space-y-1 overflow-y-auto">
<div class="max-h-48 space-y-1 overflow-y-auto pr-1">
{#each filteredTags as tag}
<button
class={`flex w-full items-center justify-between rounded px-2 py-1.5 text-left text-xs transition-colors ${
class={cn(
"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors",
selectedTags.includes(tag.name)
? "bg-accent-500/10 text-accent-400"
: "text-surface-300 hover:bg-surface-700"
}`}
? "bg-secondary text-secondary-foreground"
: "hover:bg-muted text-muted-foreground hover:text-foreground"
)}
onclick={() => toggleTag(tag.name)}
>
<div class="flex items-center gap-2">
@ -155,7 +157,7 @@
</button>
{/each}
{#if filteredTags.length === 0}
<div class="py-2 text-center text-xs text-surface-500">
<div class="py-2 text-center text-xs text-muted-foreground">
No tags found
</div>
{/if}
@ -163,14 +165,16 @@
<!-- Footer -->
{#if selectedTags.length > 0}
<div class="mt-2 border-t border-surface-700 pt-2">
<button
class="flex w-full items-center justify-center gap-1 rounded bg-surface-700 py-1.5 text-xs text-surface-300 hover:bg-surface-600 hover:text-surface-100"
<div class="mt-2 border-t pt-2">
<Button
variant="ghost"
size="sm"
class="h-7 w-full text-xs text-muted-foreground hover:text-foreground"
onclick={clearTags}
>
<X class="h-3 w-3" />
<X class="mr-1 h-3 w-3" />
Clear Filters
</button>
</Button>
</div>
{/if}
</div>

View file

@ -1,8 +1,15 @@
<script lang="ts">
import { characterVault } from "$lib/stores/characterVault.svelte";
import type { VaultCharacter, VaultCharacterType } from "$lib/types";
import { Search, User, Users, Loader2 } from "lucide-svelte";
import { Search, User, Users } from "lucide-svelte";
import { normalizeImageDataUrl } from "$lib/utils/image";
import { Input } from "$lib/components/ui/input";
import { Button } from "$lib/components/ui/button";
import { Badge } from "$lib/components/ui/badge";
import * as Avatar from "$lib/components/ui/avatar";
import { ScrollArea } from "$lib/components/ui/scroll-area";
import { Skeleton } from "$lib/components/ui/skeleton";
import { cn } from "$lib/utils/cn";
interface Props {
onSelect: (character: VaultCharacter) => void;
@ -48,10 +55,6 @@
});
});
const hasVaultCharacters = $derived(
characterVault.isLoaded && filteredCharacters.length > 0,
);
const emptyMessage = $derived(
filterType === "protagonist"
? "No protagonists in vault"
@ -75,37 +78,47 @@
}
</script>
<div class="space-y-3">
<div class="space-y-4">
<!-- Search -->
{#if characterVault.characters.filter((c) => !filterType || c.characterType === filterType).length > 0}
<div class="relative">
<Search
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-surface-500"
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
<Input
type="text"
bind:value={searchQuery}
placeholder="Search characters..."
class="w-full rounded-lg border border-surface-600 bg-surface-800 pl-10 pr-3 py-2 text-sm text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
class="pl-9 bg-background"
/>
</div>
{/if}
<!-- Character List -->
<div class="max-h-64 overflow-y-auto">
<div class="rounded-md border bg-muted/10">
<ScrollArea class="h-72 w-full rounded-md p-2">
{#if !characterVault.isLoaded}
<div class="flex h-32 items-center justify-center">
<Loader2 class="h-6 w-6 animate-spin text-surface-500" />
<div class="space-y-3 p-2">
{#each Array(3) as _}
<div class="flex items-center space-x-4">
<Skeleton class="h-10 w-10 rounded-full" />
<div class="space-y-2">
<Skeleton class="h-4 w-[150px]" />
<Skeleton class="h-3 w-[100px]" />
</div>
</div>
{/each}
</div>
{:else if filteredCharacters.length === 0}
<div class="flex h-32 items-center justify-center">
<div class="text-center">
<div
class="flex h-48 flex-col items-center justify-center p-4 text-center"
>
{#if filterType === "protagonist"}
<User class="mx-auto h-8 w-8 text-surface-600" />
<User class="mb-2 h-8 w-8 text-muted-foreground/50" />
{:else}
<Users class="mx-auto h-8 w-8 text-surface-600" />
<Users class="mb-2 h-8 w-8 text-muted-foreground/50" />
{/if}
<p class="mt-2 text-sm text-surface-400">
<p class="text-sm text-muted-foreground">
{#if searchQuery}
No characters match your search
{:else}
@ -113,58 +126,66 @@
{/if}
</p>
{#if !searchQuery && onNavigateToVault}
<button
class="mt-3 px-3 py-1.5 rounded-lg bg-surface-700 hover:bg-surface-600 text-xs text-surface-200 transition-colors"
<Button
variant="outline"
size="sm"
class="mt-4"
onclick={onNavigateToVault}
>
Go to Vault
</button>
</Button>
{/if}
</div>
</div>
{:else}
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
{#each filteredCharacters as character (character.id)}
<button
class="relative text-left rounded-lg border bg-surface-800 p-3 transition-all {isSelected(character.id)
? 'border-green-500 bg-green-500/10'
: 'border-surface-700 hover:border-accent-500'}"
class={cn(
"group relative flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isSelected(character.id)
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-transparent bg-card shadow-sm hover:border-primary/20",
)}
onclick={() => handleSelect(character)}
>
{#if isSelected(character.id)}
<div
class="absolute top-2 right-2 text-xs text-green-400 bg-green-500/20 px-1.5 py-0.5 rounded"
>
Selected
</div>
{/if}
<div class="flex items-start gap-2">
<!-- Portrait -->
{#if character.portrait}
<img
<!-- Avatar -->
<Avatar.Root class="h-10 w-10 border shadow-sm">
<Avatar.Image
src={normalizeImageDataUrl(character.portrait) ?? ""}
alt={character.name}
class="h-10 w-10 rounded-lg object-cover ring-1 ring-surface-600 shrink-0"
class="object-cover"
/>
{:else if character.characterType === "protagonist"}
<User class="h-5 w-5 text-accent-400 shrink-0 mt-0.5" />
<Avatar.Fallback class="bg-muted text-muted-foreground">
{#if character.characterType === "protagonist"}
<User class="h-5 w-5" />
{:else}
<Users class="h-5 w-5 text-surface-400 shrink-0 mt-0.5" />
<Users class="h-5 w-5" />
{/if}
<div class="flex-1 min-w-0">
<h4 class="font-medium text-surface-100 truncate text-sm">
</Avatar.Fallback>
</Avatar.Root>
<!-- Info -->
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<h4 class="truncate text-sm font-medium leading-none">
{character.name}
</h4>
<p class="text-xs text-surface-400 mt-0.5">
{#if isSelected(character.id)}
<Badge variant="default" class="h-5 px-1.5 text-[10px]">
Selected
</Badge>
{/if}
</div>
<p class="mt-1 truncate text-xs text-muted-foreground">
{character.characterType === "protagonist"
? "Protagonist"
: character.role || "Supporting"}
</p>
</div>
</div>
</button>
{/each}
</div>
{/if}
</ScrollArea>
</div>
</div>

View file

@ -1,9 +1,11 @@
<script lang="ts">
import type { VaultCharacter } from '$lib/types';
import { Star, Pencil, Trash2, User, Users, Loader2 } from 'lucide-svelte';
import { normalizeImageDataUrl } from '$lib/utils/image';
import { tagStore } from '$lib/stores/tags.svelte';
import TagBadge from '$lib/components/tags/TagBadge.svelte';
import type { VaultCharacter } from "$lib/types";
import { Star, Pencil, Trash2, User, Users, Loader2 } from "lucide-svelte";
import { normalizeImageDataUrl } from "$lib/utils/image";
import { Card, CardContent } from "$lib/components/ui/card";
import { Badge } from "$lib/components/ui/badge";
import { Button } from "$lib/components/ui/button";
import { cn } from "$lib/utils/cn";
interface Props {
character: VaultCharacter;
@ -14,7 +16,14 @@
onSelect?: () => void;
}
let { character, onEdit, onDelete, onToggleFavorite, selectable = false, onSelect }: Props = $props();
let {
character,
onEdit,
onDelete,
onToggleFavorite,
selectable = false,
onSelect,
}: Props = $props();
let confirmingDelete = $state(false);
let isImporting = $derived(character.metadata?.importing === true);
@ -45,127 +54,185 @@
}
</script>
<div
class="relative rounded-lg border bg-surface-800 p-4 {selectable && !isImporting ? 'cursor-pointer border-surface-700 hover:border-accent-500 transition-all' : 'border-surface-700'}"
<Card
class={cn(
"relative overflow-hidden transition-all group",
selectable &&
!isImporting &&
"cursor-pointer hover:border-primary/50 hover:shadow-sm",
selectable &&
!isImporting &&
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
onclick={handleCardClick}
role={selectable && !isImporting ? "button" : undefined}
tabindex={selectable && !isImporting ? 0 : undefined}
onkeydown={selectable && !isImporting ? (e) => { if (e.key === 'Enter' || e.key === ' ') handleCardClick(); } : undefined}
onkeydown={selectable && !isImporting
? (e) => {
if (e.key === "Enter" || e.key === " ") handleCardClick();
}
: undefined}
>
{#if isImporting}
<div class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg bg-surface-900/80 backdrop-blur-sm">
<Loader2 class="h-8 w-8 animate-spin text-primary-400" />
<span class="text-sm font-medium text-primary-200">Importing...</span>
<div
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-background/80 backdrop-blur-sm"
>
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<span class="text-sm font-medium text-muted-foreground">Importing...</span
>
</div>
{/if}
<div class="flex items-start gap-3">
<CardContent class="p-3">
<div class="flex gap-3">
<!-- Portrait -->
<div class="shrink-0">
{#if character.portrait}
<img
src={normalizeImageDataUrl(character.portrait) ?? ''}
src={normalizeImageDataUrl(character.portrait) ?? ""}
alt={character.name}
class="h-16 w-16 rounded-lg object-cover ring-1 ring-surface-600 flex-shrink-0"
class="h-32 rounded-md object-cover ring-1 ring-border"
/>
{:else}
<div class="flex h-16 w-16 items-center justify-center rounded-lg bg-surface-700 flex-shrink-0">
{#if character.characterType === 'protagonist'}
<User class="h-8 w-8 text-accent-400" />
<div
class="flex h-32 w-20 items-center justify-center rounded-md bg-muted"
>
{#if character.characterType === "protagonist"}
<User class="h-10 w-10 text-primary" />
{:else}
<Users class="h-8 w-8 text-surface-400" />
<Users class="h-10 w-10 text-muted-foreground" />
{/if}
</div>
{/if}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-medium text-surface-100 truncate">{character.name}</h3>
{#if character.favorite}
<Star class="h-4 w-4 text-yellow-400 fill-yellow-400 flex-shrink-0" />
{/if}
</div>
<span class="inline-block mt-1 rounded-full px-2 py-0.5 text-xs {
character.characterType === 'protagonist'
? 'bg-accent-500/20 text-accent-300'
: 'bg-surface-700 text-surface-400'
}">
{character.characterType === 'protagonist' ? 'Protagonist' : character.role || 'Supporting'}
</span>
{#if character.description}
<p class="mt-2 text-sm text-surface-400 line-clamp-2">{character.description}</p>
{/if}
{#if character.traits.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each character.traits.slice(0, 3) as trait}
<span class="rounded bg-surface-700 px-1.5 py-0.5 text-xs text-surface-400">{trait}</span>
{/each}
{#if character.traits.length > 3}
<span class="text-xs text-surface-500">+{character.traits.length - 3}</span>
{/if}
</div>
{/if}
{#if character.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each character.tags.slice(0, 3) as tag}
<TagBadge name={tag} color={tagStore.getColor(tag, 'character')} />
{/each}
{#if character.tags.length > 3}
<span class="text-xs text-surface-500">+{character.tags.length - 3}</span>
{/if}
</div>
<!-- Content -->
<div class="flex-1 min-w-0 flex flex-col">
<div class="flex justify-between items-start gap-2">
<!-- Header info -->
<div class="min-w-0">
<h3 class="font-bold text-base leading-none truncate pr-1">
{character.name}
</h3>
<div class="flex items-center gap-2 mt-1.5">
<Badge
variant={character.characterType === "protagonist"
? "default"
: "secondary"}
class="text-[10px] px-1.5 h-5"
>
{character.characterType === "protagonist"
? "Protagonist"
: character.role || "Supporting"}
</Badge>
{#if selectable && character.favorite}
<Star class="h-3 w-3 text-yellow-500 fill-yellow-500" />
{/if}
</div>
</div>
<!-- Actions -->
{#if !selectable && (onEdit || onDelete || onToggleFavorite)}
<div class="mt-3 flex items-center justify-end gap-1 border-t border-surface-700 pt-3">
<div class="flex items-center gap-0.5 -mt-1 -mr-1 shrink-0">
{#if confirmingDelete}
<button
class="rounded px-2 py-1 text-xs bg-surface-700 text-surface-300 hover:bg-surface-600"
onclick={handleCancelDelete}
<div
class="flex flex-col gap-1 items-end bg-background/95 backdrop-blur shadow-sm rounded-md p-1 absolute right-2 top-2 z-20 border border-border"
>
Cancel
</button>
<button
class="rounded px-2 py-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30"
onclick={handleDelete}
<span class="text-[10px] font-medium px-1 text-destructive"
>Delete?</span
>
Confirm Delete
</button>
<div class="flex gap-1">
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-[10px]"
onclick={handleCancelDelete}>No</Button
>
<Button
variant="destructive"
size="sm"
class="h-6 px-2 text-[10px]"
onclick={handleDelete}>Yes</Button
>
</div>
</div>
{:else}
{#if onToggleFavorite}
<button
class="rounded p-1.5 hover:bg-surface-700"
onclick={(e) => { e.stopPropagation(); onToggleFavorite?.(); }}
title={character.favorite ? 'Remove from favorites' : 'Add to favorites'}
<Button
variant="ghost"
size="icon"
class="h-7 w-7 opacity-70 group-hover:opacity-100 transition-opacity"
onclick={(e) => {
e.stopPropagation();
onToggleFavorite?.();
}}
title={character.favorite
? "Remove from favorites"
: "Add to favorites"}
>
<Star class="h-4 w-4 {character.favorite ? 'text-yellow-400 fill-yellow-400' : 'text-surface-500'}" />
</button>
<Star
class="h-3.5 w-3.5 {character.favorite
? 'text-yellow-500 fill-yellow-500'
: 'text-muted-foreground'}"
/>
</Button>
{/if}
{#if onEdit}
<button
class="rounded p-1.5 hover:bg-surface-700"
onclick={(e) => { e.stopPropagation(); onEdit?.(); }}
<Button
variant="ghost"
size="icon"
class="h-7 w-7 opacity-70 group-hover:opacity-100 transition-opacity"
onclick={(e) => {
e.stopPropagation();
onEdit?.();
}}
title="Edit"
>
<Pencil class="h-4 w-4 text-surface-500 hover:text-surface-200" />
</button>
<Pencil class="h-3.5 w-3.5 text-muted-foreground" />
</Button>
{/if}
{#if onDelete}
<button
class="rounded p-1.5 hover:bg-surface-700"
<Button
variant="ghost"
size="icon"
class="h-7 w-7 opacity-70 group-hover:opacity-100 transition-opacity hover:text-destructive hover:bg-destructive/10"
onclick={handleConfirmDelete}
title="Delete"
>
<Trash2 class="h-4 w-4 text-surface-500 hover:text-red-400" />
</button>
<Trash2 class="h-3.5 w-3.5 text-muted-foreground" />
</Button>
{/if}
{/if}
</div>
{/if}
</div>
</div>
{#if character.description}
<p
class="text-xs text-muted-foreground line-clamp-3 mt-2.5 leading-snug"
>
{character.description}
</p>
{/if}
{#if character.traits.length > 0}
<div class="mt-auto pt-2 flex flex-wrap gap-1">
{#each character.traits.slice(0, 3) as trait}
<Badge
variant="outline"
class="text-[10px] px-1.5 h-4 font-normal text-muted-foreground/80 border-muted-foreground/20"
>
{trait}
</Badge>
{/each}
{#if character.traits.length > 3}
<span class="text-[10px] text-muted-foreground self-center"
>+{character.traits.length - 3}</span
>
{/if}
</div>
{/if}
</div>
</div>
</CardContent>
</Card>

View file

@ -4,6 +4,13 @@
import { X, User, Users, ImageUp, Loader2 } from 'lucide-svelte';
import { normalizeImageDataUrl } from '$lib/utils/image';
import TagInput from '$lib/components/tags/TagInput.svelte';
import { cn } from '$lib/utils/cn';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label';
interface Props {
character?: VaultCharacter | null;
@ -120,246 +127,219 @@
}
</script>
<!-- Modal backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-lg bg-surface-800 shadow-xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-surface-700 p-4">
<h2 class="text-lg font-semibold text-surface-100">
{isEditing ? 'Edit Character' : 'New Character'}
</h2>
<button
class="rounded p-1.5 hover:bg-surface-700"
onclick={onClose}
>
<X class="h-5 w-5 text-surface-400" />
</button>
</div>
<Dialog.Root open={true} onOpenChange={(open) => { if (!open) onClose(); }}>
<Dialog.Content class="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{isEditing ? 'Edit Character' : 'New Character'}</Dialog.Title>
</Dialog.Header>
<!-- Form -->
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="p-4 space-y-4">
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4 py-2">
{#if error}
<div class="rounded-lg bg-red-500/10 border border-red-500/30 p-3 text-sm text-red-400">
<div class="rounded-md bg-destructive/10 border border-destructive/20 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<!-- Character Type -->
<div>
<label class="block text-sm font-medium text-surface-300 mb-2">Character Type</label>
<div class="space-y-2">
<Label>Character Type</Label>
<div class="flex gap-2">
<button
<Button
type="button"
class="flex-1 flex items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm transition-colors {
characterType === 'protagonist'
? 'border-accent-500 bg-accent-500/20 text-accent-300'
: 'border-surface-600 bg-surface-700 text-surface-400 hover:border-surface-500'
}"
variant={characterType === 'protagonist' ? "default" : "outline"}
class="flex-1"
onclick={() => characterType = 'protagonist'}
>
<User class="h-4 w-4" />
<User class="mr-2 h-4 w-4" />
Protagonist
</button>
<button
</Button>
<Button
type="button"
class="flex-1 flex items-center justify-center gap-2 rounded-lg border px-4 py-2 text-sm transition-colors {
characterType === 'supporting'
? 'border-accent-500 bg-accent-500/20 text-accent-300'
: 'border-surface-600 bg-surface-700 text-surface-400 hover:border-surface-500'
}"
variant={characterType === 'supporting' ? "default" : "outline"}
class="flex-1"
onclick={() => characterType = 'supporting'}
>
<Users class="h-4 w-4" />
<Users class="mr-2 h-4 w-4" />
Supporting
</button>
</Button>
</div>
</div>
<!-- Name -->
<div>
<label for="name" class="block text-sm font-medium text-surface-300 mb-1">Name *</label>
<input
<div class="space-y-2">
<Label for="name">Name *</Label>
<Input
id="name"
type="text"
bind:value={name}
placeholder="Character name"
class="w-full rounded-lg border border-surface-600 bg-surface-700 px-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
</div>
<!-- Description -->
<div>
<label for="description" class="block text-sm font-medium text-surface-300 mb-1">Description</label>
<textarea
<div class="space-y-2">
<Label for="description">Description</Label>
<Textarea
id="description"
bind:value={description}
placeholder="Brief description of the character"
rows="3"
class="w-full rounded-lg border border-surface-600 bg-surface-700 px-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none resize-none"
></textarea>
rows={3}
class="resize-none"
/>
</div>
<!-- Protagonist-specific fields -->
{#if characterType === 'protagonist'}
<div>
<label for="background" class="block text-sm font-medium text-surface-300 mb-1">Background</label>
<textarea
<div class="space-y-2">
<Label for="background">Background</Label>
<Textarea
id="background"
bind:value={background}
placeholder="Character's backstory and history"
rows="2"
class="w-full rounded-lg border border-surface-600 bg-surface-700 px-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none resize-none"
></textarea>
rows={2}
class="resize-none"
/>
</div>
<div>
<label for="motivation" class="block text-sm font-medium text-surface-300 mb-1">Motivation</label>
<input
<div class="space-y-2">
<Label for="motivation">Motivation</Label>
<Input
id="motivation"
type="text"
bind:value={motivation}
placeholder="What drives this character?"
class="w-full rounded-lg border border-surface-600 bg-surface-700 px-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
</div>
{/if}
<!-- Supporting-specific fields -->
{#if characterType === 'supporting'}
<div>
<label for="role" class="block text-sm font-medium text-surface-300 mb-1">Role</label>
<input
<div class="space-y-2">
<Label for="role">Role</Label>
<Input
id="role"
type="text"
bind:value={role}
placeholder="e.g., Mentor, Rival, Love Interest"
class="w-full rounded-lg border border-surface-600 bg-surface-700 px-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
</div>
<div>
<label for="relationship" class="block text-sm font-medium text-surface-300 mb-1">Relationship Template</label>
<input
<div class="space-y-2">
<Label for="relationship">Relationship Template</Label>
<Input
id="relationship"
type="text"
bind:value={relationshipTemplate}
placeholder="Default relationship to protagonist"
class="w-full rounded-lg border border-surface-600 bg-surface-700 px-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
</div>
{/if}
<!-- Traits -->
<div>
<label for="traits" class="block text-sm font-medium text-surface-300 mb-1">Traits</label>
<input
<div class="space-y-2">
<Label for="traits">Traits</Label>
<Input
id="traits"
type="text"
bind:value={traits}
placeholder="Brave, Curious, Stubborn (comma-separated)"
class="w-full rounded-lg border border-surface-600 bg-surface-700 px-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
<p class="mt-1 text-xs text-surface-500">Comma-separated personality traits</p>
<p class="text-[0.8rem] text-muted-foreground">Comma-separated personality traits</p>
</div>
<!-- Visual Descriptors -->
<div>
<label for="visualDescriptors" class="block text-sm font-medium text-surface-300 mb-1">Visual Descriptors</label>
<input
<div class="space-y-2">
<Label for="visualDescriptors">Visual Descriptors</Label>
<Input
id="visualDescriptors"
type="text"
bind:value={visualDescriptors}
placeholder="Tall, dark hair, blue eyes (comma-separated)"
class="w-full rounded-lg border border-surface-600 bg-surface-700 px-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
/>
<p class="mt-1 text-xs text-surface-500">Used for portrait generation</p>
<p class="text-[0.8rem] text-muted-foreground">Used for portrait generation</p>
</div>
<!-- Portrait -->
<div>
<label class="block text-sm font-medium text-surface-300 mb-2">Portrait</label>
<div class="space-y-2">
<Label>Portrait</Label>
<div class="flex items-start gap-4">
{#if portrait}
<div class="relative">
<div class="relative group">
<img
src={normalizeImageDataUrl(portrait) ?? ''}
alt="Portrait preview"
class="h-20 w-20 rounded-lg object-cover ring-1 ring-surface-600"
class="h-20 w-20 rounded-md object-cover ring-1 ring-border"
/>
<button
type="button"
class="absolute -right-2 -top-2 rounded-full bg-red-500 p-1 text-white hover:bg-red-600"
class="absolute -right-2 -top-2 rounded-full bg-destructive p-1 text-destructive-foreground hover:bg-destructive/90 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
onclick={removePortrait}
>
<X class="h-3 w-3" />
</button>
</div>
{:else}
<div class="flex h-20 w-20 items-center justify-center rounded-lg bg-surface-700 ring-1 ring-surface-600">
<div class="flex h-20 w-20 items-center justify-center rounded-md bg-muted ring-1 ring-border">
{#if characterType === 'protagonist'}
<User class="h-8 w-8 text-surface-500" />
<User class="h-8 w-8 text-muted-foreground" />
{:else}
<Users class="h-8 w-8 text-surface-500" />
<Users class="h-8 w-8 text-muted-foreground" />
{/if}
</div>
{/if}
<label class="flex cursor-pointer items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-2 text-sm text-surface-300 hover:border-surface-500">
<div class="flex-1">
<Button variant="outline" class="w-full relative cursor-pointer" disabled={uploadingPortrait}>
{#if uploadingPortrait}
<Loader2 class="h-4 w-4 animate-spin" />
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{:else}
<ImageUp class="h-4 w-4" />
<ImageUp class="mr-2 h-4 w-4" />
{/if}
Upload Image
<input
type="file"
accept="image/*"
class="hidden"
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
onchange={handlePortraitUpload}
disabled={uploadingPortrait}
/>
</label>
</Button>
</div>
</div>
</div>
<!-- Tags -->
<div>
<label for="tags" class="block text-sm font-medium text-surface-300 mb-1">Tags</label>
<div class="space-y-2">
<Label for="tags">Tags</Label>
<TagInput
value={tags}
type="character"
onChange={(newTags) => tags = newTags}
placeholder="Add tags..."
/>
<p class="mt-1 text-xs text-surface-500">For organizing your vault</p>
<p class="text-[0.8rem] text-muted-foreground">For organizing your vault</p>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-surface-700">
<button
<Dialog.Footer class="gap-2 sm:gap-0">
<Button
type="button"
class="rounded-lg px-4 py-2 text-sm text-surface-400 hover:text-surface-200"
variant="ghost"
onclick={onClose}
disabled={saving}
>
Cancel
</button>
<button
</Button>
<Button
type="submit"
class="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-white hover:bg-accent-600 disabled:opacity-50"
disabled={saving || !name.trim()}
>
{#if saving}
<Loader2 class="h-4 w-4 animate-spin" />
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
{/if}
{isEditing ? 'Save Changes' : 'Create Character'}
</button>
</div>
</Button>
</Dialog.Footer>
</form>
</div>
</div>
</Dialog.Content>
</Dialog.Root>

View file

@ -1,9 +1,14 @@
<script lang="ts">
import { characterVault } from '$lib/stores/characterVault.svelte';
import type { VaultCharacter, VaultCharacterType } from '$lib/types';
import { X, Search, User, Users, Loader2 } from 'lucide-svelte';
import { Search, User, Users, Loader2 } from 'lucide-svelte';
import VaultCharacterCard from './VaultCharacterCard.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Button } from '$lib/components/ui/button';
import { ScrollArea } from '$lib/components/ui/scroll-area';
interface Props {
filterType?: VaultCharacterType;
onSelect: (character: VaultCharacter) => void;
@ -66,67 +71,52 @@
);
</script>
<!-- Modal backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
>
<div class="w-full max-w-2xl max-h-[80vh] flex flex-col rounded-lg bg-surface-800 shadow-xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-surface-700 p-4">
<div class="flex items-center gap-2">
<Dialog.Root open={true} onOpenChange={(open) => { if (!open) onClose(); }}>
<Dialog.Content class="sm:max-w-2xl max-h-[80vh] flex flex-col gap-0 p-0 overflow-hidden">
<Dialog.Header class="p-4 pb-2 border-b">
<Dialog.Title class="flex items-center gap-2">
{#if filterType === 'protagonist'}
<User class="h-5 w-5 text-accent-400" />
<User class="h-5 w-5 text-primary" />
{:else}
<Users class="h-5 w-5 text-surface-400" />
<Users class="h-5 w-5 text-muted-foreground" />
{/if}
<h2 class="text-lg font-semibold text-surface-100">{title}</h2>
</div>
<button
class="rounded p-1.5 hover:bg-surface-700"
onclick={onClose}
>
<X class="h-5 w-5 text-surface-400" />
</button>
</div>
{title}
</Dialog.Title>
</Dialog.Header>
<!-- Search -->
<div class="border-b border-surface-700 p-4">
<div class="p-4 pb-2 border-b bg-muted/20">
<div class="relative">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-surface-500" />
<input
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
bind:value={searchQuery}
placeholder="Search characters..."
class="w-full rounded-lg border border-surface-600 bg-surface-700 pl-10 pr-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
class="pl-9 bg-background"
/>
</div>
</div>
<!-- Character List -->
<div class="flex-1 overflow-y-auto p-4">
{#if !characterVault.isLoaded}
<div class="flex h-40 items-center justify-center">
<Loader2 class="h-8 w-8 animate-spin text-surface-500" />
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
{:else if filteredCharacters.length === 0}
<div class="flex h-40 items-center justify-center">
<div class="flex h-40 items-center justify-center rounded-lg border border-dashed border-border bg-muted/20">
<div class="text-center">
{#if filterType === 'protagonist'}
<User class="mx-auto h-10 w-10 text-surface-600" />
<User class="mx-auto h-10 w-10 text-muted-foreground/50" />
{:else}
<Users class="mx-auto h-10 w-10 text-surface-600" />
<Users class="mx-auto h-10 w-10 text-muted-foreground/50" />
{/if}
<p class="mt-2 text-surface-400">
<p class="mt-2 text-muted-foreground">
{#if searchQuery}
No characters match your search
{:else}
{emptyMessage}
{/if}
</p>
<p class="mt-1 text-sm text-surface-500">
<p class="mt-1 text-sm text-muted-foreground/80">
Create characters in the Character Vault first
</p>
</div>
@ -144,14 +134,10 @@
{/if}
</div>
<!-- Footer -->
<div class="border-t border-surface-700 p-4 flex justify-end">
<button
class="rounded-lg px-4 py-2 text-sm text-surface-400 hover:text-surface-200"
onclick={onClose}
>
<Dialog.Footer class="p-4 pt-2 border-t bg-muted/20">
<Button variant="ghost" onclick={onClose}>
Cancel
</button>
</div>
</div>
</div>
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>

View file

@ -1,8 +1,10 @@
<script lang="ts">
import type { VaultLorebook, EntryType } from '$lib/types';
import { Star, Pencil, Trash2, Archive, Users, MapPin, Box, Flag, Brain, Calendar, Loader2 } from 'lucide-svelte';
import { tagStore } from '$lib/stores/tags.svelte';
import TagBadge from '$lib/components/tags/TagBadge.svelte';
import { Star, Pencil, Trash2, Archive, Users, MapPin, Box, Flag, Brain, Calendar, Loader2, Book } from 'lucide-svelte';
import { Card, CardContent } from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils/cn';
interface Props {
lorebook: VaultLorebook;
@ -62,123 +64,126 @@
});
</script>
<div
class="relative rounded-lg border bg-surface-800 p-4 {selectable && !isImporting ? 'cursor-pointer border-surface-700 hover:border-accent-500 transition-all' : 'border-surface-700'}"
<Card
class={cn(
"relative overflow-hidden transition-all group",
selectable && !isImporting && "cursor-pointer hover:border-primary/50 hover:shadow-sm",
selectable && !isImporting && "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
onclick={handleCardClick}
role={selectable && !isImporting ? "button" : undefined}
tabindex={selectable && !isImporting ? 0 : undefined}
onkeydown={selectable && !isImporting ? (e) => { if (e.key === 'Enter' || e.key === ' ') handleCardClick(); } : undefined}
>
{#if isImporting}
<div class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg bg-surface-900/80 backdrop-blur-sm">
<Loader2 class="h-8 w-8 animate-spin text-primary-400" />
<span class="text-sm font-medium text-primary-200">Processing...</span>
<div class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-background/80 backdrop-blur-sm">
<Loader2 class="h-8 w-8 animate-spin text-primary" />
<span class="text-sm font-medium text-muted-foreground">Processing...</span>
</div>
{/if}
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-surface-700 flex-shrink-0">
<Archive class="h-6 w-6 text-accent-400" />
<CardContent class="p-3">
<div class="flex gap-3">
<!-- Icon/Cover -->
<div class="shrink-0">
<div class="flex h-24 w-24 items-center justify-center rounded-md bg-muted ring-1 ring-border/50">
<Book class="h-10 w-10 text-muted-foreground/50" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-medium text-surface-100 truncate">{lorebook.name}</h3>
{#if lorebook.favorite}
<Star class="h-4 w-4 text-yellow-400 fill-yellow-400 flex-shrink-0" />
{/if}
</div>
<div class="flex items-center gap-2 mt-1">
<span class="text-xs text-surface-400">
<!-- Content -->
<div class="flex-1 min-w-0 flex flex-col">
<div class="flex justify-between items-start gap-2">
<!-- Header info -->
<div class="min-w-0">
<h3 class="font-bold text-base leading-none truncate pr-1">{lorebook.name}</h3>
<div class="flex items-center gap-2 mt-1.5">
<span class="text-[10px] text-muted-foreground font-medium">
{lorebook.entries.length} entries
</span>
{#if lorebook.source === 'story'}
<span class="text-xs text-surface-500">• From Story</span>
{:else if lorebook.source === 'import'}
<span class="text-xs text-surface-500">• Imported</span>
{#if lorebook.source === 'story' || lorebook.source === 'import'}
<Badge variant="secondary" class="text-[10px] px-1.5 h-4 font-normal">
{lorebook.source === 'story' ? 'Story' : 'Imported'}
</Badge>
{/if}
</div>
{#if lorebook.description}
<p class="mt-2 text-sm text-surface-400 line-clamp-2">{lorebook.description}</p>
{/if}
<!-- Entry Type Breakdown -->
{#if entryCounts.length > 0}
<div class="mt-3 flex flex-wrap gap-2">
{#each entryCounts.slice(0, 4) as { type, count }}
{@const Icon = typeIcons[type]}
<div class="flex items-center gap-1 rounded bg-surface-700 px-1.5 py-0.5 text-xs text-surface-400" title="{type}">
<Icon class="h-3 w-3" />
<span>{count}</span>
</div>
{/each}
{#if entryCounts.length > 4}
<span class="text-xs text-surface-500 self-center">+{entryCounts.length - 4}</span>
{/if}
</div>
{/if}
{#if lorebook.tags.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each lorebook.tags.slice(0, 3) as tag}
<TagBadge name={tag} color={tagStore.getColor(tag, 'lorebook')} />
{/each}
{#if lorebook.tags.length > 3}
<span class="text-xs text-surface-500">+{lorebook.tags.length - 3}</span>
{/if}
</div>
{#if selectable && lorebook.favorite}
<Star class="h-3 w-3 text-yellow-500 fill-yellow-500" />
{/if}
</div>
</div>
<!-- Actions -->
{#if !selectable && (onEdit || onDelete || onToggleFavorite)}
<div class="mt-3 flex items-center justify-end gap-1 border-t border-surface-700 pt-3">
<div class="flex items-center gap-0.5 -mt-1 -mr-1 shrink-0">
{#if confirmingDelete}
<button
class="rounded px-2 py-1 text-xs bg-surface-700 text-surface-300 hover:bg-surface-600"
onclick={handleCancelDelete}
>
Cancel
</button>
<button
class="rounded px-2 py-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30"
onclick={handleDelete}
>
Confirm Delete
</button>
<div class="flex flex-col gap-1 items-end bg-background/95 backdrop-blur shadow-sm rounded-md p-1 absolute right-2 top-2 z-20 border border-border">
<span class="text-[10px] font-medium px-1 text-destructive">Delete?</span>
<div class="flex gap-1">
<Button variant="ghost" size="sm" class="h-6 px-2 text-[10px]" onclick={handleCancelDelete}>No</Button>
<Button variant="destructive" size="sm" class="h-6 px-2 text-[10px]" onclick={handleDelete}>Yes</Button>
</div>
</div>
{:else}
{#if onToggleFavorite}
<button
class="rounded p-1.5 hover:bg-surface-700"
<Button
variant="ghost"
size="icon"
class="h-7 w-7 opacity-70 group-hover:opacity-100 transition-opacity"
onclick={(e) => { e.stopPropagation(); onToggleFavorite?.(); }}
title={lorebook.favorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Star class="h-4 w-4 {lorebook.favorite ? 'text-yellow-400 fill-yellow-400' : 'text-surface-500'}" />
</button>
<Star class="h-3.5 w-3.5 {lorebook.favorite ? 'text-yellow-500 fill-yellow-500' : 'text-muted-foreground'}" />
</Button>
{/if}
{#if onEdit}
<button
class="rounded p-1.5 hover:bg-surface-700"
<Button
variant="ghost"
size="icon"
class="h-7 w-7 opacity-70 group-hover:opacity-100 transition-opacity"
onclick={(e) => { e.stopPropagation(); onEdit?.(); }}
title="Edit"
>
<Pencil class="h-4 w-4 text-surface-500 hover:text-surface-200" />
</button>
<Pencil class="h-3.5 w-3.5 text-muted-foreground" />
</Button>
{/if}
{#if onDelete}
<button
class="rounded p-1.5 hover:bg-surface-700"
<Button
variant="ghost"
size="icon"
class="h-7 w-7 opacity-70 group-hover:opacity-100 transition-opacity hover:text-destructive hover:bg-destructive/10"
onclick={handleConfirmDelete}
title="Delete"
>
<Trash2 class="h-4 w-4 text-surface-500 hover:text-red-400" />
</button>
<Trash2 class="h-3.5 w-3.5 text-muted-foreground" />
</Button>
{/if}
{/if}
</div>
{/if}
</div>
</div>
{#if lorebook.description}
<p class="text-xs text-muted-foreground line-clamp-2 mt-2 leading-relaxed">{lorebook.description}</p>
{/if}
<!-- Entry Type Breakdown -->
{#if entryCounts.length > 0}
<div class="mt-auto pt-2 flex flex-wrap gap-1.5">
{#each entryCounts.slice(0, 4) as { type, count }}
{@const Icon = typeIcons[type]}
<div class="flex items-center gap-1 text-[10px] text-muted-foreground/80 bg-muted/50 px-1.5 py-0.5 rounded-sm border border-border/50" title="{type}">
{#if Icon}
<Icon class="h-3 w-3 opacity-70" />
{/if}
<span>{count}</span>
</div>
{/each}
{#if entryCounts.length > 4}
<span class="text-[10px] text-muted-foreground self-center">+{entryCounts.length - 4}</span>
{/if}
</div>
{/if}
</div>
</div>
</CardContent>
</Card>

View file

@ -11,13 +11,12 @@
} from "$lib/types";
import {
Plus,
Search,
Search as SearchIcon,
Star,
User,
Users,
ChevronLeft,
Upload,
Loader2,
Archive,
Book,
Globe,
@ -36,6 +35,20 @@
import { tagStore } from "$lib/stores/tags.svelte";
import { fade } from "svelte/transition";
// Shadcn Components
import { Button, buttonVariants } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "$lib/components/ui/tabs";
import { Badge } from "$lib/components/ui/badge";
import { Skeleton } from "$lib/components/ui/skeleton";
import { ScrollArea } from "$lib/components/ui/scroll-area";
import { cn } from "$lib/utils/cn";
// Types
type VaultTab = "characters" | "lorebooks" | "scenarios";
@ -81,10 +94,14 @@
}
if (selectedTags.length > 0) {
if (filterLogic === 'AND') {
chars = chars.filter(c => selectedTags.every(tag => c.tags.includes(tag)));
if (filterLogic === "AND") {
chars = chars.filter((c) =>
selectedTags.every((tag) => c.tags.includes(tag)),
);
} else {
chars = chars.filter(c => selectedTags.some(tag => c.tags.includes(tag)));
chars = chars.filter((c) =>
selectedTags.some((tag) => c.tags.includes(tag)),
);
}
}
@ -111,10 +128,14 @@
}
if (selectedTags.length > 0) {
if (filterLogic === 'AND') {
books = books.filter(b => selectedTags.every(tag => b.tags.includes(tag)));
if (filterLogic === "AND") {
books = books.filter((b) =>
selectedTags.every((tag) => b.tags.includes(tag)),
);
} else {
books = books.filter(b => selectedTags.some(tag => b.tags.includes(tag)));
books = books.filter((b) =>
selectedTags.some((tag) => b.tags.includes(tag)),
);
}
}
@ -140,10 +161,14 @@
}
if (selectedTags.length > 0) {
if (filterLogic === 'AND') {
items = items.filter(s => selectedTags.every(tag => s.tags.includes(tag)));
if (filterLogic === "AND") {
items = items.filter((s) =>
selectedTags.every((tag) => s.tags.includes(tag)),
);
} else {
items = items.filter(s => selectedTags.some(tag => s.tags.includes(tag)));
items = items.filter((s) =>
selectedTags.some((tag) => s.tags.includes(tag)),
);
}
}
@ -311,228 +336,260 @@
}
</script>
<div class="flex h-full flex-col bg-surface-900">
<Tabs
value={activeTab}
onValueChange={(v) => (activeTab = v as VaultTab)}
class="flex h-full flex-col bg-background"
>
<!-- Header -->
<div class="flex flex-col border-b border-surface-700 bg-surface-800">
<div class="flex flex-col border-b bg-muted/20">
<!-- Top Bar -->
<div class="flex items-center justify-between px-3 py-3 sm:px-6">
<div class="flex items-center gap-3">
<button
class="rounded p-1.5 hover:bg-surface-700 -ml-1 sm:ml-0"
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-1">
<Button
variant="link"
size="icon"
class="-ml-2 h-9 w-9 text-muted-foreground hover:text-foreground"
onclick={() => ui.setActivePanel("library")}
title="Back to Library"
>
<ChevronLeft class="h-5 w-5 text-surface-400" />
</button>
<ChevronLeft class="h-5 w-5" />
</Button>
<div class="flex items-center gap-2">
<Archive class="h-5 w-5 text-surface-400" />
<h2 class="text-lg font-semibold text-surface-100">Vault</h2>
<Archive class="h-5 w-5 text-muted-foreground" />
<h2 class="text-lg font-semibold tracking-tight">Vault</h2>
</div>
</div>
<!-- Right Side Actions (Context Sensitive) -->
<div class="flex items-center gap-2 -mr-1 sm:mr-0">
<button
class="flex items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-1.5 text-sm text-surface-300 hover:border-surface-500 hover:bg-surface-600"
<!-- Right Side Actions -->
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
class="h-9 hidden sm:flex"
onclick={() => (showTagManager = true)}
title="Manage Tags"
>
<Tags class="h-4 w-4" />
<span class="hidden sm:inline">Tags</span>
</button>
Tags
</Button>
<!-- Mobile only icon button -->
<Button
variant="outline"
size="icon"
class="h-9 w-9 sm:hidden"
onclick={() => (showTagManager = true)}
>
<Tags class="h-4 w-4" />
</Button>
{#if activeTab === "characters"}
<button
class="flex items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-1.5 text-sm text-surface-300 hover:border-surface-500 hover:bg-surface-600"
<Button
variant="outline"
size="sm"
class="h-9 hidden sm:flex"
onclick={() => openBrowseOnline("character")}
title="Browse characters online"
>
<Globe class="h-4 w-4" />
<span class="hidden sm:inline">Browse Online</span>
</button>
<label
class="flex cursor-pointer items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-1.5 text-sm text-surface-300 hover:border-surface-500"
Browse Online
</Button>
<div class="relative">
<Button
variant="outline"
size="sm"
class="h-9 cursor-pointer hidden sm:flex"
>
<Upload class="h-4 w-4" />
<span class="hidden sm:inline">Import Card</span>
Import Card
</Button>
<Button
variant="outline"
size="icon"
class="h-9 w-9 cursor-pointer sm:hidden"
>
<Upload class="h-4 w-4" />
</Button>
<input
type="file"
accept=".json,.png"
class="hidden"
class="absolute inset-0 cursor-pointer opacity-0"
onchange={handleImportCard}
/>
</label>
<button
class="flex items-center gap-2 rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-accent-600"
onclick={() => openCreateCharForm()}
>
</div>
<Button size="sm" class="h-9" onclick={() => openCreateCharForm()}>
<Plus class="h-4 w-4" />
<span class="hidden sm:inline">New Character</span>
</button>
<span class="sm:hidden">New</span>
</Button>
{:else if activeTab === "lorebooks"}
<!-- Lorebook Actions -->
<button
class="flex items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-1.5 text-sm text-surface-300 hover:border-surface-500 hover:bg-surface-600"
<Button
variant="outline"
size="sm"
class="h-9 hidden sm:flex"
onclick={() => openBrowseOnline("lorebook")}
title="Browse lorebooks online"
>
<Globe class="h-4 w-4" />
<span class="hidden sm:inline">Browse Online</span>
</button>
<label
class="flex cursor-pointer items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-1.5 text-sm text-surface-300 hover:border-surface-500"
Browse Online
</Button>
<div class="relative">
<Button
variant="outline"
size="sm"
class="h-9 cursor-pointer hidden sm:flex"
>
<Upload class="h-4 w-4" />
<span class="hidden sm:inline">Import Lorebook</span>
Import Lorebook
</Button>
<Button
variant="outline"
size="icon"
class="h-9 w-9 cursor-pointer sm:hidden"
>
<Upload class="h-4 w-4" />
</Button>
<input
type="file"
accept=".json,application/json"
class="hidden"
class="absolute inset-0 cursor-pointer opacity-0"
onchange={handleImportLorebook}
/>
</label>
<button
class="flex items-center gap-2 rounded-lg bg-accent-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-accent-600"
onclick={handleCreateLorebook}
>
</div>
<Button size="sm" class="h-9" onclick={handleCreateLorebook}>
<Plus class="h-4 w-4" />
<span class="hidden sm:inline">New Lorebook</span>
</button>
<span class="sm:hidden">New</span>
</Button>
{:else if activeTab === "scenarios"}
<!-- Scenario Actions -->
<button
class="flex items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-1.5 text-sm text-surface-300 hover:border-surface-500 hover:bg-surface-600"
<Button
variant="outline"
size="sm"
class="h-9 hidden sm:flex"
onclick={() => openBrowseOnline("scenario")}
title="Browse scenarios online"
>
<Globe class="h-4 w-4" />
<span class="hidden sm:inline">Browse Online</span>
</button>
<label
class="flex cursor-pointer items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-3 py-1.5 text-sm text-surface-300 hover:border-surface-500"
Browse Online
</Button>
<div class="relative">
<Button
variant="outline"
size="sm"
class="h-9 cursor-pointer hidden sm:flex"
>
<Upload class="h-4 w-4" />
<span class="hidden sm:inline">Import Card</span>
Import Card
</Button>
<Button
variant="outline"
size="icon"
class="h-9 w-9 cursor-pointer sm:hidden"
>
<Upload class="h-4 w-4" />
</Button>
<input
type="file"
accept=".json,.png"
class="hidden"
class="absolute inset-0 cursor-pointer opacity-0"
onchange={handleImportScenario}
/>
</label>
</div>
{/if}
</div>
</div>
<!-- Tab Bar -->
<div class="flex px-4 sm:px-6">
<button
class="flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors {activeTab ===
'characters'
? 'border-accent-500 text-accent-400'
: 'border-transparent text-surface-400 hover:text-surface-200'}"
onclick={() => (activeTab = "characters")}
>
<div class="px-4 pb-2">
<TabsList class="grid w-full grid-cols-3 max-w-md bg-muted/50">
<TabsTrigger value="characters" class="flex items-center gap-2">
<Users class="h-4 w-4" />
<span class="hidden sm:inline">Characters</span>
<span
class="rounded-full bg-surface-700 px-2 py-0.5 text-xs text-surface-400 sm:ml-1"
>
{characterVault.characters.length}
</span>
</button>
<button
class="flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors {activeTab ===
'lorebooks'
? 'border-accent-500 text-accent-400'
: 'border-transparent text-surface-400 hover:text-surface-200'}"
onclick={() => (activeTab = "lorebooks")}
<Badge variant="secondary" class="ml-1 px-1 py-0 h-5 text-[10px]"
>{characterVault.characters.length}</Badge
>
</TabsTrigger>
<TabsTrigger value="lorebooks" class="flex items-center gap-2">
<Book class="h-4 w-4" />
<span class="hidden sm:inline">Lorebooks</span>
<span
class="rounded-full bg-surface-700 px-2 py-0.5 text-xs text-surface-400 sm:ml-1"
>
{lorebookVault.lorebooks.length}
</span>
</button>
<button
class="flex items-center gap-2 border-b-2 px-4 py-3 text-sm font-medium transition-colors {activeTab ===
'scenarios'
? 'border-accent-500 text-accent-400'
: 'border-transparent text-surface-400 hover:text-surface-200'}"
onclick={() => (activeTab = "scenarios")}
<Badge variant="secondary" class="ml-1 px-1 py-0 h-5 text-[10px]"
>{lorebookVault.lorebooks.length}</Badge
>
</TabsTrigger>
<TabsTrigger value="scenarios" class="flex items-center gap-2">
<MapPin class="h-4 w-4" />
<span class="hidden sm:inline">Scenarios</span>
<span
class="rounded-full bg-surface-700 px-2 py-0.5 text-xs text-surface-400 sm:ml-1"
<Badge variant="secondary" class="ml-1 px-1 py-0 h-5 text-[10px]"
>{scenarioVault.scenarios.length}</Badge
>
{scenarioVault.scenarios.length}
</span>
</button>
</TabsTrigger>
</TabsList>
</div>
</div>
<!-- Search and Filters (Common) -->
<!-- Search and Filters -->
<div
class="border-b border-surface-700 px-3 py-3 space-y-3 sm:px-6 bg-surface-900/50"
class="flex flex-col gap-3 bg-background/95 p-4 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<!-- Errors -->
{#if activeTab === "characters" && importCharError}
<div
class="rounded-lg bg-red-500/10 border border-red-500/30 p-2 text-sm text-red-400 flex items-center justify-between"
class="flex items-center justify-between rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
<span>{importCharError}</span>
<button
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-destructive hover:bg-destructive/20 hover:text-destructive"
onclick={() => (importCharError = null)}
class="text-red-400 hover:text-red-300"
>
<span class="sr-only">Dismiss</span>
&times;
</button>
</Button>
</div>
{/if}
{#if activeTab === "lorebooks"}
{#if importLorebookError}
{#if activeTab === "lorebooks" && importLorebookError}
<div
class="rounded-lg bg-red-500/10 border border-red-500/30 p-2 text-sm text-red-400 flex items-center justify-between"
class="flex items-center justify-between rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
<span>{importLorebookError}</span>
<button
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-destructive hover:bg-destructive/20 hover:text-destructive"
onclick={() => (importLorebookError = null)}
class="text-red-400 hover:text-red-300"
>
<span class="sr-only">Dismiss</span>
&times;
</button>
</Button>
</div>
{/if}
{/if}
{#if activeTab === "scenarios"}
{#if importScenarioError}
{#if activeTab === "scenarios" && importScenarioError}
<div
class="rounded-lg bg-red-500/10 border border-red-500/30 p-2 text-sm text-red-400 flex items-center justify-between"
class="flex items-center justify-between rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
<span>{importScenarioError}</span>
<button
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-destructive hover:bg-destructive/20 hover:text-destructive"
onclick={() => (importScenarioError = null)}
class="text-red-400 hover:text-red-300"
>
<span class="sr-only">Dismiss</span>
&times;
</button>
</Button>
</div>
{/if}
{/if}
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex flex-col gap-3 sm:flex-row">
<div class="relative flex-1">
<Search
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-surface-500"
<SearchIcon
class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground"
/>
<input
<Input
type="text"
bind:value={searchQuery}
placeholder={activeTab === "characters"
@ -540,85 +597,108 @@
: activeTab === "lorebooks"
? "Search lorebooks..."
: "Search scenarios..."}
class="w-full rounded-lg border border-surface-600 bg-surface-700 pl-10 pr-3 py-2 text-surface-100 placeholder-surface-500 focus:border-accent-500 focus:outline-none"
class="pl-9 bg-muted/40"
/>
</div>
<div
class="flex items-center gap-2 overflow-x-auto -mx-3 px-3 pb-1 sm:pb-0 sm:mx-0 sm:px-0 sm:overflow-visible no-scrollbar"
class="flex items-center gap-2 overflow-x-auto pb-1 sm:pb-0 no-scrollbar"
>
{#if activeTab === "characters"}
<div
class="flex items-center gap-1 rounded-lg bg-surface-800 p-1 border border-surface-700 shrink-0"
>
<button
class="rounded-md px-3 py-1.5 text-xs transition-colors {charFilterType ===
'all'
? 'bg-surface-600 text-surface-100'
: 'text-surface-400 hover:text-surface-200'}"
<div class="flex items-center rounded-lg border bg-muted p-1">
<Button
variant={charFilterType === "all" ? "secondary" : "ghost"}
size="sm"
class="h-7 px-3 text-xs"
onclick={() => (charFilterType = "all")}
>
All
</button>
<button
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors {charFilterType ===
'protagonist'
? 'bg-surface-600 text-surface-100'
: 'text-surface-400 hover:text-surface-200'}"
</Button>
<Button
variant={charFilterType === "protagonist" ? "secondary" : "ghost"}
size="sm"
class="h-7 px-3 text-xs gap-1.5"
onclick={() => (charFilterType = "protagonist")}
>
<User class="h-3 w-3" />
Protagonists
</button>
<button
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs transition-colors {charFilterType ===
'supporting'
? 'bg-surface-600 text-surface-100'
: 'text-surface-400 hover:text-surface-200'}"
</Button>
<Button
variant={charFilterType === "supporting" ? "secondary" : "ghost"}
size="sm"
class="h-7 px-3 text-xs gap-1.5"
onclick={() => (charFilterType = "supporting")}
>
<Users class="h-3 w-3" />
Supporting
</button>
</Button>
</div>
{/if}
<TagFilter
selectedTags={selectedTags}
{selectedTags}
logic={filterLogic}
type={activeTab === "characters" ? "character" : activeTab === "lorebooks" ? "lorebook" : "scenario"}
type={activeTab === "characters"
? "character"
: activeTab === "lorebooks"
? "lorebook"
: "scenario"}
onUpdate={(tags, logic) => {
selectedTags = tags;
filterLogic = logic;
}}
/>
<button
class="flex items-center gap-1.5 rounded-lg border px-3 py-2 text-xs transition-colors shrink-0 {showFavoritesOnly
? 'border-yellow-500/50 bg-yellow-500/10 text-yellow-400'
: 'border-surface-600 bg-surface-800 text-surface-400 hover:border-surface-500'}"
<Button
variant={showFavoritesOnly ? "outline" : "outline"}
size="sm"
class={cn(
"gap-1.5 transition-all",
showFavoritesOnly &&
"border-yellow-500/50 bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20 hover:text-yellow-700",
)}
onclick={() => (showFavoritesOnly = !showFavoritesOnly)}
>
<Star class="h-3 w-3 {showFavoritesOnly ? 'fill-yellow-400' : ''}" />
<Star
class={cn(
"h-3 w-3",
showFavoritesOnly && "fill-yellow-500 text-yellow-500",
)}
/>
<span class="hidden sm:inline">Favorites</span>
</button>
</Button>
</div>
</div>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-3 sm:p-6 bg-surface-900">
{#if activeTab === "characters"}
<!-- Character Grid -->
<TabsContent
value="characters"
class="flex-1 overflow-hidden m-0 p-0 outline-none data-[state=inactive]:hidden"
>
<ScrollArea class="h-full">
<div class="px-4 py-1">
{#if !characterVault.isLoaded}
<div class="flex h-full items-center justify-center">
<Loader2 class="h-8 w-8 animate-spin text-surface-500" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each Array(6) as _}
<div class="space-y-3">
<Skeleton class="h-[200px] w-full rounded-xl" />
<div class="space-y-2">
<Skeleton class="h-4 w-[250px]" />
<Skeleton class="h-4 w-[200px]" />
</div>
</div>
{/each}
</div>
{:else if filteredCharacters.length === 0}
<div class="flex h-full items-center justify-center" in:fade>
<div class="text-center">
<Users class="mx-auto h-12 w-12 text-surface-600" />
<p class="mt-3 text-surface-400">
<div
class="flex h-[50vh] flex-col items-center justify-center text-center"
in:fade
>
<div class="rounded-full bg-muted p-4">
<Users class="h-10 w-10 text-muted-foreground" />
</div>
<p class="mt-4 text-lg font-medium text-foreground">
{#if searchQuery || showFavoritesOnly || charFilterType !== "all"}
No characters match your filters
{:else}
@ -626,25 +706,21 @@
{/if}
</p>
{#if !searchQuery && !showFavoritesOnly && charFilterType === "all"}
<div class="mt-4 flex justify-center gap-2">
<button
class="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-white hover:bg-accent-600"
onclick={() => openCreateCharForm("protagonist")}
>
<div class="mt-6 flex gap-3">
<Button onclick={() => openCreateCharForm("protagonist")}>
<User class="h-4 w-4" />
Create Protagonist
</button>
<button
class="flex items-center gap-2 rounded-lg border border-surface-600 bg-surface-700 px-4 py-2 text-sm text-surface-300 hover:border-surface-500"
</Button>
<Button
variant="outline"
onclick={() => openCreateCharForm("supporting")}
>
<Users class="h-4 w-4" />
Create Supporting
</button>
</Button>
</div>
{/if}
</div>
</div>
{:else}
<div
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
@ -660,39 +736,49 @@
{/each}
</div>
{/if}
{:else if activeTab === "lorebooks"}
<!-- Lorebook Grid -->
</div>
</ScrollArea>
</TabsContent>
<TabsContent
value="lorebooks"
class="flex-1 overflow-hidden m-0 p-0 outline-none data-[state=inactive]:hidden"
>
<ScrollArea class="h-full">
<div class="p-4 sm:p-6">
{#if !lorebookVault.isLoaded}
<div class="flex h-full items-center justify-center">
<Loader2 class="h-8 w-8 animate-spin text-surface-500" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each Array(6) as _}
<Skeleton class="h-[150px] w-full rounded-xl" />
{/each}
</div>
{:else if filteredLorebooks.length === 0}
<div class="flex h-full items-center justify-center" in:fade>
<div class="text-center">
<Book class="mx-auto h-12 w-12 text-surface-600" />
<p class="mt-3 text-surface-400">
<div
class="flex h-[50vh] flex-col items-center justify-center text-center"
in:fade
>
<div class="rounded-full bg-muted p-4">
<Book class="h-10 w-10 text-muted-foreground" />
</div>
<p class="mt-4 text-lg font-medium text-foreground">
{#if searchQuery || showFavoritesOnly}
No lorebooks match your filters
{:else}
No lorebooks in vault yet
{/if}
</p>
<p class="mt-1 text-sm text-surface-500">
<p class="mt-2 text-muted-foreground">
Create a new lorebook or import one from a file
</p>
{#if !searchQuery && !showFavoritesOnly}
<div class="mt-4">
<button
class="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm font-medium text-white hover:bg-accent-600 mx-auto"
onclick={handleCreateLorebook}
>
<div class="mt-6">
<Button onclick={handleCreateLorebook}>
<Plus class="h-4 w-4" />
Create Lorebook
</button>
</Button>
</div>
{/if}
</div>
</div>
{:else}
<div
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
@ -702,34 +788,48 @@
<VaultLorebookCard
{lorebook}
onDelete={() => handleDeleteLorebook(lorebook.id)}
onToggleFavorite={() => handleToggleFavoriteLorebook(lorebook.id)}
onToggleFavorite={() =>
handleToggleFavoriteLorebook(lorebook.id)}
onEdit={() => openEditLorebook(lorebook)}
/>
{/each}
</div>
{/if}
{:else if activeTab === "scenarios"}
<!-- Scenario Grid -->
</div>
</ScrollArea>
</TabsContent>
<TabsContent
value="scenarios"
class="flex-1 overflow-hidden m-0 p-0 outline-none data-[state=inactive]:hidden"
>
<ScrollArea class="h-full">
<div class="p-4 sm:p-6">
{#if !scenarioVault.isLoaded}
<div class="flex h-full items-center justify-center">
<Loader2 class="h-8 w-8 animate-spin text-surface-500" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each Array(6) as _}
<Skeleton class="h-[150px] w-full rounded-xl" />
{/each}
</div>
{:else if filteredScenarios.length === 0}
<div class="flex h-full items-center justify-center" in:fade>
<div class="text-center">
<MapPin class="mx-auto h-12 w-12 text-surface-600" />
<p class="mt-3 text-surface-400">
<div
class="flex h-[50vh] flex-col items-center justify-center text-center"
in:fade
>
<div class="rounded-full bg-muted p-4">
<MapPin class="h-10 w-10 text-muted-foreground" />
</div>
<p class="mt-4 text-lg font-medium text-foreground">
{#if searchQuery || showFavoritesOnly}
No scenarios match your filters
{:else}
No scenarios in vault yet
{/if}
</p>
<p class="mt-1 text-sm text-surface-500">
<p class="mt-2 text-muted-foreground">
Import character cards to extract scenario settings
</p>
</div>
</div>
{:else}
<div
class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
@ -739,15 +839,17 @@
<VaultScenarioCard
{scenario}
onDelete={() => handleDeleteScenario(scenario.id)}
onToggleFavorite={() => handleToggleFavoriteScenario(scenario.id)}
onToggleFavorite={() =>
handleToggleFavoriteScenario(scenario.id)}
onEdit={() => openEditScenario(scenario)}
/>
{/each}
</div>
{/if}
{/if}
</div>
</div>
</ScrollArea>
</TabsContent>
</Tabs>
<!-- Character Form Modal -->
{#if showCharForm}

6
src/lib/utils/cn.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View file

@ -5,7 +5,7 @@
import { grammarService } from "$lib/services/grammar";
import { updaterService } from "$lib/services/updater";
import AppShell from "$lib/components/layout/AppShell.svelte";
import ProviderSetupModal from "$lib/components/settings/ProviderSetupModal.svelte";
import WelcomeScreen from "$lib/components/intro/WelcomeScreen.svelte";
let initialized = $state(false);
let error = $state<string | null>(null);
@ -77,12 +77,6 @@
}
</script>
<!-- Provider Setup Modal (First Run) -->
<ProviderSetupModal
isOpen={showProviderSetup}
onComplete={handleProviderSetupComplete}
/>
{#if error}
<div
class="flex h-screen w-screen items-center justify-center bg-surface-900"
@ -99,14 +93,7 @@
</div>
</div>
{:else if showProviderSetup}
<!-- Show loading background while provider setup is shown -->
<div
class="flex h-screen w-screen items-center justify-center bg-surface-900"
>
<div class="flex flex-col items-center gap-4">
<p class="text-surface-400">Welcome to Aventuras</p>
</div>
</div>
<WelcomeScreen onComplete={handleProviderSetupComplete} />
{:else if !initialized}
<div
class="flex h-screen w-screen items-center justify-center bg-surface-900"

View file

@ -1,8 +1,19 @@
import { fontFamily } from "tailwindcss/defaultTheme";
import tailwindcssAnimate from "tailwindcss-animate";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'class',
safelist: ["dark"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
screens: {
'xs': '475px',
'sm': '640px',
@ -13,7 +24,50 @@ export default {
},
extend: {
colors: {
// Custom dark theme colors
border: "hsl(var(--border) / <alpha-value>)",
input: "hsl(var(--input) / <alpha-value>)",
ring: "hsl(var(--ring) / <alpha-value>)",
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
},
secondary: {
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground) / <alpha-value>)",
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
popover: {
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
foreground: "hsl(var(--popover-foreground) / <alpha-value>)",
},
card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)",
},
surface: {
50: '#f8fafc',
100: '#f1f5f9',
@ -28,26 +82,24 @@ export default {
900: '#0f172a',
950: '#020617',
},
accent: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554',
},
borderColor: {
DEFAULT: "hsl(var(--border) / <alpha-value>)",
},
ringColor: {
DEFAULT: "hsl(var(--ring) / <alpha-value>)",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
sans: ['Inter', 'system-ui', 'sans-serif', ...fontFamily.sans],
mono: ['JetBrains Mono', 'Fira Code', 'monospace', ...fontFamily.mono],
story: ['Georgia', 'Cambria', 'serif'],
},
},
},
plugins: [],
plugins: [tailwindcssAnimate],
};