diff --git a/.jules/sentinel.md b/.jules/sentinel.md
new file mode 100644
index 0000000..a1c3b66
--- /dev/null
+++ b/.jules/sentinel.md
@@ -0,0 +1,4 @@
+## 2024-08-11 - Unconstrained Base64URL Decoding DoS
+**Vulnerability:** The decodeAssetId function in lib/tokens.ts accepted arbitrary length tokens and passed them directly to Buffer.from(..., 'base64url'), creating a risk of massive memory allocation (memory exhaustion/CPU DoS).
+**Learning:** Functions that decode tokens from URLs (like GET /api/image/:token) must limit the input string length before passing it to Buffer.from. Otherwise, a maliciously crafted huge token can cause the Node.js process to throw RangeError (Invalid string length) or exhaust memory, despite being wrapped in a try/catch.
+**Prevention:** Always enforce a strict maximum length check on opaque URL tokens before decoding them.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3e5874c..8d80f22 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases).
+## [Unreleased]
+
+### Security
+
+- **Next.js 16.3.0** — picks up the fix for CVE-2025-13465 in Next's vendored
+ lodash ([#402](https://github.com/ralksta/immich-folio/pull/402)).
+
+### Internal
+
+Nothing user-facing; recorded so the next release notes are complete.
+
+- **Dependency PRs now target `dev`**, and CI runs on pull requests to `dev` as
+ well ([#411](https://github.com/ralksta/immich-folio/pull/411)). The workflow
+ previously triggered on `main` only, so a PR against `dev` carried no checks
+ at all. `CONTRIBUTING.md` and the PR template now state the target branch
+ ([#412](https://github.com/ralksta/immich-folio/pull/412)) — it was nowhere
+ documented before.
+- **`scripts/screenshots.ts` type-checks against sharp 0.34 and 0.35**
+ ([#410](https://github.com/ralksta/immich-folio/pull/410)). sharp arrives
+ transitively through Next, and 0.35 moved from `export =` to ESM: the module
+ namespace stopped being callable and the factory moved to `.default`. The
+ Next bump above would otherwise have broken `npx tsc --noEmit`.
+- Dev-dependency bumps: prettier 3.9.6, eslint 9.39.5, `@types/leaflet` 1.9.22,
+ and `github/codeql-action` v4.
+
## [0.10.0] — 2026-08-10
### Added
@@ -60,6 +85,12 @@ Releases up to and including v0.9.2 are documented in the
were visible on top of each other.
- **Docker** — health check targets `127.0.0.1` instead of `localhost`, base
image moved to `node:22-alpine`.
+- **The Trivy scan never ran.** The publish workflow referenced
+ `aquasecurity/trivy-action@0.29.0`, but that repository's tags carry a
+ leading `v`, so the job failed during action setup — before the image was
+ built. No image was published for the first v0.10.0 attempt, and no scan had
+ run since the step was introduced. The action is now pinned to a commit SHA
+ ([#409](https://github.com/ralksta/immich-folio/pull/409)).
- **Docs** — the "nature / travel journal" example in `docs/theming.md` used a
preset named `botanica` that does not exist; copying it produced an
`Unknown theme preset` error. It now uses `editorial`.
diff --git a/app/admin/admin.css b/app/admin/admin.css
index f28773e..503f9a0 100644
--- a/app/admin/admin.css
+++ b/app/admin/admin.css
@@ -1510,6 +1510,10 @@
.asset-picker-tab {
flex: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.45rem;
padding: 0.7rem 1rem;
background: none;
border: none;
@@ -1607,8 +1611,11 @@
position: absolute;
top: 4px;
left: 4px;
- font-size: 0.7rem;
- text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
+ display: flex;
+ font-size: 0.85rem;
+ color: var(--admin-accent);
+ /* drop-shadow, not text-shadow: the badge is an inline SVG, not a glyph. */
+ filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
.asset-picker-uuid-section {
@@ -1782,8 +1789,10 @@
color 0.15s ease;
}
+/* Drag handles use IconGripVertical's square 24x24 viewBox, so keep the box
+ square too — a 12x18 box would letterbox the glyph down to 12x12. */
.svg-drag {
- width: 12px;
+ width: 18px;
height: 18px;
}
@@ -1924,6 +1933,17 @@ h3 .svg-icon {
background: rgba(74, 222, 128, 0.05);
}
+/* Carries a mode name ("filename", "oldest"), so it cannot use the fixed
+ icon-sized box the other badges share. */
+.album-tile-badges .badge-sort {
+ width: auto;
+ min-width: 18px;
+ padding: 0 5px;
+ font-size: 0.62rem;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+}
+
/* ── Breitwand-Kiste Centered Modal styling ── */
.album-drawer-overlay {
position: fixed;
@@ -3821,3 +3841,140 @@ h3 .svg-icon {
background: var(--admin-btn-primary-bg, #ffffff);
color: var(--admin-btn-primary-text, #000000);
}
+
+/* ── Per-album photo order (sort: manual) ────────────────────── */
+
+.admin-field-hint {
+ display: block;
+ margin-top: 0.35rem;
+ font-size: 0.75rem;
+ line-height: 1.4;
+ color: var(--admin-text-muted);
+}
+
+.order-editor-body {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 0.75rem;
+}
+
+.order-editor-notice {
+ margin: 0 0 0.75rem;
+ padding: 0.5rem 0.7rem;
+ border-radius: 6px;
+ font-size: 0.78rem;
+ line-height: 1.4;
+ color: var(--admin-accent);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+ background: rgba(245, 158, 11, 0.05);
+}
+
+.order-editor-section + .order-editor-section {
+ margin-top: 1.25rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--admin-border);
+}
+
+.order-editor-section-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 0.5rem;
+}
+
+.order-editor-section-head h4 {
+ margin: 0;
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--admin-text);
+}
+
+.order-editor-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+.order-tile {
+ position: relative;
+ aspect-ratio: 1;
+ border-radius: 8px;
+ overflow: hidden;
+ background: var(--admin-bg-elevated);
+ border: 2px solid transparent;
+ cursor: pointer;
+ transition:
+ border-color 0.15s,
+ opacity 0.15s;
+}
+
+.order-tile img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ /* The whole tile is a drag handle; without this the browser's native image
+ drag wins and dnd-kit never sees the gesture. */
+ -webkit-user-drag: none;
+ user-select: none;
+}
+
+.order-tile-pinned {
+ border-color: var(--admin-accent);
+ cursor: grab;
+}
+
+.order-tile-pinned:active {
+ cursor: grabbing;
+}
+
+.order-tile-unpinned {
+ opacity: 0.55;
+}
+
+.order-tile-unpinned:hover {
+ opacity: 1;
+ border-color: var(--admin-border);
+}
+
+.order-tile-index {
+ position: absolute;
+ bottom: 4px;
+ left: 4px;
+ min-width: 18px;
+ padding: 0 4px;
+ border-radius: 4px;
+ font-size: 0.68rem;
+ font-weight: 700;
+ text-align: center;
+ color: #000;
+ background: var(--admin-accent);
+}
+
+.order-tile-action {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ border-radius: 50%;
+ font-size: 0.85rem;
+ line-height: 1;
+ cursor: pointer;
+ color: var(--admin-text);
+ background: var(--admin-overlay, rgba(0, 0, 0, 0.6));
+}
+
+.order-editor-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ padding: 0.75rem;
+ border-top: 1px solid var(--admin-border);
+}
diff --git a/app/admin/components/AssetOrderEditor.tsx b/app/admin/components/AssetOrderEditor.tsx
new file mode 100644
index 0000000..d998045
--- /dev/null
+++ b/app/admin/components/AssetOrderEditor.tsx
@@ -0,0 +1,277 @@
+'use client';
+
+import { useState, useEffect, useMemo } from 'react';
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ rectSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+
+interface AssetInfo {
+ id: string;
+ type: string;
+ originalFileName: string;
+ fileCreatedAt: string;
+}
+
+interface Props {
+ albumId: string;
+ albumName: string;
+ /** Currently pinned asset IDs, in display order. */
+ assetOrder: string[];
+ onSave: (assetOrder: string[]) => void;
+ onClose: () => void;
+}
+
+function SortableAssetTile({
+ asset,
+ index,
+ onUnpin,
+}: {
+ asset: AssetInfo;
+ index: number;
+ onUnpin: () => void;
+}) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: asset.id,
+ });
+
+ return (
+
+
+
{index + 1}
+
e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation();
+ onUnpin();
+ }}
+ >
+ ×
+
+
+ );
+}
+
+/**
+ * Editor for `sort: manual`.
+ *
+ * `manual` is a pinned *prefix*: the saved list holds only the photos placed by
+ * hand, and everything else follows in the album's Immich order. The two zones
+ * exist to make that visible rather than something the owner has to be told.
+ */
+export default function AssetOrderEditor({
+ albumId,
+ albumName,
+ assetOrder,
+ onSave,
+ onClose,
+}: Props) {
+ const [assets, setAssets] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [pinnedIds, setPinnedIds] = useState([]);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ // types=all: the public grid renders videos too, so a video has to be
+ // pinnable — otherwise it could only ever land in the unpinned tail.
+ const res = await fetch(`/api/admin/albums/${albumId}/assets?types=all`);
+ if (!res.ok) throw new Error(`Request failed with ${res.status}`);
+ const data = await res.json();
+ if (cancelled) return;
+ setAssets(data.assets);
+
+ // Reconcile against the *live* album rather than rendering the stored
+ // list: IDs removed from the album in Immich drop out, and new photos
+ // appear in the unpinned zone instead of being invisible.
+ const live = new Set(data.assets.map((a: AssetInfo) => a.id));
+ setPinnedIds(assetOrder.filter((id) => live.has(id)));
+ } catch (err) {
+ if (cancelled) return;
+ console.error('Failed to load album assets:', err);
+ setError('Could not load the album’s photos from Immich.');
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [albumId, assetOrder]);
+
+ const byId = useMemo(() => new Map(assets.map((a) => [a.id, a])), [assets]);
+ const pinned = useMemo(
+ () => pinnedIds.map((id) => byId.get(id)).filter((a): a is AssetInfo => Boolean(a)),
+ [pinnedIds, byId],
+ );
+ const unpinned = useMemo(() => {
+ const set = new Set(pinnedIds);
+ return assets.filter((a) => !set.has(a.id));
+ }, [assets, pinnedIds]);
+
+ // Drift is reported, never acted on by itself. A pruned list is written only
+ // when the user saves — auto-saving it would let one failed Immich request
+ // quietly destroy a hand-curated order.
+ const dropped = useMemo(() => {
+ if (loading || error) return 0;
+ const live = new Set(assets.map((a) => a.id));
+ return assetOrder.filter((id) => !live.has(id)).length;
+ }, [assetOrder, assets, loading, error]);
+
+ function handleDragEnd(event: DragEndEvent) {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ const from = pinnedIds.indexOf(String(active.id));
+ const to = pinnedIds.indexOf(String(over.id));
+ if (from < 0 || to < 0) return;
+ setPinnedIds(arrayMove(pinnedIds, from, to));
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
+
Photo order — {albumName}
+
+ ×
+
+
+
+ {loading ? (
+
+ ) : error ? (
+
{error}
+ ) : (
+
+ {dropped > 0 && (
+
+ {dropped} pinned {dropped === 1 ? 'photo is' : 'photos are'} no longer in this
+ album. {dropped === 1 ? 'It' : 'They'} will be removed from the order when you save.
+
+ )}
+
+
+
+
Pinned ({pinned.length})
+ {pinned.length > 0 && (
+ setPinnedIds([])}>
+ Unpin all
+
+ )}
+
+ {pinned.length === 0 ? (
+
+ Nothing pinned yet — the album shows in Immich order. Pin the photos that should
+ open the album.
+
+ ) : (
+
+
+
+ {pinned.map((asset, i) => (
+ setPinnedIds(pinnedIds.filter((id) => id !== asset.id))}
+ />
+ ))}
+
+
+
+ )}
+
+
+
+
+
Follows automatically ({unpinned.length})
+ {unpinned.length > 0 && (
+ setPinnedIds([...pinnedIds, ...unpinned.map((a) => a.id)])}
+ >
+ Pin all
+
+ )}
+
+
+ These follow the pinned photos in the album’s Immich order. Click one to pin it.
+
+
+ {unpinned.map((asset) => (
+
setPinnedIds([...pinnedIds, asset.id])}
+ >
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ Cancel
+
+ onSave(pinnedIds)}
+ >
+ Apply order
+
+
+
+
+ );
+}
diff --git a/app/admin/components/AssetPicker.tsx b/app/admin/components/AssetPicker.tsx
index bbae474..8f2226f 100644
--- a/app/admin/components/AssetPicker.tsx
+++ b/app/admin/components/AssetPicker.tsx
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
+import { IconFolder, IconImage, IconStar } from './Icons';
interface AssetInfo {
id: string;
@@ -126,20 +127,23 @@ export default function AssetPicker({
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
onClick={() => setTab('album')}
>
- 📂 This Album
+
+ This Album
)}
setTab('favorites')}
>
- ⭐ Favorites
+
+ Favorites
setTab('all')}
>
- 🖼️ All Photos
+
+ All Photos
@@ -173,7 +177,9 @@ export default function AssetPicker({
/>
{isUsed && ✓
}
{asset.isFavorite && !isUsed && (
- ⭐
+
+
+
)}
);
diff --git a/app/admin/components/Icons.tsx b/app/admin/components/Icons.tsx
index 6474097..6f1b6c1 100644
--- a/app/admin/components/Icons.tsx
+++ b/app/admin/components/Icons.tsx
@@ -256,6 +256,25 @@ export function IconHeart({ size = 16, className = 'svg-icon', strokeWidth = 2,
);
}
+/** Filled by default — it marks a state (favourite), not an action. */
+export function IconStar({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function IconImage({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
@@ -279,6 +298,40 @@ export function IconGripVertical({ size = 16, className = 'svg-icon', strokeWidt
);
}
+export function IconX({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function IconPlus({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function IconHome({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function IconCopy({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
export function IconQuote({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
diff --git a/app/admin/components/PageBuilder.tsx b/app/admin/components/PageBuilder.tsx
index 166d420..5603ae4 100644
--- a/app/admin/components/PageBuilder.tsx
+++ b/app/admin/components/PageBuilder.tsx
@@ -21,97 +21,43 @@ import {
import { CSS } from '@dnd-kit/utilities';
import AlbumPicker from './AlbumPicker';
import AssetPicker from './AssetPicker';
+import AssetOrderEditor from './AssetOrderEditor';
import { EssayBlockEditor } from './EssayBlockEditor';
+import {
+ ALBUM_SORT_MODES,
+ DEFAULT_ALBUM_SORT,
+ isAlbumSortMode,
+ type AlbumSortMode,
+} from '@/lib/albumSort';
+import type { AlbumEntryObject } from '@/lib/config/schema';
+import {
+ IconCamera,
+ IconCopy,
+ IconFolder,
+ IconGripVertical,
+ IconHome,
+ IconImage,
+ IconLock,
+ IconPencil,
+ IconPlus,
+ IconSearch,
+ IconTrash,
+ IconX,
+} from './Icons';
-// ── Icons ──────────────────────────────────────────────────────
-const Icons = {
- Drag: () => (
-
-
-
-
-
-
-
-
- ),
- Edit: () => (
-
-
-
-
- ),
- Trash: () => (
-
-
-
-
-
- ),
- Close: () => (
-
-
-
- ),
- Folder: () => (
-
-
-
- ),
- Camera: () => (
-
-
-
-
- ),
- ExternalLink: () => (
-
-
-
-
-
- ),
- Search: () => (
-
-
-
-
- ),
- Lock: () => (
-
-
-
-
- ),
- Plus: () => (
-
-
-
- ),
- Home: () => (
-
-
-
-
- ),
- Copy: () => (
-
-
-
-
- ),
- Check: () => (
-
-
-
- ),
- Image: () => (
-
-
-
-
-
- ),
+/**
+ * Labels for the per-album sort select. Order matches ALBUM_SORT_MODES.
+ *
+ * Plain text, no emoji: an cannot contain markup, so the SVG set from
+ * `./Icons` is not available here — and a decorative emoji is not a substitute
+ * for it. The badge on the album card uses a real icon instead.
+ */
+const SORT_LABELS: Record = {
+ immich: 'Immich order (default)',
+ newest: 'Newest first',
+ oldest: 'Oldest first',
+ filename: 'Filename',
+ manual: 'Manual',
};
// ── Types ──────────────────────────────────────────────────────
@@ -121,6 +67,9 @@ interface AlbumEntry {
description?: string;
password?: string;
heroImage?: string;
+ sort?: AlbumSortMode;
+ /** Pinned asset UUIDs for `sort: manual`; everything else follows automatically. */
+ assetOrder?: string[];
}
interface Section {
@@ -166,17 +115,27 @@ interface ImmichAlbumInfo {
// ── Helpers ────────────────────────────────────────────────────
-function parseAlbumEntries(
- raw:
- | Array<
- | string
- | Record<
- string,
- string | { title: string; description?: string; password?: string; heroImage?: string }
- >
- >
- | undefined,
-): AlbumEntry[] {
+type RawAlbumEntry = string | Record;
+
+/**
+ * Whether an entry carries anything beyond its ID.
+ *
+ * Both collapse rules below depend on this, and they are the reason every new
+ * per-album option has to be listed here: an entry that looks "empty" is
+ * serialized back to a bare UUID string, so a field missing from this check is
+ * silently dropped on the next save.
+ */
+function hasAlbumOptions(entry: AlbumEntry): boolean {
+ return Boolean(
+ entry.description ||
+ entry.password ||
+ entry.heroImage ||
+ (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) ||
+ entry.assetOrder?.length,
+ );
+}
+
+function parseAlbumEntries(raw: RawAlbumEntry[] | undefined): AlbumEntry[] {
if (!raw) return [];
return raw.map((entry) => {
if (typeof entry === 'string') return { id: entry };
@@ -188,27 +147,29 @@ function parseAlbumEntries(
description: value.description,
password: value.password,
heroImage: value.heroImage,
+ sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
+ assetOrder: value.assetOrder,
};
});
}
-function serializeAlbumEntries(
- entries: AlbumEntry[],
-): Array<
- | string
- | Record
-> {
+function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
return entries.map((entry) => {
- if (!entry.title && !entry.description && !entry.password && !entry.heroImage) return entry.id;
- if (entry.title && !entry.description && !entry.password && !entry.heroImage) {
- return { [entry.id]: entry.title };
- }
- const val: { title: string; description?: string; password?: string; heroImage?: string } = {
- title: entry.title || '',
- };
+ const extras = hasAlbumOptions(entry);
+ if (!entry.title && !extras) return entry.id;
+ if (entry.title && !extras) return { [entry.id]: entry.title };
+
+ const val: AlbumEntryObject = {};
+ // Only when set: a sort-only entry would otherwise be written with an empty
+ // title, which deriveGallery ignores but which still lands in the YAML.
+ if (entry.title) val.title = entry.title;
if (entry.description) val.description = entry.description;
if (entry.password) val.password = entry.password;
if (entry.heroImage) val.heroImage = entry.heroImage;
+ if (entry.sort && entry.sort !== DEFAULT_ALBUM_SORT) val.sort = entry.sort;
+ // Persisted regardless of the mode, so manual → newest → manual does not
+ // throw away a hand-curated order.
+ if (entry.assetOrder?.length) val.assetOrder = entry.assetOrder;
return { [entry.id]: val };
});
}
@@ -238,11 +199,11 @@ function SortableHeroTile({
return (
-
+
-
+
{index + 1}
@@ -334,7 +295,7 @@ function SortableSubpageTile({
{...attributes}
>
-
+
{sp.enabled === false && (
@@ -345,7 +306,7 @@ function SortableSubpageTile({
{sp.password && (
- Password
+ Password
)}
@@ -354,11 +315,11 @@ function SortableSubpageTile({
) : (
-
+
)}
- Edit Page
+ Edit Page
@@ -367,7 +328,7 @@ function SortableSubpageTile({
/{slug}
- {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
+ {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
@@ -396,6 +357,12 @@ export default function PageBuilder() {
currentAssetIds?: string[];
title?: string;
} | null>(null);
+ const [orderEditorTarget, setOrderEditorTarget] = useState<{
+ albumId: string;
+ albumName: string;
+ assetOrder: string[];
+ onSave: (assetOrder: string[]) => void;
+ } | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [editingAlbumAddress, setEditingAlbumAddress] = useState(null);
@@ -929,7 +896,7 @@ export default function PageBuilder() {
{/* Search Bar */}
-
+
-
Homepage Hero
+ Homepage Hero
@@ -959,7 +926,7 @@ export default function PageBuilder() {
})
}
>
- Add Hero
+ Add Hero
-
📷 Standalone Albums
+
+
+ Standalone Albums
+
setPickerTarget({ type: 'standalone' })}
@@ -1042,7 +1012,10 @@ export default function PageBuilder() {
{/* Subpages */}
-
📂 Subpages
+
+
+ Subpages
+
+ New Subpage
@@ -1107,7 +1080,7 @@ export default function PageBuilder() {
onClick={() => setDrawerMode('edit')}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
>
- Edit
+ Edit
setDrawerMode('preview')}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
>
- Live Preview
+ Live Preview
@@ -1162,7 +1135,7 @@ export default function PageBuilder() {
{thumb ? (
) : (
-
+
)}
@@ -1186,7 +1159,7 @@ export default function PageBuilder() {
{thumb ? (
) : (
-
+
)}
@@ -1242,7 +1215,7 @@ export default function PageBuilder() {
@@ -1395,7 +1373,7 @@ export default function PageBuilder() {
onClick={() => removeSection(spIndex, secIndex)}
title="Remove section"
>
-
+
@@ -1465,7 +1443,7 @@ export default function PageBuilder() {
setExpandedSubpage(null);
}}
>
-
Delete Subpage
+
Delete Subpage
e.stopPropagation()}>
- Edit Album Details
+ Edit Album Details
setEditingAlbumAddress(null)}
title="Close details"
>
-
+
@@ -1523,7 +1501,7 @@ export default function PageBuilder() {
) : (
-
+
)}
@@ -1562,7 +1540,7 @@ export default function PageBuilder() {
}}
title="Copy UUID"
>
-
+
@@ -1613,7 +1591,7 @@ export default function PageBuilder() {
onClick={() => onUpdate({ heroImage: undefined })}
title="Remove custom hero image"
>
-
+
) : (
@@ -1633,10 +1611,64 @@ export default function PageBuilder() {
})
}
>
- {album.heroImage ? 'Change Image' : 'Pick Hero Image'}
+ {album.heroImage ? 'Change Image' : 'Pick Hero Image'}
+
+ {/* Photo order */}
+
+
Photo order
+
+ onUpdate({
+ sort: isAlbumSortMode(e.target.value) ? e.target.value : undefined,
+ })
+ }
+ style={{
+ padding: '8px 12px',
+ borderRadius: '6px',
+ width: '100%',
+ fontSize: '0.9rem',
+ }}
+ >
+ {ALBUM_SORT_MODES.map((mode) => (
+
+ {SORT_LABELS[mode]}
+
+ ))}
+
+
+ {album.sort === 'manual'
+ ? 'Pinned photos come first, in the order you set. Everything else follows in the album’s Immich order.'
+ : 'Immich order follows the album’s own sort setting in Immich.'}
+
+
+ {album.sort === 'manual' && (
+
+
+ setOrderEditorTarget({
+ albumId: album.id,
+ albumName: album.title || name,
+ assetOrder: album.assetOrder || [],
+ onSave: (assetOrder) => {
+ onUpdate({ assetOrder });
+ setOrderEditorTarget(null);
+ },
+ })
+ }
+ >
+ {' '}
+ {album.assetOrder?.length
+ ? `Reorder photos (${album.assetOrder.length} pinned)`
+ : 'Reorder photos'}
+
+
+ )}
+
@@ -1645,7 +1677,7 @@ export default function PageBuilder() {
onClick={onRemove}
style={{ maxWidth: '160px' }}
>
-
Remove Album
+
Remove Album
)}
+
+ {/* Manual Photo Order Editor */}
+ {orderEditorTarget && (
+
setOrderEditorTarget(null)}
+ />
+ )}
);
@@ -1797,14 +1840,14 @@ function AlbumCard({
{dragListeners && (
-
+
)}
{heroThumb ? (
) : (
-
+
)}
@@ -1813,14 +1856,14 @@ function AlbumCard({
onClick={onEdit}
title="Edit details"
>
-
+
-
+
@@ -1832,12 +1875,17 @@ function AlbumCard({
{hasPassword && (
-
+
)}
{album.heroImage && (
-
+
+
+ )}
+ {album.sort && album.sort !== DEFAULT_ALBUM_SORT && (
+
+ {album.sort === 'manual' ? : album.sort}
)}
diff --git a/app/api/admin/albums/[albumId]/assets/__tests__/route.test.ts b/app/api/admin/albums/[albumId]/assets/__tests__/route.test.ts
new file mode 100644
index 0000000..edfc14e
--- /dev/null
+++ b/app/api/admin/albums/[albumId]/assets/__tests__/route.test.ts
@@ -0,0 +1,96 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { NextRequest } from 'next/server';
+
+vi.mock('@/lib/admin/auth', () => ({
+ isAdminEnabled: () => true,
+ isAdminAuthenticated: async () => true,
+}));
+
+vi.mock('@/lib/config', () => ({
+ getConfig: () => ({ needsSetup: false }),
+}));
+
+const getAlbumAssetsRaw = vi.fn();
+vi.mock('@/lib/immich', () => ({
+ immich: {
+ get getAlbumAssetsRaw() {
+ return getAlbumAssetsRaw;
+ },
+ },
+}));
+
+import { GET } from '../route';
+
+const ALBUM = '11111111-1111-1111-1111-111111111111';
+
+const asset = (id: string, type: 'IMAGE' | 'VIDEO') => ({
+ id,
+ type,
+ originalFileName: `${id}.jpg`,
+ fileCreatedAt: '2024-01-01T00:00:00.000Z',
+ isFavorite: false,
+});
+
+const call = (albumId: string, query = '') =>
+ GET(new NextRequest(`http://localhost/api/admin/albums/${albumId}/assets${query}`), {
+ params: Promise.resolve({ albumId }),
+ });
+
+describe('GET /api/admin/albums/[albumId]/assets', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getAlbumAssetsRaw.mockResolvedValue([
+ asset('img-1', 'IMAGE'),
+ asset('vid-1', 'VIDEO'),
+ asset('img-2', 'IMAGE'),
+ ]);
+ });
+
+ // Without this, `..%2f..%2fusers` resolves inside fetch() to an arbitrary
+ // Immich endpoint, called with the server's API key.
+ it('rejects an album ID that is not a UUID', async () => {
+ const res = await call('..%2f..%2fusers');
+
+ expect(res.status).toBe(400);
+ expect(getAlbumAssetsRaw).not.toHaveBeenCalled();
+ });
+
+ it('returns images only by default, so the hero picker is unaffected', async () => {
+ const res = await call(ALBUM);
+ const body = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(body.assets.map((a: { id: string }) => a.id)).toEqual(['img-1', 'img-2']);
+ });
+
+ // The public grid renders videos, so the sort editor has to be able to pin
+ // one — otherwise a video could only ever sit in the unpinned tail.
+ it('includes videos when types=all', async () => {
+ const res = await call(ALBUM, '?types=all');
+ const body = await res.json();
+
+ expect(body.assets.map((a: { id: string }) => a.id)).toEqual(['img-1', 'vid-1', 'img-2']);
+ });
+
+ /**
+ * Immich 3.x stopped embedding assets in `GET /albums/:id`, which is what
+ * this route used to read — so it returned nothing, and threw outright when
+ * the key was absent. It has to go through the client's paginated metadata
+ * search instead.
+ */
+ it('reads assets through the Immich client rather than the album endpoint', async () => {
+ await call(ALBUM);
+
+ expect(getAlbumAssetsRaw).toHaveBeenCalledWith(ALBUM);
+ });
+
+ it('reports an upstream failure as a 500 rather than an empty album', async () => {
+ getAlbumAssetsRaw.mockRejectedValue(new Error('immich down'));
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const res = await call(ALBUM);
+
+ expect(res.status).toBe(500);
+ err.mockRestore();
+ });
+});
diff --git a/app/api/admin/albums/[albumId]/assets/route.ts b/app/api/admin/albums/[albumId]/assets/route.ts
index 1d2310f..a463187 100644
--- a/app/api/admin/albums/[albumId]/assets/route.ts
+++ b/app/api/admin/albums/[albumId]/assets/route.ts
@@ -1,25 +1,16 @@
/**
- * Admin API: Get assets of a specific album for the hero image picker.
+ * Admin API: assets of a specific album, for the hero image picker and the
+ * manual sort editor.
*/
import { NextRequest, NextResponse } from 'next/server';
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
import { getConfig } from '@/lib/config';
+import { immich } from '@/lib/immich';
import { isUuid } from '@/lib/uuid';
-interface ImmichAlbumDetail {
- assets: Array<{
- id: string;
- type: string;
- originalFileName: string;
- thumbhash: string | null;
- fileCreatedAt: string;
- isFavorite: boolean;
- }>;
-}
-
export async function GET(
- _request: NextRequest,
+ request: NextRequest,
{ params }: { params: Promise<{ albumId: string }> },
) {
if (!isAdminEnabled()) {
@@ -42,27 +33,27 @@ export async function GET(
return NextResponse.json({ error: 'Invalid album ID' }, { status: 400 });
}
+ // Videos are opt-in. The hero picker feeds imageUrl()/assetPlaceholder and
+ // wants images only; the sort editor must show everything the public grid
+ // renders, or a video could never be pinned and would always fall to the tail.
+ const includeVideos = request.nextUrl.searchParams.get('types') === 'all';
+
try {
- const res = await fetch(`${config.immich.apiUrl}/albums/${encodeURIComponent(albumId)}`, {
- headers: {
- 'x-api-key': config.immich.apiKey,
- Accept: 'application/json',
- },
- });
+ // Via the client, not a bare fetch of `GET /albums/:id`: Immich 3.x no
+ // longer embeds assets in that response, so reading `album.assets` returned
+ // nothing (and threw outright when the key was absent). getAlbumAssetsRaw()
+ // pages through the metadata search and applies the album's own order, so
+ // this list matches what the site renders under `sort: immich`.
+ const all = await immich.getAlbumAssetsRaw(albumId);
- if (!res.ok) {
- return NextResponse.json({ error: `Immich API returned ${res.status}` }, { status: 502 });
- }
-
- const album = (await res.json()) as ImmichAlbumDetail;
-
- const assets = album.assets
- .filter((a) => a.type === 'IMAGE')
+ const assets = all
+ .filter((a) => a.type === 'IMAGE' || (includeVideos && a.type === 'VIDEO'))
.map((a) => ({
id: a.id,
+ type: a.type,
originalFileName: a.originalFileName,
fileCreatedAt: a.fileCreatedAt,
- isFavorite: a.isFavorite,
+ isFavorite: a.isFavorite ?? false,
}));
return NextResponse.json({ assets, nextPage: null });
diff --git a/content/gallery.yaml.example b/content/gallery.yaml.example
index 16aeff3..cf97359 100644
--- a/content/gallery.yaml.example
+++ b/content/gallery.yaml.example
@@ -8,9 +8,39 @@ hero:
- "your-asset-uuid-2"
# Standalone albums (shown on the home page)
+#
+# Photo order inside an album: by default a Folio album mirrors the Immich
+# timeline. The optional `sort` key lets the portfolio order differ from the
+# archive order, without changing anything in Immich.
+#
+# immich Default. Inherits the album's own asc/desc setting in Immich.
+# This is capture-time order — Immich does not expose the drag
+# order from its web UI, so Folio cannot reproduce that.
+# newest Capture time, newest first, whatever Immich is set to.
+# oldest Capture time, oldest first.
+# filename originalFileName, natural order (IMG_2 before IMG_10).
+# Matches scanned-film and camera-export workflows.
+# manual Pinned photos first, in the order given by `assetOrder`;
+# everything else follows in the album's Immich order. Far easier
+# to set in /admin, which has a drag & drop editor for it.
+#
+# Two things worth knowing:
+# • On a subpage with several albums, `sort` orders photos *within* each
+# album — the albums themselves stay in the order they are listed in.
+# • Lightbox permalinks (#photo-3) are positional, so changing an album's
+# sort moves where a previously shared link lands.
+#
+# `sort` works the same on subpage and section albums, shown further down.
albums:
- - "your-album-uuid-1"
- - "your-album-uuid-2"
+ - "your-album-uuid-1" # no sort key → Immich order, exactly as before
+ - "your-album-uuid-2":
+ sort: filename # an entry may carry only a sort, with no title override
+ - "your-album-uuid-3":
+ title: "Hokkaido, in sequence"
+ sort: manual
+ assetOrder: # pinned, in this order. Everything else follows automatically.
+ - "asset-uuid-opening-frame"
+ - "asset-uuid-second-frame"
# Subpages: group albums under custom URL paths
subpages:
diff --git a/docs/gallery-config.md b/docs/gallery-config.md
index 19428d0..8be2d88 100644
--- a/docs/gallery-config.md
+++ b/docs/gallery-config.md
@@ -94,7 +94,7 @@ Each section can have:
| `description` | string | ➖ | Optional subline under the heading |
| `albums` | list | ✅ | Album UUIDs (same format as regular albums) |
-Within the `albums` list you can use a plain UUID, a simple title override, or an object with `title` and `password`:
+Within the `albums` list you can use a plain UUID, a simple title override, or an object with any of `title`, `description`, `password`, `heroImage`, `sort` and `assetOrder`:
```yaml
albums:
@@ -103,6 +103,8 @@ albums:
- 'album-uuid':
title: My Private Title
password: 'secure-password' # → adds a password gate to this specific album
+ heroImage: 'asset-uuid' # → overrides the album cover
+ sort: filename # → see "Photo Order Within an Album" below
```
**Full example:**
@@ -133,6 +135,47 @@ subpages:
> [!NOTE]
> Each section title is automatically converted to a URL-safe anchor (`#tokyo`, `#kyoto`, …). The TOC appearance (separator character, numbering style, section rule) is fully controlled by the active theme.
+## Photo Order Within an Album
+
+By default a Folio album mirrors the Immich timeline. That is usually right, but a curated series has a narrative order that has nothing to do with capture time — and changing the sort on the Immich album would change it for the archive too. The optional `sort` key decouples the two.
+
+| Mode | Order |
+| ---------- | --------------------------------------------------------------------------------------- |
+| `immich` | Default. Inherits the album's own `asc`/`desc` setting in Immich. |
+| `newest` | Capture time, newest first, regardless of the Immich setting. |
+| `oldest` | Capture time, oldest first. |
+| `filename` | `originalFileName` in natural order — `IMG_2` before `IMG_10`. |
+| `manual` | Pinned photos first, in the order you set; everything else follows in the Immich order. |
+
+```yaml
+albums:
+ - 'album-uuid' # no sort key → Immich order, exactly as before
+ - 'album-uuid':
+ sort: filename # an entry may carry only a sort, with no title override
+ - 'album-uuid':
+ title: Hokkaido, in sequence
+ sort: manual
+ assetOrder: # pinned, in this order
+ - 'asset-uuid-opening-frame'
+ - 'asset-uuid-second-frame'
+```
+
+`sort` works the same on standalone, subpage and section albums.
+
+### About `manual`
+
+`manual` is a pinned **prefix**, not a full permutation: `assetOrder` lists only the photos you placed by hand, and everything else follows in the album's Immich order. Curating an opening sequence therefore costs a handful of UUIDs rather than one per photo in the album.
+
+This also makes the album resilient to changes in Immich. Photos removed from the album are ignored, and photos added later appear at the end rather than being hidden — so `gallery.yaml` never has to be kept in sync by hand.
+
+The `/admin` page builder has a drag & drop editor for this (album → **Photo order** → **Manual** → **Reorder photos**), which is considerably easier than writing asset UUIDs by hand. It only writes a cleaned-up `assetOrder` when you explicitly apply it.
+
+> [!NOTE]
+> Two consequences worth knowing. On a subpage with several albums, `sort` orders photos _within_ each album — the albums themselves stay in the order they are listed in. And lightbox permalinks (`#photo-3`) are positional, so changing an album's sort moves where a previously shared link lands.
+
+> [!TIP]
+> `immich` means "inherit the album's sort setting from Immich", which is capture-time order in one direction or the other. It is not the manual drag order from the Immich web UI — Immich does not expose that order through its API, so Folio cannot reproduce it. Use `manual` for a hand-picked sequence.
+
## Grid Layout
Configure the photo grid globally or per-subpage:
diff --git a/lib/__tests__/albumSort.test.ts b/lib/__tests__/albumSort.test.ts
new file mode 100644
index 0000000..331dfdf
--- /dev/null
+++ b/lib/__tests__/albumSort.test.ts
@@ -0,0 +1,211 @@
+import { describe, it, expect } from 'vitest';
+
+import {
+ ALBUM_SORT_MODES,
+ isAlbumSortMode,
+ sortAlbumAssets,
+ type AlbumSortMode,
+ type SortableAsset,
+} from '@/lib/albumSort';
+
+function asset(id: string, overrides: Partial = {}): SortableAsset {
+ return {
+ id,
+ originalFileName: `${id}.jpg`,
+ fileCreatedAt: '2024-01-01T00:00:00.000Z',
+ localDateTime: '2024-01-01T00:00:00.000Z',
+ ...overrides,
+ };
+}
+
+const ids = (assets: SortableAsset[]) => assets.map((a) => a.id);
+
+describe('sortAlbumAssets is pure', () => {
+ it('returns a new array and leaves the input untouched', () => {
+ const input = [
+ asset('b', { localDateTime: '2024-02-01T00:00:00.000Z' }),
+ asset('a', { localDateTime: '2024-01-01T00:00:00.000Z' }),
+ ];
+ const before = ids(input);
+
+ const out = sortAlbumAssets(input, { mode: 'oldest' });
+
+ expect(out).not.toBe(input);
+ expect(ids(input)).toEqual(before);
+ });
+
+ // The cached album is shared, so any mode that dropped or duplicated an asset
+ // would corrupt what every later request sees. This is the guard that any
+ // future rewrite of the manual branch has to survive.
+ it.each(ALBUM_SORT_MODES)('preserves the exact set of assets under %s', (mode) => {
+ const input = [asset('a'), asset('b'), asset('c'), asset('d')];
+
+ const out = sortAlbumAssets(input, {
+ mode: mode as AlbumSortMode,
+ immichOrder: 'asc',
+ manualOrder: ['c', 'zzz'],
+ });
+
+ expect(out).toHaveLength(input.length);
+ expect(ids(out).sort()).toEqual(['a', 'b', 'c', 'd']);
+ });
+});
+
+describe('capture-time modes', () => {
+ const jan = asset('jan', { localDateTime: '2024-01-01T00:00:00.000Z' });
+ const feb = asset('feb', { localDateTime: '2024-02-01T00:00:00.000Z' });
+ const mar = asset('mar', { localDateTime: '2024-03-01T00:00:00.000Z' });
+
+ it('orders newest first', () => {
+ expect(ids(sortAlbumAssets([jan, mar, feb], { mode: 'newest' }))).toEqual([
+ 'mar',
+ 'feb',
+ 'jan',
+ ]);
+ });
+
+ it('orders oldest first, exactly reversing newest', () => {
+ const input = [jan, mar, feb];
+ expect(ids(sortAlbumAssets(input, { mode: 'oldest' }))).toEqual(
+ ids(sortAlbumAssets(input, { mode: 'newest' })).reverse(),
+ );
+ });
+
+ it('falls back to fileCreatedAt when localDateTime ties', () => {
+ const early = asset('early', {
+ localDateTime: '2024-01-01T00:00:00.000Z',
+ fileCreatedAt: '2024-01-01T08:00:00.000Z',
+ });
+ const late = asset('late', {
+ localDateTime: '2024-01-01T00:00:00.000Z',
+ fileCreatedAt: '2024-01-01T20:00:00.000Z',
+ });
+
+ expect(ids(sortAlbumAssets([late, early], { mode: 'oldest' }))).toEqual(['early', 'late']);
+ });
+
+ // Date.parse() on garbage yields NaN, and a comparator returning NaN makes
+ // sort() incoherent — the result depends on the engine's algorithm, not the data.
+ it('treats an unparseable date as epoch rather than producing NaN', () => {
+ const broken = asset('broken', { localDateTime: 'not-a-date', fileCreatedAt: 'also-not' });
+
+ const out = sortAlbumAssets([feb, broken, jan], { mode: 'oldest' });
+
+ expect(ids(out)).toEqual(['broken', 'jan', 'feb']);
+ });
+
+ it('immich follows the album direction', () => {
+ const input = [feb, jan, mar];
+ expect(ids(sortAlbumAssets(input, { mode: 'immich', immichOrder: 'asc' }))).toEqual([
+ 'jan',
+ 'feb',
+ 'mar',
+ ]);
+ expect(ids(sortAlbumAssets(input, { mode: 'immich', immichOrder: 'desc' }))).toEqual([
+ 'mar',
+ 'feb',
+ 'jan',
+ ]);
+ });
+});
+
+describe('filename mode', () => {
+ it('collates numerically, so IMG_2 precedes IMG_10', () => {
+ const input = [
+ asset('c', { originalFileName: 'IMG_10.jpg' }),
+ asset('a', { originalFileName: 'IMG_2.jpg' }),
+ asset('b', { originalFileName: 'IMG_9.jpg' }),
+ ];
+
+ expect(ids(sortAlbumAssets(input, { mode: 'filename' }))).toEqual(['a', 'b', 'c']);
+ });
+
+ it('is case-insensitive', () => {
+ const input = [
+ asset('b', { originalFileName: 'beta.jpg' }),
+ asset('a', { originalFileName: 'Alpha.jpg' }),
+ ];
+
+ expect(ids(sortAlbumAssets(input, { mode: 'filename' }))).toEqual(['a', 'b']);
+ });
+
+ // Duplicate filenames across import batches are common; without the id
+ // tiebreak the order of a tie would follow the input and drift per request.
+ it('breaks ties on id so the order is stable', () => {
+ const one = asset('aaa', { originalFileName: 'DSC.jpg' });
+ const two = asset('bbb', { originalFileName: 'dsc.jpg' });
+
+ expect(ids(sortAlbumAssets([two, one], { mode: 'filename' }))).toEqual(['aaa', 'bbb']);
+ expect(ids(sortAlbumAssets([one, two], { mode: 'filename' }))).toEqual(['aaa', 'bbb']);
+ });
+});
+
+describe('manual mode is a pinned prefix', () => {
+ const a = asset('a', { localDateTime: '2024-01-01T00:00:00.000Z' });
+ const b = asset('b', { localDateTime: '2024-02-01T00:00:00.000Z' });
+ const c = asset('c', { localDateTime: '2024-03-01T00:00:00.000Z' });
+
+ it('puts pinned assets first, in the pinned order', () => {
+ const out = sortAlbumAssets([a, b, c], {
+ mode: 'manual',
+ immichOrder: 'asc',
+ manualOrder: ['c', 'a'],
+ });
+
+ expect(ids(out)).toEqual(['c', 'a', 'b']);
+ });
+
+ it('appends unpinned assets in the album Immich order', () => {
+ const desc = sortAlbumAssets([a, b, c], {
+ mode: 'manual',
+ immichOrder: 'desc',
+ manualOrder: ['a'],
+ });
+ const asc = sortAlbumAssets([a, b, c], {
+ mode: 'manual',
+ immichOrder: 'asc',
+ manualOrder: ['a'],
+ });
+
+ expect(ids(desc)).toEqual(['a', 'c', 'b']);
+ expect(ids(asc)).toEqual(['a', 'b', 'c']);
+ });
+
+ // The album can change in Immich after the order was saved; a stale id must
+ // not shift or drop anything.
+ it('ignores pinned ids that are no longer in the album', () => {
+ const out = sortAlbumAssets([a, b], {
+ mode: 'manual',
+ immichOrder: 'asc',
+ manualOrder: ['gone', 'b'],
+ });
+
+ expect(ids(out)).toEqual(['b', 'a']);
+ });
+
+ it('pins a duplicated id once, at its first position', () => {
+ const out = sortAlbumAssets([a, b, c], {
+ mode: 'manual',
+ immichOrder: 'asc',
+ manualOrder: ['c', 'a', 'c'],
+ });
+
+ expect(ids(out)).toEqual(['c', 'a', 'b']);
+ });
+
+ it('falls back to the album order when nothing is pinned', () => {
+ const input = [c, a, b];
+ expect(ids(sortAlbumAssets(input, { mode: 'manual', immichOrder: 'asc' }))).toEqual(
+ ids(sortAlbumAssets(input, { mode: 'immich', immichOrder: 'asc' })),
+ );
+ });
+});
+
+describe('isAlbumSortMode', () => {
+ it('accepts every declared mode and nothing else', () => {
+ for (const mode of ALBUM_SORT_MODES) expect(isAlbumSortMode(mode)).toBe(true);
+ expect(isAlbumSortMode('bogus')).toBe(false);
+ expect(isAlbumSortMode(undefined)).toBe(false);
+ expect(isAlbumSortMode(null)).toBe(false);
+ });
+});
diff --git a/lib/__tests__/config-hero-uuid.test.ts b/lib/__tests__/config-hero-uuid.test.ts
index a61d9a4..04a5a8a 100644
--- a/lib/__tests__/config-hero-uuid.test.ts
+++ b/lib/__tests__/config-hero-uuid.test.ts
@@ -76,6 +76,27 @@ describe('gallery.yaml per-album heroImage', () => {
expect(getConfig().albumHeroImages[ALBUM_ID]).toBe(HERO_ID);
});
+ // Same class of ID, same requirement: a pinned asset ID from `sort: manual`
+ // is written by the admin panel and can be hand-edited, so it goes through
+ // the same gate. A substituted zero UUID simply never matches an asset in the
+ // album, so the pin is ignored rather than corrupting the order.
+ it('routes manual assetOrder IDs through validateUuid, naming the album', () => {
+ vi.mocked(loadYaml).mockImplementation((filename: string) => {
+ if (filename === 'gallery.yaml') {
+ return { albums: [{ [ALBUM_ID]: { sort: 'manual', assetOrder: [HERO_ID] } }] };
+ }
+ if (filename === 'settings.yaml') return {};
+ return null;
+ });
+ invalidateConfigCache();
+
+ const config = getConfig();
+
+ expect(config.albumManualOrders[ALBUM_ID]).toEqual([HERO_ID]);
+ const call = validateUuidSpy.mock.calls.find((c) => c[0] === HERO_ID);
+ expect(call?.[1]).toContain(ALBUM_ID);
+ });
+
// The substitution the wiring above depends on: validateUuid never throws, so
// a bad ID degrades to a 404 image rather than taking the whole page down.
it('real validateUuid warns and substitutes rather than throwing', async () => {
diff --git a/lib/__tests__/derive-gallery.test.ts b/lib/__tests__/derive-gallery.test.ts
index c8582f4..1ee6dba 100644
--- a/lib/__tests__/derive-gallery.test.ts
+++ b/lib/__tests__/derive-gallery.test.ts
@@ -104,6 +104,77 @@ describe('deriveGallery accepts valid structures', () => {
expect(result.albumHeroImages[A]).toBe(B);
});
+ it('collects the per-album sort mode', () => {
+ const result = deriveGallery({
+ albums: [{ [A]: { title: 'Series', sort: 'filename' } }, B],
+ } as unknown as GalleryYaml);
+
+ expect(result.albumSortModes[A]).toBe('filename');
+ // Absent means the default; an explicit entry would make `immich` and
+ // "never configured" indistinguishable downstream.
+ expect(result.albumSortModes[B]).toBeUndefined();
+ });
+
+ // A typo would otherwise be invisible: the album would keep rendering in
+ // Immich order and nothing would say why. Throwing is also what gives the
+ // admin PUT its 400, via the deriveGallery dry-run.
+ it('rejects an unknown sort mode, naming the album and the valid values', () => {
+ expect(() =>
+ deriveGallery({ albums: [{ [A]: { sort: 'alphabetical' } }] } as unknown as GalleryYaml),
+ ).toThrow(/alphabetical/);
+
+ expect(() =>
+ deriveGallery({ albums: [{ [A]: { sort: 'alphabetical' } }] } as unknown as GalleryYaml),
+ ).toThrow(new RegExp(A));
+
+ expect(() =>
+ deriveGallery({ albums: [{ [A]: { sort: 'alphabetical' } }] } as unknown as GalleryYaml),
+ ).toThrow(/filename/);
+ });
+
+ it('collects the manual asset order, preserving its order', () => {
+ const result = deriveGallery({
+ albums: [{ [A]: { sort: 'manual', assetOrder: [B, A] } }],
+ } as unknown as GalleryYaml);
+
+ expect(result.albumManualOrders[A]).toEqual([B, A]);
+ });
+
+ // Kept regardless of the mode, so toggling manual → newest → manual in the
+ // admin panel does not silently destroy a hand-curated order.
+ it('keeps the asset order even when the mode is not manual', () => {
+ const result = deriveGallery({
+ albums: [{ [A]: { sort: 'newest', assetOrder: [B] } }],
+ } as unknown as GalleryYaml);
+
+ expect(result.albumSortModes[A]).toBe('newest');
+ expect(result.albumManualOrders[A]).toEqual([B]);
+ });
+
+ it('ignores an empty asset order rather than storing one', () => {
+ const result = deriveGallery({
+ albums: [{ [A]: { sort: 'manual', assetOrder: [] } }],
+ } as unknown as GalleryYaml);
+
+ expect(result.albumManualOrders[A]).toBeUndefined();
+ });
+
+ // processAlbumEntry is reached from four places; only the array-subpage path
+ // was covered before, so a regression in the others would go unnoticed.
+ it('applies the sort mode to section and map-style subpage albums too', () => {
+ const sectioned = deriveGallery({
+ subpages: [
+ { name: 'Work', sections: [{ title: 'Recent', albums: [{ [A]: { sort: 'oldest' } }] }] },
+ ],
+ } as unknown as GalleryYaml);
+ expect(sectioned.albumSortModes[A]).toBe('oldest');
+
+ const mapStyle = deriveGallery({
+ subpages: { Trips: { albums: [{ [B]: { sort: 'filename' } }] } },
+ } as unknown as GalleryYaml);
+ expect(mapStyle.albumSortModes[B]).toBe('filename');
+ });
+
it('parses enabled property on subpages correctly', () => {
const result = deriveGallery({
subpages: [
diff --git a/lib/__tests__/immich.test.ts b/lib/__tests__/immich.test.ts
index 92820ef..4c44e4c 100644
--- a/lib/__tests__/immich.test.ts
+++ b/lib/__tests__/immich.test.ts
@@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { immich, ImmichUnavailableError } from '../immich';
import * as config from '../config';
import { cache } from '../cache';
+import type { AlbumSortMode } from '../albumSort';
+
+// Per-album sort is read on every getAlbum(), so the tests below need to be
+// able to change it between calls without rebuilding the whole mock.
+const albumSortModes: Record = {};
+const albumManualOrders: Record = {};
// Mock the config by wrapping it in a factory
vi.mock('../config', async () => {
@@ -16,6 +22,8 @@ vi.mock('../config', async () => {
subpages: [],
albumOverrides: { 'album-1': 'Override Name' },
albumDescriptions: {},
+ albumSortModes,
+ albumManualOrders,
// Non-zero: a 0 TTL expires an entry in the same millisecond it is
// written, which would make any caching assertion flaky. Isolation comes
// from the cache.clear() in beforeEach, not from an unusable TTL.
@@ -37,6 +45,8 @@ describe('ImmichClient', () => {
// getAlbum() memoises into the module-level LRU; without this, later tests
// read a previous test's album and never reach the mocked fetch.
cache.clear();
+ for (const key of Object.keys(albumSortModes)) delete albumSortModes[key];
+ for (const key of Object.keys(albumManualOrders)) delete albumManualOrders[key];
});
describe('request()', () => {
@@ -579,6 +589,69 @@ describe('ImmichClient', () => {
const album = await immich.getAlbum('album-1');
expect(album?.assets.map((a) => a.id)).toEqual(['frame-1', 'frame-2', 'frame-3']);
});
+
+ /**
+ * The configured sort is applied on the way out of getAlbum(), so the LRU
+ * keeps one canonical Immich-ordered copy per album. Sorting the cached
+ * array in place would reorder the shared entry, and the next reader with a
+ * different mode would then be sorting an already-sorted array.
+ */
+ describe('per-album sort', () => {
+ const items = [
+ { ...asset('b', '2024-02-01T00:00:00Z'), originalFileName: 'IMG_2.jpg' },
+ { ...asset('c', '2024-03-01T00:00:00Z'), originalFileName: 'IMG_10.jpg' },
+ { ...asset('a', '2024-01-01T00:00:00Z'), originalFileName: 'IMG_1.jpg' },
+ ];
+
+ it('overrides the Immich direction', async () => {
+ albumSortModes['album-1'] = 'oldest';
+ mockAlbumApi({ order: 'desc', pages: [{ items, nextPage: null }] });
+
+ const album = await immich.getAlbum('album-1');
+ expect(album?.assets.map((a) => a.id)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('applies the manual pin order', async () => {
+ albumSortModes['album-1'] = 'manual';
+ albumManualOrders['album-1'] = ['c'];
+ mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
+
+ const album = await immich.getAlbum('album-1');
+ expect(album?.assets.map((a) => a.id)).toEqual(['c', 'a', 'b']);
+ });
+
+ it('leaves the cached album in canonical order', async () => {
+ albumSortModes['album-1'] = 'filename';
+ mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
+
+ const first = await immich.getAlbum('album-1');
+ expect(first?.assets.map((a) => a.id)).toEqual(['a', 'b', 'c']); // IMG_1, IMG_2, IMG_10
+
+ // Same cache entry, different mode: if the first call had sorted the
+ // stored array, the album would still be in filename order here.
+ albumSortModes['album-1'] = 'newest';
+ const second = await immich.getAlbum('album-1');
+ expect(second?.assets.map((a) => a.id)).toEqual(['c', 'b', 'a']);
+ expect(second?.assets).not.toBe(first?.assets);
+ });
+
+ it('gives concurrent callers independent arrays', async () => {
+ albumSortModes['album-1'] = 'manual';
+ albumManualOrders['album-1'] = ['b'];
+ mockAlbumApi({ order: 'asc', pages: [{ items, nextPage: null }] });
+
+ // Coalesced through the same pending promise, but each caller applies
+ // the sort itself — so neither can be handed the other's array.
+ const [one, two] = await Promise.all([
+ immich.getAlbum('album-1'),
+ immich.getAlbum('album-1'),
+ ]);
+
+ expect(one?.assets.map((a) => a.id)).toEqual(['b', 'a', 'c']);
+ expect(two?.assets.map((a) => a.id)).toEqual(['b', 'a', 'c']);
+ expect(one?.assets).not.toBe(two?.assets);
+ });
+ });
});
});
diff --git a/lib/albumSort.ts b/lib/albumSort.ts
new file mode 100644
index 0000000..88939c8
--- /dev/null
+++ b/lib/albumSort.ts
@@ -0,0 +1,148 @@
+/**
+ * Per-album asset ordering.
+ *
+ * Album order is Immich's by default — a Folio album mirrors the Immich
+ * timeline. A portfolio often needs something else: a curated series has a
+ * narrative order that has nothing to do with capture time, and changing the
+ * sort on the Immich album would change it for the archive too.
+ *
+ * Everything here is pure and never sorts in place. `lib/immich.ts` applies
+ * these *after* the cache read, so the LRU keeps exactly one canonical
+ * Immich-ordered copy per album; sorting the cached array directly would
+ * reorder the shared entry, and the next reader with a different mode would
+ * then sort an already-sorted array.
+ */
+
+/**
+ * The fields ordering actually needs. Deliberately structural rather than
+ * `ImmichAsset`: the admin reorder endpoint works on a lighter projection and
+ * must produce a baseline byte-identical to the site's, and depending on the
+ * narrow shape keeps this module free of any import from `lib/immich.ts`.
+ */
+export interface SortableAsset {
+ id: string;
+ originalFileName: string;
+ fileCreatedAt: string;
+ localDateTime?: string;
+}
+
+export const ALBUM_SORT_MODES = ['immich', 'newest', 'oldest', 'filename', 'manual'] as const;
+
+export type AlbumSortMode = (typeof ALBUM_SORT_MODES)[number];
+
+export const DEFAULT_ALBUM_SORT: AlbumSortMode = 'immich';
+
+export function isAlbumSortMode(value: unknown): value is AlbumSortMode {
+ return typeof value === 'string' && (ALBUM_SORT_MODES as readonly string[]).includes(value);
+}
+
+/**
+ * Mirrors Immich's own timeline query: primary key `localDateTime` (capture
+ * time in the photographer's local zone), tie-broken by `fileCreatedAt` (the
+ * same instant in UTC). The two only diverge for albums spanning time zones,
+ * and there local time is what the Immich UI shows. Sorting on
+ * `exifInfo.dateTimeOriginal` instead would be near-pointless: Immich already
+ * derives `fileCreatedAt` from that same EXIF chain, and it is null for assets
+ * without EXIF.
+ *
+ * Compare parsed instants, not strings — string order breaks on fractional
+ * seconds ('.' collates before '+'), which misorders bursts where only some
+ * frames carry sub-second precision. Unparseable dates fall back to 0 rather
+ * than NaN, which would make sort() incoherent.
+ */
+export function compareByCaptureTime(dir: 1 | -1) {
+ const ts = (value: string | undefined) => Date.parse(value ?? '') || 0;
+ return (a: SortableAsset, b: SortableAsset): number =>
+ dir * (ts(a.localDateTime) - ts(b.localDateTime)) ||
+ dir * (ts(a.fileCreatedAt) - ts(b.fileCreatedAt));
+}
+
+/**
+ * Natural collation, so `IMG_2.jpg` precedes `IMG_10.jpg` — this is the point
+ * of the mode, and it is what scanned-film and camera-export workflows encode
+ * their intended order in.
+ */
+const filenameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
+
+function compareByFilename(a: SortableAsset, b: SortableAsset): number {
+ // Tie-break on id: `sensitivity: 'base'` treats "a.jpg" and "A.jpg" as equal,
+ // and duplicate filenames across import batches are common. Without this the
+ // order of a tie would depend on the input order and drift between requests.
+ return filenameCollator.compare(a.originalFileName, b.originalFileName) || (a.id < b.id ? -1 : 1);
+}
+
+export interface AlbumSortOptions {
+ mode: AlbumSortMode;
+ /** The Immich album's own sort direction, used by `immich` and as the fallback. */
+ immichOrder?: 'asc' | 'desc';
+ /** Pinned asset IDs for `manual`, in the order they should appear. */
+ manualOrder?: string[];
+}
+
+/**
+ * `manual` is a pinned *prefix*, not a full permutation: `manualOrder` lists
+ * only the assets the owner placed by hand, and everything else follows in the
+ * album's Immich order.
+ *
+ * That keeps `gallery.yaml` small — a full permutation of a 2,000-photo album
+ * is ~80 KB of UUIDs in a file that is rewritten and copied to `content/.backups/`
+ * on every admin save — and it makes drift handling fall out for free: assets
+ * removed from the album in Immich simply never match, and new ones land in the
+ * tail rather than being hidden.
+ *
+ * New assets go last, not first. Choosing `manual` is an explicit opt-out of
+ * chronology, and prepending would silently change position 1 — the featured
+ * tile in the showcase layout, and the album's visual opener. A surprise at the
+ * end of a curated sequence is recoverable; one at the top is a regression the
+ * owner sees on the homepage.
+ */
+export function sortAlbumAssets(
+ assets: T[],
+ { mode, immichOrder, manualOrder }: AlbumSortOptions,
+): T[] {
+ const dir: 1 | -1 = immichOrder === 'asc' ? 1 : -1;
+
+ switch (mode) {
+ case 'newest':
+ return [...assets].sort(compareByCaptureTime(-1));
+
+ case 'oldest':
+ return [...assets].sort(compareByCaptureTime(1));
+
+ case 'filename':
+ return [...assets].sort(compareByFilename);
+
+ case 'manual': {
+ if (!manualOrder?.length) return [...assets].sort(compareByCaptureTime(dir));
+
+ // Rank lookup rather than indexOf-per-comparison: an album with a few
+ // thousand assets would otherwise be quadratic. First occurrence wins, so
+ // a duplicated id in the YAML pins the asset once instead of dropping it.
+ const rank = new Map();
+ manualOrder.forEach((id, i) => {
+ if (!rank.has(id)) rank.set(id, i);
+ });
+
+ const pinned: T[] = [];
+ const rest: T[] = [];
+ for (const asset of assets) {
+ (rank.has(asset.id) ? pinned : rest).push(asset);
+ }
+
+ pinned.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
+ // Order the tail explicitly rather than trusting the caller to hand over
+ // an already-canonical array. `getAlbum()` does, but the guarantee should
+ // not live outside this function.
+ rest.sort(compareByCaptureTime(dir));
+ return [...pinned, ...rest];
+ }
+
+ // `immich` is not a fourth algorithm — Immich albums only carry an
+ // asc/desc flag, so inheriting it is capture-time order in that direction.
+ // It means "inherit from Immich", not "the drag order in the Immich web
+ // UI": that order is not exposed by the metadata search this client uses.
+ case 'immich':
+ default:
+ return [...assets].sort(compareByCaptureTime(dir));
+ }
+}
diff --git a/lib/config/index.ts b/lib/config/index.ts
index fc3fd94..bf3d8a9 100644
--- a/lib/config/index.ts
+++ b/lib/config/index.ts
@@ -2,9 +2,11 @@ import { env } from '../env';
import { resolveAuthSecret } from '../secret';
import { loadYaml, clearYamlCache, validateUuid } from './parser';
import { resolveTheme, VALID_LAYOUTS } from './theme';
+import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
import {
slugify,
AppConfig,
+ AlbumEntryObject,
SubpageConfig,
SubpageSectionConfig,
SubpageObjectValue,
@@ -82,6 +84,8 @@ export interface GalleryDerivation {
albumDescriptions: Record;
albumPasswords: Record;
albumHeroImages: Record;
+ albumSortModes: Record;
+ albumManualOrders: Record;
}
/**
@@ -99,14 +103,11 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
const albumDescriptions: Record = {};
const albumPasswords: Record = {};
const albumHeroImages: Record = {};
+ const albumSortModes: Record = {};
+ const albumManualOrders: Record = {};
function processAlbumEntry(
- entry:
- | string
- | Record<
- string,
- string | { title: string; description?: string; password?: string; heroImage?: string }
- >,
+ entry: string | Record,
context: string,
): string {
if (typeof entry === 'string') {
@@ -133,6 +134,31 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
`${context} heroImage for album ${validatedUuid}`,
);
}
+
+ // Throw rather than fall back to the default: a typo would otherwise be
+ // invisible — the album would just keep rendering in Immich order and the
+ // owner would be left wondering why the setting does nothing. Throwing
+ // also gives the admin PUT its 400 for free via the deriveGallery
+ // dry-run, and getConfigOrNull() degrades a hand-edited file to the setup
+ // screen rather than taking the site down. Name the album and the legal
+ // values so the log line is enough to fix it.
+ if (value.sort != null) {
+ if (!isAlbumSortMode(value.sort)) {
+ throw new Error(
+ `${context}: album ${validatedUuid} has an unknown sort "${value.sort}". ` +
+ `Valid values are: ${ALBUM_SORT_MODES.join(', ')}.`,
+ );
+ }
+ albumSortModes[validatedUuid] = value.sort;
+ }
+
+ // Kept regardless of the mode, so switching manual → newest → manual does
+ // not silently destroy a hand-curated order.
+ if (value.assetOrder?.length) {
+ albumManualOrders[validatedUuid] = value.assetOrder.map((assetId, i) =>
+ validateUuid(assetId, `${context} assetOrder[${i}] for album ${validatedUuid}`),
+ );
+ }
}
return validatedUuid;
}
@@ -240,6 +266,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
albumDescriptions,
albumPasswords,
albumHeroImages,
+ albumSortModes,
+ albumManualOrders,
};
}
@@ -287,6 +315,8 @@ export function getConfig(): AppConfig {
albumDescriptions: {},
albumPasswords: {},
albumHeroImages: {},
+ albumSortModes: {},
+ albumManualOrders: {},
cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000,
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
@@ -306,6 +336,8 @@ export function getConfig(): AppConfig {
albumDescriptions,
albumPasswords,
albumHeroImages,
+ albumSortModes,
+ albumManualOrders,
} = deriveGallery(gallery);
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
@@ -378,6 +410,8 @@ export function getConfig(): AppConfig {
albumDescriptions,
albumPasswords,
albumHeroImages,
+ albumSortModes,
+ albumManualOrders,
cacheTtl: env.CACHE_TTL * 1000,
staleMaxAge: env.STALE_MAX_AGE * 1000,
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
diff --git a/lib/config/schema.ts b/lib/config/schema.ts
index 8cf3077..e0314ba 100644
--- a/lib/config/schema.ts
+++ b/lib/config/schema.ts
@@ -1,3 +1,5 @@
+import type { AlbumSortMode } from '../albumSort';
+
export interface SubpageSectionConfig {
title: string;
slug: string; // auto-derived from title, used as anchor
@@ -29,15 +31,14 @@ export interface SubpageObjectValue {
essayFile?: string;
essayText?: string;
enabled?: boolean;
- albums?: Array<
- string | Record
- >;
+ // Both album lists reuse AlbumEntryObject rather than inlining the shape:
+ // the inline copies had already drifted (they never gained `heroImage`), and
+ // this path is live for map-style subpages.
+ albums?: Array>;
sections?: Array<{
title: string;
description?: string;
- albums: Array<
- string | Record
- >;
+ albums: Array>;
}>;
}
@@ -112,6 +113,10 @@ export interface AppConfig {
albumDescriptions: Record;
albumPasswords: Record;
albumHeroImages: Record;
+ /** Per-album sort mode. Absent means `immich` — the album's own order. */
+ albumSortModes: Record;
+ /** Pinned asset UUIDs per album for `sort: manual`, in display order. */
+ albumManualOrders: Record;
cacheTtl: number;
/** How long (ms) an expired cache entry may still be served while Immich is unreachable. */
staleMaxAge: number;
@@ -133,10 +138,18 @@ export interface AppConfig {
}
export interface AlbumEntryObject {
- title: string;
+ /** Optional: an entry may carry only a sort mode and no title override. */
+ title?: string;
description?: string;
password?: string;
heroImage?: string;
+ /**
+ * Raw YAML shape — narrowed to `AlbumSortMode` in deriveGallery(), which is
+ * where an unknown value has to be rejected with a message a human can act on.
+ */
+ sort?: string;
+ /** Pinned asset UUIDs for `sort: manual`. Everything else follows automatically. */
+ assetOrder?: string[];
}
export interface GalleryYaml {
diff --git a/lib/immich.ts b/lib/immich.ts
index cd4ab3b..ede8717 100644
--- a/lib/immich.ts
+++ b/lib/immich.ts
@@ -9,6 +9,7 @@
import { getConfig, slugify, type SubpageConfig } from './config';
import { cache } from './cache';
+import { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort';
// ── Types ──────────────────────────────────────────────────────
@@ -36,6 +37,8 @@ export interface ImmichAsset {
localDateTime?: string;
exifInfo?: ImmichExifInfo;
isTrashed: boolean;
+ /** Only surfaced in the admin pickers. Optional: older Immich responses omit it. */
+ isFavorite?: boolean;
}
export interface ImmichExifInfo {
@@ -544,7 +547,61 @@ class ImmichClient {
return assets;
}
+ /**
+ * Every asset of an album, in the album's canonical Immich order, bypassing
+ * the allowlist.
+ *
+ * For the admin panel only. `getAlbum()` refuses albums that are not in the
+ * saved config, which is right for the public site but wrong for the page
+ * builder: an album just dragged in has not been saved yet, so the picker
+ * and the reorder editor would come up empty. Admin auth is the gate here.
+ */
+ async getAlbumAssetsRaw(albumId: string): Promise {
+ const [album, assets] = await Promise.all([
+ this.request(`/albums/${encodeURIComponent(albumId)}`),
+ this.fetchAlbumAssets(albumId),
+ ]);
+ if (!album) return [];
+
+ // Same order the site would render under `sort: immich`. The reorder
+ // editor's baseline has to match it exactly, or the assets it shows as
+ // "follows automatically" would not be the ones that actually follow.
+ return assets
+ .filter((a) => !a.isTrashed)
+ .sort(compareByCaptureTime(album.order === 'asc' ? 1 : -1));
+ }
+
+ /**
+ * Apply the album's configured sort on the way out.
+ *
+ * Deliberately not folded into the cached load: the LRU holds one canonical
+ * Immich-ordered copy per album, so the mode never has to enter the cache key
+ * and a config change takes effect without depending on invalidation.
+ *
+ * The clone is load-bearing. `cache.get()` hands back the stored object by
+ * reference, so sorting `album.assets` here would permanently reorder the
+ * cached entry — and under request coalescing every concurrent caller would
+ * be sorting the same array.
+ */
+ private withSort(album: ImmichAlbum): ImmichAlbum {
+ const mode = this.config.albumSortModes[album.id] ?? DEFAULT_ALBUM_SORT;
+ return {
+ ...album,
+ assets: sortAlbumAssets(album.assets, {
+ mode,
+ immichOrder: album.order,
+ manualOrder: this.config.albumManualOrders[album.id],
+ }),
+ };
+ }
+
async getAlbum(albumId: string, forceFresh = false): Promise {
+ const album = await this.loadAlbum(albumId, forceFresh);
+ return album ? this.withSort(album) : null;
+ }
+
+ /** The cached load. Always yields the canonical Immich order; see withSort(). */
+ private async loadAlbum(albumId: string, forceFresh: boolean): Promise {
// Security: only serve configured albums
if (!this.config.albums.includes(albumId)) {
console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`);
@@ -593,25 +650,10 @@ class ImmichClient {
// order. Metadata search happens to default to fileCreatedAt desc, but
// that is not contractual — sort explicitly so 'asc' albums are right.
//
- // Mirrors Immich's own timeline query: primary key `localDateTime`
- // (capture time in the photographer's local zone), tie-broken by
- // `fileCreatedAt` (the same instant in UTC). The two only diverge for
- // albums spanning time zones, and there local time is what the Immich
- // UI shows. Sorting on `exifInfo.dateTimeOriginal` instead would be
- // near-pointless: Immich already derives `fileCreatedAt` from that
- // same EXIF chain, and it is null for assets without EXIF.
- //
- // Compare parsed instants, not strings — string order breaks on
- // fractional seconds ('.' collates before '+'), which misorders bursts
- // where only some frames carry sub-second precision. Unparseable dates
- // fall back to 0 rather than NaN, which would make sort() incoherent.
- const dir = album.order === 'asc' ? 1 : -1;
- const ts = (value: string | undefined) => Date.parse(value ?? '') || 0;
- album.assets.sort(
- (a, b) =>
- dir * (ts(a.localDateTime) - ts(b.localDateTime)) ||
- dir * (ts(a.fileCreatedAt) - ts(b.fileCreatedAt)),
- );
+ // This is the *canonical* order, and it is what goes into the cache.
+ // A configured per-album sort is applied later, on the way out of
+ // getAlbum(); see withSort().
+ album.assets.sort(compareByCaptureTime(album.order === 'asc' ? 1 : -1));
const name = this.config.albumOverrides[album.id] ?? album.albumName;
const description = this.config.albumDescriptions[album.id] ?? album.description ?? '';
diff --git a/lib/tokens.ts b/lib/tokens.ts
index 646419d..b196f4b 100644
--- a/lib/tokens.ts
+++ b/lib/tokens.ts
@@ -45,6 +45,10 @@ export function encodeAssetId(assetId: string): string {
* Returns null if the token is invalid.
*/
export function decodeAssetId(token: string): string | null {
+ // 🛡️ SECURITY: Enforce maximum token length to prevent memory exhaustion DoS
+ // A v2 token for a 36-char UUID is ~110 chars. 256 is generous but safe.
+ if (token.length > 256) return null;
+
try {
const key = getKey();