Initial commit

This commit is contained in:
Kurvaz 2025-12-30 16:08:22 -07:00
commit 48ff42a845
67 changed files with 13289 additions and 0 deletions

34
.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# Environment
.env
.env.local
.env.*.local
# Dependencies
node_modules/
# Build outputs
dist/
build/
.svelte-kit/
# Rust/Tauri
src-tauri/target/
*.db
*.db-journal
# IDE
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Local development
.vscode/settings.json

7
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,7 @@
{
"recommendations": [
"svelte.svelte-vscode",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}

7
README.md Normal file
View file

@ -0,0 +1,7 @@
# Tauri + SvelteKit + TypeScript
This template should help get you started developing with Tauri, SvelteKit and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer).

2856
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

36
package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "aventura",
"version": "0.1.0",
"description": "AI-powered adventure and creative writing frontend",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri"
},
"license": "MIT",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-sql": "^2",
"@tauri-apps/plugin-fs": "^2",
"@tauri-apps/plugin-dialog": "^2",
"lucide-svelte": "^0.468.0"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"@tauri-apps/cli": "^2",
"tailwindcss": "^3.4.17",
"postcss": "^8.4.49",
"autoprefixer": "^10.4.20"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

7
src-tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

6099
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

23
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,23 @@
[package]
name = "aventura"
version = "0.1.0"
description = "AI-powered adventure and creative writing frontend"
authors = ["you"]
edition = "2021"
[lib]
name = "aventura_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,25 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"sql:default",
"sql:allow-load",
"sql:allow-execute",
"sql:allow-select",
"sql:allow-close",
"fs:default",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-exists",
"fs:allow-mkdir",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"dialog:allow-message",
"dialog:allow-ask"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,104 @@
-- Aventura Initial Schema
-- Stories table
CREATE TABLE IF NOT EXISTS stories (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
genre TEXT,
template_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
settings TEXT
);
-- Story entries (the actual narrative content)
CREATE TABLE IF NOT EXISTS story_entries (
id TEXT PRIMARY KEY,
story_id TEXT NOT NULL,
type TEXT NOT NULL,
content TEXT NOT NULL,
parent_id TEXT,
position INTEGER NOT NULL,
created_at INTEGER NOT NULL,
metadata TEXT,
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
);
-- Characters
CREATE TABLE IF NOT EXISTS characters (
id TEXT PRIMARY KEY,
story_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
relationship TEXT,
traits TEXT,
status TEXT DEFAULT 'active',
metadata TEXT,
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
);
-- Locations
CREATE TABLE IF NOT EXISTS locations (
id TEXT PRIMARY KEY,
story_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
visited INTEGER DEFAULT 0,
current INTEGER DEFAULT 0,
connections TEXT,
metadata TEXT,
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
);
-- Inventory items
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
story_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
quantity INTEGER DEFAULT 1,
equipped INTEGER DEFAULT 0,
location TEXT,
metadata TEXT,
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
);
-- Story beats / plot points
CREATE TABLE IF NOT EXISTS story_beats (
id TEXT PRIMARY KEY,
story_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
type TEXT,
status TEXT DEFAULT 'pending',
triggered_at INTEGER,
metadata TEXT,
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
);
-- Templates
CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
genre TEXT,
system_prompt TEXT NOT NULL,
initial_state TEXT,
is_builtin INTEGER DEFAULT 0,
created_at INTEGER NOT NULL
);
-- Settings
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_story_entries_story ON story_entries(story_id);
CREATE INDEX IF NOT EXISTS idx_story_entries_position ON story_entries(story_id, position);
CREATE INDEX IF NOT EXISTS idx_characters_story ON characters(story_id);
CREATE INDEX IF NOT EXISTS idx_locations_story ON locations(story_id);
CREATE INDEX IF NOT EXISTS idx_items_story ON items(story_id);
CREATE INDEX IF NOT EXISTS idx_story_beats_story ON story_beats(story_id);

25
src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,25 @@
use tauri_plugin_sql::{Migration, MigrationKind};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let migrations = vec![
Migration {
version: 1,
description: "create_initial_tables",
sql: include_str!("../migrations/001_initial.sql"),
kind: MigrationKind::Up,
},
];
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(
tauri_plugin_sql::Builder::default()
.add_migrations("sqlite:aventura.db", migrations)
.build(),
)
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
aventura_lib::run()
}

