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}
+
+
+ ${images.map((img, index) => `
+
+

+
+ `).join('')}
+
+
+ `;
+}
+
+/**
+ * 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');
+ });
+ });
+});