mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-04-26 10:51:24 +00:00
init commit
This commit is contained in:
parent
bbdc6901c6
commit
2948b9c061
68 changed files with 3859 additions and 1659 deletions
31
AGENTS.md
31
AGENTS.md
|
|
@ -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
16
components.json
Normal 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
217
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
1220
src/app.css
1220
src/app.css
File diff suppressed because it is too large
Load diff
376
src/lib/components/intro/WelcomeScreen.svelte
Normal file
376
src/lib/components/intro/WelcomeScreen.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
16
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
16
src/lib/components/ui/avatar/avatar-fallback.svelte
Normal 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}
|
||||
/>
|
||||
12
src/lib/components/ui/avatar/avatar-image.svelte
Normal file
12
src/lib/components/ui/avatar/avatar-image.svelte
Normal 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} />
|
||||
16
src/lib/components/ui/avatar/avatar.svelte
Normal file
16
src/lib/components/ui/avatar/avatar.svelte
Normal 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}
|
||||
/>
|
||||
13
src/lib/components/ui/avatar/index.ts
Normal file
13
src/lib/components/ui/avatar/index.ts
Normal 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,
|
||||
};
|
||||
50
src/lib/components/ui/badge/badge.svelte
Normal file
50
src/lib/components/ui/badge/badge.svelte
Normal 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>
|
||||
2
src/lib/components/ui/badge/index.ts
Normal file
2
src/lib/components/ui/badge/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
74
src/lib/components/ui/button/button.svelte
Normal file
74
src/lib/components/ui/button/button.svelte
Normal 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}
|
||||
17
src/lib/components/ui/button/index.ts
Normal file
17
src/lib/components/ui/button/index.ts
Normal 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,
|
||||
};
|
||||
16
src/lib/components/ui/card/card-content.svelte
Normal file
16
src/lib/components/ui/card/card-content.svelte
Normal 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>
|
||||
16
src/lib/components/ui/card/card-description.svelte
Normal file
16
src/lib/components/ui/card/card-description.svelte
Normal 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>
|
||||
16
src/lib/components/ui/card/card-footer.svelte
Normal file
16
src/lib/components/ui/card/card-footer.svelte
Normal 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>
|
||||
16
src/lib/components/ui/card/card-header.svelte
Normal file
16
src/lib/components/ui/card/card-header.svelte
Normal 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>
|
||||
25
src/lib/components/ui/card/card-title.svelte
Normal file
25
src/lib/components/ui/card/card-title.svelte
Normal 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>
|
||||
23
src/lib/components/ui/card/card.svelte
Normal file
23
src/lib/components/ui/card/card.svelte
Normal 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>
|
||||
22
src/lib/components/ui/card/index.ts
Normal file
22
src/lib/components/ui/card/index.ts
Normal 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,
|
||||
};
|
||||
38
src/lib/components/ui/dialog/dialog-content.svelte
Normal file
38
src/lib/components/ui/dialog/dialog-content.svelte
Normal 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>
|
||||
16
src/lib/components/ui/dialog/dialog-description.svelte
Normal file
16
src/lib/components/ui/dialog/dialog-description.svelte
Normal 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}
|
||||
/>
|
||||
20
src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
20
src/lib/components/ui/dialog/dialog-footer.svelte
Normal 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>
|
||||
20
src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
src/lib/components/ui/dialog/dialog-header.svelte
Normal 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>
|
||||
19
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
19
src/lib/components/ui/dialog/dialog-overlay.svelte
Normal 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}
|
||||
/>
|
||||
16
src/lib/components/ui/dialog/dialog-title.svelte
Normal file
16
src/lib/components/ui/dialog/dialog-title.svelte
Normal 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}
|
||||
/>
|
||||
37
src/lib/components/ui/dialog/index.ts
Normal file
37
src/lib/components/ui/dialog/index.ts
Normal 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,
|
||||
};
|
||||
7
src/lib/components/ui/input/index.ts
Normal file
7
src/lib/components/ui/input/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
46
src/lib/components/ui/input/input.svelte
Normal file
46
src/lib/components/ui/input/input.svelte
Normal 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}
|
||||
7
src/lib/components/ui/label/index.ts
Normal file
7
src/lib/components/ui/label/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
19
src/lib/components/ui/label/label.svelte
Normal file
19
src/lib/components/ui/label/label.svelte
Normal 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}
|
||||
/>
|
||||
10
src/lib/components/ui/scroll-area/index.ts
Normal file
10
src/lib/components/ui/scroll-area/index.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
32
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal file
32
src/lib/components/ui/scroll-area/scroll-area.svelte
Normal 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>
|
||||
34
src/lib/components/ui/select/index.ts
Normal file
34
src/lib/components/ui/select/index.ts
Normal 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,
|
||||
};
|
||||
39
src/lib/components/ui/select/select-content.svelte
Normal file
39
src/lib/components/ui/select/select-content.svelte
Normal 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>
|
||||
16
src/lib/components/ui/select/select-group-heading.svelte
Normal file
16
src/lib/components/ui/select/select-group-heading.svelte
Normal 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}
|
||||
/>
|
||||
37
src/lib/components/ui/select/select-item.svelte
Normal file
37
src/lib/components/ui/select/select-item.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
19
src/lib/components/ui/select/select-scroll-up-button.svelte
Normal file
19
src/lib/components/ui/select/select-scroll-up-button.svelte
Normal 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>
|
||||
13
src/lib/components/ui/select/select-separator.svelte
Normal file
13
src/lib/components/ui/select/select-separator.svelte
Normal 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} />
|
||||
24
src/lib/components/ui/select/select-trigger.svelte
Normal file
24
src/lib/components/ui/select/select-trigger.svelte
Normal 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>
|
||||
7
src/lib/components/ui/separator/index.ts
Normal file
7
src/lib/components/ui/separator/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
22
src/lib/components/ui/separator/separator.svelte
Normal file
22
src/lib/components/ui/separator/separator.svelte
Normal 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}
|
||||
/>
|
||||
7
src/lib/components/ui/skeleton/index.ts
Normal file
7
src/lib/components/ui/skeleton/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./skeleton.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Skeleton,
|
||||
};
|
||||
17
src/lib/components/ui/skeleton/skeleton.svelte
Normal file
17
src/lib/components/ui/skeleton/skeleton.svelte
Normal 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>
|
||||
7
src/lib/components/ui/switch/index.ts
Normal file
7
src/lib/components/ui/switch/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./switch.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Switch,
|
||||
};
|
||||
27
src/lib/components/ui/switch/switch.svelte
Normal file
27
src/lib/components/ui/switch/switch.svelte
Normal 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>
|
||||
18
src/lib/components/ui/tabs/index.ts
Normal file
18
src/lib/components/ui/tabs/index.ts
Normal 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,
|
||||
};
|
||||
19
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
19
src/lib/components/ui/tabs/tabs-content.svelte
Normal 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}
|
||||
/>
|
||||
19
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
19
src/lib/components/ui/tabs/tabs-list.svelte
Normal 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}
|
||||
/>
|
||||
19
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
19
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal 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}
|
||||
/>
|
||||
7
src/lib/components/ui/textarea/index.ts
Normal file
7
src/lib/components/ui/textarea/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./textarea.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Textarea,
|
||||
};
|
||||
22
src/lib/components/ui/textarea/textarea.svelte
Normal file
22
src/lib/components/ui/textarea/textarea.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
×
|
||||
</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>
|
||||
×
|
||||
</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>
|
||||
×
|
||||
</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
6
src/lib/utils/cn.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue