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;
}
/* Subpage Card */
.subpage-card {
background: var(--admin-bg);
/* Subpage Overview Grid */
.subpage-grid {
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-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;
}
.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;
align-items: center;
justify-content: space-between;
@@ -659,7 +732,7 @@
border-bottom: 1px solid var(--admin-border);
}
.subpage-header-left {
.subpage-detail-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
@@ -667,9 +740,14 @@
min-width: 0;
}
.subpage-detail-header-right {
display: flex;
align-items: center;
gap: 0.25rem;
}
.subpage-move-btns {
display: flex;
flex-direction: column;
gap: 0;
}
@@ -700,12 +778,6 @@
background: var(--admin-bg-input);
}
.subpage-header-right {
display: flex;
align-items: center;
gap: 0.25rem;
}
.subpage-meta {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--admin-border);

View File

@@ -88,6 +88,7 @@ export default function PageBuilder() {
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
const [expandedSubpage, setExpandedSubpage] = useState<number | null>(null);
const [pickerTarget, setPickerTarget] = useState<{
type: 'standalone' | 'subpage' | 'section';
subpageIndex?: number;
@@ -425,6 +426,24 @@ export default function PageBuilder() {
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 ───────────────────────────────────────────────────
if (loading) {
return (
@@ -542,26 +561,71 @@ export default function PageBuilder() {
</p>
)}
{gallery.subpages.map((sp, spIndex) => (
<div key={spIndex} className="subpage-card">
<div className="subpage-header">
<div className="subpage-header-left">
{/* Collapsed overview grid */}
<div className="subpage-grid">
{gallery.subpages.map((sp, spIndex) => {
const totalAlbums =
sp.albums.length +
(sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
const firstThumb = getFirstSubpageThumb(sp);
return (
<div
key={spIndex}
className={`subpage-tile ${expandedSubpage === spIndex ? 'active' : ''}`}
onClick={() =>
setExpandedSubpage(expandedSubpage === spIndex ? null : spIndex)
}
>
<div className="subpage-tile-cover">
{firstThumb ? (
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
) : (
<div className="subpage-tile-placeholder">📂</div>
)}
</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>
);
})}
</div>
{/* Expanded subpage detail */}
{expandedSubpage !== null && gallery.subpages[expandedSubpage] && (
<div className="subpage-detail">
{(() => {
const sp = gallery.subpages[expandedSubpage];
const spIndex = expandedSubpage;
return (
<>
<div className="subpage-detail-header">
<div className="subpage-detail-header-left">
<div className="subpage-move-btns">
<button
className="admin-btn-icon"
onClick={() => moveSubpage(spIndex, -1)}
onClick={() => {
moveSubpage(spIndex, -1);
setExpandedSubpage(spIndex - 1);
}}
disabled={spIndex === 0}
title="Move up"
>
↑
←
</button>
<button
className="admin-btn-icon"
onClick={() => moveSubpage(spIndex, 1)}
onClick={() => {
moveSubpage(spIndex, 1);
setExpandedSubpage(spIndex + 1);
}}
disabled={spIndex === gallery.subpages.length - 1}
title="Move down"
>
↓
→
</button>
</div>
<input
@@ -569,12 +633,23 @@ export default function PageBuilder() {
value={sp.name}
onChange={(e) => updateSubpage(spIndex, { name: e.target.value })}
placeholder="Page name (used for URL)"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="subpage-header-right">
<div className="subpage-detail-header-right">
<button
className="admin-btn-icon"
onClick={() => removeSubpage(spIndex)}
onClick={() => setExpandedSubpage(null)}
title="Collapse"
>
✕
</button>
<button
className="admin-btn-icon"
onClick={() => {
removeSubpage(spIndex);
setExpandedSubpage(null);
}}
title="Delete subpage"
>
🗑
@@ -589,7 +664,9 @@ export default function PageBuilder() {
<label>Title (optional)</label>
<input
value={sp.title || ''}
onChange={(e) => updateSubpage(spIndex, { title: e.target.value || undefined })}
onChange={(e) =>
updateSubpage(spIndex, { title: e.target.value || undefined })
}
placeholder="Display title (defaults to name)"
/>
</div>
@@ -626,7 +703,9 @@ export default function PageBuilder() {
<span>Albums</span>
<button
className="admin-btn admin-btn-xs"
onClick={() => setPickerTarget({ type: 'subpage', subpageIndex: spIndex })}
onClick={() =>
setPickerTarget({ type: 'subpage', subpageIndex: spIndex })
}
>
+ Add
</button>
@@ -738,20 +817,28 @@ export default function PageBuilder() {
)}
<div className="subpage-actions">
<button className="admin-btn admin-btn-xs" onClick={() => addSection(spIndex)}>
<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 })}
onClick={() =>
setPickerTarget({ type: 'subpage', subpageIndex: spIndex })
}
>
+ Add Album (outside sections)
</button>
)}
</div>
</>
);
})()}
</div>
))}
)}
</section>
{/* Album Picker Modal */}