feat: redesign subpages as collapsible tile grid for better overview

- Subpages shown as compact visual tiles in a grid (like folders)
- Each tile shows the first album's cover image + name + album count
- Click a tile to expand/edit that subpage's details below
- Active tile highlighted with accent border
- Only one subpage expanded at a time — no more endless scrolling
- Move arrows changed to ← → (horizontal) matching the grid layout
- Smooth slide-down animation when expanding a subpage
This commit is contained in:
ralksta
2026-05-29 17:29:26 +02:00
parent 502ca1d32c
commit 0457a74dd1
2 changed files with 363 additions and 204 deletions

View File

@@ -641,16 +641,89 @@
word-break: break-all; word-break: break-all;
} }
/* Subpage Card */ /* Subpage Overview Grid */
.subpage-card { .subpage-grid {
background: var(--admin-bg); display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.6rem;
margin-bottom: 1rem;
}
.subpage-tile {
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border); border: 1px solid var(--admin-border);
border-radius: 10px; border-radius: 10px;
margin-bottom: 1rem; overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
}
.subpage-tile:hover {
border-color: var(--admin-border-hover);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.subpage-tile.active {
border-color: var(--admin-accent);
box-shadow: 0 0 0 1px var(--admin-accent);
}
.subpage-tile-cover {
aspect-ratio: 16 / 10;
background: var(--admin-bg-elevated);
overflow: hidden; overflow: hidden;
} }
.subpage-header { .subpage-tile-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.subpage-tile-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.subpage-tile-info {
padding: 0.4rem 0.6rem;
display: flex;
flex-direction: column;
gap: 0.05rem;
}
.subpage-tile-name {
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.subpage-tile-meta {
font-size: 0.65rem;
color: var(--admin-text-muted);
}
/* Subpage Expanded Detail */
.subpage-detail {
background: var(--admin-bg);
border: 1px solid var(--admin-border);
border-radius: 10px;
overflow: hidden;
animation: slideDown 0.15s ease-out;
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.subpage-detail-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -659,7 +732,7 @@
border-bottom: 1px solid var(--admin-border); border-bottom: 1px solid var(--admin-border);
} }
.subpage-header-left { .subpage-detail-header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -667,9 +740,14 @@
min-width: 0; min-width: 0;
} }
.subpage-detail-header-right {
display: flex;
align-items: center;
gap: 0.25rem;
}
.subpage-move-btns { .subpage-move-btns {
display: flex; display: flex;
flex-direction: column;
gap: 0; gap: 0;
} }
@@ -700,12 +778,6 @@
background: var(--admin-bg-input); background: var(--admin-bg-input);
} }
.subpage-header-right {
display: flex;
align-items: center;
gap: 0.25rem;
}
.subpage-meta { .subpage-meta {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--admin-border); border-bottom: 1px solid var(--admin-border);

View File

@@ -88,6 +88,7 @@ export default function PageBuilder() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [saveMessage, setSaveMessage] = useState(''); const [saveMessage, setSaveMessage] = useState('');
const [expandedSubpage, setExpandedSubpage] = useState<number | null>(null);
const [pickerTarget, setPickerTarget] = useState<{ const [pickerTarget, setPickerTarget] = useState<{
type: 'standalone' | 'subpage' | 'section'; type: 'standalone' | 'subpage' | 'section';
subpageIndex?: number; subpageIndex?: number;
@@ -425,6 +426,24 @@ export default function PageBuilder() {
return found?.thumbnailAssetId || null; return found?.thumbnailAssetId || null;
} }
function getFirstSubpageThumb(sp: Subpage): string | null {
// Try albums first
for (const album of sp.albums) {
const thumb = getAlbumThumbnailId(album.id);
if (thumb) return thumb;
}
// Try sections
if (sp.sections) {
for (const sec of sp.sections) {
for (const album of sec.albums) {
const thumb = getAlbumThumbnailId(album.id);
if (thumb) return thumb;
}
}
}
return null;
}
// ── Render ─────────────────────────────────────────────────── // ── Render ───────────────────────────────────────────────────
if (loading) { if (loading) {
return ( return (
@@ -542,216 +561,284 @@ export default function PageBuilder() {
</p> </p>
)} )}
{gallery.subpages.map((sp, spIndex) => ( {/* Collapsed overview grid */}
<div key={spIndex} className="subpage-card"> <div className="subpage-grid">
<div className="subpage-header"> {gallery.subpages.map((sp, spIndex) => {
<div className="subpage-header-left"> const totalAlbums =
<div className="subpage-move-btns"> sp.albums.length +
<button (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
className="admin-btn-icon" const firstThumb = getFirstSubpageThumb(sp);
onClick={() => moveSubpage(spIndex, -1)} return (
disabled={spIndex === 0} <div
title="Move up" key={spIndex}
> className={`subpage-tile ${expandedSubpage === spIndex ? 'active' : ''}`}
↑ onClick={() =>
</button> setExpandedSubpage(expandedSubpage === spIndex ? null : spIndex)
<button }
className="admin-btn-icon" >
onClick={() => moveSubpage(spIndex, 1)} <div className="subpage-tile-cover">
disabled={spIndex === gallery.subpages.length - 1} {firstThumb ? (
title="Move down" <img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
> ) : (
↓ <div className="subpage-tile-placeholder">📂</div>
</button> )}
</div>
<div className="subpage-tile-info">
<span className="subpage-tile-name">{sp.title || sp.name}</span>
<span className="subpage-tile-meta">
{totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
</span>
</div> </div>
<input
className="subpage-name-input"
value={sp.name}
onChange={(e) => updateSubpage(spIndex, { name: e.target.value })}
placeholder="Page name (used for URL)"
/>
</div> </div>
<div className="subpage-header-right"> );
<button })}
className="admin-btn-icon" </div>
onClick={() => removeSubpage(spIndex)}
title="Delete subpage"
>
🗑
</button>
</div>
</div>
{/* Subpage metadata */} {/* Expanded subpage detail */}
<div className="subpage-meta"> {expandedSubpage !== null && gallery.subpages[expandedSubpage] && (
<div className="admin-field-row"> <div className="subpage-detail">
<div className="admin-field"> {(() => {
<label>Title (optional)</label> const sp = gallery.subpages[expandedSubpage];
<input const spIndex = expandedSubpage;
value={sp.title || ''} return (
onChange={(e) => updateSubpage(spIndex, { title: e.target.value || undefined })} <>
placeholder="Display title (defaults to name)" <div className="subpage-detail-header">
/> <div className="subpage-detail-header-left">
</div> <div className="subpage-move-btns">
<div className="admin-field"> <button
<label>Subtitle</label> className="admin-btn-icon"
<input onClick={() => {
value={sp.subtitle || ''} moveSubpage(spIndex, -1);
onChange={(e) => setExpandedSubpage(spIndex - 1);
updateSubpage(spIndex, { subtitle: e.target.value || undefined }) }}
} disabled={spIndex === 0}
placeholder="Subtitle text" title="Move up"
/> >
</div> ←
</div> </button>
<div className="admin-field-row"> <button
<div className="admin-field"> className="admin-btn-icon"
<label>Password (optional)</label> onClick={() => {
<input moveSubpage(spIndex, 1);
type="password" setExpandedSubpage(spIndex + 1);
value={sp.password || ''} }}
onChange={(e) => disabled={spIndex === gallery.subpages.length - 1}
updateSubpage(spIndex, { password: e.target.value || undefined }) title="Move down"
} >
placeholder="Leave empty for public" →
/> </button>
</div> </div>
</div>
</div>
{/* Albums (if no sections) */}
{(!sp.sections || sp.sections.length === 0) && (
<div className="subpage-albums">
<div className="subpage-albums-header">
<span>Albums</span>
<button
className="admin-btn admin-btn-xs"
onClick={() => setPickerTarget({ type: 'subpage', subpageIndex: spIndex })}
>
+ Add
</button>
</div>
<div className="album-list">
{sp.albums.map((album, aIndex) => (
<AlbumCard
key={`${album.id}-${aIndex}`}
album={album}
name={getAlbumName(album.id)}
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();
}}
/>
))}
</div>
</div>
)}
{/* Sections */}
{sp.sections && sp.sections.length > 0 && (
<div className="subpage-sections">
{sp.sections.map((sec, secIndex) => (
<div key={secIndex} className="section-card">
<div className="section-header">
<input <input
className="section-title-input" className="subpage-name-input"
value={sec.title} value={sp.name}
onChange={(e) => onChange={(e) => updateSubpage(spIndex, { name: e.target.value })}
updateSection(spIndex, secIndex, { title: e.target.value }) placeholder="Page name (used for URL)"
} onClick={(e) => e.stopPropagation()}
placeholder="Section title"
/> />
</div>
<div className="subpage-detail-header-right">
<button <button
className="admin-btn-icon" className="admin-btn-icon"
onClick={() => removeSection(spIndex, secIndex)} onClick={() => setExpandedSubpage(null)}
title="Remove section" title="Collapse"
> >
× ✕
</button>
<button
className="admin-btn-icon"
onClick={() => {
removeSubpage(spIndex);
setExpandedSubpage(null);
}}
title="Delete subpage"
>
🗑
</button> </button>
</div> </div>
<div className="admin-field"> </div>
<input
value={sec.description || ''} {/* Subpage metadata */}
onChange={(e) => <div className="subpage-meta">
updateSection(spIndex, secIndex, { <div className="admin-field-row">
description: e.target.value || undefined, <div className="admin-field">
}) <label>Title (optional)</label>
} <input
placeholder="Section description (optional)" value={sp.title || ''}
className="section-desc-input" onChange={(e) =>
/> updateSubpage(spIndex, { title: e.target.value || undefined })
</div> }
<div className="album-list"> placeholder="Display title (defaults to name)"
{sec.albums.map((album, aIndex) => (
<AlbumCard
key={`${album.id}-${aIndex}`}
album={album}
name={getAlbumName(album.id)}
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();
}}
/> />
</div>
<div className="admin-field">
<label>Subtitle</label>
<input
value={sp.subtitle || ''}
onChange={(e) =>
updateSubpage(spIndex, { subtitle: e.target.value || undefined })
}
placeholder="Subtitle text"
/>
</div>
</div>
<div className="admin-field-row">
<div className="admin-field">
<label>Password (optional)</label>
<input
type="password"
value={sp.password || ''}
onChange={(e) =>
updateSubpage(spIndex, { password: e.target.value || undefined })
}
placeholder="Leave empty for public"
/>
</div>
</div>
</div>
{/* Albums (if no sections) */}
{(!sp.sections || sp.sections.length === 0) && (
<div className="subpage-albums">
<div className="subpage-albums-header">
<span>Albums</span>
<button
className="admin-btn admin-btn-xs"
onClick={() =>
setPickerTarget({ type: 'subpage', subpageIndex: spIndex })
}
>
+ Add
</button>
</div>
<div className="album-list">
{sp.albums.map((album, aIndex) => (
<AlbumCard
key={`${album.id}-${aIndex}`}
album={album}
name={getAlbumName(album.id)}
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();
}}
/>
))}
</div>
</div>
)}
{/* Sections */}
{sp.sections && sp.sections.length > 0 && (
<div className="subpage-sections">
{sp.sections.map((sec, secIndex) => (
<div key={secIndex} className="section-card">
<div className="section-header">
<input
className="section-title-input"
value={sec.title}
onChange={(e) =>
updateSection(spIndex, secIndex, { title: e.target.value })
}
placeholder="Section title"
/>
<button
className="admin-btn-icon"
onClick={() => removeSection(spIndex, secIndex)}
title="Remove section"
>
×
</button>
</div>
<div className="admin-field">
<input
value={sec.description || ''}
onChange={(e) =>
updateSection(spIndex, secIndex, {
description: e.target.value || undefined,
})
}
placeholder="Section description (optional)"
className="section-desc-input"
/>
</div>
<div className="album-list">
{sec.albums.map((album, aIndex) => (
<AlbumCard
key={`${album.id}-${aIndex}`}
album={album}
name={getAlbumName(album.id)}
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();
}}
/>
))}
</div>
<button
className="admin-btn admin-btn-xs"
onClick={() =>
setPickerTarget({
type: 'section',
subpageIndex: spIndex,
sectionIndex: secIndex,
})
}
>
+ Add Album
</button>
</div>
))} ))}
</div> </div>
)}
<div className="subpage-actions">
<button <button
className="admin-btn admin-btn-xs" className="admin-btn admin-btn-xs"
onClick={() => onClick={() => addSection(spIndex)}
setPickerTarget({
type: 'section',
subpageIndex: spIndex,
sectionIndex: secIndex,
})
}
> >
+ Add Album + Add Section
</button> </button>
{sp.sections && sp.sections.length > 0 && (
<button
className="admin-btn admin-btn-xs"
onClick={() =>
setPickerTarget({ type: 'subpage', subpageIndex: spIndex })
}
>
+ Add Album (outside sections)
</button>
)}
</div> </div>
))} </>
</div> );
)} })()}
<div className="subpage-actions">
<button className="admin-btn admin-btn-xs" onClick={() => addSection(spIndex)}>
+ Add Section
</button>
{sp.sections && sp.sections.length > 0 && (
<button
className="admin-btn admin-btn-xs"
onClick={() => setPickerTarget({ type: 'subpage', subpageIndex: spIndex })}
>
+ Add Album (outside sections)
</button>
)}
</div>
</div> </div>
))} )}
</section> </section>
{/* Album Picker Modal */} {/* Album Picker Modal */}