Skip to content
9 changes: 5 additions & 4 deletions components/InputBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
</button>
<button
type="submit"
disabled={isAnyLoading}
disabled={isAnyLoading || !url.trim()}
className="px-4 py-2 bg-blue-600 text-white font-semibold rounded-md shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800 disabled:bg-blue-400 dark:disabled:bg-blue-800 disabled:cursor-not-allowed transition duration-300 ease-in-out"
title={url.trim() ? 'Fetch chapter or session from URL' : 'Enter a URL first'}
>
{isImporting ? 'Importing...' : isLoading ? 'Fetching...' : 'Load'}
{isImporting ? 'Importing...' : isLoading ? 'Fetching...' : '🔗 Fetch'}
</button>
</div>
</div>
Expand Down
106 changes: 62 additions & 44 deletions components/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,18 +48,46 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose }) => {
})));

const [currentSettings, setCurrentSettings] = useState(settings);
type SettingsTabId = 'providers' | 'general' | 'features' | 'export' | 'templates' | 'audio' | 'advanced' | 'metadata';
const [activeTab, setActiveTab] = useState<SettingsTabId>('providers');
const tabConfig: SettingsTabConfig[] = useMemo(
type SettingsPanelId = 'providers' | 'prompt' | 'advanced' | 'display' | 'audio' | 'diff' | 'templates' | 'metadata' | 'gallery' | 'export';
const [activePanel, setActivePanel] = useState<SettingsPanelId>('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' }],
},
],
[]
);
Expand Down Expand Up @@ -127,38 +156,27 @@ const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose }) => {
</header>

<SettingsModalProvider value={modalContextValue}>
<SettingsTabs
tabs={tabConfig}
activeTab={activeTab}
onSelect={(tabId) => setActiveTab(tabId as SettingsTabId)}
/>

<div className="p-4 sm:p-6 md:p-8 space-y-6 sm:space-y-8 overflow-y-auto">
{activeTab === 'providers' && <ProvidersPanel isOpen={isOpen} />}
{activeTab === 'general' && (
<>
<DisplayPanel />
<PromptPanel />
</>
)}

{activeTab === 'features' && (<DiffPanel />)}

{activeTab === 'export' && (
<SessionExportPanel onRequireMetadata={() => setActiveTab('metadata')} />
)}

{activeTab === 'metadata' && (
<MetadataPanel />
)}

{activeTab === 'templates' && (<TemplatePanel />)}

{activeTab === 'audio' && (
<AudioPanel />
)}

{activeTab === 'advanced' && <AdvancedPanel />}
<div className="flex flex-1 overflow-hidden">
<SettingsSidebar
sections={sidebarSections}
activeItem={activePanel}
onSelect={(panelId) => setActivePanel(panelId as SettingsPanelId)}
/>

<div className="flex-1 p-4 sm:p-6 md:p-8 space-y-6 sm:space-y-8 overflow-y-auto">
{activePanel === 'providers' && <ProvidersPanel isOpen={isOpen} />}
{activePanel === 'prompt' && <PromptPanel />}
{activePanel === 'advanced' && <AdvancedPanel />}
{activePanel === 'display' && <DisplayPanel />}
{activePanel === 'audio' && <AudioPanel />}
{activePanel === 'diff' && <DiffPanel />}
{activePanel === 'templates' && <TemplatePanel />}
{activePanel === 'metadata' && <MetadataPanel />}
{activePanel === 'gallery' && <GalleryPanel />}
{activePanel === 'export' && (
<SessionExportPanel onRequireMetadata={() => setActivePanel('metadata')} />
)}
</div>
</div>
</SettingsModalProvider>
<SessionActions
Expand Down
229 changes: 229 additions & 0 deletions components/settings/GalleryPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GalleryPanel } from './GalleryPanel';

// Mock the store
vi.mock('../../store', () => ({
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(<GalleryPanel />);

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(<GalleryPanel />);

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(<GalleryPanel />);

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(<GalleryPanel />);

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(<GalleryPanel />);

// 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(<GalleryPanel />);

expect(screen.getByText('Chapter with images')).toBeInTheDocument();
expect(screen.queryByText('Chapter without images')).not.toBeInTheDocument();
expect(screen.queryByText('Chapter with no translation')).not.toBeInTheDocument();
});
});
Loading
Loading