Files
immich-folio/app/admin/components/page-builder/AlbumDrawer.tsx
2026-10-02 18:14:23 +02:00

318 lines
13 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 ZoomOverrideField from '../fields/ZoomOverrideField';
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>
<ZoomOverrideField
id="album-zoom"
value={album.zoom}
onChange={(zoom) => onUpdate({ zoom })}
inheritLabel="Inherit from page / site settings"
hint="Lets visitors zoom into a photo in the viewer to check sharpness. JPEG and AVIF originals are shown without their GPS data; HEIC and RAW need Immich's full-size previews. PNG, WebP, GIF and photos edited in Immich are never zoomed. Applies where the album is listed here."
/>
</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>
);
}