release: v0.19.0

Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -15,8 +15,9 @@ import { useState, useCallback, useEffect, useMemo } from 'react';
import Image from 'next/image';
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { FadeIn } from '@/components/FadeIn';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
import type { ProofSessionInit } from '@/components/ProofingContext';
import { useProofing } from '@/components/useProofing';
import { ProofingProvider, ProofingModal, ProofSessionControls } from '@/components/ProofingLazy';
import { useDictionary } from '@/components/I18nProvider';
import { parsePhotoHash, parsePhotoQuery, buildPhotoQuery } from '@/lib/photoHash';
@@ -60,6 +61,12 @@ interface PhotoGridProps {
proofing?: boolean;
/** Offer the "send by email" button in the proofing modal. */
allowMailto?: boolean;
/**
* `encodeEmail()` of where the proofing dialog's "email to photographer"
* goes, when one is configured (#736). Lets the modal address the compose;
* without it the button is hidden.
*/
encodedMailto?: string;
/**
* The ZIP endpoint for this album, when it offers downloads. Lets the
* proofing modal offer a "download selected" action (#475).
@@ -71,6 +78,11 @@ interface PhotoGridProps {
* selection made in one album leaks into the next.
*/
albumName?: string;
/**
* A client proofing link's session (/proof/<token>). Turns proofing on and
* keeps the selection on the server instead of in the browser.
*/
proofSession?: ProofSessionInit;
}
function PhotoGridInner({
@@ -124,6 +136,33 @@ function PhotoGridInner({
}
}, [lightboxIndex, displayedAssets]);
// ── The photo on screen can leave the list ─────────────────────
// With "selected only" on, un-hearting the photo in the viewer drops it from
// `displayedAssets`. The viewer keeps its position, which now shows the next
// favourite; past the end it steps back to the last one, and with nothing
// left it closes. `shownIndex` keeps this render valid (it used to hand the
// viewer an index past the end, and the page crashed); the effect settles
// the state and the URL.
const shownIndex =
lightboxIndex === null || displayedAssets.length === 0
? null
: Math.min(lightboxIndex, displayedAssets.length - 1);
useEffect(() => {
if (lightboxIndex === null || lightboxIndex < displayedAssets.length) return;
if (displayedAssets.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLightboxIndex(displayedAssets.length - 1);
return;
}
setLightboxIndex(null);
window.history.replaceState(
null,
'',
window.location.pathname + buildPhotoQuery(window.location.search, null),
);
}, [lightboxIndex, displayedAssets.length]);
// ── Listen for browser back/forward ────────────────────────────
useEffect(() => {
const handlePopState = () => {
@@ -204,6 +243,7 @@ function PhotoGridInner({
}}
role="button"
tabIndex={0}
data-lightbox-index={index}
aria-label={t.lightbox.openPhoto(index + 1)}
aria-haspopup="dialog"
style={{
@@ -285,13 +325,38 @@ function PhotoGridInner({
});
}, [displayedAssets, layout, openLightbox, proofing, t]);
// Photos of this album that are selected. Not `favorites.size`: the stored set
// can also hold a photo since removed, or a favourite from another album whose
// name slugs the same way (they share `folio_fav_<name>`).
const selectedCount = proofing ? proofing.getSelectedTokens().length : 0;
return (
<>
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle}>
{gridItems}
</div>
{proofing?.isFilterActive && displayedAssets.length === 0 ? (
// Un-hearting the last favourite with the filter on left a blank page
// between the header and the footer. Say why, and offer the way back
// here as well as in the bar in the corner.
<div className="empty-state" role="status">
<p className="empty-state__text">{t.proofing.filterEmpty}</p>
<div className="empty-state__actions">
<button
type="button"
className="empty-state__button"
onClick={() => proofing.setIsFilterActive(false)}
>
{t.proofing.showAll}
</button>
</div>
</div>
) : (
<div className={`photo-grid photo-grid--${layout}`} style={gridStyle} data-lightbox-group>
{gridItems}
</div>
)}
{proofing && proofing.favorites.size > 0 && (
{/* Kept while the filter is on: un-hearting the last favourite there
empties the grid, and the bar holds the only way back to all photos. */}
{proofing && !proofing.session && (selectedCount > 0 || proofing.isFilterActive) && (
<div
className="proofing-sticky-bar"
style={{
@@ -327,9 +392,7 @@ function PhotoGridInner({
cursor: 'pointer',
}}
>
{proofing.isFilterActive
? t.proofing.showAll
: t.proofing.selected(proofing.favorites.size)}
{proofing.isFilterActive ? t.proofing.showAll : t.proofing.selected(selectedCount)}
</button>
<button
type="button"
@@ -349,12 +412,13 @@ function PhotoGridInner({
</div>
)}
{proofing && <ProofingModal />}
{proofing && !proofing.session && <ProofingModal />}
{proofing?.session && <ProofSessionControls />}
{lightboxIndex !== null && (
{shownIndex !== null && (
<Lightbox
assets={displayedAssets}
currentIndex={lightboxIndex}
currentIndex={shownIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
@@ -372,7 +436,7 @@ export function PhotoGrid(props: PhotoGridProps) {
// Without the provider useProofing() returns null, and every proofing control
// (hearts in the grid and in the lightbox, selection bar, modal) drops out on
// its own.
if (!props.proofing) {
if (!props.proofing && !props.proofSession) {
return <PhotoGridInner {...props} />;
}
@@ -381,7 +445,9 @@ export function PhotoGrid(props: PhotoGridProps) {
albumTokens={albumTokens}
albumName={props.albumName}
allowMailto={props.allowMailto ?? true}
encodedMailto={props.encodedMailto}
downloadArchiveUrl={props.downloadArchiveUrl}
session={props.proofSession}
>
<PhotoGridInner {...props} />
</ProofingProvider>