ui components
This commit is contained in:
parent
54133b9374
commit
8b1f8d3d88
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]"
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
494
packages/polymech/src/components/MasonryGallery.astro
Normal file
494
packages/polymech/src/components/MasonryGallery.astro
Normal 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"
|
||||
>
|
||||
×
|
||||
</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"
|
||||
>
|
||||
❮
|
||||
</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"
|
||||
>
|
||||
❯
|
||||
</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>
|
||||
160
packages/polymech/src/components/MasonryGallery.md
Normal file
160
packages/polymech/src/components/MasonryGallery.md
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user