feat: redesign admin pagebuilder with slide-over drawers and svg icons (#242)

Co-authored-by: ralksta <ralksta@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-05-31 21:27:13 +02:00
committed by GitHub
parent 02f5e6a505
commit 8964877f6a
2 changed files with 815 additions and 137 deletions

View File

@@ -1726,3 +1726,415 @@
grid-template-columns: 1fr;
}
}
/* ── Premium Slide-over Drawer & Badges Redesign ──────────────── */
/* Inline SVG Icon Global Styling */
.svg-icon {
display: inline-block;
vertical-align: middle;
flex-shrink: 0;
width: 1em;
height: 1em;
stroke-width: 2px;
transition: transform 0.2s ease, color 0.15s ease;
}
.svg-drag {
width: 12px;
height: 18px;
}
h2 .svg-icon,
h3 .svg-icon {
margin-right: 0.5rem;
font-size: 1.15em;
color: var(--admin-accent);
}
/* Album Card Simplification & Grid Spacing */
.album-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1.25rem;
margin: 0.5rem 0 1.25rem;
}
.album-tile {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
overflow: hidden;
position: relative;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.album-tile:hover {
transform: translateY(-2px);
border-color: var(--admin-border-hover);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
[data-theme='light'] .album-tile:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
.album-tile-cover {
aspect-ratio: 16 / 11;
border-bottom: 1px solid var(--admin-border);
position: relative;
background: var(--admin-bg-elevated);
overflow: hidden;
}
.album-tile-placeholder {
color: var(--admin-text-muted);
font-size: 1.75rem;
}
.album-tile-overlay {
display: flex;
gap: 0.5rem;
}
.album-tile-btn {
background: rgba(17, 17, 17, 0.85);
backdrop-filter: blur(8px);
border: 1px solid var(--admin-border);
color: var(--admin-text);
width: 32px;
height: 32px;
border-radius: 8px;
transition: all 0.15s ease;
}
.album-tile-btn:hover {
background: var(--admin-accent);
color: #000;
border-color: var(--admin-accent);
}
.album-tile-btn-danger:hover {
background: var(--admin-error) !important;
color: #fff !important;
border-color: var(--admin-error) !important;
}
.album-tile-info {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.album-tile-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.album-tile-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--admin-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.album-tile-name.custom-title {
color: var(--admin-accent);
font-style: italic;
}
/* Badges for status indications */
.album-tile-badges {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.album-tile-badges .badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
font-size: 0.7rem;
background: var(--admin-bg-elevated);
border: 1px solid var(--admin-border);
color: var(--admin-text-secondary);
}
.album-tile-badges .badge-password {
color: var(--admin-accent);
border-color: rgba(245, 158, 11, 0.3);
background: rgba(245, 158, 11, 0.05);
}
.album-tile-badges .badge-hero {
color: var(--admin-success);
border-color: rgba(74, 222, 128, 0.3);
background: rgba(74, 222, 128, 0.05);
}
/* ── Drawer Component styling ── */
.album-drawer-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--admin-overlay);
backdrop-filter: blur(8px);
display: flex;
justify-content: flex-end;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.album-drawer-overlay.open {
opacity: 1;
pointer-events: auto;
}
.album-drawer {
width: 100%;
max-width: 460px;
height: 100%;
background: var(--admin-bg-raised);
border-left: 1px solid var(--admin-border);
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.35);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.album-drawer-overlay.open .album-drawer {
transform: translateX(0);
}
.album-drawer-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--admin-border);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--admin-bg-elevated);
}
.album-drawer-header h3 {
font-size: 1.1rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.album-drawer-body {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.album-drawer-body::-webkit-scrollbar {
width: 6px;
}
.album-drawer-body::-webkit-scrollbar-track {
background: transparent;
}
.album-drawer-body::-webkit-scrollbar-thumb {
background: var(--admin-border);
border-radius: 3px;
}
.album-drawer-body::-webkit-scrollbar-thumb:hover {
background: var(--admin-border-hover);
}
.album-drawer-footer {
padding: 1.25rem 1.5rem;
border-top: 1px solid var(--admin-border);
background: var(--admin-bg-elevated);
display: flex;
gap: 0.75rem;
}
.album-drawer-footer .admin-btn {
flex: 1;
}
/* Drawer Hero Preview Banner */
.drawer-hero-section {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--admin-bg-elevated);
border: 1px solid var(--admin-border);
border-radius: 10px;
}
.drawer-hero-cover {
width: 90px;
height: 60px;
border-radius: 6px;
overflow: hidden;
background: var(--admin-bg);
border: 1px solid var(--admin-border);
flex-shrink: 0;
}
.drawer-hero-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.drawer-hero-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--admin-text-muted);
font-size: 1.25rem;
}
.drawer-hero-meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.drawer-hero-name {
font-size: 0.9rem;
font-weight: 600;
color: var(--admin-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.drawer-hero-count {
font-size: 0.75rem;
color: var(--admin-text-secondary);
}
/* Drawer Copy UUID element */
.drawer-uuid-section {
padding-top: 0.75rem;
border-top: 1px solid var(--admin-border);
}
.drawer-uuid-section label {
font-size: 0.7rem;
color: var(--admin-text-muted);
font-weight: 600;
display: block;
margin-bottom: 0.35rem;
letter-spacing: 0.05em;
}
.uuid-copy-box {
display: flex;
align-items: center;
background: var(--admin-bg-input);
border: 1px solid var(--admin-border);
border-radius: 6px;
padding: 0.4rem 0.5rem 0.4rem 0.75rem;
gap: 0.5rem;
}
.uuid-copy-box code {
font-family: monospace;
font-size: 0.75rem;
color: var(--admin-text-secondary);
flex: 1;
word-break: break-all;
user-select: all;
}
.uuid-copy-btn {
background: none;
border: none;
color: var(--admin-text-muted);
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.15s ease;
}
.uuid-copy-btn:hover {
color: var(--admin-accent);
background: var(--admin-bg-elevated);
}
/* Section Card Improvements */
.section-card {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.section-card:hover {
border-color: var(--admin-border-hover);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.section-header {
border-bottom: 1px solid var(--admin-border);
padding-bottom: 0.5rem;
margin-bottom: 0.75rem;
}
.section-title-input {
font-size: 0.95rem !important;
font-weight: 700 !important;
color: var(--admin-text) !important;
border-radius: 6px !important;
}
.section-desc-input {
border-color: transparent !important;
background: transparent !important;
padding-left: 0.4rem !important;
color: var(--admin-text-secondary) !important;
}
.section-desc-input:focus {
border-color: var(--admin-border) !important;
background: var(--admin-bg-input) !important;
}
/* ── Modern Subpage Details Spacing ── */
.subpage-detail {
margin-top: 1.5rem;
border: 1px solid var(--admin-border);
background: var(--admin-bg-elevated);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
}
[data-theme='light'] .subpage-detail {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.05);
}
.subpage-meta {
padding: 1.25rem;
background: var(--admin-bg-raised);
border-bottom: 1px solid var(--admin-border);
}

View File

@@ -22,6 +22,97 @@ import { CSS } from '@dnd-kit/utilities';
import AlbumPicker from './AlbumPicker';
import AssetPicker from './AssetPicker';
// ── Icons ──────────────────────────────────────────────────────
const Icons = {
Drag: () => (
<svg width="12" height="18" viewBox="0 0 12 18" fill="currentColor" className="svg-icon svg-drag">
<circle cx="2" cy="2" r="1.5" />
<circle cx="2" cy="9" r="1.5" />
<circle cx="2" cy="16" r="1.5" />
<circle cx="10" cy="2" r="1.5" />
<circle cx="10" cy="9" r="1.5" />
<circle cx="10" cy="16" r="1.5" />
</svg>
),
Edit: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
),
Trash: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
),
Close: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
),
Folder: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
),
Camera: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3Z" />
<circle cx="12" cy="13" r="3" />
</svg>
),
ExternalLink: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
),
Search: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
),
Lock: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
),
Plus: () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="M5 12h14M12 5v14" />
</svg>
),
Home: () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
),
Copy: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
),
Check: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<polyline points="20 6 9 17 4 12" />
</svg>
),
Image: () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="svg-icon">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
),
};
// ── Types ──────────────────────────────────────────────────────
interface AlbumEntry {
id: string;
@@ -53,6 +144,13 @@ interface GalleryState {
subpages: Subpage[];
}
interface ActiveEditAlbumAddress {
type: 'standalone' | 'subpage' | 'section';
subpageIndex?: number;
sectionIndex?: number;
albumIndex: number;
}
interface ImmichAlbumInfo {
id: string;
albumName: string;
@@ -136,11 +234,11 @@ function SortableHeroTile({
return (
<div ref={setNodeRef} style={style} className="hero-tile" {...attributes}>
<div className="hero-tile-drag" {...listeners} title="Drag to reorder">
⠿
<Icons.Drag />
</div>
<img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" />
<button className="hero-tile-remove" onClick={onRemove} title="Remove">
×
<Icons.Close />
</button>
<span className="hero-tile-index">{index + 1}</span>
</div>
@@ -156,7 +254,7 @@ function SortableAlbumCard({
count,
thumbnailId,
onRemove,
onUpdate,
onEdit,
}: {
album: AlbumEntry;
index: number;
@@ -164,7 +262,7 @@ function SortableAlbumCard({
count: number;
thumbnailId: string | null;
onRemove: () => void;
onUpdate: (updates: Partial<AlbumEntry>) => void;
onEdit: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `album-${album.id}-${index}`,
@@ -185,7 +283,7 @@ function SortableAlbumCard({
count={count}
thumbnailId={thumbnailId}
onRemove={onRemove}
onUpdate={onUpdate}
onEdit={onEdit}
dragListeners={listeners}
/>
</div>
@@ -231,13 +329,15 @@ function SortableSubpageTile({
{...attributes}
>
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
⠿
<Icons.Drag />
</div>
<div className="subpage-tile-cover">
{firstThumb ? (
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
) : (
<div className="subpage-tile-placeholder">📂</div>
<div className="subpage-tile-placeholder">
<Icons.Folder />
</div>
)}
</div>
<div className="subpage-tile-info">
@@ -265,8 +365,14 @@ export default function PageBuilder() {
subpageIndex?: number;
sectionIndex?: number;
} | null>(null);
const [showHeroPicker, setShowHeroPicker] = useState(false);
const [heroPickerTarget, setHeroPickerTarget] = useState<{
albumId?: string;
onSelect: (assetId: string) => void;
currentAssetIds?: string[];
title?: string;
} | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [editingAlbumAddress, setEditingAlbumAddress] = useState<ActiveEditAlbumAddress | null>(null);
// DnD sensors
const sensors = useSensors(
@@ -449,7 +555,7 @@ export default function PageBuilder() {
// ── Hero Picker ──────────────────────────────────────────────
function handleHeroSelect(assetId: string) {
setGallery((g) => ({ ...g, hero: [...g.hero, assetId] }));
setShowHeroPicker(false);
setHeroPickerTarget(null);
markDirty();
}
@@ -808,9 +914,18 @@ export default function PageBuilder() {
{/* Hero Section */}
<section className="builder-section">
<div className="builder-section-header">
<h2>🏠 Homepage Hero</h2>
<button className="admin-btn admin-btn-sm" onClick={() => setShowHeroPicker(true)}>
+ Add Hero
<h2><Icons.Home /> Homepage Hero</h2>
<button
className="admin-btn admin-btn-sm"
onClick={() =>
setHeroPickerTarget({
onSelect: handleHeroSelect,
currentAssetIds: gallery.hero,
title: 'Pick Hero Image for Homepage',
})
}
>
<Icons.Plus /> Add Hero
</button>
</div>
<DndContext
@@ -881,14 +996,7 @@ export default function PageBuilder() {
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
onRemove={() => removeStandaloneAlbum(originalIndex)}
onUpdate={(updates) => {
setGallery((g) => {
const albums = [...g.albums];
albums[originalIndex] = { ...albums[originalIndex], ...updates };
return { ...g, albums };
});
markDirty();
}}
onEdit={() => setEditingAlbumAddress({ type: 'standalone', albumIndex: originalIndex })}
/>
);
})}
@@ -1065,18 +1173,7 @@ export default function PageBuilder() {
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
onUpdate={(updates) => {
setGallery((g) => {
const subpages = [...g.subpages];
const sp2 = { ...subpages[spIndex] };
const albums = [...sp2.albums];
albums[aIndex] = { ...albums[aIndex], ...updates };
sp2.albums = albums;
subpages[spIndex] = sp2;
return { ...g, subpages };
});
markDirty();
}}
onEdit={() => setEditingAlbumAddress({ type: 'subpage', subpageIndex: spIndex, albumIndex: aIndex })}
/>
))}
</div>
@@ -1104,7 +1201,7 @@ export default function PageBuilder() {
onClick={() => removeSection(spIndex, secIndex)}
title="Remove section"
>
×
<Icons.Close />
</button>
</div>
<div className="admin-field">
@@ -1128,22 +1225,7 @@ export default function PageBuilder() {
count={getAlbumCount(album.id)}
thumbnailId={getAlbumThumbnailId(album.id)}
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
onUpdate={(updates) => {
setGallery((g) => {
const subpages = [...g.subpages];
const sp2 = { ...subpages[spIndex] };
const sections = [...(sp2.sections || [])];
const sec2 = { ...sections[secIndex] };
const albums = [...sec2.albums];
albums[aIndex] = { ...albums[aIndex], ...updates };
sec2.albums = albums;
sections[secIndex] = sec2;
sp2.sections = sections;
subpages[spIndex] = sp2;
return { ...g, subpages };
});
markDirty();
}}
onEdit={() => setEditingAlbumAddress({ type: 'section', subpageIndex: spIndex, sectionIndex: secIndex, albumIndex: aIndex })}
/>
))}
</div>
@@ -1194,17 +1276,247 @@ export default function PageBuilder() {
/>
)}
{/* Slide-over Drawer for Album Details */}
{(() => {
if (!editingAlbumAddress) return null;
const info = getEditingAlbumInfo(editingAlbumAddress);
if (!info) return null;
const { album, name, count, thumbnailId, onUpdate, onRemove } = info;
const heroThumb = album.heroImage || thumbnailId;
return (
<div className="album-drawer-overlay open" onClick={() => setEditingAlbumAddress(null)}>
<div className="album-drawer" onClick={(e) => e.stopPropagation()}>
<div className="album-drawer-header">
<h3>
<Icons.Camera /> Edit Album Details
</h3>
<button
className="admin-btn-icon"
onClick={() => setEditingAlbumAddress(null)}
title="Close details"
>
<Icons.Close />
</button>
</div>
<div className="album-drawer-body">
{/* Album Cover / Hero Preview */}
<div className="drawer-hero-section">
<div className="drawer-hero-cover">
{heroThumb ? (
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
) : (
<div className="drawer-hero-placeholder">
<Icons.Camera />
</div>
)}
</div>
<div className="drawer-hero-meta">
<span className="drawer-hero-name">{name}</span>
<span className="drawer-hero-count">{count} photos</span>
</div>
</div>
{/* Form Fields */}
<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={3}
/>
</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>
{/* 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"
>
<Icons.Close />
</button>
</div>
) : (
<span className="album-hero-empty">Using default album cover</span>
)}
<button
className="admin-btn admin-btn-sm"
onClick={() =>
setHeroPickerTarget({
albumId: album.id,
onSelect: (assetId) => {
onUpdate({ heroImage: assetId });
setHeroPickerTarget(null);
},
currentAssetIds: album.heroImage ? [album.heroImage] : [],
title: `Pick Hero Image for ${name}`,
})
}
>
<Icons.Image /> {album.heroImage ? 'Change Image' : 'Pick Hero Image'}
</button>
</div>
</div>
{/* Technical UUID */}
<div className="drawer-uuid-section">
<label className="admin-field-label">IMMICH ALBUM UUID</label>
<div className="uuid-copy-box">
<code>{album.id}</code>
<button
className="uuid-copy-btn"
onClick={() => {
navigator.clipboard.writeText(album.id);
}}
title="Copy UUID"
>
<Icons.Copy />
</button>
</div>
</div>
</div>
<div className="album-drawer-footer">
<button
className="admin-btn admin-btn-primary"
onClick={() => setEditingAlbumAddress(null)}
>
Done
</button>
<button
className="admin-btn admin-btn-ghost admin-btn-danger"
onClick={onRemove}
>
<Icons.Trash /> Remove Album
</button>
</div>
</div>
</div>
);
})()}
{/* Hero Asset Picker Modal */}
{showHeroPicker && (
{heroPickerTarget && (
<AssetPicker
onSelect={handleHeroSelect}
onClose={() => setShowHeroPicker(false)}
currentAssetIds={gallery.hero}
albumId={heroPickerTarget.albumId}
onSelect={heroPickerTarget.onSelect}
onClose={() => setHeroPickerTarget(null)}
currentAssetIds={heroPickerTarget.currentAssetIds}
title={heroPickerTarget.title}
/>
)}
</div>
);
function getEditingAlbumInfo(addr: ActiveEditAlbumAddress) {
let album: AlbumEntry;
let name: string;
let count: number;
let thumbnailId: string | null;
let onUpdate: (updates: Partial<AlbumEntry>) => void;
let onRemove: () => void;
if (addr.type === 'standalone') {
album = gallery.albums[addr.albumIndex];
if (!album) return null;
name = getAlbumName(album.id);
count = getAlbumCount(album.id);
thumbnailId = getAlbumThumbnailId(album.id);
onUpdate = (updates) => {
setGallery((g) => {
const albums = [...g.albums];
albums[addr.albumIndex] = { ...albums[addr.albumIndex], ...updates };
return { ...g, albums };
});
markDirty();
};
onRemove = () => {
removeStandaloneAlbum(addr.albumIndex);
};
} else if (addr.type === 'subpage') {
const sp = gallery.subpages[addr.subpageIndex!];
if (!sp) return null;
album = sp.albums[addr.albumIndex];
if (!album) return null;
name = getAlbumName(album.id);
count = getAlbumCount(album.id);
thumbnailId = getAlbumThumbnailId(album.id);
onUpdate = (updates) => {
setGallery((g) => {
const subpages = [...g.subpages];
const sp2 = { ...subpages[addr.subpageIndex!] };
const albums = [...sp2.albums];
albums[addr.albumIndex] = { ...albums[addr.albumIndex], ...updates };
sp2.albums = albums;
subpages[addr.subpageIndex!] = sp2;
return { ...g, subpages };
});
markDirty();
};
onRemove = () => {
removeSubpageAlbum(addr.subpageIndex!, addr.albumIndex);
};
} else {
const sp = gallery.subpages[addr.subpageIndex!];
if (!sp) return null;
const sec = sp.sections?.[addr.sectionIndex!];
if (!sec) return null;
album = sec.albums[addr.albumIndex];
if (!album) return null;
name = getAlbumName(album.id);
count = getAlbumCount(album.id);
thumbnailId = getAlbumThumbnailId(album.id);
onUpdate = (updates) => {
setGallery((g) => {
const subpages = [...g.subpages];
const sp2 = { ...subpages[addr.subpageIndex!] };
const sections = [...(sp2.sections || [])];
const sec2 = { ...sections[addr.sectionIndex!] };
const albums = [...sec2.albums];
albums[addr.albumIndex] = { ...albums[addr.albumIndex], ...updates };
sec2.albums = albums;
sections[addr.sectionIndex!] = sec2;
sp2.sections = sections;
subpages[addr.subpageIndex!] = sp2;
return { ...g, subpages };
});
markDirty();
};
onRemove = () => {
removeSectionAlbum(addr.subpageIndex!, addr.sectionIndex!, addr.albumIndex);
};
}
return { album, name, count, thumbnailId, onUpdate, onRemove };
}
function getAllUsedAlbumIds(): Set<string> {
const ids = new Set<string>();
gallery.albums.forEach((a) => ids.add(a.id));
@@ -1224,7 +1536,7 @@ interface AlbumCardProps {
count: number;
thumbnailId: string | null;
onRemove: () => void;
onUpdate: (updates: Partial<AlbumEntry>) => void;
onEdit: () => void;
dragListeners?: Record<string, unknown>;
}
@@ -1234,112 +1546,66 @@ function AlbumCard({
count,
thumbnailId,
onRemove,
onUpdate,
onEdit,
dragListeners,
}: AlbumCardProps) {
const [expanded, setExpanded] = useState(false);
const [showHeroPicker, setShowHeroPicker] = useState(false);
const heroThumb = album.heroImage || thumbnailId;
const hasPassword = !!album.password;
const hasTitleOverride = !!album.title;
const hasDescription = !!album.description;
return (
<div className="album-tile">
<div className={`album-tile ${hasPassword ? 'has-password' : ''}`}>
<div className="album-tile-cover">
{dragListeners && (
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder">
⠿
<Icons.Drag />
</div>
)}
{thumbnailId ? (
<img src={`/api/admin/thumbnail/${thumbnailId}`} alt="" loading="lazy" />
{heroThumb ? (
<img src={`/api/admin/thumbnail/${heroThumb}`} alt="" loading="lazy" />
) : (
<div className="album-tile-placeholder">📷</div>
<div className="album-tile-placeholder">
<Icons.Camera />
</div>
)}
<div className="album-tile-overlay">
<button
className="album-tile-btn"
onClick={() => setExpanded(!expanded)}
onClick={onEdit}
title="Edit details"
>
✏️
<Icons.Edit />
</button>
<button className="album-tile-btn" onClick={onRemove} title="Remove">
×
<button
className="album-tile-btn album-tile-btn-danger"
onClick={onRemove}
title="Remove album"
>
<Icons.Trash />
</button>
</div>
</div>
<div className="album-tile-info">
<span className="album-tile-name">{album.title || name}</span>
<div className="album-tile-title-row">
<span className={`album-tile-name ${hasTitleOverride ? 'custom-title' : ''}`} title={album.title || name}>
{album.title || name}
</span>
<div className="album-tile-badges">
{hasPassword && (
<span className="badge badge-password" title="Password protected">
<Icons.Lock />
</span>
)}
{album.heroImage && (
<span className="badge badge-hero" title="Custom Hero Image set">
<Icons.Image />
</span>
)}
</div>
</div>
<span className="album-tile-count">{count} photos</span>
</div>
{expanded && (
<div className="album-tile-details">
<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>
<input
value={album.description || ''}
onChange={(e) => onUpdate({ description: e.target.value || undefined })}
placeholder="Optional description"
/>
</div>
<div className="admin-field">
<label>Password</label>
<input
type="password"
value={album.password || ''}
onChange={(e) => onUpdate({ password: e.target.value || undefined })}
placeholder="Leave empty for public"
/>
</div>
{/* Hero Image */}
<div className="admin-field">
<label>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 hero image"
>
×
</button>
</div>
) : (
<span className="album-hero-empty">No hero image set</span>
)}
<button
className="admin-btn admin-btn-xs"
onClick={() => setShowHeroPicker(true)}
>
{album.heroImage ? 'Change' : '+ Pick'}
</button>
</div>
</div>
<div className="album-tile-uuid">
<code>{album.id}</code>
</div>
</div>
)}
{showHeroPicker && (
<AssetPicker
albumId={album.id}
onSelect={(assetId) => {
onUpdate({ heroImage: assetId });
setShowHeroPicker(false);
}}
onClose={() => setShowHeroPicker(false)}
currentAssetIds={album.heroImage ? [album.heroImage] : []}
title="Pick Hero Image for Album"
/>
)}
</div>
);
}