{"_id":"@altner/astro-justified-gallery-layout","name":"@altner/astro-justified-gallery-layout","dist-tags":{"latest":"0.6.0"},"versions":{"0.6.0":{"name":"@altner/astro-justified-gallery-layout","version":"0.6.0","description":"Justified (Flickr-style) photo gallery for Astro. Static + virtualized components, content loader with EXIF/IPTC/GPS, LQIP previews, modal lightbox with swipe. ESM, TypeScript types, zero runtime deps in the core.","type":"module","main":"src/index.js","module":"src/index.js","types":"index.d.ts","exports":{".":{"types":"./index.d.ts","import":"./src/index.js","default":"./src/index.js"},"./JustifiedGallery.astro":"./src/JustifiedGallery.astro","./JustifiedGalleryVirtual.astro":"./src/JustifiedGalleryVirtual.astro","./Lightbox.astro":"./src/Lightbox.astro","./loader":{"types":"./loader.d.ts","import":"./src/loader.js","default":"./src/loader.js"},"./exif":{"types":"./exif.d.ts","import":"./src/exif.js","default":"./src/exif.js"}},"sideEffects":false,"engines":{"node":">=18"},"peerDependencies":{"astro":">=4","exifr":">=7"},"peerDependenciesMeta":{"astro":{"optional":true},"exifr":{"optional":true}},"keywords":["astro","astro-component","justified-layout","flickr-layout","masonry","gallery","photo-gallery","lightbox","image-gallery","lqip","exif","iptc","esm"],"license":"MIT","author":{"name":"Adrian Altner"},"homepage":"https://github.com/altner/astro-justified-gallery-layout#readme","repository":{"type":"git","url":"git+https://github.com/altner/astro-justified-gallery-layout.git","directory":"packages/astro-justified-gallery-layout"},"bugs":{"url":"https://github.com/altner/astro-justified-gallery-layout/issues"},"publishConfig":{"access":"public"},"gitHead":"0c1758b2aa134f291620c1c9e07f36840c329e09","_id":"@altner/astro-justified-gallery-layout@0.6.0","_nodeVersion":"24.14.1","_npmVersion":"11.12.1","dist":{"integrity":"sha512-T2zBvvZ4ktRWS8vQ9E2saRRuClWx16spn0JZXApTp6yd+GlleOILQZXUlCILkBJfZVsUoGp0Yp/IPN3wT9tN6w==","shasum":"e16e546b7e5849d6a6a50209c2a84796eaeff415","tarball":"https://registry.npmjs.org/@altner/astro-justified-gallery-layout/-/astro-justified-gallery-layout-0.6.0.tgz","fileCount":13,"unpackedSize":87020,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIGI7bspr0cCIiUo+JoV9Ix6zF3tYpNC0pwuYMuC3vvErAiBAeElSImUPz04X955Ig8kW3jrmWnMvvq+izWMdlvbXiw=="}]},"_npmUser":{"name":"altner","email":"adrian.altner+npm@mailbox.org"},"directories":{},"maintainers":[{"name":"altner","email":"adrian.altner+npm@mailbox.org"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/astro-justified-gallery-layout_0.6.0_1777411660159_0.4477321689215048"},"_hasShrinkwrap":false}},"time":{"created":"2026-04-28T21:27:40.017Z","0.6.0":"2026-04-28T21:27:40.323Z","modified":"2026-04-28T21:27:40.547Z"},"maintainers":[{"name":"altner","email":"adrian.altner+npm@mailbox.org"}],"description":"Justified (Flickr-style) photo gallery for Astro. Static + virtualized components, content loader with EXIF/IPTC/GPS, LQIP previews, modal lightbox with swipe. ESM, TypeScript types, zero runtime deps in the core.","homepage":"https://github.com/altner/astro-justified-gallery-layout#readme","keywords":["astro","astro-component","justified-layout","flickr-layout","masonry","gallery","photo-gallery","lightbox","image-gallery","lqip","exif","iptc","esm"],"repository":{"type":"git","url":"git+https://github.com/altner/astro-justified-gallery-layout.git","directory":"packages/astro-justified-gallery-layout"},"author":{"name":"Adrian Altner"},"bugs":{"url":"https://github.com/altner/astro-justified-gallery-layout/issues"},"license":"MIT","readme":"# @altner/astro-justified-gallery-layout\n\nModern, zero-dependency justified (Flickr-style) gallery layout for Astro. Static and virtualized components, content loader with EXIF/IPTC/GPS extraction, LQIP previews, and a drop-in modal lightbox with slide animation and swipe gestures.\n\nA from-scratch replacement for Flickr's [justified-layout](https://github.com/flickr/justified-layout), which hasn't been touched in 6+ years. Smaller, ESM-first, no CommonJS legacy.\n\n## Features\n\n- **Justified row layout** — pure ~120 LOC function, deterministic, no DOM access\n- **Two components** — `<JustifiedGallery />` for normal use (≤ ~5k photos), `<JustifiedGalleryVirtual />` with DOM windowing for very large collections\n- **Content Loader** — drop-in `galleryLoader()` for Astro Content Collections; scans a directory and optionally reads EXIF/IPTC/XMP/GPS metadata\n- **LQIP previews** — tiny base64 placeholders (~1 KB) for instant first paint while full images load\n- **Lightbox** — native `<dialog>` modal with prev/next, swipe, ESC/×/backdrop close\n- **Resize-safe** — re-flows on container resize via `ResizeObserver`\n- **TypeScript types** bundled, `is:global` styles avoid scoping bugs with dynamic items\n\n## Table of contents\n\n- [Install](#install)\n- [Quick start](#quick-start) — minimal Astro setup in 30 lines\n- [Components](#components)\n  - [`<JustifiedGallery />`](#justifiedgallery-)\n  - [`<JustifiedGalleryVirtual />`](#virtualized-component-massive-galleries)\n  - [`<Lightbox />`](#lightbox)\n- [Content Loader](#content-loader)\n- [EXIF helper](#exif-helper)\n- [Pure function (`computeLayout`)](#pure-function-computelayout)\n- [Recipes](#recipes)\n- [Comparison vs. flickr/justified-layout](#comparison-vs-flickrjustified-layout)\n- [License](#license)\n\n## Install\n\n```bash\nnpm install @altner/astro-justified-gallery-layout\n```\n\nPeer dependencies (all optional):\n\n| Peer | Required for |\n|---|---|\n| `astro >= 4` | The `.astro` components and `image()` schema helper. Not needed if you only use the pure `computeLayout` function. |\n| `exifr >= 7` | EXIF/IPTC/XMP/GPS reading via the loader's `exif: true` option or the standalone `readPhotoMeta()` helper. |\n| `sharp` | LQIP preview generation in the loader (`preview: true`). Already a transitive dependency of `astro:assets`, so usually already present. |\n\nInstall only what you need; missing optional peers degrade silently.\n\n## Quick start\n\nMinimal Astro project with a content collection of photos and a static gallery:\n\n```ts\n// src/content.config.ts\nimport { defineCollection } from \"astro:content\";\nimport { z } from \"astro/zod\";\nimport { galleryLoader } from \"@altner/astro-justified-gallery-layout/loader\";\n\nexport const collections = {\n  photos: defineCollection({\n    loader: galleryLoader({\n      base: \"./src/assets/photos\",\n      preview: true, // tiny LQIPs for instant placeholders\n    }),\n    schema: ({ image }) =>\n      z.object({\n        src: image(),\n        alt: z.string().optional(),\n        preview: z.string().optional(),\n      }),\n  }),\n};\n```\n\n```astro\n---\n// src/pages/index.astro\nimport { getCollection } from \"astro:content\";\nimport JustifiedGallery from \"@altner/astro-justified-gallery-layout/JustifiedGallery.astro\";\nimport Lightbox from \"@altner/astro-justified-gallery-layout/Lightbox.astro\";\n\nconst photos = await getCollection(\"photos\");\nconst images = photos.map((p) => ({\n  src: p.data.src,\n  alt: p.data.alt,\n  preview: p.data.preview,\n}));\n---\n\n<JustifiedGallery images={images} chunkSize={30} lightbox />\n<Lightbox />\n```\n\nThat's it. Drop your photos into `src/assets/photos/`, run `astro dev`, and you have a justified gallery with lazy-loaded thumbnails, LQIP placeholders, and a modal lightbox.\n\n## Components\n\n### `<JustifiedGallery />`\n\nDrop-in static component that uses Astro's `<Image />` for automatic optimization, lays items out client-side, and re-flows on resize via `ResizeObserver`.\n\n```astro\n---\nimport JustifiedGallery from \"@altner/astro-justified-gallery-layout/JustifiedGallery.astro\";\nimport type { ImageMetadata } from \"astro\";\n\nconst modules = import.meta.glob<{ default: ImageMetadata }>(\n  \"../assets/photos/*.{jpg,jpeg,png,webp,avif}\",\n  { eager: true },\n);\nconst images = Object.values(modules).map((m) => ({ src: m.default }));\n---\n\n<JustifiedGallery images={images} targetRowHeight={240} gap={6} />\n```\n\n**Props**\n\n| Prop              | Type                                                                              | Default                 | Description                                                                                                                      |\n| ----------------- | --------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------- |\n| `images`          | `{ src: ImageMetadata, alt?: string, caption?: string, href?: string, preview?: string }[]` | —                       | Imported image modules. `caption` is HTML rendered as a hover overlay (trusted source). `preview` is a tiny LQIP data URI. |\n| `targetRowHeight` | `number`                                                                          | `240`                   | Target row height in CSS pixels.                                                                                                 |\n| `gap`             | `number`                                                                          | `6`                     | Gap between items on both axes.                                                                                                  |\n| `maxRowHeight`    | `number`                                                                          | `targetRowHeight * 1.5` | Cap for the trailing partial row.                                                                                                |\n| `chunkSize`       | `number`                                                                          | —                       | If set, only this many items are laid out initially; the rest are revealed in chunks as the user scrolls (IntersectionObserver). |\n| `chunkRootMargin` | `string`                                                                          | `\"800px\"`               | `rootMargin` of the chunk sentinel — how far ahead of the viewport bottom to trigger the next chunk.                             |\n| `lightbox`        | `boolean`                                                                         | `false`                 | When true, items become triggers for the `<Lightbox />` component.                                                               |\n| `class`           | `string`                                                                          | —                       | Additional class on the wrapper.                                                                                                 |\n\nAll `<img>` tags use `loading=\"lazy\"` regardless of `chunkSize`. `chunkSize` additionally avoids paying the layout/paint cost for items beyond the visible window. When `preview` is set on an item, its data URI is painted as the container's `background-image` — instant placeholder while the full image loads.\n\nTo trigger a re-layout manually (e.g. after changing config at runtime), dispatch an `ajg:relayout` event on the `.ajg-root` element.\n\n### `<Lightbox />`\n\nDrop-in modal lightbox. Place `<Lightbox />` once on the page (e.g. in your layout) and opt in per gallery via the `lightbox` prop.\n\n```astro\n---\nimport JustifiedGallery from \"@altner/astro-justified-gallery-layout/JustifiedGallery.astro\";\nimport Lightbox from \"@altner/astro-justified-gallery-layout/Lightbox.astro\";\n---\n\n<JustifiedGallery images={images} lightbox />\n<Lightbox />\n```\n\nBehavior:\n\n- Click an item → opens the image in a `<dialog>` modal\n- Caption (whatever you passed via `caption`) is shown beneath the image\n- ←/→ arrow keys, on-screen buttons, or click the nav arrows to navigate within the same gallery\n- ESC, the × button, or backdrop click to close\n- Static galleries can navigate every item, even ones still hidden by `chunkSize`. Virtual galleries can only navigate items currently in the DOM (a few rows above/below the viewport — adjust `bufferRows` to widen the window if needed)\n\nThe lightbox uses the original `<img>`'s `srcset` and lets the browser pick an appropriate variant for the larger display, so no extra image processing is needed.\n\n### `<JustifiedGalleryVirtual />` (massive galleries)\n\nFor galleries beyond a few thousand photos, use `<JustifiedGalleryVirtual />` instead of the static one. The server renders an empty container; the client fetches metadata in chunks from a JSON endpoint, computes the layout incrementally, and keeps only items near the viewport in the DOM (true windowing with node recycling).\n\n```ts\n// src/pages/api/gallery.json.ts\nimport type { APIRoute } from \"astro\";\nimport { getCollection } from \"astro:content\";\nimport { getImage } from \"astro:assets\";\n\nexport const GET: APIRoute = async ({ url }) => {\n  const offset = Math.max(0, Number(url.searchParams.get(\"offset\")) || 0);\n  const limit = Math.max(1, Math.min(200, Number(url.searchParams.get(\"limit\")) || 50));\n\n  const all = await getCollection(\"photos\");\n  const slice = all.slice(offset, offset + limit);\n\n  const items = await Promise.all(slice.map(async (entry) => {\n    const optimized = await getImage({\n      src: entry.data.src,\n      widths: [400, 800, 1600],\n      formats: [\"webp\"],\n    });\n    return {\n      src: optimized.src,\n      srcset: optimized.srcSet?.attribute,\n      sizes: \"(max-width: 800px) 50vw, 25vw\",\n      w: entry.data.src.width,\n      h: entry.data.src.height,\n      alt: entry.data.alt ?? \"\",\n    };\n  }));\n\n  return Response.json({ items, total: all.length });\n};\n```\n\n```astro\n---\nimport JustifiedGalleryVirtual from \"@altner/astro-justified-gallery-layout/JustifiedGalleryVirtual.astro\";\n---\n\n<JustifiedGalleryVirtual\n  endpoint=\"/api/gallery.json\"\n  targetRowHeight={240}\n  gap={6}\n  chunkSize={50}\n  bufferRows={5}\n/>\n```\n\nRequires SSR (`output: \"server\"`) so the endpoint runs on demand. The endpoint must return `{ items, total }` matching `VirtualGalleryItem[]`. **Tradeoff:** SEO crawlers see no images in the initial HTML — only use this when the alternative (inlining tens of thousands of `<img>` tags) is worse.\n\n## Content Loader\n\nFor Astro Content Collections, the package ships a loader that scans a directory of images and produces entries shaped to feed `<JustifiedGallery />` directly.\n\n```ts\n// src/content.config.ts\nimport { defineCollection } from \"astro:content\";\nimport { z } from \"astro/zod\";\nimport { galleryLoader } from \"@altner/astro-justified-gallery-layout/loader\";\n\nexport const collections = {\n  photos: defineCollection({\n    loader: galleryLoader({ base: \"./src/assets/photos\" }),\n    schema: ({ image }) =>\n      z.object({\n        src: image(),\n        alt: z.string().optional(),\n      }),\n  }),\n};\n```\n\n```astro\n---\nimport { getCollection } from \"astro:content\";\nimport JustifiedGallery from \"@altner/astro-justified-gallery-layout/JustifiedGallery.astro\";\n\nconst photos = await getCollection(\"photos\");\nconst images = photos.map((p) => ({ src: p.data.src, alt: p.data.alt }));\n---\n\n<JustifiedGallery images={images} />\n```\n\n**Options**\n\n| Option       | Default                                    | Description                                                                                                                                                            |\n| ------------ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `base`       | (required)                                 | Directory to scan, relative to project root (or absolute).                                                                                                             |\n| `extensions` | `[\"jpg\",\"jpeg\",\"png\",\"webp\",\"avif\",\"gif\"]` | File extensions (without leading dot).                                                                                                                                 |\n| `recursive`  | `true`                                     | Recurse into subdirectories.                                                                                                                                           |\n| `getAlt`     | basename without extension                 | Custom alt-text deriver (`(absPath) => string`).                                                                                                                       |\n| `filter`     | —                                          | Predicate to skip files (`(absPath) => boolean`).                                                                                                                      |\n| `exif`       | `false`                                    | Read EXIF / IPTC / XMP / GPS metadata for each image and emit it as `data.meta`. Requires the optional [`exifr`](https://www.npmjs.com/package/exifr) peer dependency. |\n\nThe loader emits paths only — image dimensions are resolved by Astro's built-in `image()` schema helper, so `p.data.src` is fully-typed `ImageMetadata`.\n\n### Reading EXIF / IPTC / GPS\n\nWhen `exif: true`, the loader also calls [`readPhotoMeta()`](#exif-helper) on every image and stores the normalized result on `data.meta`. All fields are optional — the loader writes only what's actually present in the file, so the schema must mark every nested field optional too.\n\n```ts\nimport { defineCollection } from \"astro:content\";\nimport { z } from \"astro/zod\";\nimport { galleryLoader } from \"@altner/astro-justified-gallery-layout/loader\";\n\nexport const collections = {\n  photos: defineCollection({\n    loader: galleryLoader({ base: \"./src/assets/photos\", exif: true }),\n    schema: ({ image }) =>\n      z.object({\n        src: image(),\n        alt: z.string().optional(),\n        meta: z\n          .object({\n            camera: z\n              .object({\n                make: z.string().optional(),\n                model: z.string().optional(),\n                lens: z.string().optional(),\n                focalLength: z.number().optional(),\n                fNumber: z.number().optional(),\n                exposureTime: z.number().optional(),\n                iso: z.number().optional(),\n              })\n              .optional(),\n            dateTaken: z.coerce.date().optional(),\n            gps: z\n              .object({ latitude: z.number(), longitude: z.number() })\n              .optional(),\n            iptc: z\n              .object({\n                title: z.string().optional(),\n                description: z.string().optional(),\n                keywords: z.array(z.string()).optional(),\n              })\n              .optional(),\n          })\n          .optional(),\n      }),\n  }),\n};\n```\n\n### EXIF helper\n\nStandalone helper for reading metadata from a single file. Use directly when you don't want to go through the loader (e.g. inside an SSR API endpoint that processes images on demand).\n\n```ts\nimport {\n  readPhotoMeta,\n  formatCameraLine,\n} from \"@altner/astro-justified-gallery-layout/exif\";\n\nconst meta = await readPhotoMeta(\"/abs/path/to/photo.jpg\");\n// { camera?, dateTaken?, gps?, iptc? } | null\nconst tech = formatCameraLine(meta);\n// → \"Sony ILCE-7M3 · 35mm · f/2.8 · 1/250s · ISO 400\"\n```\n\n`readPhotoMeta` returns `null` when the optional `exifr` peer dependency is not installed or the file has no parseable metadata.\n\n## Pure function (`computeLayout`)\n\nFor frameworks other than Astro, or for a fully custom DOM, use the layout function directly:\n\n```js\nimport { computeLayout } from \"@altner/astro-justified-gallery-layout\";\n\nconst items = [\n  { w: 1920, h: 1280 },  // landscape\n  { w: 1080, h: 1920 },  // portrait\n  { w: 1024, h: 1024 },  // square\n  // ...\n];\n\nconst { boxes, totalHeight } = computeLayout(items, {\n  containerWidth: container.clientWidth,\n  targetRowHeight: 240,\n  gap: 6,\n});\n\ncontainer.style.height = `${totalHeight}px`;\n\nboxes.forEach((box, i) => {\n  const el = items[i].element;\n  el.style.transform = `translate(${box.left}px, ${box.top}px)`;\n  el.style.width = `${box.width}px`;\n  el.style.height = `${box.height}px`;\n});\n```\n\n## API\n\n### `computeLayout(items, options)`\n\nPure function — same input always produces the same output, no DOM access.\n\n**`items`** — array of `{ w, h }`. Only the ratio matters; pass original pixel dimensions or anything else.\n\n**`options`**\n\n| Option            | Default                  | Description                                                  |\n| ----------------- | ------------------------ | ------------------------------------------------------------ |\n| `containerWidth`  | (required)               | Available width in CSS pixels.                               |\n| `targetRowHeight` | `240`                    | Aim for rows of roughly this height.                         |\n| `gap`             | `6`                      | Gap between items on both axes.                              |\n| `maxRowHeight`    | `targetRowHeight * 1.5`  | Cap for the trailing partial row.                            |\n\n**Returns** `{ boxes, totalHeight }`\n\n- `boxes` — array (in input order) of `{ index, left, top, width, height }` in CSS pixels.\n- `totalHeight` — total grid height, no trailing gap. Set this on your container.\n\n## Design notes\n\n- **Smart row breaking.** When adding an item would push the row at-or-below the target height, we compare both options (close-with vs. close-before) and pick whichever lands closer to the target. Avoids both stretched single items and squished thin rows.\n- **Last row.** Left-aligned at target height, not justified — stretching three landscapes to fill 1500px looks ridiculous. Capped by `maxRowHeight`.\n- **Pure & deterministic.** No DOM access, no side effects. Trivial to test, trivial to memoize, runs in workers.\n- **No virtualization.** Returns positions for every item. For 10k+ photos pair with `content-visibility: auto` (browser-native virtualization).\n\n## Recipes\n\n### Render a hover caption from EXIF / IPTC / GPS\n\nThe components don't impose a caption format — `caption` is just an HTML string. Here's a complete helper that turns a `PhotoMeta` into a styled caption with title, camera tech line, date, GPS link, and hierarchical keywords. Drop it into your project and customize the locale / map provider / fields.\n\n```ts\n// src/lib/buildCaption.ts\nimport {\n  formatCameraLine,\n  type PhotoMeta,\n} from \"@altner/astro-justified-gallery-layout/exif\";\n\nconst escMap: Record<string, string> = {\n  \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\",\n};\nconst esc = (s: string) => s.replace(/[&<>\"']/g, (c) => escMap[c]);\n\nconst dateFmt = new Intl.DateTimeFormat(\"de-DE\", {\n  year: \"numeric\", month: \"short\", day: \"numeric\",\n});\n\nexport function buildCaption(\n  meta: PhotoMeta | undefined | null,\n  fallbackTitle?: string,\n): string | undefined {\n  if (!meta && !fallbackTitle) return undefined;\n  const parts: string[] = [];\n\n  const title = meta?.iptc?.title ?? fallbackTitle;\n  if (title) parts.push(`<div class=\"ajg-title\">${esc(title)}</div>`);\n  if (meta?.iptc?.description)\n    parts.push(`<div class=\"ajg-desc\">${esc(meta.iptc.description)}</div>`);\n\n  const tech = formatCameraLine(meta);\n  if (tech) parts.push(`<div class=\"ajg-tech\">${esc(tech)}</div>`);\n\n  const metaLine: string[] = [];\n  if (meta?.dateTaken) metaLine.push(esc(dateFmt.format(meta.dateTaken)));\n  if (meta?.gps) {\n    const { latitude: lat, longitude: lon } = meta.gps;\n    const url = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=14/${lat}/${lon}`;\n    metaLine.push(\n      `<a href=\"${esc(url)}\" target=\"_blank\" rel=\"noopener\" style=\"color:inherit;text-decoration:underline\">${lat.toFixed(4)}, ${lon.toFixed(4)}</a>`,\n    );\n  }\n  if (metaLine.length)\n    parts.push(`<div class=\"ajg-meta\">${metaLine.join(\" · \")}</div>`);\n\n  const chips: string[] = [];\n  for (const path of meta?.iptc?.keywordsHierarchy?.slice(0, 6) ?? []) {\n    if (path.length)\n      chips.push(`<span class=\"ajg-keyword\">${path.map(esc).join(\" › \")}</span>`);\n  }\n  for (const k of meta?.iptc?.keywords?.slice(0, 6) ?? []) {\n    chips.push(`<span class=\"ajg-keyword\">${esc(k)}</span>`);\n  }\n  if (chips.length)\n    parts.push(`<div class=\"ajg-keywords\">${chips.join(\"\")}</div>`);\n\n  return parts.length ? parts.join(\"\") : undefined;\n}\n```\n\nThe class names `.ajg-title`, `.ajg-tech`, `.ajg-meta`, `.ajg-keywords`, `.ajg-keyword` are pre-styled by the `<JustifiedGallery />` and `<Lightbox />` components.\n\n### Massive virtualized gallery with year/month filtering\n\nServer-side filter via URL search params, paginated API endpoint:\n\n```astro\n---\n// src/pages/gallery.astro\nimport { getCollection } from \"astro:content\";\nimport JustifiedGalleryVirtual from \"@altner/astro-justified-gallery-layout/JustifiedGalleryVirtual.astro\";\nimport Lightbox from \"@altner/astro-justified-gallery-layout/Lightbox.astro\";\n\nconst year = Astro.url.searchParams.get(\"year\") ?? \"\";\nconst month = Astro.url.searchParams.get(\"month\") ?? \"\";\n\n// Compute the endpoint URL with filter params; the component appends offset/limit.\nconst u = new URLSearchParams();\nif (year) u.set(\"year\", year);\nif (month) u.set(\"month\", month);\nconst endpoint = `/api/gallery.json${u.toString() ? `?${u}` : \"\"}`;\n---\n\n<JustifiedGalleryVirtual endpoint={endpoint} chunkSize={50} bufferRows={5} lightbox />\n<Lightbox />\n```\n\n```ts\n// src/pages/api/gallery.json.ts\nimport type { APIRoute } from \"astro\";\nimport { getCollection } from \"astro:content\";\nimport { getImage } from \"astro:assets\";\n\nexport const GET: APIRoute = async ({ url }) => {\n  const offset = Math.max(0, +(url.searchParams.get(\"offset\") ?? 0));\n  const limit = Math.min(200, +(url.searchParams.get(\"limit\") ?? 50));\n  const year = url.searchParams.get(\"year\");\n  const month = url.searchParams.get(\"month\");\n\n  const all = await getCollection(\"photos\");\n  const filtered = all.filter((p) => {\n    const d = p.data.meta?.dateTaken;\n    if (!year && !month) return true;\n    if (!d) return false;\n    const y = String(d.getFullYear());\n    const m = String(d.getMonth() + 1).padStart(2, \"0\");\n    return (!year || y === year) && (!month || m === month);\n  });\n\n  const items = await Promise.all(\n    filtered.slice(offset, offset + limit).map(async (entry) => {\n      const opt = await getImage({\n        src: entry.data.src,\n        widths: [400, 800, 1600],\n        formats: [\"webp\"],\n      });\n      return {\n        src: opt.src,\n        srcset: opt.srcSet?.attribute,\n        sizes: \"(max-width: 800px) 50vw, 25vw\",\n        w: entry.data.src.width,\n        h: entry.data.src.height,\n        alt: entry.data.alt ?? \"\",\n        preview: entry.data.preview, // LQIP from the loader\n      };\n    }),\n  );\n\n  return Response.json({ items, total: filtered.length });\n};\n```\n\nThis setup runs in Astro SSR mode (`output: \"server\"`) and scales to tens of thousands of photos because the initial HTML is constant-size and the client only keeps a sliding window of items in the DOM.\n\n### Static gallery without a content collection\n\nYou don't need the loader at all — just `import.meta.glob`:\n\n```astro\n---\nimport JustifiedGallery from \"@altner/astro-justified-gallery-layout/JustifiedGallery.astro\";\nimport type { ImageMetadata } from \"astro\";\n\nconst modules = import.meta.glob<{ default: ImageMetadata }>(\n  \"../assets/photos/*.{jpg,jpeg,png,webp,avif}\",\n  { eager: true },\n);\nconst images = Object.values(modules).map((m) => ({ src: m.default }));\n---\n\n<JustifiedGallery images={images} chunkSize={30} />\n```\n\nNo EXIF, no LQIP, no captions — but minimum setup.\n\n## Comparison vs. flickr/justified-layout\n\n| | This package (core) | flickr/justified-layout |\n|---|---|---|\n| Size | ~120 LOC, ~1kB min+gz | ~30kB minified |\n| Module format | ESM only | UMD (Node-style) |\n| Dependencies | 0 | several |\n| TypeScript types | Bundled | DefinitelyTyped, often stale |\n| Astro component | Bundled | — |\n| Last release | Active | 2018 |\n\n## License\n\nMIT\n","readmeFilename":"README.md","_rev":"1-f322ab4cb9180d7fbcfc46cb5d7e860d"}