release: v0.12.0 (#520)
Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
@@ -1581,6 +1581,323 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Grouped feature settings (#510) ──────────────────────────
|
||||
The features panel used to be one flat grid of identical cards. These give it
|
||||
the structure the config already had: labelled groups, and sub-settings that
|
||||
look like sub-settings instead of peers. */
|
||||
.settings-group {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.settings-group-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.settings-group-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
/* Marks a group whose switches publish something, so it does not read like the
|
||||
one next to it that controls a fade. */
|
||||
.settings-group-chip {
|
||||
margin-left: 4px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.settings-group-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--admin-text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.settings-subpanel {
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--admin-bg-raised);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.settings-subpanel-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-subpanel-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.settings-subpanel-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
/* Quiet marker on an album Immich still considers private (#515). Deliberately
|
||||
not styled as a warning: publishing it is allowed, this only says what it is. */
|
||||
.picker-item-note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
/* ── Config doctor report (#491) ──────────────────────────────
|
||||
Each finding is one line with a severity dot: the point is scanning a list
|
||||
for the ones that are not green, not reading prose. */
|
||||
.doctor-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.doctor-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.7rem;
|
||||
padding: 0.8rem 0;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
.doctor-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.doctor-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 0.42rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--admin-success);
|
||||
}
|
||||
|
||||
.doctor-item--warn .doctor-dot {
|
||||
background: var(--admin-accent);
|
||||
}
|
||||
|
||||
.doctor-item--error .doctor-dot {
|
||||
background: var(--admin-error);
|
||||
}
|
||||
|
||||
.doctor-item-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.doctor-item-title {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
/* A finding that needs nothing recedes; the eye should land on the others. */
|
||||
.doctor-item--ok .doctor-item-title {
|
||||
font-weight: 500;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.doctor-item-detail {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
/* The metadata group as one card with an expander: four permanently visible
|
||||
sub-switches read as four decisions, when they are details of one. */
|
||||
.metadata-card {
|
||||
background: var(--admin-bg-raised);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.metadata-card.active {
|
||||
border-color: var(--admin-accent);
|
||||
box-shadow: 0 0 0 1px var(--admin-accent);
|
||||
}
|
||||
|
||||
.metadata-card-main {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.metadata-details-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin: 0 1.25rem 0.9rem;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--admin-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.metadata-details-btn:hover {
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.metadata-details-chevron {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.metadata-details-chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.metadata-details {
|
||||
padding: 0 1.25rem 0.6rem;
|
||||
border-top: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
/* The first row sits directly under that border and does not need its own. */
|
||||
.metadata-details .setting-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.7rem 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-top: 1px solid var(--admin-border);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* A sub-setting of the rows above it — indented so the dependency is visible
|
||||
rather than only true. */
|
||||
.setting-row--indented {
|
||||
padding-left: 0.9rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.setting-row--disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.setting-row--disabled .setting-row-title,
|
||||
.setting-row--disabled .setting-row-desc {
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.setting-row-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.setting-row-title {
|
||||
font-size: 0.81rem;
|
||||
font-weight: 500;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.setting-row-desc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--admin-text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Says why an inert row is inert, instead of leaving it looking broken. */
|
||||
.setting-row-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.69rem;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.switch-toggle--sm {
|
||||
width: 34px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.switch-toggle--sm .switch-slider {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.switch-toggle--sm.on .switch-slider {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
/* An inert switch must not look like an off switch someone can turn on. */
|
||||
.setting-row--disabled .switch-toggle {
|
||||
background: var(--admin-bg-elevated);
|
||||
}
|
||||
|
||||
.setting-row--disabled .switch-slider {
|
||||
background: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
/* Explains an unfinished installation before the indicators below it, which
|
||||
would otherwise read as a fault rather than a pending step. */
|
||||
.status-setup-note {
|
||||
margin: 0 0 0.35rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
background: var(--admin-bg-raised);
|
||||
border: 1px solid var(--admin-border);
|
||||
color: var(--admin-text-secondary);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.status-setup-note a {
|
||||
color: var(--admin-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, type ReactNode } from 'react';
|
||||
import { useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import BackupManagerModal from './BackupManagerModal';
|
||||
import DoctorModal from './DoctorModal';
|
||||
import type { DoctorLevel } from '@/lib/admin/doctor';
|
||||
import * as Icons from './Icons';
|
||||
|
||||
interface Props {
|
||||
@@ -26,6 +28,10 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
// Diagnostics & Backup state
|
||||
const [showStatus, setShowStatus] = useState(false);
|
||||
const [showBackupModal, setShowBackupModal] = useState(false);
|
||||
const [showDoctor, setShowDoctor] = useState(false);
|
||||
// The badge's job is "does anything need me?", and the doctor is the only
|
||||
// check that can answer that beyond "is Immich up" (#491).
|
||||
const [doctorLevel, setDoctorLevel] = useState<DoctorLevel | null>(null);
|
||||
const [status, setStatus] = useState<any>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(false);
|
||||
// Distinct from `status === null`: the check itself did not run. Without this
|
||||
@@ -80,16 +86,37 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
return healthy ? { className: 'ok', label: okLabel } : { className: 'error', label: badLabel };
|
||||
};
|
||||
|
||||
const fetchDoctor = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/doctor');
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setDoctorLevel(data.level ?? null);
|
||||
} catch {
|
||||
// A failed check must not colour the badge — "unknown" is not "broken".
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, []);
|
||||
fetchDoctor();
|
||||
}, [fetchDoctor]);
|
||||
|
||||
const immichIndicator = indicator(
|
||||
status?.immich?.status === 'connected',
|
||||
'Connected',
|
||||
'Disconnected',
|
||||
);
|
||||
const configIndicator = indicator(status?.config?.status === 'valid', 'Valid', 'Degraded');
|
||||
const configIndicator =
|
||||
!statusLoading && !statusError && status?.config?.status === 'setup'
|
||||
? { className: 'unknown', label: 'Not set up' }
|
||||
: indicator(status?.config?.status === 'valid', 'Valid', 'Degraded');
|
||||
|
||||
// An installation that was never finished is not a fault. It used to render
|
||||
// as "System Degraded" with no further hint, which sent operators looking for
|
||||
// a network problem that did not exist (#507).
|
||||
const setupIncomplete = !statusLoading && !statusError && status?.setup?.complete === false;
|
||||
const missingCredentials = status?.setup?.credentials === 'missing';
|
||||
|
||||
return (
|
||||
<div className="admin-dashboard">
|
||||
@@ -112,7 +139,15 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
{/* Diagnostics Badge */}
|
||||
<div className="status-indicator-container">
|
||||
<button
|
||||
className={`status-badge-btn ${immichIndicator.className === 'ok' ? 'connected' : immichIndicator.className === 'error' ? 'disconnected' : 'unknown'}`}
|
||||
className={`status-badge-btn ${
|
||||
setupIncomplete || doctorLevel === 'warn'
|
||||
? 'unknown'
|
||||
: immichIndicator.className === 'error' || doctorLevel === 'error'
|
||||
? 'disconnected'
|
||||
: immichIndicator.className === 'ok'
|
||||
? 'connected'
|
||||
: 'unknown'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setShowStatus(!showStatus);
|
||||
if (!showStatus) fetchStatus();
|
||||
@@ -123,11 +158,17 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
<span className="status-text">
|
||||
{statusLoading
|
||||
? 'Checking...'
|
||||
: immichIndicator.className === 'ok'
|
||||
? 'System OK'
|
||||
: setupIncomplete
|
||||
? 'Setup Incomplete'
|
||||
: immichIndicator.className === 'error'
|
||||
? 'System Degraded'
|
||||
: 'Status Unknown'}
|
||||
: doctorLevel === 'error'
|
||||
? 'Needs Attention'
|
||||
: doctorLevel === 'warn'
|
||||
? 'Check Diagnostics'
|
||||
: immichIndicator.className === 'ok'
|
||||
? 'System OK'
|
||||
: 'Status Unknown'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -147,6 +188,19 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="status-dropdown-body">
|
||||
{setupIncomplete && (
|
||||
<p className="status-setup-note">
|
||||
{missingCredentials
|
||||
? 'No Immich URL or API key configured. Set IMMICH_API_URL and IMMICH_API_KEY, or run the '
|
||||
: 'No content/gallery.yaml yet — the public site has nothing to show. Add a page below, or run the '}
|
||||
<a href="/install" target="_blank" rel="noopener noreferrer">
|
||||
setup wizard
|
||||
</a>
|
||||
{missingCredentials
|
||||
? '.'
|
||||
: ' (the one-time token is printed to the server log).'}
|
||||
</p>
|
||||
)}
|
||||
<div className="status-item">
|
||||
<span className="status-label">Immich Connection</span>
|
||||
<span className={`status-val ${immichIndicator.className}`}>
|
||||
@@ -177,6 +231,15 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-dropdown-footer">
|
||||
<button
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={() => {
|
||||
setShowStatus(false);
|
||||
setShowDoctor(true);
|
||||
}}
|
||||
>
|
||||
<Icons.IconShieldCheck size={14} /> Diagnostics
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={() => {
|
||||
@@ -226,6 +289,14 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
|
||||
<main className="admin-main">{children}</main>
|
||||
|
||||
<DoctorModal
|
||||
isOpen={showDoctor}
|
||||
onClose={() => {
|
||||
setShowDoctor(false);
|
||||
fetchDoctor();
|
||||
}}
|
||||
/>
|
||||
|
||||
<BackupManagerModal
|
||||
isOpen={showBackupModal}
|
||||
onClose={() => setShowBackupModal(false)}
|
||||
|
||||
@@ -11,6 +11,8 @@ interface ImmichAlbumInfo {
|
||||
thumbnailAssetId: string | null;
|
||||
assetCount: number;
|
||||
isConfigured: boolean;
|
||||
/** Immich's own flag. Undefined on older Immich — silence beats a false alarm. */
|
||||
shared?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -89,6 +91,13 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
|
||||
{album.assetCount} photos
|
||||
{album.description && ` · ${album.description.slice(0, 40)}`}
|
||||
</span>
|
||||
{/* Not a warning and not a barrier: publishing an unshared
|
||||
album has always worked, because Immich ignores the
|
||||
?shared=true filter this list is fetched with (#515). It
|
||||
is here so nobody publishes a private album unaware. */}
|
||||
{album.shared === false && (
|
||||
<span className="picker-item-note">Not shared in Immich</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="picker-item-action">
|
||||
{isUsed ? (
|
||||
|
||||
131
app/admin/components/DoctorModal.tsx
Normal file
131
app/admin/components/DoctorModal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
|
||||
import * as Icons from './Icons';
|
||||
import { useScrollLock } from './useScrollLock';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const LEVEL_LABEL: Record<DoctorLevel, string> = {
|
||||
ok: 'OK',
|
||||
warn: 'Check',
|
||||
error: 'Problem',
|
||||
};
|
||||
|
||||
/**
|
||||
* The config doctor's report (#491).
|
||||
*
|
||||
* Most "it does not work for me" reports are one of a handful of
|
||||
* misconfigurations that fail silently — a wrong proxy hop count, a deleted
|
||||
* album ID, a plaintext password. Each of those already logs a warning to the
|
||||
* server log, where nobody looks. This puts them where the operator is.
|
||||
*/
|
||||
export default function DoctorModal({ isOpen, onClose }: Props) {
|
||||
const [findings, setFindings] = useState<DoctorFinding[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/doctor');
|
||||
if (!res.ok) throw new Error(`Diagnostics failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
setFindings(data.findings || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Diagnostics failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCopied(false);
|
||||
run();
|
||||
}
|
||||
}, [isOpen, run]);
|
||||
|
||||
useScrollLock(isOpen);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
/** Markdown, so it can be pasted straight into a GitHub issue. */
|
||||
async function copyReport() {
|
||||
const body = findings
|
||||
.map((f) => `- **${LEVEL_LABEL[f.level]}** — ${f.title}\n ${f.detail}`)
|
||||
.join('\n');
|
||||
try {
|
||||
await navigator.clipboard.writeText(`### Immich Folio diagnostics\n\n${body}\n`);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setError('Could not copy — your browser refused clipboard access.');
|
||||
}
|
||||
}
|
||||
|
||||
const problems = findings.filter((f) => f.level !== 'ok').length;
|
||||
|
||||
return (
|
||||
<div className="backup-modal-backdrop" onClick={onClose}>
|
||||
<div className="backup-modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="backup-modal-header">
|
||||
<div>
|
||||
<h2>Diagnostics</h2>
|
||||
<p className="backup-modal-subtitle">
|
||||
{loading
|
||||
? 'Checking…'
|
||||
: problems === 0
|
||||
? 'Nothing needs your attention.'
|
||||
: `${problems} of ${findings.length} checks want a look.`}
|
||||
</p>
|
||||
</div>
|
||||
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
|
||||
<Icons.IconX size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="backup-modal-body">
|
||||
{error && <div className="admin-error">{error}</div>}
|
||||
|
||||
{loading && !findings.length ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<ul className="doctor-list">
|
||||
{findings.map((f) => (
|
||||
<li key={f.id} className={`doctor-item doctor-item--${f.level}`}>
|
||||
<span className="doctor-dot" aria-hidden="true" />
|
||||
<span className="doctor-item-body">
|
||||
<span className="doctor-item-title">{f.title}</span>
|
||||
<span className="doctor-item-detail">{f.detail}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="backup-modal-footer">
|
||||
<button className="admin-btn admin-btn-sm" onClick={run} disabled={loading}>
|
||||
<Icons.IconRefresh size={14} /> {loading ? 'Checking…' : 'Run again'}
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={copyReport}
|
||||
disabled={loading || !findings.length}
|
||||
title="Markdown, ready to paste into an issue"
|
||||
>
|
||||
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,12 @@ import {
|
||||
isAlbumSortMode,
|
||||
type AlbumSortMode,
|
||||
} from '@/lib/albumSort';
|
||||
import type { AlbumEntryObject } from '@/lib/config/schema';
|
||||
import {
|
||||
COVER_GRID_COLUMNS_MAX,
|
||||
COVER_GRID_COLUMNS_MIN,
|
||||
COVER_GRID_GAP_MAX,
|
||||
type AlbumEntryObject,
|
||||
} from '@/lib/config/schema';
|
||||
import {
|
||||
IconArrowDown,
|
||||
IconArrowUp,
|
||||
@@ -122,6 +127,21 @@ interface Subpage {
|
||||
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops keys the editor cleared so an emptied field never persists as `null`
|
||||
* in gallery.yaml, and returns undefined once nothing is left — `handleSave`
|
||||
* only writes `grid` when it is truthy.
|
||||
*/
|
||||
function normalizeSubpageGrid(grid: Subpage['grid']): Subpage['grid'] {
|
||||
if (!grid) return undefined;
|
||||
const next = { ...grid };
|
||||
for (const key of Object.keys(next) as Array<keyof typeof next>) {
|
||||
const value = next[key];
|
||||
if (value === undefined || value === '') delete next[key];
|
||||
}
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
interface GalleryState {
|
||||
hero: string[];
|
||||
albums: AlbumEntry[];
|
||||
@@ -1452,6 +1472,80 @@ export default function PageBuilder() {
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Album cover grid — hidden in essay mode, which
|
||||
renders a story instead of a cover grid. The
|
||||
album count is deliberately not checked: it
|
||||
changes in this very drawer, and a field that
|
||||
vanishes while you edit is worse than one that
|
||||
has no effect yet. */}
|
||||
{sp.grid?.layout !== 'essay' && sp.essayText == null && (
|
||||
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
||||
<label>Album Cover Grid</label>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<input
|
||||
type="number"
|
||||
min={COVER_GRID_COLUMNS_MIN}
|
||||
max={COVER_GRID_COLUMNS_MAX}
|
||||
value={sp.grid?.columns ?? ''}
|
||||
placeholder="Columns (site default)"
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const columns = raw === '' ? undefined : Number(raw);
|
||||
updateSubpage(spIndex, {
|
||||
grid: normalizeSubpageGrid({
|
||||
...(sp.grid || {}),
|
||||
columns:
|
||||
columns != null &&
|
||||
columns >= COVER_GRID_COLUMNS_MIN &&
|
||||
columns <= COVER_GRID_COLUMNS_MAX
|
||||
? columns
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
flex: 1,
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={COVER_GRID_GAP_MAX}
|
||||
value={sp.grid?.gap ?? ''}
|
||||
placeholder="Gap in px (theme default)"
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
const gap = raw === '' ? undefined : Number(raw);
|
||||
updateSubpage(spIndex, {
|
||||
grid: normalizeSubpageGrid({
|
||||
...(sp.grid || {}),
|
||||
gap:
|
||||
gap != null && gap >= 0 && gap <= COVER_GRID_GAP_MAX
|
||||
? gap
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
flex: 1,
|
||||
fontSize: '0.9rem',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="admin-field-hint">
|
||||
Overrides how the album covers are tiled on this page. Leave the
|
||||
column count empty to follow the site-wide grid setting, and the
|
||||
gap empty to keep the theme's own spacing. Tablet widths
|
||||
show at most two covers per row.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visual Photo Essay Builder (if layout === 'essay') */}
|
||||
|
||||
@@ -8,12 +8,22 @@ import SaveBar from './SaveBar';
|
||||
// Direct import from the theme module, not from '@/lib/config': the config
|
||||
// index pulls in `fs` and cannot be bundled into a client component.
|
||||
import { DEFAULT_PRESET } from '@/lib/config/theme';
|
||||
import { resolveExifDisplay, resolveWatermarkOpacity } from '@/lib/config/schema';
|
||||
import { SUPPORTED_LOCALES } from '@/lib/i18n';
|
||||
|
||||
interface Settings {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
lang?: string;
|
||||
sitePassword?: string;
|
||||
mode?: 'light' | 'dark' | 'auto';
|
||||
exifOnHover?: boolean;
|
||||
exif?: {
|
||||
camera?: boolean;
|
||||
settings?: boolean;
|
||||
location?: boolean;
|
||||
caption?: boolean;
|
||||
};
|
||||
map?: boolean;
|
||||
transitions?: boolean;
|
||||
scrollToTop?: boolean;
|
||||
@@ -204,6 +214,91 @@ interface SettingsEditorProps {
|
||||
section?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One labelled block of related switches.
|
||||
*
|
||||
* The features panel was a single flat grid of identical cards, so nothing said
|
||||
* which switches belonged together — and a switch that publishes private notes
|
||||
* looked exactly like one that controls a fade (#510).
|
||||
*/
|
||||
function FeatureGroup({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
chip,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Short marker for a group that needs its own weight, e.g. what visitors see. */
|
||||
chip?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-head">
|
||||
<span className="settings-group-title">
|
||||
{icon}
|
||||
{title}
|
||||
{chip && <span className="settings-group-chip">{chip}</span>}
|
||||
</span>
|
||||
<span className="settings-group-desc">{description}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A switch inside a group panel. Deliberately lighter than a toggle card: these
|
||||
* are settings *of* the panel they sit in, and rendering them as peers of the
|
||||
* top-level cards is what hid the structure in the first place.
|
||||
*/
|
||||
function SettingRow({
|
||||
title,
|
||||
description,
|
||||
checked,
|
||||
onToggle,
|
||||
disabled,
|
||||
hint,
|
||||
indented,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
/** A row whose parent switch is off — shown, but inert and explained. */
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
indented?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`setting-row${indented ? ' setting-row--indented' : ''}${
|
||||
disabled ? ' setting-row--disabled' : ''
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
disabled={disabled}
|
||||
aria-pressed={checked}
|
||||
>
|
||||
<span className="setting-row-info">
|
||||
<span className="setting-row-title">{title}</span>
|
||||
<span className="setting-row-desc">{description}</span>
|
||||
{hint && (
|
||||
<span className="setting-row-hint">
|
||||
<Icons.IconBan size={11} /> {hint}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={`switch-toggle switch-toggle--sm ${checked && !disabled ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
const router = useRouter();
|
||||
// An unknown section in the URL falls back to General instead of an empty panel.
|
||||
@@ -212,6 +307,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
: 'general';
|
||||
const [settings, setSettings] = useState<Settings>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Collapsed by default: the four metadata switches are a detail of one
|
||||
// decision, and showing them permanently is what made the section a wall (#510).
|
||||
const [metadataOpen, setMetadataOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
@@ -330,6 +428,25 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Write several paths in one state update — used by the metadata master switch. */
|
||||
function updateMany(entries: Record<string, unknown>) {
|
||||
setSettings((s) => {
|
||||
const copy = JSON.parse(JSON.stringify(s));
|
||||
for (const [path, value] of Object.entries(entries)) {
|
||||
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]];
|
||||
}
|
||||
obj[parts[parts.length - 1]] = value;
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
setDirty(true);
|
||||
setSaveMessage('');
|
||||
}
|
||||
|
||||
function update(path: string, value: unknown) {
|
||||
setSettings((s) => {
|
||||
const copy = JSON.parse(JSON.stringify(s));
|
||||
@@ -457,6 +574,34 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Resolved the same way the site resolves it, so the switches show what a
|
||||
// visitor actually sees — including a config that only ever set the older
|
||||
// `exifOnHover`.
|
||||
const exif = resolveExifDisplay(settings.exif, settings.exifOnHover);
|
||||
|
||||
// The master switch has no key of its own and needs none: "off" is all four
|
||||
// groups off, a state the site already acts on — with nothing left to show,
|
||||
// the lightbox withdraws its info button and the `i` key entirely (#506).
|
||||
const anyMetadata = exif.camera || exif.settings || exif.location || exif.caption;
|
||||
const metadataSummary =
|
||||
[
|
||||
exif.camera && 'Camera & lens',
|
||||
exif.settings && 'Exposure',
|
||||
exif.location && 'Location',
|
||||
exif.caption && 'Description',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || 'Nothing published — the lightbox hides its info panel';
|
||||
|
||||
/** Turning it back on selects everything; the details below narrow it again. */
|
||||
const toggleMetadata = () =>
|
||||
updateMany({
|
||||
'exif.camera': !anyMetadata,
|
||||
'exif.settings': !anyMetadata,
|
||||
'exif.location': !anyMetadata,
|
||||
'exif.caption': !anyMetadata,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="settings-editor">
|
||||
{/* The About panel edits about.md, not settings.yaml, so it drives the bar itself. */}
|
||||
@@ -537,6 +682,11 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<option value="es">Español (ES)</option>
|
||||
<option value="ja">日本語 (JA)</option>
|
||||
</select>
|
||||
<p className="admin-field-hint">
|
||||
The visitor-facing interface is translated for {SUPPORTED_LOCALES.join(', ')}.
|
||||
Other languages still set <code><html lang></code> and date formatting, but
|
||||
show the English interface. The admin panel is always English.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-divider" />
|
||||
@@ -550,136 +700,224 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-toggle-cards-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.exifOnHover !== false ? 'active' : ''}`}
|
||||
onClick={() => update('exifOnHover', settings.exifOnHover === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconCamera size={16} /> EXIF Data on Hover
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Display camera gear, lens, aperture & shutter speed on hover
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.exifOnHover !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.map === true ? 'active' : ''}`}
|
||||
onClick={() => update('map', !settings.map)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconMap size={16} /> Interactive GPS Map
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Enable /map view showing photo locations on a world map
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.map === true ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.transitions !== false ? 'active' : ''}`}
|
||||
onClick={() => update('transitions', settings.transitions === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconSparkles size={16} /> Smooth Page Transitions
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Enable subtle fade-in animations between page navigation
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.transitions !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.scrollToTop !== false ? 'active' : ''}`}
|
||||
onClick={() => update('scrollToTop', settings.scrollToTop === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconArrowUp size={16} /> Scroll-to-Top Button
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Show a floating arrow that returns visitors to the top of long pages
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.scrollToTop !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.analytics !== false ? 'active' : ''}`}
|
||||
onClick={() => update('analytics', settings.analytics === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconBarChart size={16} /> Analytics Tracking
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Collect anonymous privacy-friendly visit statistics
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.analytics !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.proofing?.enabled !== false ? 'active' : ''}`}
|
||||
onClick={() => update('proofing.enabled', settings.proofing?.enabled === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconHeart size={16} /> Client Proofing & Favorites
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Allow visitors & clients to heart, filter, and export favorite photo
|
||||
selections
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`switch-toggle ${settings.proofing?.enabled !== false ? 'on' : ''}`}
|
||||
<FeatureGroup
|
||||
icon={<Icons.IconFrame size={13} />}
|
||||
title="Pages & motion"
|
||||
description="Chrome the visitor sees on every page."
|
||||
>
|
||||
<div className="admin-toggle-cards-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.about?.enabled !== false ? 'active' : ''}`}
|
||||
onClick={() => update('about.enabled', settings.about?.enabled === false)}
|
||||
>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconCamera size={16} /> About Page
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Show a portrait, bio, and gear section on your portfolio
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`switch-toggle ${settings.about?.enabled !== false ? 'on' : ''}`}
|
||||
>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.about?.enabled !== false ? 'active' : ''}`}
|
||||
onClick={() => update('about.enabled', settings.about?.enabled === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconCamera size={16} /> About Page
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.transitions !== false ? 'active' : ''}`}
|
||||
onClick={() => update('transitions', settings.transitions === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconSparkles size={16} /> Smooth Page Transitions
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Enable subtle fade-in animations between page navigation
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.transitions !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.scrollToTop !== false ? 'active' : ''}`}
|
||||
onClick={() => update('scrollToTop', settings.scrollToTop === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconArrowUp size={16} /> Scroll-to-Top Button
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Show a floating arrow that returns visitors to the top of long pages
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.scrollToTop !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</FeatureGroup>
|
||||
|
||||
<FeatureGroup
|
||||
icon={<Icons.IconShieldCheck size={13} />}
|
||||
title="What each photo reveals"
|
||||
description="Published to anyone who opens a photo."
|
||||
chip="visible to visitors"
|
||||
>
|
||||
<div className={`metadata-card ${anyMetadata ? 'active' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="metadata-card-main"
|
||||
onClick={toggleMetadata}
|
||||
aria-pressed={anyMetadata}
|
||||
>
|
||||
<span className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconCamera size={16} /> Photo metadata
|
||||
</span>
|
||||
<span className="toggle-card-desc">{metadataSummary}</span>
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Show a portrait, bio, and gear section on your portfolio
|
||||
<span className={`switch-toggle ${anyMetadata ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.about?.enabled !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="metadata-details-btn"
|
||||
onClick={() => setMetadataOpen((open) => !open)}
|
||||
aria-expanded={metadataOpen}
|
||||
>
|
||||
Details
|
||||
<Icons.IconChevronDown
|
||||
size={13}
|
||||
className={`metadata-details-chevron${metadataOpen ? ' open' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{metadataOpen && (
|
||||
<div className="metadata-details">
|
||||
<SettingRow
|
||||
title="Camera & Lens"
|
||||
description="Body, lens and focal length"
|
||||
checked={exif.camera}
|
||||
onToggle={() => update('exif.camera', !exif.camera)}
|
||||
/>
|
||||
<SettingRow
|
||||
title="Exposure Settings"
|
||||
description="Aperture, shutter speed and ISO"
|
||||
checked={exif.settings}
|
||||
onToggle={() => update('exif.settings', !exif.settings)}
|
||||
/>
|
||||
<SettingRow
|
||||
title="Location"
|
||||
description="City and country from the photo's GPS data"
|
||||
checked={exif.location}
|
||||
onToggle={() => update('exif.location', !exif.location)}
|
||||
/>
|
||||
<SettingRow
|
||||
title="Photo Description"
|
||||
description="The description written in Immich"
|
||||
checked={exif.caption}
|
||||
onToggle={() => update('exif.caption', !exif.caption)}
|
||||
/>
|
||||
|
||||
{/* Dependent, not a peer: this only controls the grid overlay, and
|
||||
the overlay carries camera and lens. With those off it has
|
||||
nothing to show, which used to leave it switched on and
|
||||
silently doing nothing (#510). */}
|
||||
<SettingRow
|
||||
indented
|
||||
title="Summary on grid hover"
|
||||
description="Camera and lens over the photo, not only in the lightbox"
|
||||
checked={settings.exifOnHover !== false}
|
||||
disabled={!exif.camera}
|
||||
hint={exif.camera ? undefined : 'Needs Camera & Lens'}
|
||||
onToggle={() => update('exifOnHover', settings.exifOnHover === false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FeatureGroup>
|
||||
|
||||
<FeatureGroup
|
||||
icon={<Icons.IconHeart size={13} />}
|
||||
title="What visitors can do"
|
||||
description="Optional pages and interactions."
|
||||
>
|
||||
<div className="admin-toggle-cards-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.map === true ? 'active' : ''}`}
|
||||
onClick={() => update('map', !settings.map)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconMap size={16} /> Interactive GPS Map
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Enable /map view showing photo locations on a world map
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.map === true ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.proofing?.enabled !== false ? 'active' : ''}`}
|
||||
onClick={() => update('proofing.enabled', settings.proofing?.enabled === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconHeart size={16} /> Client Proofing & Favorites
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Allow visitors & clients to heart, filter, and export favorite photo
|
||||
selections
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`switch-toggle ${settings.proofing?.enabled !== false ? 'on' : ''}`}
|
||||
>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</FeatureGroup>
|
||||
|
||||
<FeatureGroup
|
||||
icon={<Icons.IconBarChart size={13} />}
|
||||
title="Measurement"
|
||||
description="Cookieless and self-hosted — nothing leaves your server."
|
||||
>
|
||||
<div className="admin-toggle-cards-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${settings.analytics !== false ? 'active' : ''}`}
|
||||
onClick={() => update('analytics', settings.analytics === false)}
|
||||
>
|
||||
<div className="toggle-card-info">
|
||||
<span className="toggle-card-title">
|
||||
<Icons.IconBarChart size={16} /> Analytics Tracking
|
||||
</span>
|
||||
<span className="toggle-card-desc">
|
||||
Collect anonymous privacy-friendly visit statistics
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${settings.analytics !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</FeatureGroup>
|
||||
|
||||
<div className="admin-field favicon-field">
|
||||
<label>Favicon</label>
|
||||
@@ -758,6 +996,23 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<label>Visitor Default Mode</label>
|
||||
<select
|
||||
value={settings.mode || 'dark'}
|
||||
onChange={(e) => update('mode', e.target.value)}
|
||||
>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="auto">Follow the visitor's system</option>
|
||||
</select>
|
||||
<p className="admin-field-hint">
|
||||
What a first-time visitor sees. The light/dark toggle in the site header still
|
||||
lets them choose, and their choice is remembered on their device. The preview
|
||||
below is your own, and does not change this.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<label>Preset</label>
|
||||
<div className="preset-card-grid">
|
||||
@@ -1256,7 +1511,11 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<h3>
|
||||
<Icons.IconColumns size={18} /> Spacing & Columns
|
||||
</h3>
|
||||
<p className="settings-section-sub">Adjust column counts and grid gap spacing.</p>
|
||||
<p className="settings-section-sub">
|
||||
Adjust column counts and grid gap spacing. The column count also tiles the album
|
||||
covers on a subpage; their spacing stays with the theme unless a page overrides
|
||||
it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field-row">
|
||||
@@ -1618,6 +1877,35 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
|
||||
{activeSection === 'security' && (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
<h3>
|
||||
<Icons.IconLock size={18} /> Site Password
|
||||
</h3>
|
||||
<p className="settings-section-sub">
|
||||
Puts the whole public site behind one password — useful while a portfolio is still
|
||||
being built, or for a folio only ever shown to clients.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<label>Site Password</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.sitePassword || ''}
|
||||
onChange={(e) => update('sitePassword', e.target.value)}
|
||||
placeholder="Leave empty for a public site"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="admin-field-hint">
|
||||
Stored in <code>settings.yaml</code>. Log in once and the server log prints a{' '}
|
||||
<code>scrypt:…</code> hash to paste back here instead of the plaintext. The{' '}
|
||||
<code>SITE_PASSWORD</code> environment variable overrides this field. The admin
|
||||
panel keeps its own password and is never behind this gate.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
<div className="settings-section-header">
|
||||
<h3>
|
||||
<Icons.IconShieldCheck size={18} /> Asset Protection & Watermark
|
||||
@@ -1730,15 +2018,20 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
{/* Read through the same normaliser the lightbox uses, so a
|
||||
config written as a percentage shows as "90%" here rather
|
||||
than "9000%" — and is written back as a fraction on save. */}
|
||||
<label>
|
||||
Opacity ({Math.round((settings.watermark?.opacity ?? 0.3) * 100)}%)
|
||||
Opacity (
|
||||
{Math.round(resolveWatermarkOpacity(settings.watermark?.opacity) * 100)}
|
||||
%)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="0.8"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={settings.watermark?.opacity ?? 0.3}
|
||||
value={resolveWatermarkOpacity(settings.watermark?.opacity)}
|
||||
onChange={(e) => update('watermark.opacity', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user