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

@@ -1,8 +1,9 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useModalDialog } from '@/hooks/useModalDialog';
import { IconCheck, IconFolder, IconImage, IconStar } from './Icons';
import { canSelectMore, matchesQuery, toggleSelection } from '@/lib/admin/assetSelection';
import { IconCheck, IconFolder, IconImage, IconSearch, IconStar } from './Icons';
import { useScrollLock } from './useScrollLock';
interface AssetInfo {
@@ -13,7 +14,15 @@ interface AssetInfo {
}
interface Props {
onSelect: (assetId: string) => void;
/** Single-select: called with the one asset picked; the caller closes the picker. */
onSelect?: (assetId: string) => void;
/**
* Multi-select (#602): tiles toggle, and a confirm button hands over every
* picked asset in the order they were picked. Takes precedence over `onSelect`.
*/
onSelectMany?: (assetIds: string[]) => void;
/** Most assets `onSelectMany` accepts; unlimited when omitted. */
max?: number;
onClose: () => void;
currentAssetIds?: string[];
albumId?: string;
@@ -22,8 +31,12 @@ interface Props {
type Tab = 'album' | 'favorites' | 'all';
const SEARCH_DEBOUNCE_MS = 300;
export default function AssetPicker({
onSelect,
onSelectMany,
max,
onClose,
currentAssetIds = [],
albumId,
@@ -31,21 +44,50 @@ export default function AssetPicker({
}: Props) {
useScrollLock(true);
const cardRef = useModalDialog(onClose);
const multi = onSelectMany !== undefined;
const [tab, setTab] = useState<Tab>(albumId ? 'album' : 'favorites');
const [assets, setAssets] = useState<AssetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [queryInput, setQueryInput] = useState('');
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<string[]>([]);
const [uuidInput, setUuidInput] = useState('');
const [uuidError, setUuidError] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const currentIds = new Set(currentAssetIds);
/**
* Bumped each time the list starts over (tab, album or search changed). A
* request is only allowed to touch the list while its generation is current:
* without this, a slow Favorites answer landing after a switch to All Photos
* replaced that tab's photos, and ended its spinner early.
*/
const generation = useRef(0);
const currentIds = useMemo(() => new Set(currentAssetIds), [currentAssetIds]);
// Debounce typing into the query the fetch runs on.
useEffect(() => {
const t = setTimeout(() => setQuery(queryInput.trim()), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(t);
}, [queryInput]);
// The album tab loads the album whole and filters locally; the library tabs
// search server-side, since they are paged.
const serverQuery = tab === 'album' ? '' : query;
const loadAssets = useCallback(
async (pageNum: number, append: boolean = false) => {
if (pageNum === 1) setLoading(true);
else setLoadingMore(true);
// A fresh list starts a generation; a further page belongs to the current one.
const gen = append ? generation.current : ++generation.current;
const current = () => gen === generation.current;
if (append) {
setLoadingMore(true);
} else {
setLoading(true);
// A "load more" of the previous list will not clear this itself.
setLoadingMore(false);
}
try {
let res: Response;
@@ -56,22 +98,31 @@ export default function AssetPicker({
page: pageNum.toString(),
favorites: tab === 'favorites' ? 'true' : 'false',
});
if (serverQuery) params.set('q', serverQuery);
res = await fetch(`/api/admin/assets?${params}`);
}
if (res.ok) {
const data = await res.json();
setAssets((prev) => (append ? [...prev, ...data.assets] : data.assets));
if (!current()) return;
setAssets((prev) => {
if (!append) return data.assets;
// Merged filename + description pages can overlap across pages.
const seen = new Set(prev.map((a) => a.id));
return [...prev, ...data.assets.filter((a: AssetInfo) => !seen.has(a.id))];
});
setHasMore(data.nextPage !== null);
setPage(pageNum);
}
} catch (err) {
console.error('Failed to load assets:', err);
if (current()) console.error('Failed to load assets:', err);
} finally {
setLoading(false);
setLoadingMore(false);
if (current()) {
setLoading(false);
setLoadingMore(false);
}
}
},
[tab, albumId],
[tab, albumId, serverQuery],
);
useEffect(() => {
@@ -79,7 +130,9 @@ export default function AssetPicker({
setPage(1);
setHasMore(false);
loadAssets(1);
}, [tab, loadAssets]);
}, [loadAssets]);
const visible = tab === 'album' ? assets.filter((a) => matchesQuery(a, query)) : assets;
// Infinite scroll
function handleScroll() {
@@ -90,6 +143,16 @@ export default function AssetPicker({
}
}
function pick(id: string) {
if (currentIds.has(id)) return;
if (multi) setSelected((s) => toggleSelection(s, id, { disabled: currentIds, max }));
else onSelect?.(id);
}
function confirm() {
if (multi && selected.length > 0) onSelectMany(selected);
}
function handleUuidSubmit(e: React.FormEvent) {
e.preventDefault();
const uuid = uuidInput.trim();
@@ -99,7 +162,12 @@ export default function AssetPicker({
return;
}
setUuidError('');
onSelect(uuid);
if (multi) {
setSelected((s) => toggleSelection(s, uuid.toLowerCase(), { disabled: currentIds, max }));
setUuidInput('');
} else {
onSelect?.(uuid);
}
}
function formatDate(dateStr: string): string {
@@ -114,6 +182,8 @@ export default function AssetPicker({
}
}
const full = multi && !canSelectMore(selected, max);
return (
<div className="picker-overlay" onClick={onClose}>
<div
@@ -126,7 +196,7 @@ export default function AssetPicker({
>
<div className="picker-header">
<h3 id="asset-picker-title">{title || 'Select Hero Image'}</h3>
<button className="admin-btn-icon" onClick={onClose}>
<button className="admin-btn-icon" onClick={onClose} aria-label="Close">
×
</button>
</div>
@@ -136,6 +206,7 @@ export default function AssetPicker({
{albumId && (
<button
className={`asset-picker-tab ${tab === 'album' ? 'active' : ''}`}
aria-pressed={tab === 'album'}
onClick={() => setTab('album')}
>
<IconFolder size={14} />
@@ -144,6 +215,7 @@ export default function AssetPicker({
)}
<button
className={`asset-picker-tab ${tab === 'favorites' ? 'active' : ''}`}
aria-pressed={tab === 'favorites'}
onClick={() => setTab('favorites')}
>
<IconStar size={14} />
@@ -151,6 +223,7 @@ export default function AssetPicker({
</button>
<button
className={`asset-picker-tab ${tab === 'all' ? 'active' : ''}`}
aria-pressed={tab === 'all'}
onClick={() => setTab('all')}
>
<IconImage size={14} />
@@ -158,45 +231,91 @@ export default function AssetPicker({
</button>
</div>
{/* Search */}
<div className="asset-picker-search">
<IconSearch size={14} />
<input
type="search"
value={queryInput}
onChange={(e) => setQueryInput(e.target.value)}
onKeyDown={(e) => {
// Enter in the field searches at once instead of submitting anything.
if (e.key === 'Enter') {
e.preventDefault();
setQuery(queryInput.trim());
}
}}
maxLength={100}
placeholder={
tab === 'album'
? 'Filter this album by filename…'
: 'Search filename, description, or a date (2024-05)…'
}
aria-label="Search photos"
className="asset-picker-search-input"
/>
</div>
{/* Asset Grid */}
<div className="asset-picker-grid" ref={listRef} onScroll={handleScroll}>
<div
className="asset-picker-grid"
ref={listRef}
onScroll={handleScroll}
role="group"
aria-label="Photos"
>
{loading ? (
<div className="asset-picker-loading">
<div className="admin-spinner" />
</div>
) : assets.length === 0 ? (
) : visible.length === 0 ? (
<p className="empty-hint">
{tab === 'favorites'
? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".'
: 'No photos found.'}
{query
? `No photos match “${query}”.`
: tab === 'favorites'
? 'No favorite photos found. Star photos in Immich first, or switch to "All Photos".'
: 'No photos found.'}
</p>
) : (
<>
{assets.map((asset) => {
{visible.map((asset) => {
const isUsed = currentIds.has(asset.id);
const order = selected.indexOf(asset.id);
const isSelected = order !== -1;
const blocked = isUsed || (full && !isSelected);
const label = `${asset.originalFileName}, ${formatDate(asset.fileCreatedAt)}${
isUsed ? ' (already used)' : ''
}`;
return (
<div
<button
type="button"
key={asset.id}
className={`asset-picker-tile ${isUsed ? 'used' : ''}`}
onClick={() => !isUsed && onSelect(asset.id)}
className={`asset-picker-tile ${isUsed ? 'used' : ''} ${
isSelected ? 'selected' : ''
}`}
onClick={() => pick(asset.id)}
disabled={blocked}
aria-pressed={multi ? isSelected : undefined}
aria-label={label}
title={`${asset.originalFileName}\n${formatDate(asset.fileCreatedAt)}`}
>
<img
src={`/api/admin/thumbnail/${asset.id}`}
alt={asset.originalFileName}
loading="lazy"
/>
<img src={`/api/admin/thumbnail/${asset.id}`} alt="" loading="lazy" />
{isUsed && (
<div className="asset-picker-used-badge">
<span className="asset-picker-used-badge" aria-hidden="true">
<IconCheck size={11} />
</div>
</span>
)}
{isSelected && (
<span className="asset-picker-order-badge" aria-hidden="true">
{max === 1 ? <IconCheck size={11} /> : order + 1}
</span>
)}
{asset.isFavorite && !isUsed && (
<div className="asset-picker-fav-badge" title="Favorite in Immich">
<span className="asset-picker-fav-badge" aria-hidden="true">
<IconStar size={14} />
</div>
</span>
)}
</div>
</button>
);
})}
{loadingMore && (
@@ -208,6 +327,36 @@ export default function AssetPicker({
)}
</div>
{/* Selection bar (multi-select) */}
{multi && (
<div className="asset-picker-selection">
<span aria-live="polite">
{selected.length === 0
? max && max > 1
? `Pick up to ${max} photos`
: 'Pick photos'
: `${selected.length}${max && max > 1 ? ` of ${max}` : ''} selected`}
</span>
{selected.length > 0 && (
<button
type="button"
className="admin-btn admin-btn-sm"
onClick={() => setSelected([])}
>
Clear
</button>
)}
<button
type="button"
className="admin-btn admin-btn-sm admin-btn-primary"
disabled={selected.length === 0}
onClick={confirm}
>
{selected.length > 1 ? `Add ${selected.length} photos` : 'Add photo'}
</button>
</div>
)}
{/* UUID Fallback */}
<div className="asset-picker-uuid-section">
<form onSubmit={handleUuidSubmit} className="asset-picker-uuid-form">
@@ -219,6 +368,7 @@ export default function AssetPicker({
setUuidError('');
}}
placeholder="Or paste asset UUID directly..."
aria-label="Asset UUID"
className="asset-picker-uuid-input"
/>
<button type="submit" className="admin-btn admin-btn-sm" disabled={!uuidInput.trim()}>