ui components

This commit is contained in:
babayaga 2025-08-19 12:28:24 +02:00
parent 54133b9374
commit 8b1f8d3d88
8 changed files with 732 additions and 47 deletions

View File

@ -5,12 +5,14 @@ import getImageSources from "./getImageSources.js";
import getProcessedImage from "./getProcessedImage.js";
import getArtDirectedImages from "./getArtDirectedImages.js";
import pMap from "p-map";
import { get_cached_object, set_cached_object } from '@polymech/cache';
// Caching moved to plugin level for proper store population
const imagesData = new Map();
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Cache helpers moved to plugin level
export default async function ({
src,
type,
@ -33,15 +35,8 @@ export default async function ({
return imagesData.get(hash);
}
// Check persistent cache
const cacheKey = { src, type, imagesizes, format, breakpoints, placeholder, fallbackFormat, includeSourceFormat, formatOptions, artDirectives, transformConfigs };
const cachedResult = await get_cached_object(cacheKey, 'astro-imagetools');
if (cachedResult) {
console.log(`Cache hit for ${type} at ${src}`);
imagesData.set(hash, cachedResult);
return cachedResult;
}
// Caching removed from this level to ensure proper Vite store population
// Cache is now handled at the plugin level where it can properly manage the store
const start = performance.now();
@ -95,30 +90,22 @@ export default async function ({
// Ensure artDirectedImages is an array
const images = Array.isArray(artDirectedImages) ? [...artDirectedImages, mainImage] : [mainImage];
const uuid = crypto.randomBytes(4).toString("hex").toUpperCase();
// Create deterministic UUID based on input hash for consistent caching
const uuid = crypto.createHash('md5').update(hash).digest("hex").slice(0, 8).toUpperCase();
const returnObject = {
uuid,
images,
};
// Cache both in memory and persistently
// Cache only in memory at this level
imagesData.set(hash, returnObject);
await set_cached_object(cacheKey, 'astro-imagetools', returnObject, {
src: args[0].src,
type,
timestamp: Date.now()
});
const end = performance.now();
console.log(
`Responsive Image sets generated for ${type} at ${args[0].src} in ${end - start}ms`
);
// Persistent caching moved to plugin level for proper store management
return returnObject;
} catch (error) {
console.error("Error processing images:", error);
console.error(`Error processing images:: ${src}`, error, error.stack);
throw error;
}
}

View File

@ -1,12 +1,12 @@
export default {
"environment": "build",
"environment": "dev",
"isSsrBuild": false,
"projectBase": "",
"publicDir": "C:\\Users\\zx\\Desktop\\polymech\\site2\\public\\",
"rootDir": "C:\\Users\\zx\\Desktop\\polymech\\site2\\",
"mode": "production",
"outDir": "C:\\Users\\zx\\Desktop\\polymech\\site2\\dist\\",
"assetsDir": "_astro",
"mode": "dev",
"outDir": "dist",
"assetsDir": "/_astro",
"sourcemap": false,
"assetFileNames": "/_astro/[name]@[width].[hash][extname]"
}

View File

@ -9,8 +9,6 @@ import pMap from "p-map"
const filename = fileURLToPath(import.meta.url);
const astroViteConfigsPath = resolve(filename, "../../astroViteConfigs.js");
import { get_cached, set_cached, get_path_cached } from '@polymech/cache'
export default {
name: "imagetools",
hooks: {
@ -62,6 +60,7 @@ export default {
assetPaths,
async ([assetPath, { hash, image, buffer }]) => {
// Retry mechanism with exponential backoff for image processing
/*
const retryWithBackoff = async (fn, retries = 3, baseDelay = 10) => {
for (let i = 0; i < retries; i++) {
try {
@ -89,9 +88,10 @@ export default {
}
}
};
*/
try {
await retryWithBackoff(async () => {
// await retryWithBackoff(async () => {
await saveAndCopyAsset(
hash,
image,
@ -101,8 +101,7 @@ export default {
assetPath,
isSsrBuild
);
});
console.log(`Image processed: ${assetPath}`);
// });
} catch (error) {
console.error(`Failed to process image ${assetPath} after retries:`, error);
// Continue processing other images even if one fails

View File

@ -7,6 +7,7 @@ import { getCachedBuffer } from "../utils/cache.js";
import { getSrcPath } from "../../api/utils/getSrcPath.js";
import { getAssetPath, getConfigOptions } from "../utils/shared.js";
import { sharp, supportedImageTypes } from "../../utils/runtimeChecks.js";
import { get_cached_object, set_cached_object } from '@polymech/cache';
const { getLoadedImage, getTransformedImage } = await (sharp
? import("../utils/imagetools.js")
@ -128,20 +129,53 @@ export default async function load(id) {
if (!store.has(assetPath)) {
const config = { width, ...options };
// Create cache key for this specific image transformation
const cacheKey = {
src: id,
width,
type,
extension,
options: objectHash(options)
};
let imageObject = null;
// Only use cache in production builds
if (environment === "production") {
imageObject = await get_cached_object(cacheKey, 'imagetools-plugin');
if (imageObject) {
console.log(`[imagetools-cache] Cache hit for ${assetPath}`);
}
}
const { image, buffer } = raw
? {
image: sharp && loadedImage,
buffer: !sharp && loadedImage.data,
}
: await getTransformedImage({
src,
image: loadedImage,
config,
// Process image if not cached
if (!imageObject) {
const { image, buffer } = raw
? {
image: sharp && loadedImage,
buffer: !sharp && loadedImage.data,
}
: await getTransformedImage({
src,
image: loadedImage,
config,
type,
});
imageObject = { hash, type, image, buffer };
// Cache the processed result in production
if (environment === "production") {
await set_cached_object(cacheKey, 'imagetools-plugin', imageObject, {
src: id,
width,
type,
timestamp: Date.now()
});
const imageObject = { hash, type, image, buffer };
console.log(`[imagetools-cache] Cached ${assetPath}`);
}
}
store.set(assetPath, imageObject);
}

View File

@ -3,7 +3,18 @@ import { store } from "../plugin/index.js";
import { getCachedBuffer } from "../plugin/utils/cache.js";
export async function middleware(request, response) {
const imageObject = store.get(request.url);
const url = request.url || request.path;
const imageObject = store.get(url);
/*
// Debug logging
if (url?.includes('_astro/') && url?.includes('.avif')) {
console.log(`[imagetools-debug] Looking for: ${url}`);
console.log(`[imagetools-debug] Store has ${store.size} entries`);
console.log(`[imagetools-debug] Found: ${!!imageObject}`);
if (!imageObject && store.size > 0) {
console.log(`[imagetools-debug] Available keys:`, [...store.keys()].slice(0, 5));
}
}*/
if (imageObject) {
const { hash, type, image, buffer } = imageObject;

View File

@ -53,5 +53,5 @@ export default function printWarning({
: `can't be defined inside attributes.${element}`)) +
colours.reset;
console.log(flag + keyLog, messageLog);
// console.log(flag + keyLog, messageLog);
}

View File

@ -0,0 +1,494 @@
---
import { Img } from "imagetools/components";
import Translate from "./i18n.astro";
import { translate } from "@/base/i18n";
import { I18N_SOURCE_LANGUAGE, IMAGE_SETTINGS } from "config/config.js";
import path from "node:path";
import fs from "node:fs";
import { glob } from 'glob';
import { globBase, globParent, pathInfoEx, pathInfo } from "@polymech/commons";
import { gallery } from "@/base/media.js";
import ExifReader from 'exifreader';
// Path resolution function (simplified version)
function resolveImagePath(src: string, entryPath?: string, astroUrl?: URL): string {
// External URLs and absolute paths
if (src.startsWith('/') || src.startsWith('http')) {
return src;
}
// Relative paths
if (src.startsWith('.') && entryPath) {
const contentDir = entryPath.substring(0, entryPath.lastIndexOf('/'));
const basePath = path.join(process.cwd(), 'src', 'content', contentDir);
try {
const absolutePath = path.resolve(basePath, src);
if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile()) {
return path.relative(process.cwd(), absolutePath).replace(/\\/g, '/');
}
} catch (e) {
console.warn(`[MasonryGallery] Error resolving path for ${src}:`, e);
}
}
return src;
}
// 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;
title?: string;
description?: string;
year?: number;
dateTaken?: Date;
}
export interface Props {
images?: Image[];
glob?: string; // Glob pattern for auto-loading images
maxItems?: number; // Maximum number of images to display
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
gallerySettings?: {
SIZES_REGULAR?: string;
SIZES_THUMB?: string;
SIZES_LARGE?: string;
SHOW_TITLE?: boolean;
SHOW_DESCRIPTION?: boolean;
};
lightboxSettings?: {
SIZES_REGULAR?: string;
SIZES_THUMB?: string;
SIZES_LARGE?: string;
SHOW_TITLE?: boolean;
SHOW_DESCRIPTION?: boolean;
};
}
const {
images = [],
glob: globPattern,
maxItems = 50,
maxWidth = "300px",
maxHeight = "400px",
entryPath,
gallerySettings = {},
lightboxSettings = {}
} = Astro.props;
const mergedGallerySettings = {
SIZES_REGULAR: gallerySettings.SIZES_REGULAR || IMAGE_SETTINGS.GALLERY.SIZES_REGULAR,
SIZES_THUMB: gallerySettings.SIZES_THUMB || IMAGE_SETTINGS.GALLERY.SIZES_THUMB,
SHOW_TITLE: gallerySettings.SHOW_TITLE ?? IMAGE_SETTINGS.GALLERY.SHOW_TITLE,
SHOW_DESCRIPTION: gallerySettings.SHOW_DESCRIPTION ?? IMAGE_SETTINGS.GALLERY.SHOW_DESCRIPTION,
};
const mergedLightboxSettings = {
SIZES_LARGE: lightboxSettings.SIZES_LARGE || IMAGE_SETTINGS.LIGHTBOX.SIZES_LARGE,
SHOW_TITLE: lightboxSettings.SHOW_TITLE ?? IMAGE_SETTINGS.LIGHTBOX.SHOW_TITLE,
SHOW_DESCRIPTION: lightboxSettings.SHOW_DESCRIPTION ?? IMAGE_SETTINGS.LIGHTBOX.SHOW_DESCRIPTION,
};
const locale = Astro.currentLocale || "en";
let allImages: Image[] = [...images];
// Process glob patterns if provided
if (globPattern) {
try {
// Get current content directory from URL or use entryPath
const currentUrl = Astro.url.pathname;
const pathSegments = currentUrl.split('/').filter(Boolean);
// Handle locale-aware URLs
const isLocaleFirst = pathSegments.length > 0 && pathSegments[0].length === 2 && /^[a-z]{2}$/.test(pathSegments[0]);
// Determine content subdirectory
let contentSubdir = 'resources';
if (pathSegments.length >= 1) {
contentSubdir = isLocaleFirst && pathSegments.length > 1
? pathSegments[1]
: pathSegments[0];
}
// Get nested content directory
let contentPath = contentSubdir;
const minNestedSegments = isLocaleFirst ? 4 : 3;
if (pathSegments.length >= minNestedSegments) {
const nestedDirIndex = isLocaleFirst ? 2 : 1;
if (pathSegments.length > nestedDirIndex) {
const nestedDir = pathSegments[nestedDirIndex];
contentPath = `${contentSubdir}/${nestedDir}`;
}
}
// Use entryPath if provided, otherwise derive from URL
let finalContentPath;
if (entryPath) {
// entryPath is like "resources/internal/Masonry", we want the directory part
const lastSlashIndex = entryPath.lastIndexOf('/');
finalContentPath = lastSlashIndex > -1 ? entryPath.substring(0, lastSlashIndex) : entryPath;
} else {
finalContentPath = contentPath;
}
const contentDir = path.join(process.cwd(), 'src', 'content', finalContentPath);
console.log(`[MasonryGallery] Searching for files with pattern: ${globPattern}`);
console.log(`[MasonryGallery] Content directory: ${contentDir}`);
let matchedFiles = glob.sync(globPattern, { cwd: contentDir, absolute: true });
console.log(`[MasonryGallery] Found ${matchedFiles.length} files:`, matchedFiles);
if (matchedFiles.length === 0) {
const pathInfo2 = pathInfo(globPattern, false, path.join(contentDir, globBase(globPattern).base));
matchedFiles = pathInfo2.FILES;
console.log(`[MasonryGallery] Fallback pathInfo found ${matchedFiles.length} files:`, matchedFiles);
}
// Process matched files
const globImages: Image[] = await Promise.all(
matchedFiles.slice(0, maxItems).map(async (filePath) => {
const fileName = path.basename(filePath, path.extname(filePath));
// Get the relative path from the content directory, not from process.cwd()
const relativeFromContentDir = path.relative(contentDir, filePath);
// Convert to a path that resolveImagePath can handle (like "./gallery/image.jpg")
const relativeSrc = `./${relativeFromContentDir.replace(/\\/g, '/')}`;
console.log(`[MasonryGallery] Processing file: ${filePath}`);
console.log(`[MasonryGallery] Content dir: ${contentDir}`);
console.log(`[MasonryGallery] Relative from content: ${relativeFromContentDir}`);
console.log(`[MasonryGallery] Relative src: ${relativeSrc}`);
// Extract date information
const { year, dateTaken } = await extractImageDate(filePath);
// Create basic image structure
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,
};
// 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;
})
);
allImages = [...allImages, ...globImages];
} catch (error) {
console.warn('Glob pattern failed:', error);
}
}
// Apply maxItems constraint and resolve image paths
const resolvedImages = allImages.slice(0, maxItems).map(image => {
const originalSrc = image.src;
const resolvedSrc = resolveImagePath(image.src, entryPath, Astro.url);
console.log(`[MasonryGallery] Path resolution: "${originalSrc}" -> "${resolvedSrc}"`);
return {
...image,
src: resolvedSrc
};
});
// 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[]>);
// Sort years in descending order (newest first)
const sortedYears = Object.keys(imagesByYear).map(Number).sort((a, b) => b - a);
// Sort images within each year by date (newest first)
sortedYears.forEach(year => {
imagesByYear[year].sort((a, b) => {
if (a.dateTaken && b.dateTaken) {
return b.dateTaken.getTime() - a.dateTaken.getTime();
}
return 0;
});
});
// Create flat array with year info for lightbox
const finalImages = resolvedImages;
console.log(`[MasonryGallery] Images grouped by years:`, sortedYears, imagesByYear);
console.log(`[MasonryGallery] Final images array:`, finalImages.length, finalImages);
---
<div
x-data={`{ open: false, currentIndex: 0, total: ${finalImages.length}, lightboxLoaded: false, touchStartX: 0, touchEndX: 0, minSwipeDistance: 50, isSwiping: false, images: ${JSON.stringify(finalImages)}, openLightbox(index) { this.currentIndex = index; this.preloadAndOpen(); }, handleSwipe() { if (!this.isSwiping) return; const swipeDistance = this.touchEndX - this.touchStartX; if (Math.abs(swipeDistance) >= this.minSwipeDistance) { if (swipeDistance > 0 && this.currentIndex > 0) { this.currentIndex--; this.preloadAndOpen(); } else if (swipeDistance < 0 && this.currentIndex < this.total - 1) { this.currentIndex++; this.preloadAndOpen(); } } this.isSwiping = false; }, preloadAndOpen() { if (this.isSwiping) return; this.lightboxLoaded = false; let img = new Image(); img.src = this.images[this.currentIndex].src; img.onload = () => { this.lightboxLoaded = true; this.open = true; }; } }`}
@keydown.escape.window="open = false"
@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 key={year} 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})`}
>
<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>
<!-- Lightbox Modal -->
<div
x-show="open"
x-transition
class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50"
style="z-index: 9999;"
>
<div
class="relative max-w-full max-h-full"
@touchstart="touchStartX = $event.touches[0].clientX; isSwiping = true;"
@touchend="touchEndX = $event.changedTouches[0].clientX; handleSwipe();"
@touchcancel="isSwiping = false;"
>
{finalImages.map((image, index) => {
return (
<div x-show={`currentIndex === ${index}`}>
<Img
src={image.src}
alt={image.alt}
placeholder="blurred"
format="avif"
objectFit="contain"
sizes={mergedLightboxSettings.SIZES_LARGE}
attributes={{
img: { class: "max-w-[90vw] max-h-[90vh] object-contain rounded-lg" }
}}
/>
{(mergedLightboxSettings.SHOW_TITLE || mergedLightboxSettings.SHOW_DESCRIPTION) && (
<div class="absolute bottom-0 left-1/2 transform -translate-x-1/2 m-2 max-h-[32vh] p-3 text-white bg-black/50 rounded-lg" style="width: 90%;">
{mergedLightboxSettings.SHOW_TITLE && image.title && (
<h3 class="text-xl mb-2">
<Translate>{image.title}</Translate>
</h3>
)}
{mergedLightboxSettings.SHOW_DESCRIPTION && image.description && (
<p>
<Translate>{image.description}</Translate>
</p>
)}
</div>
)}
</div>
);
})}
<!-- Close Button -->
<button
x-on:click="open = false"
class="absolute top-0 right-0 text-white text-2xl p-4 m-2 bg-gray-800/75 rounded-lg hover:bg-gray-700/75 transition-colors"
aria-label="Close"
>
&times;
</button>
<!-- Navigation Buttons -->
<button
x-show="currentIndex > 0"
x-on:click="currentIndex--; preloadAndOpen();"
class="absolute left-0 top-1/2 transform -translate-y-1/2 p-4 m-2 text-white text-3xl bg-gray-800/75 rounded-lg hover:bg-gray-700/75 transition-colors"
aria-label="Previous"
>
&#10094;
</button>
<button
x-show="currentIndex < total - 1"
x-on:click="currentIndex++; preloadAndOpen();"
class="absolute right-0 top-1/2 transform -translate-y-1/2 p-4 m-2 text-white text-3xl bg-gray-800/75 rounded-lg hover:bg-gray-700/75 transition-colors"
aria-label="Next"
>
&#10095;
</button>
</div>
</div>
</div>
<style>
.masonry-gallery {
width: 100%;
}
.masonry-container {
/* CSS Grid Masonry fallback for browsers that don't support masonry */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
align-items: start;
}
/* Modern browsers with masonry support */
@supports (grid-template-rows: masonry) {
.masonry-container {
grid-template-rows: masonry;
}
}
.masonry-item {
break-inside: avoid;
page-break-inside: avoid;
display: block;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.masonry-container {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
}
@media (max-width: 480px) {
.masonry-container {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.5rem;
}
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Ensure lightbox is on top */
.masonry-gallery [x-show="open"] {
z-index: 9999;
}
</style>

View File

@ -0,0 +1,160 @@
# MasonryGallery Component
A responsive masonry gallery component with lightbox functionality, glob pattern support, and configurable constraints.
## Features
- **Masonry Layout**: CSS Grid-based masonry layout with no fixed height
- **Lightbox**: Full lightbox functionality inherited from GalleryK with swipe support
- **Glob Support**: Auto-load images using glob patterns (relative to content directory)
- **Constraints**: Configurable max items, max width, and max height
- **Responsive**: Adapts to different screen sizes
- **Metadata**: Supports companion JSON and Markdown files for image metadata
## Props
```typescript
interface Props {
images?: Image[]; // Manual image array
glob?: string; // Glob pattern for auto-loading (e.g., "*.jpg", "**/*.{jpg,png}")
maxItems?: number; // Maximum number of images (default: 50)
maxWidth?: string; // Max width per image (default: "300px")
maxHeight?: string; // Max height per image (default: "400px")
entryPath?: string; // Content entry path for resolving relative images
gallerySettings?: { // Gallery display settings
SIZES_REGULAR?: string;
SIZES_THUMB?: string;
SIZES_LARGE?: string;
SHOW_TITLE?: boolean;
SHOW_DESCRIPTION?: boolean;
};
lightboxSettings?: { // Lightbox display settings
SIZES_REGULAR?: string;
SIZES_THUMB?: string;
SIZES_LARGE?: string;
SHOW_TITLE?: boolean;
SHOW_DESCRIPTION?: boolean;
};
}
```
## Usage Examples
### Basic Usage with Manual Images
```astro
---
import MasonryGallery from "./MasonryGallery.astro";
const images = [
{ src: "/images/photo1.jpg", alt: "Photo 1", title: "Beautiful Landscape" },
{ src: "/images/photo2.jpg", alt: "Photo 2", title: "City Skyline" },
// ... more images
];
---
<MasonryGallery images={images} maxItems={20} />
```
### Using Glob Patterns
```astro
---
import MasonryGallery from "./MasonryGallery.astro";
---
<!-- Load all JPG images from current content directory -->
<MasonryGallery
glob="*.jpg"
maxItems={30}
maxWidth="400px"
maxHeight="500px"
/>
<!-- Load images from subdirectories -->
<MasonryGallery
glob="gallery/**/*.{jpg,png,webp}"
maxItems={50}
/>
```
### With Custom Settings
```astro
---
import MasonryGallery from "./MasonryGallery.astro";
const gallerySettings = {
SHOW_TITLE: true,
SHOW_DESCRIPTION: true,
SIZES_REGULAR: "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
};
const lightboxSettings = {
SHOW_TITLE: true,
SHOW_DESCRIPTION: false,
SIZES_LARGE: "90vw"
};
---
<MasonryGallery
glob="**/*.jpg"
maxItems={25}
maxWidth="350px"
maxHeight="450px"
gallerySettings={gallerySettings}
lightboxSettings={lightboxSettings}
/>
```
## Metadata Support
The component supports companion files for rich metadata:
### JSON Metadata (`image.json`)
```json
{
"alt": "Beautiful mountain landscape at sunset",
"title": "Mountain Sunset",
"description": "A breathtaking view of mountains during golden hour"
}
```
### Markdown Metadata (`image.md`)
```markdown
A detailed description of the image that can include **markdown formatting**.
This will be used as the description if no JSON description is provided.
```
## Responsive Behavior
- **Desktop**: Minimum 250px columns with auto-fill
- **Tablet**: Minimum 200px columns
- **Mobile**: Minimum 150px columns
## Keyboard Controls (in Lightbox)
- **Escape**: Close lightbox
- **Arrow Left**: Previous image
- **Arrow Right**: Next image
## Touch Controls (in Lightbox)
- **Swipe Left**: Next image
- **Swipe Right**: Previous image
## CSS Classes
The component uses the following CSS classes for styling:
- `.masonry-gallery`: Main container
- `.masonry-container`: Grid container
- `.masonry-item`: Individual image container
- `.line-clamp-2`: Text truncation utility
## Browser Support
- Modern browsers with CSS Grid support
- Graceful fallback for browsers without masonry support
- Progressive enhancement for touch devices