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:
@@ -1726,3 +1726,415 @@
|
|||||||
grid-template-columns: 1fr;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,97 @@ import { CSS } from '@dnd-kit/utilities';
|
|||||||
import AlbumPicker from './AlbumPicker';
|
import AlbumPicker from './AlbumPicker';
|
||||||
import AssetPicker from './AssetPicker';
|
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 ──────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────
|
||||||
interface AlbumEntry {
|
interface AlbumEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -53,6 +144,13 @@ interface GalleryState {
|
|||||||
subpages: Subpage[];
|
subpages: Subpage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ActiveEditAlbumAddress {
|
||||||
|
type: 'standalone' | 'subpage' | 'section';
|
||||||
|
subpageIndex?: number;
|
||||||
|
sectionIndex?: number;
|
||||||
|
albumIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ImmichAlbumInfo {
|
interface ImmichAlbumInfo {
|
||||||
id: string;
|
id: string;
|
||||||
albumName: string;
|
albumName: string;
|
||||||
@@ -136,11 +234,11 @@ function SortableHeroTile({
|
|||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} className="hero-tile" {...attributes}>
|
<div ref={setNodeRef} style={style} className="hero-tile" {...attributes}>
|
||||||
<div className="hero-tile-drag" {...listeners} title="Drag to reorder">
|
<div className="hero-tile-drag" {...listeners} title="Drag to reorder">
|
||||||
⠿
|
<Icons.Drag />
|
||||||
</div>
|
</div>
|
||||||
<img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" />
|
<img src={`/api/admin/thumbnail/${id}`} alt="" loading="lazy" />
|
||||||
<button className="hero-tile-remove" onClick={onRemove} title="Remove">
|
<button className="hero-tile-remove" onClick={onRemove} title="Remove">
|
||||||
×
|
<Icons.Close />
|
||||||
</button>
|
</button>
|
||||||
<span className="hero-tile-index">{index + 1}</span>
|
<span className="hero-tile-index">{index + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +254,7 @@ function SortableAlbumCard({
|
|||||||
count,
|
count,
|
||||||
thumbnailId,
|
thumbnailId,
|
||||||
onRemove,
|
onRemove,
|
||||||
onUpdate,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
album: AlbumEntry;
|
album: AlbumEntry;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -164,7 +262,7 @@ function SortableAlbumCard({
|
|||||||
count: number;
|
count: number;
|
||||||
thumbnailId: string | null;
|
thumbnailId: string | null;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onUpdate: (updates: Partial<AlbumEntry>) => void;
|
onEdit: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: `album-${album.id}-${index}`,
|
id: `album-${album.id}-${index}`,
|
||||||
@@ -185,7 +283,7 @@ function SortableAlbumCard({
|
|||||||
count={count}
|
count={count}
|
||||||
thumbnailId={thumbnailId}
|
thumbnailId={thumbnailId}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
onUpdate={onUpdate}
|
onEdit={onEdit}
|
||||||
dragListeners={listeners}
|
dragListeners={listeners}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,13 +329,15 @@ function SortableSubpageTile({
|
|||||||
{...attributes}
|
{...attributes}
|
||||||
>
|
>
|
||||||
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
|
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
|
||||||
⠿
|
<Icons.Drag />
|
||||||
</div>
|
</div>
|
||||||
<div className="subpage-tile-cover">
|
<div className="subpage-tile-cover">
|
||||||
{firstThumb ? (
|
{firstThumb ? (
|
||||||
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
|
<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>
|
||||||
<div className="subpage-tile-info">
|
<div className="subpage-tile-info">
|
||||||
@@ -265,8 +365,14 @@ export default function PageBuilder() {
|
|||||||
subpageIndex?: number;
|
subpageIndex?: number;
|
||||||
sectionIndex?: number;
|
sectionIndex?: number;
|
||||||
} | null>(null);
|
} | 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 [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [editingAlbumAddress, setEditingAlbumAddress] = useState<ActiveEditAlbumAddress | null>(null);
|
||||||
|
|
||||||
// DnD sensors
|
// DnD sensors
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -449,7 +555,7 @@ export default function PageBuilder() {
|
|||||||
// ── Hero Picker ──────────────────────────────────────────────
|
// ── Hero Picker ──────────────────────────────────────────────
|
||||||
function handleHeroSelect(assetId: string) {
|
function handleHeroSelect(assetId: string) {
|
||||||
setGallery((g) => ({ ...g, hero: [...g.hero, assetId] }));
|
setGallery((g) => ({ ...g, hero: [...g.hero, assetId] }));
|
||||||
setShowHeroPicker(false);
|
setHeroPickerTarget(null);
|
||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,9 +914,18 @@ export default function PageBuilder() {
|
|||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="builder-section">
|
<section className="builder-section">
|
||||||
<div className="builder-section-header">
|
<div className="builder-section-header">
|
||||||
<h2>🏠 Homepage Hero</h2>
|
<h2><Icons.Home /> Homepage Hero</h2>
|
||||||
<button className="admin-btn admin-btn-sm" onClick={() => setShowHeroPicker(true)}>
|
<button
|
||||||
+ Add Hero
|
className="admin-btn admin-btn-sm"
|
||||||
|
onClick={() =>
|
||||||
|
setHeroPickerTarget({
|
||||||
|
onSelect: handleHeroSelect,
|
||||||
|
currentAssetIds: gallery.hero,
|
||||||
|
title: 'Pick Hero Image for Homepage',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icons.Plus /> Add Hero
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -881,14 +996,7 @@ export default function PageBuilder() {
|
|||||||
count={getAlbumCount(album.id)}
|
count={getAlbumCount(album.id)}
|
||||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||||
onRemove={() => removeStandaloneAlbum(originalIndex)}
|
onRemove={() => removeStandaloneAlbum(originalIndex)}
|
||||||
onUpdate={(updates) => {
|
onEdit={() => setEditingAlbumAddress({ type: 'standalone', albumIndex: originalIndex })}
|
||||||
setGallery((g) => {
|
|
||||||
const albums = [...g.albums];
|
|
||||||
albums[originalIndex] = { ...albums[originalIndex], ...updates };
|
|
||||||
return { ...g, albums };
|
|
||||||
});
|
|
||||||
markDirty();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1065,18 +1173,7 @@ export default function PageBuilder() {
|
|||||||
count={getAlbumCount(album.id)}
|
count={getAlbumCount(album.id)}
|
||||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||||
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
|
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
|
||||||
onUpdate={(updates) => {
|
onEdit={() => setEditingAlbumAddress({ type: 'subpage', subpageIndex: spIndex, albumIndex: aIndex })}
|
||||||
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>
|
||||||
@@ -1104,7 +1201,7 @@ export default function PageBuilder() {
|
|||||||
onClick={() => removeSection(spIndex, secIndex)}
|
onClick={() => removeSection(spIndex, secIndex)}
|
||||||
title="Remove section"
|
title="Remove section"
|
||||||
>
|
>
|
||||||
×
|
<Icons.Close />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-field">
|
<div className="admin-field">
|
||||||
@@ -1128,22 +1225,7 @@ export default function PageBuilder() {
|
|||||||
count={getAlbumCount(album.id)}
|
count={getAlbumCount(album.id)}
|
||||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||||
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
|
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
|
||||||
onUpdate={(updates) => {
|
onEdit={() => setEditingAlbumAddress({ type: 'section', subpageIndex: spIndex, sectionIndex: secIndex, albumIndex: aIndex })}
|
||||||
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>
|
||||||
@@ -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 */}
|
{/* Hero Asset Picker Modal */}
|
||||||
{showHeroPicker && (
|
{heroPickerTarget && (
|
||||||
<AssetPicker
|
<AssetPicker
|
||||||
onSelect={handleHeroSelect}
|
albumId={heroPickerTarget.albumId}
|
||||||
onClose={() => setShowHeroPicker(false)}
|
onSelect={heroPickerTarget.onSelect}
|
||||||
currentAssetIds={gallery.hero}
|
onClose={() => setHeroPickerTarget(null)}
|
||||||
|
currentAssetIds={heroPickerTarget.currentAssetIds}
|
||||||
|
title={heroPickerTarget.title}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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> {
|
function getAllUsedAlbumIds(): Set<string> {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
gallery.albums.forEach((a) => ids.add(a.id));
|
gallery.albums.forEach((a) => ids.add(a.id));
|
||||||
@@ -1224,7 +1536,7 @@ interface AlbumCardProps {
|
|||||||
count: number;
|
count: number;
|
||||||
thumbnailId: string | null;
|
thumbnailId: string | null;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onUpdate: (updates: Partial<AlbumEntry>) => void;
|
onEdit: () => void;
|
||||||
dragListeners?: Record<string, unknown>;
|
dragListeners?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1234,112 +1546,66 @@ function AlbumCard({
|
|||||||
count,
|
count,
|
||||||
thumbnailId,
|
thumbnailId,
|
||||||
onRemove,
|
onRemove,
|
||||||
onUpdate,
|
onEdit,
|
||||||
dragListeners,
|
dragListeners,
|
||||||
}: AlbumCardProps) {
|
}: AlbumCardProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const heroThumb = album.heroImage || thumbnailId;
|
||||||
const [showHeroPicker, setShowHeroPicker] = useState(false);
|
const hasPassword = !!album.password;
|
||||||
|
const hasTitleOverride = !!album.title;
|
||||||
|
const hasDescription = !!album.description;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="album-tile">
|
<div className={`album-tile ${hasPassword ? 'has-password' : ''}`}>
|
||||||
<div className="album-tile-cover">
|
<div className="album-tile-cover">
|
||||||
{dragListeners && (
|
{dragListeners && (
|
||||||
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder">
|
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder">
|
||||||
⠿
|
<Icons.Drag />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{thumbnailId ? (
|
{heroThumb ? (
|
||||||
<img src={`/api/admin/thumbnail/${thumbnailId}`} alt="" loading="lazy" />
|
<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">
|
<div className="album-tile-overlay">
|
||||||
<button
|
<button
|
||||||
className="album-tile-btn"
|
className="album-tile-btn"
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={onEdit}
|
||||||
title="Edit details"
|
title="Edit details"
|
||||||
>
|
>
|
||||||
✏️
|
<Icons.Edit />
|
||||||
</button>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="album-tile-info">
|
<div className="album-tile-info">
|
||||||
<span className="album-tile-name">{album.title || name}</span>
|
<div className="album-tile-title-row">
|
||||||
<span className="album-tile-count">{count} photos</span>
|
<span className={`album-tile-name ${hasTitleOverride ? 'custom-title' : ''}`} title={album.title || name}>
|
||||||
</div>
|
{album.title || name}
|
||||||
{expanded && (
|
</span>
|
||||||
<div className="album-tile-details">
|
<div className="album-tile-badges">
|
||||||
<div className="admin-field">
|
{hasPassword && (
|
||||||
<label>Title override</label>
|
<span className="badge badge-password" title="Password protected">
|
||||||
<input
|
<Icons.Lock />
|
||||||
value={album.title || ''}
|
</span>
|
||||||
onChange={(e) => onUpdate({ title: e.target.value || undefined })}
|
)}
|
||||||
placeholder={name}
|
{album.heroImage && (
|
||||||
/>
|
<span className="badge badge-hero" title="Custom Hero Image set">
|
||||||
</div>
|
<Icons.Image />
|
||||||
<div className="admin-field">
|
</span>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<span className="album-tile-count">{count} photos</span>
|
||||||
{showHeroPicker && (
|
</div>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user