From 9e52d7a0b89227278c9366576987d29d5853e25e Mon Sep 17 00:00:00 2001 From: ralksta Date: Fri, 29 May 2026 17:04:37 +0200 Subject: [PATCH] feat: add visual admin panel with page builder and settings editor - Add /admin route with password-protected visual page builder - Page Builder: manage hero images, standalone albums, subpages, and sections - Album Picker: browse all shared Immich albums with search and filtering - Settings Editor: theme, grid, footer, legal, SEO configuration - YAML service with atomic writes and automatic backups (content/.backups/) - HMAC-signed admin sessions (24h expiry, HttpOnly cookies) - API endpoints: /api/admin/{auth,gallery,settings,albums,reload} - Enable with ADMIN_PASSWORD env var; disabled by default - invalidateConfigCache() export for hot-reload after saves --- .env.local.example | 11 + app/admin/admin.css | 936 ++++++++++++++++++++++++ app/admin/components/AdminDashboard.tsx | 72 ++ app/admin/components/AdminLogin.tsx | 68 ++ app/admin/components/AlbumPicker.tsx | 97 +++ app/admin/components/PageBuilder.tsx | 831 +++++++++++++++++++++ app/admin/components/SettingsEditor.tsx | 559 ++++++++++++++ app/admin/layout.tsx | 10 + app/admin/page.tsx | 54 ++ app/api/admin/albums/route.ts | 67 ++ app/api/admin/auth/route.ts | 64 ++ app/api/admin/gallery/route.ts | 48 ++ app/api/admin/reload/route.ts | 19 + app/api/admin/settings/route.ts | 44 ++ lib/admin/auth.ts | 83 +++ lib/admin/yaml-service.ts | 148 ++++ lib/config/index.ts | 13 +- lib/env.ts | 4 + 18 files changed, 3127 insertions(+), 1 deletion(-) create mode 100644 app/admin/admin.css create mode 100644 app/admin/components/AdminDashboard.tsx create mode 100644 app/admin/components/AdminLogin.tsx create mode 100644 app/admin/components/AlbumPicker.tsx create mode 100644 app/admin/components/PageBuilder.tsx create mode 100644 app/admin/components/SettingsEditor.tsx create mode 100644 app/admin/layout.tsx create mode 100644 app/admin/page.tsx create mode 100644 app/api/admin/albums/route.ts create mode 100644 app/api/admin/auth/route.ts create mode 100644 app/api/admin/gallery/route.ts create mode 100644 app/api/admin/reload/route.ts create mode 100644 app/api/admin/settings/route.ts create mode 100644 lib/admin/auth.ts create mode 100644 lib/admin/yaml-service.ts diff --git a/.env.local.example b/.env.local.example index 9f75139..4640a81 100644 --- a/.env.local.example +++ b/.env.local.example @@ -30,6 +30,17 @@ CACHE_TTL=300 # If not provided, IMMICH_API_KEY will be used as a fallback (not recommended). # AUTH_SECRET=your-super-long-random-secret-here +# Webhook secret for cache invalidation (optional, string). +# Set this to a long random string and configure Immich to send webhooks to +# POST /api/webhook with an x-immich-signature header (HMAC-SHA256 of the body). +# If not set, the webhook endpoint returns 501 and is effectively disabled. +# WEBHOOK_SECRET=your-long-random-webhook-secret + +# ── Admin Panel ────────────────────────────────────────────────── +# Set a password to enable the visual admin panel at /admin. +# Leave unset or empty to disable the admin UI entirely. +# ADMIN_PASSWORD=your-secure-admin-password + # ── Album configuration ────────────────────────────────────────── # Albums, subpages, hero image, and passwords are defined in content/gallery.yaml # See content/gallery.yaml.example for the full reference. diff --git a/app/admin/admin.css b/app/admin/admin.css new file mode 100644 index 0000000..dce3557 --- /dev/null +++ b/app/admin/admin.css @@ -0,0 +1,936 @@ +/* ── Admin Panel Styles ─────────────────────────────────────── */ +/* Self-contained — does not affect the public-facing gallery. */ + +.admin-layout { + min-height: 100vh; + background: #0f0f0f; + color: #e5e5e5; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; +} + +/* ── Loading / Disabled ─────────────────────────────────────── */ + +.admin-loading { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.admin-spinner { + width: 24px; + height: 24px; + border: 2px solid #333; + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.admin-disabled { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.admin-disabled-card { + max-width: 420px; + text-align: center; +} + +.admin-disabled-card h1 { + font-size: 1.5rem; + margin-bottom: 0.75rem; +} + +.admin-disabled-card code { + background: #1a1a1a; + padding: 0.2em 0.5em; + border-radius: 4px; + font-size: 0.85em; + color: #f59e0b; +} + +/* ── Login ─────────────────────────────────────────────────── */ + +.admin-login { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.admin-login-card { + width: 100%; + max-width: 360px; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 12px; + padding: 2rem; +} + +.admin-login-header { + text-align: center; + margin-bottom: 1.5rem; +} + +.admin-login-header h1 { + font-size: 1.25rem; + font-weight: 600; + margin: 0; +} + +.admin-login-header p { + color: #888; + margin: 0.25rem 0 0; + font-size: 0.875rem; +} + +/* ── Fields ─────────────────────────────────────────────────── */ + +.admin-field { + margin-bottom: 0.75rem; +} + +.admin-field label { + display: block; + font-size: 0.75rem; + font-weight: 500; + color: #888; + margin-bottom: 0.3rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.admin-field input, +.admin-field select, +.admin-field textarea { + width: 100%; + padding: 0.6rem 0.75rem; + background: #111; + border: 1px solid #333; + border-radius: 6px; + color: #e5e5e5; + font-size: 0.875rem; + transition: border-color 0.15s; +} + +.admin-field input:focus, +.admin-field select:focus, +.admin-field textarea:focus { + outline: none; + border-color: #555; +} + +.admin-field textarea { + resize: vertical; + min-height: 60px; +} + +.admin-field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.admin-field-checks { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin: 0.75rem 0; +} + +.admin-field-checks label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: #ccc; + cursor: pointer; + text-transform: none; + letter-spacing: 0; +} + +.admin-field-checks input[type='checkbox'] { + width: 16px; + height: 16px; + accent-color: #fff; +} + +/* ── Error ─────────────────────────────────────────────────── */ + +.admin-error { + background: #2d1111; + border: 1px solid #5c2020; + color: #f87171; + padding: 0.5rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + margin-bottom: 0.75rem; +} + +/* ── Buttons ───────────────────────────────────────────────── */ + +.admin-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.6rem 1.2rem; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.admin-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.admin-btn-primary { + background: #fff; + color: #000; +} + +.admin-btn-primary:hover:not(:disabled) { + background: #e5e5e5; +} + +.admin-btn-ghost { + background: transparent; + color: #999; + border: 1px solid #333; +} + +.admin-btn-ghost:hover:not(:disabled) { + color: #fff; + border-color: #555; +} + +.admin-btn-sm { + padding: 0.4rem 0.8rem; + font-size: 0.8rem; + background: #222; + color: #ccc; + border: 1px solid #333; +} + +.admin-btn-sm:hover { + background: #2a2a2a; + border-color: #444; +} + +.admin-btn-xs { + padding: 0.3rem 0.6rem; + font-size: 0.75rem; + background: #1a1a1a; + color: #999; + border: 1px solid #2a2a2a; +} + +.admin-btn-xs:hover { + color: #ccc; + border-color: #444; +} + +.admin-btn-icon { + background: none; + border: none; + color: #666; + cursor: pointer; + font-size: 1rem; + padding: 0.25rem; + border-radius: 4px; + transition: color 0.15s; +} + +.admin-btn-icon:hover { + color: #fff; +} + +.admin-btn-icon:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* ── Dashboard Layout ──────────────────────────────────────── */ + +.admin-dashboard { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.admin-header { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + background: #0f0f0f; + border-bottom: 1px solid #222; + backdrop-filter: blur(8px); +} + +.admin-header h1 { + font-size: 1rem; + font-weight: 600; + margin: 0; + white-space: nowrap; +} + +.admin-header-left { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.admin-header-right { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.admin-tabs { + display: flex; + gap: 0; +} + +.admin-tab { + background: none; + border: none; + color: #666; + padding: 0.5rem 1rem; + font-size: 0.85rem; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s; +} + +.admin-tab:hover { + color: #ccc; +} + +.admin-tab.active { + color: #fff; + border-bottom-color: #fff; +} + +.admin-main { + flex: 1; + padding: 1.5rem; + max-width: 960px; + margin: 0 auto; + width: 100%; +} + +/* ── Save Bar ──────────────────────────────────────────────── */ + +.save-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; + border-radius: 8px; + background: #1a1a1a; + border: 1px solid #222; + transition: border-color 0.2s; +} + +.save-bar.dirty { + border-color: #f59e0b44; +} + +.save-bar-left { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.unsaved-badge { + font-size: 0.75rem; + color: #f59e0b; + font-weight: 500; +} + +.save-message { + font-size: 0.75rem; + font-weight: 500; +} + +.save-message.success { + color: #4ade80; +} +.save-message.error { + color: #f87171; +} + +/* ── Page Builder ──────────────────────────────────────────── */ + +.builder-section { + margin-bottom: 2rem; +} + +.builder-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.builder-section-header h2 { + font-size: 1rem; + font-weight: 600; + margin: 0; +} + +.empty-hint { + color: #555; + font-size: 0.8rem; + font-style: italic; + padding: 0.75rem; + border: 1px dashed #2a2a2a; + border-radius: 6px; + text-align: center; +} + +/* Hero List */ +.hero-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.hero-item { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.3rem 0.6rem; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 6px; +} + +.hero-uuid { + font-family: monospace; + font-size: 0.75rem; + color: #888; +} + +/* Album List */ +.album-list { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.album-card { + background: #161616; + border: 1px solid #2a2a2a; + border-radius: 8px; + overflow: hidden; + transition: border-color 0.15s; +} + +.album-card:hover { + border-color: #3a3a3a; +} + +.album-card-main { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 0.75rem; +} + +.album-card-info { + display: flex; + align-items: baseline; + gap: 0.5rem; + min-width: 0; +} + +.album-card-name { + font-size: 0.85rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.album-card-count { + font-size: 0.7rem; + color: #666; + white-space: nowrap; +} + +.album-card-actions { + display: flex; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; +} + +.album-card-details { + padding: 0.75rem; + border-top: 1px solid #222; + background: #111; +} + +.album-card-uuid { + margin-top: 0.5rem; +} + +.album-card-uuid code { + font-size: 0.7rem; + color: #555; + word-break: break-all; +} + +/* Subpage Card */ +.subpage-card { + background: #141414; + border: 1px solid #252525; + border-radius: 10px; + margin-bottom: 1rem; + overflow: hidden; +} + +.subpage-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: #1a1a1a; + border-bottom: 1px solid #222; +} + +.subpage-header-left { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; +} + +.subpage-move-btns { + display: flex; + flex-direction: column; + gap: 0; +} + +.subpage-move-btns .admin-btn-icon { + font-size: 0.75rem; + padding: 0.1rem 0.3rem; +} + +.subpage-name-input { + flex: 1; + background: transparent; + border: 1px solid transparent; + color: #fff; + font-size: 0.95rem; + font-weight: 600; + padding: 0.3rem 0.5rem; + border-radius: 4px; + transition: border-color 0.15s; +} + +.subpage-name-input:hover { + border-color: #333; +} + +.subpage-name-input:focus { + outline: none; + border-color: #555; + background: #111; +} + +.subpage-header-right { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.subpage-meta { + padding: 0.75rem 1rem; + border-bottom: 1px solid #1a1a1a; +} + +.subpage-albums { + padding: 0.75rem 1rem; +} + +.subpage-albums-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; + font-size: 0.75rem; + color: #666; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.subpage-sections { + padding: 0 1rem 0.5rem; +} + +.subpage-actions { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-top: 1px solid #1a1a1a; +} + +/* Section Card */ +.section-card { + background: #111; + border: 1px solid #222; + border-radius: 8px; + padding: 0.75rem; + margin-bottom: 0.5rem; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.section-title-input { + flex: 1; + background: transparent; + border: 1px solid transparent; + color: #ddd; + font-size: 0.85rem; + font-weight: 500; + padding: 0.2rem 0.4rem; + border-radius: 4px; +} + +.section-title-input:focus { + outline: none; + border-color: #444; + background: #0a0a0a; +} + +.section-desc-input { + font-size: 0.8rem !important; + padding: 0.4rem 0.5rem !important; +} + +/* ── Album Picker ──────────────────────────────────────────── */ + +.picker-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + backdrop-filter: blur(4px); +} + +.picker-modal { + width: 100%; + max-width: 560px; + max-height: 80vh; + background: #1a1a1a; + border: 1px solid #333; + border-radius: 12px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.picker-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid #2a2a2a; +} + +.picker-header h3 { + font-size: 1rem; + font-weight: 600; + margin: 0; +} + +.picker-search { + padding: 0.75rem 1.25rem; + border-bottom: 1px solid #222; +} + +.picker-search input { + width: 100%; + padding: 0.6rem 0.75rem; + background: #111; + border: 1px solid #333; + border-radius: 6px; + color: #e5e5e5; + font-size: 0.85rem; +} + +.picker-search input:focus { + outline: none; + border-color: #555; +} + +.picker-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.picker-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + border-radius: 8px; + cursor: pointer; + transition: background 0.1s; +} + +.picker-item:hover:not(.used) { + background: #222; +} + +.picker-item.used { + opacity: 0.5; + cursor: not-allowed; +} + +.picker-item-thumb { + flex-shrink: 0; +} + +.picker-thumb-placeholder { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: #222; + border-radius: 6px; + font-size: 1.1rem; +} + +.picker-item-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.picker-item-name { + font-size: 0.85rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.picker-item-meta { + font-size: 0.7rem; + color: #666; +} + +.picker-item-action { + flex-shrink: 0; +} + +.picker-used-badge { + font-size: 0.7rem; + color: #666; + padding: 0.2rem 0.5rem; + border: 1px solid #333; + border-radius: 4px; +} + +.picker-select-badge { + font-size: 0.7rem; + color: #4ade80; + padding: 0.2rem 0.5rem; + border: 1px solid #16653480; + border-radius: 4px; + opacity: 0; + transition: opacity 0.1s; +} + +.picker-item:hover .picker-select-badge { + opacity: 1; +} + +/* ── Settings Editor ──────────────────────────────────────── */ + +.settings-layout { + display: grid; + grid-template-columns: 160px 1fr; + gap: 2rem; +} + +.settings-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.settings-nav-item { + background: none; + border: none; + color: #666; + text-align: left; + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + border-radius: 6px; + cursor: pointer; + transition: all 0.1s; +} + +.settings-nav-item:hover { + color: #ccc; + background: #1a1a1a; +} + +.settings-nav-item.active { + color: #fff; + background: #222; +} + +.settings-content { + min-width: 0; +} + +.settings-panel h3 { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 1rem; +} + +/* Preset Grid */ +.preset-grid { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.preset-btn { + padding: 0.4rem 0.8rem; + background: #1a1a1a; + border: 1px solid #333; + border-radius: 6px; + color: #999; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.1s; +} + +.preset-btn:hover { + border-color: #555; + color: #fff; +} + +.preset-btn.active { + background: #fff; + color: #000; + border-color: #fff; +} + +/* Color Field */ +.color-field { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.color-field input[type='color'] { + width: 36px; + height: 36px; + padding: 0; + border: 1px solid #333; + border-radius: 6px; + background: none; + cursor: pointer; +} + +.color-field input[type='text'] { + flex: 1; + padding: 0.6rem 0.75rem; + background: #111; + border: 1px solid #333; + border-radius: 6px; + color: #e5e5e5; + font-size: 0.875rem; + font-family: monospace; +} + +/* Radio Group */ +.radio-group { + display: flex; + gap: 1rem; +} + +.radio-group label { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.85rem; + color: #ccc; + cursor: pointer; +} + +/* ── Responsive ──────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .admin-main { + padding: 1rem; + } + + .admin-header { + padding: 0.5rem 1rem; + } + + .admin-header h1 { + display: none; + } + + .settings-layout { + grid-template-columns: 1fr; + gap: 1rem; + } + + .settings-nav { + flex-direction: row; + overflow-x: auto; + gap: 0; + } + + .settings-nav-item { + white-space: nowrap; + } + + .admin-field-row { + grid-template-columns: 1fr; + } +} diff --git a/app/admin/components/AdminDashboard.tsx b/app/admin/components/AdminDashboard.tsx new file mode 100644 index 0000000..37bdfdd --- /dev/null +++ b/app/admin/components/AdminDashboard.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useState } from 'react'; +import PageBuilder from './PageBuilder'; +import SettingsEditor from './SettingsEditor'; + +interface Props { + onLogout: () => void; +} + +type Tab = 'pages' | 'settings'; + +export default function AdminDashboard({ onLogout }: Props) { + const [activeTab, setActiveTab] = useState('pages'); + const [saving, setSaving] = useState(false); + + async function handleLogout() { + await fetch('/api/admin/auth', { method: 'DELETE' }); + onLogout(); + } + + async function handleReload() { + setSaving(true); + try { + await fetch('/api/admin/reload', { method: 'POST' }); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+

