diff --git a/components/InputBar.tsx b/components/InputBar.tsx index 7bc511e..8809662 100644 --- a/components/InputBar.tsx +++ b/components/InputBar.tsx @@ -192,16 +192,17 @@ const InputBar: React.FC = () => { onClick={() => fileInputRef.current?.click()} disabled={isAnyLoading} className="px-4 py-2 bg-gray-600 text-white font-semibold rounded-md shadow-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:focus:ring-offset-gray-800 disabled:bg-gray-400 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition duration-300 ease-in-out" - title="Select session JSON file from disk" + title="Import session from a JSON file on your computer" > - ๐Ÿ“ File + ๐Ÿ“ Import diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index cd82ad9..71d11da 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -11,7 +11,8 @@ import DiffPanel from './settings/DiffPanel'; import PromptPanel from './settings/PromptPanel'; import TemplatePanel from './settings/TemplatePanel'; import AdvancedPanel from './settings/AdvancedPanel'; -import { SettingsTabs, type SettingsTabConfig } from './settings/SettingsTabs'; +import GalleryPanel from './settings/GalleryPanel'; +import { SettingsSidebar, type SidebarSection } from './settings/SettingsSidebar'; import { SettingsModalProvider, ParameterSupportState } from './settings/SettingsModalContext'; import DisplayPanel from './settings/DisplayPanel'; import SessionActions from './settings/SessionActions'; @@ -47,18 +48,46 @@ const SettingsModal: React.FC = ({ isOpen, onClose }) => { }))); const [currentSettings, setCurrentSettings] = useState(settings); - type SettingsTabId = 'providers' | 'general' | 'features' | 'export' | 'templates' | 'audio' | 'advanced' | 'metadata'; - const [activeTab, setActiveTab] = useState('providers'); - const tabConfig: SettingsTabConfig[] = useMemo( + type SettingsPanelId = 'providers' | 'prompt' | 'advanced' | 'display' | 'audio' | 'diff' | 'templates' | 'metadata' | 'gallery' | 'export'; + const [activePanel, setActivePanel] = useState('providers'); + const sidebarSections: SidebarSection[] = useMemo( () => [ - { id: 'providers', label: 'Providers' }, - { id: 'general', label: 'General' }, - { id: 'features', label: 'Features' }, - { id: 'export', label: 'Export' }, - { id: 'metadata', label: 'Metadata' }, - { id: 'templates', label: 'Templates' }, - { id: 'audio', label: 'Audio' }, - { id: 'advanced', label: 'Advanced' }, + { + id: 'settings', + label: 'Settings', + icon: 'โš™๏ธ', + items: [ + { id: 'providers', label: 'Providers' }, + { id: 'prompt', label: 'Prompt' }, + { id: 'advanced', label: 'Advanced' }, + ], + }, + { + id: 'features', + label: 'Features', + icon: 'โœจ', + items: [ + { id: 'display', label: 'Display' }, + { id: 'audio', label: 'Audio' }, + { id: 'diff', label: 'Diff Heatmap' }, + ], + }, + { + id: 'workspace', + label: 'Workspace', + icon: '๐Ÿ“', + items: [ + { id: 'templates', label: 'Templates' }, + { id: 'metadata', label: 'Metadata' }, + { id: 'gallery', label: 'Gallery' }, + ], + }, + { + id: 'export-section', + label: 'Export', + icon: '๐Ÿ“ค', + items: [{ id: 'export', label: 'Export' }], + }, ], [] ); @@ -127,38 +156,27 @@ const SettingsModal: React.FC = ({ isOpen, onClose }) => { - setActiveTab(tabId as SettingsTabId)} - /> - -
- {activeTab === 'providers' && } - {activeTab === 'general' && ( - <> - - - - )} - - {activeTab === 'features' && ()} - - {activeTab === 'export' && ( - setActiveTab('metadata')} /> - )} - - {activeTab === 'metadata' && ( - - )} - - {activeTab === 'templates' && ()} - - {activeTab === 'audio' && ( - - )} - - {activeTab === 'advanced' && } +
+ setActivePanel(panelId as SettingsPanelId)} + /> + +
+ {activePanel === 'providers' && } + {activePanel === 'prompt' && } + {activePanel === 'advanced' && } + {activePanel === 'display' && } + {activePanel === 'audio' && } + {activePanel === 'diff' && } + {activePanel === 'templates' && } + {activePanel === 'metadata' && } + {activePanel === 'gallery' && } + {activePanel === 'export' && ( + setActivePanel('metadata')} /> + )} +
({ + useAppStore: vi.fn(), +})); + +// Mock useBlobUrl hook +vi.mock('../../hooks/useBlobUrl', () => ({ + useBlobUrl: vi.fn(() => null), +})); + +// Mock useNovelMetadata hook +vi.mock('../../hooks/useNovelMetadata', () => ({ + useNovelMetadata: vi.fn(() => ({ + novelMetadata: null, + setCoverImage: vi.fn(), + })), +})); + +import { useAppStore } from '../../store'; +import { useBlobUrl } from '../../hooks/useBlobUrl'; +import { useNovelMetadata } from '../../hooks/useNovelMetadata'; + +describe('GalleryPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows empty state when no images exist', () => { + vi.mocked(useAppStore).mockReturnValue(new Map()); + + render(); + + expect(screen.getByText('No images generated yet')).toBeInTheDocument(); + expect(screen.getByText('Generate illustrations in chapters to see them here')).toBeInTheDocument(); + }); + + it('displays chapter sections with images', () => { + const mockChapters = new Map([ + [ + 'chapter-1', + { + title: 'Chapter 1: The Beginning', + translationResult: { + translatedTitle: 'Chapter 1: The Beginning', + suggestedIllustrations: [ + { + placementMarker: 'img-001', + imagePrompt: 'A beautiful sunrise', + imageCacheKey: { chapterId: 'chapter-1', placementMarker: 'img-001', version: 1 }, + }, + { + placementMarker: 'img-002', + imagePrompt: 'A forest path', + generatedImage: { imageData: 'data:image/png;base64,abc123' }, + }, + ], + }, + }, + ], + ]); + + vi.mocked(useAppStore).mockReturnValue(mockChapters); + vi.mocked(useBlobUrl).mockReturnValue('blob:test-url'); + + render(); + + expect(screen.getByText('Image Gallery')).toBeInTheDocument(); + expect(screen.getByText('Chapter 1: The Beginning')).toBeInTheDocument(); + expect(screen.getByText('2 images')).toBeInTheDocument(); + }); + + it('shows cover status indicator', () => { + const mockChapters = new Map([ + [ + 'chapter-1', + { + title: 'Test Chapter', + translationResult: { + translatedTitle: 'Test Chapter', + suggestedIllustrations: [ + { + placementMarker: 'img-001', + imagePrompt: 'Test image', + imageCacheKey: { chapterId: 'chapter-1', placementMarker: 'img-001', version: 1 }, + }, + ], + }, + }, + ], + ]); + + vi.mocked(useAppStore).mockReturnValue(mockChapters); + vi.mocked(useNovelMetadata).mockReturnValue({ + novelMetadata: { + coverImage: { chapterId: 'chapter-1', marker: 'img-001', cacheKey: null }, + }, + setCoverImage: vi.fn(), + handleNovelMetadataChange: vi.fn(), + } as any); + vi.mocked(useBlobUrl).mockReturnValue('blob:test-url'); + + render(); + + expect(screen.getByText('Cover: โœ“ Selected')).toBeInTheDocument(); + }); + + it('shows "Cover: None" when no cover selected', () => { + const mockChapters = new Map([ + [ + 'chapter-1', + { + title: 'Test Chapter', + translationResult: { + translatedTitle: 'Test Chapter', + suggestedIllustrations: [ + { + placementMarker: 'img-001', + imagePrompt: 'Test image', + imageCacheKey: { chapterId: 'chapter-1', placementMarker: 'img-001', version: 1 }, + }, + ], + }, + }, + ], + ]); + + vi.mocked(useAppStore).mockReturnValue(mockChapters); + vi.mocked(useNovelMetadata).mockReturnValue({ + novelMetadata: null, + setCoverImage: vi.fn(), + handleNovelMetadataChange: vi.fn(), + } as any); + vi.mocked(useBlobUrl).mockReturnValue('blob:test-url'); + + render(); + + expect(screen.getByText('Cover: None')).toBeInTheDocument(); + }); + + it('can collapse and expand chapter sections', async () => { + const user = userEvent.setup(); + const mockChapters = new Map([ + [ + 'chapter-1', + { + title: 'Test Chapter', + translationResult: { + translatedTitle: 'Test Chapter', + suggestedIllustrations: [ + { + placementMarker: 'img-001', + imagePrompt: 'Test image', + imageCacheKey: { chapterId: 'chapter-1', placementMarker: 'img-001', version: 1 }, + }, + ], + }, + }, + ], + ]); + + vi.mocked(useAppStore).mockReturnValue(mockChapters); + vi.mocked(useBlobUrl).mockReturnValue('blob:test-url'); + + render(); + + // Find the collapse button and click it + const collapseButton = screen.getByRole('button', { name: /Test Chapter/i }); + + // Initially expanded (โ–ผ indicator) + expect(collapseButton.textContent).toContain('โ–ผ'); + + await user.click(collapseButton); + + // After collapse (โ–ถ indicator) + expect(collapseButton.textContent).toContain('โ–ถ'); + }); + + it('filters out chapters without images', () => { + const mockChapters = new Map([ + [ + 'chapter-1', + { + title: 'Chapter with images', + translationResult: { + translatedTitle: 'Chapter with images', + suggestedIllustrations: [ + { + placementMarker: 'img-001', + imagePrompt: 'Has image', + imageCacheKey: { chapterId: 'chapter-1', placementMarker: 'img-001', version: 1 }, + }, + ], + }, + }, + ], + [ + 'chapter-2', + { + title: 'Chapter without images', + translationResult: { + translatedTitle: 'Chapter without images', + suggestedIllustrations: [], + }, + }, + ], + [ + 'chapter-3', + { + title: 'Chapter with no translation', + translationResult: null, + }, + ], + ]); + + vi.mocked(useAppStore).mockReturnValue(mockChapters); + vi.mocked(useBlobUrl).mockReturnValue('blob:test-url'); + + render(); + + expect(screen.getByText('Chapter with images')).toBeInTheDocument(); + expect(screen.queryByText('Chapter without images')).not.toBeInTheDocument(); + expect(screen.queryByText('Chapter with no translation')).not.toBeInTheDocument(); + }); +}); diff --git a/components/settings/GalleryPanel.tsx b/components/settings/GalleryPanel.tsx new file mode 100644 index 0000000..5398a48 --- /dev/null +++ b/components/settings/GalleryPanel.tsx @@ -0,0 +1,236 @@ +import React, { useMemo, useState, useCallback } from 'react'; +import { useAppStore } from '../../store'; +import { useBlobUrl } from '../../hooks/useBlobUrl'; +import { useNovelMetadata } from '../../hooks/useNovelMetadata'; +import type { SuggestedIllustration, ImageCacheKey } from '../../types'; +import ImageLightbox from './ImageLightbox'; + +export interface GalleryImage { + chapterId: string; + chapterTitle: string; + marker: string; + prompt: string; + imageCacheKey: ImageCacheKey | null; + // For legacy support + legacyImageData?: string; +} + +export const GalleryPanel: React.FC = () => { + const chapters = useAppStore((s) => s.chapters); + const { novelMetadata, setCoverImage } = useNovelMetadata(chapters); + + const [lightboxImage, setLightboxImage] = useState(null); + + // Collect all images from all chapters + const imagesByChapter = useMemo(() => { + const result: Record = {}; + + // chapters is a Map + chapters.forEach((chapter, chapterId) => { + const illustrations = chapter.translationResult?.suggestedIllustrations || []; + const chapterTitle = chapter.translationResult?.translatedTitle || chapter.title || chapterId; + + const images = illustrations + .filter((ill: SuggestedIllustration) => { + // Has any kind of image data + return ( + ill.imageCacheKey || + ill.generatedImage?.imageCacheKey || + ill.generatedImage?.imageData || + ill.url + ); + }) + .map((ill: SuggestedIllustration): GalleryImage => { + const cacheKey = ill.imageCacheKey || ill.generatedImage?.imageCacheKey || null; + const legacyData = + ill.generatedImage?.imageData || + ill.url || + undefined; + + return { + chapterId, + chapterTitle, + marker: ill.placementMarker, + prompt: ill.imagePrompt, + imageCacheKey: cacheKey, + legacyImageData: legacyData, + }; + }); + + if (images.length > 0) { + result[chapterId] = images; + } + }); + + return result; + }, [chapters]); + + const allImages = useMemo(() => { + return Object.values(imagesByChapter).flat(); + }, [imagesByChapter]); + + const handleImageClick = useCallback((image: GalleryImage) => { + setLightboxImage(image); + }, []); + + const handleSetCover = useCallback( + (image: GalleryImage) => { + setCoverImage({ + chapterId: image.chapterId, + marker: image.marker, + cacheKey: image.imageCacheKey, + }); + }, + [setCoverImage] + ); + + const handleCloseLightbox = useCallback(() => { + setLightboxImage(null); + }, []); + + const isCover = useCallback( + (image: GalleryImage) => { + if (!novelMetadata?.coverImage) return false; + return ( + novelMetadata.coverImage.chapterId === image.chapterId && + novelMetadata.coverImage.marker === image.marker + ); + }, + [novelMetadata?.coverImage] + ); + + if (Object.keys(imagesByChapter).length === 0) { + return ( +
+
๐Ÿ–ผ๏ธ
+

