masonry
This commit is contained in:
parent
5c8fb0015c
commit
d993f45cc8
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user