Files
immich-folio/app/admin/components/page-builder/AlbumDrawer.tsx
Ralksta ad8b09700b 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>
2026-09-29 22:50:30 +02:00

309 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { DEFAULT_ALBUM_SORT } from '@/lib/albumSort';
import { IconCamera, IconCopy, IconGripVertical, IconImage, IconTrash, IconX } from '../Icons';
import { Listbox } from '../Listbox';
import { SORT_OPTIONS } from './sortOptions';
import type { AlbumEntry, HeroPickerTarget, OrderEditorTarget } from './types';
import PasswordField from '../fields/PasswordField';
import { useModalDialog } from '@/hooks/useModalDialog';
interface AlbumDrawerProps {
album: AlbumEntry;
name: string;
count: number;
thumbnailId: string | null;
onUpdate: (updates: Partial<AlbumEntry>) => void;
onRemove: () => void;
onClose: () => void;
onPickHero: (target: HeroPickerTarget | null) => void;
onEditOrder: (target: OrderEditorTarget | null) => void;
}
/**
* Per-album settings, opened from a card in the builder or from inside the
* subpage drawer. Which album it edits — and how an edit is written back —
* is resolved by the caller and arrives as `onUpdate`/`onRemove`.
*/
export default function AlbumDrawer({
album,
name,
count,
thumbnailId,
onUpdate,
onRemove,
onClose,
onPickHero,
onEditOrder,
}: AlbumDrawerProps) {
const heroThumb = album.heroImage || thumbnailId;
const cardRef = useModalDialog(onClose);
return (
<div className="album-drawer-overlay open" onClick={() => onClose()}>
<div
className="album-drawer"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="album-drawer-title"
onClick={(e) => e.stopPropagation()}
>
<div className="album-drawer-header">
<h3 id="album-drawer-title">
<IconCamera /> Edit Album Details
</h3>
<button
type="button"
className="admin-btn-icon"
onClick={() => onClose()}
title="Close details"
aria-label="Close details"
>
<IconX size={14} />
</button>
</div>
{/* One scrolling column: cover banner, then the form. Anything the
form already shows (password state, custom hero) is not
repeated as a read-only stat. */}
<div className="album-drawer-body">
<div className="admin-sheet-columns">
<div className="admin-sheet-col">
<div className="modal-cover-container">
{heroThumb ? (
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
) : (
<div className="modal-cover-placeholder">
<IconCamera />
</div>
)}
</div>
<div className="modal-meta-box">
<span className="modal-album-title">{album.title || name}</span>
<span className="modal-album-subtitle">
{count} {count === 1 ? 'photo' : 'photos'}
{album.title ? ` · Original: ${name}` : ''}
</span>
</div>
<div className="admin-field">
<label htmlFor="album-title-override">Title override</label>
<input
id="album-title-override"
value={album.title || ''}
onChange={(e) => onUpdate({ title: e.target.value || undefined })}
placeholder={name}
/>
</div>
<div className="admin-field">
<label htmlFor="album-description">Description</label>
<textarea
id="album-description"
value={album.description || ''}
onChange={(e) => onUpdate({ description: e.target.value || undefined })}
placeholder="Optional description for visitors"
rows={4}
/>
</div>
<div className="admin-field">
<label htmlFor="album-password">Password protection</label>
<PasswordField
id="album-password"
value={album.password}
onChange={(password) => onUpdate({ password })}
label="Album password"
/>
</div>
<div className="admin-field">
<span className="admin-field-label">Downloads</span>
<button
type="button"
className={`admin-toggle-card ${album.download ? 'active' : ''}`}
onClick={() => onUpdate({ download: !album.download })}
aria-pressed={!!album.download}
style={{
padding: '10px 14px',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderRadius: '8px',
cursor: 'pointer',
}}
>
<div className="toggle-card-info" style={{ textAlign: 'left' }}>
<span
className="toggle-card-title"
style={{ fontWeight: 600, display: 'inline-flex', alignItems: 'center' }}
>
Allow original downloads
</span>
<span
className="toggle-card-desc"
style={{ fontSize: '0.8rem', display: 'block', opacity: 0.75 }}
>
Visitors can download the whole album or their selected photos as a ZIP
</span>
</div>
<div className={`switch-toggle ${album.download ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
</div>
</div>
<div className="admin-sheet-col admin-sheet-col--divided">
<div className="admin-field">
<label htmlFor="album-layout-override">Layout override (Experimental)</label>
<select
id="album-layout-override"
aria-label="Layout override"
value={album.grid?.layout || ''}
onChange={(e) => {
const layout = e.target.value || undefined;
// Only the layout is exposed here; drop the grid object
// entirely when it goes back to "inherit" so the YAML
// stays clean.
onUpdate({
grid: layout ? { ...(album.grid || {}), layout } : undefined,
});
}}
>
<option value="">Inherit from page / global settings</option>
<option value="masonry">Masonry</option>
<option value="uniform">Uniform Grid</option>
<option value="showcase">Showcase</option>
<option value="filmstrip">Filmstrip</option>
<option value="editorial-flow">Editorial Flow</option>
<option value="justified">Justified (Experimental)</option>
</select>
</div>
<div className="admin-field">
<label htmlFor="album-cover-focal-point">Cover focal point (Experimental)</label>
<input
id="album-cover-focal-point"
value={album.coverPosition || ''}
onChange={(e) => onUpdate({ coverPosition: e.target.value || undefined })}
placeholder={'e.g. "50% 25%" or "top" — where the cover crop should anchor'}
/>
</div>
{/* Hero Image Selection */}
<div className="admin-field">
<span className="admin-field-label">Custom Hero Image</span>
<div className="album-hero-field">
{album.heroImage ? (
<div className="album-hero-preview">
<img src={`/api/admin/thumbnail/${album.heroImage}`} alt="Hero" />
<button
className="album-hero-remove"
onClick={() => onUpdate({ heroImage: undefined })}
title="Remove custom hero image"
>
<IconX size={14} />
</button>
</div>
) : (
<span className="album-hero-empty">Using default album cover</span>
)}
<button
className="admin-btn admin-btn-sm"
onClick={() =>
onPickHero({
albumId: album.id,
onSelect: (assetId) => {
onUpdate({ heroImage: assetId });
onPickHero(null);
},
currentAssetIds: album.heroImage ? [album.heroImage] : [],
title: `Pick Hero Image for ${name}`,
})
}
>
<IconImage size={12} /> {album.heroImage ? 'Change Image' : 'Pick Hero Image'}
</button>
</div>
</div>
{/* Photo order */}
<div className="admin-field">
<span className="admin-field-label" id="album-sort-label">
Photo order
</span>
<Listbox
labelledBy="album-sort-label"
value={album.sort || DEFAULT_ALBUM_SORT}
options={SORT_OPTIONS}
onChange={(mode) => onUpdate({ sort: mode })}
/>
<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={() =>
onEditOrder({
albumId: album.id,
albumName: album.title || name,
assetOrder: album.assetOrder || [],
onSave: (assetOrder) => {
onUpdate({ assetOrder });
onEditOrder(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 className="album-drawer-footer">
<button
className="admin-btn admin-btn-ghost admin-btn-danger"
onClick={onRemove}
style={{ maxWidth: '160px' }}
>
<IconTrash size={14} /> Remove Album
</button>
<div className="uuid-copy-box" title="Immich album UUID">
<code>{album.id}</code>
<button
className="uuid-copy-btn"
onClick={() => {
navigator.clipboard.writeText(album.id);
}}
title="Copy UUID"
aria-label="Copy Immich album UUID"
>
<IconCopy size={12} />
</button>
</div>
<button
className="admin-btn admin-btn-primary"
onClick={() => onClose()}
style={{ maxWidth: '140px' }}
>
Done
</button>
</div>
</div>
</div>
);
}