From 868ea720c72eae79462a3bb19d2537a3d151a09d Mon Sep 17 00:00:00 2001 From: hugo Date: Fri, 13 Feb 2026 16:42:49 +0100 Subject: [PATCH] feat: photo_archive macro --- src/renderer/components/Editor/Editor.css | 116 +++++++++++ src/renderer/components/Editor/Editor.tsx | 174 +++++++++++++++- src/renderer/macros/definitions/index.ts | 1 + .../macros/definitions/photo_archive.ts | 111 +++++++++++ tests/renderer/macros/photo_archive.test.ts | 186 ++++++++++++++++++ 5 files changed, 578 insertions(+), 10 deletions(-) create mode 100644 src/renderer/macros/definitions/photo_archive.ts create mode 100644 tests/renderer/macros/photo_archive.test.ts diff --git a/src/renderer/components/Editor/Editor.css b/src/renderer/components/Editor/Editor.css index 166626c..9633f6b 100644 --- a/src/renderer/components/Editor/Editor.css +++ b/src/renderer/components/Editor/Editor.css @@ -834,3 +834,119 @@ color: var(--vscode-descriptionForeground); font-style: italic; } + +/* Photo Archive Macro Styles */ +.macro-photo-archive { + margin: 16px 0; +} + +.photo-archive-container { + display: flex; + flex-direction: column; + gap: 24px; +} + +.photo-archive-month-wrapper { + border-bottom: 1px solid var(--vscode-panel-border); + padding-bottom: 24px; +} + +.photo-archive-month-wrapper:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.photo-archive-month { + display: flex; + gap: 16px; +} + +.photo-archive-month-label { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + min-width: 32px; + background: var(--vscode-button-secondaryBackground); + border-radius: 4px; + padding: 8px 0; +} + +.photo-archive-month-label span { + writing-mode: vertical-rl; + text-orientation: mixed; + transform: rotate(180deg); + font-weight: 600; + font-size: 14px; + color: var(--vscode-button-secondaryForeground); + letter-spacing: 1px; + text-transform: uppercase; +} + +.photo-archive-gallery { + flex: 1; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.photo-archive-item { + aspect-ratio: 1; + overflow: hidden; + border-radius: 4px; + cursor: pointer; + background: var(--vscode-input-background); + transition: transform 0.1s, box-shadow 0.1s; +} + +.photo-archive-item:hover { + transform: scale(1.02); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.photo-archive-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.photo-archive-loading, +.photo-archive-empty, +.photo-archive-error { + padding: 24px; + text-align: center; + color: var(--vscode-descriptionForeground); + font-style: italic; + background: var(--vscode-input-background); + border-radius: 4px; +} + +.photo-archive-error { + color: var(--vscode-errorForeground); +} + +/* Single month view - slightly different layout */ +.photo-archive-single-month .photo-archive-gallery { + grid-template-columns: repeat(5, 1fr); +} + +/* Responsive adjustments */ +@media (max-width: 800px) { + .photo-archive-gallery { + grid-template-columns: repeat(3, 1fr); + } + + .photo-archive-single-month .photo-archive-gallery { + grid-template-columns: repeat(4, 1fr); + } +} + +@media (max-width: 600px) { + .photo-archive-gallery { + grid-template-columns: repeat(2, 1fr); + } + + .photo-archive-single-month .photo-archive-gallery { + grid-template-columns: repeat(3, 1fr); + } +} diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 6d71a8c..6796d17 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -237,6 +237,161 @@ const hydrateGalleries = async ( } }; +const FULL_MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' +]; + +/** + * Hydrate photo_archive elements in the preview with actual media from the given year/month. + * Also links the discovered media to the post. + */ +const hydratePhotoArchive = async ( + container: HTMLElement, + postId: string, + onImageClick: (index: number, images: { src: string; alt: string }[]) => void +) => { + const archives = container.querySelectorAll('.macro-photo-archive[data-year]'); + + for (const archive of archives) { + const year = parseInt(archive.getAttribute('data-year') || '0', 10); + const monthStr = archive.getAttribute('data-month'); + const month = monthStr ? parseInt(monthStr, 10) : undefined; + + if (!year) continue; + + const archiveContainer = archive.querySelector('.photo-archive-container'); + if (!archiveContainer) continue; + + try { + let html = ''; + + if (month !== undefined) { + // Single month view + const mediaItems = await window.electronAPI?.media.filter({ + year, + month: month - 1, // API uses 0-based month + }); + + const images = (mediaItems || []).filter(m => m.mimeType?.startsWith('image/')); + + if (images.length === 0) { + archiveContainer.innerHTML = `
No photos found for ${FULL_MONTH_NAMES[month - 1]} ${year}
`; + continue; + } + + // Link images to the post + for (const img of images) { + const isLinked = await window.electronAPI?.postMedia.isLinked(postId, img.id); + if (!isLinked) { + await window.electronAPI?.postMedia.link(postId, img.id); + } + } + + html = buildMonthGallery(month, year, images, onImageClick); + } else { + // Full year view - show each month that has images + const monthsWithMedia: { month: number; images: { id: string; originalName: string; alt?: string; mimeType: string }[] }[] = []; + + for (let m = 0; m < 12; m++) { + const mediaItems = await window.electronAPI?.media.filter({ + year, + month: m, // API uses 0-based month + }); + + const images = (mediaItems || []).filter(m => m.mimeType?.startsWith('image/')); + if (images.length > 0) { + monthsWithMedia.push({ month: m + 1, images }); // Store as 1-based month + + // Link images to the post + for (const img of images) { + const isLinked = await window.electronAPI?.postMedia.isLinked(postId, img.id); + if (!isLinked) { + await window.electronAPI?.postMedia.link(postId, img.id); + } + } + } + } + + if (monthsWithMedia.length === 0) { + archiveContainer.innerHTML = `
No photos found for ${year}
`; + continue; + } + + html = monthsWithMedia.map(({ month, images }) => + `
${buildMonthGallery(month, year, images, onImageClick)}
` + ).join(''); + } + + archiveContainer.innerHTML = html; + + // Set up click handlers for all images + setupPhotoArchiveClickHandlers(archiveContainer, onImageClick); + + } catch (error) { + console.error('Failed to hydrate photo archive:', error); + archiveContainer.innerHTML = '
Failed to load photo archive
'; + } + } +}; + +/** + * Build HTML for a single month's gallery with rotated month label + */ +function buildMonthGallery( + month: number, + year: number, + images: { id: string; originalName: string; alt?: string }[], + _onImageClick: (index: number, images: { src: string; alt: string }[]) => void +): string { + const monthName = FULL_MONTH_NAMES[month - 1]; + + return ` +
+
+ ${monthName} +
+ +
+ `; +} + +/** + * Set up click handlers for photo archive gallery items + */ +function setupPhotoArchiveClickHandlers( + container: Element, + onImageClick: (index: number, images: { src: string; alt: string }[]) => void +) { + // Find all month galleries + const monthGalleries = container.querySelectorAll('.photo-archive-month'); + + monthGalleries.forEach(monthGallery => { + const items = monthGallery.querySelectorAll('.photo-archive-item'); + const imageData = Array.from(items).map(item => { + const img = item.querySelector('img'); + return { + src: img?.getAttribute('src') || '', + alt: img?.getAttribute('alt') || '', + }; + }); + + items.forEach((item, index) => { + item.addEventListener('click', () => onImageClick(index, imageData)); + }); + }); +} + interface PostEditorProps { post: PostData; } @@ -303,22 +458,21 @@ const PostEditor: React.FC = ({ post }) => { return galleryImages.length > 0 ? galleryImages : images; }, [images, galleryImages]); - // Hydrate galleries when in preview mode + // Hydrate galleries and photo archives when in preview mode useEffect(() => { if (editorMode !== 'preview' || !previewRef.current) return; // Small delay to ensure DOM is updated const timer = setTimeout(() => { if (previewRef.current) { - hydrateGalleries( - previewRef.current, - post.id, - (index, imgs) => { - setGalleryImages(imgs); - setLightboxIndex(index); - setLightboxOpen(true); - } - ); + const lightboxHandler = (index: number, imgs: { src: string; alt: string }[]) => { + setGalleryImages(imgs); + setLightboxIndex(index); + setLightboxOpen(true); + }; + + hydrateGalleries(previewRef.current, post.id, lightboxHandler); + hydratePhotoArchive(previewRef.current, post.id, lightboxHandler); } }, 100); diff --git a/src/renderer/macros/definitions/index.ts b/src/renderer/macros/definitions/index.ts index 0a580dc..791c2b2 100644 --- a/src/renderer/macros/definitions/index.ts +++ b/src/renderer/macros/definitions/index.ts @@ -12,6 +12,7 @@ // Import all macro definitions - they self-register on import import './gallery'; import './youtube'; +import './photo_archive'; // Add new macro imports here: // import './myNewMacro'; diff --git a/src/renderer/macros/definitions/photo_archive.ts b/src/renderer/macros/definitions/photo_archive.ts new file mode 100644 index 0000000..f7c5304 --- /dev/null +++ b/src/renderer/macros/definitions/photo_archive.ts @@ -0,0 +1,111 @@ +/** + * Photo Archive Macro + * + * Creates a photo gallery organized by year and month. + * Images are discovered dynamically from the media library based on their creation date. + * When rendered, images are automatically linked to the post. + * + * Usage: + * [[photo_archive year="2024"]] - All months of 2024, each in its own lightbox + * [[photo_archive year="2024" month="6"]] - Only June 2024 photos + * + * Parameters: + * - year (required): The year to display photos from (e.g., 2024) + * - month (optional): Specific month (1-12). If omitted, shows all months with photos. + * + * Gallery Layout: + * - Each month is in its own lightbox with the month name rotated 90° on the side + * - Images are displayed in a grid and are clickable for lightbox viewing + */ + +import { registerMacro } from '../registry'; +import type { MacroDefinition, MacroParams, MacroRenderContext } from '../types'; + +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' +]; + +/** + * Get the full month name from a 1-based month number + */ +function getMonthName(month: number): string { + return MONTH_NAMES[month - 1] || 'Unknown'; +} + +const photoArchiveMacro: MacroDefinition = { + name: 'photo_archive', + description: 'Creates a photo archive gallery organized by year and month, automatically linking discovered images to the post', + + validate(params: MacroParams): string | undefined { + // Year is required + if (!params.year) { + return 'photo_archive macro requires a "year" parameter'; + } + + const year = parseInt(params.year, 10); + if (isNaN(year) || year < 1000 || year > 9999) { + return 'Year must be a valid 4-digit year (e.g., 2024)'; + } + + // Month is optional but must be valid if provided + if (params.month) { + const month = parseInt(params.month, 10); + if (isNaN(month) || month < 1 || month > 12) { + return 'Month must be a number between 1 and 12'; + } + } + + return undefined; + }, + + editorPreview(params: MacroParams): string { + const year = params.year || '????'; + if (params.month) { + const monthNum = parseInt(params.month, 10); + const monthName = getMonthName(monthNum); + return `📅 Photo Archive: ${monthName} ${year}`; + } + return `📅 Photo Archive: ${year}`; + }, + + render(params: MacroParams, context: MacroRenderContext): string { + const { year, month } = params; + + // Build data attributes for hydration + const dataAttrs = [ + `data-year="${year}"`, + ]; + + if (month) { + dataAttrs.push(`data-month="${month}"`); + } + + if (context.postId) { + dataAttrs.push(`data-post-id="${context.postId}"`); + } + + // CSS classes + const classes = ['macro-photo-archive']; + if (month) { + classes.push('photo-archive-single-month'); + } else { + classes.push('photo-archive-full-year'); + } + + // Generate placeholder HTML - actual content is hydrated by Editor.tsx + let html = `
`; + html += `
`; + html += `
Loading photo archive for ${year}${month ? ` / ${getMonthName(parseInt(month, 10))}` : ''}...
`; + html += `
`; + html += `
`; + + return html; + }, +}; + +// Self-register +registerMacro(photoArchiveMacro); + +export default photoArchiveMacro; +export { getMonthName, MONTH_NAMES }; diff --git a/tests/renderer/macros/photo_archive.test.ts b/tests/renderer/macros/photo_archive.test.ts new file mode 100644 index 0000000..470aa64 --- /dev/null +++ b/tests/renderer/macros/photo_archive.test.ts @@ -0,0 +1,186 @@ +/** + * Tests for the photo_archive Macro + * + * This macro creates a photo gallery organized by year and month, + * dynamically loading images from the media library based on their creation date. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { clearMacros, getMacro, registerMacro } from '../../../src/renderer/macros/registry'; +import type { MacroParams, MacroRenderContext } from '../../../src/renderer/macros/types'; +import photoArchiveMacro from '../../../src/renderer/macros/definitions/photo_archive'; + +describe('photo_archive macro', () => { + beforeEach(() => { + clearMacros(); + // Re-register the macro for each test + registerMacro(photoArchiveMacro); + }); + + describe('validation', () => { + it('should require year parameter', () => { + const macro = getMacro('photo_archive'); + + expect(macro).toBeDefined(); + const error = macro!.validate?.({}); + expect(error).toBe('photo_archive macro requires a "year" parameter'); + }); + + it('should reject non-numeric year', () => { + const macro = getMacro('photo_archive'); + + const error = macro!.validate?.({ year: 'abc' }); + expect(error).toBe('Year must be a valid 4-digit year (e.g., 2024)'); + }); + + it('should reject year out of range', () => { + const macro = getMacro('photo_archive'); + + const error = macro!.validate?.({ year: '999' }); + expect(error).toBe('Year must be a valid 4-digit year (e.g., 2024)'); + }); + + it('should accept valid year only', () => { + const macro = getMacro('photo_archive'); + + const error = macro!.validate?.({ year: '2024' }); + expect(error).toBeUndefined(); + }); + + it('should reject invalid month', () => { + const macro = getMacro('photo_archive'); + + const error = macro!.validate?.({ year: '2024', month: '13' }); + expect(error).toBe('Month must be a number between 1 and 12'); + }); + + it('should reject month of zero', () => { + const macro = getMacro('photo_archive'); + + const error = macro!.validate?.({ year: '2024', month: '0' }); + expect(error).toBe('Month must be a number between 1 and 12'); + }); + + it('should accept valid year and month', () => { + const macro = getMacro('photo_archive'); + + const error = macro!.validate?.({ year: '2024', month: '6' }); + expect(error).toBeUndefined(); + }); + }); + + describe('editorPreview', () => { + it('should show year-only preview when no month', () => { + const macro = getMacro('photo_archive'); + + const preview = macro!.editorPreview?.({ year: '2024' }); + expect(preview).toBe('📅 Photo Archive: 2024'); + }); + + it('should show year and month in preview', () => { + const macro = getMacro('photo_archive'); + + const preview = macro!.editorPreview?.({ year: '2024', month: '6' }); + expect(preview).toBe('📅 Photo Archive: June 2024'); + }); + + it('should handle all months correctly', () => { + const macro = getMacro('photo_archive'); + + const months = [ + { month: '1', expected: 'January' }, + { month: '2', expected: 'February' }, + { month: '3', expected: 'March' }, + { month: '4', expected: 'April' }, + { month: '5', expected: 'May' }, + { month: '6', expected: 'June' }, + { month: '7', expected: 'July' }, + { month: '8', expected: 'August' }, + { month: '9', expected: 'September' }, + { month: '10', expected: 'October' }, + { month: '11', expected: 'November' }, + { month: '12', expected: 'December' }, + ]; + + for (const { month, expected } of months) { + const preview = macro!.editorPreview?.({ year: '2024', month }); + expect(preview).toContain(expected); + } + }); + }); + + describe('render - year only', () => { + it('should render wrapper with year data attribute', () => { + const macro = getMacro('photo_archive'); + const context: MacroRenderContext = { isPreview: true, postId: 'test-post-id' }; + + const html = macro!.render({ year: '2024' }, context); + + expect(html).toContain('data-year="2024"'); + expect(html).toContain('macro-photo-archive'); + }); + + it('should not include month attribute when only year provided', () => { + const macro = getMacro('photo_archive'); + const context: MacroRenderContext = { isPreview: true, postId: 'test-post-id' }; + + const html = macro!.render({ year: '2024' }, context); + + expect(html).not.toContain('data-month='); + }); + + it('should include post-id data attribute when available', () => { + const macro = getMacro('photo_archive'); + const context: MacroRenderContext = { isPreview: true, postId: 'post-123' }; + + const html = macro!.render({ year: '2024' }, context); + + expect(html).toContain('data-post-id="post-123"'); + }); + + it('should include loading placeholder', () => { + const macro = getMacro('photo_archive'); + const context: MacroRenderContext = { isPreview: true, postId: 'test-post-id' }; + + const html = macro!.render({ year: '2024' }, context); + + expect(html).toContain('photo-archive-loading'); + expect(html).toContain('Loading photo archive'); + }); + }); + + describe('render - year and month', () => { + it('should render with both year and month data attributes', () => { + const macro = getMacro('photo_archive'); + const context: MacroRenderContext = { isPreview: true, postId: 'test-post-id' }; + + const html = macro!.render({ year: '2024', month: '6' }, context); + + expect(html).toContain('data-year="2024"'); + expect(html).toContain('data-month="6"'); + }); + + it('should have single-month class when month specified', () => { + const macro = getMacro('photo_archive'); + const context: MacroRenderContext = { isPreview: true, postId: 'test-post-id' }; + + const html = macro!.render({ year: '2024', month: '6' }, context); + + expect(html).toContain('photo-archive-single-month'); + }); + }); + + describe('self-registration', () => { + it('should self-register on import', () => { + const macro = getMacro('photo_archive'); + expect(macro).toBeDefined(); + expect(macro!.name).toBe('photo_archive'); + }); + + it('should have proper description', () => { + const macro = getMacro('photo_archive'); + expect(macro!.description).toContain('photo'); + expect(macro!.description.toLowerCase()).toContain('archive'); + }); + }); +});