Merge remote-tracking branch 'origin/master' into shadcn-UI-rework

This commit is contained in:
munimunigamer 2026-01-25 10:25:15 -06:00
commit 79cd91a6fc
16 changed files with 916 additions and 91 deletions

View file

@ -26,6 +26,7 @@
"harper.js": "^1.2.0",
"html5-qrcode": "^2.3.8",
"jsonrepair": "^3.13.2",
"jszip": "^3.10.1",
"lucide-svelte": "^0.468.0",
"marked": "^17.0.1",
"tailwind-merge": "^3.4.0"

View file

@ -12,7 +12,9 @@
"sql:allow-select",
"sql:allow-close",
"fs:default",
"fs:allow-read-file",
"fs:allow-read-text-file",
"fs:allow-write-file",
"fs:allow-write-text-file",
"fs:allow-exists",
"fs:allow-mkdir",

View file

@ -6,6 +6,7 @@
import Header from './Header.svelte';
import StoryView from '$lib/components/story/StoryView.svelte';
import LibraryView from '$lib/components/story/LibraryView.svelte';
import GalleryTab from '$lib/components/story/GalleryTab.svelte';
import LorebookView from '$lib/components/lorebook/LorebookView.svelte';
import MemoryView from '$lib/components/memory/MemoryView.svelte';
import VaultPanel from '$lib/components/vault/VaultPanel.svelte';
@ -61,6 +62,8 @@
<main class="flex-1 overflow-hidden">
{#if ui.activePanel === 'story' && story.currentStory}
<StoryView />
{:else if ui.activePanel === 'gallery' && story.currentStory}
<GalleryTab />
{:else if ui.activePanel === 'lorebook' && story.currentStory}
<LorebookView />
{:else if ui.activePanel === 'memory' && story.currentStory}

View file

@ -228,6 +228,17 @@
{/if}
{#if story.currentStory}
<!-- Gallery Button -->
<button
class="btn-ghost flex items-center gap-1 rounded-lg p-2 sm:px-2 sm:py-1.5 text-sm min-h-[44px] min-w-[44px] justify-center"
onclick={() => ui.setActivePanel(ui.activePanel === 'gallery' ? 'story' : 'gallery')}
title="View generated images"
>
<ImageIcon class="h-4 w-4" />
<span class="hidden sm:inline">Gallery</span>
</button>
<!-- Export Button -->
<div class="relative">
<Button
icon={Download}

View file

@ -13,6 +13,7 @@
let { entry, isMobile = false }: Props = $props();
let deleting = $state(false);
let confirmingDelete = $state(false);
const typeIcons: Record<EntryType, typeof Users> = {
character: Users,
@ -55,9 +56,6 @@
}
async function handleDelete() {
const confirmed = confirm(`Delete "${entry.name}"? This cannot be undone.`);
if (!confirmed) return;
deleting = true;
try {
await story.deleteLorebookEntry(entry.id);
@ -66,11 +64,23 @@
} else {
ui.selectLorebookEntry(null);
}
} catch (error) {
console.error('[LorebookDetail] Failed to delete entry:', error);
alert(error instanceof Error ? error.message : 'Failed to delete entry');
} finally {
deleting = false;
confirmingDelete = false;
}
}
function handleConfirmDelete() {
confirmingDelete = true;
}
function handleCancelDelete() {
confirmingDelete = false;
}
function handleBack() {
if (ui.lorebookEditMode) {
ui.setLorebookEditMode(false);
@ -109,22 +119,39 @@
{#if !ui.lorebookEditMode}
<div class="flex items-center gap-2">
<button
class="btn-ghost p-2 rounded-lg"
onclick={() => ui.setLorebookEditMode(true)}
disabled={isLoreManagementActive}
title={isLoreManagementActive ? 'Editing disabled during lore management' : 'Edit'}
>
<Pencil class="h-4 w-4" />
</button>
<button
class="btn-ghost p-2 rounded-lg text-red-400 hover:text-red-300"
onclick={handleDelete}
disabled={deleting || isLoreManagementActive}
title={isLoreManagementActive ? 'Deletion disabled during lore management' : 'Delete'}
>
<Trash2 class="h-4 w-4" />
</button>
{#if confirmingDelete}
<button
class="rounded px-2 py-1 text-xs bg-surface-700 text-surface-300 hover:bg-surface-600"
onclick={handleCancelDelete}
disabled={deleting}
>
Cancel
</button>
<button
class="rounded px-2 py-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50"
onclick={handleDelete}
disabled={deleting}
>
Confirm Delete
</button>
{:else}
<button
class="btn-ghost p-2 rounded-lg"
onclick={() => ui.setLorebookEditMode(true)}
disabled={isLoreManagementActive}
title={isLoreManagementActive ? 'Editing disabled during lore management' : 'Edit'}
>
<Pencil class="h-4 w-4" />
</button>
<button
class="btn-ghost p-2 rounded-lg text-red-400 hover:text-red-300"
onclick={handleConfirmDelete}
disabled={isLoreManagementActive}
title={isLoreManagementActive ? 'Deletion disabled during lore management' : 'Delete'}
>
<Trash2 class="h-4 w-4" />
</button>
{/if}
</div>
{/if}
</div>

View file

@ -22,6 +22,8 @@
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let showTypeFilter = $state(false);
let showSortMenu = $state(false);
let confirmingBulkDelete = $state(false);
let isDeleting = $state(false);
const entryTypes: Array<EntryType | "all"> = [
"all",
@ -113,16 +115,30 @@
}
async function handleBulkDelete() {
if (ui.lorebookBulkSelection.size === 0) return;
const count = ui.lorebookBulkSelection.size;
const confirmed = confirm(
`Delete ${count} entr${count === 1 ? "y" : "ies"}? This cannot be undone.`,
);
if (!confirmed) return;
const ids = Array.from(ui.lorebookBulkSelection);
await story.deleteLorebookEntries(ids);
isDeleting = true;
try {
await story.deleteLorebookEntries(ids);
ui.clearBulkSelection();
} catch (error) {
console.error('[LorebookList] Failed to delete entries:', error);
alert(error instanceof Error ? error.message : 'Failed to delete entries');
} finally {
isDeleting = false;
confirmingBulkDelete = false;
}
}
function handleConfirmBulkDelete() {
if (ui.lorebookBulkSelection.size === 0) return;
confirmingBulkDelete = true;
}
function handleCancelBulkDelete() {
confirmingBulkDelete = false;
}
function handleClearSelection() {
ui.clearBulkSelection();
}
@ -277,19 +293,36 @@
>
</div>
<div class="flex items-center gap-2">
<button
class="btn-ghost flex items-center gap-1.5 px-2 py-1 text-sm text-red-400 hover:text-red-300"
onclick={handleBulkDelete}
>
<Trash2 class="h-4 w-4" />
<span class="hidden xs:inline">Delete</span>
</button>
<button
class="btn-ghost px-2 py-1 text-sm text-surface-400 hover:text-surface-300"
onclick={() => ui.clearBulkSelection()}
>
Cancel
</button>
{#if confirmingBulkDelete}
<button
class="rounded px-2 py-1 text-xs bg-surface-700 text-surface-300 hover:bg-surface-600"
onclick={handleCancelBulkDelete}
disabled={isDeleting}
>
Cancel
</button>
<button
class="rounded px-2 py-1 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 disabled:opacity-50"
onclick={handleBulkDelete}
disabled={isDeleting}
>
Confirm Delete {ui.lorebookBulkSelection.size} entr{ui.lorebookBulkSelection.size === 1 ? "y" : "ies"}
</button>
{:else}
<button
class="btn-ghost flex items-center gap-1.5 px-2 py-1 text-sm text-red-400 hover:text-red-300"
onclick={handleConfirmBulkDelete}
>
<Trash2 class="h-4 w-4" />
<span class="hidden xs:inline">Delete</span>
</button>
<button
class="btn-ghost px-2 py-1 text-sm text-surface-400 hover:text-surface-300"
onclick={handleClearSelection}
>
Cancel
</button>
{/if}
</div>
</div>
{/if}
@ -305,7 +338,7 @@
</div>
<!-- Entry list -->
<div class="flex-1 overflow-y-auto p-3 pb-safe space-y-2">
<div class="flex-1 overflow-y-auto p-3 space-y-2 pb-16 sm:pb-20">
{#if filteredEntries.length === 0}
{#if story.lorebookEntries.length === 0}
<div class="text-center py-8 text-surface-500">

View file

@ -34,6 +34,7 @@
type ResponseStreamingEvent,
type ClassificationCompleteEvent,
} from "$lib/services/events";
import { isTouchDevice } from "$lib/utils/swipe";
function log(...args: any[]) {
console.log("[ActionInput]", ...args);
@ -62,6 +63,11 @@
// In creative writing mode, show different input style
const isCreativeMode = $derived(story.storyMode === "creative-writing");
// Keyboard shortcut hint
const sendKeyHint = $derived(
isTouchDevice() ? "Shift+Enter to send" : "Enter to send, Shift+Enter for new line"
);
// Action type configuration for the redesigned input
type ActionType = "do" | "say" | "think" | "story" | "free";
@ -1842,7 +1848,15 @@
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
const isMobile = isTouchDevice();
// On mobile: Enter = new line, Shift+Enter = send
// On desktop: Enter = send, Shift+Enter = new line
const shouldSubmit = isMobile
? (event.key === "Enter" && event.shiftKey)
: (event.key === "Enter" && !event.shiftKey);
if (shouldSubmit) {
event.preventDefault();
handleSubmit();
}
@ -1953,28 +1967,28 @@
{#if !ui.isRetryingLastMessage}
<button
onclick={handleStopGeneration}
class="h-9 w-9 p-0 flex items-center justify-center rounded-lg text-red-400 hover:text-red-300 transition-all active:scale-95 flex-shrink-0 animate-pulse"
class="h-11 w-11 p-0 flex items-center justify-center rounded-lg text-red-400 hover:text-red-300 transition-all active:scale-95 flex-shrink-0 animate-pulse"
title="Stop generation"
>
<Square class="h-5 w-5" />
<Square class="h-6 w-6" />
</button>
{:else}
<button
disabled
class="h-9 w-9 p-0 flex items-center justify-center rounded-lg text-red-400 opacity-50 cursor-not-allowed flex-shrink-0"
class="h-11 w-11 p-0 flex items-center justify-center rounded-lg text-red-400 opacity-50 cursor-not-allowed flex-shrink-0"
title="Stop disabled during retry"
>
<Square class="h-5 w-5" />
<Square class="h-6 w-6" />
</button>
{/if}
{:else}
<button
onclick={handleSubmit}
disabled={!inputValue.trim()}
class="h-9 w-9 p-0 flex items-center justify-center rounded-lg transition-all active:scale-95 disabled:opacity-50 flex-shrink-0 text-accent-400 hover:text-accent-300 hover:bg-accent-500/10"
title="Send direction"
class="h-11 w-11 p-0 flex items-center justify-center rounded-lg transition-all active:scale-95 disabled:opacity-50 flex-shrink-0 text-accent-400 hover:text-accent-300 hover:bg-accent-500/10"
title="Send direction ({sendKeyHint})"
>
<Send class="h-5 w-5" />
<Send class="h-6 w-6" />
</button>
{/if}
</div>
@ -2053,29 +2067,30 @@
{#if !ui.isRetryingLastMessage}
<button
onclick={handleStopGeneration}
class="h-9 w-9 p-0 flex items-center justify-center rounded-lg text-red-400 hover:text-red-300 transition-all active:scale-95 flex-shrink-0 animate-pulse"
class="h-11 w-11 p-0 flex items-center justify-center rounded-lg text-red-400 hover:text-red-300 transition-all active:scale-95 flex-shrink-0 animate-pulse"
title="Stop generation"
>
<Square class="h-5 w-5" />
<Square class="h-6 w-6" />
</button>
{:else}
<button
disabled
class="h-9 w-9 p-0 flex items-center justify-center rounded-lg text-red-400 opacity-50 cursor-not-allowed flex-shrink-0"
class="h-11 w-11 p-0 flex items-center justify-center rounded-lg text-red-400 opacity-50 cursor-not-allowed flex-shrink-0"
title="Stop disabled during retry"
>
<Square class="h-5 w-5" />
<Square class="h-6 w-6" />
</button>
{/if}
{:else}
<button
onclick={handleSubmit}
disabled={!inputValue.trim()}
class="h-9 w-9 p-0 flex items-center justify-center rounded-lg transition-all active:scale-95 disabled:opacity-50 flex-shrink-0 {actionButtonStyles[
class="h-11 w-11 p-0 flex items-center justify-center rounded-lg transition-all active:scale-95 disabled:opacity-50 flex-shrink-0 {actionButtonStyles[
actionType
]}"
title="Send ({sendKeyHint})"
>
<Send class="h-5 w-5" />
<Send class="h-6 w-6" />
</button>
{/if}
</div>

View file

@ -0,0 +1,432 @@
<script lang="ts">
import { story } from '$lib/stores/story.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { database } from '$lib/services/database';
import { imageExportService } from '$lib/services/imageExport';
import type { EmbeddedImage } from '$lib/types';
import { Download, ImageIcon, AlertCircle, X, RefreshCw } from 'lucide-svelte';
const SWIPE_THRESHOLD = 50;
let images = $state<EmbeddedImage[]>([]);
let isLoading = $state(false);
let isSaving = $state(false);
let selectedImageIds = $state<Set<string>>(new Set());
let selectAllChecked = $state(false);
let lightboxOpen = $state(false);
let lightboxImageIndex = $state(0);
let touchStartX = $state(0);
let touchEndX = $state(0);
function resetSelection() {
selectedImageIds = new Set();
selectAllChecked = false;
}
function closeGallery() {
ui.activePanel = 'story';
}
async function refreshImages() {
const storyId = story.currentStory?.id;
if (!storyId) return;
ui.clearGalleryImages(storyId);
await loadImagesForStory(storyId);
}
async function loadImagesForStory(storyId: string) {
isLoading = true;
try {
const loaded = await database.getEmbeddedImagesForStory(storyId);
ui.setGalleryImages(storyId, loaded);
images = loaded;
} catch (error) {
console.error('[Gallery] Failed to load images:', error);
ui.showToast('Failed to load gallery images', 'error');
images = [];
} finally {
isLoading = false;
}
}
$effect(() => {
const storyId = story.currentStory?.id;
resetSelection(); // Always reset selection on story change
if (storyId) {
const cached = ui.getGalleryImages(storyId);
if (cached) {
images = cached;
isLoading = false;
} else {
loadImagesForStory(storyId);
}
} else {
images = [];
isLoading = false;
}
});
function toggleSelectAll() {
selectAllChecked = !selectAllChecked;
if (selectAllChecked) {
selectedImageIds = new Set(images.map(img => img.id));
} else {
selectedImageIds = new Set();
}
}
function toggleImageSelection(imageId: string) {
const newSet = new Set(selectedImageIds);
newSet.has(imageId) ? newSet.delete(imageId) : newSet.add(imageId);
selectedImageIds = newSet;
selectAllChecked = newSet.size === images.length;
}
async function handleSaveImages() {
if (!story.currentStory || images.length === 0) return;
const imagesToSave = selectedImageIds.size || images.length;
isSaving = true;
try {
const success = await imageExportService.exportImages(
story.currentStory.title,
images,
selectedImageIds.size > 0 ? selectedImageIds : undefined
);
if (success) {
const message = imagesToSave === 1
? 'Saved 1 image as PNG'
: `Saved ${imagesToSave} images as ZIP`;
ui.showToast(message, 'info');
resetSelection();
}
} catch (error) {
console.error('[Gallery] Save failed:', error);
ui.showToast(
`Failed to save images: ${error instanceof Error ? error.message : 'Unknown error'}`,
'error'
);
} finally {
isSaving = false;
}
}
function getImagePreview(imageData: string): string {
return imageData.startsWith('data:')
? imageData
: `data:image/png;base64,${imageData}`;
}
function openLightbox(index: number) {
lightboxImageIndex = index;
lightboxOpen = true;
}
function closeLightbox() {
lightboxOpen = false;
}
function previousImage() {
if (lightboxImageIndex > 0) {
lightboxImageIndex--;
}
}
function nextImage() {
if (lightboxImageIndex < images.length - 1) {
lightboxImageIndex++;
}
}
function handleTouchStart(e: TouchEvent) {
touchStartX = e.changedTouches[0].screenX;
}
function handleTouchEnd(e: TouchEvent) {
touchEndX = e.changedTouches[0].screenX;
const diff = touchStartX - touchEndX;
if (Math.abs(diff) > SWIPE_THRESHOLD) {
diff > 0 ? nextImage() : previousImage();
}
}
$effect(() => {
function handleKeydown(e: KeyboardEvent) {
if (lightboxOpen) {
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') previousImage();
if (e.key === 'ArrowRight') nextImage();
} else {
if (e.key === 'Escape') closeGallery();
}
}
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
</script>
<div class="flex h-full flex-col bg-surface-900">
<!-- Header -->
<div class="border-b border-surface-700 px-4 py-3 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<ImageIcon class="h-5 w-5 text-accent-400" />
<h2 class="text-lg font-semibold text-surface-100">Gallery</h2>
{#if images.length > 0}
<span class="text-sm text-surface-500">({images.length})</span>
{/if}
</div>
{#if images.length > 0 && !isLoading}
<div class="flex items-center gap-2">
<!-- Refresh button -->
<button
class="flex items-center justify-center rounded p-2 hover:bg-surface-700 transition-colors disabled:opacity-50 min-h-[44px] min-w-[44px]"
onclick={refreshImages}
disabled={isLoading || !story.currentStory}
title="Refresh gallery"
>
<RefreshCw class="h-4 w-4 text-surface-300 {isLoading ? 'animate-spin' : ''}" />
</button>
<!-- Save button -->
<button
class="flex items-center gap-2 rounded-lg bg-accent-600 hover:bg-accent-700 disabled:opacity-50 disabled:cursor-not-allowed px-2.5 sm:px-3 py-2 text-xs sm:text-sm font-medium text-white transition-colors min-h-[44px]"
onclick={handleSaveImages}
disabled={isSaving}
title={isSaving ? 'Saving images...' : selectedImageIds.size > 0 ? `Save ${selectedImageIds.size} selected` : 'Save all images'}
>
<Download class="h-4 w-4 flex-shrink-0" />
<span class="hidden sm:inline">
{isSaving ? 'Saving...' : selectedImageIds.size > 0 ? `Save ${selectedImageIds.size}` : 'Save All'}
</span>
<span class="sm:hidden">
{isSaving ? '...' : selectedImageIds.size > 0 ? selectedImageIds.size : 'All'}
</span>
</button>
<!-- Close/Back button -->
<button
class="flex items-center gap-1.5 rounded-lg border border-surface-600 hover:bg-surface-700 px-2.5 sm:px-3 py-2 text-xs sm:text-sm font-medium text-surface-300 hover:text-surface-100 transition-colors min-h-[44px]"
onclick={closeGallery}
title="Close gallery (Esc)"
>
<X class="h-4 w-4 flex-shrink-0" />
<span class="hidden sm:inline">Close</span>
</button>
</div>
{/if}
</div>
</div>
<!-- Selection toolbar (shown when images exist) -->
{#if images.length > 0 && !isLoading}
<div class="border-b border-surface-700 bg-surface-800 px-4 sm:px-6 py-2.5 flex items-center gap-3">
<input
type="checkbox"
checked={selectAllChecked}
onchange={toggleSelectAll}
class="h-5 w-5 cursor-pointer rounded border-surface-600 text-accent-600 min-h-[44px] min-w-[44px] p-2"
title="Select all images"
/>
<span class="text-xs sm:text-sm text-surface-400">
{selectedImageIds.size === 0
? 'Select images to download'
: selectedImageIds.size === images.length
? 'All selected'
: `${selectedImageIds.size} selected`}
</span>
</div>
{/if}
<!-- Content -->
<div class="flex-1 overflow-y-auto px-4 sm:px-6 py-4">
{#if isLoading}
<!-- Loading state -->
<div class="flex items-center justify-center py-12">
<div class="flex flex-col items-center gap-3">
<div class="h-8 w-8 animate-spin rounded-full border-2 border-surface-600 border-t-accent-400"></div>
<p class="text-sm text-surface-400">Loading images...</p>
</div>
</div>
{:else if images.length === 0}
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-12 text-center">
<ImageIcon class="h-12 w-12 text-surface-700 mb-3" />
<p class="text-surface-300 font-medium">No generated images yet</p>
<p class="text-sm text-surface-500 mt-1">
Generate images using the image generation feature in your story
</p>
</div>
{:else}
<!-- Images grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each images as image, index (image.id)}
<div class="group relative overflow-hidden rounded-lg bg-surface-800 border-2 transition-colors"
class:border-accent-500={selectedImageIds.has(image.id)}
class:border-surface-700={!selectedImageIds.has(image.id)}
class:hover:border-accent-400={!selectedImageIds.has(image.id)}
>
<!-- Checkbox overlay (top-left) -->
<div class="absolute top-2 left-2 z-20 flex items-center pointer-events-none">
<input
type="checkbox"
checked={selectedImageIds.has(image.id)}
onchange={() => toggleImageSelection(image.id)}
class="h-5 w-5 cursor-pointer rounded border-surface-500 text-accent-600 pointer-events-auto"
style="min-height: 44px; min-width: 44px; padding: 6px;"
/>
</div>
<!-- Image container with proper scaling -->
<div class="relative bg-surface-700 aspect-video flex items-center justify-center cursor-pointer hover:bg-surface-600 transition-colors"
onclick={() => openLightbox(index)}
>
<img
src={getImagePreview(image.imageData)}
alt={`Generated image ${index + 1}`}
class="w-full h-full object-contain"
/>
</div>
<!-- Hover overlay with view button -->
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center pointer-events-none">
<button
class="pointer-events-auto px-3 py-1.5 bg-accent-600 hover:bg-accent-700 text-white text-sm font-medium rounded transition-colors"
onclick={() => openLightbox(index)}
>
View
</button>
</div>
<!-- Image metadata (shown below) -->
<div class="p-3 border-t border-surface-700 text-xs">
<div class="space-y-1 text-surface-400">
{#if image.model}
<p class="truncate">
<span class="text-surface-500 font-medium">Model:</span>
{image.model}
</p>
{/if}
{#if image.styleId}
<p class="truncate">
<span class="text-surface-500 font-medium">Style:</span>
{image.styleId}
</p>
{/if}
{#if image.width && image.height}
<p>
<span class="text-surface-500 font-medium">Size:</span>
{image.width}×{image.height}
</p>
{/if}
{#if image.generationMode}
<p class="capitalize">
<span class="text-surface-500 font-medium">Mode:</span>
{image.generationMode}
</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Footer info -->
{#if images.length > 0 && !isLoading}
<div class="border-t border-surface-700 bg-surface-800 px-4 sm:px-6 py-2">
<div class="flex items-start gap-2 text-xs text-surface-400">
<AlertCircle class="h-4 w-4 flex-shrink-0 mt-0.5" />
<p>
{selectedImageIds.size > 0
? `${selectedImageIds.size} image${selectedImageIds.size > 1 ? 's' : ''} selected for download`
: `${images.length} image${images.length > 1 ? 's' : ''} available. Select images to download.`}
</p>
</div>
</div>
{/if}
</div>
<!-- Lightbox Modal -->
{#if lightboxOpen && images.length > 0}
<div
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90 p-4"
onclick={() => closeLightbox()}
role="dialog"
aria-modal="true"
>
<!-- Close button -->
<button
class="absolute top-4 right-4 p-2 hover:bg-surface-700/50 rounded-lg transition-colors z-[10000]"
onclick={(e) => {
e.stopPropagation();
closeLightbox();
}}
title="Close (Esc)"
>
<X class="h-6 w-6 text-white" />
</button>
<!-- Image container -->
<div
class="flex items-center justify-center max-w-5xl max-h-[80vh]"
onclick={(e) => e.stopPropagation()}
ontouchstart={handleTouchStart}
ontouchend={handleTouchEnd}
>
<img
src={getImagePreview(images[lightboxImageIndex].imageData)}
alt={`Generated image ${lightboxImageIndex + 1}`}
class="max-w-full max-h-full object-contain rounded-lg"
/>
</div>
<!-- Navigation buttons -->
{#if images.length > 1}
<!-- Previous button -->
<button
class="absolute left-3 sm:left-8 top-1/2 -translate-y-1/2 p-2.5 sm:p-3 bg-black/40 hover:bg-black/60 rounded-full transition-colors disabled:opacity-30 disabled:cursor-not-allowed z-[10000] min-h-[44px] min-w-[44px] flex items-center justify-center"
onclick={(e) => {
e.stopPropagation();
previousImage();
}}
disabled={lightboxImageIndex === 0}
title="Previous image (← or swipe right)"
>
<svg class="h-6 w-6 sm:h-8 sm:w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<!-- Next button -->
<button
class="absolute right-3 sm:right-8 top-1/2 -translate-y-1/2 p-2.5 sm:p-3 bg-black/40 hover:bg-black/60 rounded-full transition-colors disabled:opacity-30 disabled:cursor-not-allowed z-[10000] min-h-[44px] min-w-[44px] flex items-center justify-center"
onclick={(e) => {
e.stopPropagation();
nextImage();
}}
disabled={lightboxImageIndex === images.length - 1}
title="Next image (→ or swipe left)"
>
<svg class="h-6 w-6 sm:h-8 sm:w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Image counter -->
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1.5 sm:px-4 sm:py-2 bg-black/60 rounded-lg text-white text-xs sm:text-sm font-medium z-[10000]">
{lightboxImageIndex + 1} / {images.length}
</div>
{/if}
</div>
{/if}

View file

@ -104,6 +104,17 @@
scrollRAF = null;
});
});
// Scroll to bottom when returning from gallery or other panels
$effect(() => {
if (ui.activePanel === 'story' && storyContainer) {
requestAnimationFrame(() => {
if (storyContainer) {
storyContainer.scrollTop = storyContainer.scrollHeight;
}
});
}
});
</script>
<div class="flex h-full flex-col">

View file

@ -21,6 +21,7 @@
import { Textarea } from '$lib/components/ui/textarea';
import { Badge } from '$lib/components/ui/badge';
import { cn } from '$lib/utils/cn';
import { isTouchDevice } from '$lib/utils/swipe';
// AbortController for cancelling ongoing requests
let abortController: AbortController | null = null;
@ -339,7 +340,15 @@
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
const isMobile = isTouchDevice();
// On mobile: Enter = new line, Shift+Enter = send
// On desktop: Enter = send, Shift+Enter = new line
const shouldSubmit = isMobile
? (e.key === 'Enter' && e.shiftKey)
: (e.key === 'Enter' && !e.shiftKey);
if (shouldSubmit) {
e.preventDefault();
handleSend();
}
@ -602,14 +611,14 @@
title="Send message"
>
{#if isGenerating}
<Loader2 class="h-5 w-5 animate-spin" />
<Loader2 class="h-6 w-6 animate-spin" />
{:else}
<Send class="h-5 w-5" />
<Send class="h-6 w-6" />
{/if}
</Button>
</div>
<div class="mt-2 text-xs text-muted-foreground hidden md:block text-center">
Press Enter to send, Shift+Enter for new line
Press {isTouchDevice() ? 'Shift+Enter to send, Enter for new line' : 'Enter to send, Shift+Enter for new line'}
</div>
</div>
</div>

View file

@ -170,6 +170,7 @@
let openingError = $state<string | null>(null);
let isEditingOpening = $state(false);
let openingDraft = $state("");
let manualOpeningText = $state("");
// Derived display variables - use translated version when available, fall back to original
const expandedSettingDisplay = $derived(expandedSettingTranslated ?? expandedSetting);
@ -878,6 +879,8 @@
isGeneratingOpening = true;
openingError = null;
clearOpeningEditState();
// Clear manual text since we're generating with AI
manualOpeningText = "";
const wizardData: WizardData = {
mode: selectedMode,
@ -986,6 +989,12 @@
openingDraft = "";
}
function clearGeneratedOpening() {
generatedOpening = null;
generatedOpeningTranslated = null;
openingError = null;
}
function startOpeningEdit() {
if (!generatedOpening || isEditingOpening) return;
openingError = null;
@ -1111,6 +1120,19 @@
async function createStory() {
if (!storyTitle.trim()) return;
// Use manual opening if provided
if (!generatedOpening && manualOpeningText.trim()) {
generatedOpening = {
scene: manualOpeningText.trim(),
title: storyTitle || "Untitled Story",
initialLocation: {
name: "Starting Location",
description: "The place where your journey begins.",
},
};
}
// Use card imported opening if available
if (!generatedOpening && cardImportedFirstMessage) {
generatedOpening = {
scene: cardImportedFirstMessage,
@ -1123,7 +1145,7 @@
}
if (!generatedOpening) {
openingError = "Please generate an opening scene first";
openingError = "Please provide an opening scene (write your own or generate with AI)";
return;
}
@ -2187,6 +2209,7 @@
{isEditingOpening}
{openingDraft}
{openingError}
{manualOpeningText}
{cardImportedFirstMessage}
{cardImportedAlternateGreetings}
{selectedGreetingIndex}
@ -2209,6 +2232,8 @@
onDraftChange={(v) => (openingDraft = v)}
onUseCardOpening={useCardOpening}
onClearCardOpening={clearCardOpening}
onManualOpeningChange={(v) => (manualOpeningText = v)}
onClearGenerated={clearGeneratedOpening}
/>
{/if}
</div>
@ -2234,8 +2259,8 @@
isGeneratingOpening ||
isRefiningOpening ||
isEditingOpening ||
!generatedOpening}
title={!generatedOpening ? "Generate an opening scene first" : ""}
(!generatedOpening && !manualOpeningText.trim() && !cardImportedFirstMessage)}
title={(!generatedOpening && !manualOpeningText.trim() && !cardImportedFirstMessage) ? "Provide an opening scene first" : ""}
>
<Play class="h-4 w-4" />
Begin Story

View file

@ -7,6 +7,7 @@
Sparkles,
PenTool,
Book,
X,
} from "lucide-svelte";
import type {
StoryMode,
@ -32,6 +33,7 @@
isEditingOpening: boolean;
openingDraft: string;
openingError: string | null;
manualOpeningText: string;
// Card import
cardImportedFirstMessage: string | null;
@ -60,6 +62,8 @@
onDraftChange: (value: string) => void;
onUseCardOpening: () => void;
onClearCardOpening: () => void;
onManualOpeningChange: (value: string) => void;
onClearGenerated: () => void;
}
let {
@ -71,6 +75,7 @@
isEditingOpening,
openingDraft,
openingError,
manualOpeningText,
cardImportedFirstMessage,
cardImportedAlternateGreetings,
selectedGreetingIndex,
@ -93,6 +98,8 @@
onDraftChange,
onUseCardOpening,
onClearCardOpening,
onClearGenerated,
onManualOpeningChange,
}: Props = $props();
// POV options for summary
@ -105,7 +112,7 @@
<div class="space-y-4">
<p class="text-surface-400">
Give your story a title and generate the opening scene.
Give your story a title and either write your own opening scene or generate one with AI.
</p>
<div>
@ -227,36 +234,83 @@
</div>
{/if}
<!-- Manual Opening Entry or AI Generation -->
{#if storyTitle.trim()}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
class="btn btn-secondary flex w-full items-center justify-center gap-2 sm:w-auto"
onclick={onGenerateOpening}
disabled={isGeneratingOpening || isRefiningOpening}
>
{#if isGeneratingOpening}
<Loader2 class="h-4 w-4 animate-spin" />
Generating Opening...
{:else}
<PenTool class="h-4 w-4" />
{generatedOpening
? "Regenerate Opening"
: "Generate Opening Scene"}
<div class="card bg-surface-900 p-4 space-y-3">
<h4 class="font-medium text-surface-200">Opening Scene</h4>
<p class="text-sm text-surface-400">
Write your own opening scene or generate one with AI
</p>
<!-- Manual Text Entry -->
<div>
<label class="mb-2 block text-sm font-medium text-surface-300">
Write Your Own Opening
</label>
<textarea
value={manualOpeningText}
oninput={(e) => onManualOpeningChange(e.currentTarget.value)}
placeholder="Write the opening scene of your story here... Describe the setting, introduce your character, set the mood. This will be the first entry in your adventure."
class="input min-h-[140px] resize-y text-sm"
rows="6"
disabled={isGeneratingOpening || isRefiningOpening || generatedOpening !== null}
></textarea>
{#if generatedOpening}
<p class="mt-2 text-xs text-amber-400">
AI-generated opening active. Clear it below to write your own.
</p>
{:else if manualOpeningText.trim()}
<p class="mt-2 text-xs text-green-400">
✓ Custom opening ready
</p>
{/if}
</button>
{#if !generatedOpening && !isGeneratingOpening && !cardImportedFirstMessage}
<span class="text-sm text-amber-400"
>Required to begin story</span
>
{:else if !generatedOpening && !isGeneratingOpening && cardImportedFirstMessage}
<span class="text-sm text-surface-400"
>Or use the imported opening above</span
>
{/if}
</div>
<!-- Divider -->
<div class="flex items-center gap-3">
<div class="flex-1 border-t border-surface-700"></div>
<span class="text-xs text-surface-500">OR</span>
<div class="flex-1 border-t border-surface-700"></div>
</div>
<!-- AI Generation Button -->
<div class="flex flex-col gap-3">
<div class="flex gap-2">
<button
class="btn btn-secondary flex-1 flex items-center justify-center gap-2"
onclick={onGenerateOpening}
disabled={isGeneratingOpening || isRefiningOpening}
>
{#if isGeneratingOpening}
<Loader2 class="h-4 w-4 animate-spin" />
Generating Opening...
{:else}
<PenTool class="h-4 w-4" />
{generatedOpening
? "Regenerate with AI"
: "Generate Opening with AI"}
{/if}
</button>
{#if generatedOpening}
<button
class="btn btn-secondary px-3"
onclick={onClearGenerated}
title="Clear AI-generated opening"
>
<X class="h-4 w-4" />
</button>
{/if}
</div>
{#if !generatedOpening && !isGeneratingOpening && !manualOpeningText.trim() && !cardImportedFirstMessage}
<span class="text-sm text-amber-400 text-center">
Either write your own opening or generate one with AI
</span>
{/if}
</div>
</div>
{:else}
<p class="text-sm text-surface-500">
Enter a title to generate the opening scene
Enter a title to continue
</p>
{/if}

View file

@ -32,7 +32,10 @@
}
async function resetTime() {
const confirmed = confirm('Reset time to zero? This cannot be undone.');
const confirmed = await new Promise<boolean>((resolve) => {
const result = confirm('Reset time to zero? This cannot be undone.');
resolve(result);
});
if (!confirmed) return;
await story.setTimeTracker({ years: 0, days: 0, hours: 0, minutes: 0 });
}

View file

@ -0,0 +1,179 @@
import { save } from '@tauri-apps/plugin-dialog';
import { writeFile, mkdir } from '@tauri-apps/plugin-fs';
import type { EmbeddedImage } from '$lib/types';
class ImageExportService {
private base64ToBytes(imageData: string): Uint8Array {
const base64Data = imageData.startsWith('data:')
? imageData.split(',')[1]
: imageData;
if (!base64Data) {
throw new Error('Invalid image data');
}
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
private filterImages(images: EmbeddedImage[], selectedIds?: Set<string>): EmbeddedImage[] {
return selectedIds ? images.filter(img => selectedIds.has(img.id)) : images;
}
async exportSingleImage(storyTitle: string, image: EmbeddedImage): Promise<boolean> {
try {
const selectedPath = await save({
defaultPath: `${storyTitle}-image.png`,
filters: [{ name: 'PNG Image', extensions: ['png'] }],
});
if (!selectedPath) return false;
const bytes = this.base64ToBytes(image.imageData);
await writeFile(selectedPath, bytes);
console.log(`[ImageExport] Exported to ${selectedPath}`);
return true;
} catch (error) {
console.error('[ImageExport] Single image export failed:', error);
throw error;
}
}
async exportImagesToZip(
storyTitle: string,
images: EmbeddedImage[],
selectedImageIds?: Set<string>
): Promise<boolean> {
const imagesToExport = this.filterImages(images, selectedImageIds);
if (imagesToExport.length === 0) {
throw new Error('No images to export');
}
try {
const { default: JSZip } = await import('jszip');
const selectedPath = await save({
defaultPath: `${storyTitle}-images.zip`,
filters: [{ name: 'ZIP Archive', extensions: ['zip'] }],
});
if (!selectedPath) return false;
const zip = new JSZip();
const errors: string[] = [];
for (let i = 0; i < imagesToExport.length; i++) {
const fileName = `image-${String(i + 1).padStart(3, '0')}.png`;
try {
const base64Data = imagesToExport[i].imageData.startsWith('data:')
? imagesToExport[i].imageData.split(',')[1]
: imagesToExport[i].imageData;
if (!base64Data) {
errors.push(`Image ${i + 1}: Invalid data`);
continue;
}
zip.file(fileName, base64Data, { base64: true });
} catch (error) {
errors.push(`Image ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
const zipData = await zip.generateAsync({ type: 'uint8array' });
await writeFile(selectedPath, zipData);
if (errors.length > 0) {
console.warn('[ImageExport] Completed with errors:', errors);
}
console.log(`[ImageExport] Exported ${imagesToExport.length - errors.length}/${imagesToExport.length} images`);
return true;
} catch (error) {
console.error('[ImageExport] ZIP export failed:', error);
throw error;
}
}
async exportImages(
storyTitle: string,
images: EmbeddedImage[],
selectedImageIds?: Set<string>
): Promise<boolean> {
const imagesToExport = this.filterImages(images, selectedImageIds);
if (imagesToExport.length === 0) {
throw new Error('No images to export');
}
return imagesToExport.length === 1
? this.exportSingleImage(storyTitle, imagesToExport[0])
: this.exportImagesToZip(storyTitle, images, selectedImageIds);
}
/**
* @deprecated Use exportImages() instead
*/
async exportImagesToDirectory(
storyTitle: string,
images: EmbeddedImage[],
selectedImageIds?: Set<string>
): Promise<boolean> {
const imagesToExport = this.filterImages(images, selectedImageIds);
if (imagesToExport.length === 0) {
throw new Error('No images to export');
}
try {
const selectedPath = await save({
defaultPath: `${storyTitle}-images`,
filters: [{ name: 'Folders', extensions: ['*'] }],
});
if (!selectedPath) return false;
try {
await mkdir(selectedPath, { recursive: true });
} catch {
// Directory might already exist
}
const errors: string[] = [];
for (let i = 0; i < imagesToExport.length; i++) {
const fileName = `image-${String(i + 1).padStart(3, '0')}.png`;
const filePath = `${selectedPath}/${fileName}`;
try {
const bytes = this.base64ToBytes(imagesToExport[i].imageData);
await writeFile(filePath, bytes);
} catch (error) {
errors.push(`Image ${i + 1}: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
if (errors.length === imagesToExport.length) {
throw new Error(`Failed to save images: ${errors.join(', ')}`);
}
if (errors.length > 0) {
console.warn('[ImageExport] Completed with errors:', errors);
}
console.log(`[ImageExport] Exported ${imagesToExport.length - errors.length}/${imagesToExport.length} images`);
return true;
} catch (error) {
console.error('[ImageExport] Export failed:', error);
throw error;
}
}
}
export const imageExportService = new ImageExportService();

View file

@ -101,6 +101,9 @@ class UIStore {
imageAnalysisInProgress = $state(false); // LLM analyzing narrative for imageable scenes
imagesGenerating = $state(0); // Count of images currently being generated
// Gallery image cache - persists across component unmounts
private galleryImageCache = new SvelteMap<string, EmbeddedImage[]>();
// Streaming state
streamingContent = $state('');
streamingReasoning = $state('');
@ -148,6 +151,23 @@ class UIStore {
this.currentRetryStoryId = storyId;
}
// Gallery image cache methods
getGalleryImages(storyId: string): EmbeddedImage[] | undefined {
return this.galleryImageCache.get(storyId);
}
setGalleryImages(storyId: string, images: EmbeddedImage[]): void {
this.galleryImageCache.set(storyId, images);
}
hasGalleryImages(storyId: string): boolean {
return this.galleryImageCache.has(storyId);
}
clearGalleryImages(storyId: string): void {
this.galleryImageCache.delete(storyId);
}
// RPG action choices (displayed after narration)
actionChoices = $state<ActionChoice[]>([]);
actionChoicesLoading = $state(false);

View file

@ -606,7 +606,7 @@ export interface AgenticSession {
}
// UI State types
export type ActivePanel = 'story' | 'library' | 'settings' | 'templates' | 'lorebook' | 'memory' | 'vault';
export type ActivePanel = 'story' | 'library' | 'settings' | 'templates' | 'lorebook' | 'memory' | 'vault' | 'gallery';
export type SidebarTab = 'characters' | 'locations' | 'inventory' | 'quests' | 'time' | 'branches';
export interface UIState {