Immich Folio

+ +
+
+ + +
+
+ +
+ {activeTab === 'pages' && } + {activeTab === 'settings' && } +
+
+ ); +} diff --git a/app/admin/components/AdminLogin.tsx b/app/admin/components/AdminLogin.tsx new file mode 100644 index 0000000..d70621d --- /dev/null +++ b/app/admin/components/AdminLogin.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useState } from 'react'; + +interface Props { + onSuccess: () => void; +} + +export default function AdminLogin({ onSuccess }: Props) { + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const res = await fetch('/api/admin/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + + if (res.ok) { + onSuccess(); + } else { + const data = await res.json(); + setError(data.error || 'Login failed'); + } + } catch { + setError('Connection error'); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

Immich Folio

+

Admin Panel

+
+ +
+ + setPassword(e.target.value)} + placeholder="Enter admin password" + autoFocus + disabled={loading} + /> +
+ + {error &&
{error}
} + + +
+
+ ); +} diff --git a/app/admin/components/AlbumPicker.tsx b/app/admin/components/AlbumPicker.tsx new file mode 100644 index 0000000..3c1ff06 --- /dev/null +++ b/app/admin/components/AlbumPicker.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useState, useMemo } from 'react'; + +interface ImmichAlbumInfo { + id: string; + albumName: string; + description: string; + thumbnailAssetId: string | null; + assetCount: number; + isConfigured: boolean; +} + +interface Props { + albums: ImmichAlbumInfo[]; + onSelect: (albumId: string) => void; + onClose: () => void; + usedAlbumIds: Set; +} + +export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }: Props) { + const [search, setSearch] = useState(''); + + const filtered = useMemo(() => { + if (!search.trim()) return albums; + const q = search.toLowerCase(); + return albums.filter( + (a) => + a.albumName.toLowerCase().includes(q) || + a.description.toLowerCase().includes(q) || + a.id.includes(q), + ); + }, [albums, search]); + + return ( +
+
e.stopPropagation()}> +
+

Select Album

+ +
+ +
+ setSearch(e.target.value)} + autoFocus + /> +
+ +
+ {filtered.length === 0 && ( +

+ No albums found. Make sure you have shared albums in Immich. +

+ )} + {filtered.map((album) => { + const isUsed = usedAlbumIds.has(album.id); + return ( +
!isUsed && onSelect(album.id)} + > +
+ {album.thumbnailAssetId ? ( +
📷
+ ) : ( +
📁
+ )} +
+
+ {album.albumName} + + {album.assetCount} photos + {album.description && ` · ${album.description.slice(0, 40)}`} + +
+
+ {isUsed ? ( + In use + ) : ( + Select + )} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/app/admin/components/PageBuilder.tsx b/app/admin/components/PageBuilder.tsx new file mode 100644 index 0000000..6b26f2c --- /dev/null +++ b/app/admin/components/PageBuilder.tsx @@ -0,0 +1,831 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import AlbumPicker from './AlbumPicker'; + +// ── Types ────────────────────────────────────────────────────── +interface AlbumEntry { + id: string; + title?: string; + description?: string; + password?: string; +} + +interface Section { + title: string; + description?: string; + albums: AlbumEntry[]; +} + +interface Subpage { + name: string; + title?: string; + subtitle?: string; + password?: string; + sections?: Section[]; + albums: AlbumEntry[]; + grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string }; +} + +interface GalleryState { + hero: string[]; + albums: AlbumEntry[]; + subpages: Subpage[]; +} + +interface ImmichAlbumInfo { + id: string; + albumName: string; + description: string; + thumbnailAssetId: string | null; + assetCount: number; + isConfigured: boolean; +} + +// ── Helpers ──────────────────────────────────────────────────── + +function parseAlbumEntries( + raw: + | Array< + string | Record + > + | undefined, +): AlbumEntry[] { + if (!raw) return []; + return raw.map((entry) => { + if (typeof entry === 'string') return { id: entry }; + const [id, value] = Object.entries(entry)[0]; + if (typeof value === 'string') return { id, title: value }; + return { id, title: value.title, description: value.description, password: value.password }; + }); +} + +function serializeAlbumEntries( + entries: AlbumEntry[], +): Array< + string | Record +> { + return entries.map((entry) => { + if (!entry.title && !entry.description && !entry.password) return entry.id; + if (entry.title && !entry.description && !entry.password) { + return { [entry.id]: entry.title }; + } + const val: { title: string; description?: string; password?: string } = { + title: entry.title || '', + }; + if (entry.description) val.description = entry.description; + if (entry.password) val.password = entry.password; + return { [entry.id]: val }; + }); +} + +// ── Component ────────────────────────────────────────────────── + +export default function PageBuilder() { + const [gallery, setGallery] = useState({ hero: [], albums: [], subpages: [] }); + const [immichAlbums, setImmichAlbums] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + const [saveMessage, setSaveMessage] = useState(''); + const [pickerTarget, setPickerTarget] = useState<{ + type: 'standalone' | 'subpage' | 'section'; + subpageIndex?: number; + sectionIndex?: number; + } | null>(null); + + // Load data + useEffect(() => { + loadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function loadData() { + setLoading(true); + try { + const [galleryRes, albumsRes] = await Promise.all([ + fetch('/api/admin/gallery'), + fetch('/api/admin/albums'), + ]); + + if (galleryRes.ok) { + const { gallery: raw } = await galleryRes.json(); + setGallery(parseGalleryYaml(raw)); + } + + if (albumsRes.ok) { + const { albums } = await albumsRes.json(); + setImmichAlbums(albums); + } + } catch (err) { + console.error('Failed to load admin data:', err); + } finally { + setLoading(false); + } + } + + function parseGalleryYaml(raw: Record): GalleryState { + const hero = Array.isArray(raw.hero) ? raw.hero : raw.hero ? [raw.hero as string] : []; + const albums = parseAlbumEntries( + raw.albums as Array> | undefined, + ); + + let subpages: Subpage[] = []; + if (Array.isArray(raw.subpages)) { + subpages = (raw.subpages as Array>).map((sp) => ({ + name: (sp.name as string) || '', + title: sp.title as string | undefined, + subtitle: sp.subtitle as string | undefined, + password: sp.password as string | undefined, + albums: parseAlbumEntries(sp.albums as Array> | undefined), + sections: sp.sections + ? (sp.sections as Array>).map((sec) => ({ + title: (sec.title as string) || '', + description: sec.description as string | undefined, + albums: parseAlbumEntries(sec.albums as Array>), + })) + : undefined, + grid: sp.grid as Subpage['grid'], + })); + } else if (raw.subpages && typeof raw.subpages === 'object') { + subpages = Object.entries(raw.subpages as Record).map(([name, value]) => { + if (Array.isArray(value)) { + return { name, albums: parseAlbumEntries(value), sections: undefined }; + } + const sp = value as Record; + return { + name, + title: sp.title as string | undefined, + subtitle: sp.subtitle as string | undefined, + password: sp.password as string | undefined, + albums: parseAlbumEntries( + sp.albums as Array> | undefined, + ), + sections: undefined, + grid: sp.grid as Subpage['grid'], + }; + }); + } + + return { hero, albums, subpages }; + } + + const markDirty = useCallback(() => { + setDirty(true); + setSaveMessage(''); + }, []); + + // ── Save ────────────────────────────────────────────────────── + async function handleSave() { + setSaving(true); + setSaveMessage(''); + + // Serialize back to YAML-compatible format + const yamlData: Record = {}; + + if (gallery.hero.length > 0) { + yamlData.hero = gallery.hero; + } + if (gallery.albums.length > 0) { + yamlData.albums = serializeAlbumEntries(gallery.albums); + } + if (gallery.subpages.length > 0) { + yamlData.subpages = gallery.subpages.map((sp) => { + const entry: Record = { name: sp.name }; + if (sp.title) entry.title = sp.title; + if (sp.subtitle) entry.subtitle = sp.subtitle; + if (sp.password) entry.password = sp.password; + if (sp.grid) entry.grid = sp.grid; + + if (sp.sections && sp.sections.length > 0) { + entry.sections = sp.sections.map((sec) => { + const s: Record = { + title: sec.title, + albums: serializeAlbumEntries(sec.albums), + }; + if (sec.description) s.description = sec.description; + return s; + }); + // Also include top-level albums if they exist alongside sections + if (sp.albums.length > 0) { + entry.albums = serializeAlbumEntries(sp.albums); + } + } else { + entry.albums = serializeAlbumEntries(sp.albums); + } + + return entry; + }); + } + + try { + const res = await fetch('/api/admin/gallery', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ gallery: yamlData }), + }); + + if (res.ok) { + setDirty(false); + setSaveMessage('Saved successfully!'); + setTimeout(() => setSaveMessage(''), 3000); + } else { + const err = await res.json(); + setSaveMessage(`Error: ${err.error}`); + } + } catch { + setSaveMessage('Error: Failed to save'); + } finally { + setSaving(false); + } + } + + // ── Album Picker Handlers ──────────────────────────────────── + function handlePickAlbum(albumId: string) { + if (!pickerTarget) return; + + const entry: AlbumEntry = { id: albumId }; + + if (pickerTarget.type === 'standalone') { + setGallery((g) => ({ ...g, albums: [...g.albums, entry] })); + } else if (pickerTarget.type === 'subpage' && pickerTarget.subpageIndex != null) { + setGallery((g) => { + const subpages = [...g.subpages]; + const sp = { ...subpages[pickerTarget.subpageIndex!] }; + sp.albums = [...sp.albums, entry]; + subpages[pickerTarget.subpageIndex!] = sp; + return { ...g, subpages }; + }); + } else if ( + pickerTarget.type === 'section' && + pickerTarget.subpageIndex != null && + pickerTarget.sectionIndex != null + ) { + setGallery((g) => { + const subpages = [...g.subpages]; + const sp = { ...subpages[pickerTarget.subpageIndex!] }; + const sections = [...(sp.sections || [])]; + const sec = { ...sections[pickerTarget.sectionIndex!] }; + sec.albums = [...sec.albums, entry]; + sections[pickerTarget.sectionIndex!] = sec; + sp.sections = sections; + subpages[pickerTarget.subpageIndex!] = sp; + return { ...g, subpages }; + }); + } + + setPickerTarget(null); + markDirty(); + } + + // ── Subpage Management ──────────────────────────────────────── + function addSubpage() { + setGallery((g) => ({ + ...g, + subpages: [ + ...g.subpages, + { name: `New Page ${g.subpages.length + 1}`, albums: [], sections: undefined }, + ], + })); + markDirty(); + } + + function removeSubpage(index: number) { + if (!confirm('Remove this subpage?')) return; + setGallery((g) => ({ + ...g, + subpages: g.subpages.filter((_, i) => i !== index), + })); + markDirty(); + } + + function updateSubpage(index: number, updates: Partial) { + setGallery((g) => { + const subpages = [...g.subpages]; + subpages[index] = { ...subpages[index], ...updates }; + return { ...g, subpages }; + }); + markDirty(); + } + + // ── Section Management ──────────────────────────────────────── + function addSection(subpageIndex: number) { + setGallery((g) => { + const subpages = [...g.subpages]; + const sp = { ...subpages[subpageIndex] }; + sp.sections = [...(sp.sections || []), { title: 'New Section', albums: [] }]; + subpages[subpageIndex] = sp; + return { ...g, subpages }; + }); + markDirty(); + } + + function removeSection(subpageIndex: number, sectionIndex: number) { + setGallery((g) => { + const subpages = [...g.subpages]; + const sp = { ...subpages[subpageIndex] }; + sp.sections = (sp.sections || []).filter((_, i) => i !== sectionIndex); + subpages[subpageIndex] = sp; + return { ...g, subpages }; + }); + markDirty(); + } + + function updateSection(subpageIndex: number, sectionIndex: number, updates: Partial
) { + setGallery((g) => { + const subpages = [...g.subpages]; + const sp = { ...subpages[subpageIndex] }; + const sections = [...(sp.sections || [])]; + sections[sectionIndex] = { ...sections[sectionIndex], ...updates }; + sp.sections = sections; + subpages[subpageIndex] = sp; + return { ...g, subpages }; + }); + markDirty(); + } + + // ── Album Removal ──────────────────────────────────────────── + function removeStandaloneAlbum(index: number) { + setGallery((g) => ({ + ...g, + albums: g.albums.filter((_, i) => i !== index), + })); + markDirty(); + } + + function removeSubpageAlbum(subpageIndex: number, albumIndex: number) { + setGallery((g) => { + const subpages = [...g.subpages]; + const sp = { ...subpages[subpageIndex] }; + sp.albums = sp.albums.filter((_, i) => i !== albumIndex); + subpages[subpageIndex] = sp; + return { ...g, subpages }; + }); + markDirty(); + } + + function removeSectionAlbum(subpageIndex: number, sectionIndex: number, albumIndex: number) { + setGallery((g) => { + const subpages = [...g.subpages]; + const sp = { ...subpages[subpageIndex] }; + const sections = [...(sp.sections || [])]; + const sec = { ...sections[sectionIndex] }; + sec.albums = sec.albums.filter((_, i) => i !== albumIndex); + sections[sectionIndex] = sec; + sp.sections = sections; + subpages[subpageIndex] = sp; + return { ...g, subpages }; + }); + markDirty(); + } + + // ── Hero Management ────────────────────────────────────────── + function removeHero(index: number) { + setGallery((g) => ({ + ...g, + hero: g.hero.filter((_, i) => i !== index), + })); + markDirty(); + } + + function addHero() { + const uuid = prompt('Enter Immich asset UUID for hero image:'); + if (uuid?.trim()) { + setGallery((g) => ({ ...g, hero: [...g.hero, uuid.trim()] })); + markDirty(); + } + } + + // ── Move helpers ────────────────────────────────────────────── + function moveSubpage(index: number, direction: -1 | 1) { + const newIndex = index + direction; + if (newIndex < 0 || newIndex >= gallery.subpages.length) return; + setGallery((g) => { + const subpages = [...g.subpages]; + [subpages[index], subpages[newIndex]] = [subpages[newIndex], subpages[index]]; + return { ...g, subpages }; + }); + markDirty(); + } + + // ── Helpers ────────────────────────────────────────────────── + function getAlbumName(id: string): string { + const found = immichAlbums.find((a) => a.id === id); + return found?.albumName || id.slice(0, 8) + '...'; + } + + function getAlbumCount(id: string): number { + const found = immichAlbums.find((a) => a.id === id); + return found?.assetCount || 0; + } + + // ── Render ─────────────────────────────────────────────────── + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Save Bar */} +
+
+ {dirty && Unsaved changes} + {saveMessage && ( + + {saveMessage} + + )} +
+ +
+ + {/* Hero Section */} +
+
+

🏠 Homepage Hero

+ +
+
+ {gallery.hero.length === 0 && ( +

+ No hero images configured. Add asset UUIDs to show a hero carousel. +

+ )} + {gallery.hero.map((id, i) => ( +
+ + {id.slice(0, 8)}...{id.slice(-4)} + + +
+ ))} +
+
+ + {/* Standalone Albums */} +
+
+

📷 Standalone Albums

+ +
+
+ {gallery.albums.length === 0 && ( +

No standalone albums. These show directly on the homepage.

+ )} + {gallery.albums.map((album, i) => ( + removeStandaloneAlbum(i)} + onUpdate={(updates) => { + setGallery((g) => { + const albums = [...g.albums]; + albums[i] = { ...albums[i], ...updates }; + return { ...g, albums }; + }); + markDirty(); + }} + /> + ))} +
+
+ + {/* Subpages */} +
+
+

📂 Subpages

+ +
+ + {gallery.subpages.length === 0 && ( +

+ No subpages. Create one to group albums under a custom URL path. +

+ )} + + {gallery.subpages.map((sp, spIndex) => ( +
+
+
+
+ + +
+ updateSubpage(spIndex, { name: e.target.value })} + placeholder="Page name (used for URL)" + /> +
+
+ +
+
+ + {/* Subpage metadata */} +
+
+
+ + updateSubpage(spIndex, { title: e.target.value || undefined })} + placeholder="Display title (defaults to name)" + /> +
+
+ + + updateSubpage(spIndex, { subtitle: e.target.value || undefined }) + } + placeholder="Subtitle text" + /> +
+
+
+
+ + + updateSubpage(spIndex, { password: e.target.value || undefined }) + } + placeholder="Leave empty for public" + /> +
+
+
+ + {/* Albums (if no sections) */} + {(!sp.sections || sp.sections.length === 0) && ( +
+
+ Albums + +
+
+ {sp.albums.map((album, aIndex) => ( + 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(); + }} + /> + ))} +
+
+ )} + + {/* Sections */} + {sp.sections && sp.sections.length > 0 && ( +
+ {sp.sections.map((sec, secIndex) => ( +
+
+ + updateSection(spIndex, secIndex, { title: e.target.value }) + } + placeholder="Section title" + /> + +
+
+ + updateSection(spIndex, secIndex, { + description: e.target.value || undefined, + }) + } + placeholder="Section description (optional)" + className="section-desc-input" + /> +
+
+ {sec.albums.map((album, aIndex) => ( + 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(); + }} + /> + ))} +
+ +
+ ))} +
+ )} + +
+ + {sp.sections && sp.sections.length > 0 && ( + + )} +
+
+ ))} +
+ + {/* Album Picker Modal */} + {pickerTarget && ( + setPickerTarget(null)} + usedAlbumIds={getAllUsedAlbumIds()} + /> + )} +
+ ); + + function getAllUsedAlbumIds(): Set { + const ids = new Set(); + gallery.albums.forEach((a) => ids.add(a.id)); + gallery.subpages.forEach((sp) => { + sp.albums.forEach((a) => ids.add(a.id)); + sp.sections?.forEach((sec) => sec.albums.forEach((a) => ids.add(a.id))); + }); + return ids; + } +} + +// ── Album Card Sub-component ─────────────────────────────────── + +interface AlbumCardProps { + album: AlbumEntry; + name: string; + count: number; + onRemove: () => void; + onUpdate: (updates: Partial) => void; +} + +function AlbumCard({ album, name, count, onRemove, onUpdate }: AlbumCardProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+
+ {album.title || name} + {count} photos +
+
+ + +
+
+ {expanded && ( +
+
+ + onUpdate({ title: e.target.value || undefined })} + placeholder={name} + /> +
+
+ + onUpdate({ description: e.target.value || undefined })} + placeholder="Optional description" + /> +
+
+ + onUpdate({ password: e.target.value || undefined })} + placeholder="Leave empty for public" + /> +
+
+ {album.id} +
+
+ )} +
+ ); +} diff --git a/app/admin/components/SettingsEditor.tsx b/app/admin/components/SettingsEditor.tsx new file mode 100644 index 0000000..d2b2472 --- /dev/null +++ b/app/admin/components/SettingsEditor.tsx @@ -0,0 +1,559 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +interface Settings { + title?: string; + subtitle?: string; + lang?: string; + exifOnHover?: boolean; + map?: boolean; + transitions?: boolean; + theme?: { + preset?: string; + accent?: string; + photoFrame?: string; + grain?: boolean; + headerDot?: boolean; + heroStyle?: string; + }; + grid?: { + columns?: number; + gap?: number; + aspectRatio?: string; + layout?: string; + }; + footer?: { + name?: string; + instagram?: string; + email?: string; + website?: string; + }; + legal?: { + enabled?: boolean; + name?: string; + address?: string; + zipCity?: string; + country?: string; + email?: string; + phone?: string; + taxId?: string; + vatId?: string; + extraInfo?: string; + }; + seo?: { + title?: string; + description?: string; + noIndex?: boolean; + noFollow?: boolean; + }; +} + +const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph']; +const LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow']; +const PHOTO_FRAMES = ['none', 'passepartout', 'shadow']; +const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic']; +const ASPECT_RATIOS = ['1', '3/2', '2/3', '16/9', 'auto']; + +export default function SettingsEditor() { + const [settings, setSettings] = useState({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [dirty, setDirty] = useState(false); + const [saveMessage, setSaveMessage] = useState(''); + const [activeSection, setActiveSection] = useState('general'); + + useEffect(() => { + loadSettings(); + }, []); + + async function loadSettings() { + setLoading(true); + try { + const res = await fetch('/api/admin/settings'); + if (res.ok) { + const { settings: data } = await res.json(); + setSettings(data || {}); + } + } catch (err) { + console.error('Failed to load settings:', err); + } finally { + setLoading(false); + } + } + + function update(path: string, value: unknown) { + setSettings((s) => { + const copy = JSON.parse(JSON.stringify(s)); + const parts = path.split('.'); + let obj = copy; + for (let i = 0; i < parts.length - 1; i++) { + if (!obj[parts[i]]) obj[parts[i]] = {}; + obj = obj[parts[i]]; + } + const key = parts[parts.length - 1]; + if (value === '' || value === undefined) { + delete obj[key]; + } else { + obj[key] = value; + } + return copy; + }); + setDirty(true); + setSaveMessage(''); + } + + async function handleSave() { + setSaving(true); + setSaveMessage(''); + + // Clean up empty objects + const cleaned = JSON.parse(JSON.stringify(settings)); + for (const key of Object.keys(cleaned)) { + if (typeof cleaned[key] === 'object' && Object.keys(cleaned[key]).length === 0) { + delete cleaned[key]; + } + } + + try { + const res = await fetch('/api/admin/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: cleaned }), + }); + + if (res.ok) { + setDirty(false); + setSaveMessage('Saved!'); + setTimeout(() => setSaveMessage(''), 3000); + } else { + const err = await res.json(); + setSaveMessage(`Error: ${err.error}`); + } + } catch { + setSaveMessage('Error: Failed to save'); + } finally { + setSaving(false); + } + } + + if (loading) { + return ( +
+
+
+ ); + } + + const sections = [ + { id: 'general', label: 'General' }, + { id: 'theme', label: 'Theme' }, + { id: 'grid', label: 'Grid' }, + { id: 'footer', label: 'Footer' }, + { id: 'legal', label: 'Legal' }, + { id: 'seo', label: 'SEO' }, + ]; + + return ( +
+ {/* Save Bar */} +
+
+ {dirty && Unsaved changes} + {saveMessage && ( + + {saveMessage} + + )} +
+ +
+ +
+ {/* Sidebar */} + + + {/* Content */} +
+ {activeSection === 'general' && ( +
+

General

+
+ + update('title', e.target.value)} + placeholder="My Portfolio" + /> +
+
+ + update('subtitle', e.target.value)} + placeholder="A visual journal" + /> +
+
+ + +
+
+ + + +
+
+ )} + + {activeSection === 'theme' && ( +
+

Theme

+
+ +
+ {PRESETS.map((p) => ( + + ))} +
+
+
+ +
+ update('theme.accent', e.target.value)} + /> + update('theme.accent', e.target.value)} + placeholder="#e60012" + /> +
+
+
+ +
+ {PHOTO_FRAMES.map((f) => ( + + ))} +
+
+
+ + +
+
+ + +
+
+ )} + + {activeSection === 'grid' && ( +
+

Grid Layout

+
+ +
+ {LAYOUTS.map((l) => ( + + ))} +
+
+
+
+ + update('grid.columns', parseInt(e.target.value) || 3)} + /> +
+
+ + update('grid.gap', parseInt(e.target.value) || 0)} + /> +
+
+
+ +
+ {ASPECT_RATIOS.map((r) => ( + + ))} +
+
+
+ )} + + {activeSection === 'footer' && ( +
+

Footer

+
+ + update('footer.name', e.target.value)} + placeholder="My Photography" + /> +
+
+ + update('footer.instagram', e.target.value)} + placeholder="https://instagram.com/your-handle" + /> +
+
+ + update('footer.email', e.target.value)} + placeholder="hello@example.com" + /> +
+
+ + update('footer.website', e.target.value)} + placeholder="https://example.com" + /> +
+
+ )} + + {activeSection === 'legal' && ( +
+

Legal / Impressum

+
+ +
+ {settings.legal?.enabled && ( + <> +
+ + update('legal.name', e.target.value)} + placeholder="Max Mustermann" + /> +
+
+ + update('legal.address', e.target.value)} + placeholder="Musterstraße 1" + /> +
+
+
+ + update('legal.zipCity', e.target.value)} + placeholder="12345 Berlin" + /> +
+
+ + update('legal.country', e.target.value)} + placeholder="Germany" + /> +
+
+
+
+ + update('legal.email', e.target.value)} + placeholder="legal@example.com" + /> +
+
+ + update('legal.phone', e.target.value)} + placeholder="+49 123 456789" + /> +
+
+
+ +