No images generated yet

+

+ Generate illustrations in chapters to see them here +

+
+ ); + } + + return ( +
+
+

+ Image Gallery +

+ + Cover: {novelMetadata?.coverImage ? 'โœ“ Selected' : 'None'} + +
+ + {Object.entries(imagesByChapter).map(([chapterId, images]) => ( + + ))} + + {lightboxImage && ( + + )} +
+ ); +}; + +interface ChapterSectionProps { + title: string; + images: GalleryImage[]; + onImageClick: (image: GalleryImage) => void; + isCover: (image: GalleryImage) => boolean; +} + +const ChapterSection: React.FC = ({ + title, + images, + onImageClick, + isCover, +}) => { + const [collapsed, setCollapsed] = useState(false); + + return ( +
+ + + {!collapsed && ( +
+ {images.map((image, idx) => ( + onImageClick(image)} + isCover={isCover(image)} + /> + ))} +
+ )} +
+ ); +}; + +interface GalleryThumbnailProps { + image: GalleryImage; + onClick: () => void; + isCover: boolean; +} + +const GalleryThumbnail: React.FC = ({ image, onClick, isCover }) => { + // Use blob URL hook for cache key images + const blobUrl = useBlobUrl(image.imageCacheKey); + + // Determine final image URL + const imageUrl = blobUrl || image.legacyImageData || null; + + if (!imageUrl) { + return ( +
+ ? +
+ ); + } + + return ( +
+ {image.prompt} + {isCover && ( +
+ ๐Ÿ† Cover +
+ )} +
+
+ ); +}; + +export default GalleryPanel; diff --git a/components/settings/ImageLightbox.test.tsx b/components/settings/ImageLightbox.test.tsx new file mode 100644 index 0000000..27561e9 --- /dev/null +++ b/components/settings/ImageLightbox.test.tsx @@ -0,0 +1,167 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ImageLightbox } from './ImageLightbox'; +import type { GalleryImage } from './GalleryPanel'; + +// Mock useBlobUrl hook +vi.mock('../../hooks/useBlobUrl', () => ({ + useBlobUrl: vi.fn(() => 'blob:test-image-url'), +})); + +const createMockImage = (id: string, prompt: string): GalleryImage => ({ + chapterId: `chapter-${id}`, + chapterTitle: `Chapter ${id}`, + marker: `img-${id}`, + prompt, + imageCacheKey: { chapterId: `chapter-${id}`, placementMarker: `img-${id}`, version: 1 }, +}); + +describe('ImageLightbox', () => { + const mockImages: GalleryImage[] = [ + createMockImage('1', 'First image prompt'), + createMockImage('2', 'Second image prompt'), + createMockImage('3', 'Third image prompt'), + ]; + + const defaultProps = { + image: mockImages[0], + allImages: mockImages, + onClose: vi.fn(), + onSetCover: vi.fn(), + isCover: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders image and info panel', () => { + render(); + + expect(screen.getByText(/Chapter 1/)).toBeInTheDocument(); + expect(screen.getByText(/Image 1 of 3/)).toBeInTheDocument(); + expect(screen.getByText(/First image prompt/)).toBeInTheDocument(); + }); + + it('displays "Set as Cover" button when not cover', () => { + render(); + + expect(screen.getByRole('button', { name: /Set as Cover/i })).toBeInTheDocument(); + }); + + it('displays "Cover Selected" when already cover', () => { + render(); + + expect(screen.getByRole('button', { name: /Cover Selected/i })).toBeInTheDocument(); + }); + + it('calls onClose when close button clicked', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + + render(); + + await user.click(screen.getByRole('button', { name: /Close/i })); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when Escape key pressed', () => { + const onClose = vi.fn(); + + render(); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('navigates to next image when right arrow clicked', async () => { + const user = userEvent.setup(); + + render(); + + expect(screen.getByText(/Image 1 of 3/)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Next image/i })); + + expect(screen.getByText(/Image 2 of 3/)).toBeInTheDocument(); + expect(screen.getByText(/Second image prompt/)).toBeInTheDocument(); + }); + + it('navigates to previous image when left arrow clicked', async () => { + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: /Previous image/i })); + + // Wraps around to last image + expect(screen.getByText(/Image 3 of 3/)).toBeInTheDocument(); + expect(screen.getByText(/Third image prompt/)).toBeInTheDocument(); + }); + + it('navigates with arrow keys', () => { + render(); + + expect(screen.getByText(/Image 1 of 3/)).toBeInTheDocument(); + + fireEvent.keyDown(window, { key: 'ArrowRight' }); + expect(screen.getByText(/Image 2 of 3/)).toBeInTheDocument(); + + fireEvent.keyDown(window, { key: 'ArrowLeft' }); + expect(screen.getByText(/Image 1 of 3/)).toBeInTheDocument(); + }); + + it('wraps around navigation at boundaries', async () => { + const user = userEvent.setup(); + + // Start at first image + render(); + + expect(screen.getByText(/Image 1 of 3/)).toBeInTheDocument(); + + // Go backwards - should wrap to last + await user.click(screen.getByRole('button', { name: /Previous image/i })); + expect(screen.getByText(/Image 3 of 3/)).toBeInTheDocument(); + + // Go forward - should wrap to first + await user.click(screen.getByRole('button', { name: /Next image/i })); + expect(screen.getByText(/Image 1 of 3/)).toBeInTheDocument(); + }); + + it('calls onSetCover when "Set as Cover" clicked', async () => { + const user = userEvent.setup(); + const onSetCover = vi.fn(); + + render(); + + await user.click(screen.getByRole('button', { name: /Set as Cover/i })); + expect(onSetCover).toHaveBeenCalledWith(mockImages[0]); + }); + + it('hides navigation arrows when only one image', () => { + render( + + ); + + expect(screen.queryByRole('button', { name: /Previous image/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Next image/i })).not.toBeInTheDocument(); + }); + + it('closes when backdrop clicked', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + + const { container } = render(); + + // Click the backdrop (the outermost div) + const backdrop = container.querySelector('.fixed.inset-0'); + if (backdrop) { + await user.click(backdrop); + expect(onClose).toHaveBeenCalled(); + } + }); +}); diff --git a/components/settings/ImageLightbox.tsx b/components/settings/ImageLightbox.tsx new file mode 100644 index 0000000..c022a00 --- /dev/null +++ b/components/settings/ImageLightbox.tsx @@ -0,0 +1,161 @@ +import React, { useEffect, useCallback, useState } from 'react'; +import { useBlobUrl } from '../../hooks/useBlobUrl'; +import type { GalleryImage } from './GalleryPanel'; + +interface ImageLightboxProps { + image: GalleryImage; + allImages: GalleryImage[]; + onClose: () => void; + onSetCover: (image: GalleryImage) => void; + isCover: boolean; +} + +export const ImageLightbox: React.FC = ({ + image, + allImages, + onClose, + onSetCover, + isCover: initialIsCover, +}) => { + const [currentIndex, setCurrentIndex] = useState(() => + allImages.findIndex( + (img) => img.chapterId === image.chapterId && img.marker === image.marker + ) + ); + + const currentImage = allImages[currentIndex] || image; + + // Check if current image is cover + const [isCoverCurrent, setIsCoverCurrent] = useState(initialIsCover); + + useEffect(() => { + // Update cover status when navigating + setIsCoverCurrent( + currentImage.chapterId === image.chapterId && currentImage.marker === image.marker + ? initialIsCover + : false + ); + }, [currentImage, image, initialIsCover]); + + const goToPrev = useCallback(() => { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : allImages.length - 1)); + }, [allImages.length]); + + const goToNext = useCallback(() => { + setCurrentIndex((prev) => (prev < allImages.length - 1 ? prev + 1 : 0)); + }, [allImages.length]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + if (e.key === 'ArrowLeft') goToPrev(); + if (e.key === 'ArrowRight') goToNext(); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose, goToPrev, goToNext]); + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + + const handleSetCover = () => { + onSetCover(currentImage); + setIsCoverCurrent(true); + }; + + return ( +
+ {/* Close button */} + + + {/* Navigation arrows */} + {allImages.length > 1 && ( + <> + + + + )} + + {/* Main content */} +
e.stopPropagation()} + > + + + {/* Info panel */} +
+
+ {currentImage.chapterTitle} โ€ข Image {currentIndex + 1} of {allImages.length} +
+
+ Prompt: "{currentImage.prompt}" +
+ + +
+
+
+ ); +}; + +interface LightboxImageProps { + image: GalleryImage; +} + +const LightboxImage: React.FC = ({ image }) => { + // Use blob URL hook for cache key images + const blobUrl = useBlobUrl(image.imageCacheKey); + const imageUrl = blobUrl || image.legacyImageData || null; + + if (!imageUrl) { + return ( +
+ ? +
+ ); + } + + return ( + {image.prompt} + ); +}; + +export default ImageLightbox; diff --git a/components/settings/SettingsSidebar.test.tsx b/components/settings/SettingsSidebar.test.tsx new file mode 100644 index 0000000..dd9adc4 --- /dev/null +++ b/components/settings/SettingsSidebar.test.tsx @@ -0,0 +1,126 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; +import { SettingsSidebar, type SidebarSection } from './SettingsSidebar'; + +const mockSections: SidebarSection[] = [ + { + id: 'settings', + label: 'Settings', + icon: 'โš™๏ธ', + items: [ + { id: 'providers', label: 'Providers' }, + { id: 'prompt', label: 'Prompt' }, + { id: 'advanced', label: 'Advanced' }, + ], + }, + { + id: 'features', + label: 'Features', + icon: 'โœจ', + items: [ + { id: 'display', label: 'Display' }, + { id: 'audio', label: 'Audio' }, + ], + }, +]; + +describe('SettingsSidebar', () => { + it('renders all sections and items', () => { + const onSelect = vi.fn(); + render( + + ); + + expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.getByText('Features')).toBeInTheDocument(); + expect(screen.getByText('Providers')).toBeInTheDocument(); + expect(screen.getByText('Audio')).toBeInTheDocument(); + }); + + it('calls onSelect when item clicked', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render( + + ); + + await user.click(screen.getByText('Audio')); + expect(onSelect).toHaveBeenCalledWith('audio'); + }); + + it('highlights active item', () => { + const onSelect = vi.fn(); + render( + + ); + + const activeItem = screen.getByText('Providers').closest('button'); + expect(activeItem).toHaveClass('bg-blue-600'); + }); + + it('can collapse and expand sections', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + render( + + ); + + // Initially all items are visible + expect(screen.getByText('Providers')).toBeInTheDocument(); + + // Click Settings section header to collapse + await user.click(screen.getByText('Settings')); + + // Items should be hidden after collapse + expect(screen.queryByText('Providers')).not.toBeInTheDocument(); + + // Click again to expand + await user.click(screen.getByText('Settings')); + + // Items visible again + expect(screen.getByText('Providers')).toBeInTheDocument(); + }); + + it('hides items marked as hidden', () => { + const onSelect = vi.fn(); + const sectionsWithHidden: SidebarSection[] = [ + { + id: 'test', + label: 'Test', + icon: '๐Ÿงช', + items: [ + { id: 'visible', label: 'Visible' }, + { id: 'hidden', label: 'Hidden', hidden: true }, + ], + }, + ]; + + render( + + ); + + expect(screen.getByText('Visible')).toBeInTheDocument(); + expect(screen.queryByText('Hidden')).not.toBeInTheDocument(); + }); +}); diff --git a/components/settings/SettingsSidebar.tsx b/components/settings/SettingsSidebar.tsx new file mode 100644 index 0000000..6d120b5 --- /dev/null +++ b/components/settings/SettingsSidebar.tsx @@ -0,0 +1,80 @@ +import React, { useState } from 'react'; + +export interface SidebarItem { + id: string; + label: string; + hidden?: boolean; +} + +export interface SidebarSection { + id: string; + label: string; + icon: string; + items: SidebarItem[]; +} + +interface SettingsSidebarProps { + sections: SidebarSection[]; + activeItem: string; + onSelect: (itemId: string) => void; +} + +export const SettingsSidebar: React.FC = ({ + sections, + activeItem, + onSelect, +}) => { + const [collapsedSections, setCollapsedSections] = useState>(new Set()); + + const toggleSection = (sectionId: string) => { + setCollapsedSections((prev) => { + const next = new Set(prev); + if (next.has(sectionId)) { + next.delete(sectionId); + } else { + next.add(sectionId); + } + return next; + }); + }; + + return ( +
+ {sections.map((section) => ( +
+ + {!collapsedSections.has(section.id) && ( +
+ {section.items + .filter((item) => !item.hidden) + .map((item) => ( + + ))} +
+ )} +
+ ))} +
+ ); +}; + +export default SettingsSidebar; diff --git a/components/settings/types.ts b/components/settings/types.ts index 7ed53e2..ace37d8 100644 --- a/components/settings/types.ts +++ b/components/settings/types.ts @@ -1,4 +1,15 @@ import type { NovelMetadata } from '../../types/novel'; +import type { ImageCacheKey } from '../../types'; + +/** + * Cover image reference from the gallery + * Points to a generated image in a chapter + */ +export interface CoverImageRef { + chapterId: string; + marker: string; + cacheKey: ImageCacheKey | null; +} export type PublisherMetadata = NovelMetadata & { title?: string; @@ -10,4 +21,6 @@ export type PublisherMetadata = NovelMetadata & { translationApproach?: string; versionDescription?: string; contentNotes?: string; + /** Selected cover image from the gallery */ + coverImage?: CoverImageRef; }; diff --git a/docs/plans/2025-12-28-publish-to-library-design.md b/docs/plans/2025-12-28-publish-to-library-design.md new file mode 100644 index 0000000..2e5450f --- /dev/null +++ b/docs/plans/2025-12-28-publish-to-library-design.md @@ -0,0 +1,215 @@ +# Publish to Library Feature Design + +**Date:** 2025-12-28 +**Status:** Approved + +## Overview + +Add a "Publish to Library" button to the export modal that allows users to save their translation directly to a local git repo (lexiconforge-novels), generating/updating both `metadata.json` and `session.json` files. + +## User Flow + +``` +Step 1: User clicks "Publish to Library" button in export modal + โ†“ +Step 2: Folder picker opens โ†’ user selects novel folder + (e.g., lexiconforge-novels/novels/dungeon-defense-wn/) + โ†“ +Step 3: App checks: Does metadata.json exist in this folder? + โ†“ + โ”œโ”€ YES โ†’ "Update existing book or add new version?" + โ”‚ [Update Stats Only] [Add New Version] + โ”‚ + โ””โ”€ NO โ†’ "Create new book" form + (novel title, author, language, etc.) + โ†“ +Step 4: If "Add New Version" or "New Book": + Show version details form: + - Version name (e.g., "Complete AI Translation v2") + - Translator name + - Description + - Style dropdown (faithful/liberal/etc.) + โ†“ +Step 5: App writes files: + - session.json (overwrites or new file) + - metadata.json (updates stats or creates new) + โ†“ +Step 6: If new book AND registry.json found in parent: + "Update registry.json?" โ†’ [Yes] [No] + โ†“ +Step 7: Success message with git commands: + "Files written! Run: git add . && git commit -m 'Update X' && git push" +``` + +## UI Changes + +### A. New button in export modal (SessionInfo.tsx) + +Add third button after "Export JSON" and "Export EPUB": + +```tsx + +``` + +### B. New state for publish flow + +```tsx +const [publishStep, setPublishStep] = useState< + 'idle' | 'picking-folder' | 'confirm-action' | 'version-form' | 'writing' | 'done' +>('idle'); +const [existingMetadata, setExistingMetadata] = useState(null); +const [selectedDirHandle, setSelectedDirHandle] = useState(null); +``` + +### C. Publish modal states + +| Scenario | UI shown | +|----------|----------| +| Existing metadata.json found | Two buttons: "Update Stats Only" / "Add New Version" | +| No metadata.json | Full "Create New Book" form | +| "Add New Version" clicked | Version details form | + +### D. Version details form fields + +- Version name (text input) +- Translator name (text input) +- Translator website (optional URL) +- Description (textarea) +- Style (dropdown: faithful / liberal / image-heavy / other) +- Completion status (dropdown: In Progress / Complete) + +## Service Layer Changes + +### A. New method: `ExportService.publishToLibrary()` + +```typescript +static async publishToLibrary(options: { + mode: 'update-stats' | 'new-version' | 'new-book'; + dirHandle: FileSystemDirectoryHandle; + versionDetails?: { + versionName: string; + translatorName: string; + translatorLink?: string; + description: string; + style: 'faithful' | 'liberal' | 'image-heavy' | 'other'; + completionStatus: 'In Progress' | 'Complete'; + }; + novelDetails?: { // Only for new-book + title: string; + author: string; + originalLanguage: string; + genres?: string[]; + description?: string; + }; +}): Promise<{ success: boolean; filesWritten: string[]; registryUpdated: boolean }> +``` + +### B. Logic inside publishToLibrary() + +1. Generate session.json from current IndexedDB data (reuse generateQuickExport + include images) + +2. If mode === 'update-stats': + - Read existing metadata.json + - Recalculate stats (chapters, images, footnotes, etc.) + - Update lastUpdated timestamp + - Write back metadata.json + session.json + +3. If mode === 'new-version': + - Read existing metadata.json + - Append new version to versions[] array + - Recalculate stats + - Write metadata.json + new session file + +4. If mode === 'new-book': + - Generate fresh metadata.json with novelDetails + versionDetails + - Write metadata.json + session.json + - Check parent folder for registry.json + - If found, offer to update it + +5. Return summary of what was written + +### C. Helper: detectExistingNovel() + +```typescript +static async detectExistingNovel( + dirHandle: FileSystemDirectoryHandle +): Promise<{ exists: boolean; metadata?: NovelEntry }> +``` + +## File Outputs + +### What gets written for each mode + +| Mode | metadata.json | session.json | registry.json | +|------|---------------|--------------|---------------| +| Update Stats Only | Update stats + lastUpdated | Overwrite | No change | +| New Version | Append to versions[] | New file (e.g., session-v2.json) | No change | +| New Book | Create new | Create new | Add entry (optional) | + +### Auto-computed stats in metadata.json + +```json +{ + "lastUpdated": "2025-12-28", + "versions": [{ + "stats": { + "content": { + "totalRawChapters": 509, + "totalTranslatedChapters": 350, + "totalImages": 463, + "totalFootnotes": 462, + "avgImagesPerChapter": 1.32, + "avgFootnotesPerChapter": 1.32 + }, + "translation": { + "totalCost": 45.23, + "totalTokens": 2500000, + "mostUsedModel": "OpenRouter/deepseek-chat" + } + }, + "chapterRange": { "from": 1, "to": 350 }, + "completionStatus": "In Progress" + }] +} +``` + +### Session filename convention + +- Primary: `session.json` +- Additional versions: `session-v2-translator-name.json` +- metadata.json `sessionJsonUrl` points to the correct file + +## Implementation Plan + +### Files to modify + +1. **`components/SessionInfo.tsx`** (~100 LOC) + - Add "Publish to Library" button + - Add publish flow state management + - Add publish modal UI + +2. **`services/exportService.ts`** (~150 LOC) + - Add `publishToLibrary()` method + - Add `detectExistingNovel()` helper + - Add stats recalculation logic + +### Optional: Extract to new component + +Could extract publish UI to `components/PublishToLibraryModal.tsx` (~200 LOC) if SessionInfo.tsx gets too large. + +## Existing Infrastructure + +Already implemented in `exportService.ts`: +- `generateMetadataFile()` - generates metadata with auto-computed stats +- `saveToDirectory()` - File System Access API for folder picking +- `updateRegistry()` - updates registry.json +- `generateQuickExport()` - generates session.json data + +## Estimated Scope + +~300-400 lines of new/modified code diff --git a/docs/plans/2025-12-29-gallery-sidebar-implementation.md b/docs/plans/2025-12-29-gallery-sidebar-implementation.md new file mode 100644 index 0000000..518416e --- /dev/null +++ b/docs/plans/2025-12-29-gallery-sidebar-implementation.md @@ -0,0 +1,858 @@ +# Gallery & Sidebar Navigation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace horizontal tabs in SettingsModal with sidebar navigation and add an image gallery with cover selection for EPUB export. + +**Architecture:** Create a new SettingsSidebar component that renders collapsible sections. Extract "General" into "Prompt" (under Settings) and move "Display" into Features. Add GalleryPanel and ImageLightbox components for image browsing. Store cover selection in novel metadata for persistence. + +**Tech Stack:** React, TypeScript, Tailwind CSS, Zustand store, existing Cache API for images + +--- + +## Phase 1: Sidebar Navigation + +### Task 1: Create SettingsSidebar Component + +**Files:** +- Create: `components/settings/SettingsSidebar.tsx` +- Test: `components/settings/SettingsSidebar.test.tsx` + +**Step 1: Write the failing test** + +```typescript +// components/settings/SettingsSidebar.test.tsx +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { SettingsSidebar } from './SettingsSidebar'; + +const mockSections = [ + { + id: 'settings', + label: 'Settings', + icon: 'โš™๏ธ', + items: [ + { id: 'providers', label: 'Providers' }, + { id: 'prompt', label: 'Prompt' }, + { id: 'advanced', label: 'Advanced' }, + ], + }, + { + id: 'features', + label: 'Features', + icon: 'โœจ', + items: [ + { id: 'display', label: 'Display' }, + { id: 'audio', label: 'Audio' }, + ], + }, +]; + +describe('SettingsSidebar', () => { + it('renders all sections and items', () => { + const onSelect = vi.fn(); + render( + + ); + + expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.getByText('Features')).toBeInTheDocument(); + expect(screen.getByText('Providers')).toBeInTheDocument(); + expect(screen.getByText('Audio')).toBeInTheDocument(); + }); + + it('calls onSelect when item clicked', () => { + const onSelect = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText('Audio')); + expect(onSelect).toHaveBeenCalledWith('audio'); + }); + + it('highlights active item', () => { + const onSelect = vi.fn(); + render( + + ); + + const activeItem = screen.getByText('Providers').closest('button'); + expect(activeItem).toHaveClass('bg-blue-600'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- components/settings/SettingsSidebar.test.tsx --run` +Expected: FAIL with "Cannot find module" + +**Step 3: Write minimal implementation** + +```typescript +// components/settings/SettingsSidebar.tsx +import React, { useState } from 'react'; + +export interface SidebarItem { + id: string; + label: string; + hidden?: boolean; +} + +export interface SidebarSection { + id: string; + label: string; + icon: string; + items: SidebarItem[]; +} + +interface SettingsSidebarProps { + sections: SidebarSection[]; + activeItem: string; + onSelect: (itemId: string) => void; +} + +export const SettingsSidebar: React.FC = ({ + sections, + activeItem, + onSelect, +}) => { + const [collapsedSections, setCollapsedSections] = useState>(new Set()); + + const toggleSection = (sectionId: string) => { + setCollapsedSections((prev) => { + const next = new Set(prev); + if (next.has(sectionId)) { + next.delete(sectionId); + } else { + next.add(sectionId); + } + return next; + }); + }; + + return ( +
+ {sections.map((section) => ( +
+ + {!collapsedSections.has(section.id) && ( +
+ {section.items + .filter((item) => !item.hidden) + .map((item) => ( + + ))} +
+ )} +
+ ))} +
+ ); +}; +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- components/settings/SettingsSidebar.test.tsx --run` +Expected: PASS + +**Step 5: Commit** + +```bash +git add components/settings/SettingsSidebar.tsx components/settings/SettingsSidebar.test.tsx +git commit -m "feat(settings): add SettingsSidebar component with collapsible sections" +``` + +--- + +### Task 2: Create PromptPanel (extracted from GeneralPanel) + +**Files:** +- Create: `components/settings/PromptPanel.tsx` (if not exists, or verify it exists) +- Modify: Check existing GeneralPanel and ensure Prompt is separate + +**Step 1: Verify PromptPanel exists** + +Run: `ls -la components/settings/PromptPanel.tsx` + +If exists, skip to Task 3. If not, create it by extracting prompt-related settings from GeneralPanel. + +**Step 2: Commit if changes made** + +```bash +git add components/settings/PromptPanel.tsx +git commit -m "refactor(settings): ensure PromptPanel is standalone" +``` + +--- + +### Task 3: Integrate Sidebar into SettingsModal + +**Files:** +- Modify: `components/SettingsModal.tsx` + +**Step 1: Read current SettingsModal structure** + +Understand current tab configuration and panel rendering. + +**Step 2: Replace tabs with sidebar** + +Update SettingsModal.tsx to: +1. Import SettingsSidebar +2. Define sections array matching new structure +3. Replace SettingsTabs with SettingsSidebar +4. Update layout to sidebar + content panel + +**Step 3: Update tab IDs** + +Change from: `'providers' | 'general' | 'features' | 'export' | 'templates' | 'audio' | 'advanced' | 'metadata'` + +To: `'providers' | 'prompt' | 'advanced' | 'display' | 'audio' | 'diff' | 'templates' | 'metadata' | 'gallery' | 'export'` + +**Step 4: Run existing tests** + +Run: `npm test -- components/SettingsModal --run` +Expected: Some tests may need updating for new structure + +**Step 5: Update failing tests** + +Fix any tests that reference old tab structure. + +**Step 6: Commit** + +```bash +git add components/SettingsModal.tsx +git commit -m "refactor(settings): replace horizontal tabs with sidebar navigation" +``` + +--- + +## Phase 2: Gallery Panel + +### Task 4: Create GalleryPanel Component + +**Files:** +- Create: `components/settings/GalleryPanel.tsx` +- Test: `components/settings/GalleryPanel.test.tsx` + +**Step 1: Write the failing test** + +```typescript +// components/settings/GalleryPanel.test.tsx +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { GalleryPanel } from './GalleryPanel'; + +// Mock the store +vi.mock('../../store/useAppStore', () => ({ + useAppStore: vi.fn(() => ({ + chapters: { + 'ch1': { + translationResult: { + suggestedIllustrations: [ + { placementMarker: '[ILLUSTRATION-1]', imagePrompt: 'A hero', url: 'data:image/png;base64,abc' }, + ], + }, + }, + }, + })), +})); + +describe('GalleryPanel', () => { + it('renders gallery header', () => { + render(); + expect(screen.getByText(/Image Gallery/i)).toBeInTheDocument(); + }); + + it('shows "No images" when no illustrations exist', () => { + vi.mocked(useAppStore).mockReturnValue({ chapters: {} }); + render(); + expect(screen.getByText(/No images/i)).toBeInTheDocument(); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- components/settings/GalleryPanel.test.tsx --run` +Expected: FAIL + +**Step 3: Write minimal implementation** + +```typescript +// components/settings/GalleryPanel.tsx +import React, { useMemo, useState } from 'react'; +import { useAppStore } from '../../store/useAppStore'; +import { ImageLightbox } from './ImageLightbox'; + +interface GalleryImage { + chapterId: string; + chapterTitle: string; + marker: string; + prompt: string; + imageData: string; + cacheKey?: any; +} + +export const GalleryPanel: React.FC = () => { + const chapters = useAppStore((s) => s.chapters); + const [selectedImage, setSelectedImage] = useState(null); + const [lightboxOpen, setLightboxOpen] = useState(false); + + const imagesByChapter = useMemo(() => { + const result: Record = {}; + + Object.entries(chapters).forEach(([chapterId, data]) => { + const illustrations = data?.translationResult?.suggestedIllustrations || []; + const chapterTitle = data?.chapter?.title || chapterId; + + const images = illustrations + .filter((ill: any) => ill.url || ill.generatedImage?.imageData) + .map((ill: any) => ({ + chapterId, + chapterTitle, + marker: ill.placementMarker, + prompt: ill.imagePrompt, + imageData: ill.generatedImage?.imageData || ill.url || '', + cacheKey: ill.imageCacheKey, + })); + + if (images.length > 0) { + result[chapterId] = images; + } + }); + + return result; + }, [chapters]); + + const allImages = useMemo(() => { + return Object.values(imagesByChapter).flat(); + }, [imagesByChapter]); + + const coverImage = useAppStore((s) => s.novelMetadata?.coverImage); + + const handleImageClick = (image: GalleryImage) => { + setSelectedImage(image); + setLightboxOpen(true); + }; + + const handleSetCover = (image: GalleryImage) => { + // Will implement in Task 6 + console.log('Set cover:', image); + }; + + if (Object.keys(imagesByChapter).length === 0) { + return ( +
+

