rename UI to aventuras

This commit is contained in:
munimunigamer 2026-01-19 17:11:24 -06:00
parent 2d82d6b0e4
commit 0b447bc87b
14 changed files with 635 additions and 380 deletions

4
.gitignore vendored
View file

@ -1,5 +1,5 @@
# =============================================================================
# Aventura .gitignore
# Aventuras .gitignore
# =============================================================================
# -----------------------------------------------------------------------------
@ -55,7 +55,7 @@ src-tauri/Cargo.lock.bak
*.sqlite
*.sqlite3
# Aventura story files (user data, not source code)
# Aventuras story files (user data, not source code)
*.avt
# -----------------------------------------------------------------------------

View file

@ -1,6 +1,6 @@
# AGENTS.md
This file contains guidelines for agentic coding assistants working on the Aventura codebase.
This file contains guidelines for agentic coding assistants working on the Aventuras codebase.
## Build/Lint/Test Commands

View file

@ -1,6 +1,6 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Aventura",
"productName": "Aventuras",
"version": "0.5.0",
"identifier": "com.karelian.aventura",
"build": {
@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "Aventura",
"title": "Aventuras",
"width": 1280,
"height": 800,
"minWidth": 800,
@ -43,9 +43,9 @@
"plugins": {
"updater": {
"endpoints": [
"https://github.com/unkarelian/Aventura/releases/latest/download/latest.json"
"https://github.com/unkarelian/Aventuras/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDJCNUQyM0I1NzM5MEQ2MDgKUldRSTFwQnp0U05kSzdwVUV5dEYvc3ZaaS9ZWGJDcytQaERmWDhNSGJ0OTdHVlE1UkttUW5NTy8K"
}
}
}
}

View file

@ -1,16 +1,21 @@
<!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>
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Aventuras</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

