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
This commit is contained in:
@@ -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.
|
||||
|
||||
936
app/admin/admin.css
Normal file
936
app/admin/admin.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
72
app/admin/components/AdminDashboard.tsx
Normal file
72
app/admin/components/AdminDashboard.tsx
Normal file
@@ -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<Tab>('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 (
|
||||
<div className="admin-dashboard">
|
||||
<header className="admin-header">
|
||||
<div className="admin-header-left">
|
||||
<h1>Immich Folio</h1>
|
||||
<nav className="admin-tabs">
|
||||
<button
|
||||
className={`admin-tab ${activeTab === 'pages' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('pages')}
|
||||
>
|
||||
Pages
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab ${activeTab === 'settings' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('settings')}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="admin-header-right">
|
||||
<button
|
||||
className="admin-btn admin-btn-ghost"
|
||||
onClick={handleReload}
|
||||
disabled={saving}
|
||||
title="Reload config & clear cache"
|
||||
>
|
||||
↻ Reload
|
||||
</button>
|
||||
<button className="admin-btn admin-btn-ghost" onClick={handleLogout}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="admin-main">
|
||||
{activeTab === 'pages' && <PageBuilder />}
|
||||
{activeTab === 'settings' && <SettingsEditor />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
app/admin/components/AdminLogin.tsx
Normal file
68
app/admin/components/AdminLogin.tsx
Normal file
@@ -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 (
|
||||
<div className="admin-login">
|
||||
<form className="admin-login-card" onSubmit={handleSubmit}>
|
||||
<div className="admin-login-header">
|
||||
<h1>Immich Folio</h1>
|
||||
<p>Admin Panel</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<label htmlFor="admin-password">Password</label>
|
||||
<input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter admin password"
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="admin-error">{error}</div>}
|
||||
|
||||
<button type="submit" className="admin-btn admin-btn-primary" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
app/admin/components/AlbumPicker.tsx
Normal file
97
app/admin/components/AlbumPicker.tsx
Normal file
@@ -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<string>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="picker-overlay" onClick={onClose}>
|
||||
<div className="picker-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-header">
|
||||
<h3>Select Album</h3>
|
||||
<button className="admin-btn-icon" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="picker-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search albums by name or UUID..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="picker-list">
|
||||
{filtered.length === 0 && (
|
||||
<p className="empty-hint">
|
||||
No albums found. Make sure you have shared albums in Immich.
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((album) => {
|
||||
const isUsed = usedAlbumIds.has(album.id);
|
||||
return (
|
||||
<div
|
||||
key={album.id}
|
||||
className={`picker-item ${isUsed ? 'used' : ''}`}
|
||||
onClick={() => !isUsed && onSelect(album.id)}
|
||||
>
|
||||
<div className="picker-item-thumb">
|
||||
{album.thumbnailAssetId ? (
|
||||
<div className="picker-thumb-placeholder">📷</div>
|
||||
) : (
|
||||
<div className="picker-thumb-placeholder">📁</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="picker-item-info">
|
||||
<span className="picker-item-name">{album.albumName}</span>
|
||||
<span className="picker-item-meta">
|
||||
{album.assetCount} photos
|
||||
{album.description && ` · ${album.description.slice(0, 40)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="picker-item-action">
|
||||
{isUsed ? (
|
||||
<span className="picker-used-badge">In use</span>
|
||||
) : (
|
||||
<span className="picker-select-badge">Select</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
831
app/admin/components/PageBuilder.tsx
Normal file
831
app/admin/components/PageBuilder.tsx
Normal file
@@ -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<string, string | { title: string; description?: string; password?: string }>
|
||||
>
|
||||
| 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<string, string | { title: string; description?: string; password?: string }>
|
||||
> {
|
||||
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<GalleryState>({ hero: [], albums: [], subpages: [] });
|
||||
const [immichAlbums, setImmichAlbums] = useState<ImmichAlbumInfo[]>([]);
|
||||
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<string, unknown>): GalleryState {
|
||||
const hero = Array.isArray(raw.hero) ? raw.hero : raw.hero ? [raw.hero as string] : [];
|
||||
const albums = parseAlbumEntries(
|
||||
raw.albums as Array<string | Record<string, string>> | undefined,
|
||||
);
|
||||
|
||||
let subpages: Subpage[] = [];
|
||||
if (Array.isArray(raw.subpages)) {
|
||||
subpages = (raw.subpages as Array<Record<string, unknown>>).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<string | Record<string, string>> | undefined),
|
||||
sections: sp.sections
|
||||
? (sp.sections as Array<Record<string, unknown>>).map((sec) => ({
|
||||
title: (sec.title as string) || '',
|
||||
description: sec.description as string | undefined,
|
||||
albums: parseAlbumEntries(sec.albums as Array<string | Record<string, string>>),
|
||||
}))
|
||||
: undefined,
|
||||
grid: sp.grid as Subpage['grid'],
|
||||
}));
|
||||
} else if (raw.subpages && typeof raw.subpages === 'object') {
|
||||
subpages = Object.entries(raw.subpages as Record<string, unknown>).map(([name, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
return { name, albums: parseAlbumEntries(value), sections: undefined };
|
||||
}
|
||||
const sp = value as Record<string, unknown>;
|
||||
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<string | Record<string, string>> | 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<string, unknown> = {};
|
||||
|
||||
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<string, unknown> = { 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<string, unknown> = {
|
||||
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<Subpage>) {
|
||||
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<Section>) {
|
||||
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 (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-builder">
|
||||
{/* Save Bar */}
|
||||
<div className={`save-bar ${dirty ? 'dirty' : ''}`}>
|
||||
<div className="save-bar-left">
|
||||
{dirty && <span className="unsaved-badge">Unsaved changes</span>}
|
||||
{saveMessage && (
|
||||
<span
|
||||
className={`save-message ${saveMessage.startsWith('Error') ? 'error' : 'success'}`}
|
||||
>
|
||||
{saveMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="builder-section">
|
||||
<div className="builder-section-header">
|
||||
<h2>🏠 Homepage Hero</h2>
|
||||
<button className="admin-btn admin-btn-sm" onClick={addHero}>
|
||||
+ Add Hero
|
||||
</button>
|
||||
</div>
|
||||
<div className="hero-list">
|
||||
{gallery.hero.length === 0 && (
|
||||
<p className="empty-hint">
|
||||
No hero images configured. Add asset UUIDs to show a hero carousel.
|
||||
</p>
|
||||
)}
|
||||
{gallery.hero.map((id, i) => (
|
||||
<div key={i} className="hero-item">
|
||||
<span className="hero-uuid" title={id}>
|
||||
{id.slice(0, 8)}...{id.slice(-4)}
|
||||
</span>
|
||||
<button className="admin-btn-icon" onClick={() => removeHero(i)} title="Remove">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Standalone Albums */}
|
||||
<section className="builder-section">
|
||||
<div className="builder-section-header">
|
||||
<h2>📷 Standalone Albums</h2>
|
||||
<button
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={() => setPickerTarget({ type: 'standalone' })}
|
||||
>
|
||||
+ Add Album
|
||||
</button>
|
||||
</div>
|
||||
<div className="album-list">
|
||||
{gallery.albums.length === 0 && (
|
||||
<p className="empty-hint">No standalone albums. These show directly on the homepage.</p>
|
||||
)}
|
||||
{gallery.albums.map((album, i) => (
|
||||
<AlbumCard
|
||||
key={`${album.id}-${i}`}
|
||||
album={album}
|
||||
name={getAlbumName(album.id)}
|
||||
count={getAlbumCount(album.id)}
|
||||
onRemove={() => removeStandaloneAlbum(i)}
|
||||
onUpdate={(updates) => {
|
||||
setGallery((g) => {
|
||||
const albums = [...g.albums];
|
||||
albums[i] = { ...albums[i], ...updates };
|
||||
return { ...g, albums };
|
||||
});
|
||||
markDirty();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Subpages */}
|
||||
<section className="builder-section">
|
||||
<div className="builder-section-header">
|
||||
<h2>📂 Subpages</h2>
|
||||
<button className="admin-btn admin-btn-sm" onClick={addSubpage}>
|
||||
+ New Subpage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{gallery.subpages.length === 0 && (
|
||||
<p className="empty-hint">
|
||||
No subpages. Create one to group albums under a custom URL path.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{gallery.subpages.map((sp, spIndex) => (
|
||||
<div key={spIndex} className="subpage-card">
|
||||
<div className="subpage-header">
|
||||
<div className="subpage-header-left">
|
||||
<div className="subpage-move-btns">
|
||||
<button
|
||||
className="admin-btn-icon"
|
||||
onClick={() => moveSubpage(spIndex, -1)}
|
||||
disabled={spIndex === 0}
|
||||
title="Move up"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn-icon"
|
||||
onClick={() => moveSubpage(spIndex, 1)}
|
||||
disabled={spIndex === gallery.subpages.length - 1}
|
||||
title="Move down"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="subpage-name-input"
|
||||
value={sp.name}
|
||||
onChange={(e) => updateSubpage(spIndex, { name: e.target.value })}
|
||||
placeholder="Page name (used for URL)"
|
||||
/>
|
||||
</div>
|
||||
<div className="subpage-header-right">
|
||||
<button
|
||||
className="admin-btn-icon"
|
||||
onClick={() => removeSubpage(spIndex)}
|
||||
title="Delete subpage"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subpage metadata */}
|
||||
<div className="subpage-meta">
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>Title (optional)</label>
|
||||
<input
|
||||
value={sp.title || ''}
|
||||
onChange={(e) => updateSubpage(spIndex, { title: e.target.value || undefined })}
|
||||
placeholder="Display title (defaults to name)"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Subtitle</label>
|
||||
<input
|
||||
value={sp.subtitle || ''}
|
||||
onChange={(e) =>
|
||||
updateSubpage(spIndex, { subtitle: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Subtitle text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>Password (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={sp.password || ''}
|
||||
onChange={(e) =>
|
||||
updateSubpage(spIndex, { password: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Leave empty for public"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Albums (if no sections) */}
|
||||
{(!sp.sections || sp.sections.length === 0) && (
|
||||
<div className="subpage-albums">
|
||||
<div className="subpage-albums-header">
|
||||
<span>Albums</span>
|
||||
<button
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => setPickerTarget({ type: 'subpage', subpageIndex: spIndex })}
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="album-list">
|
||||
{sp.albums.map((album, aIndex) => (
|
||||
<AlbumCard
|
||||
key={`${album.id}-${aIndex}`}
|
||||
album={album}
|
||||
name={getAlbumName(album.id)}
|
||||
count={getAlbumCount(album.id)}
|
||||
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
|
||||
onUpdate={(updates) => {
|
||||
setGallery((g) => {
|
||||
const subpages = [...g.subpages];
|
||||
const sp2 = { ...subpages[spIndex] };
|
||||
const albums = [...sp2.albums];
|
||||
albums[aIndex] = { ...albums[aIndex], ...updates };
|
||||
sp2.albums = albums;
|
||||
subpages[spIndex] = sp2;
|
||||
return { ...g, subpages };
|
||||
});
|
||||
markDirty();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sections */}
|
||||
{sp.sections && sp.sections.length > 0 && (
|
||||
<div className="subpage-sections">
|
||||
{sp.sections.map((sec, secIndex) => (
|
||||
<div key={secIndex} className="section-card">
|
||||
<div className="section-header">
|
||||
<input
|
||||
className="section-title-input"
|
||||
value={sec.title}
|
||||
onChange={(e) =>
|
||||
updateSection(spIndex, secIndex, { title: e.target.value })
|
||||
}
|
||||
placeholder="Section title"
|
||||
/>
|
||||
<button
|
||||
className="admin-btn-icon"
|
||||
onClick={() => removeSection(spIndex, secIndex)}
|
||||
title="Remove section"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<input
|
||||
value={sec.description || ''}
|
||||
onChange={(e) =>
|
||||
updateSection(spIndex, secIndex, {
|
||||
description: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Section description (optional)"
|
||||
className="section-desc-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="album-list">
|
||||
{sec.albums.map((album, aIndex) => (
|
||||
<AlbumCard
|
||||
key={`${album.id}-${aIndex}`}
|
||||
album={album}
|
||||
name={getAlbumName(album.id)}
|
||||
count={getAlbumCount(album.id)}
|
||||
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
|
||||
onUpdate={(updates) => {
|
||||
setGallery((g) => {
|
||||
const subpages = [...g.subpages];
|
||||
const sp2 = { ...subpages[spIndex] };
|
||||
const sections = [...(sp2.sections || [])];
|
||||
const sec2 = { ...sections[secIndex] };
|
||||
const albums = [...sec2.albums];
|
||||
albums[aIndex] = { ...albums[aIndex], ...updates };
|
||||
sec2.albums = albums;
|
||||
sections[secIndex] = sec2;
|
||||
sp2.sections = sections;
|
||||
subpages[spIndex] = sp2;
|
||||
return { ...g, subpages };
|
||||
});
|
||||
markDirty();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
setPickerTarget({
|
||||
type: 'section',
|
||||
subpageIndex: spIndex,
|
||||
sectionIndex: secIndex,
|
||||
})
|
||||
}
|
||||
>
|
||||
+ Add Album
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="subpage-actions">
|
||||
<button className="admin-btn admin-btn-xs" onClick={() => addSection(spIndex)}>
|
||||
+ Add Section
|
||||
</button>
|
||||
{sp.sections && sp.sections.length > 0 && (
|
||||
<button
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => setPickerTarget({ type: 'subpage', subpageIndex: spIndex })}
|
||||
>
|
||||
+ Add Album (outside sections)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* Album Picker Modal */}
|
||||
{pickerTarget && (
|
||||
<AlbumPicker
|
||||
albums={immichAlbums}
|
||||
onSelect={handlePickAlbum}
|
||||
onClose={() => setPickerTarget(null)}
|
||||
usedAlbumIds={getAllUsedAlbumIds()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
function getAllUsedAlbumIds(): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
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<AlbumEntry>) => void;
|
||||
}
|
||||
|
||||
function AlbumCard({ album, name, count, onRemove, onUpdate }: AlbumCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="album-card">
|
||||
<div className="album-card-main">
|
||||
<div className="album-card-info">
|
||||
<span className="album-card-name">{album.title || name}</span>
|
||||
<span className="album-card-count">{count} photos</span>
|
||||
</div>
|
||||
<div className="album-card-actions">
|
||||
<button
|
||||
className="admin-btn-icon"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
title="Edit details"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button className="admin-btn-icon" onClick={onRemove} title="Remove">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="album-card-details">
|
||||
<div className="admin-field">
|
||||
<label>Title override</label>
|
||||
<input
|
||||
value={album.title || ''}
|
||||
onChange={(e) => onUpdate({ title: e.target.value || undefined })}
|
||||
placeholder={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Description</label>
|
||||
<input
|
||||
value={album.description || ''}
|
||||
onChange={(e) => onUpdate({ description: e.target.value || undefined })}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={album.password || ''}
|
||||
onChange={(e) => onUpdate({ password: e.target.value || undefined })}
|
||||
placeholder="Leave empty for public"
|
||||
/>
|
||||
</div>
|
||||
<div className="album-card-uuid">
|
||||
<code>{album.id}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
559
app/admin/components/SettingsEditor.tsx
Normal file
559
app/admin/components/SettingsEditor.tsx
Normal file
@@ -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<Settings>({});
|
||||
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 (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="settings-editor">
|
||||
{/* Save Bar */}
|
||||
<div className={`save-bar ${dirty ? 'dirty' : ''}`}>
|
||||
<div className="save-bar-left">
|
||||
{dirty && <span className="unsaved-badge">Unsaved changes</span>}
|
||||
{saveMessage && (
|
||||
<span
|
||||
className={`save-message ${saveMessage.startsWith('Error') ? 'error' : 'success'}`}
|
||||
>
|
||||
{saveMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-layout">
|
||||
{/* Sidebar */}
|
||||
<nav className="settings-nav">
|
||||
{sections.map((sec) => (
|
||||
<button
|
||||
key={sec.id}
|
||||
className={`settings-nav-item ${activeSection === sec.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveSection(sec.id)}
|
||||
>
|
||||
{sec.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<div className="settings-content">
|
||||
{activeSection === 'general' && (
|
||||
<div className="settings-panel">
|
||||
<h3>General</h3>
|
||||
<div className="admin-field">
|
||||
<label>Site Title</label>
|
||||
<input
|
||||
value={settings.title || ''}
|
||||
onChange={(e) => update('title', e.target.value)}
|
||||
placeholder="My Portfolio"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Subtitle</label>
|
||||
<input
|
||||
value={settings.subtitle || ''}
|
||||
onChange={(e) => update('subtitle', e.target.value)}
|
||||
placeholder="A visual journal"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Language</label>
|
||||
<select
|
||||
value={settings.lang || 'en'}
|
||||
onChange={(e) => update('lang', e.target.value)}
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="ja">日本語</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field-checks">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.exifOnHover !== false}
|
||||
onChange={(e) => update('exifOnHover', e.target.checked)}
|
||||
/>
|
||||
EXIF on Hover
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.map === true}
|
||||
onChange={(e) => update('map', e.target.checked)}
|
||||
/>
|
||||
Map enabled
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.transitions !== false}
|
||||
onChange={(e) => update('transitions', e.target.checked)}
|
||||
/>
|
||||
Page Transitions
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'theme' && (
|
||||
<div className="settings-panel">
|
||||
<h3>Theme</h3>
|
||||
<div className="admin-field">
|
||||
<label>Preset</label>
|
||||
<div className="preset-grid">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`preset-btn ${(settings.theme?.preset || 'studio') === p ? 'active' : ''}`}
|
||||
onClick={() => update('theme.preset', p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Accent Color</label>
|
||||
<div className="color-field">
|
||||
<input
|
||||
type="color"
|
||||
value={settings.theme?.accent || '#e60012'}
|
||||
onChange={(e) => update('theme.accent', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.theme?.accent || ''}
|
||||
onChange={(e) => update('theme.accent', e.target.value)}
|
||||
placeholder="#e60012"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Photo Frame</label>
|
||||
<div className="radio-group">
|
||||
{PHOTO_FRAMES.map((f) => (
|
||||
<label key={f}>
|
||||
<input
|
||||
type="radio"
|
||||
name="photoFrame"
|
||||
checked={(settings.theme?.photoFrame || 'none') === f}
|
||||
onChange={() => update('theme.photoFrame', f)}
|
||||
/>
|
||||
{f}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Hero Style</label>
|
||||
<select
|
||||
value={settings.theme?.heroStyle || 'split'}
|
||||
onChange={(e) => update('theme.heroStyle', e.target.value)}
|
||||
>
|
||||
{HERO_STYLES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field-checks">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.theme?.grain === true}
|
||||
onChange={(e) => update('theme.grain', e.target.checked)}
|
||||
/>
|
||||
Film Grain
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.theme?.headerDot !== false}
|
||||
onChange={(e) => update('theme.headerDot', e.target.checked)}
|
||||
/>
|
||||
Header Dot
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'grid' && (
|
||||
<div className="settings-panel">
|
||||
<h3>Grid Layout</h3>
|
||||
<div className="admin-field">
|
||||
<label>Layout</label>
|
||||
<div className="preset-grid">
|
||||
{LAYOUTS.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
className={`preset-btn ${(settings.grid?.layout || 'masonry') === l ? 'active' : ''}`}
|
||||
onClick={() => update('grid.layout', l)}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>Columns</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={6}
|
||||
value={settings.grid?.columns ?? 3}
|
||||
onChange={(e) => update('grid.columns', parseInt(e.target.value) || 3)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Gap (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={48}
|
||||
value={settings.grid?.gap ?? 12}
|
||||
onChange={(e) => update('grid.gap', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Aspect Ratio</label>
|
||||
<div className="preset-grid">
|
||||
{ASPECT_RATIOS.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
className={`preset-btn ${(settings.grid?.aspectRatio || '1') === r ? 'active' : ''}`}
|
||||
onClick={() => update('grid.aspectRatio', r)}
|
||||
>
|
||||
{r === '1' ? 'Square' : r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'footer' && (
|
||||
<div className="settings-panel">
|
||||
<h3>Footer</h3>
|
||||
<div className="admin-field">
|
||||
<label>Name</label>
|
||||
<input
|
||||
value={settings.footer?.name || ''}
|
||||
onChange={(e) => update('footer.name', e.target.value)}
|
||||
placeholder="My Photography"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Instagram URL</label>
|
||||
<input
|
||||
value={settings.footer?.instagram || ''}
|
||||
onChange={(e) => update('footer.instagram', e.target.value)}
|
||||
placeholder="https://instagram.com/your-handle"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Email</label>
|
||||
<input
|
||||
value={settings.footer?.email || ''}
|
||||
onChange={(e) => update('footer.email', e.target.value)}
|
||||
placeholder="hello@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Website</label>
|
||||
<input
|
||||
value={settings.footer?.website || ''}
|
||||
onChange={(e) => update('footer.website', e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'legal' && (
|
||||
<div className="settings-panel">
|
||||
<h3>Legal / Impressum</h3>
|
||||
<div className="admin-field-checks">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.legal?.enabled === true}
|
||||
onChange={(e) => update('legal.enabled', e.target.checked)}
|
||||
/>
|
||||
Enable Impressum page
|
||||
</label>
|
||||
</div>
|
||||
{settings.legal?.enabled && (
|
||||
<>
|
||||
<div className="admin-field">
|
||||
<label>Full Name</label>
|
||||
<input
|
||||
value={settings.legal?.name || ''}
|
||||
onChange={(e) => update('legal.name', e.target.value)}
|
||||
placeholder="Max Mustermann"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Address</label>
|
||||
<input
|
||||
value={settings.legal?.address || ''}
|
||||
onChange={(e) => update('legal.address', e.target.value)}
|
||||
placeholder="Musterstraße 1"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>ZIP & City</label>
|
||||
<input
|
||||
value={settings.legal?.zipCity || ''}
|
||||
onChange={(e) => update('legal.zipCity', e.target.value)}
|
||||
placeholder="12345 Berlin"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Country</label>
|
||||
<input
|
||||
value={settings.legal?.country || ''}
|
||||
onChange={(e) => update('legal.country', e.target.value)}
|
||||
placeholder="Germany"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>Email</label>
|
||||
<input
|
||||
value={settings.legal?.email || ''}
|
||||
onChange={(e) => update('legal.email', e.target.value)}
|
||||
placeholder="legal@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Phone</label>
|
||||
<input
|
||||
value={settings.legal?.phone || ''}
|
||||
onChange={(e) => update('legal.phone', e.target.value)}
|
||||
placeholder="+49 123 456789"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Extra Info</label>
|
||||
<textarea
|
||||
value={settings.legal?.extraInfo || ''}
|
||||
onChange={(e) => update('legal.extraInfo', e.target.value)}
|
||||
placeholder="Verantwortlich für den Inhalt..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'seo' && (
|
||||
<div className="settings-panel">
|
||||
<h3>SEO & Metadata</h3>
|
||||
<div className="admin-field">
|
||||
<label>SEO Title</label>
|
||||
<input
|
||||
value={settings.seo?.title || ''}
|
||||
onChange={(e) => update('seo.title', e.target.value)}
|
||||
placeholder="Overrides site title for search engines"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>SEO Description</label>
|
||||
<textarea
|
||||
value={settings.seo?.description || ''}
|
||||
onChange={(e) => update('seo.description', e.target.value)}
|
||||
placeholder="A curated selection of photography work."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field-checks">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.seo?.noIndex === true}
|
||||
onChange={(e) => update('seo.noIndex', e.target.checked)}
|
||||
/>
|
||||
noindex (hide from search engines)
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.seo?.noFollow === true}
|
||||
onChange={(e) => update('seo.noFollow', e.target.checked)}
|
||||
/>
|
||||
nofollow (don't follow links)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
app/admin/layout.tsx
Normal file
10
app/admin/layout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Immich Folio Admin',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="admin-layout">{children}</div>;
|
||||
}
|
||||
54
app/admin/page.tsx
Normal file
54
app/admin/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import AdminLogin from './components/AdminLogin';
|
||||
import AdminDashboard from './components/AdminDashboard';
|
||||
import './admin.css';
|
||||
|
||||
export default function AdminPage() {
|
||||
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/auth');
|
||||
const data = await res.json();
|
||||
setAuthenticated(data.authenticated);
|
||||
setEnabled(data.enabled);
|
||||
} catch {
|
||||
setAuthenticated(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (authenticated === null) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="admin-disabled">
|
||||
<div className="admin-disabled-card">
|
||||
<h1>Admin Panel</h1>
|
||||
<p>
|
||||
The admin panel is not enabled. Set <code>ADMIN_PASSWORD</code> in your environment
|
||||
variables to activate it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
return <AdminLogin onSuccess={() => setAuthenticated(true)} />;
|
||||
}
|
||||
|
||||
return <AdminDashboard onLogout={() => setAuthenticated(false)} />;
|
||||
}
|
||||
67
app/api/admin/albums/route.ts
Normal file
67
app/api/admin/albums/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
interface ImmichAlbumSummary {
|
||||
id: string;
|
||||
albumName: string;
|
||||
description: string;
|
||||
albumThumbnailAssetId: string | null;
|
||||
assetCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** GET: List ALL shared Immich albums (not just allowlisted ones). */
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
if (config.needsSetup) {
|
||||
return NextResponse.json({ error: 'Immich not configured' }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${config.immich.apiUrl}/albums?shared=true`, {
|
||||
headers: {
|
||||
'x-api-key': config.immich.apiKey,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: `Immich API returned ${res.status}` }, { status: 502 });
|
||||
}
|
||||
|
||||
const albums = (await res.json()) as ImmichAlbumSummary[];
|
||||
|
||||
// Mark which albums are already configured
|
||||
const configuredIds = new Set(config.albums);
|
||||
const enriched = albums.map((album) => ({
|
||||
id: album.id,
|
||||
albumName: album.albumName,
|
||||
description: album.description || '',
|
||||
thumbnailAssetId: album.albumThumbnailAssetId,
|
||||
assetCount: album.assetCount,
|
||||
createdAt: album.createdAt,
|
||||
updatedAt: album.updatedAt,
|
||||
isConfigured: configuredIds.has(album.id),
|
||||
}));
|
||||
|
||||
// Sort: configured first, then by name
|
||||
enriched.sort((a, b) => {
|
||||
if (a.isConfigured !== b.isConfigured) return a.isConfigured ? -1 : 1;
|
||||
return a.albumName.localeCompare(b.albumName);
|
||||
});
|
||||
|
||||
return NextResponse.json({ albums: enriched });
|
||||
} catch (err) {
|
||||
console.error('[Admin] Failed to fetch albums from Immich:', err);
|
||||
return NextResponse.json({ error: 'Failed to connect to Immich' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
64
app/api/admin/auth/route.ts
Normal file
64
app/api/admin/auth/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
verifyAdminPassword,
|
||||
createAdminToken,
|
||||
isAdminAuthenticated,
|
||||
isAdminEnabled,
|
||||
COOKIE_NAME,
|
||||
SESSION_DURATION_MS,
|
||||
} from '@/lib/admin/auth';
|
||||
|
||||
/** POST: Login with admin password. */
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admin panel is not enabled. Set ADMIN_PASSWORD in your environment.' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.password || typeof body.password !== 'string') {
|
||||
return NextResponse.json({ error: 'Password is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!verifyAdminPassword(body.password)) {
|
||||
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = createAdminToken();
|
||||
const response = NextResponse.json({ success: true });
|
||||
|
||||
response.cookies.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: SESSION_DURATION_MS / 1000,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/** GET: Check session status. */
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ authenticated: false, enabled: false });
|
||||
}
|
||||
|
||||
const authenticated = await isAdminAuthenticated();
|
||||
return NextResponse.json({ authenticated, enabled: true });
|
||||
}
|
||||
|
||||
/** DELETE: Logout. */
|
||||
export async function DELETE() {
|
||||
const response = NextResponse.json({ success: true });
|
||||
response.cookies.set(COOKIE_NAME, '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
48
app/api/admin/gallery/route.ts
Normal file
48
app/api/admin/gallery/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { readGalleryYaml, writeGalleryYaml } from '@/lib/admin/yaml-service';
|
||||
import { invalidateConfigCache } from '@/lib/config';
|
||||
import type { GalleryYaml } from '@/lib/config/schema';
|
||||
|
||||
/** GET: Read current gallery.yaml config. */
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const gallery = await readGalleryYaml();
|
||||
return NextResponse.json({ gallery: gallery || { hero: [], albums: [], subpages: [] } });
|
||||
}
|
||||
|
||||
/** PUT: Write gallery.yaml config. */
|
||||
export async function PUT(request: Request) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.gallery) {
|
||||
return NextResponse.json({ error: 'Missing gallery data' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validate basic structure
|
||||
const gallery = body.gallery as GalleryYaml;
|
||||
if (!Array.isArray(gallery.hero) && typeof gallery.hero !== 'string' && gallery.hero != null) {
|
||||
return NextResponse.json({ error: 'Invalid hero format' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await writeGalleryYaml(gallery);
|
||||
invalidateConfigCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[Admin] Failed to write gallery.yaml:', err);
|
||||
return NextResponse.json({ error: 'Failed to save gallery config' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
19
app/api/admin/reload/route.ts
Normal file
19
app/api/admin/reload/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { invalidateConfigCache } from '@/lib/config';
|
||||
import { immich } from '@/lib/immich';
|
||||
|
||||
/** POST: Force reload config and clear caches. */
|
||||
export async function POST() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
invalidateConfigCache();
|
||||
immich.invalidateAll();
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Config and cache reloaded' });
|
||||
}
|
||||
44
app/api/admin/settings/route.ts
Normal file
44
app/api/admin/settings/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { readSettingsYaml, writeSettingsYaml } from '@/lib/admin/yaml-service';
|
||||
import { invalidateConfigCache } from '@/lib/config';
|
||||
import type { SettingsYaml } from '@/lib/config/schema';
|
||||
|
||||
/** GET: Read current settings.yaml config. */
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const settings = await readSettingsYaml();
|
||||
return NextResponse.json({ settings: settings || {} });
|
||||
}
|
||||
|
||||
/** PUT: Write settings.yaml config. */
|
||||
export async function PUT(request: Request) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.settings) {
|
||||
return NextResponse.json({ error: 'Missing settings data' }, { status: 400 });
|
||||
}
|
||||
|
||||
const settings = body.settings as SettingsYaml;
|
||||
|
||||
try {
|
||||
await writeSettingsYaml(settings);
|
||||
invalidateConfigCache();
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[Admin] Failed to write settings.yaml:', err);
|
||||
return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
83
lib/admin/auth.ts
Normal file
83
lib/admin/auth.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Admin authentication utilities.
|
||||
* Uses HMAC-signed session tokens stored in HttpOnly cookies.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { env } from '../env';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const COOKIE_NAME = 'folio_admin_session';
|
||||
const SESSION_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
function getSigningKey(): Buffer {
|
||||
const secret = env.AUTH_SECRET || 'dev-fallback-secret';
|
||||
return crypto.createHash('sha256').update(`admin:${secret}`).digest();
|
||||
}
|
||||
|
||||
/** Create a signed session token. */
|
||||
export function createAdminToken(): string {
|
||||
const payload = {
|
||||
role: 'admin',
|
||||
iat: Date.now(),
|
||||
exp: Date.now() + SESSION_DURATION_MS,
|
||||
};
|
||||
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const key = getSigningKey();
|
||||
const sig = crypto.createHmac('sha256', key).update(data).digest('base64url');
|
||||
return `${data}.${sig}`;
|
||||
}
|
||||
|
||||
/** Verify a session token. Returns true if valid. */
|
||||
export function verifyAdminToken(token: string): boolean {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
const [data, sig] = parts;
|
||||
const key = getSigningKey();
|
||||
const expectedSig = crypto.createHmac('sha256', key).update(data).digest('base64url');
|
||||
|
||||
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(data, 'base64url').toString());
|
||||
if (payload.role !== 'admin') return false;
|
||||
if (typeof payload.exp !== 'number' || payload.exp < Date.now()) return false;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify admin password. */
|
||||
export function verifyAdminPassword(password: string): boolean {
|
||||
const adminPw = env.ADMIN_PASSWORD;
|
||||
if (!adminPw) return false;
|
||||
|
||||
// Constant-time comparison
|
||||
const pwBuf = Buffer.from(password);
|
||||
const expectedBuf = Buffer.from(adminPw);
|
||||
if (pwBuf.length !== expectedBuf.length) {
|
||||
// Still do a comparison to prevent timing attacks on length
|
||||
crypto.timingSafeEqual(pwBuf, Buffer.alloc(pwBuf.length));
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(pwBuf, expectedBuf);
|
||||
}
|
||||
|
||||
/** Check if the current request has a valid admin session. */
|
||||
export async function isAdminAuthenticated(): Promise<boolean> {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(COOKIE_NAME)?.value;
|
||||
if (!token) return false;
|
||||
return verifyAdminToken(token);
|
||||
}
|
||||
|
||||
/** Check if admin panel is enabled (password is set). */
|
||||
export function isAdminEnabled(): boolean {
|
||||
return !!env.ADMIN_PASSWORD;
|
||||
}
|
||||
|
||||
export { COOKIE_NAME, SESSION_DURATION_MS };
|
||||
148
lib/admin/yaml-service.ts
Normal file
148
lib/admin/yaml-service.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* YAML read/write service for the admin panel.
|
||||
* Handles atomic writes with automatic backup creation.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import type { GalleryYaml, SettingsYaml } from '../config/schema';
|
||||
|
||||
const CONTENT_DIR = path.join(process.cwd(), 'content');
|
||||
const MAX_BACKUPS = 10; // Keep last 10 backups per file
|
||||
|
||||
/** Read gallery.yaml and return parsed content. */
|
||||
export async function readGalleryYaml(): Promise<GalleryYaml | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(CONTENT_DIR, 'gallery.yaml'), 'utf8');
|
||||
return yaml.load(raw) as GalleryYaml;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read settings.yaml and return parsed content. */
|
||||
export async function readSettingsYaml(): Promise<SettingsYaml | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(CONTENT_DIR, 'settings.yaml'), 'utf8');
|
||||
return yaml.load(raw) as SettingsYaml;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically write a YAML file with backup. */
|
||||
async function writeYamlFile(filename: string, data: unknown): Promise<void> {
|
||||
const filePath = path.join(CONTENT_DIR, filename);
|
||||
|
||||
// Ensure content directory exists
|
||||
await fs.mkdir(CONTENT_DIR, { recursive: true });
|
||||
|
||||
// Create backup if file exists
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
const backupDir = path.join(CONTENT_DIR, '.backups');
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupName = `${filename}.${timestamp}.bak`;
|
||||
await fs.copyFile(filePath, path.join(backupDir, backupName));
|
||||
|
||||
// Prune old backups
|
||||
await pruneBackups(backupDir, filename);
|
||||
} catch {
|
||||
// File doesn't exist yet, no backup needed
|
||||
}
|
||||
|
||||
// Generate YAML content with header comment
|
||||
const header =
|
||||
filename === 'gallery.yaml'
|
||||
? '# ── Gallery Structure (managed by Immich Folio Admin) ──────────────\n'
|
||||
: '# ── Site Settings (managed by Immich Folio Admin) ─────────────────\n';
|
||||
|
||||
const content =
|
||||
header +
|
||||
yaml.dump(data, {
|
||||
lineWidth: 120,
|
||||
quotingType: '"',
|
||||
noRefs: true,
|
||||
sortKeys: false,
|
||||
});
|
||||
|
||||
// Atomic write: write to temp file, then rename
|
||||
const tmpPath = `${filePath}.tmp`;
|
||||
await fs.writeFile(tmpPath, content, 'utf8');
|
||||
await fs.rename(tmpPath, filePath);
|
||||
|
||||
console.log(`[Admin] ✅ Saved ${filename}`);
|
||||
}
|
||||
|
||||
/** Write gallery.yaml. */
|
||||
export async function writeGalleryYaml(data: GalleryYaml): Promise<void> {
|
||||
await writeYamlFile('gallery.yaml', data);
|
||||
}
|
||||
|
||||
/** Write settings.yaml. */
|
||||
export async function writeSettingsYaml(data: SettingsYaml): Promise<void> {
|
||||
await writeYamlFile('settings.yaml', data);
|
||||
}
|
||||
|
||||
/** List available backups for a file. */
|
||||
export async function listBackups(filename: string): Promise<string[]> {
|
||||
const backupDir = path.join(CONTENT_DIR, '.backups');
|
||||
try {
|
||||
const files = await fs.readdir(backupDir);
|
||||
return files
|
||||
.filter((f) => f.startsWith(filename))
|
||||
.sort()
|
||||
.reverse();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a specific backup. */
|
||||
export async function restoreBackup(backupFilename: string): Promise<void> {
|
||||
const backupDir = path.join(CONTENT_DIR, '.backups');
|
||||
const backupPath = path.join(backupDir, backupFilename);
|
||||
|
||||
// Determine the original filename
|
||||
const originalFilename = backupFilename.includes('gallery.yaml')
|
||||
? 'gallery.yaml'
|
||||
: 'settings.yaml';
|
||||
|
||||
const targetPath = path.join(CONTENT_DIR, originalFilename);
|
||||
|
||||
// Create a backup of current state first
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const preRestoreBackup = `${originalFilename}.${timestamp}.pre-restore.bak`;
|
||||
try {
|
||||
await fs.copyFile(targetPath, path.join(backupDir, preRestoreBackup));
|
||||
} catch {
|
||||
// Original might not exist
|
||||
}
|
||||
|
||||
await fs.copyFile(backupPath, targetPath);
|
||||
console.log(`[Admin] 🔄 Restored ${originalFilename} from ${backupFilename}`);
|
||||
}
|
||||
|
||||
/** Remove old backups, keeping only MAX_BACKUPS most recent. */
|
||||
async function pruneBackups(backupDir: string, filename: string): Promise<void> {
|
||||
try {
|
||||
const files = await fs.readdir(backupDir);
|
||||
const relevant = files
|
||||
.filter((f) => f.startsWith(filename) && !f.includes('pre-restore'))
|
||||
.sort();
|
||||
|
||||
if (relevant.length > MAX_BACKUPS) {
|
||||
const toDelete = relevant.slice(0, relevant.length - MAX_BACKUPS);
|
||||
for (const f of toDelete) {
|
||||
await fs.unlink(path.join(backupDir, f));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during pruning
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,11 @@ export * from './theme';
|
||||
let _config: AppConfig | null = null;
|
||||
let _fallbackSecret: string | null = null;
|
||||
|
||||
/** Invalidate the cached config so the next getConfig() call re-reads YAML files. */
|
||||
export function invalidateConfigCache(): void {
|
||||
_config = null;
|
||||
}
|
||||
|
||||
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
|
||||
export function buildSubpageGrid(raw?: {
|
||||
columns?: number;
|
||||
@@ -95,6 +100,7 @@ export function getConfig(): AppConfig {
|
||||
map: false,
|
||||
transitions: false,
|
||||
albumOverrides: {},
|
||||
albumDescriptions: {},
|
||||
albumPasswords: {},
|
||||
cacheTtl: env.CACHE_TTL * 1000,
|
||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||
@@ -107,10 +113,13 @@ export function getConfig(): AppConfig {
|
||||
const theme = resolveTheme(settings.theme);
|
||||
|
||||
const albumOverrides: Record<string, string> = {};
|
||||
const albumDescriptions: Record<string, string> = {};
|
||||
const albumPasswords: Record<string, string> = {};
|
||||
|
||||
function processAlbumEntry(
|
||||
entry: string | Record<string, string | { title: string; password?: string }>,
|
||||
entry:
|
||||
| string
|
||||
| Record<string, string | { title: string; description?: string; password?: string }>,
|
||||
context: string,
|
||||
): string {
|
||||
if (typeof entry === 'string') {
|
||||
@@ -123,6 +132,7 @@ export function getConfig(): AppConfig {
|
||||
albumOverrides[validatedUuid] = value;
|
||||
} else {
|
||||
if (value.title) albumOverrides[validatedUuid] = value.title;
|
||||
if (value.description) albumDescriptions[validatedUuid] = value.description;
|
||||
if (value.password) albumPasswords[validatedUuid] = value.password;
|
||||
}
|
||||
return validatedUuid;
|
||||
@@ -271,6 +281,7 @@ export function getConfig(): AppConfig {
|
||||
map: settings.map === true,
|
||||
transitions: settings.transitions !== false,
|
||||
albumOverrides,
|
||||
albumDescriptions,
|
||||
albumPasswords,
|
||||
cacheTtl: env.CACHE_TTL * 1000,
|
||||
rateLimitRpm: env.RATE_LIMIT_RPM,
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface Env {
|
||||
RATE_LIMIT_RPM: number;
|
||||
AUTH_SECRET?: string;
|
||||
TRUSTED_PROXIES: string[];
|
||||
WEBHOOK_SECRET?: string;
|
||||
ADMIN_PASSWORD?: string;
|
||||
}
|
||||
|
||||
function parseEnv(): Env {
|
||||
@@ -49,6 +51,8 @@ function parseEnv(): Env {
|
||||
.split(',')
|
||||
.map((ip) => ip.trim())
|
||||
.filter((ip) => ip !== ''),
|
||||
WEBHOOK_SECRET: process.env.WEBHOOK_SECRET || undefined,
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user