feat: photo_archive macro
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
111
src/renderer/macros/definitions/photo_archive.ts
Normal file
111
src/renderer/macros/definitions/photo_archive.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user