release: v0.20.1

This commit is contained in:
Ralksta
2026-10-04 09:49:35 +02:00
committed by GitHub
parent 5f831f719a
commit 9270546c9d
17 changed files with 189 additions and 47 deletions

View File

@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Releases up to and including v0.9.2 are documented in the
[GitHub releases](https://github.com/ralksta/immich-folio/releases).
## [0.20.1] — 2026-10-04
### Changed
- **A single click zooms the lightbox photo in by 1.5×**
([#835](https://github.com/ralksta/immich-folio/pull/835)). The first
version of zoom needed a double-click and went straight to 1:1, which was
more than visitors wanted when they only wanted a closer look. A click now
zooms to 1.5× around the cursor and a second click returns to fit. A press
that moves more than 10 px or is held longer than half a second is a pan, so
dragging a zoomed photo no longer drops out of the zoom. Ctrl and wheel,
pinch, touch double-tap, the magnifier button (still 1:1) and `+`, `-`, `0`
and `Esc` work as before.
### Fixed
- **Photo zoom and originals download need the `asset.download` permission**
on the Immich API key, which the setup guide never said. Zoom into a JPEG or
AVIF, the single download and both ZIPs stream `/assets/:id/original`, and
Immich answers `403` to a key that only has `asset.read` and `asset.view`.
The README permission table (which also credited `asset.read` with
originals), the photo zoom and originals download sections of the gallery
guide and the setup wizard hint now name it, and the log says so when a
`403` hits an original. Nothing changes for a site that uses neither
feature. Reported by a user who had to widen their key after enabling zoom.
## [0.20.0] — 2026-10-02
### Security

View File

@@ -238,10 +238,13 @@ PROOFING_WEBHOOK_URL=https://… # notify on submitted client proofing s
Create a dedicated API key in Immich under **Account Settings → API Keys**. Immich Folio only needs **read access** — it never modifies your library.
| Permission | Required | Used for |
| ------------ | -------- | -------------------------------------------------------------------- |
| ---------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `album.read` | ✅ Yes | List and fetch album metadata & photo lists |
| `asset.read` | ✅ Yes | Fetch asset metadata, EXIF data, thumbnails, previews, and originals |
| `asset.read` | ✅ Yes | Fetch asset metadata, EXIF data and search results |
| `asset.view` | ✅ Yes | Stream image/video files (thumbnail, preview, video playback) |
| `asset.download` | Only for zoom and downloads | Stream originals: [photo zoom](docs/gallery-config.md#photo-zoom) on JPEG/AVIF and [originals download](docs/gallery-config.md#originals-download) |
> **Zoom and downloads need `asset.download`.** Without it Immich answers `403` for the original file, so the zoom button reports that full resolution is unavailable and downloads fail. Leave it off if you use neither feature.
> **No write permissions needed.** `album.create`, `asset.upload`, `asset.delete`, etc. can all be left **off**.
@@ -347,7 +350,7 @@ own password, separate from any album passwords, and writes straight to
v0.20.0 — zoom, five new presets and photos as you edited them:
- **1:1 zoom in the lightbox** — double-click, pinch or press `+` to see a photo at full resolution and judge its sharpness; off by default, switched on for the site, a page or a single album ([guide](docs/gallery-config.md#photo-zoom))
- **1:1 zoom in the lightbox** — click, pinch, Ctrl + scroll or press `+` to see a photo at full resolution and judge its sharpness; off by default, switched on for the site, a page or a single album ([guide](docs/gallery-config.md#photo-zoom))
- **Five new theme presets** — Kunsthalle (a grey gallery wall with numbered wall labels), Ma, Cyanotype, Salon and Birch, each in dark and light; the passepartout mat of Studio, Classic and Noir is finally visible too
- **Edits made in Immich show up** — crops and rotations from Immich's editor appear in the grid, the lightbox, share images and downloads
- **No GPS in downloads** — original downloads and ZIPs keep camera data and colour profile but lose the coordinates and sub-city place names ([advisory](https://github.com/ralksta/immich-folio/security/advisories/GHSA-g4xf-4285-4cv4))

View File

@@ -238,7 +238,9 @@ export function InstallWizard({ initialApiUrl, setupToken }: Props) {
disabled={testing}
/>
<p className="admin-hint">
Create one in Immich under Admin Settings → API Keys.
Create one in Immich under Account Settings → API Keys. It needs album.read,
asset.read and asset.view, plus asset.download if you want photo zoom or
original downloads.
</p>
</div>

View File

@@ -7,7 +7,7 @@ import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
/**
* The lightbox zoom (#467), wired up: the control, the keys, Esc order,
* double-click, swipe while zoomed, and that the full-resolution file is
* mouse click and pan, swipe while zoomed, and that the full-resolution file is
* requested only when the visitor zooms.
*
* jsdom does no layout, so the fit box is stubbed: every image is laid out at
@@ -229,14 +229,44 @@ describe('keys', () => {
});
describe('gestures', () => {
it('double-click goes to 1:1 around the cursor, and back', () => {
/** A mouse press at a point, released `moveBy` px further on. */
const press = (surface: HTMLElement, x: number, y: number, moveBy = 0) => {
const at = { pointerId: 1, pointerType: 'mouse', button: 0, clientY: y };
fireEvent.pointerDown(surface, { ...at, clientX: x });
if (moveBy) fireEvent.pointerMove(surface, { ...at, clientX: x + moveBy });
fireEvent.pointerUp(surface, { ...at, clientX: x + moveBy });
};
it('a click on the photo zooms in by half around the cursor, and back', () => {
render(<Harness assets={[ZOOMABLE]} />);
const surface = preview().parentElement!;
press(surface, 600, 300);
// Fit box centre (500, 300); the cursor 100px right of it stays put at
// 1.5×, and the top edge is clamped to the 768px jsdom viewport.
expect(preview().style.transform).toBe('translate3d(-50px, 18px, 0) scale(1.5)');
press(surface, 600, 300);
expect(preview().style.transform).toContain('scale(1)');
});
it('a drag pans the zoomed photo instead of leaving the zoom', () => {
render(<Harness assets={[ZOOMABLE]} />);
const surface = preview().parentElement!;
press(surface, 600, 300);
press(surface, 600, 300, 100);
expect(preview().style.transform).toBe('translate3d(50px, 18px, 0) scale(1.5)');
});
it('a click beside the photo does not zoom', () => {
render(<Harness assets={[ZOOMABLE]} />);
press(preview().parentElement!, 1010, 300);
expect(preview().style.transform).toBe('');
});
it('the dblclick event itself no longer zooms', () => {
render(<Harness assets={[ZOOMABLE]} />);
const surface = preview().parentElement!;
fireEvent.doubleClick(surface, { clientX: 600, clientY: 300 });
// Fit box centre (500, 300); the cursor 100px right of it stays put at 6×.
expect(preview().style.transform).toBe('translate3d(-500px, 0px, 0) scale(6)');
fireEvent.doubleClick(surface, { clientX: 600, clientY: 300 });
expect(preview().style.transform).toContain('scale(1)');
expect(preview().style.transform).toBe('');
});
it('no swipe navigation while zoomed', () => {

View File

@@ -262,6 +262,9 @@ albums:
download: true
```
The API key needs the `asset.download` permission in Immich, since originals
are streamed from `/assets/:id/original`.
Once enabled, visitors get a **Download album** link in the album header (every
original as a ZIP), and — where [client proofing](#client-proofing) is on — a
**Download selected (.zip)** action in the export dialog that zips just the
@@ -762,13 +765,15 @@ In the admin panel the switch is under **Settings › General › Portfolio
Features**, and the page and album drawers in the page builder have an
_Inherit / On / Off_ choice.
**Using it.** Double-click (or double-tap) zooms to 1:1 around the point
clicked and back to fit; so does the magnifier button in the lightbox bar.
Pinch, or Ctrl + scroll wheel / trackpad pinch, zooms continuously between fit
and 1:1 — never further, since enlarging past 1:1 only interpolates pixels and
makes good focus look soft. Drag (or one finger) pans; `+` / `-` step, `0` and
`Esc` go back to fit (a second `Esc` closes the viewer). Swiping to the next
photo is off while zoomed, and changing photos resets the zoom.
**Using it.** A click on the photo zooms in by half (1.5× the fit size)
around the point clicked, and a second click goes back to fit. Double-tap on a
touch screen, or the magnifier button in the lightbox bar, zooms straight to
1:1 and back. Pinch, or Ctrl + scroll wheel / trackpad pinch, zooms
continuously between fit and 1:1 — never further, since enlarging past 1:1
only interpolates pixels and makes good focus look soft. Drag (or one finger)
pans; `+` / `-` step, `0` and `Esc` go back to fit (a second `Esc` closes the
viewer). Swiping to the next photo is off while zoomed, and changing photos
resets the zoom.
**What is shown.** The full-resolution file is requested only when a visitor
zooms — never on opening the viewer, and never for the neighbouring photos.
@@ -788,6 +793,13 @@ GPS coordinates and place names below city level are overwritten in place;
camera data and colour profile stay. A file whose metadata cannot be read is
refused rather than sent.
Zoom into a JPEG or AVIF streams the original from Immich, so the API key
needs the `asset.download` permission (_Account Settings › API Keys_). With
only `asset.read` and `asset.view`, Immich answers `403` and the lightbox says
full resolution is unavailable; the Folio log shows
`Failed to stream … 403`. The same permission covers
[originals download](#originals-download).
Formats a browser cannot display need Immich's **full-size preview**
(_Administration › Settings › Image Settings › Full-size image_, JPEG format).
Immich generates it only while that setting is on, so turn it on and run the

View File

@@ -18,11 +18,13 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import {
CLICK_ZOOM_SCALE,
FIT_VIEW,
canZoom,
clampView,
isZoomed,
maxZoomScale,
onFitBox,
panBy,
pinchView,
stepView,
@@ -61,6 +63,8 @@ const DOUBLE_TAP_MS = 300;
const DOUBLE_TAP_PX = 30;
/** A pointer that moved further than this was a drag, not a tap. */
const TAP_SLOP_PX = 10;
/** A mouse press held longer than this was not a click. */
const CLICK_MS = 500;
/** The `+` / `-` step. */
const KEY_STEP = 2;
/** Wheel delta per e-fold of zoom; see onWheel. */
@@ -163,6 +167,26 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
[apply, usable],
);
/**
* A mouse click: on the photo at fit, a closer look around the cursor;
* anywhere while zoomed, back to fit. A click beside the photo does nothing.
*/
const click = useCallback(
(point: Point) => {
if (isZoomed(viewRef.current)) {
apply(FIT_VIEW, true);
return;
}
const m = usable();
if (!m || !onFitBox(point, m.fit)) return;
apply(
toggleView(viewRef.current, point, m.fit, m.viewport, m.maxScale, CLICK_ZOOM_SCALE),
true,
);
},
[apply, usable],
);
const step = useCallback(
(factor: number) => {
const m = usable();
@@ -231,7 +255,6 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
/** Two fingers were down at some point of the current touch sequence. */
let multi = false;
let lastTap: { t: number; x: number; y: number } | null = null;
let lastPointerType = '';
const midAndDistance = () => {
const [a, b] = [...pointers.values()];
@@ -246,7 +269,6 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
};
const onPointerDown = (e: PointerEvent) => {
lastPointerType = e.pointerType;
if (e.pointerType === 'mouse' && e.button !== 0) return;
if (statusRef.current === 'failed') return;
const at = { x: e.clientX, y: e.clientY };
@@ -329,6 +351,19 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
gesture = { kind: 'none' };
}
// A mouse press that neither moved nor lingered is a click; one that
// moved was a pan and leaves the view where the pan put it.
const wasClick =
e.type === 'pointerup' &&
e.pointerType === 'mouse' &&
down &&
!down.moved &&
e.timeStamp - down.t < CLICK_MS;
if (wasClick) {
click({ x: e.clientX, y: e.clientY });
return;
}
const wasTap =
e.type === 'pointerup' &&
e.pointerType !== 'mouse' &&
@@ -352,14 +387,6 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
}
};
// Mouse only: touch has its own double-tap above, and some browsers also
// synthesise a dblclick from it, which would toggle straight back.
const onDoubleClick = (e: MouseEvent) => {
if (lastPointerType && lastPointerType !== 'mouse') return;
e.preventDefault();
toggle({ x: e.clientX, y: e.clientY });
};
// Ctrl+wheel is how Chrome, Firefox and Edge report a trackpad pinch (and
// what a mouse user presses to zoom); a plain wheel pans once zoomed.
const onWheel = (e: WheelEvent) => {
@@ -435,7 +462,6 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
surface.addEventListener('pointermove', onPointerMove);
surface.addEventListener('pointerup', onPointerEnd);
surface.addEventListener('pointercancel', onPointerEnd);
surface.addEventListener('dblclick', onDoubleClick);
surface.addEventListener('wheel', onWheel, { passive: false });
surface.addEventListener('gesturestart', onGestureStart);
surface.addEventListener('gesturechange', onGestureChange);
@@ -445,13 +471,12 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
surface.removeEventListener('pointermove', onPointerMove);
surface.removeEventListener('pointerup', onPointerEnd);
surface.removeEventListener('pointercancel', onPointerEnd);
surface.removeEventListener('dblclick', onDoubleClick);
surface.removeEventListener('wheel', onWheel);
surface.removeEventListener('gesturestart', onGestureStart);
surface.removeEventListener('gesturechange', onGestureChange);
surface.removeEventListener('gestureend', onGestureEnd);
};
}, [enabled, photoKey, surface, measure, apply, toggle]);
}, [enabled, photoKey, surface, measure, apply, toggle, click]);
const zoomed = isZoomed(view);
/** The full-resolution image is in the page (requested, loading or shown). */

View File

@@ -1,11 +1,13 @@
import { describe, it, expect } from 'vitest';
import {
CLICK_ZOOM_SCALE,
FIT_VIEW,
canZoom,
clampView,
isZoomed,
maxZoomScale,
oneToOneScale,
onFitBox,
panBy,
pinchView,
stepView,
@@ -127,7 +129,26 @@ describe('zooming around a point', () => {
expect(fit.cx + next.x - (fit.width * 6) / 2).toBeCloseTo(0);
});
it('double-click goes to 1:1 at the point, and back to fit', () => {
it('toggles to a given scale at the point, clamped to 1:1', () => {
const point = { x: 650, y: 420 };
const closer = toggleView(FIT_VIEW, point, fit, viewport, max, CLICK_ZOOM_SCALE);
expect(closer.scale).toBe(1.5);
const after = screenOf(closer, point.x - fit.cx, point.y - fit.cy);
expect(after.x).toBeCloseTo(point.x);
expect(after.y).toBeCloseTo(point.y);
expect(toggleView(closer, point, fit, viewport, max, CLICK_ZOOM_SCALE)).toEqual(FIT_VIEW);
// A photo with less to give than the click asks for stops at 1:1.
expect(toggleView(FIT_VIEW, point, fit, viewport, 1.2, CLICK_ZOOM_SCALE).scale).toBe(1.2);
});
it('knows the photo from the band beside it', () => {
expect(onFitBox({ x: fit.cx, y: fit.cy }, fit)).toBe(true);
expect(onFitBox({ x: fit.cx + fit.width / 2, y: fit.cy }, fit)).toBe(true);
expect(onFitBox({ x: fit.cx + fit.width / 2 + 1, y: fit.cy }, fit)).toBe(false);
expect(onFitBox({ x: fit.cx, y: fit.cy - fit.height / 2 - 1 }, fit)).toBe(false);
});
it('double-tap and the button go to 1:1 at the point, and back to fit', () => {
const point = { x: 650, y: 420 };
const zoomed = toggleView(FIT_VIEW, point, fit, viewport, max);
expect(zoomed.scale).toBe(max);

View File

@@ -187,7 +187,7 @@ export const de: Dictionary = {
shortcutList: 'Diese Liste',
shortcutClose: 'Ansicht schließen',
zoomIn: 'Auf volle Auflösung zoomen',
zoomInTitle: 'Auf volle Auflösung zoomen (+ oder Doppelklick)',
zoomInTitle: 'Auf volle Auflösung zoomen (+)',
zoomOut: 'Foto an den Bildschirm anpassen',
zoomOutTitle: 'Einpassen (0 oder Esc)',
zoomLoading: 'Volle Auflösung wird geladen …',

View File

@@ -191,7 +191,7 @@ export const en = {
shortcutClose: 'Close the viewer',
/** The zoom button (#467): to 1:1 from fit, and back. */
zoomIn: 'Zoom to full resolution',
zoomInTitle: 'Zoom to full resolution (+ or double-click)',
zoomInTitle: 'Zoom to full resolution (+)',
zoomOut: 'Fit the photo to the screen',
zoomOutTitle: 'Fit to screen (0 or Esc)',
zoomLoading: 'Loading full resolution…',

View File

@@ -187,7 +187,7 @@ export const es: Dictionary = {
shortcutList: 'Esta lista',
shortcutClose: 'Cerrar el visor',
zoomIn: 'Ampliar a resolución completa',
zoomInTitle: 'Ampliar a resolución completa (+ o doble clic)',
zoomInTitle: 'Ampliar a resolución completa (+)',
zoomOut: 'Ajustar la foto a la pantalla',
zoomOutTitle: 'Ajustar a la pantalla (0 o Esc)',
zoomLoading: 'Cargando la resolución completa…',

View File

@@ -190,7 +190,7 @@ export const fr: Dictionary = {
shortcutList: 'Cette liste',
shortcutClose: 'Fermer la visionneuse',
zoomIn: 'Zoomer en pleine résolution',
zoomInTitle: 'Zoomer en pleine résolution (+ ou double-clic)',
zoomInTitle: 'Zoomer en pleine résolution (+)',
zoomOut: 'Ajuster la photo à l’écran',
zoomOutTitle: 'Ajuster à l’écran (0 ou Échap)',
zoomLoading: 'Chargement de la pleine résolution…',

View File

@@ -187,7 +187,7 @@ export const it: Dictionary = {
shortcutList: 'Questo elenco',
shortcutClose: 'Chiudi il visualizzatore',
zoomIn: 'Ingrandisci alla risoluzione piena',
zoomInTitle: 'Ingrandisci alla risoluzione piena (+ o doppio clic)',
zoomInTitle: 'Ingrandisci alla risoluzione piena (+)',
zoomOut: 'Adatta la foto allo schermo',
zoomOutTitle: 'Adatta allo schermo (0 o Esc)',
zoomLoading: 'Caricamento della risoluzione piena…',

View File

@@ -188,7 +188,7 @@ export const nl: Dictionary = {
shortcutList: 'Deze lijst',
shortcutClose: 'De viewer sluiten',
zoomIn: 'Inzoomen op volledige resolutie',
zoomInTitle: 'Inzoomen op volledige resolutie (+ of dubbelklik)',
zoomInTitle: 'Inzoomen op volledige resolutie (+)',
zoomOut: 'Foto aan het scherm aanpassen',
zoomOutTitle: 'Aan scherm aanpassen (0 of Esc)',
zoomLoading: 'Volledige resolutie laden…',

View File

@@ -375,7 +375,12 @@ class ImmichClient {
}
if (!res.ok) {
console.error(`[Immich] Failed to stream ${assetId}: ${res.status}`);
console.error(
`[Immich] Failed to stream ${assetId}: ${res.status}` +
(res.status === 403 && endpoint.includes('/original')
? ' (the API key probably lacks the asset.download permission)'
: ''),
);
// Neither branch below reads the body — an unconsumed one keeps its
// socket out of undici's pool until GC finalises it (#635).
await res.body?.cancel();

View File

@@ -134,7 +134,7 @@ export function clampView(
/**
* Change the scale while the photo point under `point` stays under it — the
* cursor for a wheel or a double-click, the midpoint of two fingers for a
* cursor for a wheel or a click, the midpoint of two fingers for a
* pinch. Clamping can still move it at the edges, which is the price of never
* showing an empty band.
*/
@@ -198,16 +198,34 @@ export function panBy(
return clampView({ ...view, x: view.x + dx, y: view.y + dy }, fit, viewport, maxScale);
}
/** Double-click / double-tap: fit ↔ 1:1, around the point. */
/**
* How far a mouse click zooms: half again the fit size, a closer look rather
* than the jump to 1:1, which on a large file is several times the screen.
* Ctrl + wheel goes the rest of the way.
*/
export const CLICK_ZOOM_SCALE = 1.5;
/**
* Fit ↔ zoomed, around the point: to `target` (clamped to 1:1), or to 1:1
* itself without one — the double-tap and the magnifier button.
*/
export function toggleView(
view: ZoomView,
point: Point,
fit: FitBox,
viewport: Viewport,
maxScale: number,
target: number = maxScale,
): ZoomView {
if (isZoomed(view)) return { ...FIT_VIEW };
return zoomAround(view, maxScale, point, fit, viewport, maxScale);
return zoomAround(view, target, point, fit, viewport, maxScale);
}
/** Whether a point lies on the photo at fit — not on the band beside it. */
export function onFitBox(point: Point, fit: FitBox): boolean {
return (
Math.abs(point.x - fit.cx) <= fit.width / 2 && Math.abs(point.y - fit.cy) <= fit.height / 2
);
}
/**

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-folio",
"version": "0.20.0",
"version": "0.20.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-folio",
"version": "0.20.0",
"version": "0.20.1",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "immich-folio",
"version": "0.20.0",
"version": "0.20.1",
"license": "MIT",
"scripts": {
"dev": "next dev",