This commit is contained in:
babayaga 2025-08-19 15:33:25 +02:00
parent 5c8fb0015c
commit d993f45cc8
2 changed files with 340 additions and 155 deletions

View File

@ -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) => {

View File

@ -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<number, Image[]>);
// 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<string, { images: Image[], groupInfo: GroupInfo }> = {};
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<string, { images: Image[], groupInfo: GroupInfo }>);
// 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"
>
<!-- Masonry Grid with Year Headers -->
<div class="space-y-8">
{sortedYears.map((year) => {
const yearImages = imagesByYear[year];
const startIndex = finalImages.findIndex(img => img.year === year);
return (
<div class="year-group">
<h2 class="text-2xl font-bold text-gray-800 mb-4 sticky top-4 bg-white/90 backdrop-blur-sm py-2 px-4 rounded-lg shadow-sm z-10">
{year}
<span class="text-sm font-normal text-gray-500 ml-2">
({yearImages.length} {yearImages.length === 1 ? 'image' : 'images'})
</span>
</h2>
<div
class="masonry-container"
style={`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
align-items: start;
`}
>
{yearImages.map((image, yearIndex) => {
const globalIndex = finalImages.findIndex(img => img.src === image.src);
return (
<div
class="masonry-item cursor-pointer group"
style={`max-width: ${maxWidth}; max-height: ${maxHeight};`}
x-on:click={`openLightbox(${globalIndex})`}
>
{groupingFunction ? (
<!-- Masonry Grid with Group Headers -->
<div class="space-y-8">
{sortedGroupKeys.map((groupKey) => {
const groupData = imagesByGroup[groupKey];
const groupImages = groupData.images;
return (
<div class="group-section">
<h2 class="text-2xl font-bold text-gray-800 mb-4 sticky top-4 bg-white/90 backdrop-blur-sm py-2 px-4 rounded-lg shadow-sm z-10">
{groupData.groupInfo.label}
<span class="text-sm font-normal text-gray-500 ml-2">
({groupImages.length} {groupImages.length === 1 ? 'image' : 'images'})
</span>
</h2>
<div
class="masonry-container"
style={`
display: grid;
grid-template-columns: ${gridColumns};
gap: ${gap};
align-items: start;
`}
>
{groupImages.map((image, groupIndex) => {
const globalIndex = finalImages.findIndex(img => img.src === image.src);
return (
<div
class="masonry-item cursor-pointer group"
style={`max-width: ${maxWidth}; max-height: ${maxHeight};`}
x-on:click={`openLightbox(${globalIndex})`}
>
<div class="relative overflow-hidden rounded-lg bg-gray-100">
<Img
src={image.src}
alt={image.alt}
objectFit="cover"
format="avif"
placeholder="blurred"
sizes={mergedGallerySettings.SIZES_REGULAR}
attributes={{
img: {
class: "w-full h-auto group-hover:scale-105 transition-transform duration-300 rounded-lg",
style: `max-width: ${maxWidth}; max-height: ${maxHeight}; object-fit: cover;`
}
}}
loading="lazy"
/>
{/* Overlay with title/description on hover */}
{(mergedGallerySettings.SHOW_TITLE || mergedGallerySettings.SHOW_DESCRIPTION) && (
<div class="absolute inset-0 bg-gradient-to-t from-black/0 via-transparent to-transparent group-hover:from-black/70 group-hover:via-black/20 transition-all duration-300 flex items-end pointer-events-none">
<div class="p-4 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300">
{mergedGallerySettings.SHOW_TITLE && image.title && (
<h3 class="font-semibold text-sm mb-1">
<Translate>{image.title}</Translate>
</h3>
)}
{mergedGallerySettings.SHOW_DESCRIPTION && image.description && (
<p class="text-xs line-clamp-2">
<Translate>{image.description}</Translate>
</p>
)}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
<!-- Simple Masonry Grid without Year Headers -->
<div
class="masonry-container"
style={`
display: grid;
grid-template-columns: ${gridColumns};
gap: ${gap};
align-items: start;
`}
>
{finalImages.map((image, index) => (
<div
class="masonry-item cursor-pointer group"
style={`max-width: ${maxWidth}; max-height: ${maxHeight};`}
x-on:click={`openLightbox(${index})`}
>
<div class="relative overflow-hidden rounded-lg bg-gray-100">
<Img
src={image.src}
@ -343,32 +387,28 @@ console.log('[MasonryGallery] Sample final image paths:', finalImages.slice(0, 3
loading="lazy"
/>
{/* Overlay with title/description on hover */}
{(mergedGallerySettings.SHOW_TITLE || mergedGallerySettings.SHOW_DESCRIPTION) && (
<div class="absolute inset-0 bg-gradient-to-t from-black/0 via-transparent to-transparent group-hover:from-black/70 group-hover:via-black/20 transition-all duration-300 flex items-end pointer-events-none">
<div class="p-4 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300">
{mergedGallerySettings.SHOW_TITLE && image.title && (
<h3 class="font-semibold text-sm mb-1">
<Translate>{image.title}</Translate>
</h3>
)}
{mergedGallerySettings.SHOW_DESCRIPTION && image.description && (
<p class="text-xs line-clamp-2">
<Translate>{image.description}</Translate>
</p>
)}
</div>
</div>
)}
{/* Overlay with title/description on hover */}
{(mergedGallerySettings.SHOW_TITLE || mergedGallerySettings.SHOW_DESCRIPTION) && (
<div class="absolute inset-0 bg-gradient-to-t from-black/0 via-transparent to-transparent group-hover:from-black/70 group-hover:via-black/20 transition-all duration-300 flex items-end pointer-events-none">
<div class="p-4 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300">
{mergedGallerySettings.SHOW_TITLE && image.title && (
<h3 class="font-semibold text-sm mb-1">
<Translate>{image.title}</Translate>
</h3>
)}
{mergedGallerySettings.SHOW_DESCRIPTION && image.description && (
<p class="text-xs line-clamp-2">
<Translate>{image.description}</Translate>
</p>
)}
</div>
</div>
);
})}
)}
</div>
</div>
);
})}
</div>
))}
</div>
)}
<!-- Lightbox Modal -->
<div
@ -454,7 +494,7 @@ console.log('[MasonryGallery] Sample final image paths:', finalImages.slice(0, 3
/* CSS Grid Masonry fallback for browsers that don't support masonry */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
gap: 0.75rem; /* Default gap, overridden by inline styles */
align-items: start;
}
@ -474,15 +514,15 @@ console.log('[MasonryGallery] Sample final image paths:', finalImages.slice(0, 3
/* Responsive adjustments */
@media (max-width: 768px) {
.masonry-container {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(45%, 1fr));
gap: 0.5rem;
}
}
@media (max-width: 480px) {
.masonry-container {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.5rem;
grid-template-columns: repeat(auto-fill, minmax(47%, 1fr));
gap: 0.375rem;
}
}
@ -498,13 +538,13 @@ console.log('[MasonryGallery] Sample final image paths:', finalImages.slice(0, 3
z-index: 9999;
}
/* Year group styling */
.year-group {
/* Group section styling */
.group-section {
scroll-margin-top: 6rem;
}
/* Sticky year headers */
.year-group h2 {
/* Sticky group headers */
.group-section h2 {
position: sticky;
top: 1rem;
z-index: 20;