🛡️ Sentinel: [HIGH] Fix timingSafeEqual DoS Vulnerability (#423)
* Fix timingSafeEqual DoS Vulnerability in Webhook Co-authored-by: ralksta <16941050+ralksta@users.noreply.github.com> * Fix memory exhaustion DoS in token decoding Co-authored-by: ralksta <16941050+ralksta@users.noreply.github.com> --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
4
.jules/sentinel.md
Normal file
4
.jules/sentinel.md
Normal file
@@ -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.
|
||||||
31
CHANGELOG.md
31
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
|
Releases up to and including v0.9.2 are documented in the
|
||||||
[GitHub releases](https://github.com/ralksta/immich-folio/releases).
|
[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
|
## [0.10.0] — 2026-08-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -60,6 +85,12 @@ Releases up to and including v0.9.2 are documented in the
|
|||||||
were visible on top of each other.
|
were visible on top of each other.
|
||||||
- **Docker** — health check targets `127.0.0.1` instead of `localhost`, base
|
- **Docker** — health check targets `127.0.0.1` instead of `localhost`, base
|
||||||
image moved to `node:22-alpine`.
|
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
|
- **Docs** — the "nature / travel journal" example in `docs/theming.md` used a
|
||||||
preset named `botanica` that does not exist; copying it produced an
|
preset named `botanica` that does not exist; copying it produced an
|
||||||
`Unknown theme preset` error. It now uses `editorial`.
|
`Unknown theme preset` error. It now uses `editorial`.
|
||||||
|
|||||||
@@ -1510,6 +1510,10 @@
|
|||||||
|
|
||||||
.asset-picker-tab {
|
.asset-picker-tab {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
padding: 0.7rem 1rem;
|
padding: 0.7rem 1rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -1607,8 +1611,11 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
left: 4px;
|
left: 4px;
|
||||||
font-size: 0.7rem;
|
display: flex;
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
|
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 {
|
.asset-picker-uuid-section {
|
||||||
@@ -1782,8 +1789,10 @@
|
|||||||
color 0.15s ease;
|
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 {
|
.svg-drag {
|
||||||
width: 12px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1924,6 +1933,17 @@ h3 .svg-icon {
|
|||||||
background: rgba(74, 222, 128, 0.05);
|
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 ── */
|
/* ── Breitwand-Kiste Centered Modal styling ── */
|
||||||
.album-drawer-overlay {
|
.album-drawer-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -3821,3 +3841,140 @@ h3 .svg-icon {
|
|||||||
background: var(--admin-btn-primary-bg, #ffffff);
|
background: var(--admin-btn-primary-bg, #ffffff);
|
||||||
color: var(--admin-btn-primary-text, #000000);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
277
app/admin/components/AssetOrderEditor.tsx
Normal file
277
app/admin/components/AssetOrderEditor.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
}}
|
||||||
|
className="order-tile order-tile-pinned"
|
||||||
|
title={asset.originalFileName}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<img src={`/api/admin/thumbnail/${asset.id}`} alt={asset.originalFileName} loading="lazy" />
|
||||||
|
<span className="order-tile-index">{index + 1}</span>
|
||||||
|
<button
|
||||||
|
className="order-tile-action"
|
||||||
|
title="Unpin — let this photo follow automatically"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUnpin();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<AssetInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [pinnedIds, setPinnedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
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<string>(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 (
|
||||||
|
<div className="picker-overlay" onClick={onClose}>
|
||||||
|
<div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="picker-header">
|
||||||
|
<h3>Photo order — {albumName}</h3>
|
||||||
|
<button className="admin-btn-icon" onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="asset-picker-loading">
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<p className="empty-hint">{error}</p>
|
||||||
|
) : (
|
||||||
|
<div className="order-editor-body">
|
||||||
|
{dropped > 0 && (
|
||||||
|
<p className="order-editor-notice">
|
||||||
|
{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.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="order-editor-section">
|
||||||
|
<div className="order-editor-section-head">
|
||||||
|
<h4>Pinned ({pinned.length})</h4>
|
||||||
|
{pinned.length > 0 && (
|
||||||
|
<button className="admin-btn admin-btn-sm" onClick={() => setPinnedIds([])}>
|
||||||
|
Unpin all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pinned.length === 0 ? (
|
||||||
|
<p className="empty-hint">
|
||||||
|
Nothing pinned yet — the album shows in Immich order. Pin the photos that should
|
||||||
|
open the album.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={pinnedIds} strategy={rectSortingStrategy}>
|
||||||
|
<div className="order-editor-grid">
|
||||||
|
{pinned.map((asset, i) => (
|
||||||
|
<SortableAssetTile
|
||||||
|
key={asset.id}
|
||||||
|
asset={asset}
|
||||||
|
index={i}
|
||||||
|
onUnpin={() => setPinnedIds(pinnedIds.filter((id) => id !== asset.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="order-editor-section">
|
||||||
|
<div className="order-editor-section-head">
|
||||||
|
<h4>Follows automatically ({unpinned.length})</h4>
|
||||||
|
{unpinned.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="admin-btn admin-btn-sm"
|
||||||
|
onClick={() => setPinnedIds([...pinnedIds, ...unpinned.map((a) => a.id)])}
|
||||||
|
>
|
||||||
|
Pin all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="empty-hint">
|
||||||
|
These follow the pinned photos in the album’s Immich order. Click one to pin it.
|
||||||
|
</p>
|
||||||
|
<div className="order-editor-grid">
|
||||||
|
{unpinned.map((asset) => (
|
||||||
|
<div
|
||||||
|
key={asset.id}
|
||||||
|
className="order-tile order-tile-unpinned"
|
||||||
|
title={asset.originalFileName}
|
||||||
|
onClick={() => setPinnedIds([...pinnedIds, asset.id])}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/api/admin/thumbnail/${asset.id}`}
|
||||||
|
alt={asset.originalFileName}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span className="order-tile-action">+</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="order-editor-footer">
|
||||||
|
<button className="admin-btn admin-btn-ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="admin-btn admin-btn-primary"
|
||||||
|
// Saving after a failed load would persist an empty or truncated
|
||||||
|
// order over the real one.
|
||||||
|
disabled={loading || !!error}
|
||||||
|
onClick={() => onSave(pinnedIds)}
|
||||||
|
>
|
||||||
|
Apply order
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { IconFolder, IconImage, IconStar } from './Icons';
|
||||||
|
|
||||||
interface AssetInfo {
|
interface AssetInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -126,20 +127,23 @@ export default function AssetPicker({
|
|||||||
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
|
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
|
||||||
onClick={() => setTab('album')}
|
onClick={() => setTab('album')}
|
||||||
>
|
>
|
||||||
📂 This Album
|
<IconFolder size={14} />
|
||||||
|
This Album
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
|
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
|
||||||
onClick={() => setTab('favorites')}
|
onClick={() => setTab('favorites')}
|
||||||
>
|
>
|
||||||
⭐ Favorites
|
<IconStar size={14} />
|
||||||
|
Favorites
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`}
|
className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`}
|
||||||
onClick={() => setTab('all')}
|
onClick={() => setTab('all')}
|
||||||
>
|
>
|
||||||
🖼️ All Photos
|
<IconImage size={14} />
|
||||||
|
All Photos
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -173,7 +177,9 @@ export default function AssetPicker({
|
|||||||
/>
|
/>
|
||||||
{isUsed && <div className="asset-picker-used-badge">✓</div>}
|
{isUsed && <div className="asset-picker-used-badge">✓</div>}
|
||||||
{asset.isFavorite && !isUsed && (
|
{asset.isFavorite && !isUsed && (
|
||||||
<div className="asset-picker-fav-badge">⭐</div>
|
<div className="asset-picker-fav-badge" title="Favorite in Immich">
|
||||||
|
<IconStar size={14} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||||
|
<path d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14l-5-4.87 6.91-1.01Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconImage({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||||
|
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||||
|
<circle cx="9" cy="9" r="2" />
|
||||||
|
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||||
@@ -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 (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconPlus({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||||
|
<path d="M5 12h14M12 5v14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconHome({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||||
|
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconCopy({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||||
|
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||||
|
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function IconQuote({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
export function IconQuote({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||||
|
|||||||
@@ -21,97 +21,43 @@ import {
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import AlbumPicker from './AlbumPicker';
|
import AlbumPicker from './AlbumPicker';
|
||||||
import AssetPicker from './AssetPicker';
|
import AssetPicker from './AssetPicker';
|
||||||
|
import AssetOrderEditor from './AssetOrderEditor';
|
||||||
import { EssayBlockEditor } from './EssayBlockEditor';
|
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 = {
|
* Labels for the per-album sort select. Order matches ALBUM_SORT_MODES.
|
||||||
Drag: () => (
|
*
|
||||||
<svg width="12" height="18" viewBox="0 0 12 18" fill="currentColor" className="svg-icon svg-drag">
|
* Plain text, no emoji: an <option> cannot contain markup, so the SVG set from
|
||||||
<circle cx="2" cy="2" r="1.5" />
|
* `./Icons` is not available here — and a decorative emoji is not a substitute
|
||||||
<circle cx="2" cy="9" r="1.5" />
|
* for it. The badge on the album card uses a real icon instead.
|
||||||
<circle cx="2" cy="16" r="1.5" />
|
*/
|
||||||
<circle cx="10" cy="2" r="1.5" />
|
const SORT_LABELS: Record<AlbumSortMode, string> = {
|
||||||
<circle cx="10" cy="9" r="1.5" />
|
immich: 'Immich order (default)',
|
||||||
<circle cx="10" cy="16" r="1.5" />
|
newest: 'Newest first',
|
||||||
</svg>
|
oldest: 'Oldest first',
|
||||||
),
|
filename: 'Filename',
|
||||||
Edit: () => (
|
manual: 'Manual',
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
|
|
||||||
<path d="M12 20h9" />
|
|
||||||
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Trash: () => (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
|
|
||||||
<path d="M3 6h18" />
|
|
||||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
|
||||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Close: () => (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
|
|
||||||
<path d="M18 6 6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Folder: () => (
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
|
|
||||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Camera: () => (
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
|
|
||||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
|
|
||||||
<circle cx="12" cy="13" r="3" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
ExternalLink: () => (
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
|
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
||||||
<polyline points="15 3 21 3 21 9" />
|
|
||||||
<line x1="10" y1="14" x2="21" y2="3" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Search: () => (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="m21 21-4.3-4.3" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Lock: () => (
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
|
|
||||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Plus: () => (
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
|
|
||||||
<path d="M5 12h14M12 5v14" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Home: () => (
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
|
|
||||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
||||||
<polyline points="9 22 9 12 15 12 15 22" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Copy: () => (
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
|
|
||||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
|
||||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Check: () => (
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
|
|
||||||
<polyline points="20 6 9 17 4 12" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Image: () => (
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
|
|
||||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
|
||||||
<circle cx="9" cy="9" r="2" />
|
|
||||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────
|
||||||
@@ -121,6 +67,9 @@ interface AlbumEntry {
|
|||||||
description?: string;
|
description?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
heroImage?: string;
|
heroImage?: string;
|
||||||
|
sort?: AlbumSortMode;
|
||||||
|
/** Pinned asset UUIDs for `sort: manual`; everything else follows automatically. */
|
||||||
|
assetOrder?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Section {
|
interface Section {
|
||||||
@@ -166,17 +115,27 @@ interface ImmichAlbumInfo {
|
|||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function parseAlbumEntries(
|
type RawAlbumEntry = string | Record<string, string | AlbumEntryObject>;
|
||||||
raw:
|
|
||||||
| Array<
|
/**
|
||||||
| string
|
* Whether an entry carries anything beyond its ID.
|
||||||
| Record<
|
*
|
||||||
string,
|
* Both collapse rules below depend on this, and they are the reason every new
|
||||||
string | { title: string; description?: string; password?: string; heroImage?: string }
|
* 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.
|
||||||
| undefined,
|
*/
|
||||||
): AlbumEntry[] {
|
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 [];
|
if (!raw) return [];
|
||||||
return raw.map((entry) => {
|
return raw.map((entry) => {
|
||||||
if (typeof entry === 'string') return { id: entry };
|
if (typeof entry === 'string') return { id: entry };
|
||||||
@@ -188,27 +147,29 @@ function parseAlbumEntries(
|
|||||||
description: value.description,
|
description: value.description,
|
||||||
password: value.password,
|
password: value.password,
|
||||||
heroImage: value.heroImage,
|
heroImage: value.heroImage,
|
||||||
|
sort: isAlbumSortMode(value.sort) ? value.sort : undefined,
|
||||||
|
assetOrder: value.assetOrder,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeAlbumEntries(
|
function serializeAlbumEntries(entries: AlbumEntry[]): RawAlbumEntry[] {
|
||||||
entries: AlbumEntry[],
|
|
||||||
): Array<
|
|
||||||
| string
|
|
||||||
| Record<string, string | { title: string; description?: string; password?: string; heroImage?: string }>
|
|
||||||
> {
|
|
||||||
return entries.map((entry) => {
|
return entries.map((entry) => {
|
||||||
if (!entry.title && !entry.description && !entry.password && !entry.heroImage) return entry.id;
|
const extras = hasAlbumOptions(entry);
|
||||||
if (entry.title && !entry.description && !entry.password && !entry.heroImage) {
|
if (!entry.title && !extras) return entry.id;
|
||||||
return { [entry.id]: entry.title };
|
if (entry.title && !extras) return { [entry.id]: entry.title };
|
||||||
}
|
|
||||||
const val: { title: string; description?: string; password?: string; heroImage?: string } = {
|
const val: AlbumEntryObject = {};
|
||||||
title: entry.title || '',
|
// 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.description) val.description = entry.description;
|
||||||
if (entry.password) val.password = entry.password;
|
if (entry.password) val.password = entry.password;
|
||||||
if (entry.heroImage) val.heroImage = entry.heroImage;
|
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 };
|
return { [entry.id]: val };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -238,11 +199,11 @@ function SortableHeroTile({
|
|||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} className="hero-tile" {...attributes}>
|
<div ref={setNodeRef} style={style} className="hero-tile" {...attributes}>
|
||||||
<div className="hero-tile-drag" {...listeners} title="Drag to reorder">
|
<div className="hero-tile-drag" {...listeners} title="Drag to reorder">
|
||||||
<Icons.Drag />
|
<IconGripVertical size={18} className="svg-icon svg-drag" />
|
||||||
</div>
|
</div>
|
||||||
<img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" />
|
<img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" />
|
||||||
<button className="hero-tile-remove" onClick={onRemove} title="Remove">
|
<button className="hero-tile-remove" onClick={onRemove} title="Remove">
|
||||||
<Icons.Close />
|
<IconX size={14} />
|
||||||
</button>
|
</button>
|
||||||
<span className="hero-tile-index">{index + 1}</span>
|
<span className="hero-tile-index">{index + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +295,7 @@ function SortableSubpageTile({
|
|||||||
{...attributes}
|
{...attributes}
|
||||||
>
|
>
|
||||||
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
|
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
|
||||||
<Icons.Drag />
|
<IconGripVertical size={18} className="svg-icon svg-drag" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sp.enabled === false && (
|
{sp.enabled === false && (
|
||||||
@@ -345,7 +306,7 @@ function SortableSubpageTile({
|
|||||||
|
|
||||||
{sp.password && (
|
{sp.password && (
|
||||||
<span className="subpage-badge-protected" title="Password protected">
|
<span className="subpage-badge-protected" title="Password protected">
|
||||||
<Icons.Lock /> Password
|
<IconLock size={12} /> Password
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -354,11 +315,11 @@ function SortableSubpageTile({
|
|||||||
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
|
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<div className="subpage-tile-placeholder">
|
<div className="subpage-tile-placeholder">
|
||||||
<Icons.Folder />
|
<IconFolder />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="subpage-hover-overlay">
|
<div className="subpage-hover-overlay">
|
||||||
<span className="hover-action-btn"><Icons.Edit /> Edit Page</span>
|
<span className="hover-action-btn"><IconPencil size={14} /> Edit Page</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="subpage-tile-info">
|
<div className="subpage-tile-info">
|
||||||
@@ -367,7 +328,7 @@ function SortableSubpageTile({
|
|||||||
<span className="subpage-tile-slug">/{slug}</span>
|
<span className="subpage-tile-slug">/{slug}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="subpage-tile-meta">
|
<span className="subpage-tile-meta">
|
||||||
<Icons.Folder /> {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
|
<IconFolder /> {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,6 +357,12 @@ export default function PageBuilder() {
|
|||||||
currentAssetIds?: string[];
|
currentAssetIds?: string[];
|
||||||
title?: string;
|
title?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [orderEditorTarget, setOrderEditorTarget] = useState<{
|
||||||
|
albumId: string;
|
||||||
|
albumName: string;
|
||||||
|
assetOrder: string[];
|
||||||
|
onSave: (assetOrder: string[]) => void;
|
||||||
|
} | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [editingAlbumAddress, setEditingAlbumAddress] = useState<ActiveEditAlbumAddress | null>(null);
|
const [editingAlbumAddress, setEditingAlbumAddress] = useState<ActiveEditAlbumAddress | null>(null);
|
||||||
|
|
||||||
@@ -929,7 +896,7 @@ export default function PageBuilder() {
|
|||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="builder-search-container">
|
<div className="builder-search-container">
|
||||||
<div className="builder-search-wrapper">
|
<div className="builder-search-wrapper">
|
||||||
<span className="builder-search-icon"><Icons.Search /></span>
|
<span className="builder-search-icon"><IconSearch size={14} /></span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="builder-search-input"
|
className="builder-search-input"
|
||||||
@@ -948,7 +915,7 @@ export default function PageBuilder() {
|
|||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="builder-section">
|
<section className="builder-section">
|
||||||
<div className="builder-section-header">
|
<div className="builder-section-header">
|
||||||
<h2><Icons.Home /> Homepage Hero</h2>
|
<h2><IconHome /> Homepage Hero</h2>
|
||||||
<button
|
<button
|
||||||
className="admin-btn admin-btn-sm"
|
className="admin-btn admin-btn-sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -959,7 +926,7 @@ export default function PageBuilder() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icons.Plus /> Add Hero
|
<IconPlus size={14} /> Add Hero
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -993,7 +960,10 @@ export default function PageBuilder() {
|
|||||||
{/* Standalone Albums */}
|
{/* Standalone Albums */}
|
||||||
<section className="builder-section">
|
<section className="builder-section">
|
||||||
<div className="builder-section-header">
|
<div className="builder-section-header">
|
||||||
<h2>📷 Standalone Albums</h2>
|
<h2>
|
||||||
|
<IconCamera />
|
||||||
|
Standalone Albums
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
className="admin-btn admin-btn-sm"
|
className="admin-btn admin-btn-sm"
|
||||||
onClick={() => setPickerTarget({ type: 'standalone' })}
|
onClick={() => setPickerTarget({ type: 'standalone' })}
|
||||||
@@ -1042,7 +1012,10 @@ export default function PageBuilder() {
|
|||||||
{/* Subpages */}
|
{/* Subpages */}
|
||||||
<section className="builder-section">
|
<section className="builder-section">
|
||||||
<div className="builder-section-header">
|
<div className="builder-section-header">
|
||||||
<h2>📂 Subpages</h2>
|
<h2>
|
||||||
|
<IconFolder />
|
||||||
|
Subpages
|
||||||
|
</h2>
|
||||||
<button className="admin-btn admin-btn-sm" onClick={addSubpage}>
|
<button className="admin-btn admin-btn-sm" onClick={addSubpage}>
|
||||||
+ New Subpage
|
+ New Subpage
|
||||||
</button>
|
</button>
|
||||||
@@ -1107,7 +1080,7 @@ export default function PageBuilder() {
|
|||||||
onClick={() => setDrawerMode('edit')}
|
onClick={() => setDrawerMode('edit')}
|
||||||
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
|
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
|
||||||
>
|
>
|
||||||
<Icons.Edit /> Edit
|
<IconPencil size={14} /> Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1115,7 +1088,7 @@ export default function PageBuilder() {
|
|||||||
onClick={() => setDrawerMode('preview')}
|
onClick={() => setDrawerMode('preview')}
|
||||||
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
|
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
|
||||||
>
|
>
|
||||||
<Icons.Search /> Live Preview
|
<IconSearch size={14} /> Live Preview
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1162,7 +1135,7 @@ export default function PageBuilder() {
|
|||||||
{thumb ? (
|
{thumb ? (
|
||||||
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
|
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
|
||||||
) : (
|
) : (
|
||||||
<div className="subpage-tile-placeholder"><Icons.Folder /></div>
|
<div className="subpage-tile-placeholder"><IconFolder /></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="preview-album-title">
|
<span className="preview-album-title">
|
||||||
@@ -1186,7 +1159,7 @@ export default function PageBuilder() {
|
|||||||
{thumb ? (
|
{thumb ? (
|
||||||
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
|
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
|
||||||
) : (
|
) : (
|
||||||
<div className="subpage-tile-placeholder"><Icons.Folder /></div>
|
<div className="subpage-tile-placeholder"><IconFolder /></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="preview-album-title">
|
<span className="preview-album-title">
|
||||||
@@ -1242,7 +1215,7 @@ export default function PageBuilder() {
|
|||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
<label>Password Protection (optional)</label>
|
<label>Password Protection (optional)</label>
|
||||||
<div className="password-input-wrapper">
|
<div className="password-input-wrapper">
|
||||||
<span className="password-icon"><Icons.Lock /></span>
|
<span className="password-icon"><IconLock size={12} /></span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={sp.password || ''}
|
value={sp.password || ''}
|
||||||
@@ -1289,12 +1262,17 @@ export default function PageBuilder() {
|
|||||||
}}
|
}}
|
||||||
style={{ padding: '8px 12px', borderRadius: '6px', width: '100%', fontSize: '0.9rem' }}
|
style={{ padding: '8px 12px', borderRadius: '6px', width: '100%', fontSize: '0.9rem' }}
|
||||||
>
|
>
|
||||||
<option value="masonry">📷 Masonry Grid (Dynamic Height)</option>
|
{/*
|
||||||
<option value="uniform">🔲 Uniform Grid (Square Tiles)</option>
|
Plain text, no emoji: an <option> cannot contain markup, so the
|
||||||
<option value="showcase">⭐ Showcase (Featured First Asset)</option>
|
SVG icon set is not available here — and a decorative emoji is
|
||||||
<option value="filmstrip">🎞️ Filmstrip (Horizontal Scroll)</option>
|
not a substitute for it.
|
||||||
<option value="editorial-flow">📰 Editorial Flow</option>
|
*/}
|
||||||
<option value="essay">📖 Photo Essay Mode (Storytelling Editor)</option>
|
<option value="masonry">Masonry Grid (Dynamic Height)</option>
|
||||||
|
<option value="uniform">Uniform Grid (Square Tiles)</option>
|
||||||
|
<option value="showcase">Showcase (Featured First Asset)</option>
|
||||||
|
<option value="filmstrip">Filmstrip (Horizontal Scroll)</option>
|
||||||
|
<option value="editorial-flow">Editorial Flow</option>
|
||||||
|
<option value="essay">Photo Essay Mode (Storytelling Editor)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1395,7 +1373,7 @@ export default function PageBuilder() {
|
|||||||
onClick={() => removeSection(spIndex, secIndex)}
|
onClick={() => removeSection(spIndex, secIndex)}
|
||||||
title="Remove section"
|
title="Remove section"
|
||||||
>
|
>
|
||||||
<Icons.Close />
|
<IconX size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
@@ -1465,7 +1443,7 @@ export default function PageBuilder() {
|
|||||||
setExpandedSubpage(null);
|
setExpandedSubpage(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icons.Trash /> Delete Subpage
|
<IconTrash size={14} /> Delete Subpage
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||||
@@ -1505,14 +1483,14 @@ export default function PageBuilder() {
|
|||||||
<div className="album-drawer" onClick={(e) => e.stopPropagation()}>
|
<div className="album-drawer" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="album-drawer-header">
|
<div className="album-drawer-header">
|
||||||
<h3>
|
<h3>
|
||||||
<Icons.Camera /> Edit Album Details
|
<IconCamera /> Edit Album Details
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
className="admin-btn-icon"
|
className="admin-btn-icon"
|
||||||
onClick={() => setEditingAlbumAddress(null)}
|
onClick={() => setEditingAlbumAddress(null)}
|
||||||
title="Close details"
|
title="Close details"
|
||||||
>
|
>
|
||||||
<Icons.Close />
|
<IconX size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="album-drawer-body">
|
<div className="album-drawer-body">
|
||||||
@@ -1523,7 +1501,7 @@ export default function PageBuilder() {
|
|||||||
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
|
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<div className="modal-cover-placeholder">
|
<div className="modal-cover-placeholder">
|
||||||
<Icons.Camera />
|
<IconCamera />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1562,7 +1540,7 @@ export default function PageBuilder() {
|
|||||||
}}
|
}}
|
||||||
title="Copy UUID"
|
title="Copy UUID"
|
||||||
>
|
>
|
||||||
<Icons.Copy />
|
<IconCopy size={12} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1613,7 +1591,7 @@ export default function PageBuilder() {
|
|||||||
onClick={() => onUpdate({ heroImage: undefined })}
|
onClick={() => onUpdate({ heroImage: undefined })}
|
||||||
title="Remove custom hero image"
|
title="Remove custom hero image"
|
||||||
>
|
>
|
||||||
<Icons.Close />
|
<IconX size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1633,10 +1611,64 @@ export default function PageBuilder() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Icons.Image /> {album.heroImage ? 'Change Image' : 'Pick Hero Image'}
|
<IconImage size={12} /> {album.heroImage ? 'Change Image' : 'Pick Hero Image'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Photo order */}
|
||||||
|
<div className="admin-field">
|
||||||
|
<label>Photo order</label>
|
||||||
|
<select
|
||||||
|
value={album.sort || DEFAULT_ALBUM_SORT}
|
||||||
|
onChange={(e) =>
|
||||||
|
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) => (
|
||||||
|
<option key={mode} value={mode}>
|
||||||
|
{SORT_LABELS[mode]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="admin-field-hint">
|
||||||
|
{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.'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{album.sort === 'manual' && (
|
||||||
|
<div style={{ marginTop: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
className="admin-btn admin-btn-sm"
|
||||||
|
onClick={() =>
|
||||||
|
setOrderEditorTarget({
|
||||||
|
albumId: album.id,
|
||||||
|
albumName: album.title || name,
|
||||||
|
assetOrder: album.assetOrder || [],
|
||||||
|
onSave: (assetOrder) => {
|
||||||
|
onUpdate({ assetOrder });
|
||||||
|
setOrderEditorTarget(null);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconGripVertical size={18} className="svg-icon svg-drag" />{' '}
|
||||||
|
{album.assetOrder?.length
|
||||||
|
? `Reorder photos (${album.assetOrder.length} pinned)`
|
||||||
|
: 'Reorder photos'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="album-drawer-footer">
|
<div className="album-drawer-footer">
|
||||||
@@ -1645,7 +1677,7 @@ export default function PageBuilder() {
|
|||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
style={{ maxWidth: '160px' }}
|
style={{ maxWidth: '160px' }}
|
||||||
>
|
>
|
||||||
<Icons.Trash /> Remove Album
|
<IconTrash size={14} /> Remove Album
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="admin-btn admin-btn-primary"
|
className="admin-btn admin-btn-primary"
|
||||||
@@ -1670,6 +1702,17 @@ export default function PageBuilder() {
|
|||||||
title={heroPickerTarget.title}
|
title={heroPickerTarget.title}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Manual Photo Order Editor */}
|
||||||
|
{orderEditorTarget && (
|
||||||
|
<AssetOrderEditor
|
||||||
|
albumId={orderEditorTarget.albumId}
|
||||||
|
albumName={orderEditorTarget.albumName}
|
||||||
|
assetOrder={orderEditorTarget.assetOrder}
|
||||||
|
onSave={orderEditorTarget.onSave}
|
||||||
|
onClose={() => setOrderEditorTarget(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1797,14 +1840,14 @@ function AlbumCard({
|
|||||||
<div className="album-tile-cover">
|
<div className="album-tile-cover">
|
||||||
{dragListeners && (
|
{dragListeners && (
|
||||||
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder">
|
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder">
|
||||||
<Icons.Drag />
|
<IconGripVertical size={18} className="svg-icon svg-drag" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{heroThumb ? (
|
{heroThumb ? (
|
||||||
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
|
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<div className="album-tile-placeholder">
|
<div className="album-tile-placeholder">
|
||||||
<Icons.Camera />
|
<IconCamera />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="album-tile-overlay">
|
<div className="album-tile-overlay">
|
||||||
@@ -1813,14 +1856,14 @@ function AlbumCard({
|
|||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
title="Edit details"
|
title="Edit details"
|
||||||
>
|
>
|
||||||
<Icons.Edit />
|
<IconPencil size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="album-tile-btn album-tile-btn-danger"
|
className="album-tile-btn album-tile-btn-danger"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
title="Remove album"
|
title="Remove album"
|
||||||
>
|
>
|
||||||
<Icons.Trash />
|
<IconTrash size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1832,12 +1875,17 @@ function AlbumCard({
|
|||||||
<div className="album-tile-badges">
|
<div className="album-tile-badges">
|
||||||
{hasPassword && (
|
{hasPassword && (
|
||||||
<span className="badge badge-password" title="Password protected">
|
<span className="badge badge-password" title="Password protected">
|
||||||
<Icons.Lock />
|
<IconLock size={12} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{album.heroImage && (
|
{album.heroImage && (
|
||||||
<span className="badge badge-hero" title="Custom Hero Image set">
|
<span className="badge badge-hero" title="Custom Hero Image set">
|
||||||
<Icons.Image />
|
<IconImage size={12} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{album.sort && album.sort !== DEFAULT_ALBUM_SORT && (
|
||||||
|
<span className="badge badge-sort" title={`Photo order: ${SORT_LABELS[album.sort]}`}>
|
||||||
|
{album.sort === 'manual' ? <IconGripVertical size={18} className="svg-icon svg-drag" /> : album.sort}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||||
import { getConfig } from '@/lib/config';
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { immich } from '@/lib/immich';
|
||||||
import { isUuid } from '@/lib/uuid';
|
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(
|
export async function GET(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ albumId: string }> },
|
{ params }: { params: Promise<{ albumId: string }> },
|
||||||
) {
|
) {
|
||||||
if (!isAdminEnabled()) {
|
if (!isAdminEnabled()) {
|
||||||
@@ -42,27 +33,27 @@ export async function GET(
|
|||||||
return NextResponse.json({ error: 'Invalid album ID' }, { status: 400 });
|
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 {
|
try {
|
||||||
const res = await fetch(`${config.immich.apiUrl}/albums/${encodeURIComponent(albumId)}`, {
|
// Via the client, not a bare fetch of `GET /albums/:id`: Immich 3.x no
|
||||||
headers: {
|
// longer embeds assets in that response, so reading `album.assets` returned
|
||||||
'x-api-key': config.immich.apiKey,
|
// nothing (and threw outright when the key was absent). getAlbumAssetsRaw()
|
||||||
Accept: 'application/json',
|
// 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) {
|
const assets = all
|
||||||
return NextResponse.json({ error: `Immich API returned ${res.status}` }, { status: 502 });
|
.filter((a) => a.type === 'IMAGE' || (includeVideos && a.type === 'VIDEO'))
|
||||||
}
|
|
||||||
|
|
||||||
const album = (await res.json()) as ImmichAlbumDetail;
|
|
||||||
|
|
||||||
const assets = album.assets
|
|
||||||
.filter((a) => a.type === 'IMAGE')
|
|
||||||
.map((a) => ({
|
.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
|
type: a.type,
|
||||||
originalFileName: a.originalFileName,
|
originalFileName: a.originalFileName,
|
||||||
fileCreatedAt: a.fileCreatedAt,
|
fileCreatedAt: a.fileCreatedAt,
|
||||||
isFavorite: a.isFavorite,
|
isFavorite: a.isFavorite ?? false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json({ assets, nextPage: null });
|
return NextResponse.json({ assets, nextPage: null });
|
||||||
|
|||||||
@@ -8,9 +8,39 @@ hero:
|
|||||||
- "your-asset-uuid-2"
|
- "your-asset-uuid-2"
|
||||||
|
|
||||||
# Standalone albums (shown on the home page)
|
# 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:
|
albums:
|
||||||
- "your-album-uuid-1"
|
- "your-album-uuid-1" # no sort key → Immich order, exactly as before
|
||||||
- "your-album-uuid-2"
|
- "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: group albums under custom URL paths
|
||||||
subpages:
|
subpages:
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ Each section can have:
|
|||||||
| `description` | string | ➖ | Optional subline under the heading |
|
| `description` | string | ➖ | Optional subline under the heading |
|
||||||
| `albums` | list | ✅ | Album UUIDs (same format as regular albums) |
|
| `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
|
```yaml
|
||||||
albums:
|
albums:
|
||||||
@@ -103,6 +103,8 @@ albums:
|
|||||||
- 'album-uuid':
|
- 'album-uuid':
|
||||||
title: My Private Title
|
title: My Private Title
|
||||||
password: 'secure-password' # → adds a password gate to this specific album
|
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:**
|
**Full example:**
|
||||||
@@ -133,6 +135,47 @@ subpages:
|
|||||||
> [!NOTE]
|
> [!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.
|
> 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
|
## Grid Layout
|
||||||
|
|
||||||
Configure the photo grid globally or per-subpage:
|
Configure the photo grid globally or per-subpage:
|
||||||
|
|||||||
211
lib/__tests__/albumSort.test.ts
Normal file
211
lib/__tests__/albumSort.test.ts
Normal file
@@ -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> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,6 +76,27 @@ describe('gallery.yaml per-album heroImage', () => {
|
|||||||
expect(getConfig().albumHeroImages[ALBUM_ID]).toBe(HERO_ID);
|
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
|
// 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.
|
// 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 () => {
|
it('real validateUuid warns and substitutes rather than throwing', async () => {
|
||||||
|
|||||||
@@ -104,6 +104,77 @@ describe('deriveGallery accepts valid structures', () => {
|
|||||||
expect(result.albumHeroImages[A]).toBe(B);
|
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', () => {
|
it('parses enabled property on subpages correctly', () => {
|
||||||
const result = deriveGallery({
|
const result = deriveGallery({
|
||||||
subpages: [
|
subpages: [
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
import { immich, ImmichUnavailableError } from '../immich';
|
import { immich, ImmichUnavailableError } from '../immich';
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import { cache } from '../cache';
|
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<string, AlbumSortMode> = {};
|
||||||
|
const albumManualOrders: Record<string, string[]> = {};
|
||||||
|
|
||||||
// Mock the config by wrapping it in a factory
|
// Mock the config by wrapping it in a factory
|
||||||
vi.mock('../config', async () => {
|
vi.mock('../config', async () => {
|
||||||
@@ -16,6 +22,8 @@ vi.mock('../config', async () => {
|
|||||||
subpages: [],
|
subpages: [],
|
||||||
albumOverrides: { 'album-1': 'Override Name' },
|
albumOverrides: { 'album-1': 'Override Name' },
|
||||||
albumDescriptions: {},
|
albumDescriptions: {},
|
||||||
|
albumSortModes,
|
||||||
|
albumManualOrders,
|
||||||
// Non-zero: a 0 TTL expires an entry in the same millisecond it is
|
// Non-zero: a 0 TTL expires an entry in the same millisecond it is
|
||||||
// written, which would make any caching assertion flaky. Isolation comes
|
// written, which would make any caching assertion flaky. Isolation comes
|
||||||
// from the cache.clear() in beforeEach, not from an unusable TTL.
|
// 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
|
// getAlbum() memoises into the module-level LRU; without this, later tests
|
||||||
// read a previous test's album and never reach the mocked fetch.
|
// read a previous test's album and never reach the mocked fetch.
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
for (const key of Object.keys(albumSortModes)) delete albumSortModes[key];
|
||||||
|
for (const key of Object.keys(albumManualOrders)) delete albumManualOrders[key];
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('request()', () => {
|
describe('request()', () => {
|
||||||
@@ -579,6 +589,69 @@ describe('ImmichClient', () => {
|
|||||||
const album = await immich.getAlbum('album-1');
|
const album = await immich.getAlbum('album-1');
|
||||||
expect(album?.assets.map((a) => a.id)).toEqual(['frame-1', 'frame-2', 'frame-3']);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
148
lib/albumSort.ts
Normal file
148
lib/albumSort.ts
Normal file
@@ -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<T extends SortableAsset>(
|
||||||
|
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<string, number>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ import { env } from '../env';
|
|||||||
import { resolveAuthSecret } from '../secret';
|
import { resolveAuthSecret } from '../secret';
|
||||||
import { loadYaml, clearYamlCache, validateUuid } from './parser';
|
import { loadYaml, clearYamlCache, validateUuid } from './parser';
|
||||||
import { resolveTheme, VALID_LAYOUTS } from './theme';
|
import { resolveTheme, VALID_LAYOUTS } from './theme';
|
||||||
|
import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
|
||||||
import {
|
import {
|
||||||
slugify,
|
slugify,
|
||||||
AppConfig,
|
AppConfig,
|
||||||
|
AlbumEntryObject,
|
||||||
SubpageConfig,
|
SubpageConfig,
|
||||||
SubpageSectionConfig,
|
SubpageSectionConfig,
|
||||||
SubpageObjectValue,
|
SubpageObjectValue,
|
||||||
@@ -82,6 +84,8 @@ export interface GalleryDerivation {
|
|||||||
albumDescriptions: Record<string, string>;
|
albumDescriptions: Record<string, string>;
|
||||||
albumPasswords: Record<string, string>;
|
albumPasswords: Record<string, string>;
|
||||||
albumHeroImages: Record<string, string>;
|
albumHeroImages: Record<string, string>;
|
||||||
|
albumSortModes: Record<string, AlbumSortMode>;
|
||||||
|
albumManualOrders: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,14 +103,11 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
|||||||
const albumDescriptions: Record<string, string> = {};
|
const albumDescriptions: Record<string, string> = {};
|
||||||
const albumPasswords: Record<string, string> = {};
|
const albumPasswords: Record<string, string> = {};
|
||||||
const albumHeroImages: Record<string, string> = {};
|
const albumHeroImages: Record<string, string> = {};
|
||||||
|
const albumSortModes: Record<string, AlbumSortMode> = {};
|
||||||
|
const albumManualOrders: Record<string, string[]> = {};
|
||||||
|
|
||||||
function processAlbumEntry(
|
function processAlbumEntry(
|
||||||
entry:
|
entry: string | Record<string, string | AlbumEntryObject>,
|
||||||
| string
|
|
||||||
| Record<
|
|
||||||
string,
|
|
||||||
string | { title: string; description?: string; password?: string; heroImage?: string }
|
|
||||||
>,
|
|
||||||
context: string,
|
context: string,
|
||||||
): string {
|
): string {
|
||||||
if (typeof entry === 'string') {
|
if (typeof entry === 'string') {
|
||||||
@@ -133,6 +134,31 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
|||||||
`${context} heroImage for album ${validatedUuid}`,
|
`${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;
|
return validatedUuid;
|
||||||
}
|
}
|
||||||
@@ -240,6 +266,8 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
|||||||
albumDescriptions,
|
albumDescriptions,
|
||||||
albumPasswords,
|
albumPasswords,
|
||||||
albumHeroImages,
|
albumHeroImages,
|
||||||
|
albumSortModes,
|
||||||
|
albumManualOrders,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +315,8 @@ export function getConfig(): AppConfig {
|
|||||||
albumDescriptions: {},
|
albumDescriptions: {},
|
||||||
albumPasswords: {},
|
albumPasswords: {},
|
||||||
albumHeroImages: {},
|
albumHeroImages: {},
|
||||||
|
albumSortModes: {},
|
||||||
|
albumManualOrders: {},
|
||||||
cacheTtl: env.CACHE_TTL * 1000,
|
cacheTtl: env.CACHE_TTL * 1000,
|
||||||
staleMaxAge: env.STALE_MAX_AGE * 1000,
|
staleMaxAge: env.STALE_MAX_AGE * 1000,
|
||||||
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
|
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
|
||||||
@@ -306,6 +336,8 @@ export function getConfig(): AppConfig {
|
|||||||
albumDescriptions,
|
albumDescriptions,
|
||||||
albumPasswords,
|
albumPasswords,
|
||||||
albumHeroImages,
|
albumHeroImages,
|
||||||
|
albumSortModes,
|
||||||
|
albumManualOrders,
|
||||||
} = deriveGallery(gallery);
|
} = deriveGallery(gallery);
|
||||||
|
|
||||||
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
|
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
|
||||||
@@ -378,6 +410,8 @@ export function getConfig(): AppConfig {
|
|||||||
albumDescriptions,
|
albumDescriptions,
|
||||||
albumPasswords,
|
albumPasswords,
|
||||||
albumHeroImages,
|
albumHeroImages,
|
||||||
|
albumSortModes,
|
||||||
|
albumManualOrders,
|
||||||
cacheTtl: env.CACHE_TTL * 1000,
|
cacheTtl: env.CACHE_TTL * 1000,
|
||||||
staleMaxAge: env.STALE_MAX_AGE * 1000,
|
staleMaxAge: env.STALE_MAX_AGE * 1000,
|
||||||
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
|
immichTimeoutMs: env.IMMICH_TIMEOUT_MS,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { AlbumSortMode } from '../albumSort';
|
||||||
|
|
||||||
export interface SubpageSectionConfig {
|
export interface SubpageSectionConfig {
|
||||||
title: string;
|
title: string;
|
||||||
slug: string; // auto-derived from title, used as anchor
|
slug: string; // auto-derived from title, used as anchor
|
||||||
@@ -29,15 +31,14 @@ export interface SubpageObjectValue {
|
|||||||
essayFile?: string;
|
essayFile?: string;
|
||||||
essayText?: string;
|
essayText?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
albums?: Array<
|
// Both album lists reuse AlbumEntryObject rather than inlining the shape:
|
||||||
string | Record<string, string | { title: string; description?: string; password?: string }>
|
// the inline copies had already drifted (they never gained `heroImage`), and
|
||||||
>;
|
// this path is live for map-style subpages.
|
||||||
|
albums?: Array<string | Record<string, string | AlbumEntryObject>>;
|
||||||
sections?: Array<{
|
sections?: Array<{
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
albums: Array<
|
albums: Array<string | Record<string, string | AlbumEntryObject>>;
|
||||||
string | Record<string, string | { title: string; description?: string; password?: string }>
|
|
||||||
>;
|
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +113,10 @@ export interface AppConfig {
|
|||||||
albumDescriptions: Record<string, string>;
|
albumDescriptions: Record<string, string>;
|
||||||
albumPasswords: Record<string, string>;
|
albumPasswords: Record<string, string>;
|
||||||
albumHeroImages: Record<string, string>;
|
albumHeroImages: Record<string, string>;
|
||||||
|
/** Per-album sort mode. Absent means `immich` — the album's own order. */
|
||||||
|
albumSortModes: Record<string, AlbumSortMode>;
|
||||||
|
/** Pinned asset UUIDs per album for `sort: manual`, in display order. */
|
||||||
|
albumManualOrders: Record<string, string[]>;
|
||||||
cacheTtl: number;
|
cacheTtl: number;
|
||||||
/** How long (ms) an expired cache entry may still be served while Immich is unreachable. */
|
/** How long (ms) an expired cache entry may still be served while Immich is unreachable. */
|
||||||
staleMaxAge: number;
|
staleMaxAge: number;
|
||||||
@@ -133,10 +138,18 @@ export interface AppConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AlbumEntryObject {
|
export interface AlbumEntryObject {
|
||||||
title: string;
|
/** Optional: an entry may carry only a sort mode and no title override. */
|
||||||
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
heroImage?: 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 {
|
export interface GalleryYaml {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
import { getConfig, slugify, type SubpageConfig } from './config';
|
import { getConfig, slugify, type SubpageConfig } from './config';
|
||||||
import { cache } from './cache';
|
import { cache } from './cache';
|
||||||
|
import { compareByCaptureTime, sortAlbumAssets, DEFAULT_ALBUM_SORT } from './albumSort';
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@ export interface ImmichAsset {
|
|||||||
localDateTime?: string;
|
localDateTime?: string;
|
||||||
exifInfo?: ImmichExifInfo;
|
exifInfo?: ImmichExifInfo;
|
||||||
isTrashed: boolean;
|
isTrashed: boolean;
|
||||||
|
/** Only surfaced in the admin pickers. Optional: older Immich responses omit it. */
|
||||||
|
isFavorite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImmichExifInfo {
|
export interface ImmichExifInfo {
|
||||||
@@ -544,7 +547,61 @@ class ImmichClient {
|
|||||||
return assets;
|
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<ImmichAsset[]> {
|
||||||
|
const [album, assets] = await Promise.all([
|
||||||
|
this.request<ImmichAlbum>(`/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<ImmichAlbum | null> {
|
async getAlbum(albumId: string, forceFresh = false): Promise<ImmichAlbum | null> {
|
||||||
|
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<ImmichAlbum | null> {
|
||||||
// Security: only serve configured albums
|
// Security: only serve configured albums
|
||||||
if (!this.config.albums.includes(albumId)) {
|
if (!this.config.albums.includes(albumId)) {
|
||||||
console.warn(`[Immich] Album ${albumId} is not in LIGHTBOX_ALBUMS`);
|
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
|
// order. Metadata search happens to default to fileCreatedAt desc, but
|
||||||
// that is not contractual — sort explicitly so 'asc' albums are right.
|
// that is not contractual — sort explicitly so 'asc' albums are right.
|
||||||
//
|
//
|
||||||
// Mirrors Immich's own timeline query: primary key `localDateTime`
|
// This is the *canonical* order, and it is what goes into the cache.
|
||||||
// (capture time in the photographer's local zone), tie-broken by
|
// A configured per-album sort is applied later, on the way out of
|
||||||
// `fileCreatedAt` (the same instant in UTC). The two only diverge for
|
// getAlbum(); see withSort().
|
||||||
// albums spanning time zones, and there local time is what the Immich
|
album.assets.sort(compareByCaptureTime(album.order === 'asc' ? 1 : -1));
|
||||||
// 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)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const name = this.config.albumOverrides[album.id] ?? album.albumName;
|
const name = this.config.albumOverrides[album.id] ?? album.albumName;
|
||||||
const description = this.config.albumDescriptions[album.id] ?? album.description ?? '';
|
const description = this.config.albumDescriptions[album.id] ?? album.description ?? '';
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export function encodeAssetId(assetId: string): string {
|
|||||||
* Returns null if the token is invalid.
|
* Returns null if the token is invalid.
|
||||||
*/
|
*/
|
||||||
export function decodeAssetId(token: string): string | null {
|
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 {
|
try {
|
||||||
const key = getKey();
|
const key = getKey();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user