From cf12b1ddd64f5759f4a94393ca887cb333e9296e Mon Sep 17 00:00:00 2001 From: Javedz678 Date: Sat, 24 Jan 2026 17:02:33 +0530 Subject: [PATCH] =?UTF-8?q?Ajouter=20une=20galerie=20pour=20les=20images?= =?UTF-8?q?=20int=C3=A9gr=C3=A9es.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src-tauri/capabilities/default.json | 2 + src/lib/components/layout/AppShell.svelte | 3 + src/lib/components/layout/Header.svelte | 11 + src/lib/components/story/GalleryTab.svelte | 453 +++++++++++++++++++++ src/lib/components/story/StoryView.svelte | 11 + src/lib/services/imageExport.ts | 179 ++++++++ src/lib/stores/ui.svelte.ts | 20 + src/lib/types/index.ts | 2 +- 9 files changed, 681 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/story/GalleryTab.svelte create mode 100644 src/lib/services/imageExport.ts diff --git a/package.json b/package.json index 5b9c0ac..ab8f41f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,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" }, diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a2cfa11..839e1f3 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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", diff --git a/src/lib/components/layout/AppShell.svelte b/src/lib/components/layout/AppShell.svelte index 440a56b..528c645 100644 --- a/src/lib/components/layout/AppShell.svelte +++ b/src/lib/components/layout/AppShell.svelte @@ -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 @@
{#if ui.activePanel === 'story' && story.currentStory} + {:else if ui.activePanel === 'gallery' && story.currentStory} + {:else if ui.activePanel === 'lorebook' && story.currentStory} {:else if ui.activePanel === 'memory' && story.currentStory} diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte index 37efb7c..66ac1b0 100644 --- a/src/lib/components/layout/Header.svelte +++ b/src/lib/components/layout/Header.svelte @@ -226,6 +226,17 @@ {/if} {#if story.currentStory} + + + +
+ + + + + + +
+ {/if} + + + + + {#if images.length > 0 && !isLoading} +
+ + + {selectedImageIds.size === 0 + ? 'Select images to download' + : selectedImageIds.size === images.length + ? 'All selected' + : `${selectedImageIds.size} selected`} + +
+ {/if} + + +
+ {#if isLoading} + +
+
+
+

Loading images...

+
+
+ {:else if images.length === 0} + +
+ +

No generated images yet

+

+ Generate images using the image generation feature in your story +

+
+ {:else} + +
+ {#each images as image, index (image.id)} +
+ +
+ 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;" + /> +
+ + +
openLightbox(index)} + > + {`Generated +
+ + +
+ +
+ + +
+
+ {#if image.model} +

+ Model: + {image.model} +

+ {/if} + {#if image.styleId} +

+ Style: + {image.styleId} +

+ {/if} + {#if image.width && image.height} +

+ Size: + {image.width}×{image.height} +

+ {/if} + {#if image.generationMode} +

+ Mode: + {image.generationMode} +

+ {/if} +
+
+
+ {/each} +
+ {/if} +
+ + + {#if images.length > 0 && !isLoading} +
+
+ +

+ {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.`} +

+
+
+ {/if} + + + +{#if lightboxOpen && images.length > 0} +
closeLightbox()} + role="dialog" + aria-modal="true" + > + + + + +
e.stopPropagation()} + ontouchstart={handleTouchStart} + ontouchend={handleTouchEnd} + > + {`Generated +
+ + + {#if images.length > 1} + + + + + + + +
+ {lightboxImageIndex + 1} / {images.length} +
+ {/if} +
+{/if} diff --git a/src/lib/components/story/StoryView.svelte b/src/lib/components/story/StoryView.svelte index 130c847..55353c1 100644 --- a/src/lib/components/story/StoryView.svelte +++ b/src/lib/components/story/StoryView.svelte @@ -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; + } + }); + } + });
diff --git a/src/lib/services/imageExport.ts b/src/lib/services/imageExport.ts new file mode 100644 index 0000000..37314fb --- /dev/null +++ b/src/lib/services/imageExport.ts @@ -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): EmbeddedImage[] { + return selectedIds ? images.filter(img => selectedIds.has(img.id)) : images; + } + + async exportSingleImage(storyTitle: string, image: EmbeddedImage): Promise { + 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 + ): Promise { + 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 + ): Promise { + 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 + ): Promise { + 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(); diff --git a/src/lib/stores/ui.svelte.ts b/src/lib/stores/ui.svelte.ts index dbb822a..5fa25a6 100644 --- a/src/lib/stores/ui.svelte.ts +++ b/src/lib/stores/ui.svelte.ts @@ -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(); + // 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([]); actionChoicesLoading = $state(false); diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 43970ce..bd7cc27 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -618,7 +618,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 {