37
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,37 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Aventura",
"version": "0.1.0",
"identifier": "com.aureal.aventura",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../build"
},
"app": {
"windows": [
{
"title": "Aventura",
"width": 1280,
"height": 800,
"minWidth": 800,
"minHeight": 600
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

84
src/app.css Normal file
View file

@ -0,0 +1,84 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom base styles */
@layer base {
html {
@apply antialiased;
}
body {
@apply bg-surface-900 text-surface-100;
}
/* Scrollbar styling for dark mode */
::-webkit-scrollbar {
@apply w-2;
}
::-webkit-scrollbar-track {
@apply bg-surface-800;
}
::-webkit-scrollbar-thumb {
@apply bg-surface-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-surface-500;
}
}
/* Custom component styles */
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
}
.btn-primary {
@apply bg-accent-600 text-white hover:bg-accent-700 active:bg-accent-800;
}
.btn-secondary {
@apply bg-surface-700 text-surface-100 hover:bg-surface-600 active:bg-surface-500;
}
.btn-ghost {
@apply bg-transparent text-surface-300 hover:bg-surface-800 hover:text-surface-100;
}
.input {
@apply w-full px-3 py-2 bg-surface-800 border border-surface-700 rounded-lg
text-surface-100 placeholder-surface-500
focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-transparent
transition-colors duration-200;
}
.card {
@apply bg-surface-800 border border-surface-700 rounded-xl p-4;
}
.panel {
@apply bg-surface-800 border-r border-surface-700;
}
}
/* Story text styling */
@layer utilities {
.story-text {
@apply font-story text-lg leading-relaxed text-surface-200;
}
.user-action {
@apply text-accent-400 font-medium;
}
.narration {
@apply text-surface-200;
}
.system-text {
@apply text-surface-400 italic text-sm;
}
}

16
src/app.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Aventura</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="overflow-hidden">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte';
import { story } from '$lib/stores/story.svelte';
import Sidebar from './Sidebar.svelte';
import Header from './Header.svelte';
import StoryView from '$lib/components/story/StoryView.svelte';
import LibraryView from '$lib/components/story/LibraryView.svelte';
import SettingsModal from '$lib/components/settings/SettingsModal.svelte';
import type { Snippet } from 'svelte';
let { children }: { children?: Snippet } = $props();
</script>
<div class="flex h-screen w-screen bg-surface-900">
<!-- Sidebar -->
{#if ui.sidebarOpen && story.currentStory}
<Sidebar />
{/if}
<!-- Main content area -->
<div class="flex flex-1 flex-col overflow-hidden">
<Header />
<main class="flex-1 overflow-hidden">
{#if ui.activePanel === 'story' && story.currentStory}
<StoryView />
{:else if ui.activePanel === 'library' || !story.currentStory}
<LibraryView />
{:else if children}
{@render children()}
{/if}
</main>
</div>
<!-- Settings Modal -->
{#if ui.settingsModalOpen}
<SettingsModal />
{/if}
</div>

View file

@ -0,0 +1,163 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte';
import { story } from '$lib/stores/story.svelte';
import { settings } from '$lib/stores/settings.svelte';
import { exportService } from '$lib/services/export';
import { PanelLeft, Settings, BookOpen, Library, Feather, Download, FileJson, FileText, ChevronDown } from 'lucide-svelte';
let showExportMenu = $state(false);
async function exportAventura() {
if (!story.currentStory) return;
showExportMenu = false;
await exportService.exportToAventura(
story.currentStory,
story.entries,
story.characters,
story.locations,
story.items,
story.storyBeats
);
}
async function exportMarkdown() {
if (!story.currentStory) return;
showExportMenu = false;
await exportService.exportToMarkdown(
story.currentStory,
story.entries,
story.characters,
story.locations,
true
);
}
async function exportText() {
if (!story.currentStory) return;
showExportMenu = false;
await exportService.exportToText(story.currentStory, story.entries);
}
</script>
<header class="flex h-14 items-center justify-between border-b border-surface-700 bg-surface-800 px-4">
<!-- Left side: Menu toggle and story title -->
<div class="flex items-center gap-3">
{#if story.currentStory}
<button
class="btn-ghost rounded-lg p-2"
onclick={() => ui.toggleSidebar()}
title={ui.sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
>
<PanelLeft class="h-5 w-5" />
</button>
{/if}
<div class="flex items-center gap-2">
<Feather class="h-5 w-5 text-accent-500" />
<span class="font-semibold text-surface-100">Aventura</span>
</div>
{#if story.currentStory}
<span class="text-surface-500">|</span>
<span class="text-surface-300">{story.currentStory.title}</span>
{#if settings.uiSettings.showWordCount}
<span class="text-sm text-surface-500">({story.wordCount} words)</span>
{/if}
{/if}
</div>
<!-- Center: Navigation tabs -->
<div class="flex items-center gap-1">
<button
class="btn-ghost flex items-center gap-2 rounded-lg px-3 py-1.5"
class:bg-surface-700={ui.activePanel === 'library' || !story.currentStory}
onclick={() => ui.setActivePanel('library')}
>
<Library class="h-4 w-4" />
<span class="text-sm">Library</span>
</button>
{#if story.currentStory}
<button
class="btn-ghost flex items-center gap-2 rounded-lg px-3 py-1.5"
class:bg-surface-700={ui.activePanel === 'story'}
onclick={() => ui.setActivePanel('story')}
>
<BookOpen class="h-4 w-4" />
<span class="text-sm">Story</span>
</button>
{/if}
</div>
<!-- Right side: Export and Settings -->
<div class="flex items-center gap-2">
{#if ui.isGenerating}
<div class="flex items-center gap-2 text-sm text-accent-400">
<div class="h-2 w-2 animate-pulse rounded-full bg-accent-500"></div>
<span>Generating...</span>
</div>
{/if}
{#if story.currentStory}
<div class="relative">
<button
class="btn-ghost flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm"
onclick={() => showExportMenu = !showExportMenu}
title="Export story"
>
<Download class="h-4 w-4" />
<span>Export</span>
<ChevronDown class="h-3 w-3" />
</button>
{#if showExportMenu}
<div
class="absolute right-0 top-full z-50 mt-1 w-48 rounded-lg border border-surface-600 bg-surface-800 py-1 shadow-lg"
>
<button
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-surface-300 hover:bg-surface-700"
onclick={exportAventura}
>
<FileJson class="h-4 w-4 text-accent-400" />
Aventura (.avt)
</button>
<button
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-surface-300 hover:bg-surface-700"
onclick={exportMarkdown}
>
<FileText class="h-4 w-4 text-blue-400" />
Markdown (.md)
</button>
<button
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-surface-300 hover:bg-surface-700"
onclick={exportText}
>
<FileText class="h-4 w-4 text-surface-400" />
Plain Text (.txt)
</button>
</div>
{/if}
</div>
{/if}
<button
class="btn-ghost rounded-lg p-2"
onclick={() => ui.openSettings()}
title="Settings"
>
<Settings class="h-5 w-5" />
</button>
</div>
</header>
<!-- Click outside to close export menu -->
{#if showExportMenu}
<div
class="fixed inset-0 z-40"
onclick={() => showExportMenu = false}
onkeydown={(e) => e.key === 'Escape' && (showExportMenu = false)}
role="button"
tabindex="-1"
aria-label="Close export menu"
></div>
{/if}

View file

@ -0,0 +1,55 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte';
import { story } from '$lib/stores/story.svelte';
import { Users, MapPin, Backpack, Scroll } from 'lucide-svelte';
import CharacterPanel from '$lib/components/world/CharacterPanel.svelte';
import LocationPanel from '$lib/components/world/LocationPanel.svelte';
import InventoryPanel from '$lib/components/world/InventoryPanel.svelte';
import QuestPanel from '$lib/components/world/QuestPanel.svelte';
const tabs = [
{ id: 'characters' as const, icon: Users, label: 'Characters' },
{ id: 'locations' as const, icon: MapPin, label: 'Locations' },
{ id: 'inventory' as const, icon: Backpack, label: 'Inventory' },
{ id: 'quests' as const, icon: Scroll, label: 'Quests' },
];
</script>
<aside class="sidebar flex h-full w-72 flex-col border-r border-surface-700">
<!-- Tab navigation -->
<div class="flex border-b border-surface-700">
{#each tabs as tab}
<button
class="flex flex-1 items-center justify-center gap-1.5 py-3 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)}
title={tab.label}
>
<svelte:component this={tab.icon} class="h-4 w-4" />
</button>
{/each}
</div>
<!-- Panel content -->
<div class="flex-1 overflow-y-auto p-3">
{#if ui.sidebarTab === 'characters'}
<CharacterPanel />
{:else if ui.sidebarTab === 'locations'}
<LocationPanel />
{:else if ui.sidebarTab === 'inventory'}
<InventoryPanel />
{:else if ui.sidebarTab === 'quests'}
<QuestPanel />
{/if}
</div>
</aside>
<style>
.sidebar {
background-color: rgb(20 27 37);
}
</style>

View file

@ -0,0 +1,227 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte';
import { settings } from '$lib/stores/settings.svelte';
import { X, Key, Cpu, Palette } from 'lucide-svelte';
let activeTab = $state<'api' | 'generation' | 'ui'>('api');
// Local state for API key (to avoid showing actual key)
let apiKeyInput = $state('');
let apiKeySet = $state(false);
$effect(() => {
apiKeySet = !!settings.apiSettings.openrouterApiKey;
});
async function saveApiKey() {
if (apiKeyInput.trim()) {
await settings.setApiKey(apiKeyInput.trim());
apiKeyInput = '';
}
}
async function clearApiKey() {
await settings.setApiKey('');
apiKeySet = false;
}
const popularModels = [
{ id: 'anthropic/claude-3.5-sonnet', name: 'Claude 3.5 Sonnet' },
{ id: 'anthropic/claude-3-opus', name: 'Claude 3 Opus' },
{ id: 'openai/gpt-4o', name: 'GPT-4o' },
{ id: 'openai/gpt-4-turbo', name: 'GPT-4 Turbo' },
{ id: 'google/gemini-pro-1.5', name: 'Gemini Pro 1.5' },
{ id: 'meta-llama/llama-3.1-405b-instruct', name: 'Llama 3.1 405B' },
{ id: 'mistralai/mistral-large', name: 'Mistral Large' },
];
</script>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick={() => ui.closeSettings()}>
<div
class="card w-full max-w-2xl max-h-[80vh] overflow-hidden"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-surface-700 pb-4">
<h2 class="text-xl font-semibold text-surface-100">Settings</h2>
<button class="btn-ghost rounded-lg p-2" onclick={() => ui.closeSettings()}>
<X class="h-5 w-5" />
</button>
</div>
<!-- Tabs -->
<div class="flex gap-1 border-b border-surface-700 py-2">
<button
class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm"
class:bg-surface-700={activeTab === 'api'}
class:text-surface-100={activeTab === 'api'}
class:text-surface-400={activeTab !== 'api'}
onclick={() => activeTab = 'api'}
>
<Key class="h-4 w-4" />
API
</button>
<button
class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm"
class:bg-surface-700={activeTab === 'generation'}
class:text-surface-100={activeTab === 'generation'}
class:text-surface-400={activeTab !== 'generation'}
onclick={() => activeTab = 'generation'}
>
<Cpu class="h-4 w-4" />
Generation
</button>
<button
class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm"
class:bg-surface-700={activeTab === 'ui'}
class:text-surface-100={activeTab === 'ui'}
class:text-surface-400={activeTab !== 'ui'}
onclick={() => activeTab = 'ui'}
>
<Palette class="h-4 w-4" />
Interface
</button>
</div>
<!-- Content -->
<div class="max-h-96 overflow-y-auto py-4">
{#if activeTab === 'api'}
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-surface-300">
OpenRouter API Key
</label>
{#if apiKeySet}
<div class="flex items-center gap-2">
<div class="input flex-1 bg-surface-700 text-surface-400">
••••••••••••••••••••
</div>
<button class="btn btn-secondary" onclick={clearApiKey}>
Clear
</button>
</div>
<p class="mt-1 text-sm text-green-400">API key configured</p>
{:else}
<div class="flex gap-2">
<input
type="password"
bind:value={apiKeyInput}
placeholder="sk-or-..."
class="input flex-1"
/>
<button
class="btn btn-primary"
onclick={saveApiKey}
disabled={!apiKeyInput.trim()}
>
Save
</button>
</div>
{/if}
<p class="mt-2 text-sm text-surface-500">
Get your API key from <a href="https://openrouter.ai/keys" target="_blank" class="text-accent-400 hover:underline">openrouter.ai</a>
</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-surface-300">
Default Model
</label>
<select
class="input"
value={settings.apiSettings.defaultModel}
onchange={(e) => settings.setDefaultModel(e.currentTarget.value)}
>
{#each popularModels as model}
<option value={model.id}>{model.name}</option>
{/each}
</select>
</div>
</div>
{:else if activeTab === 'generation'}
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-surface-300">
Temperature: {settings.apiSettings.temperature.toFixed(1)}
</label>
<input
type="range"
min="0"
max="2"
step="0.1"
value={settings.apiSettings.temperature}
oninput={(e) => settings.setTemperature(parseFloat(e.currentTarget.value))}
class="w-full"
/>
<div class="flex justify-between text-xs text-surface-500">
<span>Focused</span>
<span>Creative</span>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-surface-300">
Max Tokens: {settings.apiSettings.maxTokens}
</label>
<input
type="range"
min="256"
max="4096"
step="128"
value={settings.apiSettings.maxTokens}
oninput={(e) => settings.setMaxTokens(parseInt(e.currentTarget.value))}
class="w-full"
/>
<div class="flex justify-between text-xs text-surface-500">
<span>Shorter</span>
<span>Longer</span>
</div>
</div>
</div>
{:else if activeTab === 'ui'}
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-surface-300">
Theme
</label>
<select
class="input"
value={settings.uiSettings.theme}
onchange={(e) => settings.setTheme(e.currentTarget.value as 'dark' | 'light')}
>
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-surface-300">
Font Size
</label>
<select
class="input"
value={settings.uiSettings.fontSize}
onchange={(e) => settings.setFontSize(e.currentTarget.value as 'small' | 'medium' | 'large')}
>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</select>
</div>
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-surface-300">Show Word Count</label>
<input
type="checkbox"
checked={settings.uiSettings.showWordCount}
onchange={() => {
settings.uiSettings.showWordCount = !settings.uiSettings.showWordCount;
}}
class="h-5 w-5 rounded border-surface-600 bg-surface-700"
/>
</div>
</div>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,150 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte';
import { story } from '$lib/stores/story.svelte';
import { settings } from '$lib/stores/settings.svelte';
import { aiService } from '$lib/services/ai';
import { Send, Wand2, MessageSquare, Brain, Sparkles } from 'lucide-svelte';
let inputValue = $state('');
let actionType = $state<'do' | 'say' | 'think' | 'story'>('do');
const actionPrefixes = {
do: 'You ',
say: 'You say "',
think: 'You think to yourself, "',
story: '',
};
const actionSuffixes = {
do: '',
say: '"',
think: '"',
story: '',
};
async function handleSubmit() {
if (!inputValue.trim() || ui.isGenerating) return;
const content = actionPrefixes[actionType] + inputValue.trim() + actionSuffixes[actionType];
// Add user action to story
await story.addEntry('user_action', content);
// Clear input
inputValue = '';
// Generate AI response with streaming
if (settings.hasApiKey) {
ui.setGenerating(true);
ui.startStreaming();
try {
// Build world state for AI context
const worldState = {
characters: story.characters,
locations: story.locations,
items: story.items,
storyBeats: story.storyBeats,
currentLocation: story.currentLocation,
};
let fullResponse = '';
// Use streaming response
for await (const chunk of aiService.streamResponse(story.entries, worldState, story.currentStory)) {
if (chunk.content) {
fullResponse += chunk.content;
ui.appendStreamContent(chunk.content);
}
if (chunk.done) {
break;
}
}
// Save the complete response as a story entry
if (fullResponse.trim()) {
await story.addEntry('narration', fullResponse);
}
} catch (error) {
console.error('Generation failed:', error);
await story.addEntry('system', 'Failed to generate response. Please check your API settings.');
} finally {
ui.endStreaming();
ui.setGenerating(false);
}
} else {
await story.addEntry('system', 'Please configure your OpenRouter API key in settings to enable AI generation.');
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
</script>
<div class="space-y-3">
<!-- Action type buttons -->
<div class="flex gap-2">
<button
class="btn flex items-center gap-1.5 text-sm"
class:btn-primary={actionType === 'do'}
class:btn-secondary={actionType !== 'do'}
onclick={() => actionType = 'do'}
>
<Wand2 class="h-4 w-4" />
Do
</button>
<button
class="btn flex items-center gap-1.5 text-sm"
class:btn-primary={actionType === 'say'}
class:btn-secondary={actionType !== 'say'}
onclick={() => actionType = 'say'}
>
<MessageSquare class="h-4 w-4" />
Say
</button>
<button
class="btn flex items-center gap-1.5 text-sm"
class:btn-primary={actionType === 'think'}
class:btn-secondary={actionType !== 'think'}
onclick={() => actionType = 'think'}
>
<Brain class="h-4 w-4" />
Think
</button>
<button
class="btn flex items-center gap-1.5 text-sm"
class:btn-primary={actionType === 'story'}
class:btn-secondary={actionType !== 'story'}
onclick={() => actionType = 'story'}
>
<Sparkles class="h-4 w-4" />
Story
</button>
</div>
<!-- Input area -->
<div class="flex gap-2">
<div class="relative flex-1">
<textarea
bind:value={inputValue}
onkeydown={handleKeydown}
placeholder={actionType === 'story' ? 'Describe what happens...' : 'What do you do?'}
class="input min-h-[60px] resize-none pr-12"
rows="2"
disabled={ui.isGenerating}
></textarea>
</div>
<button
onclick={handleSubmit}
disabled={!inputValue.trim() || ui.isGenerating}
class="btn btn-primary self-end px-4 py-3"
>
<Send class="h-5 w-5" />
</button>
</div>
</div>

View file

@ -0,0 +1,317 @@
<script lang="ts">
import { story } from '$lib/stores/story.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { templateService, BUILTIN_TEMPLATES } from '$lib/services/templates';
import { exportService } from '$lib/services/export';
import { Plus, BookOpen, Trash2, Clock, Sparkles, Wand2, Rocket, Search, Skull, Heart, FileText, Upload } from 'lucide-svelte';
import type { Template } from '$lib/types';
let showNewStoryModal = $state(false);
let newStoryTitle = $state('');
let selectedTemplateId = $state<string | null>(null);
let step = $state<'template' | 'details'>('template');
// Derived template based on selection
let selectedTemplate = $derived(
selectedTemplateId ? BUILTIN_TEMPLATES.find(t => t.id === selectedTemplateId) : null
);
const templateIcons: Record<string, typeof Wand2> = {
'fantasy-adventure': Wand2,
'scifi-exploration': Rocket,
'mystery-investigation': Search,
'horror-survival': Skull,
'slice-of-life': Heart,
'custom': FileText,
};
// Load stories on mount
$effect(() => {
story.loadAllStories();
});
function selectTemplate(templateId: string) {
selectedTemplateId = templateId;
step = 'details';
// Set default title based on template
const template = BUILTIN_TEMPLATES.find(t => t.id === templateId);
if (template && template.id !== 'custom') {
newStoryTitle = `My ${template.genre} Adventure`;
}
}
async function createNewStory() {
if (!newStoryTitle.trim() || !selectedTemplateId) return;
const template = BUILTIN_TEMPLATES.find(t => t.id === selectedTemplateId);
const newStoryData = await story.createStoryFromTemplate(
newStoryTitle.trim(),
selectedTemplateId,
template?.genre ?? undefined
);
await story.loadStory(newStoryData.id);
ui.setActivePanel('story');
closeModal();
}
function closeModal() {
showNewStoryModal = false;
newStoryTitle = '';
selectedTemplateId = null;
step = 'template';
}
function goBackToTemplates() {
step = 'template';
newStoryTitle = '';
}
async function openStory(storyId: string) {
await story.loadStory(storyId);
ui.setActivePanel('story');
}
async function deleteStory(storyId: string, event: MouseEvent) {
event.stopPropagation();
if (confirm('Are you sure you want to delete this story?')) {
await story.deleteStory(storyId);
}
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
function getGenreColor(genre: string | null): string {
switch (genre) {
case 'Fantasy': return 'bg-purple-500/20 text-purple-400';
case 'Sci-Fi': return 'bg-cyan-500/20 text-cyan-400';
case 'Mystery': return 'bg-amber-500/20 text-amber-400';
case 'Horror': return 'bg-red-500/20 text-red-400';
case 'Slice of Life': return 'bg-green-500/20 text-green-400';
default: return 'bg-surface-700 text-surface-400';
}
}
let importError = $state<string | null>(null);
async function importStory() {
importError = null;
const result = await exportService.importFromAventura();
if (result.success && result.storyId) {
await story.loadAllStories();
await story.loadStory(result.storyId);
ui.setActivePanel('story');
} else if (result.error) {
importError = result.error;
setTimeout(() => importError = null, 5000);
}
}
</script>
<div class="h-full overflow-y-auto p-6">
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-surface-100">Story Library</h1>
<p class="text-surface-400">Your adventures await</p>
</div>
<div class="flex items-center gap-2">
<button
class="btn btn-secondary flex items-center gap-2"
onclick={importStory}
>
<Upload class="h-5 w-5" />
Import
</button>
<button
class="btn btn-primary flex items-center gap-2"
onclick={() => showNewStoryModal = true}
>
<Plus class="h-5 w-5" />
New Story
</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">
{importError}
</div>
{/if}
<!-- Stories grid -->
{#if story.allStories.length === 0}
<div class="flex flex-col items-center justify-center py-20 text-center">
<BookOpen class="mb-4 h-16 w-16 text-surface-600" />
<h2 class="text-xl font-semibold text-surface-300">No stories yet</h2>
<p class="mt-2 text-surface-500">Create your first adventure to get started</p>
<button
class="btn btn-primary mt-6 flex items-center gap-2"
onclick={() => showNewStoryModal = true}
>
<Plus class="h-5 w-5" />
Create Story
</button>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each story.allStories as s (s.id)}
<div
role="button"
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"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="font-semibold text-surface-100 group-hover:text-accent-400">
{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
onclick={(e) => deleteStory(s.id, e)}
class="rounded p-1 text-surface-500 opacity-0 transition-opacity hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
title="Delete story"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
{#if s.description}
<p class="mt-2 line-clamp-2 text-sm text-surface-400">
{s.description}
</p>
{/if}
<div class="mt-3 flex items-center gap-1 text-xs text-surface-500">
<Clock class="h-3 w-3" />
<span>Updated {formatDate(s.updatedAt)}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- New Story Modal -->
{#if showNewStoryModal}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
role="dialog"
aria-modal="true"
>
<div class="card w-full max-w-2xl max-h-[80vh] overflow-hidden">
{#if step === 'template'}
<!-- Template Selection Step -->
<div class="flex items-center justify-between border-b border-surface-700 pb-4">
<div>
<h2 class="text-xl font-semibold text-surface-100">Choose Your Adventure</h2>
<p class="text-sm text-surface-400">Select a genre template to get started</p>
</div>
<button
class="btn-ghost rounded-lg p-2 text-surface-400 hover:text-surface-100"
onclick={closeModal}
>
</button>
</div>
<div class="grid gap-3 py-4 sm:grid-cols-2 max-h-96 overflow-y-auto">
{#each BUILTIN_TEMPLATES as template}
{@const Icon = templateIcons[template.id] ?? Sparkles}
<button
onclick={() => selectTemplate(template.id)}
class="card flex items-start gap-3 p-4 text-left transition-all hover:border-accent-500/50 hover:bg-surface-700/50"
class:border-accent-500={selectedTemplateId === template.id}
>
<div class="rounded-lg bg-surface-700 p-2">
<Icon class="h-5 w-5 text-accent-400" />
</div>
<div class="flex-1">
<h3 class="font-medium text-surface-100">{template.name}</h3>
<p class="mt-1 text-sm text-surface-400 line-clamp-2">{template.description}</p>
</div>
</button>
{/each}
</div>
{:else}
<!-- Story Details Step -->
<div class="flex items-center justify-between border-b border-surface-700 pb-4">
<div class="flex items-center gap-3">
<button
class="btn-ghost rounded-lg p-2 text-surface-400 hover:text-surface-100"
onclick={goBackToTemplates}
>
</button>
<div>
<h2 class="text-xl font-semibold text-surface-100">Name Your Story</h2>
{#if selectedTemplate}
<p class="text-sm text-surface-400">Template: {selectedTemplate.name}</p>
{/if}
</div>
</div>
<button
class="btn-ghost rounded-lg p-2 text-surface-400 hover:text-surface-100"
onclick={closeModal}
>
</button>
</div>
<div class="py-4">
<label class="mb-2 block text-sm font-medium text-surface-300">
Story Title
</label>
<input
type="text"
bind:value={newStoryTitle}
placeholder="Enter a title for your adventure..."
class="input"
onkeydown={(e) => e.key === 'Enter' && newStoryTitle.trim() && createNewStory()}
/>
{#if selectedTemplate?.initialState?.openingScene}
<div class="mt-4">
<label class="mb-2 block text-sm font-medium text-surface-300">
Opening Scene Preview
</label>
<div class="rounded-lg bg-surface-900 p-4 text-sm text-surface-400 max-h-32 overflow-y-auto">
{selectedTemplate.initialState.openingScene}
</div>
</div>
{/if}
</div>
<div class="flex justify-end gap-2 border-t border-surface-700 pt-4">
<button class="btn btn-secondary" onclick={goBackToTemplates}>
Back
</button>
<button
class="btn btn-primary flex items-center gap-2"
onclick={createNewStory}
disabled={!newStoryTitle.trim()}
>
<Sparkles class="h-4 w-4" />
Begin Adventure
</button>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,147 @@
<script lang="ts">
import type { StoryEntry } from '$lib/types';
import { story } from '$lib/stores/story.svelte';
import { User, BookOpen, Info, Pencil, Trash2, Check, X } from 'lucide-svelte';
let { entry }: { entry: StoryEntry } = $props();
let isEditing = $state(false);
let editContent = $state('');
let isDeleting = $state(false);
const icons = {
user_action: User,
narration: BookOpen,
system: Info,
retry: BookOpen,
};
const styles = {
user_action: 'border-l-accent-500 bg-accent-500/5',
narration: 'border-l-surface-600 bg-surface-800/50',
system: 'border-l-surface-500 bg-surface-800/30 italic text-surface-400',
retry: 'border-l-amber-500 bg-amber-500/5',
};
const Icon = $derived(icons[entry.type]);
function startEdit() {
editContent = entry.content;
isEditing = true;
}
async function saveEdit() {
if (editContent.trim() && editContent !== entry.content) {
await story.updateEntry(entry.id, editContent.trim());
}
isEditing = false;
}
function cancelEdit() {
isEditing = false;
editContent = '';
}
async function confirmDelete() {
await story.deleteEntry(entry.id);
isDeleting = false;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
cancelEdit();
} else if (event.key === 'Enter' && event.ctrlKey) {
saveEdit();
}
}
</script>
<div class="group rounded-lg border-l-4 p-4 {styles[entry.type]} relative">
<div class="flex items-start gap-3">
<div class="mt-0.5 rounded-full bg-surface-700 p-1.5">
<Icon class="h-4 w-4 text-surface-400" />
</div>
<div class="flex-1">
{#if entry.type === 'user_action'}
<p class="user-action mb-1 text-sm font-medium">You</p>
{/if}
{#if isEditing}
<div class="space-y-2">
<textarea
bind:value={editContent}
onkeydown={handleKeydown}
class="input min-h-[100px] w-full resize-y text-sm"
rows="4"
></textarea>
<div class="flex gap-2">
<button
onclick={saveEdit}
class="btn btn-primary flex items-center gap-1 text-xs"
>
<Check class="h-3 w-3" />
Save
</button>
<button
onclick={cancelEdit}
class="btn btn-secondary flex items-center gap-1 text-xs"
>
<X class="h-3 w-3" />
Cancel
</button>
</div>
<p class="text-xs text-surface-500">Ctrl+Enter to save, Esc to cancel</p>
</div>
{:else if isDeleting}
<div class="space-y-2">
<p class="text-sm text-surface-300">Delete this entry?</p>
<div class="flex gap-2">
<button
onclick={confirmDelete}
class="btn flex items-center gap-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30"
>
<Trash2 class="h-3 w-3" />
Delete
</button>
<button
onclick={() => isDeleting = false}
class="btn btn-secondary flex items-center gap-1 text-xs"
>
<X class="h-3 w-3" />
Cancel
</button>
</div>
</div>
{:else}
<div class="story-text whitespace-pre-wrap">
{entry.content}
</div>
{#if entry.metadata?.tokenCount}
<p class="mt-2 text-xs text-surface-500">
{entry.metadata.tokenCount} tokens
</p>
{/if}
{/if}
</div>
<!-- Edit/Delete buttons (shown on hover) -->
{#if !isEditing && !isDeleting && entry.type !== 'system'}
<div class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onclick={startEdit}
class="rounded p-1.5 text-surface-400 hover:bg-surface-600 hover:text-surface-200"
title="Edit entry"
>
<Pencil class="h-3.5 w-3.5" />
</button>
<button
onclick={() => isDeleting = true}
class="rounded p-1.5 text-surface-400 hover:bg-red-500/20 hover:text-red-400"
title="Delete entry"
>
<Trash2 class="h-3.5 w-3.5" />
</button>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,55 @@
<script lang="ts">
import { story } from '$lib/stores/story.svelte';
import { ui } from '$lib/stores/ui.svelte';
import StoryEntry from './StoryEntry.svelte';
import StreamingEntry from './StreamingEntry.svelte';
import ActionInput from './ActionInput.svelte';
let storyContainer: HTMLDivElement;
// Auto-scroll to bottom when new entries are added or streaming content changes
$effect(() => {
// Track both entries and streaming state for scroll
const _ = story.entries.length;
const __ = ui.streamingContent;
if (storyContainer) {
storyContainer.scrollTop = storyContainer.scrollHeight;
}
});
</script>
<div class="flex h-full flex-col">
<!-- Story entries container -->
<div
bind:this={storyContainer}
class="flex-1 overflow-y-auto px-6 py-4"
>
<div class="mx-auto max-w-3xl space-y-4">
{#if story.entries.length === 0 && !ui.isStreaming}
<div class="flex flex-col items-center justify-center py-20 text-center">
<p class="text-lg text-surface-400">Your adventure begins here...</p>
<p class="mt-2 text-sm text-surface-500">
Type an action below to start your story
</p>
</div>
{:else}
{#each story.entries as entry (entry.id)}
<StoryEntry {entry} />
{/each}
<!-- Show streaming entry while generating -->
{#if ui.isStreaming}
<StreamingEntry />
{/if}
{/if}
</div>
</div>
<!-- Action input area -->
<div class="border-t border-surface-700 bg-surface-800 p-4">
<div class="mx-auto max-w-3xl">
<ActionInput />
</div>
</div>
</div>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte';
import { BookOpen } from 'lucide-svelte';
// Reactive binding to streaming content
let content = $derived(ui.streamingContent);
</script>
<div class="rounded-lg border-l-4 border-l-accent-500 bg-accent-500/5 p-4 animate-fade-in">
<div class="flex items-start gap-3">
<div class="mt-0.5 rounded-full bg-surface-700 p-1.5">
<BookOpen class="h-4 w-4 text-accent-400 animate-pulse" />
</div>
<div class="flex-1">
<div class="story-text whitespace-pre-wrap">
{content}<span class="inline-block w-2 h-4 ml-0.5 bg-accent-400 animate-blink"></span>
</div>
</div>
</div>
</div>
<style>
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
.animate-blink {
animation: blink 1s infinite;
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fade-in 0.3s ease-out;
}
</style>

View file

@ -0,0 +1,116 @@
<script lang="ts">
import { story } from '$lib/stores/story.svelte';
import { Plus, User, Heart, Skull, UserX } from 'lucide-svelte';
let showAddForm = $state(false);
let newName = $state('');
let newDescription = $state('');
let newRelationship = $state('');
async function addCharacter() {
if (!newName.trim()) return;
await story.addCharacter(newName.trim(), newDescription.trim() || undefined, newRelationship.trim() || undefined);
newName = '';
newDescription = '';
newRelationship = '';
showAddForm = false;
}
function getStatusIcon(status: string) {
switch (status) {
case 'active': return User;
case 'inactive': return UserX;
case 'deceased': return Skull;
default: return User;
}
}
function getStatusColor(status: string) {
switch (status) {
case 'active': return 'text-green-400';
case 'inactive': return 'text-surface-500';
case 'deceased': return 'text-red-400';
default: return 'text-surface-400';
}
}
function renderStatusIcon(status: string) {
return getStatusIcon(status);
}
</script>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="font-medium text-surface-200">Characters</h3>
<button
class="btn-ghost rounded p-1"
onclick={() => showAddForm = !showAddForm}
title="Add character"
>
<Plus class="h-4 w-4" />
</button>
</div>
{#if showAddForm}
<div class="card space-y-2">
<input
type="text"
bind:value={newName}
placeholder="Character name"
class="input text-sm"
/>
<input
type="text"
bind:value={newRelationship}
placeholder="Relationship (e.g., ally, enemy)"
class="input text-sm"
/>
<textarea
bind:value={newDescription}
placeholder="Description (optional)"
class="input text-sm"
rows="2"
></textarea>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary text-xs" onclick={() => showAddForm = false}>
Cancel
</button>
<button class="btn btn-primary text-xs" onclick={addCharacter} disabled={!newName.trim()}>
Add
</button>
</div>
</div>
{/if}
{#if story.characters.length === 0}
<p class="py-4 text-center text-sm text-surface-500">
No characters yet
</p>
{:else}
<div class="space-y-2">
{#each story.characters as character (character.id)}
{@const StatusIcon = getStatusIcon(character.status)}
<div class="card p-3">
<div class="flex items-start gap-2">
<div class="rounded-full bg-surface-700 p-1.5 {getStatusColor(character.status)}">
<StatusIcon class="h-4 w-4" />
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-surface-100">{character.name}</span>
{#if character.relationship}
<span class="rounded-full bg-surface-700 px-2 py-0.5 text-xs text-surface-400">
{character.relationship}
</span>
{/if}
</div>
{#if character.description}
<p class="mt-1 text-sm text-surface-400">{character.description}</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,115 @@
<script lang="ts">
import { story } from '$lib/stores/story.svelte';
import { Plus, Package, Shield } from 'lucide-svelte';
let showAddForm = $state(false);
let newName = $state('');
let newDescription = $state('');
let newQuantity = $state(1);
async function addItem() {
if (!newName.trim()) return;
await story.addItem(newName.trim(), newDescription.trim() || undefined, newQuantity);
newName = '';
newDescription = '';
newQuantity = 1;
showAddForm = false;
}
</script>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="font-medium text-surface-200">Inventory</h3>
<button
class="btn-ghost rounded p-1"
onclick={() => showAddForm = !showAddForm}
title="Add item"
>
<Plus class="h-4 w-4" />
</button>
</div>
{#if showAddForm}
<div class="card space-y-2">
<input
type="text"
bind:value={newName}
placeholder="Item name"
class="input text-sm"
/>
<div class="flex gap-2">
<input
type="number"
bind:value={newQuantity}
min="1"
class="input w-20 text-sm"
/>
<span class="self-center text-sm text-surface-400">quantity</span>
</div>
<textarea
bind:value={newDescription}
placeholder="Description (optional)"
class="input text-sm"
rows="2"
></textarea>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary text-xs" onclick={() => showAddForm = false}>
Cancel
</button>
<button class="btn btn-primary text-xs" onclick={addItem} disabled={!newName.trim()}>
Add
</button>
</div>
</div>
{/if}
<!-- Equipped items -->
{#if story.equippedItems.length > 0}
<div class="space-y-2">
<h4 class="text-sm font-medium text-surface-400">Equipped</h4>
{#each story.equippedItems as item (item.id)}
<div class="card border-accent-500/30 bg-accent-500/5 p-3">
<div class="flex items-center gap-2">
<Shield class="h-4 w-4 text-accent-400" />
<span class="font-medium text-surface-100">{item.name}</span>
{#if item.quantity > 1}
<span class="text-sm text-surface-400">x{item.quantity}</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{#if story.inventoryItems.length === 0 && story.equippedItems.length === 0}
<p class="py-4 text-center text-sm text-surface-500">
No items yet
</p>
{:else if story.inventoryItems.length > 0}
<div class="space-y-2">
{#if story.equippedItems.length > 0}
<h4 class="text-sm font-medium text-surface-400">Inventory</h4>
{/if}
{#each story.inventoryItems as item (item.id)}
<div class="card p-3">
<div class="flex items-start gap-2">
<div class="rounded-full bg-surface-700 p-1.5">
<Package class="h-4 w-4 text-surface-400" />
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-surface-100">{item.name}</span>
{#if item.quantity > 1}
<span class="text-sm text-surface-400">x{item.quantity}</span>
{/if}
</div>
{#if item.description}
<p class="mt-1 text-sm text-surface-400">{item.description}</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,110 @@
<script lang="ts">
import { story } from '$lib/stores/story.svelte';
import { Plus, MapPin, Eye, Navigation } from 'lucide-svelte';
let showAddForm = $state(false);
let newName = $state('');
let newDescription = $state('');
async function addLocation() {
if (!newName.trim()) return;
const makeCurrent = story.locations.length === 0;
await story.addLocation(newName.trim(), newDescription.trim() || undefined, makeCurrent);
newName = '';
newDescription = '';
showAddForm = false;
}
async function goToLocation(locationId: string) {
await story.setCurrentLocation(locationId);
}
</script>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="font-medium text-surface-200">Locations</h3>
<button
class="btn-ghost rounded p-1"
onclick={() => showAddForm = !showAddForm}
title="Add location"
>
<Plus class="h-4 w-4" />
</button>
</div>
{#if showAddForm}
<div class="card space-y-2">
<input
type="text"
bind:value={newName}
placeholder="Location name"
class="input text-sm"
/>
<textarea
bind:value={newDescription}
placeholder="Description (optional)"
class="input text-sm"
rows="2"
></textarea>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary text-xs" onclick={() => showAddForm = false}>
Cancel
</button>
<button class="btn btn-primary text-xs" onclick={addLocation} disabled={!newName.trim()}>
Add
</button>
</div>
</div>
{/if}
<!-- Current Location -->
{#if story.currentLocation}
<div class="card border-accent-500/50 bg-accent-500/10 p-3">
<div class="flex items-center gap-2 text-accent-400">
<Navigation class="h-4 w-4" />
<span class="text-sm font-medium">Current Location</span>
</div>
<h4 class="mt-1 font-medium text-surface-100">{story.currentLocation.name}</h4>
{#if story.currentLocation.description}
<p class="mt-1 text-sm text-surface-400">{story.currentLocation.description}</p>
{/if}
</div>
{/if}
{#if story.locations.length === 0}
<p class="py-4 text-center text-sm text-surface-500">
No locations yet
</p>
{:else}
<div class="space-y-2">
{#each story.locations.filter(l => !l.current) as location (location.id)}
<div class="card p-3">
<div class="flex items-start justify-between">
<div class="flex items-start gap-2">
<div class="rounded-full bg-surface-700 p-1.5">
<MapPin class="h-4 w-4 text-surface-400" />
</div>
<div>
<span class="font-medium text-surface-100">{location.name}</span>
{#if location.visited}
<span class="ml-2 text-xs text-surface-500">
<Eye class="inline h-3 w-3" /> visited
</span>
{/if}
{#if location.description}
<p class="mt-1 text-sm text-surface-400">{location.description}</p>
{/if}
</div>
</div>
<button
class="btn-ghost rounded px-2 py-1 text-xs"
onclick={() => goToLocation(location.id)}
>
Go
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,146 @@
<script lang="ts">
import { story } from '$lib/stores/story.svelte';
import { Plus, Target, CheckCircle, XCircle, Circle } from 'lucide-svelte';
import type { StoryBeat } from '$lib/types';
let showAddForm = $state(false);
let newTitle = $state('');
let newDescription = $state('');
let newType = $state<StoryBeat['type']>('quest');
async function addBeat() {
if (!newTitle.trim()) return;
await story.addStoryBeat(newTitle.trim(), newType, newDescription.trim() || undefined);
newTitle = '';
newDescription = '';
newType = 'quest';
showAddForm = false;
}
function getStatusIcon(status: string) {
switch (status) {
case 'pending': return Circle;
case 'active': return Target;
case 'completed': return CheckCircle;
case 'failed': return XCircle;
default: return Circle;
}
}
function getStatusColor(status: string) {
switch (status) {
case 'pending': return 'text-surface-500';
case 'active': return 'text-amber-400';
case 'completed': return 'text-green-400';
case 'failed': return 'text-red-400';
default: return 'text-surface-400';
}
}
function getTypeLabel(type: string) {
switch (type) {
case 'milestone': return 'Milestone';
case 'quest': return 'Quest';
case 'revelation': return 'Revelation';
case 'event': return 'Event';
default: return type;
}
}
</script>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="font-medium text-surface-200">Story Beats</h3>
<button
class="btn-ghost rounded p-1"
onclick={() => showAddForm = !showAddForm}
title="Add story beat"
>
<Plus class="h-4 w-4" />
</button>
</div>
{#if showAddForm}
<div class="card space-y-2">
<input
type="text"
bind:value={newTitle}
placeholder="Title"
class="input text-sm"
/>
<select bind:value={newType} class="input text-sm">
<option value="quest">Quest</option>
<option value="milestone">Milestone</option>
<option value="revelation">Revelation</option>
<option value="event">Event</option>
</select>
<textarea
bind:value={newDescription}
placeholder="Description (optional)"
class="input text-sm"
rows="2"
></textarea>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary text-xs" onclick={() => showAddForm = false}>
Cancel
</button>
<button class="btn btn-primary text-xs" onclick={addBeat} disabled={!newTitle.trim()}>
Add
</button>
</div>
</div>
{/if}
<!-- Active quests -->
{#if story.pendingQuests.length > 0}
<div class="space-y-2">
<h4 class="text-sm font-medium text-surface-400">Active</h4>
{#each story.pendingQuests as beat (beat.id)}
{@const StatusIcon = getStatusIcon(beat.status)}
<div class="card p-3">
<div class="flex items-start gap-2">
<div class={getStatusColor(beat.status)}>
<StatusIcon class="h-5 w-5" />
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-surface-100">{beat.title}</span>
<span class="rounded-full bg-surface-700 px-2 py-0.5 text-xs text-surface-400">
{getTypeLabel(beat.type)}
</span>
</div>
{#if beat.description}
<p class="mt-1 text-sm text-surface-400">{beat.description}</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
{#if story.storyBeats.length === 0}
<p class="py-4 text-center text-sm text-surface-500">
No story beats yet
</p>
{:else}
<!-- Completed/Failed -->
{@const completedBeats = story.storyBeats.filter(b => b.status === 'completed' || b.status === 'failed')}
{#if completedBeats.length > 0}
<div class="space-y-2">
<h4 class="text-sm font-medium text-surface-400">History</h4>
{#each completedBeats as beat (beat.id)}
{@const StatusIcon = getStatusIcon(beat.status)}
<div class="card p-3 opacity-60">
<div class="flex items-center gap-2">
<div class={getStatusColor(beat.status)}>
<StatusIcon class="h-4 w-4" />
</div>
<span class="text-surface-300">{beat.title}</span>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,190 @@
import { settings } from '$lib/stores/settings.svelte';
import { OpenRouterProvider } from './openrouter';
import { BUILTIN_TEMPLATES } from '$lib/services/templates';
import type { Message, GenerationResponse, StreamChunk } from './types';
import type { Story, StoryEntry, Character, Location, Item, StoryBeat } from '$lib/types';
interface WorldState {
characters: Character[];
locations: Location[];
items: Item[];
storyBeats: StoryBeat[];
currentLocation?: Location;
}
class AIService {
private getProvider() {
const apiKey = settings.apiSettings.openrouterApiKey;
if (!apiKey) {
throw new Error('No API key configured');
}
return new OpenRouterProvider(apiKey);
}
async generateResponse(
entries: StoryEntry[],
worldState: WorldState,
story?: Story | null
): Promise<string> {
const provider = this.getProvider();
// Build the system prompt with world state context
const systemPrompt = this.buildSystemPrompt(worldState, story?.templateId);
// Build conversation history
const messages: Message[] = [
{ role: 'system', content: systemPrompt },
];
// Add recent entries as conversation history
const recentEntries = entries.slice(-20); // Keep last 20 entries for context
for (const entry of recentEntries) {
if (entry.type === 'user_action') {
messages.push({ role: 'user', content: entry.content });
} else if (entry.type === 'narration') {
messages.push({ role: 'assistant', content: entry.content });
}
}
const response = await provider.generateResponse({
messages,
model: settings.apiSettings.defaultModel,
temperature: settings.apiSettings.temperature,
maxTokens: settings.apiSettings.maxTokens,
});
return response.content;
}
async *streamResponse(
entries: StoryEntry[],
worldState: WorldState,
story?: Story | null
): AsyncIterable<StreamChunk> {
const provider = this.getProvider();
// Build the system prompt with world state context
const systemPrompt = this.buildSystemPrompt(worldState, story?.templateId);
// Build conversation history
const messages: Message[] = [
{ role: 'system', content: systemPrompt },
];
// Add recent entries as conversation history
const recentEntries = entries.slice(-20);
for (const entry of recentEntries) {
if (entry.type === 'user_action') {
messages.push({ role: 'user', content: entry.content });
} else if (entry.type === 'narration') {
messages.push({ role: 'assistant', content: entry.content });
}
}
yield* provider.streamResponse({
messages,
model: settings.apiSettings.defaultModel,
temperature: settings.apiSettings.temperature,
maxTokens: settings.apiSettings.maxTokens,
});
}
private buildSystemPrompt(worldState: WorldState, templateId?: string | null): string {
// Get template-specific system prompt if available
let basePrompt = '';
if (templateId) {
const template = BUILTIN_TEMPLATES.find(t => t.id === templateId);
if (template?.systemPrompt) {
basePrompt = template.systemPrompt;
}
}
// If no template prompt, use default
if (!basePrompt) {
basePrompt = `You are an expert interactive fiction narrator and game master. Your role is to create engaging, immersive narrative responses to player actions.
## Guidelines:
- Write in second person ("You see...", "You feel...")
- Be descriptive and evocative, using sensory details
- Respond to player actions naturally and logically
- Maintain consistency with established world elements
- Introduce interesting characters, challenges, and plot developments
- Keep responses concise but atmospheric (2-4 paragraphs typically)
- Never break character or mention being an AI`;
}
// Add current world state context
let worldContext = '\n\n---\n\n## Current World State:';
let hasContext = false;
// Add current location
if (worldState.currentLocation) {
hasContext = true;
worldContext += `\n\n### Current Location: ${worldState.currentLocation.name}`;
if (worldState.currentLocation.description) {
worldContext += `\n${worldState.currentLocation.description}`;
}
}
// Add active characters (excluding self)
const activeChars = worldState.characters.filter(c => c.status === 'active' && c.relationship !== 'self');
if (activeChars.length > 0) {
hasContext = true;
worldContext += '\n\n### Known Characters:';
for (const char of activeChars) {
worldContext += `\n- **${char.name}**`;
if (char.relationship) worldContext += ` (${char.relationship})`;
if (char.description) worldContext += `: ${char.description}`;
if (char.traits && char.traits.length > 0) {
worldContext += ` [${char.traits.join(', ')}]`;
}
}
}
// Add inventory
const inventory = worldState.items.filter(i => i.location === 'inventory');
if (inventory.length > 0) {
hasContext = true;
worldContext += '\n\n### Player Inventory:';
for (const item of inventory) {
worldContext += `\n- ${item.name}`;
if (item.quantity > 1) worldContext += ` (x${item.quantity})`;
if (item.equipped) worldContext += ' [equipped]';
if (item.description) worldContext += `: ${item.description}`;
}
}
// Add active quests
const activeQuests = worldState.storyBeats.filter(b => b.status === 'active' || b.status === 'pending');
if (activeQuests.length > 0) {
hasContext = true;
worldContext += '\n\n### Active Story Threads:';
for (const quest of activeQuests) {
worldContext += `\n- **${quest.title}**`;
if (quest.type) worldContext += ` [${quest.type}]`;
if (quest.description) worldContext += `: ${quest.description}`;
}
}
// Add visited locations for context
const visitedLocations = worldState.locations.filter(l => l.visited && !l.current);
if (visitedLocations.length > 0) {
hasContext = true;
worldContext += '\n\n### Previously Visited:';
worldContext += visitedLocations.map(l => l.name).join(', ');
}
// Only add world context if we have any
if (hasContext) {
basePrompt += worldContext;
}
// Add final instruction
basePrompt += '\n\n---\n\n**Instructions:** Respond to the player\'s action with an engaging narrative continuation. Describe the results of their action, include sensory details and character reactions, and set up opportunities for further exploration or interaction.';
return basePrompt;
}
}
export const aiService = new AIService();

View file

@ -0,0 +1,145 @@
import type { AIProvider, GenerationRequest, GenerationResponse, StreamChunk, ModelInfo, Message } from './types';
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
export class OpenRouterProvider implements AIProvider {
id = 'openrouter';
name = 'OpenRouter';
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
async generateResponse(request: GenerationRequest): Promise<GenerationResponse> {
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
'HTTP-Referer': 'https://aventura.app',
'X-Title': 'Aventura',
},
body: JSON.stringify({
model: request.model,
messages: request.messages,
temperature: request.temperature ?? 0.8,
max_tokens: request.maxTokens ?? 1024,
stop: request.stopSequences,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenRouter API error: ${response.status} - ${error}`);
}
const data = await response.json();
return {
content: data.choices[0]?.message?.content ?? '',
model: data.model,
usage: data.usage ? {
promptTokens: data.usage.prompt_tokens,
completionTokens: data.usage.completion_tokens,
totalTokens: data.usage.total_tokens,
} : undefined,
};
}
async *streamResponse(request: GenerationRequest): AsyncIterable<StreamChunk> {
const response = await fetch(OPENROUTER_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
'HTTP-Referer': 'https://aventura.app',
'X-Title': 'Aventura',
},
body: JSON.stringify({
model: request.model,
messages: request.messages,
temperature: request.temperature ?? 0.8,
max_tokens: request.maxTokens ?? 1024,
stop: request.stopSequences,
stream: true,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenRouter API error: ${response.status} - ${error}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
yield { content: '', done: true };
return;
}
try {
const parsed = JSON.parse(data);
const content = parsed.choices[0]?.delta?.content ?? '';
if (content) {
yield { content, done: false };
}
} catch {
// Ignore parsing errors for incomplete JSON
}
}
}
}
}
async listModels(): Promise<ModelInfo[]> {
const response = await fetch('https://openrouter.ai/api/v1/models', {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch models');
}
const data = await response.json();
return data.data.map((model: any) => ({
id: model.id,
name: model.name ?? model.id,
description: model.description,
contextLength: model.context_length ?? 4096,
pricing: model.pricing ? {
prompt: parseFloat(model.pricing.prompt),
completion: parseFloat(model.pricing.completion),
} : undefined,
}));
}
async validateApiKey(): Promise<boolean> {
try {
await this.listModels();
return true;
} catch {
return false;
}
}
}

View file

@ -0,0 +1,48 @@
export interface Message {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface GenerationRequest {
messages: Message[];
model: string;
temperature?: number;
maxTokens?: number;
stopSequences?: string[];
}
export interface GenerationResponse {
content: string;
model: string;
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
export interface StreamChunk {
content: string;
done: boolean;
}
export interface ModelInfo {
id: string;
name: string;
description?: string;
contextLength: number;
pricing?: {
prompt: number;
completion: number;
};
}
export interface AIProvider {
id: string;
name: string;
generateResponse(request: GenerationRequest): Promise<GenerationResponse>;
streamResponse(request: GenerationRequest): AsyncIterable<StreamChunk>;
listModels(): Promise<ModelInfo[]>;
validateApiKey(): Promise<boolean>;
}

View file

@ -0,0 +1,433 @@
import Database from '@tauri-apps/plugin-sql';
import type { Story, StoryEntry, Character, Location, Item, StoryBeat, Template } from '$lib/types';
class DatabaseService {
private db: Database | null = null;
async init(): Promise<void> {
if (this.db) return;
this.db = await Database.load('sqlite:aventura.db');
}
private async getDb(): Promise<Database> {
if (!this.db) {
await this.init();
}
return this.db!;
}
// Settings operations
async getSetting(key: string): Promise<string | null> {
const db = await this.getDb();
const result = await db.select<{ value: string }[]>(
'SELECT value FROM settings WHERE key = ?',
[key]
);
return result.length > 0 ? result[0].value : null;
}
async setSetting(key: string, value: string): Promise<void> {
const db = await this.getDb();
await db.execute(
'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)',
[key, value]
);
}
// Story operations
async getAllStories(): Promise<Story[]> {
const db = await this.getDb();
const results = await db.select<any[]>(
'SELECT * FROM stories ORDER BY updated_at DESC'
);
return results.map(this.mapStory);
}
async getStory(id: string): Promise<Story | null> {
const db = await this.getDb();
const results = await db.select<any[]>(
'SELECT * FROM stories WHERE id = ?',
[id]
);
return results.length > 0 ? this.mapStory(results[0]) : null;
}
async createStory(story: Omit<Story, 'createdAt' | 'updatedAt'>): Promise<Story> {
const db = await this.getDb();
const now = Date.now();
await db.execute(
`INSERT INTO stories (id, title, description, genre, template_id, created_at, updated_at, settings)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
story.id,
story.title,
story.description,
story.genre,
story.templateId,
now,
now,
story.settings ? JSON.stringify(story.settings) : null,
]
);
return { ...story, createdAt: now, updatedAt: now };
}
async updateStory(id: string, updates: Partial<Story>): Promise<void> {
const db = await this.getDb();
const now = Date.now();
const setClauses: string[] = ['updated_at = ?'];
const values: any[] = [now];
if (updates.title !== undefined) {
setClauses.push('title = ?');
values.push(updates.title);
}
if (updates.description !== undefined) {
setClauses.push('description = ?');
values.push(updates.description);
}
if (updates.genre !== undefined) {
setClauses.push('genre = ?');
values.push(updates.genre);
}
if (updates.settings !== undefined) {
setClauses.push('settings = ?');
values.push(JSON.stringify(updates.settings));
}
values.push(id);
await db.execute(
`UPDATE stories SET ${setClauses.join(', ')} WHERE id = ?`,
values
);
}
async deleteStory(id: string): Promise<void> {
const db = await this.getDb();
await db.execute('DELETE FROM stories WHERE id = ?', [id]);
}
// Story entries operations
async getStoryEntries(storyId: string): Promise<StoryEntry[]> {
const db = await this.getDb();
const results = await db.select<any[]>(
'SELECT * FROM story_entries WHERE story_id = ? ORDER BY position ASC',
[storyId]
);
return results.map(this.mapStoryEntry);
}
async addStoryEntry(entry: Omit<StoryEntry, 'createdAt'>): Promise<StoryEntry> {
const db = await this.getDb();
const now = Date.now();
await db.execute(
`INSERT INTO story_entries (id, story_id, type, content, parent_id, position, created_at, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
entry.id,
entry.storyId,
entry.type,
entry.content,
entry.parentId,
entry.position,
now,
entry.metadata ? JSON.stringify(entry.metadata) : null,
]
);
return { ...entry, createdAt: now };
}
async getNextEntryPosition(storyId: string): Promise<number> {
const db = await this.getDb();
const result = await db.select<{ maxPos: number | null }[]>(
'SELECT MAX(position) as maxPos FROM story_entries WHERE story_id = ?',
[storyId]
);
return (result[0]?.maxPos ?? -1) + 1;
}
async updateStoryEntry(id: string, updates: Partial<StoryEntry>): Promise<void> {
const db = await this.getDb();
const setClauses: string[] = [];
const values: any[] = [];
if (updates.content !== undefined) {
setClauses.push('content = ?');
values.push(updates.content);
}
if (updates.type !== undefined) {
setClauses.push('type = ?');
values.push(updates.type);
}
if (updates.metadata !== undefined) {
setClauses.push('metadata = ?');
values.push(updates.metadata ? JSON.stringify(updates.metadata) : null);
}
if (setClauses.length === 0) return;
values.push(id);
await db.execute(
`UPDATE story_entries SET ${setClauses.join(', ')} WHERE id = ?`,
values
);
}
async deleteStoryEntry(id: string): Promise<void> {
const db = await this.getDb();
await db.execute('DELETE FROM story_entries WHERE id = ?', [id]);
}
// Character operations
async getCharacters(storyId: string): Promise<Character[]> {
const db = await this.getDb();
const results = await db.select<any[]>(
'SELECT * FROM characters WHERE story_id = ?',
[storyId]
);
return results.map(this.mapCharacter);
}
async addCharacter(character: Character): Promise<void> {
const db = await this.getDb();
await db.execute(
`INSERT INTO characters (id, story_id, name, description, relationship, traits, status, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
character.id,
character.storyId,
character.name,
character.description,
character.relationship,
JSON.stringify(character.traits),
character.status,
character.metadata ? JSON.stringify(character.metadata) : null,
]
);
}
async updateCharacter(id: string, updates: Partial<Character>): Promise<void> {
const db = await this.getDb();
const setClauses: string[] = [];
const values: any[] = [];
if (updates.name !== undefined) { setClauses.push('name = ?'); values.push(updates.name); }
if (updates.description !== undefined) { setClauses.push('description = ?'); values.push(updates.description); }
if (updates.relationship !== undefined) { setClauses.push('relationship = ?'); values.push(updates.relationship); }
if (updates.traits !== undefined) { setClauses.push('traits = ?'); values.push(JSON.stringify(updates.traits)); }
if (updates.status !== undefined) { setClauses.push('status = ?'); values.push(updates.status); }
if (updates.metadata !== undefined) { setClauses.push('metadata = ?'); values.push(JSON.stringify(updates.metadata)); }
if (setClauses.length === 0) return;
values.push(id);
await db.execute(`UPDATE characters SET ${setClauses.join(', ')} WHERE id = ?`, values);
}
// Location operations
async getLocations(storyId: string): Promise<Location[]> {
const db = await this.getDb();
const results = await db.select<any[]>(
'SELECT * FROM locations WHERE story_id = ?',
[storyId]
);
return results.map(this.mapLocation);
}
async addLocation(location: Location): Promise<void> {
const db = await this.getDb();
await db.execute(
`INSERT INTO locations (id, story_id, name, description, visited, current, connections, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
location.id,
location.storyId,
location.name,
location.description,
location.visited ? 1 : 0,
location.current ? 1 : 0,
JSON.stringify(location.connections),
location.metadata ? JSON.stringify(location.metadata) : null,
]
);
}
async setCurrentLocation(storyId: string, locationId: string): Promise<void> {
const db = await this.getDb();
await db.execute('UPDATE locations SET current = 0 WHERE story_id = ?', [storyId]);
await db.execute('UPDATE locations SET current = 1, visited = 1 WHERE id = ?', [locationId]);
}
// Item operations
async getItems(storyId: string): Promise<Item[]> {
const db = await this.getDb();
const results = await db.select<any[]>(
'SELECT * FROM items WHERE story_id = ?',
[storyId]
);
return results.map(this.mapItem);
}
async addItem(item: Item): Promise<void> {
const db = await this.getDb();
await db.execute(
`INSERT INTO items (id, story_id, name, description, quantity, equipped, location, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
item.id,
item.storyId,
item.name,
item.description,
item.quantity,
item.equipped ? 1 : 0,
item.location,
item.metadata ? JSON.stringify(item.metadata) : null,
]
);
}
// Story beats operations
async getStoryBeats(storyId: string): Promise<StoryBeat[]> {
const db = await this.getDb();
const results = await db.select<any[]>(
'SELECT * FROM story_beats WHERE story_id = ?',
[storyId]
);
return results.map(this.mapStoryBeat);
}
async addStoryBeat(beat: StoryBeat): Promise<void> {
const db = await this.getDb();
await db.execute(
`INSERT INTO story_beats (id, story_id, title, description, type, status, triggered_at, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
beat.id,
beat.storyId,
beat.title,
beat.description,
beat.type,
beat.status,
beat.triggeredAt,
beat.metadata ? JSON.stringify(beat.metadata) : null,
]
);
}
// Template operations
async getTemplates(): Promise<Template[]> {
const db = await this.getDb();
const results = await db.select<any[]>('SELECT * FROM templates ORDER BY is_builtin DESC, name ASC');
return results.map(this.mapTemplate);
}
async addTemplate(template: Template): Promise<void> {
const db = await this.getDb();
await db.execute(
`INSERT INTO templates (id, name, description, genre, system_prompt, initial_state, is_builtin, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
template.id,
template.name,
template.description,
template.genre,
template.systemPrompt,
template.initialState ? JSON.stringify(template.initialState) : null,
template.isBuiltin ? 1 : 0,
template.createdAt,
]
);
}
// Mapping functions
private mapStory(row: any): Story {
return {
id: row.id,
title: row.title,
description: row.description,
genre: row.genre,
templateId: row.template_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
settings: row.settings ? JSON.parse(row.settings) : null,
};
}
private mapStoryEntry(row: any): StoryEntry {
return {
id: row.id,
storyId: row.story_id,
type: row.type,
content: row.content,
parentId: row.parent_id,
position: row.position,
createdAt: row.created_at,
metadata: row.metadata ? JSON.parse(row.metadata) : null,
};
}
private mapCharacter(row: any): Character {
return {
id: row.id,
storyId: row.story_id,
name: row.name,
description: row.description,
relationship: row.relationship,
traits: row.traits ? JSON.parse(row.traits) : [],
status: row.status,
metadata: row.metadata ? JSON.parse(row.metadata) : null,
};
}
private mapLocation(row: any): Location {
return {
id: row.id,
storyId: row.story_id,
name: row.name,
description: row.description,
visited: row.visited === 1,
current: row.current === 1,
connections: row.connections ? JSON.parse(row.connections) : [],
metadata: row.metadata ? JSON.parse(row.metadata) : null,
};
}
private mapItem(row: any): Item {
return {
id: row.id,
storyId: row.story_id,
name: row.name,
description: row.description,
quantity: row.quantity,
equipped: row.equipped === 1,
location: row.location,
metadata: row.metadata ? JSON.parse(row.metadata) : null,
};
}
private mapStoryBeat(row: any): StoryBeat {
return {
id: row.id,
storyId: row.story_id,
title: row.title,
description: row.description,
type: row.type,
status: row.status,
triggeredAt: row.triggered_at,
metadata: row.metadata ? JSON.parse(row.metadata) : null,
};
}
private mapTemplate(row: any): Template {
return {
id: row.id,
name: row.name,
description: row.description,
genre: row.genre,
systemPrompt: row.system_prompt,
initialState: row.initial_state ? JSON.parse(row.initial_state) : null,
isBuiltin: row.is_builtin === 1,
createdAt: row.created_at,
};
}
}
export const database = new DatabaseService();

313
src/lib/services/export.ts Normal file
View file

@ -0,0 +1,313 @@
import { save, open } from '@tauri-apps/plugin-dialog';
import { writeTextFile, readTextFile } from '@tauri-apps/plugin-fs';
import { database } from './database';
import type { Story, StoryEntry, Character, Location, Item, StoryBeat } from '$lib/types';
export interface AventuraExport {
version: string;
exportedAt: number;
story: Story;
entries: StoryEntry[];
characters: Character[];
locations: Location[];
items: Item[];
storyBeats: StoryBeat[];
}
class ExportService {
private readonly VERSION = '1.0.0';
// Export to Aventura format (.avt - JSON)
async exportToAventura(
story: Story,
entries: StoryEntry[],
characters: Character[],
locations: Location[],
items: Item[],
storyBeats: StoryBeat[]
): Promise<boolean> {
const exportData: AventuraExport = {
version: this.VERSION,
exportedAt: Date.now(),
story,
entries,
characters,
locations,
items,
storyBeats,
};
const filePath = await save({
defaultPath: `${this.sanitizeFilename(story.title)}.avt`,
filters: [
{ name: 'Aventura Story', extensions: ['avt'] },
{ name: 'JSON', extensions: ['json'] },
],
});
if (!filePath) return false;
await writeTextFile(filePath, JSON.stringify(exportData, null, 2));
return true;
}
// Export to Markdown
async exportToMarkdown(
story: Story,
entries: StoryEntry[],
characters: Character[],
locations: Location[],
includeWorldState: boolean = false
): Promise<boolean> {
let markdown = `# ${story.title}\n\n`;
if (story.description) {
markdown += `*${story.description}*\n\n`;
}
if (story.genre) {
markdown += `**Genre:** ${story.genre}\n\n`;
}
markdown += `---\n\n`;
// Add story entries
for (const entry of entries) {
if (entry.type === 'user_action') {
markdown += `> **You:** ${entry.content}\n\n`;
} else if (entry.type === 'narration') {
markdown += `${entry.content}\n\n`;
} else if (entry.type === 'system') {
markdown += `*[System: ${entry.content}]*\n\n`;
}
}
// Optionally include world state
if (includeWorldState) {
markdown += `---\n\n## World State\n\n`;
if (characters.length > 0) {
markdown += `### Characters\n\n`;
for (const char of characters) {
markdown += `- **${char.name}**`;
if (char.relationship) markdown += ` (${char.relationship})`;
if (char.description) markdown += `: ${char.description}`;
markdown += `\n`;
}
markdown += `\n`;
}
if (locations.length > 0) {
markdown += `### Locations\n\n`;
for (const loc of locations) {
markdown += `- **${loc.name}**`;
if (loc.current) markdown += ` [Current]`;
if (loc.visited) markdown += ` [Visited]`;
if (loc.description) markdown += `: ${loc.description}`;
markdown += `\n`;
}
markdown += `\n`;
}
}
// Add export metadata
markdown += `---\n\n`;
markdown += `*Exported from Aventura on ${new Date().toLocaleDateString()}*\n`;
const filePath = await save({
defaultPath: `${this.sanitizeFilename(story.title)}.md`,
filters: [
{ name: 'Markdown', extensions: ['md'] },
{ name: 'Text', extensions: ['txt'] },
],
});
if (!filePath) return false;
await writeTextFile(filePath, markdown);
return true;
}
// Export to plain text
async exportToText(story: Story, entries: StoryEntry[]): Promise<boolean> {
let text = `${story.title}\n${'='.repeat(story.title.length)}\n\n`;
if (story.description) {
text += `${story.description}\n\n`;
}
text += `---\n\n`;
for (const entry of entries) {
if (entry.type === 'user_action') {
text += `> ${entry.content}\n\n`;
} else if (entry.type === 'narration') {
text += `${entry.content}\n\n`;
}
}
const filePath = await save({
defaultPath: `${this.sanitizeFilename(story.title)}.txt`,
filters: [
{ name: 'Text', extensions: ['txt'] },
],
});
if (!filePath) return false;
await writeTextFile(filePath, text);
return true;
}
// Import from Aventura format (.avt)
async importFromAventura(): Promise<{ success: boolean; storyId?: string; error?: string }> {
const filePath = await open({
filters: [
{ name: 'Aventura Story', extensions: ['avt'] },
{ name: 'JSON', extensions: ['json'] },
],
});
if (!filePath || typeof filePath !== 'string') {
return { success: false };
}
try {
const content = await readTextFile(filePath);
const data: AventuraExport = JSON.parse(content);
// Validate the import data
if (!data.version || !data.story || !data.entries) {
return { success: false, error: 'Invalid Aventura file format' };
}
// Generate new IDs to avoid conflicts
const oldToNewId = new Map<string, string>();
// Create new story ID
const newStoryId = crypto.randomUUID();
oldToNewId.set(data.story.id, newStoryId);
// Create the story with a modified title to indicate it was imported
const importedStory: Omit<Story, 'createdAt' | 'updatedAt'> = {
id: newStoryId,
title: `${data.story.title} (Imported)`,
description: data.story.description,
genre: data.story.genre,
templateId: data.story.templateId,
settings: data.story.settings,
};
await database.createStory(importedStory);
// Import entries
for (const entry of data.entries) {
const newEntryId = crypto.randomUUID();
oldToNewId.set(entry.id, newEntryId);
await database.addStoryEntry({
id: newEntryId,
storyId: newStoryId,
type: entry.type,
content: entry.content,
parentId: entry.parentId ? oldToNewId.get(entry.parentId) ?? null : null,
position: entry.position,
metadata: entry.metadata,
});
}
// Import characters
if (data.characters) {
for (const char of data.characters) {
const newCharId = crypto.randomUUID();
oldToNewId.set(char.id, newCharId);
await database.addCharacter({
id: newCharId,
storyId: newStoryId,
name: char.name,
description: char.description,
relationship: char.relationship,
traits: char.traits,
status: char.status,
metadata: char.metadata,
});
}
}
// Import locations
if (data.locations) {
for (const loc of data.locations) {
const newLocId = crypto.randomUUID();
oldToNewId.set(loc.id, newLocId);
await database.addLocation({
id: newLocId,
storyId: newStoryId,
name: loc.name,
description: loc.description,
visited: loc.visited,
current: loc.current,
connections: loc.connections.map(c => oldToNewId.get(c) ?? c),
metadata: loc.metadata,
});
}
}
// Import items
if (data.items) {
for (const item of data.items) {
const newItemId = crypto.randomUUID();
oldToNewId.set(item.id, newItemId);
await database.addItem({
id: newItemId,
storyId: newStoryId,
name: item.name,
description: item.description,
quantity: item.quantity,
equipped: item.equipped,
location: item.location,
metadata: item.metadata,
});
}
}
// Import story beats
if (data.storyBeats) {
for (const beat of data.storyBeats) {
const newBeatId = crypto.randomUUID();
oldToNewId.set(beat.id, newBeatId);
await database.addStoryBeat({
id: newBeatId,
storyId: newStoryId,
title: beat.title,
description: beat.description,
type: beat.type,
status: beat.status,
triggeredAt: beat.triggeredAt,
metadata: beat.metadata,
});
}
}
return { success: true, storyId: newStoryId };
} catch (error) {
console.error('Import failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to import file',
};
}
}
private sanitizeFilename(name: string): string {
return name
.replace(/[<>:"/\\|?*]/g, '')
.replace(/\s+/g, '_')
.slice(0, 100);
}
}
export const exportService = new ExportService();

View file

@ -0,0 +1,296 @@
import type { Template, TemplateInitialState } from '$lib/types';
import { database } from './database';
export const BUILTIN_TEMPLATES: Omit<Template, 'createdAt'>[] = [
{
id: 'fantasy-adventure',
name: 'Fantasy Adventure',
description: 'Epic quests, magic, mythical creatures, and heroic journeys in a medieval fantasy world.',
genre: 'Fantasy',
isBuiltin: true,
systemPrompt: `You are a master storyteller crafting an immersive fantasy adventure. The world is filled with magic, mythical creatures, ancient prophecies, and epic quests.
## World Elements:
- Magic exists and can be learned or innate
- Various races (elves, dwarves, orcs, etc.) may exist
- Medieval-esque technology with magical enhancements
- Gods and divine forces may influence events
- Ancient ruins, enchanted forests, and mystical locations abound
## Narrative Style:
- Rich, descriptive prose with vivid imagery
- Balance action, dialogue, and atmosphere
- Include moments of wonder and discovery
- Build tension through challenges and mysteries
- Reward clever thinking and brave actions
## Guidelines:
- Write in second person ("You see...", "You feel...")
- Responses should be 2-4 paragraphs
- Include sensory details (sights, sounds, smells)
- NPCs should have distinct personalities
- Combat should be exciting but not gratuitously violent
- Magic should feel wondrous, not mundane`,
initialState: {
protagonist: {
name: 'The Adventurer',
description: 'A brave soul seeking glory and purpose',
traits: ['brave', 'curious', 'determined'],
},
startingLocation: {
name: 'The Crossroads Inn',
description: 'A weathered tavern where travelers share tales of distant lands and rumors of adventure.',
},
initialItems: [
{ name: 'Worn Traveler\'s Pack', description: 'A sturdy leather pack containing basic supplies' },
{ name: 'Copper Coins', description: 'A small pouch of coins, enough for a few meals', quantity: 15 },
],
openingScene: 'The hearth crackles warmly as you push open the heavy oak door of the Crossroads Inn. The smell of roasted meat and spiced ale fills your nostrils. Travelers huddle at worn tables, their conversations a low murmur punctuated by occasional laughter. The innkeeper, a stout woman with kind eyes, looks up from polishing a tankard and nods in your direction. Through the smoky air, you notice a cloaked figure in the corner booth, and a notice board near the bar covered in weathered parchments.',
},
},
{
id: 'scifi-exploration',
name: 'Sci-Fi Exploration',
description: 'Explore the cosmos, encounter alien civilizations, and unravel the mysteries of the universe.',
genre: 'Sci-Fi',
isBuiltin: true,
systemPrompt: `You are crafting a science fiction adventure set in a vast, explorable universe. Technology is advanced but grounded in plausible science fiction concepts.
## World Elements:
- Faster-than-light travel exists (warp drives, jump gates, etc.)
- Multiple alien species with unique cultures
- Advanced AI, cybernetics, and biotechnology
- Corporate factions, space stations, and colony worlds
- Mysteries of ancient civilizations and cosmic phenomena
## Narrative Style:
- Blend hard sci-fi concepts with accessible storytelling
- Create a sense of scale and wonder at the cosmos
- Include technical details that enhance immersion
- Balance exploration, social interaction, and action
- Explore themes of humanity, consciousness, and discovery
## Guidelines:
- Write in second person ("You see...", "You feel...")
- Responses should be 2-4 paragraphs
- Make technology feel lived-in, not sterile
- Alien cultures should feel genuinely foreign
- Include ethical dilemmas and moral complexity
- Space should feel vast and sometimes dangerous`,
initialState: {
protagonist: {
name: 'The Captain',
description: 'Commander of a small independent vessel, seeking fortune and discovery among the stars',
traits: ['resourceful', 'adaptable', 'ambitious'],
},
startingLocation: {
name: 'Nexus Station',
description: 'A bustling space station at the intersection of major trade routes, home to traders, mercenaries, and those seeking to disappear.',
},
initialItems: [
{ name: 'Personal Datapad', description: 'A versatile handheld computer with navigation and communication capabilities' },
{ name: 'Standard Sidearm', description: 'A reliable energy pistol, standard issue for spacers' },
{ name: 'Credit Chip', description: 'Digital currency storage', quantity: 500 },
],
openingScene: 'The airlock hisses open, and you step onto Nexus Station\'s main concourse. Holographic advertisements flicker in a dozen languages—some human, some decidedly not. The station\'s artificial gravity feels slightly off, a common quirk of older installations. Your ship, the *Vagrant Star*, sits in docking bay 47, her hull still bearing scorch marks from your last job. The message that brought you here promised lucrative work, but the sender remained anonymous. Through the crowd, you spot the designated meeting point: a grimy bar called "The Event Horizon."',
},
},
{
id: 'mystery-investigation',
name: 'Mystery Investigation',
description: 'Solve intricate puzzles, uncover hidden truths, and bring justice to light as a detective.',
genre: 'Mystery',
isBuiltin: true,
systemPrompt: `You are weaving an intricate mystery narrative where the player takes on the role of an investigator. Clues, red herrings, and revelations drive the story forward.
## World Elements:
- A grounded, realistic setting (modern day, noir, Victorian, etc.)
- Complex characters with secrets and motivations
- Interconnected clues that reward careful attention
- Multiple suspects with means, motive, and opportunity
- Atmospheric locations that enhance the mood
## Narrative Style:
- Build tension through uncertainty and discovery
- Plant clues naturally within descriptions
- Create memorable, morally gray characters
- Balance investigation, interrogation, and deduction
- Reward player attention and clever thinking
## Guidelines:
- Write in second person ("You notice...", "You deduce...")
- Responses should be 2-4 paragraphs
- Include sensory details that might be clues
- NPCs should be consistent but may lie or omit
- Never solve the mystery for the player
- Maintain fair playall clues should be available`,
initialState: {
protagonist: {
name: 'The Detective',
description: 'A sharp-minded investigator with an eye for detail and a nose for deception',
traits: ['observant', 'persistent', 'analytical'],
},
startingLocation: {
name: 'The Crime Scene',
description: 'An elegant study in a wealthy estate, now cordoned off with police tape.',
},
initialItems: [
{ name: 'Detective\'s Notebook', description: 'A well-worn leather notebook for recording observations and theories' },
{ name: 'Magnifying Glass', description: 'A quality lens for examining fine details' },
{ name: 'Business Cards', description: 'Your professional credentials', quantity: 10 },
],
openingScene: 'The grandfather clock in the corner reads 11:47 PM—presumably the time it stopped when knocked over during the struggle. You duck under the yellow tape and survey the scene. Edmund Blackwood, textile magnate and philanthropist, lies face-down on the Persian rug, a letter opener protruding from his back. The French windows are ajar, curtains stirring in the night breeze. A half-empty glass of brandy sits on the desk beside scattered papers. The household staff waits in the parlor, and Officer Chen hands you a preliminary report. "No forced entry," she notes. "Someone he knew."',
},
},
{
id: 'horror-survival',
name: 'Horror Survival',
description: 'Face your fears, survive the night, and confront the darkness that lurks in the shadows.',
genre: 'Horror',
isBuiltin: true,
systemPrompt: `You are crafting a horror narrative designed to create tension, dread, and fear. The player must survive against supernatural or mundane terrors.
## World Elements:
- An atmosphere of creeping dread and unease
- Threats that are initially hidden or poorly understood
- Safe spaces that gradually become compromised
- Limited resources and difficult choices
- Psychological elements alongside physical danger
## Narrative Style:
- Build tension slowly, then release in bursts
- Use sensory details to create unease
- Leave things to imaginationsuggestion over explicit
- Create a sense of isolation and vulnerability
- Subvert expectations to maintain fear
## Guidelines:
- Write in second person ("Your heart pounds...", "You hear...")
- Responses should be 2-4 paragraphs
- Never let the player feel completely safe
- Horror should unsettle, not merely disgust
- Give the player agency but make choices difficult
- Include moments of hope to make darkness darker`,
initialState: {
protagonist: {
name: 'The Survivor',
description: 'An ordinary person thrust into extraordinary horror',
traits: ['resourceful', 'frightened', 'determined'],
},
startingLocation: {
name: 'The Old House',
description: 'A decrepit Victorian mansion on the outskirts of town, long abandoned—until tonight.',
},
initialItems: [
{ name: 'Flashlight', description: 'A reliable flashlight with fading batteries' },
{ name: 'Cell Phone', description: 'No signal, but the screen provides some light' },
],
openingScene: 'The front door slams shut behind you. You spin around, pulling at the handle, but it won\'t budge. Through the grimy windows, your car sits in the overgrown driveway, impossibly far away. The dare was simple—spend one hour in the Ashford House. Your friends are probably laughing right now. But the laughter you heard just before the door closed... that didn\'t come from outside. Dust motes drift through your flashlight beam as you scan the entry hall. A grand staircase leads up into darkness. Doors lead left and right. Somewhere above, a floorboard creaks.',
},
},
{
id: 'slice-of-life',
name: 'Slice of Life',
description: 'Experience everyday moments, build relationships, and find meaning in the ordinary.',
genre: 'Slice of Life',
isBuiltin: true,
systemPrompt: `You are crafting a warm, character-driven narrative focused on everyday life, relationships, and personal growth. The magic is in the mundane.
## World Elements:
- A grounded, realistic contemporary setting
- Rich supporting characters with their own lives
- Meaningful locations that feel lived-in
- Seasonal changes and passage of time
- Small stakes that feel personally significant
## Narrative Style:
- Focus on character emotions and relationships
- Find beauty and meaning in ordinary moments
- Develop characters through small interactions
- Balance humor, warmth, and gentle melancholy
- Create a sense of community and belonging
## Guidelines:
- Write in second person ("You smile...", "You remember...")
- Responses should be 2-4 paragraphs
- Focus on internal experience as much as external
- NPCs should feel like real people with real lives
- Embrace quiet moments and comfortable silences
- Growth comes from relationships and self-reflection`,
initialState: {
protagonist: {
name: 'You',
description: 'Someone at a crossroads in life, seeking connection and meaning',
traits: ['thoughtful', 'kind', 'searching'],
},
startingLocation: {
name: 'Your New Apartment',
description: 'A small but cozy apartment in a new city, boxes still waiting to be unpacked.',
},
initialItems: [
{ name: 'Moving Boxes', description: 'Cardboard boxes containing your belongings and memories', quantity: 12 },
{ name: 'Favorite Mug', description: 'A chipped ceramic mug that\'s been with you through everything' },
],
openingScene: 'Sunlight streams through the bare windows of your new apartment, catching the dust you\'ve stirred up while unpacking. The space is small—a studio with a kitchenette—but it\'s yours. Outside, you can hear the unfamiliar sounds of your new neighborhood: a dog barking, someone practicing piano badly, the rumble of traffic. Your phone buzzes with a text from your mother asking if you\'ve eaten. You haven\'t. There\'s a café on the corner you noticed while moving in, and your stomach reminds you that coffee and human contact might both be good ideas. First day in a new city. Everything feels possible.',
},
},
{
id: 'custom',
name: 'Custom Adventure',
description: 'Start with a blank slate and define your own world, setting, and narrative style.',
genre: 'Custom',
isBuiltin: true,
systemPrompt: `You are a collaborative storyteller helping to craft an interactive narrative. Adapt your style to match the world and tone the player establishes.
## Guidelines:
- Write in second person ("You see...", "You feel...")
- Responses should be 2-4 paragraphs
- Match the tone and genre the player establishes
- Be responsive to player choices and creativity
- Create interesting characters and situations
- Maintain consistency with established world details`,
initialState: {
openingScene: 'A blank page awaits your story. Where would you like to begin? Describe the world, your character, or simply start with an action—the narrative will follow your lead.',
},
},
];
class TemplateService {
private initialized = false;
async init(): Promise<void> {
if (this.initialized) return;
const existingTemplates = await database.getTemplates();
const existingIds = new Set(existingTemplates.map(t => t.id));
// Add any missing built-in templates
for (const template of BUILTIN_TEMPLATES) {
if (!existingIds.has(template.id)) {
await database.addTemplate({
...template,
createdAt: Date.now(),
});
}
}
this.initialized = true;
}
async getTemplates(): Promise<Template[]> {
await this.init();
return database.getTemplates();
}
async getTemplate(id: string): Promise<Template | null> {
await this.init();
const templates = await database.getTemplates();
return templates.find(t => t.id === id) ?? null;
}
getBuiltinTemplates(): Omit<Template, 'createdAt'>[] {
return BUILTIN_TEMPLATES;
}
}
export const templateService = new TemplateService();

View file

@ -0,0 +1,96 @@
import type { APISettings, UISettings } from '$lib/types';
import { database } from '$lib/services/database';
// Settings Store using Svelte 5 runes
class SettingsStore {
apiSettings = $state<APISettings>({
openrouterApiKey: null,
defaultModel: 'anthropic/claude-3.5-sonnet',
temperature: 0.8,
maxTokens: 1024,
});
uiSettings = $state<UISettings>({
theme: 'dark',
fontSize: 'medium',
showWordCount: true,
autoSave: true,
});
initialized = $state(false);
async init() {
if (this.initialized) return;
try {
// Load API settings
const apiKey = await database.getSetting('openrouter_api_key');
const defaultModel = await database.getSetting('default_model');
const temperature = await database.getSetting('temperature');
const maxTokens = await database.getSetting('max_tokens');
if (apiKey) this.apiSettings.openrouterApiKey = apiKey;
if (defaultModel) this.apiSettings.defaultModel = defaultModel;
if (temperature) this.apiSettings.temperature = parseFloat(temperature);
if (maxTokens) this.apiSettings.maxTokens = parseInt(maxTokens);
// Load UI settings
const theme = await database.getSetting('theme');
const fontSize = await database.getSetting('font_size');
const showWordCount = await database.getSetting('show_word_count');
const autoSave = await database.getSetting('auto_save');
if (theme) this.uiSettings.theme = theme as 'dark' | 'light';
if (fontSize) this.uiSettings.fontSize = fontSize as 'small' | 'medium' | 'large';
if (showWordCount) this.uiSettings.showWordCount = showWordCount === 'true';
if (autoSave) this.uiSettings.autoSave = autoSave === 'true';
this.initialized = true;
} catch (error) {
console.error('Failed to load settings:', error);
this.initialized = true; // Mark as initialized even on error to prevent infinite retries
}
}
async setApiKey(key: string) {
this.apiSettings.openrouterApiKey = key;
await database.setSetting('openrouter_api_key', key);
}
async setDefaultModel(model: string) {
this.apiSettings.defaultModel = model;
await database.setSetting('default_model', model);
}
async setTemperature(temp: number) {
this.apiSettings.temperature = temp;
await database.setSetting('temperature', temp.toString());
}
async setMaxTokens(tokens: number) {
this.apiSettings.maxTokens = tokens;
await database.setSetting('max_tokens', tokens.toString());
}
async setTheme(theme: 'dark' | 'light') {
this.uiSettings.theme = theme;
await database.setSetting('theme', theme);
// Update the document class for Tailwind dark mode
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
async setFontSize(size: 'small' | 'medium' | 'large') {
this.uiSettings.fontSize = size;
await database.setSetting('font_size', size);
}
get hasApiKey(): boolean {
return !!this.apiSettings.openrouterApiKey;
}
}
export const settings = new SettingsStore();

View file

@ -0,0 +1,344 @@
import type { Story, StoryEntry, Character, Location, Item, StoryBeat } from '$lib/types';
import { database } from '$lib/services/database';
import { BUILTIN_TEMPLATES } from '$lib/services/templates';
// Story Store using Svelte 5 runes
class StoryStore {
// Current active story
currentStory = $state<Story | null>(null);
entries = $state<StoryEntry[]>([]);
// World state for current story
characters = $state<Character[]>([]);
locations = $state<Location[]>([]);
items = $state<Item[]>([]);
storyBeats = $state<StoryBeat[]>([]);
// Story library
allStories = $state<Story[]>([]);
// Derived states
get currentLocation(): Location | undefined {
return this.locations.find(l => l.current);
}
get activeCharacters(): Character[] {
return this.characters.filter(c => c.status === 'active');
}
get inventoryItems(): Item[] {
return this.items.filter(i => i.location === 'inventory');
}
get equippedItems(): Item[] {
return this.items.filter(i => i.equipped);
}
get pendingQuests(): StoryBeat[] {
return this.storyBeats.filter(b => b.status === 'pending' || b.status === 'active');
}
get wordCount(): number {
return this.entries.reduce((count, entry) => {
return count + entry.content.split(/\s+/).filter(Boolean).length;
}, 0);
}
// Load all stories for library view
async loadAllStories(): Promise<void> {
this.allStories = await database.getAllStories();
}
// Load a specific story with all its data
async loadStory(storyId: string): Promise<void> {
const story = await database.getStory(storyId);
if (!story) {
throw new Error(`Story not found: ${storyId}`);
}
this.currentStory = story;
// Load all related data in parallel
const [entries, characters, locations, items, storyBeats] = await Promise.all([
database.getStoryEntries(storyId),
database.getCharacters(storyId),
database.getLocations(storyId),
database.getItems(storyId),
database.getStoryBeats(storyId),
]);
this.entries = entries;
this.characters = characters;
this.locations = locations;
this.items = items;
this.storyBeats = storyBeats;
}
// Create a new story
async createStory(title: string, templateId?: string, genre?: string): Promise<Story> {
const storyData = await database.createStory({
id: crypto.randomUUID(),
title,
description: null,
genre: genre ?? null,
templateId: templateId ?? null,
settings: null,
});
this.allStories = [storyData, ...this.allStories];
return storyData;
}
// Create a new story from a template with initialization
async createStoryFromTemplate(title: string, templateId: string, genre?: string): Promise<Story> {
const template = BUILTIN_TEMPLATES.find(t => t.id === templateId);
// Create the base story
const storyData = await database.createStory({
id: crypto.randomUUID(),
title,
description: template?.description ?? null,
genre: genre ?? null,
templateId,
settings: null,
});
this.allStories = [storyData, ...this.allStories];
// Initialize with template data if available
if (template?.initialState) {
const state = template.initialState;
// Add protagonist as a character if defined
if (state.protagonist) {
const protagonist: Character = {
id: crypto.randomUUID(),
storyId: storyData.id,
name: state.protagonist.name ?? 'Protagonist',
description: state.protagonist.description ?? null,
relationship: 'self',
traits: state.protagonist.traits ?? [],
status: 'active',
metadata: null,
};
await database.addCharacter(protagonist);
}
// Add starting location if defined
if (state.startingLocation) {
const location: Location = {
id: crypto.randomUUID(),
storyId: storyData.id,
name: state.startingLocation.name ?? 'Starting Location',
description: state.startingLocation.description ?? null,
visited: true,
current: true,
connections: [],
metadata: null,
};
await database.addLocation(location);
}
// Add initial items if defined
if (state.initialItems) {
for (const itemData of state.initialItems) {
const item: Item = {
id: crypto.randomUUID(),
storyId: storyData.id,
name: itemData.name ?? 'Item',
description: itemData.description ?? null,
quantity: itemData.quantity ?? 1,
equipped: false,
location: 'inventory',
metadata: null,
};
await database.addItem(item);
}
}
// Add opening scene as first narration entry
if (state.openingScene) {
await database.addStoryEntry({
id: crypto.randomUUID(),
storyId: storyData.id,
type: 'narration',
content: state.openingScene,
parentId: null,
position: 0,
metadata: { source: 'template' },
});
}
}
return storyData;
}
// Add a new story entry
async addEntry(type: StoryEntry['type'], content: string, metadata?: StoryEntry['metadata']): Promise<StoryEntry> {
if (!this.currentStory) {
throw new Error('No story loaded');
}
const position = await database.getNextEntryPosition(this.currentStory.id);
const entry = await database.addStoryEntry({
id: crypto.randomUUID(),
storyId: this.currentStory.id,
type,
content,
parentId: null,
position,
metadata: metadata ?? null,
});
this.entries = [...this.entries, entry];
// Update story's updatedAt
await database.updateStory(this.currentStory.id, {});
return entry;
}
// Update a story entry
async updateEntry(entryId: string, content: string): Promise<void> {
if (!this.currentStory) throw new Error('No story loaded');
await database.updateStoryEntry(entryId, { content });
this.entries = this.entries.map(e =>
e.id === entryId ? { ...e, content } : e
);
// Update story's updatedAt
await database.updateStory(this.currentStory.id, {});
}
// Delete a story entry
async deleteEntry(entryId: string): Promise<void> {
if (!this.currentStory) throw new Error('No story loaded');
await database.deleteStoryEntry(entryId);
this.entries = this.entries.filter(e => e.id !== entryId);
// Update story's updatedAt
await database.updateStory(this.currentStory.id, {});
}
// Add a character
async addCharacter(name: string, description?: string, relationship?: string): Promise<Character> {
if (!this.currentStory) throw new Error('No story loaded');
const character: Character = {
id: crypto.randomUUID(),
storyId: this.currentStory.id,
name,
description: description ?? null,
relationship: relationship ?? null,
traits: [],
status: 'active',
metadata: null,
};
await database.addCharacter(character);
this.characters = [...this.characters, character];
return character;
}
// Add a location
async addLocation(name: string, description?: string, makeCurrent = false): Promise<Location> {
if (!this.currentStory) throw new Error('No story loaded');
const location: Location = {
id: crypto.randomUUID(),
storyId: this.currentStory.id,
name,
description: description ?? null,
visited: makeCurrent,
current: makeCurrent,
connections: [],
metadata: null,
};
await database.addLocation(location);
if (makeCurrent) {
// Update other locations to not be current
this.locations = this.locations.map(l => ({ ...l, current: false }));
}
this.locations = [...this.locations, location];
return location;
}
// Set current location
async setCurrentLocation(locationId: string): Promise<void> {
if (!this.currentStory) throw new Error('No story loaded');
await database.setCurrentLocation(this.currentStory.id, locationId);
this.locations = this.locations.map(l => ({
...l,
current: l.id === locationId,
visited: l.id === locationId ? true : l.visited,
}));
}
// Add an item to inventory
async addItem(name: string, description?: string, quantity = 1): Promise<Item> {
if (!this.currentStory) throw new Error('No story loaded');
const item: Item = {
id: crypto.randomUUID(),
storyId: this.currentStory.id,
name,
description: description ?? null,
quantity,
equipped: false,
location: 'inventory',
metadata: null,
};
await database.addItem(item);
this.items = [...this.items, item];
return item;
}
// Add a story beat
async addStoryBeat(title: string, type: StoryBeat['type'], description?: string): Promise<StoryBeat> {
if (!this.currentStory) throw new Error('No story loaded');
const beat: StoryBeat = {
id: crypto.randomUUID(),
storyId: this.currentStory.id,
title,
description: description ?? null,
type,
status: 'pending',
triggeredAt: null,
metadata: null,
};
await database.addStoryBeat(beat);
this.storyBeats = [...this.storyBeats, beat];
return beat;
}
// Clear current story (when switching or closing)
clearCurrentStory(): void {
this.currentStory = null;
this.entries = [];
this.characters = [];
this.locations = [];
this.items = [];
this.storyBeats = [];
}
// Delete a story
async deleteStory(storyId: string): Promise<void> {
await database.deleteStory(storyId);
this.allStories = this.allStories.filter(s => s.id !== storyId);
if (this.currentStory?.id === storyId) {
this.clearCurrentStory();
}
}
}
export const story = new StoryStore();

View file

@ -0,0 +1,59 @@
import type { ActivePanel, SidebarTab, UIState } from '$lib/types';
// UI State using Svelte 5 runes
class UIStore {
activePanel = $state<ActivePanel>('story');
sidebarTab = $state<SidebarTab>('characters');
sidebarOpen = $state(true);
settingsModalOpen = $state(false);
isGenerating = $state(false);
// Streaming state
streamingContent = $state('');
isStreaming = $state(false);
setActivePanel(panel: ActivePanel) {
this.activePanel = panel;
}
setSidebarTab(tab: SidebarTab) {
this.sidebarTab = tab;
}
toggleSidebar() {
this.sidebarOpen = !this.sidebarOpen;
}
openSettings() {
this.settingsModalOpen = true;
}
closeSettings() {
this.settingsModalOpen = false;
}
setGenerating(value: boolean) {
this.isGenerating = value;
}
// Streaming methods
startStreaming() {
this.isStreaming = true;
this.streamingContent = '';
}
appendStreamContent(content: string) {
this.streamingContent += content;
}
endStreaming() {
this.isStreaming = false;
this.streamingContent = '';
}
getStreamingContent(): string {
return this.streamingContent;
}
}
export const ui = new UIStore();

125
src/lib/types/index.ts Normal file
View file

@ -0,0 +1,125 @@
// Core entity types for Aventura
export interface Story {
id: string;
title: string;
description: string | null;
genre: string | null;
templateId: string | null;
createdAt: number;
updatedAt: number;
settings: StorySettings | null;
}
export interface StorySettings {
model?: string;
temperature?: number;
maxTokens?: number;
systemPromptOverride?: string;
}
export interface StoryEntry {
id: string;
storyId: string;
type: 'user_action' | 'narration' | 'system' | 'retry';
content: string;
parentId: string | null;
position: number;
createdAt: number;
metadata: EntryMetadata | null;
}
export interface EntryMetadata {
tokenCount?: number;
model?: string;
generationTime?: number;
source?: string;
}
export interface Character {
id: string;
storyId: string;
name: string;
description: string | null;
relationship: string | null;
traits: string[];
status: 'active' | 'inactive' | 'deceased';
metadata: Record<string, unknown> | null;
}
export interface Location {
id: string;
storyId: string;
name: string;
description: string | null;
visited: boolean;
current: boolean;
connections: string[];
metadata: Record<string, unknown> | null;
}
export interface Item {
id: string;
storyId: string;
name: string;
description: string | null;
quantity: number;
equipped: boolean;
location: string;
metadata: Record<string, unknown> | null;
}
export interface StoryBeat {
id: string;
storyId: string;
title: string;
description: string | null;
type: 'milestone' | 'quest' | 'revelation' | 'event';
status: 'pending' | 'active' | 'completed' | 'failed';
triggeredAt: number | null;
metadata: Record<string, unknown> | null;
}
export interface Template {
id: string;
name: string;
description: string | null;
genre: string | null;
systemPrompt: string;
initialState: TemplateInitialState | null;
isBuiltin: boolean;
createdAt: number;
}
export interface TemplateInitialState {
protagonist?: Partial<Character>;
startingLocation?: Partial<Location>;
initialItems?: Partial<Item>[];
openingScene?: string;
}
// UI State types
export type ActivePanel = 'story' | 'library' | 'settings' | 'templates';
export type SidebarTab = 'characters' | 'locations' | 'inventory' | 'quests';
export interface UIState {
activePanel: ActivePanel;
sidebarTab: SidebarTab;
sidebarOpen: boolean;
settingsModalOpen: boolean;
}
// API Settings
export interface APISettings {
openrouterApiKey: string | null;
defaultModel: string;
temperature: number;
maxTokens: number;
}
export interface UISettings {
theme: 'dark' | 'light';
fontSize: 'small' | 'medium' | 'large';
showWordCount: boolean;
autoSave: boolean;
}

View file

@ -0,0 +1,9 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
<div class="h-screen w-screen overflow-hidden">
{@render children()}
</div>

5
src/routes/+layout.ts Normal file
View file

@ -0,0 +1,5 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
export const ssr = false;

50
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,50 @@
<script lang="ts">
import { onMount } from 'svelte';
import { database } from '$lib/services/database';
import { settings } from '$lib/stores/settings.svelte';
import AppShell from '$lib/components/layout/AppShell.svelte';
let initialized = $state(false);
let error = $state<string | null>(null);
onMount(async () => {
try {
// Initialize database connection
await database.init();
// Initialize settings from database
await settings.init();
initialized = true;
} catch (e) {
console.error('Initialization error:', e);
error = e instanceof Error ? e.message : 'Failed to initialize application';
}
});
</script>
{#if error}
<div class="flex h-screen w-screen items-center justify-center bg-surface-900">
<div class="card max-w-md text-center">
<h1 class="text-xl font-semibold text-red-400">Initialization Error</h1>
<p class="mt-2 text-surface-400">{error}</p>
<button
class="btn btn-primary mt-4"
onclick={() => window.location.reload()}
>
Retry
</button>
</div>
</div>
{:else if !initialized}
<div class="flex h-screen w-screen items-center justify-center bg-surface-900">
<div class="flex flex-col items-center gap-4">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent"></div>
<p class="text-surface-400">Loading Aventura...</p>
</div>
</div>
{:else}
<AppShell>
<!-- Default slot content if needed -->
</AppShell>
{/if}

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

1
static/svelte.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

6
static/tauri.svg Normal file
View file

@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

1
static/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

18
svelte.config.js Normal file
View file

@ -0,0 +1,18 @@
// Tauri doesn't have a Node.js server to do proper SSR
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
// See: https://svelte.dev/docs/kit/single-page-apps
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
import adapter from "@sveltejs/adapter-static";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: "index.html",
}),
},
};
export default config;

45
tailwind.config.js Normal file
View file

@ -0,0 +1,45 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'class',
theme: {
extend: {
colors: {
// Custom dark theme colors
surface: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
850: '#141b25',
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',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
story: ['Georgia', 'Cambria', 'serif'],
},
},
},
plugins: [],
};

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

31
vite.config.js Normal file
View file

@ -0,0 +1,31 @@
import { defineConfig } from "vite";
import { sveltekit } from "@sveltejs/kit/vite";
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [sveltekit()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));