@ -82,7 +82,7 @@
}
}
async function exportAventura() {
async function exportAventuras() {
if (!story.currentStory) return;
const [
entries,
@ -109,7 +109,7 @@
]);
await handleExport(
() =>
exportService.exportToAventura(
exportService.exportToAventuras(
story.currentStory,
entries,
characters,
@ -122,7 +122,7 @@
branches,
chapters,
),
"Aventura (.avt)",
"Aventuras (.avt)",
);
}
@ -157,7 +157,7 @@
<div class="flex items-center min-w-0">
<div class="flex items-center gap-3 px-2.5 sm:px-1">
{#if story.currentStory}
<img src="/logo.png" alt="Aventura" class="h-7 w-7 flex-shrink-0" />
<img src="/logo.png" alt="Aventuras" class="h-7 w-7 flex-shrink-0" />
<span
class="font-semibold text-surface-100 text-sm sm:text-base truncate max-w-[160px] sm:max-w-none"
>
@ -170,8 +170,8 @@
{/if}
{:else}
<!-- App Branding (Library Mode) -->
<img src="/logo.png" alt="Aventura" class="h-7 w-7 flex-shrink-0" />
<span class="font-semibold text-surface-100 text-lg">Aventura</span>
<img src="/logo.png" alt="Aventuras" class="h-7 w-7 flex-shrink-0" />
<span class="font-semibold text-surface-100 text-lg">Aventuras</span>
{/if}
</div>
</div>
@ -245,11 +245,11 @@
class="flex w-full items-center gap-2 px-3 py-3 sm:py-2 text-left text-sm text-surface-300 hover:bg-surface-700 min-h-[44px] pointer-events-auto cursor-pointer"
onclick={(e) => {
e.stopPropagation();
exportAventura();
exportAventuras();
}}
>
<FileJson class="h-4 w-4 text-accent-400" />
Aventura (.avt)
Aventuras (.avt)
</button>
<button
class="flex w-full items-center gap-2 px-3 py-3 sm:py-2 text-left text-sm text-surface-300 hover:bg-surface-700 min-h-[44px] pointer-events-auto cursor-pointer"

View file

@ -1,17 +1,24 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte';
import { story } from '$lib/stores/story.svelte';
import { ui } from "$lib/stores/ui.svelte";
import { story } from "$lib/stores/story.svelte";
import {
parseLorebook,
classifyEntriesWithLLM,
convertToEntries,
type ImportedEntry,
type LorebookImportResult
} from '$lib/services/lorebookImporter';
import { database } from '$lib/services/database';
import { X, Upload, FileJson, Loader2, Check, AlertCircle } from 'lucide-svelte';
import { swipe } from '$lib/utils/swipe';
import type { Entry } from '$lib/types';
type LorebookImportResult,
} from "$lib/services/lorebookImporter";
import { database } from "$lib/services/database";
import {
X,
Upload,
FileJson,
Loader2,
Check,
AlertCircle,
} from "lucide-svelte";
import { swipe } from "$lib/utils/swipe";
import type { Entry } from "$lib/types";
let fileInput: HTMLInputElement;
let dragOver = $state(false);
@ -27,10 +34,13 @@
// Type counts for preview
const typeCounts = $derived.by(() => {
if (!parseResult) return {};
return parseResult.entries.reduce((acc, e) => {
acc[e.type] = (acc[e.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
return parseResult.entries.reduce(
(acc, e) => {
acc[e.type] = (acc[e.type] || 0) + 1;
return acc;
},
{} as Record<string, number>,
);
});
function handleFileSelect(e: Event) {
@ -65,8 +75,8 @@
// Check file extension (case-insensitive)
const fileName = file.name.toLowerCase();
if (!fileName.endsWith('.json') && !fileName.endsWith('.avt')) {
error = 'Please select a JSON or Aventura file (.json or .avt)';
if (!fileName.endsWith(".json") && !fileName.endsWith(".avt")) {
error = "Please select a JSON or Aventuras file (.json or .avt)";
return;
}
@ -75,20 +85,21 @@
const result = parseLorebook(text);
if (!result.success) {
error = result.errors.length > 0
? result.errors.join(', ')
: 'Invalid lorebook file format';
error =
result.errors.length > 0
? result.errors.join(", ")
: "Invalid lorebook file format";
return;
}
if (result.entries.length === 0) {
error = 'No valid entries found in this lorebook file';
error = "No valid entries found in this lorebook file";
return;
}
parseResult = result;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to read file';
error = err instanceof Error ? err.message : "Failed to read file";
}
}
@ -105,14 +116,14 @@
(progress) => {
classificationProgress = progress;
},
story.currentStory?.mode ?? 'adventure'
story.currentStory?.mode ?? "adventure",
);
parseResult = {
...parseResult,
entries: classified,
};
} catch (err) {
error = err instanceof Error ? err.message : 'Classification failed';
error = err instanceof Error ? err.message : "Classification failed";
} finally {
classifying = false;
}
@ -135,13 +146,13 @@
(progress) => {
classificationProgress = progress;
},
story.currentStory?.mode ?? 'adventure'
story.currentStory?.mode ?? "adventure",
);
classifying = false;
}
// Convert to Entry format
const entries = convertToEntries(entriesToImport, 'import');
const entries = convertToEntries(entriesToImport, "import");
// Add each entry to the database
const storyId = story.currentStory.id;
@ -161,7 +172,7 @@
// Close modal
ui.closeLorebookImport();
} catch (err) {
error = err instanceof Error ? err.message : 'Import failed';
error = err instanceof Error ? err.message : "Import failed";
} finally {
importing = false;
classifying = false;
@ -181,7 +192,7 @@
<div
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60"
onclick={close}
onkeydown={(e) => e.key === 'Escape' && close()}
onkeydown={(e) => e.key === "Escape" && close()}
role="dialog"
aria-modal="true"
tabindex="-1"
@ -199,7 +210,9 @@
</div>
<!-- Header -->
<div class="flex items-center justify-between border-b border-surface-700 pb-4 pt-0 sm:pt-0">
<div
class="flex items-center justify-between border-b border-surface-700 pb-4 pt-0 sm:pt-0"
>
<div class="flex items-center gap-2">
<Upload class="h-5 w-5 text-accent-400" />
<h2 class="text-xl font-semibold text-surface-100">Import Lorebook</h2>
@ -212,7 +225,9 @@
<!-- Content -->
<div class="py-4 space-y-4 max-h-[60vh] overflow-y-auto">
{#if error}
<div class="p-3 rounded-lg bg-red-500/20 border border-red-500/50 flex items-start gap-2">
<div
class="p-3 rounded-lg bg-red-500/20 border border-red-500/50 flex items-start gap-2"
>
<AlertCircle class="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
<p class="text-red-300 text-sm">{error}</p>
</div>
@ -222,14 +237,16 @@
<!-- File upload area -->
<div
class="border-2 border-dashed rounded-lg p-8 text-center transition-colors
{dragOver ? 'border-accent-500 bg-accent-500/10' : 'border-surface-600 hover:border-surface-500'}"
{dragOver
? 'border-accent-500 bg-accent-500/10'
: 'border-surface-600 hover:border-surface-500'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
role="button"
tabindex="0"
onclick={() => fileInput.click()}
onkeydown={(e) => e.key === 'Enter' && fileInput.click()}
onkeydown={(e) => e.key === "Enter" && fileInput.click()}
>
<FileJson class="h-12 w-12 text-surface-500 mx-auto mb-3" />
<p class="text-surface-300 mb-1">Drop a lorebook file here</p>
@ -244,7 +261,7 @@
/>
<p class="text-xs text-surface-500">
Supports Aventura (.avt, .json) and SillyTavern lorebook formats
Supports Aventuras (.avt, .json) and SillyTavern lorebook formats
</p>
{:else}
<!-- Preview -->
@ -266,10 +283,14 @@
</div>
<!-- AI Classification toggle -->
<label class="flex items-center justify-between p-3 rounded-lg bg-surface-800/50 border border-surface-700 cursor-pointer">
<label
class="flex items-center justify-between p-3 rounded-lg bg-surface-800/50 border border-surface-700 cursor-pointer"
>
<div>
<div class="text-surface-200">AI-powered classification</div>
<div class="text-xs text-surface-500">Use AI to better categorize entry types</div>
<div class="text-xs text-surface-500">
Use AI to better categorize entry types
</div>
</div>
<input
type="checkbox"
@ -279,10 +300,14 @@
</label>
{#if classifying}
<div class="p-3 rounded-lg bg-surface-800/50 border border-surface-700">
<div
class="p-3 rounded-lg bg-surface-800/50 border border-surface-700"
>
<div class="flex items-center gap-2 mb-2">
<Loader2 class="h-4 w-4 text-accent-400 animate-spin" />
<span class="text-sm text-surface-300">Classifying entries...</span>
<span class="text-sm text-surface-300"
>Classifying entries...</span
>
</div>
<div class="w-full h-2 bg-surface-700 rounded-full overflow-hidden">
<div
@ -296,7 +321,10 @@
<!-- Change file button -->
<button
class="text-sm text-accent-400 hover:text-accent-300"
onclick={() => { parseResult = null; error = null; }}
onclick={() => {
parseResult = null;
error = null;
}}
>
Choose a different file
</button>
@ -319,7 +347,7 @@
>
{#if importing || classifying}
<Loader2 class="h-4 w-4 animate-spin" />
{classifying ? 'Classifying...' : 'Importing...'}
{classifying ? "Classifying..." : "Importing..."}
{:else}
Import {previewCount} Entries
{/if}

View file

@ -503,7 +503,8 @@
placeholder={'{"temperature": 0.7, "top_p": 0.9}'}
></textarea>
<p class="text-xs text-surface-500 mt-1">
Overrides request parameters; messages and tools are managed by Aventura.
Overrides request parameters; messages and tools are managed by
Aventuras.
</p>
</div>
{/if}

View file

@ -1,24 +1,28 @@
<script lang="ts">
import { settings } from '$lib/stores/settings.svelte';
import { Cpu, RefreshCw } from 'lucide-svelte';
import ProviderOnlySelector from './ProviderOnlySelector.svelte';
import type { ProviderInfo } from '$lib/services/ai/types';
import { OpenAIProvider } from '$lib/services/ai/openrouter';
import type { ReasoningEffort } from '$lib/types';
import { settings } from "$lib/stores/settings.svelte";
import { Cpu, RefreshCw } from "lucide-svelte";
import ProviderOnlySelector from "./ProviderOnlySelector.svelte";
import type { ProviderInfo } from "$lib/services/ai/types";
import { OpenAIProvider } from "$lib/services/ai/openrouter";
import type { ReasoningEffort } from "$lib/types";
interface Props {
providerOptions: ProviderInfo[];
onOpenManualBodyEditor: (title: string, value: string, onSave: (next: string) => void) => void;
onOpenManualBodyEditor: (
title: string,
value: string,
onSave: (next: string) => void,
) => void;
}
let { providerOptions, onOpenManualBodyEditor }: Props = $props();
const reasoningLevels: ReasoningEffort[] = ['off', 'low', 'medium', 'high'];
const reasoningLevels: ReasoningEffort[] = ["off", "low", "medium", "high"];
const reasoningLabels: Record<ReasoningEffort, string> = {
off: 'Off',
low: 'Low',
medium: 'Medium',
high: 'High',
off: "Off",
low: "Low",
medium: "Medium",
high: "High",
};
let isLoadingModels = $state(false);
@ -28,21 +32,23 @@
let profileModels = $derived.by(() => {
const profile = settings.getMainNarrativeProfile();
if (!profile) return [];
const models = [...new Set([...profile.fetchedModels, ...profile.customModels])];
const models = [
...new Set([...profile.fetchedModels, ...profile.customModels]),
];
const providerPriority: Record<string, number> = {
'x-ai': 1,
'deepseek': 2,
'openai': 3,
'anthropic': 4,
'google': 5,
'meta-llama': 6,
'mistralai': 7,
"x-ai": 1,
deepseek: 2,
openai: 3,
anthropic: 4,
google: 5,
"meta-llama": 6,
mistralai: 7,
};
return models.sort((a, b) => {
const providerA = a.split('/')[0];
const providerB = b.split('/')[0];
const providerA = a.split("/")[0];
const providerB = b.split("/")[0];
const priorityA = providerPriority[providerA] ?? 99;
const priorityB = providerPriority[providerB] ?? 99;
@ -52,7 +58,7 @@
});
function getReasoningIndex(value?: ReasoningEffort): number {
const index = reasoningLevels.indexOf(value ?? 'off');
const index = reasoningLevels.indexOf(value ?? "off");
return index === -1 ? 0 : index;
}
@ -79,23 +85,31 @@
const fetchedModels = await provider.listModels();
const filteredModelIds = fetchedModels
.filter(m => {
.filter((m) => {
const id = m.id.toLowerCase();
if (id.includes('embedding') || id.includes('vision-only') || id.includes('tts') || id.includes('whisper')) {
if (
id.includes("embedding") ||
id.includes("vision-only") ||
id.includes("tts") ||
id.includes("whisper")
) {
return false;
}
return true;
})
.map(m => m.id);
.map((m) => m.id);
await settings.updateProfile(profile.id, {
fetchedModels: filteredModelIds,
});
console.log(`[MainNarrative] Fetched ${filteredModelIds.length} models to profile`);
console.log(
`[MainNarrative] Fetched ${filteredModelIds.length} models to profile`,
);
} catch (error) {
console.error('[MainNarrative] Failed to fetch models:', error);
modelError = error instanceof Error ? error.message : 'Failed to load models.';
console.error("[MainNarrative] Failed to fetch models:", error);
modelError =
error instanceof Error ? error.message : "Failed to load models.";
} finally {
isLoadingModels = false;
}
@ -107,7 +121,7 @@
<Cpu class="h-5 w-5 text-accent-400" />
<h3 class="text-sm font-semibold text-surface-100">Main Narrative</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- API Endpoint -->
<div>
@ -122,7 +136,8 @@
{#each settings.apiSettings.profiles as profile (profile.id)}
<option value={profile.id}>
{profile.name}
{#if profile.id === settings.getDefaultProfileIdForProvider()} (Default){/if}
{#if profile.id === settings.getDefaultProfileIdForProvider()}
(Default){/if}
</option>
{/each}
</select>
@ -131,21 +146,19 @@
<!-- Model Select -->
<div>
<div class="mb-1.5 flex items-center justify-between">
<label class="text-xs font-medium text-surface-400">
Model
</label>
<label class="text-xs font-medium text-surface-400"> Model </label>
<button
class="flex items-center gap-1 text-xs text-accent-400 hover:text-accent-300 disabled:opacity-50"
onclick={fetchModelsToProfile}
disabled={isLoadingModels}
>
<span class={isLoadingModels ? 'animate-spin' : ''}>
<span class={isLoadingModels ? "animate-spin" : ""}>
<RefreshCw class="h-3 w-3" />
</span>
Refresh
</button>
</div>
{#if modelError}
<p class="mb-1 text-xs text-amber-400">{modelError}</p>
{/if}
@ -175,7 +188,10 @@
</div>
<!-- Temperature & Max Tokens Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4 pt-4 border-t border-surface-700" class:opacity-50={settings.advancedRequestSettings.manualMode}>
<div
class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4 pt-4 border-t border-surface-700"
class:opacity-50={settings.advancedRequestSettings.manualMode}
>
<div>
<label class="mb-1.5 block text-xs font-medium text-surface-400">
Temperature: {settings.apiSettings.temperature.toFixed(1)}
@ -186,7 +202,8 @@
max="1"
step="0.1"
value={settings.apiSettings.temperature}
oninput={(e) => settings.setTemperature(parseFloat(e.currentTarget.value))}
oninput={(e) =>
settings.setTemperature(parseFloat(e.currentTarget.value))}
disabled={settings.advancedRequestSettings.manualMode}
class="w-full"
/>
@ -229,7 +246,10 @@
</div>
<!-- Thinking & Provider Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4 pt-4 border-t border-surface-700" class:opacity-50={settings.advancedRequestSettings.manualMode}>
<div
class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4 pt-4 border-t border-surface-700"
class:opacity-50={settings.advancedRequestSettings.manualMode}
>
<div>
<label class="mb-1.5 block text-xs font-medium text-surface-400">
Thinking: {reasoningLabels[settings.apiSettings.reasoningEffort]}
@ -240,7 +260,10 @@
max="3"
step="1"
value={getReasoningIndex(settings.apiSettings.reasoningEffort)}
onchange={(e) => settings.setMainReasoningEffort(getReasoningValue(parseInt(e.currentTarget.value)))}
onchange={(e) =>
settings.setMainReasoningEffort(
getReasoningValue(parseInt(e.currentTarget.value)),
)}
disabled={settings.advancedRequestSettings.manualMode}
class="w-full"
/>
@ -267,25 +290,34 @@
{#if settings.advancedRequestSettings.manualMode}
<div class="mt-4 pt-4 border-t border-surface-700">
<div class="mb-1 flex items-center justify-between">
<label class="text-xs font-medium text-surface-400">Manual Request Body (JSON)</label>
<label class="text-xs font-medium text-surface-400"
>Manual Request Body (JSON)</label
>
<button
class="text-xs text-accent-400 hover:text-accent-300"
onclick={() => onOpenManualBodyEditor('Main Narrative', settings.apiSettings.manualBody, (next) => {
settings.apiSettings.manualBody = next;
settings.setMainManualBody(next);
})}
onclick={() =>
onOpenManualBodyEditor(
"Main Narrative",
settings.apiSettings.manualBody,
(next) => {
settings.apiSettings.manualBody = next;
settings.setMainManualBody(next);
},
)}
>
Pop out
</button>
</div>
<textarea
bind:value={settings.apiSettings.manualBody}
onblur={() => settings.setMainManualBody(settings.apiSettings.manualBody)}
onblur={() =>
settings.setMainManualBody(settings.apiSettings.manualBody)}
class="input text-xs min-h-[100px] resize-y font-mono w-full"
rows="4"
></textarea>
<p class="text-xs text-surface-500 mt-1">
Overrides request parameters; messages and tools are managed by Aventura.
Overrides request parameters; messages and tools are managed by
Aventuras.
</p>
</div>
{/if}

View file

@ -1,11 +1,11 @@
<script lang="ts">
import { settings } from '$lib/stores/settings.svelte';
import { promptExportService } from '$lib/services/promptExport';
import { settings } from "$lib/stores/settings.svelte";
import { promptExportService } from "$lib/services/promptExport";
import type {
ParsedPromptImport,
ImportPresetConfig,
ReasoningEffort,
} from '$lib/types';
} from "$lib/types";
import {
X,
Upload,
@ -16,10 +16,10 @@
AlertTriangle,
ChevronDown,
Sparkles,
} from 'lucide-svelte';
import { swipe } from '$lib/utils/swipe';
import { fly, fade, slide } from 'svelte/transition';
import CompactSelect from './CompactSelect.svelte';
} from "lucide-svelte";
import { swipe } from "$lib/utils/swipe";
import { fly, fade, slide } from "svelte/transition";
import CompactSelect from "./CompactSelect.svelte";
interface Props {
open: boolean;
@ -41,11 +41,13 @@
// Derived
const availableProfiles = $derived(
settings.apiSettings.profiles.map(p => ({ id: p.id, name: p.name }))
settings.apiSettings.profiles.map((p) => ({ id: p.id, name: p.name })),
);
function getAvailableModelsForProfile(profileId: string): string[] {
const profile = settings.apiSettings.profiles.find(p => p.id === profileId);
const profile = settings.apiSettings.profiles.find(
(p) => p.id === profileId,
);
if (!profile) return [];
return [...profile.customModels, ...profile.fetchedModels];
}
@ -61,7 +63,7 @@
};
});
const stepTitles = ['Select File', 'Configure', 'Import'];
const stepTitles = ["Select File", "Configure", "Import"];
// Functions
function resetState() {
@ -105,8 +107,8 @@
error = null;
parseResult = null;
if (!file.name.endsWith('.json')) {
error = 'Please select a JSON file';
if (!file.name.endsWith(".json")) {
error = "Please select a JSON file";
return;
}
@ -115,7 +117,7 @@
const result = promptExportService.parseImportFile(text);
if (!result.success) {
error = result.errors.join(', ');
error = result.errors.join(", ");
return;
}
@ -123,7 +125,7 @@
const configs = new Map<string, ImportPresetConfig>();
const profiles = settings.apiSettings.profiles;
const defaultProfileId = profiles.length > 0 ? profiles[0].id : '';
const defaultProfileId = profiles.length > 0 ? profiles[0].id : "";
for (const preset of result.data!.generationPresets) {
configs.set(preset.id, {
@ -141,11 +143,14 @@
presetConfigs = configs;
currentStep = 2;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to read file';
error = err instanceof Error ? err.message : "Failed to read file";
}
}
function updatePresetConfig(presetId: string, updates: Partial<ImportPresetConfig>) {
function updatePresetConfig(
presetId: string,
updates: Partial<ImportPresetConfig>,
) {
const newConfigs = new Map(presetConfigs);
const config = newConfigs.get(presetId)!;
newConfigs.set(presetId, { ...config, ...updates });
@ -171,7 +176,7 @@
handleClose();
}, 2000);
} catch (err) {
error = err instanceof Error ? err.message : 'Import failed';
error = err instanceof Error ? err.message : "Import failed";
} finally {
isImporting = false;
}
@ -183,7 +188,7 @@
<div
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center"
onclick={handleClose}
onkeydown={(e) => e.key === 'Escape' && handleClose()}
onkeydown={(e) => e.key === "Escape" && handleClose()}
role="dialog"
aria-modal="true"
tabindex="-1"
@ -215,8 +220,12 @@
<Upload class="h-5 w-5 text-accent-400" />
</div>
<div>
<h2 class="text-lg font-semibold text-surface-100">Import Prompts</h2>
<p class="text-xs text-surface-500">{stepTitles[currentStep - 1]}</p>
<h2 class="text-lg font-semibold text-surface-100">
Import Prompts
</h2>
<p class="text-xs text-surface-500">
{stepTitles[currentStep - 1]}
</p>
</div>
</div>
<button
@ -232,10 +241,12 @@
{#each [1, 2, 3] as step}
<div class="flex-1 flex items-center gap-2">
<div
class="h-1.5 flex-1 rounded-full transition-all duration-300 {
step < currentStep ? 'bg-accent-500' :
step === currentStep ? 'bg-accent-500' : 'bg-surface-700'
}"
class="h-1.5 flex-1 rounded-full transition-all duration-300 {step <
currentStep
? 'bg-accent-500'
: step === currentStep
? 'bg-accent-500'
: 'bg-surface-700'}"
></div>
</div>
{/each}
@ -246,7 +257,10 @@
<div class="flex-1 overflow-y-auto px-5 sm:px-6 pb-4">
<!-- Error Message -->
{#if error}
<div class="mb-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 flex items-start gap-3" transition:fly={{ y: -10, duration: 200 }}>
<div
class="mb-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 flex items-start gap-3"
transition:fly={{ y: -10, duration: 200 }}
>
<AlertCircle class="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
<p class="text-red-300 text-sm">{error}</p>
</div>
@ -254,8 +268,12 @@
<!-- Warnings -->
{#if parseResult?.warnings && parseResult.warnings.length > 0}
<div class="mb-4 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-start gap-3">
<AlertTriangle class="h-5 w-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div
class="mb-4 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-start gap-3"
>
<AlertTriangle
class="h-5 w-5 text-amber-400 flex-shrink-0 mt-0.5"
/>
<div class="text-amber-300 text-sm">
{#each parseResult.warnings as warning}
<p>{warning}</p>
@ -268,23 +286,23 @@
{#if currentStep === 1}
<div class="space-y-4" transition:fade={{ duration: 150 }}>
<div
class="border-2 border-dashed rounded-2xl p-8 sm:p-10 text-center transition-all cursor-pointer {
dragOver
? 'border-accent-500 bg-accent-500/5 scale-[1.02]'
: 'border-surface-600 hover:border-surface-500 hover:bg-surface-800/30'
}"
class="border-2 border-dashed rounded-2xl p-8 sm:p-10 text-center transition-all cursor-pointer {dragOver
? 'border-accent-500 bg-accent-500/5 scale-[1.02]'
: 'border-surface-600 hover:border-surface-500 hover:bg-surface-800/30'}"
ondrop={handleDrop}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
role="button"
tabindex="0"
onclick={() => fileInput.click()}
onkeydown={(e) => e.key === 'Enter' && fileInput.click()}
onkeydown={(e) => e.key === "Enter" && fileInput.click()}
>
<div class="inline-flex p-4 rounded-2xl bg-surface-800/50 mb-4">
<FileJson class="h-10 w-10 text-surface-400" />
</div>
<p class="text-surface-200 font-medium mb-1">Drop your file here</p>
<p class="text-surface-200 font-medium mb-1">
Drop your file here
</p>
<p class="text-sm text-surface-500">or click to browse</p>
</div>
<input
@ -295,30 +313,38 @@
onchange={handleFileSelect}
/>
<p class="text-center text-xs text-surface-500">
Supports Aventura prompt export files (.json)
Supports Aventuras prompt export files (.json)
</p>
</div>
<!-- Step 2: Configure Presets -->
<!-- Step 2: Configure Presets -->
{:else if currentStep === 2 && parseResult?.data}
<div class="space-y-4" transition:fade={{ duration: 150 }}>
<!-- Stats Summary -->
{#if importStats}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div class="p-3 rounded-xl bg-surface-800/50 text-center">
<div class="text-lg font-semibold text-surface-100">{importStats.templateOverrides}</div>
<div class="text-lg font-semibold text-surface-100">
{importStats.templateOverrides}
</div>
<div class="text-xs text-surface-500">Prompts</div>
</div>
<div class="p-3 rounded-xl bg-surface-800/50 text-center">
<div class="text-lg font-semibold text-surface-100">{importStats.customMacros}</div>
<div class="text-lg font-semibold text-surface-100">
{importStats.customMacros}
</div>
<div class="text-xs text-surface-500">Macros</div>
</div>
<div class="p-3 rounded-xl bg-surface-800/50 text-center">
<div class="text-lg font-semibold text-surface-100">{importStats.macroOverrides}</div>
<div class="text-lg font-semibold text-surface-100">
{importStats.macroOverrides}
</div>
<div class="text-xs text-surface-500">Overrides</div>
</div>
<div class="p-3 rounded-xl bg-surface-800/50 text-center">
<div class="text-lg font-semibold text-surface-100">{importStats.presets}</div>
<div class="text-lg font-semibold text-surface-100">
{importStats.presets}
</div>
<div class="text-xs text-surface-500">Presets</div>
</div>
</div>
@ -327,50 +353,78 @@
<!-- Presets Configuration -->
<div>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-medium text-surface-300">Generation Presets</h3>
<h3 class="text-sm font-medium text-surface-300">
Generation Presets
</h3>
</div>
<div class="space-y-2">
{#each parseResult.data.generationPresets as preset (preset.id)}
{@const config = presetConfigs.get(preset.id)!}
{@const isExpanded = expandedPreset === preset.id}
{@const availableModels = getAvailableModelsForProfile(config.profileId)}
{@const availableModels = getAvailableModelsForProfile(
config.profileId,
)}
<div class="rounded-xl border border-surface-700 bg-surface-800/30 transition-all">
<div
class="rounded-xl border border-surface-700 bg-surface-800/30 transition-all"
>
<!-- Preset Header with Profile & Model (always visible) -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="w-full px-3 py-2.5 flex flex-col gap-2 text-left hover:bg-surface-800/50 transition-colors cursor-pointer rounded-t-xl {isExpanded ? '' : 'rounded-b-xl'} focus:outline-none"
class="w-full px-3 py-2.5 flex flex-col gap-2 text-left hover:bg-surface-800/50 transition-colors cursor-pointer rounded-t-xl {isExpanded
? ''
: 'rounded-b-xl'} focus:outline-none"
onclick={() => togglePresetExpanded(preset.id)}
role="button"
tabindex="0"
>
<!-- Header Row (Icon + Name + Original Model) -->
<div class="flex items-center gap-3 w-full min-w-0">
<Sparkles class="h-4 w-4 text-accent-400 flex-shrink-0" />
<div class="flex-1 min-w-0 flex items-center gap-2 overflow-hidden">
<span class="text-sm font-medium text-surface-100 truncate">{preset.name}</span>
<span class="text-xs text-surface-500 truncate border-l border-surface-700 pl-2">
Was: <span class="text-surface-400">{preset.model}</span>
<Sparkles
class="h-4 w-4 text-accent-400 flex-shrink-0"
/>
<div
class="flex-1 min-w-0 flex items-center gap-2 overflow-hidden"
>
<span
class="text-sm font-medium text-surface-100 truncate"
>{preset.name}</span
>
<span
class="text-xs text-surface-500 truncate border-l border-surface-700 pl-2"
>
Was: <span class="text-surface-400"
>{preset.model}</span
>
</span>
</div>
<ChevronDown class="h-4 w-4 text-surface-400 transition-transform flex-shrink-0 {isExpanded ? 'rotate-180' : ''}" />
<ChevronDown
class="h-4 w-4 text-surface-400 transition-transform flex-shrink-0 {isExpanded
? 'rotate-180'
: ''}"
/>
</div>
<!-- Controls Row -->
<div class="flex items-center gap-2 w-full">
<!-- Profile Select -->
<CompactSelect
value={config.profileId}
options={availableProfiles.map(p => ({ value: p.id, label: p.name }))}
options={availableProfiles.map((p) => ({
value: p.id,
label: p.name,
}))}
class="flex-1"
onSelect={(newProfileId) => {
if (!newProfileId) return;
const models = getAvailableModelsForProfile(newProfileId);
const newModel = models.length > 0 ? models[0] : config.model;
const models =
getAvailableModelsForProfile(newProfileId);
const newModel =
models.length > 0 ? models[0] : config.model;
updatePresetConfig(preset.id, {
profileId: newProfileId,
model: newModel
model: newModel,
});
}}
/>
@ -379,14 +433,18 @@
<CompactSelect
value={config.model}
options={availableModels.length > 0
? availableModels.map(m => ({ value: m, label: m }))
: [{ value: null, label: 'No models' }]
}
? availableModels.map((m) => ({
value: m,
label: m,
}))
: [{ value: null, label: "No models" }]}
class="flex-1"
placeholder="Select model"
onSelect={(newModel) => {
if (newModel) {
updatePresetConfig(preset.id, { model: newModel });
updatePresetConfig(preset.id, {
model: newModel,
});
}
}}
/>
@ -395,12 +453,16 @@
<!-- Expanded Settings -->
{#if isExpanded}
<div class="px-3 pb-3 pt-2 border-t border-surface-700/30" transition:slide={{ duration: 200 }}>
<div
class="px-3 pb-3 pt-2 border-t border-surface-700/30"
transition:slide={{ duration: 200 }}
>
<div class="grid grid-cols-2 gap-2">
<!-- Temperature -->
<div>
<label class="text-xs text-surface-500 mb-1 block">Temperature</label>
<label class="text-xs text-surface-500 mb-1 block"
>Temperature</label
>
<input
type="number"
step="0.1"
@ -408,36 +470,51 @@
max="2"
class="w-full px-2 py-1.5 rounded-md bg-surface-700/50 border border-surface-600 text-surface-100 text-sm focus:border-accent-500 focus:ring-1 focus:ring-accent-500 transition-colors"
value={config.temperature}
oninput={(e) => updatePresetConfig(preset.id, { temperature: parseFloat(e.currentTarget.value) || 0 })}
oninput={(e) =>
updatePresetConfig(preset.id, {
temperature:
parseFloat(e.currentTarget.value) || 0,
})}
/>
</div>
<!-- Max Tokens -->
<div>
<label class="text-xs text-surface-500 mb-1 block">Max Tokens</label>
<label class="text-xs text-surface-500 mb-1 block"
>Max Tokens</label
>
<input
type="number"
min="1"
class="w-full px-2 py-1.5 rounded-md bg-surface-700/50 border border-surface-600 text-surface-100 text-sm focus:border-accent-500 focus:ring-1 focus:ring-accent-500 transition-colors"
value={config.maxTokens}
oninput={(e) => updatePresetConfig(preset.id, { maxTokens: parseInt(e.currentTarget.value) || 1 })}
oninput={(e) =>
updatePresetConfig(preset.id, {
maxTokens:
parseInt(e.currentTarget.value) || 1,
})}
/>
</div>
<!-- Reasoning Effort -->
<div class="col-span-2">
<label class="text-xs text-surface-500 mb-1 block">Reasoning</label>
<label class="text-xs text-surface-500 mb-1 block"
>Reasoning</label
>
<div class="grid grid-cols-4 gap-1">
{#each ['off', 'low', 'medium', 'high'] as level}
{#each ["off", "low", "medium", "high"] as level}
<button
class="py-1.5 px-2 rounded-md text-xs font-medium transition-colors {
config.reasoningEffort === level
? 'bg-accent-500 text-white'
: 'bg-surface-700/50 text-surface-400 hover:bg-surface-700'
}"
onclick={() => updatePresetConfig(preset.id, { reasoningEffort: level as ReasoningEffort })}
class="py-1.5 px-2 rounded-md text-xs font-medium transition-colors {config.reasoningEffort ===
level
? 'bg-accent-500 text-white'
: 'bg-surface-700/50 text-surface-400 hover:bg-surface-700'}"
onclick={() =>
updatePresetConfig(preset.id, {
reasoningEffort: level as ReasoningEffort,
})}
>
{level.charAt(0).toUpperCase() + level.slice(1)}
{level.charAt(0).toUpperCase() +
level.slice(1)}
</button>
{/each}
</div>
@ -451,29 +528,39 @@
</div>
<!-- Warning -->
<div class="p-3 rounded-xl bg-amber-500/5 border border-amber-500/20 flex items-center gap-3">
<div
class="p-3 rounded-xl bg-amber-500/5 border border-amber-500/20 flex items-center gap-3"
>
<AlertTriangle class="h-5 w-5 text-amber-400 flex-shrink-0" />
<p class="text-xs text-amber-300/80">
Importing will <span class="font-medium text-amber-300">replace</span> all current prompts, macros, and presets.
Importing will <span class="font-medium text-amber-300"
>replace</span
> all current prompts, macros, and presets.
</p>
</div>
</div>
<!-- Step 3: Success -->
<!-- Step 3: Success -->
{:else if currentStep === 3}
<div class="py-8 text-center" transition:fade={{ duration: 150 }}>
<div class="inline-flex p-4 rounded-full bg-green-500/10 mb-4">
<Check class="h-10 w-10 text-green-400" />
</div>
<h3 class="text-xl font-semibold text-surface-100 mb-2">Import Complete!</h3>
<p class="text-surface-400">Your prompts have been imported successfully.</p>
<h3 class="text-xl font-semibold text-surface-100 mb-2">
Import Complete!
</h3>
<p class="text-surface-400">
Your prompts have been imported successfully.
</p>
</div>
{/if}
</div>
<!-- Footer -->
{#if currentStep !== 3}
<div class="px-5 sm:px-6 py-4 border-t border-surface-700/50 bg-surface-800/30">
<div
class="px-5 sm:px-6 py-4 border-t border-surface-700/50 bg-surface-800/30"
>
<div class="flex gap-3">
{#if currentStep === 1}
<button
@ -485,7 +572,10 @@
{:else if currentStep === 2}
<button
class="py-2.5 px-4 rounded-xl text-sm font-medium text-surface-300 bg-surface-700/50 hover:bg-surface-700 transition-colors"
onclick={() => { currentStep = 1; parseResult = null; }}
onclick={() => {
currentStep = 1;
parseResult = null;
}}
>
Back
</button>

View file

@ -1,20 +1,17 @@
<script lang="ts">
import { settings, type ProviderPreset } from '$lib/stores/settings.svelte';
import { Check, ExternalLink } from 'lucide-svelte';
import { settings, type ProviderPreset } from "$lib/stores/settings.svelte";
import { Check, ExternalLink } from "lucide-svelte";
interface Props {
isOpen: boolean;
onComplete: () => void;
}
let {
isOpen,
onComplete,
}: Props = $props();
let { isOpen, onComplete }: Props = $props();
// Form state
let selectedProvider = $state<ProviderPreset>('openrouter');
let apiKey = $state('');
let selectedProvider = $state<ProviderPreset>("openrouter");
let apiKey = $state("");
let showApiKey = $state(false);
let isSubmitting = $state(false);
let error = $state<string | null>(null);
@ -22,41 +19,43 @@
// Provider info
const providers = [
{
id: 'openrouter' as ProviderPreset,
name: 'OpenRouter',
description: 'Access multiple AI models through a single API.',
url: 'https://openrouter.ai',
signupUrl: 'https://openrouter.ai/keys',
keyPrefix: 'sk-or-',
id: "openrouter" as ProviderPreset,
name: "OpenRouter",
description: "Access multiple AI models through a single API.",
url: "https://openrouter.ai",
signupUrl: "https://openrouter.ai/keys",
keyPrefix: "sk-or-",
requiresKey: true,
},
{
id: 'nanogpt' as ProviderPreset,
name: 'NanoGPT',
description: 'Affordable AI API with access to models like Deepseek and GLM.',
url: 'https://nano-gpt.com',
signupUrl: 'https://nano-gpt.com/api',
keyPrefix: 'sk-nano-',
id: "nanogpt" as ProviderPreset,
name: "NanoGPT",
description:
"Affordable AI API with access to models like Deepseek and GLM.",
url: "https://nano-gpt.com",
signupUrl: "https://nano-gpt.com/api",
keyPrefix: "sk-nano-",
requiresKey: true,
note: 'For thinking models, append :thinking to the model name (e.g., deepseek/deepseek-v3.2:thinking)',
note: "For thinking models, append :thinking to the model name (e.g., deepseek/deepseek-v3.2:thinking)",
},
{
id: 'custom' as ProviderPreset,
name: 'Custom / Self-Hosted',
description: 'Configure your own OpenAI-compatible API endpoint in settings.',
url: '',
signupUrl: '',
keyPrefix: '',
id: "custom" as ProviderPreset,
name: "Custom / Self-Hosted",
description:
"Configure your own OpenAI-compatible API endpoint in settings.",
url: "",
signupUrl: "",
keyPrefix: "",
requiresKey: false,
note: 'You can configure your API endpoint and key later in the Settings → API tab.',
note: "You can configure your API endpoint and key later in the Settings → API tab.",
},
];
$effect(() => {
if (isOpen) {
// Reset state when modal opens
selectedProvider = 'openrouter';
apiKey = '';
selectedProvider = "openrouter";
apiKey = "";
showApiKey = false;
isSubmitting = false;
error = null;
@ -69,7 +68,7 @@
async function handleSubmit() {
if (requiresApiKey() && !apiKey.trim()) {
error = 'Please enter your API key';
error = "Please enter your API key";
return;
}
@ -80,21 +79,24 @@
await settings.initializeWithProvider(selectedProvider, apiKey.trim());
onComplete();
} catch (e) {
console.error('Failed to initialize with provider:', e);
error = e instanceof Error ? e.message : 'Failed to initialize. Please try again.';
console.error("Failed to initialize with provider:", e);
error =
e instanceof Error
? e.message
: "Failed to initialize. Please try again.";
} finally {
isSubmitting = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && apiKey.trim()) {
if (e.key === "Enter" && apiKey.trim()) {
handleSubmit();
}
}
function getSelectedProvider() {
return providers.find(p => p.id === selectedProvider);
return providers.find((p) => p.id === selectedProvider);
}
</script>
@ -102,7 +104,7 @@
<div class="modal-backdrop">
<div class="modal">
<div class="modal-header">
<h1>Welcome to Aventura</h1>
<h1>Welcome to Aventuras</h1>
<p class="subtitle">Choose your AI provider to get started</p>
</div>
@ -114,7 +116,7 @@
type="button"
class="provider-card"
class:selected={selectedProvider === provider.id}
onclick={() => selectedProvider = provider.id}
onclick={() => (selectedProvider = provider.id)}
>
<div class="provider-header">
<span class="provider-name">{provider.name}</span>
@ -149,18 +151,20 @@
<div class="api-key-container">
<input
id="api-key"
type={showApiKey ? 'text' : 'password'}
type={showApiKey ? "text" : "password"}
class="input"
placeholder={getSelectedProvider()?.keyPrefix ? `${getSelectedProvider()?.keyPrefix}...` : 'Enter your API key'}
placeholder={getSelectedProvider()?.keyPrefix
? `${getSelectedProvider()?.keyPrefix}...`
: "Enter your API key"}
bind:value={apiKey}
onkeydown={handleKeyDown}
/>
<button
type="button"
class="toggle-key-btn"
onclick={() => showApiKey = !showApiKey}
onclick={() => (showApiKey = !showApiKey)}
>
{showApiKey ? 'Hide' : 'Show'}
{showApiKey ? "Hide" : "Show"}
</button>
</div>
</div>
@ -213,7 +217,9 @@
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.05);
box-shadow:
0 16px 64px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.05);
}
.modal-header {
@ -423,7 +429,9 @@
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
/* Mobile adjustments */

View file

@ -782,7 +782,8 @@
const SWIPE_TIMEOUT = 400; // Max time in ms for a valid swipe
// Elements that should not trigger swipe gestures
const INTERACTIVE_SELECTORS = 'input[type="range"], input[type="text"], input[type="number"], textarea, select, button, a';
const INTERACTIVE_SELECTORS =
'input[type="range"], input[type="text"], input[type="number"], textarea, select, button, a';
function handleTouchStart(e: TouchEvent) {
// Check if touch started on an interactive element
@ -2070,7 +2071,7 @@
></textarea>
<p class="text-xs text-surface-500 mt-2">
Overrides request parameters; messages and tools are managed by
Aventura.
Aventuras.
</p>
</div>
<div

View file

@ -1,10 +1,19 @@
<script lang="ts">
import { story } from '$lib/stores/story.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { exportService } from '$lib/services/export';
import { ask } from '@tauri-apps/plugin-dialog';
import { BookOpen, Trash2, Clock, Sparkles, Upload, RefreshCw, Archive, Plus } from 'lucide-svelte';
import SetupWizard from '../wizard/SetupWizard.svelte';
import { story } from "$lib/stores/story.svelte";
import { ui } from "$lib/stores/ui.svelte";
import { exportService } from "$lib/services/export";
import { ask } from "@tauri-apps/plugin-dialog";
import {
BookOpen,
Trash2,
Clock,
Sparkles,
Upload,
RefreshCw,
Archive,
Plus,
} from "lucide-svelte";
import SetupWizard from "../wizard/SetupWizard.svelte";
// File input for import (HTML-based for mobile compatibility)
let importFileInput: HTMLInputElement;
@ -24,37 +33,47 @@
async function openStory(storyId: string) {
await story.loadStory(storyId);
ui.setActivePanel('story');
ui.setActivePanel("story");
}
async function deleteStory(storyId: string, event: MouseEvent) {
event.stopPropagation();
const confirmed = await ask('Are you sure you want to delete this story? This action cannot be undone.', {
title: 'Delete Story',
kind: 'warning',
});
const confirmed = await ask(
"Are you sure you want to delete this story? This action cannot be undone.",
{
title: "Delete Story",
kind: "warning",
},
);
if (confirmed) {
await story.deleteStory(storyId);
}
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
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';
case 'Historical': return 'bg-orange-500/20 text-orange-400';
default: return 'bg-surface-700 text-surface-400';
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";
case "Historical":
return "bg-orange-500/20 text-orange-400";
default:
return "bg-surface-700 text-surface-400";
}
}
@ -78,18 +97,19 @@
if (result.success && result.storyId) {
await story.loadAllStories();
await story.loadStory(result.storyId);
ui.setActivePanel('story');
ui.setActivePanel("story");
} else if (result.error) {
importError = result.error;
setTimeout(() => importError = null, 5000);
setTimeout(() => (importError = null), 5000);
}
} catch (error) {
importError = error instanceof Error ? error.message : 'Failed to read file';
setTimeout(() => importError = null, 5000);
importError =
error instanceof Error ? error.message : "Failed to read file";
setTimeout(() => (importError = null), 5000);
}
// Reset file input for re-selection
input.value = '';
input.value = "";
}
</script>
@ -98,8 +118,12 @@
<!-- Header -->
<div class="mb-6 sm:mb-8 flex items-start justify-between gap-4 shrink-0">
<div>
<h1 class="text-xl sm:text-2xl font-bold text-surface-100">Story Library</h1>
<p class="text-sm sm:text-base text-surface-400">Your adventures await</p>
<h1 class="text-xl sm:text-2xl font-bold text-surface-100">
Story Library
</h1>
<p class="text-sm sm:text-base text-surface-400">
Your adventures await
</p>
</div>
<div class="flex items-center gap-2 flex-nowrap">
<button
@ -112,7 +136,7 @@
</button>
<button
class="btn btn-secondary flex items-center gap-1.5 sm:gap-2 min-h-[44px] px-3 sm:px-4 text-sm"
onclick={() => ui.setActivePanel('vault')}
onclick={() => ui.setActivePanel("vault")}
title="Vault - Manage reusable characters and lorebooks"
>
<Archive class="h-4 w-4 sm:h-5 sm:w-5" />
@ -151,10 +175,16 @@
<!-- Stories grid -->
{#if story.allStories.length === 0}
<div class="flex flex-col items-center justify-center flex-1 text-center px-4 pb-20">
<div
class="flex flex-col items-center justify-center flex-1 text-center px-4 pb-20"
>
<BookOpen class="mb-2 h-12 w-12 sm:h-16 sm:w-16 text-surface-600" />
<h2 class="text-lg sm:text-xl font-semibold text-surface-300">No stories yet</h2>
<p class="mt-1 text-sm sm:text-base text-surface-500">Create your first adventure to get started</p>
<h2 class="text-lg sm:text-xl font-semibold text-surface-300">
No stories yet
</h2>
<p class="mt-1 text-sm sm:text-base text-surface-500">
Create your first adventure to get started
</p>
<button
class="btn btn-primary flex items-center justify-center gap-2 min-h-[48px] mt-4"
onclick={openSetupWizard}
@ -164,22 +194,30 @@
</button>
</div>
{:else}
<div class="grid gap-3 sm:gap-4 grid-cols-1 xs:grid-cols-2 lg:grid-cols-3">
<div
class="grid gap-3 sm:gap-4 grid-cols-1 xs: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)}
onkeydown={(e) => e.key === "Enter" && openStory(s.id)}
class="card group cursor-pointer text-left transition-colors hover:border-accent-500/50 hover:bg-surface-700/50 active:bg-surface-700 min-h-[80px]"
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-surface-100 group-hover:text-accent-400 truncate">
<h3
class="font-semibold text-surface-100 group-hover:text-accent-400 truncate"
>
{s.title}
</h3>
{#if s.genre}
<span class="mt-1 inline-block rounded-full px-2 py-0.5 text-xs {getGenreColor(s.genre)}">
<span
class="mt-1 inline-block rounded-full px-2 py-0.5 text-xs {getGenreColor(
s.genre,
)}"
>
{s.genre}
</span>
{/if}
@ -215,15 +253,17 @@
class="hidden sm:flex fixed bottom-safe-4 left-safe-4 items-center gap-2 rounded-lg bg-[#5865F2] px-3 py-2 text-sm text-white shadow-lg transition-all hover:bg-[#4752C4] hover:scale-105"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
<span class="hidden sm:inline">Official Aventura Discord</span>
<span class="hidden sm:inline">Official Aventuras Discord</span>
</a>
</div>
<!-- Setup Wizard -->
{#if showSetupWizard}
{#key setupWizardKey}
<SetupWizard onClose={() => showSetupWizard = false} />
<SetupWizard onClose={() => (showSetupWizard = false)} />
{/key}
{/if}

View file

@ -1,9 +1,9 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte';
import { story } from '$lib/stores/story.svelte';
import { syncService } from '$lib/services/sync';
import { exportService } from '$lib/services/export';
import { getVersion } from '@tauri-apps/api/app';
import { ui } from "$lib/stores/ui.svelte";
import { story } from "$lib/stores/story.svelte";
import { syncService } from "$lib/services/sync";
import { exportService } from "$lib/services/export";
import { getVersion } from "@tauri-apps/api/app";
import {
X,
QrCode,
@ -14,14 +14,14 @@
AlertTriangle,
RefreshCw,
Check,
} from 'lucide-svelte';
import { Html5Qrcode } from 'html5-qrcode';
} from "lucide-svelte";
import { Html5Qrcode } from "html5-qrcode";
import type {
SyncServerInfo,
SyncStoryPreview,
SyncConnectionData,
} from '$lib/types/sync';
import { onDestroy } from 'svelte';
} from "$lib/types/sync";
import { onDestroy } from "svelte";
// State
let serverInfo = $state<SyncServerInfo | null>(null);
@ -51,7 +51,7 @@
// QR Scanner
let scanner: Html5Qrcode | null = null;
let scannerElementId = 'qr-reader';
let scannerElementId = "qr-reader";
// Reset state when modal opens
$effect(() => {
@ -135,23 +135,28 @@
try {
// If replacing, delete the existing story first
const existingId = await syncService.findStoryIdByTitle(receivedStoryPreview.title);
const existingId = await syncService.findStoryIdByTitle(
receivedStoryPreview.title,
);
if (existingId) {
await syncService.createPreSyncBackup(existingId);
await syncService.deleteStory(existingId);
}
const result = await exportService.importFromContent(receivedStoryJson, true);
const result = await exportService.importFromContent(
receivedStoryJson,
true,
);
if (result.success) {
await story.loadAllStories();
syncSuccess = true;
syncMessage = `Successfully received "${receivedStoryPreview.title}"`;
} else {
error = result.error ?? 'Import failed';
error = result.error ?? "Import failed";
}
} catch (e) {
error = e instanceof Error ? e.message : 'Import failed';
error = e instanceof Error ? e.message : "Import failed";
} finally {
loading = false;
receivedStoryJson = null;
@ -192,7 +197,7 @@
}
async function startGenerateMode() {
ui.setSyncMode('generate');
ui.setSyncMode("generate");
loading = true;
error = null;
@ -203,15 +208,15 @@
// Start polling for pushed stories
startPolling();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to start server';
ui.setSyncMode('select');
error = e instanceof Error ? e.message : "Failed to start server";
ui.setSyncMode("select");
} finally {
loading = false;
}
}
async function startScanMode() {
ui.setSyncMode('scan');
ui.setSyncMode("scan");
error = null;
// Wait for DOM to update
@ -225,7 +230,7 @@
await scanner.start(
{
facingMode: 'environment',
facingMode: "environment",
},
{
fps: 10,
@ -237,27 +242,36 @@
},
() => {
// Ignore scan failures during continuous scanning
}
},
);
// Apply zoom after camera starts (more reliable on mobile)
try {
const videoElement = document.querySelector(`#${scannerElementId} video`) as HTMLVideoElement;
const videoElement = document.querySelector(
`#${scannerElementId} video`,
) as HTMLVideoElement;
if (videoElement && videoElement.srcObject) {
const track = (videoElement.srcObject as MediaStream).getVideoTracks()[0];
const capabilities = track.getCapabilities() as MediaTrackCapabilities & { zoom?: { min: number; max: number } };
const track = (
videoElement.srcObject as MediaStream
).getVideoTracks()[0];
const capabilities =
track.getCapabilities() as MediaTrackCapabilities & {
zoom?: { min: number; max: number };
};
if (capabilities.zoom) {
const maxZoom = capabilities.zoom.max;
const targetZoom = Math.min(maxZoom, 2.5);
await track.applyConstraints({ advanced: [{ zoom: targetZoom } as MediaTrackConstraintSet] });
await track.applyConstraints({
advanced: [{ zoom: targetZoom } as MediaTrackConstraintSet],
});
}
}
} catch {
// Zoom not supported on this device, continue without it
}
} catch (e) {
error = 'Camera access denied or not available';
ui.setSyncMode('select');
error = "Camera access denied or not available";
ui.setSyncMode("select");
}
}
@ -287,8 +301,8 @@
// No mismatch, proceed normally
await proceedWithConnection(parsed);
} catch (e) {
error = e instanceof Error ? e.message : 'Connection failed';
ui.setSyncMode('select');
error = e instanceof Error ? e.message : "Connection failed";
ui.setSyncMode("select");
}
}
@ -297,7 +311,7 @@
pendingConnection = null;
remoteVersion = null;
localVersion = null;
ui.setSyncMode('select');
ui.setSyncMode("select");
}
async function proceedWithVersionMismatch() {
@ -312,7 +326,7 @@
async function proceedWithConnection(conn: SyncConnectionData) {
try {
connection = conn;
ui.setSyncMode('connected');
ui.setSyncMode("connected");
// Fetch available stories from remote
loading = true;
@ -328,8 +342,8 @@
entryCount: 0, // We don't track this in the store
}));
} catch (e) {
error = e instanceof Error ? e.message : 'Connection failed';
ui.setSyncMode('select');
error = e instanceof Error ? e.message : "Connection failed";
ui.setSyncMode("select");
} finally {
loading = false;
}
@ -339,21 +353,25 @@
if (!connection || !selectedRemoteStory) return;
// Check for conflict
const exists = await syncService.checkStoryExists(selectedRemoteStory.title);
const exists = await syncService.checkStoryExists(
selectedRemoteStory.title,
);
if (exists && !showConflictWarning) {
conflictStoryTitle = selectedRemoteStory.title;
showConflictWarning = true;
return;
}
ui.setSyncMode('syncing');
ui.setSyncMode("syncing");
loading = true;
error = null;
showConflictWarning = false;
try {
// If replacing, delete the existing story first
const existingId = await syncService.findStoryIdByTitle(selectedRemoteStory.title);
const existingId = await syncService.findStoryIdByTitle(
selectedRemoteStory.title,
);
if (existingId) {
await syncService.createPreSyncBackup(existingId);
await syncService.deleteStory(existingId);
@ -362,7 +380,7 @@
// Pull the story
const storyJson = await syncService.pullStory(
connection,
selectedRemoteStory.id
selectedRemoteStory.id,
);
// Import using existing import service
@ -374,10 +392,10 @@
syncSuccess = true;
syncMessage = `Successfully pulled "${selectedRemoteStory.title}"`;
} else {
error = result.error ?? 'Import failed';
error = result.error ?? "Import failed";
}
} catch (e) {
error = e instanceof Error ? e.message : 'Pull failed';
error = e instanceof Error ? e.message : "Pull failed";
} finally {
loading = false;
}
@ -386,7 +404,7 @@
async function pushStory() {
if (!connection || !selectedLocalStory) return;
ui.setSyncMode('syncing');
ui.setSyncMode("syncing");
loading = true;
error = null;
@ -395,7 +413,9 @@
await syncService.createPreSyncBackup(selectedLocalStory.id);
// Export the story
const storyJson = await syncService.exportStoryToJson(selectedLocalStory.id);
const storyJson = await syncService.exportStoryToJson(
selectedLocalStory.id,
);
// Push to remote
await syncService.pushStory(connection, storyJson);
@ -403,7 +423,7 @@
syncSuccess = true;
syncMessage = `Successfully pushed "${selectedLocalStory.title}"`;
} catch (e) {
error = e instanceof Error ? e.message : 'Push failed';
error = e instanceof Error ? e.message : "Push failed";
} finally {
loading = false;
}
@ -420,10 +440,10 @@
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
return new Date(timestamp).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
</script>
@ -444,15 +464,15 @@
<div class="flex items-center gap-2">
<RefreshCw class="h-5 w-5 text-accent-400" />
<h2 class="text-lg font-semibold text-surface-100">
{#if ui.syncMode === 'select'}
{#if ui.syncMode === "select"}
Local Network Sync
{:else if ui.syncMode === 'generate'}
{:else if ui.syncMode === "generate"}
Waiting for Connection
{:else if ui.syncMode === 'scan'}
{:else if ui.syncMode === "scan"}
Scan QR Code
{:else if ui.syncMode === 'connected'}
{:else if ui.syncMode === "connected"}
Select Story to Sync
{:else if ui.syncMode === 'syncing'}
{:else if ui.syncMode === "syncing"}
Syncing...
{/if}
</h2>
@ -490,7 +510,7 @@
<p class="text-surface-400">{syncMessage}</p>
<button class="btn btn-primary mt-6" onclick={close}>Done</button>
</div>
{:else if ui.syncMode === 'select'}
{:else if ui.syncMode === "select"}
<!-- Mode Selection -->
<p class="text-surface-400 text-sm mb-4">
Sync stories between devices on the same network.
@ -527,7 +547,7 @@
</div>
</button>
</div>
{:else if ui.syncMode === 'generate'}
{:else if ui.syncMode === "generate"}
<!-- QR Code Display -->
{#if showReceivedConflict && receivedStoryPreview}
<!-- Conflict warning for received push -->
@ -541,11 +561,15 @@
Story Already Exists
</h3>
<p class="text-surface-400 mb-4">
A story named "{receivedStoryPreview.title}" already exists on this device.
Replacing it will create a "Pre-sync backup" checkpoint first. Continue?
A story named "{receivedStoryPreview.title}" already exists on
this device. Replacing it will create a "Pre-sync backup"
checkpoint first. Continue?
</p>
<div class="flex justify-center gap-3">
<button class="btn btn-secondary" onclick={cancelReceivedImport}>
<button
class="btn btn-secondary"
onclick={cancelReceivedImport}
>
Cancel
</button>
<button class="btn btn-primary" onclick={importReceivedStory}>
@ -560,9 +584,7 @@
</div>
{:else if serverInfo}
<div class="text-center">
<div
class="bg-white p-4 rounded-lg inline-block mx-auto mb-4"
>
<div class="bg-white p-4 rounded-lg inline-block mx-auto mb-4">
<img
src="data:image/png;base64,{serverInfo.qrCodeBase64}"
alt="QR Code"
@ -570,14 +592,14 @@
/>
</div>
<p class="text-surface-400 text-sm">
Scan this QR code with another device running Aventura
Scan this QR code with another device running Aventuras
</p>
<p class="text-surface-500 text-xs mt-2">
Server: {serverInfo.ip}:{serverInfo.port}
</p>
</div>
{/if}
{:else if ui.syncMode === 'scan'}
{:else if ui.syncMode === "scan"}
<!-- Version Mismatch Warning -->
{#if showVersionWarning}
<div class="text-center py-4">
@ -590,20 +612,27 @@
Version Mismatch
</h3>
<p class="text-surface-400 mb-2">
The remote device is running a different version of Aventura.
The remote device is running a different version of Aventuras.
</p>
<div class="text-sm text-surface-500 mb-4">
<p>Local: v{localVersion}</p>
<p>Remote: {remoteVersion ? `v${remoteVersion}` : 'unknown'}</p>
<p>Remote: {remoteVersion ? `v${remoteVersion}` : "unknown"}</p>
</div>
<p class="text-surface-400 text-sm mb-4">
Syncing between different versions may cause issues. Continue anyway?
Syncing between different versions may cause issues. Continue
anyway?
</p>
<div class="flex justify-center gap-3">
<button class="btn btn-secondary" onclick={cancelVersionMismatch}>
<button
class="btn btn-secondary"
onclick={cancelVersionMismatch}
>
Cancel
</button>
<button class="btn btn-primary" onclick={proceedWithVersionMismatch}>
<button
class="btn btn-primary"
onclick={proceedWithVersionMismatch}
>
Continue Anyway
</button>
</div>
@ -621,7 +650,7 @@
</p>
</div>
{/if}
{:else if ui.syncMode === 'connected'}
{:else if ui.syncMode === "connected"}
<!-- Story Selection -->
{#if loading}
<div class="flex flex-col items-center justify-center py-8">
@ -631,17 +660,15 @@
{:else}
<!-- Conflict Warning -->
{#if showConflictWarning}
<div
class="mb-4 rounded-lg bg-amber-500/20 p-4 text-amber-400"
>
<div class="mb-4 rounded-lg bg-amber-500/20 p-4 text-amber-400">
<div class="flex items-center gap-2 mb-2">
<AlertTriangle class="h-5 w-5" />
<span class="font-semibold">Story Already Exists</span>
</div>
<p class="text-sm">
A story named "{conflictStoryTitle}" already exists on this
device. Pulling will replace it after creating a "Pre-sync backup"
checkpoint.
device. Pulling will replace it after creating a "Pre-sync
backup" checkpoint.
</p>
<div class="flex gap-2 mt-3">
<button
@ -692,7 +719,7 @@
</div>
<div class="text-xs text-surface-500 mt-1">
{remoteStory.entryCount} entries • Updated {formatDate(
remoteStory.updatedAt
remoteStory.updatedAt,
)}
</div>
</button>
@ -747,12 +774,12 @@
</div>
{/if}
{/if}
{:else if ui.syncMode === 'syncing'}
{:else if ui.syncMode === "syncing"}
<!-- Syncing State -->
<div class="flex flex-col items-center justify-center py-12">
<Loader2 class="h-8 w-8 text-accent-400 animate-spin" />
<p class="mt-4 text-surface-400">
{selectedRemoteStory ? 'Pulling' : 'Pushing'} story...
{selectedRemoteStory ? "Pulling" : "Pushing"} story...
</p>
<p class="text-surface-500 text-sm mt-1">Please wait</p>
</div>
@ -760,17 +787,25 @@
</div>
<!-- Footer -->
{#if ui.syncMode === 'connected' && !showConflictWarning && !loading && !syncSuccess}
<div class="border-t border-surface-700 px-4 pt-4 pb-modal-safe -mx-4 -mb-4 sm:mx-0 sm:mb-0">
{#if ui.syncMode === "connected" && !showConflictWarning && !loading && !syncSuccess}
<div
class="border-t border-surface-700 px-4 pt-4 pb-modal-safe -mx-4 -mb-4 sm:mx-0 sm:mb-0"
>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary" onclick={close}>Cancel</button>
{#if selectedRemoteStory}
<button class="btn btn-primary flex items-center gap-2" onclick={pullStory}>
<button
class="btn btn-primary flex items-center gap-2"
onclick={pullStory}
>
<Download class="h-4 w-4" />
Pull Story
</button>
{:else if selectedLocalStory}
<button class="btn btn-primary flex items-center gap-2" onclick={pushStory}>
<button
class="btn btn-primary flex items-center gap-2"
onclick={pushStory}
>
<Upload class="h-4 w-4" />
Push Story
</button>

View file

@ -1,11 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { database } from '$lib/services/database';
import { settings } from '$lib/stores/settings.svelte';
import { grammarService } from '$lib/services/grammar';
import { updaterService } from '$lib/services/updater';
import AppShell from '$lib/components/layout/AppShell.svelte';
import ProviderSetupModal from '$lib/components/settings/ProviderSetupModal.svelte';
import { onMount } from "svelte";
import { database } from "$lib/services/database";
import { settings } from "$lib/stores/settings.svelte";
import { grammarService } from "$lib/services/grammar";
import { updaterService } from "$lib/services/updater";
import AppShell from "$lib/components/layout/AppShell.svelte";
import ProviderSetupModal from "$lib/components/settings/ProviderSetupModal.svelte";
let initialized = $state(false);
let error = $state<string | null>(null);
@ -31,22 +31,28 @@
// Check for updates on startup if enabled (don't await, run in background)
if (settings.updateSettings.autoCheck) {
const { checkInterval, lastChecked, autoDownload } = settings.updateSettings;
const { checkInterval, lastChecked, autoDownload } =
settings.updateSettings;
const now = Date.now();
const shouldCheck = checkInterval <= 0
? true
: !lastChecked || now - lastChecked >= checkInterval * 60 * 60 * 1000;
const shouldCheck =
checkInterval <= 0
? true
: !lastChecked ||
now - lastChecked >= checkInterval * 60 * 60 * 1000;
if (shouldCheck) {
updaterService.checkForUpdates()
updaterService
.checkForUpdates()
.then(async (updateInfo) => {
await settings.setLastChecked(Date.now());
if (updateInfo.available) {
console.log(`[Updater] Update available: v${updateInfo.version}`);
console.log(
`[Updater] Update available: v${updateInfo.version}`,
);
// Auto-download if enabled
if (autoDownload) {
console.log('[Updater] Auto-downloading update...');
console.log("[Updater] Auto-downloading update...");
updaterService.downloadAndInstall().catch(console.error);
}
}
@ -57,8 +63,9 @@
initialized = true;
} catch (e) {
console.error('Initialization error:', e);
error = e instanceof Error ? e.message : 'Failed to initialize application';
console.error("Initialization error:", e);
error =
e instanceof Error ? e.message : "Failed to initialize application";
}
});
@ -77,7 +84,9 @@
/>
{#if error}
<div class="flex h-screen w-screen items-center justify-center bg-surface-900">
<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>
@ -91,16 +100,22 @@
</div>
{:else if showProviderSetup}
<!-- Show loading background while provider setup is shown -->
<div class="flex h-screen w-screen items-center justify-center bg-surface-900">
<div
class="flex h-screen w-screen items-center justify-center bg-surface-900"
>
<div class="flex flex-col items-center gap-4">
<p class="text-surface-400">Welcome to Aventura</p>
<p class="text-surface-400">Welcome to Aventuras</p>
</div>
</div>
{:else if !initialized}
<div class="flex h-screen w-screen items-center justify-center bg-surface-900">
<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
class="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent"
></div>
<p class="text-surface-400">Loading Aventuras...</p>
</div>
</div>
{:else}