Files
immich-folio/app/admin/components/page-builder/AlbumDrawer.tsx
Ralksta c3f92ca4ec release: v0.15.0 (#585)
Eighteen changes since v0.14.0. See CHANGELOG.md for the full list and the upgrade notes (bump IMAGE_CACHE_VERSION).
2026-09-19 22:24:10 +02:00

252 lines
9.7 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';
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;
return (
<div className="album-drawer-overlay open" onClick={() => onClose()}>
<div className="album-drawer" onClick={(e) => e.stopPropagation()}>
<div className="album-drawer-header">
<h3>
<IconCamera /> Edit Album Details
</h3>
<button className="admin-btn-icon" onClick={() => onClose()} title="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>Title override</label>
<input
value={album.title || ''}
onChange={(e) => onUpdate({ title: e.target.value || undefined })}
placeholder={name}
/>
</div>
<div className="admin-field">
<label>Description</label>
<textarea
value={album.description || ''}
onChange={(e) => onUpdate({ description: e.target.value || undefined })}
placeholder="Optional description for visitors"
rows={4}
/>
</div>
<div className="admin-field">
<label>Password protection</label>
<div className="input-with-icon">
<input
type="password"
value={album.password || ''}
onChange={(e) => onUpdate({ password: e.target.value || undefined })}
placeholder="Leave empty for public access"
/>
</div>
</div>
</div>
<div className="admin-sheet-col admin-sheet-col--divided">
<div className="admin-field">
<label>Layout override (Experimental)</label>
<select
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>Cover focal point (Experimental)</label>
<input
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">
<label>Custom Hero Image</label>
<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">
<label id="album-sort-label">Photo order</label>
<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>
);
}