feat: photo_archive macro

This commit is contained in:
2026-02-13 16:42:49 +01:00
parent 00e8255cd7
commit 868ea720c7
5 changed files with 578 additions and 10 deletions

View File

@@ -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);
}
}

View File

@@ -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 = `<div class="photo-archive-empty">No photos found for ${FULL_MONTH_NAMES[month - 1]} ${year}</div>`;
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 = `<div class="photo-archive-empty">No photos found for ${year}</div>`;
continue;
}
html = monthsWithMedia.map(({ month, images }) =>
`<div class="photo-archive-month-wrapper">${buildMonthGallery(month, year, images, onImageClick)}</div>`
).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 = '<div class="photo-archive-error">Failed to load photo archive</div>';
}
}
};
/**
* 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 `
<div class="photo-archive-month" data-month="${month}" data-year="${year}">
<div class="photo-archive-month-label">
<span>${monthName}</span>
</div>
<div class="photo-archive-gallery gallery-lightbox">
${images.map((img, index) => `
<div class="photo-archive-item" data-index="${index}" data-media-id="${img.id}">
<img
src="bds-media://${img.id}"
alt="${img.alt || img.originalName}"
title="${img.originalName}"
/>
</div>
`).join('')}
</div>
</div>
`;
}
/**
* 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<PostEditorProps> = ({ 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);

View File

@@ -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';

View File

@@ -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 = `<div class="${classes.join(' ')}" ${dataAttrs.join(' ')}>`;
html += `<div class="photo-archive-container">`;
html += `<div class="photo-archive-loading">Loading photo archive for ${year}${month ? ` / ${getMonthName(parseInt(month, 10))}` : ''}...</div>`;
html += `</div>`;
html += `</div>`;
return html;
},
};
// Self-register
registerMacro(photoArchiveMacro);
export default photoArchiveMacro;
export { getMonthName, MONTH_NAMES };

View File

@@ -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');
});
});
});