diff --git a/packages/imagetools/api/utils/getImage.js b/packages/imagetools/api/utils/getImage.js index 2e45598..e385ba1 100644 --- a/packages/imagetools/api/utils/getImage.js +++ b/packages/imagetools/api/utils/getImage.js @@ -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; } } diff --git a/packages/imagetools/astroViteConfigs.js b/packages/imagetools/astroViteConfigs.js index 2f7b5cc..8b2d327 100644 --- a/packages/imagetools/astroViteConfigs.js +++ b/packages/imagetools/astroViteConfigs.js @@ -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]" } \ No newline at end of file diff --git a/packages/imagetools/integration/index.js b/packages/imagetools/integration/index.js index 4114eee..ef4c381 100644 --- a/packages/imagetools/integration/index.js +++ b/packages/imagetools/integration/index.js @@ -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 diff --git a/packages/imagetools/plugin/hooks/load.js b/packages/imagetools/plugin/hooks/load.js index 4998301..2b0bfcf 100644 --- a/packages/imagetools/plugin/hooks/load.js +++ b/packages/imagetools/plugin/hooks/load.js @@ -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); } diff --git a/packages/imagetools/ssr/index.js b/packages/imagetools/ssr/index.js index d6c6898..f25f02e 100644 --- a/packages/imagetools/ssr/index.js +++ b/packages/imagetools/ssr/index.js @@ -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; diff --git a/packages/imagetools/utils/printWarning.js b/packages/imagetools/utils/printWarning.js index 754026b..efeac3d 100644 --- a/packages/imagetools/utils/printWarning.js +++ b/packages/imagetools/utils/printWarning.js @@ -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); } diff --git a/packages/polymech/src/components/MasonryGallery.astro b/packages/polymech/src/components/MasonryGallery.astro new file mode 100644 index 0000000..cfccb34 --- /dev/null +++ b/packages/polymech/src/components/MasonryGallery.astro @@ -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); + +// 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); +--- + +
= 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" +> + +
+ {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 ( +
+
+ {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) => { + return ( +
+ {image.alt} + {(mergedLightboxSettings.SHOW_TITLE || mergedLightboxSettings.SHOW_DESCRIPTION) && ( +
+ {mergedLightboxSettings.SHOW_TITLE && image.title && ( +

+ {image.title} +

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

+ {image.description} +

+ )} +
+ )} +
+ ); + })} + + + + + + + +
+
+
+ + \ No newline at end of file diff --git a/packages/polymech/src/components/MasonryGallery.md b/packages/polymech/src/components/MasonryGallery.md new file mode 100644 index 0000000..965a8db --- /dev/null +++ b/packages/polymech/src/components/MasonryGallery.md @@ -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 +]; +--- + + +``` + +### Using Glob Patterns + +```astro +--- +import MasonryGallery from "./MasonryGallery.astro"; +--- + + + + + + +``` + +### 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" +}; +--- + + +``` + +## 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