mirror of
https://github.com/AventurasTeam/Aventuras.git
synced 2026-04-28 03:40:11 +00:00
Merge remote-tracking branch 'origin/master' into shadcn-UI-rework
This commit is contained in:
commit
79cd91a6fc
16 changed files with 916 additions and 91 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
432
src/lib/components/story/GalleryTab.svelte
Normal file
432
src/lib/components/story/GalleryTab.svelte
Normal 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}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
179
src/lib/services/imageExport.ts
Normal file
179
src/lib/services/imageExport.ts
Normal 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();
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue