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 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).
## [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 ## [0.20.0] — 2026-10-02
### Security ### Security

View File

@@ -237,11 +237,14 @@ 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. 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 | | Permission | Required | Used for |
| ------------ | -------- | -------------------------------------------------------------------- | | ---------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `album.read` | ✅ Yes | List and fetch album metadata & photo lists | | `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.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**. > **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: 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 - **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 - **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)) - **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} disabled={testing}
/> />
<p className="admin-hint"> <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> </p>
</div> </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, * 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. * requested only when the visitor zooms.
* *
* jsdom does no layout, so the fit box is stubbed: every image is laid out at * jsdom does no layout, so the fit box is stubbed: every image is laid out at
@@ -229,14 +229,44 @@ describe('keys', () => {
}); });
describe('gestures', () => { 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]} />); render(<Harness assets={[ZOOMABLE]} />);
const surface = preview().parentElement!; const surface = preview().parentElement!;
fireEvent.doubleClick(surface, { clientX: 600, clientY: 300 }); 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('');
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)');
}); });
it('no swipe navigation while zoomed', () => { it('no swipe navigation while zoomed', () => {

View File

@@ -262,6 +262,9 @@ albums:
download: true 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 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 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 **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 Features**, and the page and album drawers in the page builder have an
_Inherit / On / Off_ choice. _Inherit / On / Off_ choice.
**Using it.** Double-click (or double-tap) zooms to 1:1 around the point **Using it.** A click on the photo zooms in by half (1.5× the fit size)
clicked and back to fit; so does the magnifier button in the lightbox bar. around the point clicked, and a second click goes back to fit. Double-tap on a
Pinch, or Ctrl + scroll wheel / trackpad pinch, zooms continuously between fit touch screen, or the magnifier button in the lightbox bar, zooms straight to
and 1:1 — never further, since enlarging past 1:1 only interpolates pixels and 1:1 and back. Pinch, or Ctrl + scroll wheel / trackpad pinch, zooms
makes good focus look soft. Drag (or one finger) pans; `+` / `-` step, `0` and continuously between fit and 1:1 — never further, since enlarging past 1:1
`Esc` go back to fit (a second `Esc` closes the viewer). Swiping to the next only interpolates pixels and makes good focus look soft. Drag (or one finger)
photo is off while zoomed, and changing photos resets the zoom. 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 **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. 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 camera data and colour profile stay. A file whose metadata cannot be read is
refused rather than sent. 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** Formats a browser cannot display need Immich's **full-size preview**
(_Administration › Settings › Image Settings › Full-size image_, JPEG format). (_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 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 { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import { import {
CLICK_ZOOM_SCALE,
FIT_VIEW, FIT_VIEW,
canZoom, canZoom,
clampView, clampView,
isZoomed, isZoomed,
maxZoomScale, maxZoomScale,
onFitBox,
panBy, panBy,
pinchView, pinchView,
stepView, stepView,
@@ -61,6 +63,8 @@ const DOUBLE_TAP_MS = 300;
const DOUBLE_TAP_PX = 30; const DOUBLE_TAP_PX = 30;
/** A pointer that moved further than this was a drag, not a tap. */ /** A pointer that moved further than this was a drag, not a tap. */
const TAP_SLOP_PX = 10; const TAP_SLOP_PX = 10;
/** A mouse press held longer than this was not a click. */
const CLICK_MS = 500;
/** The `+` / `-` step. */ /** The `+` / `-` step. */
const KEY_STEP = 2; const KEY_STEP = 2;
/** Wheel delta per e-fold of zoom; see onWheel. */ /** Wheel delta per e-fold of zoom; see onWheel. */
@@ -163,6 +167,26 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
[apply, usable], [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( const step = useCallback(
(factor: number) => { (factor: number) => {
const m = usable(); 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. */ /** Two fingers were down at some point of the current touch sequence. */
let multi = false; let multi = false;
let lastTap: { t: number; x: number; y: number } | null = null; let lastTap: { t: number; x: number; y: number } | null = null;
let lastPointerType = '';
const midAndDistance = () => { const midAndDistance = () => {
const [a, b] = [...pointers.values()]; const [a, b] = [...pointers.values()];
@@ -246,7 +269,6 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
}; };
const onPointerDown = (e: PointerEvent) => { const onPointerDown = (e: PointerEvent) => {
lastPointerType = e.pointerType;
if (e.pointerType === 'mouse' && e.button !== 0) return; if (e.pointerType === 'mouse' && e.button !== 0) return;
if (statusRef.current === 'failed') return; if (statusRef.current === 'failed') return;
const at = { x: e.clientX, y: e.clientY }; const at = { x: e.clientX, y: e.clientY };
@@ -329,6 +351,19 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
gesture = { kind: 'none' }; 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 = const wasTap =
e.type === 'pointerup' && e.type === 'pointerup' &&
e.pointerType !== 'mouse' && 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 // 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. // what a mouse user presses to zoom); a plain wheel pans once zoomed.
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
@@ -435,7 +462,6 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
surface.addEventListener('pointermove', onPointerMove); surface.addEventListener('pointermove', onPointerMove);
surface.addEventListener('pointerup', onPointerEnd); surface.addEventListener('pointerup', onPointerEnd);
surface.addEventListener('pointercancel', onPointerEnd); surface.addEventListener('pointercancel', onPointerEnd);
surface.addEventListener('dblclick', onDoubleClick);
surface.addEventListener('wheel', onWheel, { passive: false }); surface.addEventListener('wheel', onWheel, { passive: false });
surface.addEventListener('gesturestart', onGestureStart); surface.addEventListener('gesturestart', onGestureStart);
surface.addEventListener('gesturechange', onGestureChange); surface.addEventListener('gesturechange', onGestureChange);
@@ -445,13 +471,12 @@ export function useZoom({ photoKey, naturalWidth, imageRef, surface }: UseZoomOp
surface.removeEventListener('pointermove', onPointerMove); surface.removeEventListener('pointermove', onPointerMove);
surface.removeEventListener('pointerup', onPointerEnd); surface.removeEventListener('pointerup', onPointerEnd);
surface.removeEventListener('pointercancel', onPointerEnd); surface.removeEventListener('pointercancel', onPointerEnd);
surface.removeEventListener('dblclick', onDoubleClick);
surface.removeEventListener('wheel', onWheel); surface.removeEventListener('wheel', onWheel);
surface.removeEventListener('gesturestart', onGestureStart); surface.removeEventListener('gesturestart', onGestureStart);
surface.removeEventListener('gesturechange', onGestureChange); surface.removeEventListener('gesturechange', onGestureChange);
surface.removeEventListener('gestureend', onGestureEnd); surface.removeEventListener('gestureend', onGestureEnd);
}; };
}, [enabled, photoKey, surface, measure, apply, toggle]); }, [enabled, photoKey, surface, measure, apply, toggle, click]);
const zoomed = isZoomed(view); const zoomed = isZoomed(view);
/** The full-resolution image is in the page (requested, loading or shown). */ /** 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 { describe, it, expect } from 'vitest';
import { import {
CLICK_ZOOM_SCALE,
FIT_VIEW, FIT_VIEW,
canZoom, canZoom,
clampView, clampView,
isZoomed, isZoomed,
maxZoomScale, maxZoomScale,
oneToOneScale, oneToOneScale,
onFitBox,
panBy, panBy,
pinchView, pinchView,
stepView, stepView,
@@ -127,7 +129,26 @@ describe('zooming around a point', () => {
expect(fit.cx + next.x - (fit.width * 6) / 2).toBeCloseTo(0); 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 point = { x: 650, y: 420 };
const zoomed = toggleView(FIT_VIEW, point, fit, viewport, max); const zoomed = toggleView(FIT_VIEW, point, fit, viewport, max);
expect(zoomed.scale).toBe(max); expect(zoomed.scale).toBe(max);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -375,7 +375,12 @@ class ImmichClient {
} }
if (!res.ok) { 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 // Neither branch below reads the body — an unconsumed one keeps its
// socket out of undici's pool until GC finalises it (#635). // socket out of undici's pool until GC finalises it (#635).
await res.body?.cancel(); 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 * 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 * pinch. Clamping can still move it at the edges, which is the price of never
* showing an empty band. * 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); 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( export function toggleView(
view: ZoomView, view: ZoomView,
point: Point, point: Point,
fit: FitBox, fit: FitBox,
viewport: Viewport, viewport: Viewport,
maxScale: number, maxScale: number,
target: number = maxScale,
): ZoomView { ): ZoomView {
if (isZoomed(view)) return { ...FIT_VIEW }; 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", "name": "immich-folio",
"version": "0.20.0", "version": "0.20.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-folio", "name": "immich-folio",
"version": "0.20.0", "version": "0.20.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

View File

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