From d993f45cc884d185124017dc206e52b52c2cfec1 Mon Sep 17 00:00:00 2001 From: babayaga Date: Tue, 19 Aug 2025 15:33:25 +0200 Subject: [PATCH] masonry --- packages/polymech/src/base/media.ts | 145 ++++++++ .../src/components/MasonryGallery.astro | 350 ++++++++++-------- 2 files changed, 340 insertions(+), 155 deletions(-) diff --git a/packages/polymech/src/base/media.ts b/packages/polymech/src/base/media.ts index 951a29a..5253824 100644 --- a/packages/polymech/src/base/media.ts +++ b/packages/polymech/src/base/media.ts @@ -5,6 +5,7 @@ import { GlobOptions } from 'glob' import { sanitizeUri } from 'micromark-util-sanitize-uri' import ExifReader from 'exifreader' +import fs from 'node:fs' import { loadImage } from "imagetools/api" @@ -24,6 +25,150 @@ import { env } from './index.js' const IMAGES_GLOB = '*.+(JPG|jpg|png|PNG|gif)' +// Extract date from EXIF data or file stats +export async function extractImageDate(filePath: string): Promise<{ year: number, dateTaken: Date }> { + let dateTaken: Date | null = null; + + try { + // Try to get EXIF date + const exifData = await ExifReader.load(filePath); + const dateTimeOriginal = exifData?.['DateTimeOriginal']?.description; + const dateTime = exifData?.['DateTime']?.description; + const createDate = exifData?.['CreateDate']?.description; + + // Parse EXIF date (format: "YYYY:MM:DD HH:MM:SS") + const exifDateString = dateTimeOriginal || dateTime || createDate; + if (exifDateString) { + const [datePart, timePart] = exifDateString.split(' '); + const [year, month, day] = datePart.split(':').map(Number); + const [hour, minute, second] = (timePart || '00:00:00').split(':').map(Number); + dateTaken = new Date(year, month - 1, day, hour, minute, second); + } + } catch (e) { + console.warn(`[extractImageDate] Could not read EXIF data for ${filePath}:`, e); + } + + // Fallback to file modification date + if (!dateTaken) { + try { + const stats = fs.statSync(filePath); + dateTaken = stats.mtime; + } catch (e) { + console.warn(`[extractImageDate] Could not read file stats for ${filePath}:`, e); + dateTaken = new Date(); // Ultimate fallback + } + } + + return { + year: dateTaken.getFullYear(), + dateTaken + }; +} + +// Predefined grouping functions +export const groupByYear = (image: { year?: number, dateTaken?: Date }) => { + const year = image.year || (image.dateTaken ? image.dateTaken.getFullYear() : new Date().getFullYear()); + return { + key: year.toString(), + label: year.toString(), + sortOrder: year + }; +}; + +export const groupByMonth = (image: { year?: number, dateTaken?: Date }) => { + const date = image.dateTaken || new Date(); + const year = date.getFullYear(); + const month = date.getMonth(); + const monthNames = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' + ]; + + return { + key: `${year}-${month.toString().padStart(2, '0')}`, + label: `${monthNames[month]} ${year}`, + sortOrder: year * 100 + month + }; +}; + +// Type for grouping function result +export interface GroupInfo { + key: string; + label: string; + sortOrder: number; +} + +// Extract comprehensive metadata from image file +export async function extractImageMetadata(filePath: string): Promise<{ + year: number; + dateTaken: Date; + title: string; + description: string; + keywords: string[]; + width?: number; + height?: number; + exifRaw?: any; +}> { + const fileName = path.basename(filePath, path.extname(filePath)); + const baseDir = path.dirname(filePath); + + // Get date information + const { year, dateTaken } = await extractImageDate(filePath); + + // Load companion metadata files + const metaJsonPath = path.join(baseDir, `${fileName}.json`); + const metaMarkdownPath = path.join(baseDir, `${fileName}.md`); + + let metaJson: any = { alt: "", keywords: "", title: "", description: "" }; + let metaMarkdown = ""; + + try { + if (fs.existsSync(metaJsonPath)) { + metaJson = JSON.parse(fs.readFileSync(metaJsonPath, 'utf8')); + } + + if (fs.existsSync(metaMarkdownPath)) { + metaMarkdown = fs.readFileSync(metaMarkdownPath, 'utf8'); + } + } catch (e) { + console.warn(`Error loading metadata for ${fileName}:`, e); + } + + // Load EXIF data + let exifRaw: any = null; + try { + exifRaw = await ExifReader.load(filePath); + } catch (e) { + console.warn(`Error loading EXIF data for ${filePath}:`, e); + exifRaw = {}; + } + + // Extract EXIF metadata following the same priority as media.ts gallery function + const keywords = exifRaw?.['LastKeywordXMP']?.description || exifRaw?.iptc?.Keywords?.description || ''; + const exifDescription = exifRaw?.['ImageDescription']?.description || ''; + const width = exifRaw?.['Image Width']?.value; + const height = exifRaw?.['Image Height']?.value; + + // Priority order: EXIF title -> JSON title -> generated from filename + const title = exifRaw?.title?.description || metaJson.title || + fileName.replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + + // Priority order: JSON description -> Markdown -> EXIF ImageDescription -> EXIF IPTC Caption -> empty + const description = metaJson.description || metaMarkdown || exifDescription || + exifRaw?.iptc?.['Caption/Abstract']?.description || ''; + + return { + year, + dateTaken, + title, + description, + keywords: keywords ? keywords.split(',').map((k: string) => k.trim()) : [], + width, + height, + exifRaw + }; +} + export const default_sort = (files: string[]): string[] => { const getSortableParts = (filename: string) => { diff --git a/packages/polymech/src/components/MasonryGallery.astro b/packages/polymech/src/components/MasonryGallery.astro index 89012e9..5dc2be3 100644 --- a/packages/polymech/src/components/MasonryGallery.astro +++ b/packages/polymech/src/components/MasonryGallery.astro @@ -7,50 +7,9 @@ import path from "node:path"; import fs from "node:fs"; import { glob } from 'glob'; import { globBase, pathInfo } from "@polymech/commons"; -import { gallery } from "@/base/media.js"; -import ExifReader from 'exifreader'; +import { gallery, extractImageDate, extractImageMetadata, groupByYear, groupByMonth, GroupInfo } from "../base/media"; import { resolveImagePath } from "../utils/path-resolution.js"; -// Extract date from EXIF data or file stats -async function extractImageDate(filePath: string): Promise<{ year: number, dateTaken: Date }> { - let dateTaken: Date | null = null; - - try { - // Try to get EXIF date - const exifData = await ExifReader.load(filePath); - const dateTimeOriginal = exifData?.['DateTimeOriginal']?.description; - const dateTime = exifData?.['DateTime']?.description; - const createDate = exifData?.['CreateDate']?.description; - - // Parse EXIF date (format: "YYYY:MM:DD HH:MM:SS") - const exifDateString = dateTimeOriginal || dateTime || createDate; - if (exifDateString) { - const [datePart, timePart] = exifDateString.split(' '); - const [year, month, day] = datePart.split(':').map(Number); - const [hour, minute, second] = (timePart || '00:00:00').split(':').map(Number); - dateTaken = new Date(year, month - 1, day, hour, minute, second); - } - } catch (e) { - console.warn(`[MasonryGallery] Could not read EXIF data for ${filePath}:`, e); - } - - // Fallback to file modification date - if (!dateTaken) { - try { - const stats = fs.statSync(filePath); - dateTaken = stats.mtime; - } catch (e) { - console.warn(`[MasonryGallery] Could not read file stats for ${filePath}:`, e); - dateTaken = new Date(); // Ultimate fallback - } - } - - return { - year: dateTaken.getFullYear(), - dateTaken - }; -} - interface Image { alt: string; src: string; @@ -60,6 +19,8 @@ interface Image { dateTaken?: Date; } +type GroupByFunction = (image: Image) => GroupInfo; + export interface Props { images?: Image[]; glob?: string; // Glob pattern for auto-loading images @@ -67,6 +28,7 @@ export interface Props { maxWidth?: string; // Maximum width for individual images (e.g., "300px") maxHeight?: string; // Maximum height for individual images (e.g., "400px") entryPath?: string; // Content entry path for resolving relative images + groupBy?: GroupByFunction | 'groupByYear' | 'groupByMonth' | null; // Grouping strategy (default: null = no grouping) gallerySettings?: { SIZES_REGULAR?: string; SIZES_THUMB?: string; @@ -90,6 +52,7 @@ const { maxWidth = "300px", maxHeight = "400px", entryPath, + groupBy = null, gallerySettings = {}, lightboxSettings = {} } = Astro.props; @@ -109,6 +72,24 @@ const mergedLightboxSettings = { const locale = Astro.currentLocale || "en"; +// Calculate responsive gap and grid columns based on maxWidth +const maxWidthNum = parseInt(maxWidth); +const gap = maxWidthNum <= 200 ? '0.5rem' : maxWidthNum <= 300 ? '0.75rem' : '1rem'; + +// Calculate responsive grid columns - use smaller minmax for smaller images +const getGridColumns = (maxWidth: string) => { + const width = parseInt(maxWidth); + if (width <= 200) { + return 'repeat(auto-fill, minmax(140px, 1fr))'; + } else if (width <= 300) { + return 'repeat(auto-fill, minmax(200px, 1fr))'; + } else { + return 'repeat(auto-fill, minmax(250px, 1fr))'; + } +}; + +const gridColumns = getGridColumns(maxWidth); + let allImages: Image[] = [...images]; // Process glob patterns if provided @@ -161,39 +142,18 @@ if (globPattern) { // Convert to a path that resolveImagePath can handle (like "./gallery/image.jpg") const relativeSrc = `./${relativeFromContentDir.replace(/\\/g, '/')}`; - // Extract date information - const { year, dateTaken } = await extractImageDate(filePath); + // Extract comprehensive metadata using the same logic as media.ts + const metadata = await extractImageMetadata(filePath); - // Create basic image structure + // Create image structure with extracted metadata const image: Image = { src: relativeSrc, - alt: `Image: ${fileName}`, - title: fileName.replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), - description: `Auto-loaded from ${globPattern}`, - year, - dateTaken, + alt: metadata.description || `Image: ${fileName}`, + title: metadata.title, + description: metadata.description || `Auto-loaded from ${globPattern}`, + year: metadata.year, + dateTaken: metadata.dateTaken, }; - - // Try to load companion metadata files - const baseDir = path.dirname(filePath); - const metaJsonPath = path.join(baseDir, `${fileName}.json`); - const metaMarkdownPath = path.join(baseDir, `${fileName}.md`); - - try { - if (fs.existsSync(metaJsonPath)) { - const metaJson = JSON.parse(fs.readFileSync(metaJsonPath, 'utf8')); - image.alt = metaJson.alt || image.alt; - image.title = metaJson.title || image.title; - image.description = metaJson.description || image.description; - } - - if (fs.existsSync(metaMarkdownPath)) { - const markdown = fs.readFileSync(metaMarkdownPath, 'utf8'); - image.description = markdown || image.description; - } - } catch (e) { - console.warn(`Error loading metadata for ${fileName}:`, e); - } return image; }) @@ -216,34 +176,57 @@ const resolvedImages = allImages.slice(0, maxItems).map(image => { }; }); -// Group images by year -const imagesByYear = resolvedImages.reduce((groups, image) => { - const year = image.year || new Date().getFullYear(); - if (!groups[year]) { - groups[year] = []; - } - groups[year].push(image); - return groups; -}, {} as Record); +// Determine the grouping function to use +let groupingFunction: GroupByFunction | null = null; +if (groupBy === 'groupByYear') { + groupingFunction = groupByYear; +} else if (groupBy === 'groupByMonth') { + groupingFunction = groupByMonth; +} else if (typeof groupBy === 'function') { + groupingFunction = groupBy; +} -// Sort years in descending order (newest first) -const sortedYears = Object.keys(imagesByYear).map(Number).sort((a, b) => b - a); +let imagesByGroup: Record = {}; +let sortedGroupKeys: string[] = []; +let finalImages: Image[] = []; -// Sort images within each year by date (newest first) -sortedYears.forEach(year => { - imagesByYear[year].sort((a, b) => { +if (groupingFunction) { + // Group images using the specified function + imagesByGroup = resolvedImages.reduce((groups, image) => { + const groupInfo = groupingFunction!(image); + if (!groups[groupInfo.key]) { + groups[groupInfo.key] = { images: [], groupInfo }; + } + groups[groupInfo.key].images.push(image); + return groups; + }, {} as Record); + + // Sort groups by sortOrder (descending - newest first) + sortedGroupKeys = Object.keys(imagesByGroup).sort((a, b) => + imagesByGroup[b].groupInfo.sortOrder - imagesByGroup[a].groupInfo.sortOrder + ); + + // Sort images within each group by date (newest first) + sortedGroupKeys.forEach(key => { + imagesByGroup[key].images.sort((a, b) => { + if (a.dateTaken && b.dateTaken) { + return b.dateTaken.getTime() - a.dateTaken.getTime(); + } + return 0; + }); + }); + + // Create flat array with proper sorting for lightbox + finalImages = sortedGroupKeys.flatMap(key => imagesByGroup[key].images); +} else { + // No grouping - sort all images by date (newest first) + finalImages = resolvedImages.sort((a, b) => { if (a.dateTaken && b.dateTaken) { return b.dateTaken.getTime() - a.dateTaken.getTime(); } return 0; }); -}); - -// Create flat array with proper year/date sorting for lightbox -const finalImages = sortedYears.flatMap(year => imagesByYear[year]); - -console.log('[MasonryGallery] Sample final image paths:', finalImages.slice(0, 3).map(img => ({ src: img.src, title: img.title }))); - +} --- @@ -294,38 +277,99 @@ console.log('[MasonryGallery] Sample final image paths:', finalImages.slice(0, 3 @keydown.window="if(open){ if($event.key === 'ArrowRight' && currentIndex < total - 1){ currentIndex++; preloadAndOpen(); } else if($event.key === 'ArrowLeft' && currentIndex > 0){ currentIndex--; preloadAndOpen(); } }" class="masonry-gallery" > - -
- {sortedYears.map((year) => { - const yearImages = imagesByYear[year]; - const startIndex = finalImages.findIndex(img => img.year === year); - - return ( -
-

- {year} - - ({yearImages.length} {yearImages.length === 1 ? 'image' : 'images'}) - -

- -
- {yearImages.map((image, yearIndex) => { - const globalIndex = finalImages.findIndex(img => img.src === image.src); - return ( -
+ {groupingFunction ? ( + +
+ {sortedGroupKeys.map((groupKey) => { + const groupData = imagesByGroup[groupKey]; + const groupImages = groupData.images; + + return ( +
+

+ {groupData.groupInfo.label} + + ({groupImages.length} {groupImages.length === 1 ? 'image' : 'images'}) + +

+ +
+ {groupImages.map((image, groupIndex) => { + const globalIndex = finalImages.findIndex(img => img.src === image.src); + return ( +
+
+ {image.alt} + + {/* Overlay with title/description on hover */} + {(mergedGallerySettings.SHOW_TITLE || mergedGallerySettings.SHOW_DESCRIPTION) && ( +
+
+ {mergedGallerySettings.SHOW_TITLE && image.title && ( +

+ {image.title} +

+ )} + {mergedGallerySettings.SHOW_DESCRIPTION && image.description && ( +

+ {image.description} +

+ )} +
+
+ )} +
+
+ ); + })} +
+
+ ); + })} +
+ ) : ( + +
+ {finalImages.map((image, index) => ( +
- {/* Overlay with title/description on hover */} - {(mergedGallerySettings.SHOW_TITLE || mergedGallerySettings.SHOW_DESCRIPTION) && ( -
-
- {mergedGallerySettings.SHOW_TITLE && image.title && ( -

- {image.title} -

- )} - {mergedGallerySettings.SHOW_DESCRIPTION && image.description && ( -

- {image.description} -

- )} -
-
- )} + {/* Overlay with title/description on hover */} + {(mergedGallerySettings.SHOW_TITLE || mergedGallerySettings.SHOW_DESCRIPTION) && ( +
+
+ {mergedGallerySettings.SHOW_TITLE && image.title && ( +

+ {image.title} +

+ )} + {mergedGallerySettings.SHOW_DESCRIPTION && image.description && ( +

+ {image.description} +

+ )}
- ); - })} + )}
- ); - })} -
+ ))} +
+ )}