No images generated yet

+

+ Generate illustrations in chapters to see them here +

+
+ ); + } + + return ( +
+
+

Image Gallery

+ + Cover: {coverImage ? 'Selected' : 'None'} + +
+ + {Object.entries(imagesByChapter).map(([chapterId, images]) => ( + + ))} + + {lightboxOpen && selectedImage && ( + setLightboxOpen(false)} + onSetCover={handleSetCover} + isCover={coverImage?.marker === selectedImage.marker} + /> + )} +
+ ); +}; + +interface ChapterSectionProps { + title: string; + images: GalleryImage[]; + coverMarker?: string; + onImageClick: (image: GalleryImage) => void; +} + +const ChapterSection: React.FC = ({ + title, + images, + coverMarker, + onImageClick, +}) => { + const [collapsed, setCollapsed] = useState(false); + + return ( +
+ + + {!collapsed && ( +
+ {images.map((image, idx) => ( +
onImageClick(image)} + > + {image.prompt} + {coverMarker === image.marker && ( +
+ ๐Ÿ† +
+ )} +
+ ))} +
+ )} +
+ ); +}; + +export default GalleryPanel; +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- components/settings/GalleryPanel.test.tsx --run` +Expected: PASS + +**Step 5: Commit** + +```bash +git add components/settings/GalleryPanel.tsx components/settings/GalleryPanel.test.tsx +git commit -m "feat(gallery): add GalleryPanel with chapter-grouped images" +``` + +--- + +### Task 5: Create ImageLightbox Component + +**Files:** +- Create: `components/settings/ImageLightbox.tsx` +- Test: `components/settings/ImageLightbox.test.tsx` + +**Step 1: Write the failing test** + +```typescript +// components/settings/ImageLightbox.test.tsx +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ImageLightbox } from './ImageLightbox'; + +const mockImage = { + chapterId: 'ch1', + chapterTitle: 'Chapter 1', + marker: '[ILLUSTRATION-1]', + prompt: 'A dramatic scene', + imageData: 'data:image/png;base64,abc', +}; + +const mockAllImages = [mockImage]; + +describe('ImageLightbox', () => { + it('renders image and prompt', () => { + render( + + ); + + expect(screen.getByAltText('A dramatic scene')).toBeInTheDocument(); + expect(screen.getByText(/A dramatic scene/)).toBeInTheDocument(); + }); + + it('calls onClose when close button clicked', () => { + const onClose = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText('โœ•')); + expect(onClose).toHaveBeenCalled(); + }); + + it('shows Set as Cover button when not cover', () => { + render( + + ); + + expect(screen.getByText(/Set as Cover/)).toBeInTheDocument(); + }); + + it('shows Cover Selected when is cover', () => { + render( + + ); + + expect(screen.getByText(/Cover Selected/)).toBeInTheDocument(); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- components/settings/ImageLightbox.test.tsx --run` +Expected: FAIL + +**Step 3: Write minimal implementation** + +```typescript +// components/settings/ImageLightbox.tsx +import React, { useEffect, useCallback, useState } from 'react'; + +interface GalleryImage { + chapterId: string; + chapterTitle: string; + marker: string; + prompt: string; + imageData: string; + cacheKey?: any; +} + +interface ImageLightboxProps { + image: GalleryImage; + allImages: GalleryImage[]; + onClose: () => void; + onSetCover: (image: GalleryImage) => void; + isCover: boolean; +} + +export const ImageLightbox: React.FC = ({ + image, + allImages, + onClose, + onSetCover, + isCover, +}) => { + const [currentIndex, setCurrentIndex] = useState(() => + allImages.findIndex((img) => img.marker === image.marker) + ); + + const currentImage = allImages[currentIndex] || image; + + const goToPrev = useCallback(() => { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : allImages.length - 1)); + }, [allImages.length]); + + const goToNext = useCallback(() => { + setCurrentIndex((prev) => (prev < allImages.length - 1 ? prev + 1 : 0)); + }, [allImages.length]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + if (e.key === 'ArrowLeft') goToPrev(); + if (e.key === 'ArrowRight') goToNext(); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose, goToPrev, goToNext]); + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) onClose(); + }; + + return ( +
+ {/* Close button */} + + + {/* Navigation arrows */} + {allImages.length > 1 && ( + <> + + + + )} + + {/* Main content */} +
+ {currentImage.prompt} + + {/* Info panel */} +
+
+ {currentImage.chapterTitle} โ€ข Image {currentIndex + 1} of {allImages.length} +
+
+ Prompt: "{currentImage.prompt}" +
+ + +
+
+
+ ); +}; + +export default ImageLightbox; +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- components/settings/ImageLightbox.test.tsx --run` +Expected: PASS + +**Step 5: Commit** + +```bash +git add components/settings/ImageLightbox.tsx components/settings/ImageLightbox.test.tsx +git commit -m "feat(gallery): add ImageLightbox with navigation and cover selection" +``` + +--- + +## Phase 3: Cover Persistence & EPUB Integration + +### Task 6: Add Cover to Novel Metadata + +**Files:** +- Modify: `hooks/useNovelMetadata.ts` +- Modify: `types.ts` (NovelMetadata type if needed) + +**Step 1: Check current NovelMetadata type** + +Read types.ts to understand current structure. + +**Step 2: Add coverImage field to metadata handling** + +Update useNovelMetadata to handle coverImage with chapterId, marker, cacheKey. + +**Step 3: Wire up GalleryPanel to save cover selection** + +Update GalleryPanel's handleSetCover to call the metadata update function. + +**Step 4: Run tests** + +Run: `npm test -- --run` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add hooks/useNovelMetadata.ts components/settings/GalleryPanel.tsx +git commit -m "feat(metadata): persist cover image selection in novel metadata" +``` + +--- + +### Task 7: Wire Cover into EPUB Packager + +**Files:** +- Modify: `services/epubService/packagers/epubPackager.ts` +- Modify: `services/epubService/types.ts` + +**Step 1: Update EpubMeta type** + +Add coverImage field to EpubMeta interface. + +**Step 2: Update generateEpub3WithJSZip** + +1. Accept cover in EpubMeta +2. Fetch cover image data from cache +3. Add cover to manifest with `properties="cover-image"` +4. Create cover.xhtml page +5. Add cover.xhtml to spine as first item + +**Step 3: Update epubService.ts to pass cover** + +Pass novelMetadata.coverImage to the packager. + +**Step 4: Test manually** + +Generate an EPUB with cover selected and verify it appears. + +**Step 5: Commit** + +```bash +git add services/epubService/packagers/epubPackager.ts services/epubService/types.ts services/epubService.ts +git commit -m "feat(epub): add cover image support from gallery selection" +``` + +--- + +## Phase 4: Final Integration + +### Task 8: Add Gallery to Sidebar Config + +**Files:** +- Modify: `components/SettingsModal.tsx` + +**Step 1: Add Gallery to sidebar sections** + +Add Gallery item under Workspace section. + +**Step 2: Add GalleryPanel rendering** + +Add case for 'gallery' in panel rendering switch. + +**Step 3: Test full flow** + +1. Open Settings +2. Navigate to Gallery in sidebar +3. Click an image โ†’ lightbox opens +4. Set as cover โ†’ badge appears +5. Export EPUB โ†’ cover appears + +**Step 4: Commit** + +```bash +git add components/SettingsModal.tsx +git commit -m "feat(settings): integrate Gallery panel into sidebar navigation" +``` + +--- + +### Task 9: Final Tests & Cleanup + +**Step 1: Run full test suite** + +Run: `npm test -- --run` +Expected: All tests pass + +**Step 2: Run TypeScript check** + +Run: `npx tsc --noEmit` +Expected: No errors (or only pre-existing ones) + +**Step 3: Run build** + +Run: `npm run build` +Expected: Build succeeds + +**Step 4: Final commit** + +```bash +git add -A +git commit -m "chore: cleanup and final adjustments for gallery feature" +``` + +--- + +## Summary + +| Phase | Tasks | Estimated LOC | +|-------|-------|---------------| +| Phase 1: Sidebar | Tasks 1-3 | ~200 | +| Phase 2: Gallery | Tasks 4-5 | ~250 | +| Phase 3: Persistence | Tasks 6-7 | ~100 | +| Phase 4: Integration | Tasks 8-9 | ~50 | +| **Total** | 9 tasks | ~600 | diff --git a/hooks/useNovelMetadata.ts b/hooks/useNovelMetadata.ts index cde0672..f275074 100644 --- a/hooks/useNovelMetadata.ts +++ b/hooks/useNovelMetadata.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react'; import { SettingsOps } from '../services/db/operations'; import { debugLog } from '../utils/debug'; -import type { PublisherMetadata } from '../components/settings/types'; +import type { PublisherMetadata, CoverImageRef } from '../components/settings/types'; const pickFirstNonEmpty = (...values: (string | undefined | null)[]): string | undefined => { for (const value of values) { @@ -155,6 +155,41 @@ export const useNovelMetadata = (chaptersMap?: Map | null) => { [applyMetadata] ); + const setCoverImage = useCallback( + (coverImage: CoverImageRef | null) => { + setNovelMetadata((prev) => { + if (!prev) { + // Create minimal metadata if none exists + const newMetadata: PublisherMetadata = { + title: 'Untitled Novel', + description: '', + originalLanguage: 'Unknown', + chapterCount: 1, + genres: [], + lastUpdated: new Date().toISOString().split('T')[0], + coverImage: coverImage ?? undefined, + }; + localStorage.setItem('novelMetadata', JSON.stringify(newMetadata)); + persistNovelMetadata(newMetadata); + return newMetadata; + } + + const updated = { + ...prev, + coverImage: coverImage ?? undefined, + }; + localStorage.setItem('novelMetadata', JSON.stringify(updated)); + persistNovelMetadata(updated); + debugLog('ui', 'summary', '[useNovelMetadata] Cover image updated', { + chapterId: coverImage?.chapterId, + marker: coverImage?.marker, + }); + return updated; + }); + }, + [persistNovelMetadata] + ); + useEffect(() => { const loadMetadataFromSession = async () => { if (novelMetadata) return; @@ -194,5 +229,5 @@ export const useNovelMetadata = (chaptersMap?: Map | null) => { loadMetadataFromSession(); }, [applyMetadata, chaptersMap, novelMetadata]); - return { novelMetadata, handleNovelMetadataChange }; + return { novelMetadata, handleNovelMetadataChange, setCoverImage }; }; diff --git a/services/epubService.ts b/services/epubService.ts index 3a20312..e1ee269 100644 --- a/services/epubService.ts +++ b/services/epubService.ts @@ -137,7 +137,8 @@ export const generateEpub = async (options: EpubExportOptions): Promise => description, language, identifier: bookId, - publisher: novelConfig.publisher + publisher: novelConfig.publisher, + coverImage: options.coverImage, }, chapters); // Create download link diff --git a/services/epubService/packagers/epubPackager.ts b/services/epubService/packagers/epubPackager.ts index a855299..f942470 100644 --- a/services/epubService/packagers/epubPackager.ts +++ b/services/epubService/packagers/epubPackager.ts @@ -93,12 +93,47 @@ export const generateEpub3WithJSZip = async (meta: EpubMeta, chapters: EpubChapt const stylesheet = EPUB_STYLESHEET_CSS; // Extract data:image payloads from chapter XHTML and rewrite to packaged image files - type ImgEntry = { href: string; mediaType: string; base64: string; id: string }; + type ImgEntry = { href: string; mediaType: string; base64: string; id: string; isCover?: boolean }; const processedChapters: { ch: EpubChapter; xhtml: string }[] = []; const imageEntries: ImgEntry[] = []; let imgIndex = 1; const dataImgRegex = /(]*?src=")(data:(image\/[A-Za-z0-9.+-]+);base64,([A-Za-z0-9+/=]+))("[^>]*>)/g; + // Process cover image if provided + let coverEntry: ImgEntry | null = null; + if (meta.coverImage) { + const coverMatch = meta.coverImage.match(/^data:(image\/[A-Za-z0-9.+-]+);base64,(.+)$/); + if (coverMatch) { + const [, mime, b64] = coverMatch; + const ext = mime.endsWith('jpeg') ? 'jpg' : (mime.split('/')[1] || 'png'); + coverEntry = { + href: `images/cover.${ext}`, + mediaType: mime, + base64: b64, + id: 'cover-image', + isCover: true, + }; + imageEntries.push(coverEntry); + } + } + + // Generate cover page XHTML if we have a cover + const coverXhtml = coverEntry ? ` + + + + + Cover + + + + Cover + +` : null; + for (const ch of chapters) { let xhtml = ch.xhtml; xhtml = xhtml.replace(dataImgRegex, (_m, p1, _src, mime, b64, p5) => { @@ -117,11 +152,21 @@ export const generateEpub3WithJSZip = async (meta: EpubMeta, chapters: EpubChapt const manifestItemsText = processedChapters.map(({ ch }) => `` ).join('\n '); - const manifestItemsImages = imageEntries.map(img => - `` - ).join('\n '); + const manifestItemsImages = imageEntries.map(img => { + const props = img.isCover ? ' properties="cover-image"' : ''; + return ``; + }).join('\n '); + + // Cover page manifest item (if we have a cover) + const coverPageManifest = coverEntry ? `` : ''; + + // Spine: cover first (if present), then chapters + const coverSpineItem = coverEntry ? `` : ''; const spineItems2 = processedChapters.map(({ ch }) => ``).join('\n '); + // Cover metadata reference (if we have a cover) + const coverMeta = coverEntry ? `` : ''; + const contentOpf2 = ` @@ -132,15 +177,16 @@ export const generateEpub3WithJSZip = async (meta: EpubMeta, chapters: EpubChapt ${meta.publisher ? `${escapeXml(meta.publisher)}` : ''} ${meta.description ? `${escapeXml(meta.description)}` : ''} ${new Date().toISOString()} + ${coverMeta} - ${manifestItemsText} + ${coverPageManifest ? `${coverPageManifest}\n ` : ''}${manifestItemsText} ${manifestItemsImages ? `\n ${manifestItemsImages}` : ''} - ${spineItems2} + ${coverSpineItem ? `${coverSpineItem}\n ` : ''}${spineItems2} `; @@ -157,6 +203,11 @@ export const generateEpub3WithJSZip = async (meta: EpubMeta, chapters: EpubChapt zip.file(`${oebps}/content.opf`, contentOpf2); zip.file(`${textDir}/nav.xhtml`, navXhtml); zip.file(`${stylesDir}/stylesheet.css`, stylesheet); + + // Add cover page if we have a cover + if (coverXhtml) { + zip.file(`${textDir}/cover.xhtml`, coverXhtml); + } // Add processed chapter files and extracted images (with optional strict XML parse diagnostics) const parseErrors: string[] = []; diff --git a/services/epubService/types.ts b/services/epubService/types.ts index 6553092..fed333f 100644 --- a/services/epubService/types.ts +++ b/services/epubService/types.ts @@ -97,6 +97,8 @@ export interface EpubExportOptions { customTemplate?: any; manualConfig?: any; chapterUrls?: string[]; + /** Cover image as base64 data URL (e.g., "data:image/jpeg;base64,...") */ + coverImage?: string; } export interface EpubChapter { @@ -113,4 +115,6 @@ export interface EpubMeta { language?: string; identifier?: string; publisher?: string; + /** Cover image as base64 data URL (e.g., "data:image/jpeg;base64,...") */ + coverImage?: string; } \ No newline at end of file diff --git a/store/slices/exportSlice.ts b/store/slices/exportSlice.ts index f5d9e52..03f217a 100644 --- a/store/slices/exportSlice.ts +++ b/store/slices/exportSlice.ts @@ -2,10 +2,12 @@ import { StateCreator } from 'zustand'; import type { ExportSessionOptions } from '../../services/db/types'; import type { TelemetryInsights } from '../../services/epubService'; import { blobToBase64DataUrl } from '../../services/imageUtils'; +import { ImageCacheStore } from '../../services/imageCacheService'; import { telemetryService } from '../../services/telemetryService'; import type { ImageGenerationMetadata } from '../../types'; import { SessionExportOps, SettingsOps, TranslationOps } from '../../services/db/operations'; import { fetchChaptersForReactRendering } from '../../services/db/operations/rendering'; +import type { CoverImageRef } from '../../components/settings/types'; // Export slice state export interface ExportSlice { @@ -404,6 +406,24 @@ export const createExportSlice: StateCreator< if (s.epubProjectDescription) tpl.projectDescription = s.epubProjectDescription; if (s.epubFooter !== undefined) tpl.customFooter = s.epubFooter || ''; + // Fetch cover image from cache if selected + let coverImageData: string | undefined; + try { + const novelMetaJson = localStorage.getItem('novelMetadata'); + if (novelMetaJson) { + const novelMeta = JSON.parse(novelMetaJson) as { coverImage?: CoverImageRef }; + if (novelMeta?.coverImage?.cacheKey) { + const blob = await ImageCacheStore.getImageBlob(novelMeta.coverImage.cacheKey); + if (blob) { + coverImageData = await blobToBase64DataUrl(blob); + console.log('[ExportSlice] Cover image loaded from cache'); + } + } + } + } catch (err) { + console.warn('[ExportSlice] Failed to load cover image:', err); + } + await generateEpub({ title: undefined, author: undefined, @@ -418,6 +438,7 @@ export const createExportSlice: StateCreator< customTemplate: undefined, manualConfig: undefined, chapterUrls: undefined, + coverImage: coverImageData, }); const end = typeof performance !== 'undefined' && typeof performance.now === 'function' diff --git a/tests/components/SettingsModal.test.tsx b/tests/components/SettingsModal.test.tsx index 39c9d55..a8cff61 100644 --- a/tests/components/SettingsModal.test.tsx +++ b/tests/components/SettingsModal.test.tsx @@ -2,38 +2,46 @@ import { describe, it, expect } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import SettingsModal from '../../components/SettingsModal'; -describe('SettingsModal Tabs', () => { - it('should render all tabs', () => { +describe('SettingsModal Sidebar Navigation', () => { + it('should render all sidebar sections and items', () => { render( {}} />); - // Check for current tab names (updated UI) - expect(screen.getByText('General')).toBeInTheDocument(); + // Check for sidebar section headers (Settings appears twice - header + sidebar) + expect(screen.getAllByText('Settings').length).toBeGreaterThanOrEqual(2); expect(screen.getByText('Features')).toBeInTheDocument(); - expect(screen.getByText('Export')).toBeInTheDocument(); - expect(screen.getByText('Metadata')).toBeInTheDocument(); - expect(screen.getByText('Templates')).toBeInTheDocument(); - expect(screen.getByText('Audio')).toBeInTheDocument(); + expect(screen.getByText('Workspace')).toBeInTheDocument(); + + // Check for sidebar items + expect(screen.getByText('Providers')).toBeInTheDocument(); + expect(screen.getByText('Prompt')).toBeInTheDocument(); expect(screen.getByText('Advanced')).toBeInTheDocument(); + expect(screen.getByText('Display')).toBeInTheDocument(); + expect(screen.getByText('Audio')).toBeInTheDocument(); + expect(screen.getByText('Templates')).toBeInTheDocument(); + expect(screen.getByText('Metadata')).toBeInTheDocument(); + expect(screen.getByText('Gallery')).toBeInTheDocument(); }); - it('should switch to Metadata tab on click', () => { + it('should switch to Metadata panel on click', () => { render( {}} />); - const metadataTab = screen.getByText('Metadata'); - fireEvent.click(metadataTab); + const metadataItem = screen.getByText('Metadata'); + fireEvent.click(metadataItem); - // Should show metadata form header (updated text) + // Should show metadata form header expect(screen.getByText('Novel Metadata')).toBeInTheDocument(); expect(screen.getByText('Basic Information')).toBeInTheDocument(); }); - it('should show Export tab with action buttons', () => { + it('should show Export panel with action buttons', () => { render( {}} />); - const exportTab = screen.getByText('Export'); - fireEvent.click(exportTab); + // Find and click the Export item in the sidebar + const exportItems = screen.getAllByText('Export'); + // Second one is the sidebar item (first is section header) + fireEvent.click(exportItems[1]); - // Check for current button text (updated UI) + // Check for current button text expect(screen.getByText('Quick Export (Session Only)')).toBeInTheDocument(); expect(screen.getByText('Publish to Library')).toBeInTheDocument(); });