release: v0.15.0 (#585)
Eighteen changes since v0.14.0. See CHANGELOG.md for the full list and the upgrade notes (bump IMAGE_CACHE_VERSION).
This commit is contained in:
@@ -7,6 +7,7 @@ import { ProofingProvider, useProofing } from '@/components/ProofingContext';
|
||||
import { ProofingModal } from '@/components/ProofingModal';
|
||||
import { FadeIn } from '@/components/FadeIn';
|
||||
import type { ParsedEssay, EssayBlock } from '@/lib/essay';
|
||||
import { renderInlineMarkdown } from '@/lib/essay';
|
||||
import type { PhotoItem } from './PhotoGrid';
|
||||
import './essay.css';
|
||||
import { useDictionary } from '@/components/I18nProvider';
|
||||
@@ -78,7 +79,15 @@ function EssayViewContent({
|
||||
return (
|
||||
<blockquote key={idx} className="essay-quote">
|
||||
<div dangerouslySetInnerHTML={{ __html: block.text }} />
|
||||
{block.author && <span className="essay-quote__author">— {block.author}</span>}
|
||||
{block.author && (
|
||||
<span
|
||||
className="essay-quote__author"
|
||||
// The attribution keeps its Markdown and is rendered here, like
|
||||
// the quote body, so `[link](…)` becomes an anchor (#559).
|
||||
// renderInlineMarkdown escapes everything it does not emit.
|
||||
dangerouslySetInnerHTML={{ __html: `— ${renderInlineMarkdown(block.author)}` }}
|
||||
/>
|
||||
)}
|
||||
</blockquote>
|
||||
);
|
||||
|
||||
@@ -312,9 +321,9 @@ function EssayViewContent({
|
||||
gap: '12px',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '30px',
|
||||
background: 'var(--bg-surface, #1e1e1e)',
|
||||
background: 'var(--bg-card, #1e1e1e)',
|
||||
color: 'var(--text-primary, #ffffff)',
|
||||
border: '1px solid var(--border-color, rgba(255,255,255,0.15))',
|
||||
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
@@ -325,8 +334,8 @@ function EssayViewContent({
|
||||
style={{
|
||||
background: proofing.isFilterActive
|
||||
? 'var(--accent, #e60012)'
|
||||
: 'rgba(255,255,255,0.15)',
|
||||
color: '#fff',
|
||||
: 'var(--bg-card-hover)',
|
||||
color: proofing.isFilterActive ? '#fff' : 'var(--text-primary)',
|
||||
border: 'none',
|
||||
padding: '6px 14px',
|
||||
borderRadius: '20px',
|
||||
|
||||
@@ -266,9 +266,9 @@ function PhotoGridInner({
|
||||
gap: '12px',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '30px',
|
||||
background: 'var(--bg-surface, #1e1e1e)',
|
||||
background: 'var(--bg-card, #1e1e1e)',
|
||||
color: 'var(--text-primary, #ffffff)',
|
||||
border: '1px solid var(--border-color, rgba(255,255,255,0.15))',
|
||||
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
@@ -279,8 +279,8 @@ function PhotoGridInner({
|
||||
style={{
|
||||
background: proofing.isFilterActive
|
||||
? 'var(--accent, #e60012)'
|
||||
: 'rgba(255,255,255,0.15)',
|
||||
color: '#fff',
|
||||
: 'var(--bg-card-hover)',
|
||||
color: proofing.isFilterActive ? '#fff' : 'var(--text-primary)',
|
||||
border: 'none',
|
||||
padding: '6px 14px',
|
||||
borderRadius: '20px',
|
||||
|
||||
@@ -55,13 +55,19 @@ function AlbumGrid({
|
||||
key={album.id}
|
||||
href={`/${slug}/${album.slug}`}
|
||||
className="subpage-grid__item"
|
||||
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
|
||||
aria-label={getServerDictionary().subpage.coverAria(
|
||||
album.albumName,
|
||||
photoCount(album.assetCount),
|
||||
)}
|
||||
>
|
||||
<span className="subpage-grid__item-media">
|
||||
{/* The loading colour belongs to the image, not the card: presets
|
||||
with a caption bar below the cover (studio-modern) would
|
||||
otherwise paint the bar in the photo's dominant colour, and
|
||||
their --text-primary caption becomes dark-on-dark in light mode. */}
|
||||
<span
|
||||
className="subpage-grid__item-media"
|
||||
style={ph?.dominantColor ? { backgroundColor: ph.dominantColor } : undefined}
|
||||
>
|
||||
{album.albumThumbnailAssetId ? (
|
||||
<Image
|
||||
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
}
|
||||
|
||||
.essay-header__title {
|
||||
font-family: var(--font-heading, serif);
|
||||
font-family: var(--font-serif, serif);
|
||||
font-size: clamp(2.2rem, 5vw, 3.8rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
|
||||
.essay-header__subtitle {
|
||||
font-family: var(--font-body, sans-serif);
|
||||
font-family: var(--font-sans, sans-serif);
|
||||
font-size: clamp(1.1rem, 2.5vw, 1.4rem);
|
||||
opacity: 0.8;
|
||||
line-height: 1.5;
|
||||
@@ -60,7 +60,7 @@
|
||||
.essay-prose {
|
||||
max-width: var(--essay-measure);
|
||||
margin: 0 auto 2.5rem auto;
|
||||
font-family: var(--font-body, sans-serif);
|
||||
font-family: var(--font-sans, sans-serif);
|
||||
font-size: clamp(1.05rem, 2vw, 1.2rem);
|
||||
line-height: 1.75;
|
||||
color: var(--text-primary, rgba(255, 255, 255, 0.92));
|
||||
@@ -71,7 +71,7 @@
|
||||
}
|
||||
|
||||
.essay-heading {
|
||||
font-family: var(--font-heading, serif);
|
||||
font-family: var(--font-serif, serif);
|
||||
max-width: var(--essay-measure);
|
||||
margin: 3.5rem auto 1.5rem auto;
|
||||
font-weight: 600;
|
||||
@@ -94,9 +94,9 @@
|
||||
margin: 3.5rem auto;
|
||||
padding: 1.5rem 2rem;
|
||||
border-left: 3px solid var(--accent, #e60012);
|
||||
background: var(--bg-surface, rgba(255, 255, 255, 0.03));
|
||||
background: var(--bg-card, rgba(255, 255, 255, 0.03));
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-family: var(--font-heading, serif);
|
||||
font-family: var(--font-serif, serif);
|
||||
font-style: italic;
|
||||
font-size: clamp(1.15rem, 2.2vw, 1.35rem);
|
||||
line-height: 1.6;
|
||||
@@ -142,7 +142,7 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius, 0px);
|
||||
border-radius: var(--radius-md, 0px);
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
|
||||
46
app/admin/__tests__/findAlbumAddress.test.ts
Normal file
46
app/admin/__tests__/findAlbumAddress.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { findAlbumAddress } from '../components/page-builder/findAlbumAddress';
|
||||
import type { Subpage } from '../components/page-builder/types';
|
||||
|
||||
const subpage = (name: string, albums: string[], sections: string[][] = []): Subpage => ({
|
||||
name,
|
||||
albums: albums.map((id) => ({ id })),
|
||||
sections: sections.map((ids, i) => ({ title: `S${i}`, albums: ids.map((id) => ({ id })) })),
|
||||
});
|
||||
|
||||
describe('findAlbumAddress', () => {
|
||||
it('finds a standalone album', () => {
|
||||
expect(findAlbumAddress({ albums: [{ id: 'x' }, { id: 'a' }], subpages: [] }, 'a')).toEqual({
|
||||
type: 'standalone',
|
||||
albumIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("finds an album on a subpage's own list", () => {
|
||||
const gallery = { albums: [], subpages: [subpage('one', ['x']), subpage('two', ['y', 'a'])] };
|
||||
expect(findAlbumAddress(gallery, 'a')).toEqual({
|
||||
type: 'subpage',
|
||||
subpageIndex: 1,
|
||||
albumIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('finds an album inside a section', () => {
|
||||
const gallery = { albums: [], subpages: [subpage('one', [], [['x'], ['y', 'a']])] };
|
||||
expect(findAlbumAddress(gallery, 'a')).toEqual({
|
||||
type: 'section',
|
||||
subpageIndex: 0,
|
||||
sectionIndex: 1,
|
||||
albumIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the standalone listing when an album appears twice', () => {
|
||||
const gallery = { albums: [{ id: 'a' }], subpages: [subpage('one', ['a'])] };
|
||||
expect(findAlbumAddress(gallery, 'a')?.type).toBe('standalone');
|
||||
});
|
||||
|
||||
it('returns null for an album the builder does not list', () => {
|
||||
expect(findAlbumAddress({ albums: [], subpages: [subpage('one', ['x'])] }, 'a')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -31,3 +31,4 @@
|
||||
@import './styles/essay-builder.css';
|
||||
@import './styles/asset-order.css';
|
||||
@import './styles/favicon.css';
|
||||
@import './styles/diagnostics.css';
|
||||
|
||||
@@ -4,9 +4,8 @@ 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 { systemHealth } from './systemHealth';
|
||||
import { DOCTOR_LEVEL_EVENT, systemHealth } from './systemHealth';
|
||||
import * as Icons from './Icons';
|
||||
|
||||
interface Props {
|
||||
@@ -30,7 +29,6 @@ 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);
|
||||
@@ -104,6 +102,14 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
fetchDoctor();
|
||||
}, [fetchDoctor]);
|
||||
|
||||
// The diagnostics page runs the doctor itself; take its answer rather than
|
||||
// asking Immich the same three questions again.
|
||||
useEffect(() => {
|
||||
const onLevel = (e: Event) => setDoctorLevel((e as CustomEvent<DoctorLevel>).detail);
|
||||
window.addEventListener(DOCTOR_LEVEL_EVENT, onLevel);
|
||||
return () => window.removeEventListener(DOCTOR_LEVEL_EVENT, onLevel);
|
||||
}, []);
|
||||
|
||||
const immichIndicator = indicator(
|
||||
status?.immich?.status === 'connected',
|
||||
'Connected',
|
||||
@@ -242,15 +248,13 @@ export default function AdminDashboard({ onLogout, children }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-dropdown-footer">
|
||||
<button
|
||||
<Link
|
||||
href="/admin/diagnostics"
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={() => {
|
||||
setShowStatus(false);
|
||||
setShowDoctor(true);
|
||||
}}
|
||||
onClick={() => setShowStatus(false)}
|
||||
>
|
||||
<Icons.IconShieldCheck size={14} /> Diagnostics
|
||||
</button>
|
||||
</Link>
|
||||
<button
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={() => {
|
||||
@@ -300,14 +304,6 @@ 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)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useScrollLock } from './useScrollLock';
|
||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||
import { IconFolder } from './Icons';
|
||||
|
||||
interface ImmichAlbumInfo {
|
||||
@@ -37,11 +38,20 @@ export default function AlbumPicker({ albums, onSelect, onClose, usedAlbumIds }:
|
||||
);
|
||||
}, [albums, search]);
|
||||
|
||||
const cardRef = useModalDialog(onClose);
|
||||
|
||||
return (
|
||||
<div className="picker-overlay" onClick={onClose}>
|
||||
<div className="picker-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="picker-modal"
|
||||
ref={cardRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="album-picker-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="picker-header">
|
||||
<h3>Select Album</h3>
|
||||
<h3 id="album-picker-title">Select Album</h3>
|
||||
<button className="admin-btn-icon" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||
import { IconCheck, IconFolder, IconImage, IconStar } from './Icons';
|
||||
import { useScrollLock } from './useScrollLock';
|
||||
|
||||
@@ -29,6 +30,7 @@ export default function AssetPicker({
|
||||
title,
|
||||
}: Props) {
|
||||
useScrollLock(true);
|
||||
const cardRef = useModalDialog(onClose);
|
||||
const [tab, setTab] = useState<Tab>(albumId ? 'album' : 'favorites');
|
||||
const [assets, setAssets] = useState<AssetInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -114,9 +116,16 @@ export default function AssetPicker({
|
||||
|
||||
return (
|
||||
<div className="picker-overlay" onClick={onClose}>
|
||||
<div className="asset-picker-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="asset-picker-modal"
|
||||
ref={cardRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="asset-picker-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="picker-header">
|
||||
<h3>{title || 'Select Hero Image'}</h3>
|
||||
<h3 id="asset-picker-title">{title || 'Select Hero Image'}</h3>
|
||||
<button className="admin-btn-icon" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { BackupItem } from '@/app/api/admin/backups/route';
|
||||
import type { BackupItem, BackupTarget } from '@/app/api/admin/backups/route';
|
||||
import * as Icons from './Icons';
|
||||
import { useScrollLock } from './useScrollLock';
|
||||
import { useModalDialog } from '@/hooks/useModalDialog';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -11,17 +12,25 @@ interface Props {
|
||||
onRestoreSuccess: () => void;
|
||||
}
|
||||
|
||||
const TABS: { target: BackupTarget; label: string; file: string }[] = [
|
||||
{ target: 'gallery', label: 'Gallery', file: 'gallery.yaml' },
|
||||
{ target: 'settings', label: 'Settings', file: 'settings.yaml' },
|
||||
{ target: 'about', label: 'About', file: 'about.md' },
|
||||
{ target: 'journal', label: 'Journal', file: 'journal entries' },
|
||||
];
|
||||
|
||||
type BackupLists = Record<BackupTarget, BackupItem[]>;
|
||||
|
||||
const EMPTY_LISTS: BackupLists = { gallery: [], settings: [], about: [], journal: [] };
|
||||
|
||||
export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<'gallery' | 'settings'>('gallery');
|
||||
const [activeTab, setActiveTab] = useState<BackupTarget>('gallery');
|
||||
const [showAllBackups, setShowAllBackups] = useState(false);
|
||||
const [backups, setBackups] = useState<{ gallery: BackupItem[]; settings: BackupItem[] }>({
|
||||
gallery: [],
|
||||
settings: [],
|
||||
});
|
||||
const [backups, setBackups] = useState<BackupLists>(EMPTY_LISTS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [restoringFilename, setRestoringFilename] = useState<string | null>(null);
|
||||
const [confirmFilename, setConfirmFilename] = useState<string | null>(null);
|
||||
const [confirmItem, setConfirmItem] = useState<BackupItem | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
const fetchBackups = useCallback(async () => {
|
||||
@@ -33,7 +42,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
throw new Error('Failed to fetch backup history');
|
||||
}
|
||||
const data = await res.json();
|
||||
setBackups(data.backups || { gallery: [], settings: [] });
|
||||
setBackups({ ...EMPTY_LISTS, ...data.backups });
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Error loading backups');
|
||||
} finally {
|
||||
@@ -44,17 +53,20 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchBackups();
|
||||
setConfirmFilename(null);
|
||||
setConfirmItem(null);
|
||||
setSuccessMsg(null);
|
||||
setShowAllBackups(false);
|
||||
}
|
||||
}, [isOpen, fetchBackups]);
|
||||
|
||||
useScrollLock(isOpen);
|
||||
/* Before the early `return null`: hooks must not run conditionally. */
|
||||
const cardRef = useModalDialog(onClose, isOpen);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
async function handleRestore(filename: string) {
|
||||
async function handleRestore(item: BackupItem) {
|
||||
const filename = item.filename;
|
||||
setRestoringFilename(filename);
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
@@ -63,7 +75,7 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
const res = await fetch('/api/admin/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ backupFilename: filename }),
|
||||
body: JSON.stringify({ backupFilename: filename, target: item.target }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
@@ -72,8 +84,12 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
throw new Error(data.error || 'Failed to restore backup');
|
||||
}
|
||||
|
||||
setSuccessMsg(`Backup restored successfully! (${filename})`);
|
||||
setConfirmFilename(null);
|
||||
setSuccessMsg(
|
||||
item.slug
|
||||
? `Journal entry "${item.slug}" restored.`
|
||||
: `Backup restored successfully! (${filename})`,
|
||||
);
|
||||
setConfirmItem(null);
|
||||
await fetchBackups();
|
||||
onRestoreSuccess();
|
||||
} catch (err: any) {
|
||||
@@ -90,12 +106,20 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
|
||||
return (
|
||||
<div className="backup-modal-backdrop" onClick={onClose}>
|
||||
<div className="backup-modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className="backup-modal-container"
|
||||
ref={cardRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="backup-modal-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="backup-modal-header">
|
||||
<div>
|
||||
<h2>Backup History & Restoration</h2>
|
||||
<h2 id="backup-modal-title">Backup History & Restoration</h2>
|
||||
<p className="backup-modal-subtitle">
|
||||
Restore previous states of your configuration files with 1-click.
|
||||
Restore an earlier version of your pages, settings, about page or journal — including
|
||||
deleted journal entries.
|
||||
</p>
|
||||
</div>
|
||||
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
|
||||
@@ -105,26 +129,19 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
|
||||
{/* Tab Selection */}
|
||||
<div className="backup-modal-tabs">
|
||||
<button
|
||||
className={`backup-modal-tab ${activeTab === 'gallery' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveTab('gallery');
|
||||
setConfirmFilename(null);
|
||||
setShowAllBackups(false);
|
||||
}}
|
||||
>
|
||||
Gallery Backups ({backups.gallery.length})
|
||||
</button>
|
||||
<button
|
||||
className={`backup-modal-tab ${activeTab === 'settings' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveTab('settings');
|
||||
setConfirmFilename(null);
|
||||
setShowAllBackups(false);
|
||||
}}
|
||||
>
|
||||
Settings Backups ({backups.settings.length})
|
||||
</button>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.target}
|
||||
className={`backup-modal-tab ${activeTab === tab.target ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.target);
|
||||
setConfirmItem(null);
|
||||
setShowAllBackups(false);
|
||||
}}
|
||||
>
|
||||
{tab.label} ({backups[tab.target].length})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
@@ -132,25 +149,25 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
{successMsg && <div className="backup-status-alert success">{successMsg}</div>}
|
||||
|
||||
{/* Confirmation Modal Section */}
|
||||
{confirmFilename && (
|
||||
{confirmItem && (
|
||||
<div className="backup-confirm-box">
|
||||
<div className="backup-confirm-content">
|
||||
<strong>Confirm Restoration</strong>
|
||||
<p>
|
||||
Are you sure you want to restore <code>{confirmFilename}</code>? A safety snapshot
|
||||
of your current state will be created automatically before reverting.
|
||||
Are you sure you want to restore <code>{confirmItem.filename}</code>? A safety
|
||||
snapshot of your current state will be created automatically before reverting.
|
||||
</p>
|
||||
<div className="backup-confirm-actions">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setConfirmFilename(null)}
|
||||
onClick={() => setConfirmItem(null)}
|
||||
disabled={!!restoringFilename}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => handleRestore(confirmFilename)}
|
||||
onClick={() => handleRestore(confirmItem)}
|
||||
disabled={!!restoringFilename}
|
||||
>
|
||||
{restoringFilename ? 'Restoring...' : 'Yes, Restore Now'}
|
||||
@@ -165,7 +182,9 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
{loading ? (
|
||||
<div className="backup-loading">Loading backups...</div>
|
||||
) : currentList.length === 0 ? (
|
||||
<div className="backup-empty">No backups available yet for {activeTab}.yaml</div>
|
||||
<div className="backup-empty">
|
||||
No backups available yet for {TABS.find((t) => t.target === activeTab)?.file}
|
||||
</div>
|
||||
) : (
|
||||
<div className="backup-list">
|
||||
{visibleList.map((item) => {
|
||||
@@ -182,7 +201,16 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
<div key={item.filename} className="backup-item">
|
||||
<div className="backup-item-info">
|
||||
<div className="backup-item-title">
|
||||
{item.slug && <strong className="backup-slug">{item.slug}</strong>}
|
||||
<span className="backup-filename">{formattedDate}</span>
|
||||
{item.isDeleted && (
|
||||
<span
|
||||
className="backup-badge deleted"
|
||||
title="Snapshot taken when the entry was deleted"
|
||||
>
|
||||
Deleted entry
|
||||
</span>
|
||||
)}
|
||||
{item.isPreRestore && (
|
||||
<span
|
||||
className="backup-badge pre-restore"
|
||||
@@ -198,8 +226,8 @@ export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => setConfirmFilename(item.filename)}
|
||||
disabled={!!restoringFilename || confirmFilename === item.filename}
|
||||
onClick={() => setConfirmItem(item)}
|
||||
disabled={!!restoringFilename || confirmItem?.filename === item.filename}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
|
||||
574
app/admin/components/DiagnosticsView.tsx
Normal file
574
app/admin/components/DiagnosticsView.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import BackupManagerModal from './BackupManagerModal';
|
||||
import * as Icons from './Icons';
|
||||
import { DOCTOR_LEVEL_EVENT } from './systemHealth';
|
||||
import type { DoctorFinding, DoctorLevel } from '@/lib/admin/doctor';
|
||||
import type { AltTextReport } from '@/lib/admin/alt-text';
|
||||
|
||||
/**
|
||||
* Diagnostics as a page (/admin/diagnostics).
|
||||
*
|
||||
* It started as a modal (#491), which was fine for seven one-line findings and
|
||||
* nothing else: a photo list does not fit in 640px, a modal cannot be linked
|
||||
* from an issue, and backups and the cache lived in two further places. This
|
||||
* page holds all of it, worst news first.
|
||||
*
|
||||
* The status badge in the top bar still answers only from the doctor. Alt text
|
||||
* is shown here but kept out of the badge on purpose: most libraries have some
|
||||
* photos without a description, and a badge that is never green stops being
|
||||
* read.
|
||||
*/
|
||||
|
||||
interface StatusData {
|
||||
immich?: { status: 'connected' | 'disconnected' };
|
||||
config?: { status: 'valid' | 'invalid' | 'setup'; gallery: string; settings: string };
|
||||
cache?: { size: number };
|
||||
backups?: { count: number; lastBackup: string | null };
|
||||
update?: { current: string; latest?: string; updateAvailable?: boolean };
|
||||
}
|
||||
|
||||
type AltTextData = AltTextReport & {
|
||||
unreadable: number;
|
||||
immichUrl: string;
|
||||
/** `external-domain` is Immich's own public address; `api-url` may be internal. */
|
||||
immichUrlSource: 'external-domain' | 'api-url';
|
||||
};
|
||||
|
||||
/** Which group a doctor check belongs to. An unknown id lands in Content. */
|
||||
const GROUPS: { id: string; title: string; checks: string[] }[] = [
|
||||
{ id: 'connection', title: 'Connection', checks: ['immich-api', 'album-ids'] },
|
||||
{ id: 'security', title: 'Security', checks: ['auth-secret', 'passwords', 'proxy-hops'] },
|
||||
{ id: 'content', title: 'Content', checks: ['albums-shared', 'content-writable', 'alt-text'] },
|
||||
];
|
||||
|
||||
const LEVEL_LABEL: Record<DoctorLevel, string> = { ok: 'OK', warn: 'Check', error: 'Problem' };
|
||||
const LEVEL_RANK: Record<DoctorLevel, number> = { error: 0, warn: 1, ok: 2 };
|
||||
|
||||
const DOCS = 'https://github.com/ralksta/immich-folio/blob/main';
|
||||
|
||||
/** Where each doctor finding is fixed. Admin paths are in-app links. */
|
||||
const FIXES: Record<string, { label: string; href: string }> = {
|
||||
'auth-secret': {
|
||||
label: 'How to set it',
|
||||
href: `${DOCS}/README.md#environment-variables-envlocal`,
|
||||
},
|
||||
'proxy-hops': { label: 'How to set it', href: `${DOCS}/docs/gallery-config.md#trusted-proxies` },
|
||||
passwords: { label: 'Open settings', href: '/admin/settings/security' },
|
||||
'album-ids': { label: 'Open pages', href: '/admin/pages' },
|
||||
'albums-shared': { label: 'Open pages', href: '/admin/pages' },
|
||||
'content-writable': { label: 'How to fix', href: `${DOCS}/docs/admin-panel.md#docker-usage` },
|
||||
'immich-api': {
|
||||
label: 'API key permissions',
|
||||
href: `${DOCS}/README.md#immich-api-key-permissions`,
|
||||
},
|
||||
};
|
||||
|
||||
/** Photos shown per album before the "+n more" tile. */
|
||||
const PREVIEW_COUNT = 12;
|
||||
|
||||
function groupOf(id: string): string {
|
||||
return GROUPS.find((g) => g.checks.includes(id))?.id ?? 'content';
|
||||
}
|
||||
|
||||
/** The alt-text report, phrased as one more finding so it sorts with the rest. */
|
||||
function altTextFinding(report: AltTextData): DoctorFinding {
|
||||
if (!report.captionsEnabled) {
|
||||
return {
|
||||
id: 'alt-text',
|
||||
level: 'warn',
|
||||
title: 'Photo descriptions are not used as alt text',
|
||||
detail:
|
||||
'“Photo Description” is switched off in Settings → General, so every photo on the site has an empty alt text, whatever Immich holds.',
|
||||
};
|
||||
}
|
||||
const missing = report.total - report.described;
|
||||
const unread = report.unreadable
|
||||
? ` ${report.unreadable} ${report.unreadable === 1 ? 'album' : 'albums'} could not be read and are not counted.`
|
||||
: '';
|
||||
if (!missing) {
|
||||
return {
|
||||
id: 'alt-text',
|
||||
level: 'ok',
|
||||
title: `All ${report.total} published photos have alt text`,
|
||||
detail: `Every photo carries its Immich description.${unread}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: 'alt-text',
|
||||
level: 'warn',
|
||||
title: `${missing} published ${missing === 1 ? 'photo has' : 'photos have'} no alt text`,
|
||||
detail: `Folio uses the Immich description as alt text. Without one, screen readers announce nothing and search engines index nothing for the photo.${unread}`,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DiagnosticsView() {
|
||||
const [status, setStatus] = useState<StatusData | null>(null);
|
||||
const [findings, setFindings] = useState<DoctorFinding[] | null>(null);
|
||||
const [altText, setAltText] = useState<AltTextData | null>(null);
|
||||
const [altTextFailed, setAltTextFailed] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [ranAt, setRanAt] = useState<Date | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showBackups, setShowBackups] = useState(false);
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [altTextOpen, setAltTextOpen] = useState(false);
|
||||
const [expandedAlbums, setExpandedAlbums] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/status');
|
||||
setStatus(res.ok ? await res.json() : null);
|
||||
} catch {
|
||||
setStatus(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setCopied(false);
|
||||
|
||||
// The alt-text report reads every published album and is the slow one, so
|
||||
// it arrives on its own and does not hold up the rest of the page.
|
||||
setAltTextFailed(false);
|
||||
fetch('/api/admin/alt-text')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error();
|
||||
setAltText(await res.json());
|
||||
})
|
||||
.catch(() => {
|
||||
setAltText(null);
|
||||
setAltTextFailed(true);
|
||||
});
|
||||
|
||||
try {
|
||||
const [doctorRes] = await Promise.all([fetch('/api/admin/doctor'), fetchStatus()]);
|
||||
if (!doctorRes.ok) throw new Error(`Diagnostics failed (${doctorRes.status})`);
|
||||
const data = await doctorRes.json();
|
||||
setFindings(data.findings || []);
|
||||
setRanAt(new Date());
|
||||
if (data.level) {
|
||||
window.dispatchEvent(new CustomEvent(DOCTOR_LEVEL_EVENT, { detail: data.level }));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Diagnostics failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
run();
|
||||
}, [run]);
|
||||
|
||||
async function clearCache() {
|
||||
setClearing(true);
|
||||
try {
|
||||
await fetch('/api/admin/reload', { method: 'POST' });
|
||||
await fetchStatus();
|
||||
} finally {
|
||||
setClearing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const all: DoctorFinding[] = [...(findings ?? []), ...(altText ? [altTextFinding(altText)] : [])];
|
||||
const attention = all
|
||||
.filter((f) => f.level !== 'ok')
|
||||
.sort((a, b) => LEVEL_RANK[a.level] - LEVEL_RANK[b.level]);
|
||||
|
||||
/** Markdown, so it can be pasted straight into a GitHub issue. */
|
||||
async function copyReport() {
|
||||
const body = all
|
||||
.map((f) => `- **${LEVEL_LABEL[f.level]}** — ${f.title}\n ${f.detail}`)
|
||||
.join('\n');
|
||||
const version = status?.update?.current ? `\nVersion ${status.update.current}\n` : '';
|
||||
try {
|
||||
await navigator.clipboard.writeText(`### Immich Folio diagnostics\n${version}\n${body}\n`);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
setError('Could not copy — your browser refused clipboard access.');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAlbum(id: string) {
|
||||
setExpandedAlbums((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const headline = loading
|
||||
? 'Checking…'
|
||||
: attention.length === 0
|
||||
? 'Nothing needs your attention'
|
||||
: `${attention.length} of ${all.length} checks want a look`;
|
||||
|
||||
const renderAltTextList = (report: AltTextData) => (
|
||||
<div className="diag-alt" id="diag-alt-list">
|
||||
<div className="diag-coverage">
|
||||
<div className="diag-coverage-bar" aria-hidden="true">
|
||||
<span style={{ width: `${Math.round((report.described / report.total) * 100)}%` }} />
|
||||
</div>
|
||||
<span>
|
||||
{report.described} of {report.total} described ·{' '}
|
||||
{Math.round((report.described / report.total) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{report.albums.map((album) => {
|
||||
const expanded = expandedAlbums.has(album.albumId);
|
||||
const shown = expanded ? album.missing : album.missing.slice(0, PREVIEW_COUNT);
|
||||
const hidden = album.missing.length - shown.length;
|
||||
return (
|
||||
<div className="diag-album" key={album.albumId}>
|
||||
<div className="diag-album-head">
|
||||
<span className="diag-album-name">
|
||||
{album.albumName}
|
||||
<span className="diag-album-path">{album.path}</span>
|
||||
</span>
|
||||
<span className="diag-album-count">
|
||||
{album.missing.length} of {album.total} missing
|
||||
</span>
|
||||
</div>
|
||||
<div className="diag-thumbs">
|
||||
{shown.map((gap) => (
|
||||
<a
|
||||
key={gap.assetId}
|
||||
className="diag-thumb"
|
||||
href={`${report.immichUrl}/photos/${gap.assetId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`${gap.fileName} — open in Immich`}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- admin thumbnail proxy, not an optimisable image */}
|
||||
<img src={`/api/admin/thumbnail/${gap.assetId}`} alt="" loading="lazy" />
|
||||
<span className="diag-thumb-name">{gap.fileName}</span>
|
||||
</a>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="diag-thumb-more"
|
||||
onClick={() => toggleAlbum(album.albumId)}
|
||||
>
|
||||
+{hidden} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<p className="diag-note">
|
||||
Add the description in Immich, then press Reload in the top bar.{' '}
|
||||
{report.immichUrlSource === 'external-domain' ? (
|
||||
<>
|
||||
Photos open at <code>{report.immichUrl}</code>, Immich’s external domain.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Photos open at <code>{report.immichUrl}</code> — the address Folio reaches Immich at,
|
||||
which your browser may not be able to open. Set <strong>External domain</strong> in
|
||||
Immich (Administration → Settings → Server) and the links use that instead.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* The way to the fix, for a finding that wants one. Where the fix lives in
|
||||
* the admin the button goes there; where it is an environment variable or a
|
||||
* volume, it goes to the part of the docs that explains it. A finding with
|
||||
* no entry gets no button rather than a label that looks like one.
|
||||
*/
|
||||
const renderFix = (f: DoctorFinding) => {
|
||||
const id = f.id;
|
||||
// A finding about exactly one album opens the page it is published on.
|
||||
if (f.albumIds?.length === 1) {
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/pages?album=${encodeURIComponent(f.albumIds[0])}`}
|
||||
className="admin-btn admin-btn-sm"
|
||||
>
|
||||
Open in Pages
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (id === 'alt-text') {
|
||||
if (altText && !altText.captionsEnabled) {
|
||||
return (
|
||||
<Link href="/admin/settings/general" className="admin-btn admin-btn-sm">
|
||||
Open settings
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return null; // The photo list toggle is rendered separately.
|
||||
}
|
||||
const fix = FIXES[id];
|
||||
if (!fix) return null;
|
||||
return fix.href.startsWith('/') ? (
|
||||
<Link href={fix.href} className="admin-btn admin-btn-sm">
|
||||
{fix.label}
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
href={fix.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-sm"
|
||||
>
|
||||
{fix.label} ↗
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFinding = (f: DoctorFinding) => {
|
||||
const isAltText = f.id === 'alt-text' && altText && altText.captionsEnabled;
|
||||
const hasList = isAltText && altText.albums.length > 0;
|
||||
return (
|
||||
<li key={f.id} className={`diag-check diag-check--${f.level}`}>
|
||||
<div className="diag-check-row">
|
||||
<span className="diag-dot" aria-hidden="true" />
|
||||
<div className="diag-check-body">
|
||||
<span className="diag-check-title">
|
||||
{f.level !== 'ok' && <span className="sr-only">{LEVEL_LABEL[f.level]}: </span>}
|
||||
{f.title}
|
||||
</span>
|
||||
<span className="diag-check-detail">{f.detail}</span>
|
||||
</div>
|
||||
<div className="diag-check-side">
|
||||
{f.level !== 'ok' && renderFix(f)}
|
||||
{hasList && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-sm"
|
||||
aria-expanded={altTextOpen}
|
||||
aria-controls="diag-alt-list"
|
||||
onClick={() => setAltTextOpen(!altTextOpen)}
|
||||
>
|
||||
{altTextOpen ? 'Hide photos' : 'Show photos'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasList && altTextOpen && renderAltTextList(altText)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const okIn = (groupId: string) =>
|
||||
all.filter((f) => f.level === 'ok' && groupOf(f.id) === groupId);
|
||||
|
||||
const immichOk = status?.immich?.status === 'connected';
|
||||
const configState = status?.config?.status;
|
||||
|
||||
return (
|
||||
<div className="diag-page">
|
||||
<div className="diag-head">
|
||||
<div>
|
||||
<h2>{headline}</h2>
|
||||
<p className="diag-sub">
|
||||
{ranAt ? `Last run ${ranAt.toLocaleString()}` : 'Running the checks…'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="diag-actions">
|
||||
<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 || !all.length}
|
||||
title="Markdown, ready to paste into an issue"
|
||||
>
|
||||
<Icons.IconCopy size={14} /> {copied ? 'Copied' : 'Copy report'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="admin-error">{error}</div>}
|
||||
|
||||
<section className="diag-vitals" aria-label="Overview">
|
||||
<div className="diag-vital">
|
||||
<span className="diag-vital-label">Immich</span>
|
||||
<span className="diag-vital-val">
|
||||
<span
|
||||
className={`diag-dot diag-dot--${status ? (immichOk ? 'ok' : 'error') : 'unknown'}`}
|
||||
/>
|
||||
{status ? (immichOk ? 'Connected' : 'Disconnected') : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="diag-vital">
|
||||
<span className="diag-vital-label">Config</span>
|
||||
<span className="diag-vital-val">
|
||||
<span
|
||||
className={`diag-dot diag-dot--${
|
||||
configState === 'valid' ? 'ok' : configState === 'invalid' ? 'error' : 'unknown'
|
||||
}`}
|
||||
/>
|
||||
{configState === 'valid'
|
||||
? 'Valid'
|
||||
: configState === 'invalid'
|
||||
? 'Invalid'
|
||||
: configState === 'setup'
|
||||
? 'Not set up'
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="diag-vital">
|
||||
<span className="diag-vital-label">Backups</span>
|
||||
<span className="diag-vital-val">{status?.backups?.count ?? '—'}</span>
|
||||
<span className="diag-vital-meta">
|
||||
{status?.backups?.lastBackup
|
||||
? `latest ${new Date(status.backups.lastBackup).toLocaleDateString()}`
|
||||
: 'none yet'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="diag-vital">
|
||||
<span className="diag-vital-label">Version</span>
|
||||
<span className="diag-vital-val">{status?.update?.current ?? '—'}</span>
|
||||
<span className="diag-vital-meta">
|
||||
{status?.update?.updateAvailable ? (
|
||||
<a
|
||||
href="https://github.com/ralksta/immich-folio/releases/latest"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{status.update.latest} available
|
||||
</a>
|
||||
) : status?.update?.current ? (
|
||||
'up to date'
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loading && !findings ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{attention.length > 0 && (
|
||||
<section className="diag-group" aria-labelledby="diag-attention">
|
||||
<h3 id="diag-attention" className="diag-group-title">
|
||||
Needs attention
|
||||
</h3>
|
||||
<ul className="diag-checks">{attention.map(renderFinding)}</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{GROUPS.map((group) => {
|
||||
const ok = okIn(group.id);
|
||||
const pendingAltText = group.id === 'content' && !altText && !altTextFailed;
|
||||
const failedAltText = group.id === 'content' && altTextFailed;
|
||||
if (!ok.length && !pendingAltText && !failedAltText) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<section key={group.id} className="diag-group" aria-labelledby={`diag-${group.id}`}>
|
||||
<h3 id={`diag-${group.id}`} className="diag-group-title">
|
||||
{group.title}
|
||||
</h3>
|
||||
<ul className="diag-checks">
|
||||
{ok.map(renderFinding)}
|
||||
{pendingAltText && (
|
||||
<li className="diag-check diag-check--unknown">
|
||||
<div className="diag-check-row">
|
||||
<span className="diag-dot" aria-hidden="true" />
|
||||
<div className="diag-check-body">
|
||||
<span className="diag-check-title">Checking alt text…</span>
|
||||
<span className="diag-check-detail">
|
||||
Reading every published album; large libraries take a moment.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
{failedAltText && (
|
||||
<li className="diag-check diag-check--unknown">
|
||||
<div className="diag-check-row">
|
||||
<span className="diag-dot" aria-hidden="true" />
|
||||
<div className="diag-check-body">
|
||||
<span className="diag-check-title">Alt text could not be checked</span>
|
||||
<span className="diag-check-detail">
|
||||
The albums did not load. If Immich is disconnected, that is the reason —
|
||||
try again once it is back.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<section className="diag-group" aria-labelledby="diag-storage">
|
||||
<h3 id="diag-storage" className="diag-group-title">
|
||||
Storage & cache
|
||||
</h3>
|
||||
<ul className="diag-checks">
|
||||
<li className="diag-check diag-check--ok">
|
||||
<div className="diag-check-row">
|
||||
<span className="diag-dot" aria-hidden="true" />
|
||||
<div className="diag-check-body">
|
||||
<span className="diag-check-title">
|
||||
{status?.backups?.count ?? 0} config backups
|
||||
</span>
|
||||
<span className="diag-check-detail">
|
||||
Written before every admin save, ten per file.
|
||||
</span>
|
||||
</div>
|
||||
<div className="diag-check-side">
|
||||
<button className="admin-btn admin-btn-sm" onClick={() => setShowBackups(true)}>
|
||||
<Icons.IconArchive size={14} /> Manage backups
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="diag-check diag-check--ok">
|
||||
<div className="diag-check-row">
|
||||
<span className="diag-dot" aria-hidden="true" />
|
||||
<div className="diag-check-body">
|
||||
<span className="diag-check-title">
|
||||
In-memory cache holds {status?.cache?.size ?? 0} items
|
||||
</span>
|
||||
<span className="diag-check-detail">
|
||||
Album lists and photo metadata from Immich. Clearing it makes the next page
|
||||
views slower, nothing else.
|
||||
</span>
|
||||
</div>
|
||||
<div className="diag-check-side">
|
||||
<button
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={clearCache}
|
||||
disabled={clearing}
|
||||
>
|
||||
<Icons.IconRefresh size={14} /> {clearing ? 'Clearing…' : 'Clear cache'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<BackupManagerModal
|
||||
isOpen={showBackups}
|
||||
onClose={() => setShowBackups(false)}
|
||||
onRestoreSuccess={fetchStatus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -218,6 +218,7 @@ export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlo
|
||||
{block.type === 'heading' && (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<select
|
||||
aria-label="Heading level"
|
||||
value={block.level}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, level: Number(e.target.value) })
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import DoctorModal from './DoctorModal';
|
||||
import Link from 'next/link';
|
||||
import * as Icons from './Icons';
|
||||
import { en } from '@/lib/i18n/locales/en';
|
||||
import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts';
|
||||
@@ -19,8 +18,6 @@ import { LIGHTBOX_SHORTCUTS, shortcutDisplayKeys } from '@/lib/lightboxShortcuts
|
||||
* English-only, hence `en` directly.
|
||||
*/
|
||||
export default function HelpView() {
|
||||
const [showDoctor, setShowDoctor] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="settings-panel">
|
||||
<div className="settings-section-header">
|
||||
@@ -80,13 +77,11 @@ export default function HelpView() {
|
||||
</div>
|
||||
|
||||
<div className="help-action">
|
||||
<button type="button" className="admin-btn" onClick={() => setShowDoctor(true)}>
|
||||
<Icons.IconShieldCheck size={14} /> Run diagnostics
|
||||
</button>
|
||||
<Link href="/admin/diagnostics" className="admin-btn">
|
||||
<Icons.IconShieldCheck size={14} /> Open diagnostics
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DoctorModal isOpen={showDoctor} onClose={() => setShowDoctor(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,12 @@ export function JournalStudio({ slug: activeSlug }: JournalStudioProps) {
|
||||
};
|
||||
|
||||
const handleDelete = async (slug: string) => {
|
||||
if (!confirm(`Are you sure you want to delete "${slug}"?`)) return;
|
||||
if (
|
||||
!confirm(
|
||||
`Delete "${slug}"?\n\nA copy is kept and can be restored from Backups on the dashboard.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/journal/${slug}`, {
|
||||
@@ -812,6 +817,7 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
|
||||
{block.type === 'heading' && (
|
||||
<div className="journal-heading-row">
|
||||
<select
|
||||
aria-label="Heading level"
|
||||
className="admin-input journal-level-select"
|
||||
value={block.level}
|
||||
onChange={(e) =>
|
||||
@@ -918,6 +924,7 @@ function JournalEditor({ slug, onBack }: JournalEditorProps) {
|
||||
>
|
||||
<div className="journal-photo-layout-row">
|
||||
<select
|
||||
aria-label="Photo layout"
|
||||
className="admin-input"
|
||||
value={block.layout}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -26,6 +26,7 @@ import SaveBar from './SaveBar';
|
||||
import AlbumDrawer from './page-builder/AlbumDrawer';
|
||||
import { SortableAlbumCard } from './page-builder/AlbumCard';
|
||||
import SubpageDrawer from './page-builder/SubpageDrawer';
|
||||
import { findAlbumAddress } from './page-builder/findAlbumAddress';
|
||||
import {
|
||||
seedCoverGrid,
|
||||
type ActiveEditAlbumAddress,
|
||||
@@ -280,6 +281,8 @@ export default function PageBuilder() {
|
||||
const [editingAlbumAddress, setEditingAlbumAddress] = useState<ActiveEditAlbumAddress | null>(
|
||||
null,
|
||||
);
|
||||
/** Album a `?album=` link pointed at, marked in its subpage sheet for a moment. */
|
||||
const [linkedAlbumId, setLinkedAlbumId] = useState<string | null>(null);
|
||||
|
||||
// Keep the builder still behind either drawer. One combined lock rather than
|
||||
// one per drawer: the album drawer opens from inside the subpage drawer, and
|
||||
@@ -331,6 +334,26 @@ export default function PageBuilder() {
|
||||
|
||||
useUnsavedGuard(dirty);
|
||||
|
||||
// A subpage opened from a `?album=` link can hold dozens of albums: bring the
|
||||
// linked one into view, and let the mark fade after a moment. The mark itself
|
||||
// is a prop on the tile — a class added to the DOM here would be wiped by the
|
||||
// next render of the tile.
|
||||
useEffect(() => {
|
||||
if (!linkedAlbumId || expandedSubpage === null) return;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const tile = document.querySelector<HTMLElement>(
|
||||
`.subpage-drawer-container [data-album-id="${window.CSS.escape(linkedAlbumId)}"]`,
|
||||
);
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
tile?.scrollIntoView({ block: 'center', behavior: reduce ? 'auto' : 'smooth' });
|
||||
});
|
||||
const fade = window.setTimeout(() => setLinkedAlbumId(null), 2600);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.clearTimeout(fade);
|
||||
};
|
||||
}, [linkedAlbumId, expandedSubpage]);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -341,7 +364,9 @@ export default function PageBuilder() {
|
||||
|
||||
if (galleryRes.ok) {
|
||||
const { gallery: raw } = await galleryRes.json();
|
||||
setGallery(parseGalleryYaml(raw));
|
||||
const parsed = parseGalleryYaml(raw);
|
||||
setGallery(parsed);
|
||||
openAlbumFromLink(parsed);
|
||||
}
|
||||
|
||||
if (albumsRes.ok) {
|
||||
@@ -355,6 +380,35 @@ export default function PageBuilder() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `?album=<id>` opens the place that album is published from — the
|
||||
* diagnostics page links here from a finding about one album. That is the
|
||||
* subpage's sheet when the album sits on a subpage: the fix for such a
|
||||
* finding (take it off the page, or check which page shows it) lives there,
|
||||
* not in the album's own details. A standalone album has no page, so its
|
||||
* own drawer is the place.
|
||||
*
|
||||
* One-shot: the parameter is dropped once read, so a reload after closing
|
||||
* the sheet does not open it again.
|
||||
*/
|
||||
function openAlbumFromLink(state: GalleryState) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const albumId = params.get('album');
|
||||
if (!albumId) return;
|
||||
params.delete('album');
|
||||
const query = params.toString();
|
||||
window.history.replaceState(null, '', window.location.pathname + (query ? `?${query}` : ''));
|
||||
|
||||
const address = findAlbumAddress(state, albumId);
|
||||
if (!address) return;
|
||||
if (address.subpageIndex !== undefined) {
|
||||
setExpandedSubpage(address.subpageIndex);
|
||||
setLinkedAlbumId(albumId);
|
||||
} else {
|
||||
setEditingAlbumAddress(address);
|
||||
}
|
||||
}
|
||||
|
||||
function parseGalleryYaml(raw: Record<string, unknown>): GalleryState {
|
||||
const hero = Array.isArray(raw.hero) ? raw.hero : raw.hero ? [raw.hero as string] : [];
|
||||
const albums = parseAlbumEntries(
|
||||
@@ -613,6 +667,13 @@ export default function PageBuilder() {
|
||||
markDirty();
|
||||
}
|
||||
|
||||
/** The "03 — Collection" number the page renders: counted over enabled subpages only. */
|
||||
function enabledPosition(index: number): number | undefined {
|
||||
const sp = gallery.subpages[index];
|
||||
if (!sp || sp.enabled === false) return undefined;
|
||||
return gallery.subpages.slice(0, index).filter((s) => s.enabled !== false).length + 1;
|
||||
}
|
||||
|
||||
function removeSubpage(index: number) {
|
||||
if (!confirm('Remove this subpage?')) return;
|
||||
setGallery((g) => ({
|
||||
@@ -966,7 +1027,7 @@ export default function PageBuilder() {
|
||||
items={filteredSubpages.map(({ index }) => `subpage-${index}`)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<div className="subpage-grid">
|
||||
<div className="subpage-tiles">
|
||||
{filteredSubpages.map(({ sp, index }) => (
|
||||
<SortableSubpageTile
|
||||
key={`subpage-${index}`}
|
||||
@@ -986,11 +1047,15 @@ export default function PageBuilder() {
|
||||
<SubpageDrawer
|
||||
sp={gallery.subpages[expandedSubpage]}
|
||||
spIndex={expandedSubpage}
|
||||
kickerIndex={enabledPosition(expandedSubpage)}
|
||||
immichAlbums={immichAlbums}
|
||||
sensors={sensors}
|
||||
drawerMode={drawerMode}
|
||||
onDrawerModeChange={setDrawerMode}
|
||||
onClose={() => setExpandedSubpage(null)}
|
||||
onClose={() => {
|
||||
setExpandedSubpage(null);
|
||||
setLinkedAlbumId(null);
|
||||
}}
|
||||
updateSubpage={updateSubpage}
|
||||
removeSubpage={removeSubpage}
|
||||
addSection={addSection}
|
||||
@@ -1005,6 +1070,7 @@ export default function PageBuilder() {
|
||||
getAlbumName={getAlbumName}
|
||||
getAlbumCount={getAlbumCount}
|
||||
getAlbumThumbnailId={getAlbumThumbnailId}
|
||||
highlightedAlbumId={linkedAlbumId}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import * as Icons from './Icons';
|
||||
import SaveBar from './SaveBar';
|
||||
@@ -486,11 +486,6 @@ const SETTINGS_SECTIONS = [
|
||||
{ id: 'about', label: 'About' },
|
||||
];
|
||||
|
||||
interface SettingsEditorProps {
|
||||
/** Sub-section to show, taken from the /admin/settings/[section] route. */
|
||||
section?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One labelled block of related switches.
|
||||
*
|
||||
@@ -576,8 +571,11 @@ function SettingRow({
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
export default function SettingsEditor() {
|
||||
const router = useRouter();
|
||||
// Read here rather than passed in: the editor is mounted by the settings
|
||||
// layout, which does not receive the child route's params.
|
||||
const section = useParams<{ section?: string }>()?.section;
|
||||
// An unknown section in the URL falls back to General instead of an empty panel.
|
||||
const activeSection = SETTINGS_SECTIONS.some((sec) => sec.id === section)
|
||||
? (section as string)
|
||||
@@ -609,6 +607,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
}>({});
|
||||
const [aboutBody, setAboutBody] = useState('');
|
||||
const [aboutLoading, setAboutLoading] = useState(false);
|
||||
const [aboutLoaded, setAboutLoaded] = useState(false);
|
||||
const [aboutSaving, setAboutSaving] = useState(false);
|
||||
const [aboutDirty, setAboutDirty] = useState(false);
|
||||
const [aboutMessage, setAboutMessage] = useState('');
|
||||
@@ -636,11 +635,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
}
|
||||
}, [settings.theme?.preset, settings.theme?.accent]);
|
||||
|
||||
// The About panel has its own route, so load its content when that section opens
|
||||
// Loaded the first time the About section opens, and only then: the editor
|
||||
// stays mounted across sections, so reloading on every visit would overwrite
|
||||
// unsaved About edits with the file on disk.
|
||||
useEffect(() => {
|
||||
if (activeSection === 'about') loadAboutContent();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeSection]);
|
||||
if (activeSection === 'about' && !aboutLoaded) loadAboutContent();
|
||||
}, [activeSection, aboutLoaded]);
|
||||
|
||||
const toggleMode = (mode: 'dark' | 'light') => {
|
||||
setCurrentMode(mode);
|
||||
@@ -652,25 +652,9 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
|
||||
// ── Keyboard shortcut: ⌘+S / Ctrl+S ─────────────────────────
|
||||
const handleSaveRef = useCallback(() => {
|
||||
if (activeSection === 'about') {
|
||||
if (aboutDirty && !aboutSaving) saveAboutContent();
|
||||
return;
|
||||
}
|
||||
if (dirty && !saving) {
|
||||
handleSave();
|
||||
}
|
||||
saveAll();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
dirty,
|
||||
saving,
|
||||
settings,
|
||||
activeSection,
|
||||
aboutDirty,
|
||||
aboutSaving,
|
||||
aboutMeta,
|
||||
aboutBody,
|
||||
aboutGearText,
|
||||
]);
|
||||
}, [dirty, saving, settings, aboutDirty, aboutSaving, aboutMeta, aboutBody, aboutGearText]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
@@ -683,7 +667,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleSaveRef]);
|
||||
|
||||
useUnsavedGuard(dirty);
|
||||
useUnsavedGuard(dirty || aboutDirty);
|
||||
|
||||
async function loadSettings() {
|
||||
setLoading(true);
|
||||
@@ -786,6 +770,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
setAboutMeta(data.meta || {});
|
||||
setAboutBody(data.body || '');
|
||||
setAboutGearText(data.meta?.gear?.join('\n') || '');
|
||||
setAboutLoaded(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load about content:', err);
|
||||
@@ -833,6 +818,16 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save whatever is unsaved, in either file. The owner edits one set of
|
||||
* settings; that About lives in about.md and the rest in settings.yaml is
|
||||
* not theirs to keep track of.
|
||||
*/
|
||||
function saveAll() {
|
||||
if (dirty && !saving) handleSave();
|
||||
if (aboutDirty && !aboutSaving) saveAboutContent();
|
||||
}
|
||||
|
||||
function updateAboutMeta(key: string, value: unknown) {
|
||||
setAboutMeta((m) => ({ ...m, [key]: value }));
|
||||
setAboutDirty(true);
|
||||
@@ -875,26 +870,21 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
'exif.caption': !anyMetadata,
|
||||
});
|
||||
|
||||
// An error from either save wins over the other's success.
|
||||
const saveBarMessage =
|
||||
[saveMessage, aboutMessage].find((m) => m.startsWith('Error')) || saveMessage || aboutMessage;
|
||||
|
||||
return (
|
||||
<div className="settings-editor">
|
||||
{/* The About panel edits about.md, not settings.yaml, so it drives the bar itself. */}
|
||||
{activeSection === 'about' ? (
|
||||
<SaveBar
|
||||
dirty={aboutDirty}
|
||||
saving={aboutSaving}
|
||||
saveMessage={aboutMessage}
|
||||
onSave={saveAboutContent}
|
||||
label="Save About"
|
||||
/>
|
||||
) : (
|
||||
<SaveBar
|
||||
dirty={dirty}
|
||||
saving={saving}
|
||||
saveMessage={saveMessage}
|
||||
onSave={handleSave}
|
||||
label="Save Settings"
|
||||
/>
|
||||
)}
|
||||
{/* One bar for both files, on every section: an About edit stays visible
|
||||
from Theme, and a settings edit from About. */}
|
||||
<SaveBar
|
||||
dirty={dirty || aboutDirty}
|
||||
saving={saving || aboutSaving}
|
||||
saveMessage={saveBarMessage}
|
||||
onSave={saveAll}
|
||||
label="Save Changes"
|
||||
/>
|
||||
|
||||
<p className="settings-live-sync-note">
|
||||
<Icons.IconRefresh size={13} /> Live Sync (No Docker restart required)
|
||||
@@ -946,6 +936,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="admin-field">
|
||||
<label>Language</label>
|
||||
<select
|
||||
aria-label="Language"
|
||||
value={settings.lang || 'en'}
|
||||
onChange={(e) => update('lang', e.target.value)}
|
||||
>
|
||||
@@ -1209,12 +1200,13 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="admin-field">
|
||||
<label>Visitor Default Mode</label>
|
||||
<select
|
||||
aria-label="Visitor default mode"
|
||||
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>
|
||||
<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
|
||||
@@ -1880,6 +1872,7 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
||||
<div className="admin-field">
|
||||
<label>Position</label>
|
||||
<select
|
||||
aria-label="Watermark position"
|
||||
value={settings.watermark?.position || 'bottom-right'}
|
||||
onChange={(e) => update('watermark.position', e.target.value)}
|
||||
>
|
||||
|
||||
@@ -38,10 +38,11 @@
|
||||
border-color: var(--admin-border-hover, #444);
|
||||
}
|
||||
|
||||
.journal-studio .admin-input:focus,
|
||||
.journal-editor-container .admin-input:focus,
|
||||
.journal-modal-card .admin-input:focus {
|
||||
outline: none;
|
||||
.journal-studio .admin-input:focus-visible,
|
||||
.journal-editor-container .admin-input:focus-visible,
|
||||
.journal-modal-card .admin-input:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--admin-border-focus, #6366f1);
|
||||
}
|
||||
|
||||
@@ -261,7 +262,14 @@
|
||||
.journal-editor-resizer:focus-visible,
|
||||
.journal-editor-split.is-dragging .journal-editor-resizer {
|
||||
background: var(--admin-accent, #6366f1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* The resizer used to change colour on focus exactly as it does on hover,
|
||||
so from the keyboard there was no telling whether it had the focus or the
|
||||
mouse was merely resting on it. */
|
||||
.journal-editor-resizer:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Grip dots, and a widened invisible hit area so the divider is easy to grab. */
|
||||
@@ -295,7 +303,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
background: var(--surface, #111);
|
||||
background: var(--admin-bg, #111);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -357,8 +365,9 @@
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.journal-raw-markdown-editor:focus {
|
||||
outline: none;
|
||||
.journal-raw-markdown-editor:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--admin-accent, #6366f1);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export function SortableAlbumCard({
|
||||
thumbnailId,
|
||||
onRemove,
|
||||
onEdit,
|
||||
highlighted,
|
||||
}: {
|
||||
album: AlbumEntry;
|
||||
index: number;
|
||||
@@ -23,6 +24,7 @@ export function SortableAlbumCard({
|
||||
thumbnailId: string | null;
|
||||
onRemove: () => void;
|
||||
onEdit: () => void;
|
||||
highlighted?: boolean;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: `album-${album.id}-${index}`,
|
||||
@@ -45,6 +47,7 @@ export function SortableAlbumCard({
|
||||
onRemove={onRemove}
|
||||
onEdit={onEdit}
|
||||
dragListeners={listeners}
|
||||
highlighted={highlighted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -58,6 +61,8 @@ interface AlbumCardProps {
|
||||
onRemove: () => void;
|
||||
onEdit: () => void;
|
||||
dragListeners?: Record<string, unknown>;
|
||||
/** Marked briefly when a diagnostics link pointed at this album. */
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
export default function AlbumCard({
|
||||
@@ -68,13 +73,17 @@ export default function AlbumCard({
|
||||
onRemove,
|
||||
onEdit,
|
||||
dragListeners,
|
||||
highlighted,
|
||||
}: AlbumCardProps) {
|
||||
const heroThumb = album.heroImage || thumbnailId;
|
||||
const hasPassword = !!album.password;
|
||||
const hasTitleOverride = !!album.title;
|
||||
|
||||
return (
|
||||
<div className={`album-tile ${hasPassword ? 'has-password' : ''}`}>
|
||||
<div
|
||||
className={`album-tile ${hasPassword ? 'has-password' : ''} ${highlighted ? 'album-tile--linked' : ''}`}
|
||||
data-album-id={album.id}
|
||||
>
|
||||
<div className="album-tile-cover">
|
||||
{dragListeners && (
|
||||
<div className="album-tile-drag" {...dragListeners} title="Drag to reorder">
|
||||
|
||||
@@ -106,6 +106,7 @@ export default function AlbumDrawer({
|
||||
<div className="admin-field">
|
||||
<label>Layout override (Experimental)</label>
|
||||
<select
|
||||
aria-label="Layout override"
|
||||
value={album.grid?.layout || ''}
|
||||
onChange={(e) => {
|
||||
const layout = e.target.value || undefined;
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
interface SubpageDrawerProps {
|
||||
sp: Subpage;
|
||||
spIndex: number;
|
||||
/** 1-based position among the enabled subpages; undefined while disabled. */
|
||||
kickerIndex?: number;
|
||||
immichAlbums: ImmichAlbumInfo[];
|
||||
sensors: SensorDescriptor<SensorOptions>[];
|
||||
drawerMode: 'edit' | 'preview';
|
||||
@@ -64,6 +66,8 @@ interface SubpageDrawerProps {
|
||||
getAlbumName: (id: string) => string;
|
||||
getAlbumCount: (id: string) => number;
|
||||
getAlbumThumbnailId: (id: string) => string | null;
|
||||
/** Album to mark, when the sheet was opened from a diagnostics link. */
|
||||
highlightedAlbumId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +80,7 @@ interface SubpageDrawerProps {
|
||||
export default function SubpageDrawer({
|
||||
sp,
|
||||
spIndex,
|
||||
kickerIndex,
|
||||
immichAlbums,
|
||||
sensors,
|
||||
drawerMode,
|
||||
@@ -95,6 +100,7 @@ export default function SubpageDrawer({
|
||||
getAlbumName,
|
||||
getAlbumCount,
|
||||
getAlbumThumbnailId,
|
||||
highlightedAlbumId,
|
||||
}: SubpageDrawerProps) {
|
||||
return (
|
||||
<div className="subpage-drawer-backdrop" onClick={() => onClose()}>
|
||||
@@ -141,7 +147,7 @@ export default function SubpageDrawer({
|
||||
|
||||
<div className="subpage-drawer-body">
|
||||
{drawerMode === 'preview' ? (
|
||||
<SubpagePreview sp={sp} immichAlbums={immichAlbums} />
|
||||
<SubpagePreview sp={sp} immichAlbums={immichAlbums} index={kickerIndex} />
|
||||
) : (
|
||||
<div className="admin-sheet-columns admin-sheet-columns--settings-aside">
|
||||
<div className="admin-sheet-col">
|
||||
@@ -300,6 +306,7 @@ export default function SubpageDrawer({
|
||||
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
||||
<label>Page Layout Style</label>
|
||||
<select
|
||||
aria-label="Page layout style"
|
||||
value={sp.grid?.layout || 'masonry'}
|
||||
onChange={(e) => {
|
||||
const newLayout = e.target.value;
|
||||
@@ -446,6 +453,7 @@ export default function SubpageDrawer({
|
||||
name={getAlbumName(album.id)}
|
||||
count={getAlbumCount(album.id)}
|
||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||
highlighted={album.id === highlightedAlbumId}
|
||||
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
|
||||
onEdit={() =>
|
||||
onEditAlbum({
|
||||
@@ -506,6 +514,7 @@ export default function SubpageDrawer({
|
||||
name={getAlbumName(album.id)}
|
||||
count={getAlbumCount(album.id)}
|
||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||
highlighted={album.id === highlightedAlbumId}
|
||||
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
|
||||
onEdit={() =>
|
||||
onEditAlbum({
|
||||
|
||||
@@ -1,77 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import { IconFolder } from '../Icons';
|
||||
import type { ImmichAlbumInfo, Subpage } from './types';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { buildCoverGridVars } from '@/lib/config/schema';
|
||||
import { dictionaryFor } from '@/lib/i18n';
|
||||
import type { AlbumEntry, ImmichAlbumInfo, Subpage } from './types';
|
||||
|
||||
interface SubpagePreviewProps {
|
||||
sp: Subpage;
|
||||
immichAlbums: ImmichAlbumInfo[];
|
||||
/** 1-based position among the enabled subpages, for the header kicker. */
|
||||
index?: number;
|
||||
}
|
||||
|
||||
/** The drawer's read-only "Live Preview" tab: hero banner, sections, covers. */
|
||||
export default function SubpagePreview({ sp, immichAlbums }: SubpagePreviewProps) {
|
||||
/**
|
||||
* The width the page is laid out at before it is scaled into the drawer —
|
||||
* a desktop viewport, which is also what the stylesheet's media queries see.
|
||||
*/
|
||||
const CANVAS_WIDTH = 1280;
|
||||
|
||||
/** Mirrors the fallback in lib/config/index.ts when settings.yaml sets none. */
|
||||
const DEFAULT_GRID_COLUMNS = 3;
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
interface PreviewAlbum {
|
||||
key: string;
|
||||
name: string;
|
||||
assetCount: number;
|
||||
thumb: string | null;
|
||||
coverPosition?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawer's read-only "Live Preview" tab.
|
||||
*
|
||||
* It used to draw its own hero banner and tiles in admin styling, which knew
|
||||
* nothing of the preset, the cover grid or the caption bar — the preview showed
|
||||
* a page the site never rendered. It now emits the same markup and classes as
|
||||
* `SubpageGridView`, so the preset CSS (keyed on `[data-preset]`, which the
|
||||
* admin's <html> carries as well) styles it exactly like the public page. The
|
||||
* cover grid is sized by the same `buildCoverGridVars()` from the same inputs.
|
||||
*
|
||||
* The page is laid out at CANVAS_WIDTH and scaled down with `zoom` rather than
|
||||
* squeezed into the drawer, so a three-column grid still looks like one.
|
||||
*/
|
||||
export default function SubpagePreview({ sp, immichAlbums, index }: SubpagePreviewProps) {
|
||||
const frameRef = useRef<HTMLDivElement>(null);
|
||||
const [zoom, setZoom] = useState(0.4);
|
||||
const [site, setSite] = useState<{ columns: number; lang?: string }>({
|
||||
columns: DEFAULT_GRID_COLUMNS,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current;
|
||||
if (!frame) return;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
setZoom(entry.contentRect.width / CANVAS_WIDTH);
|
||||
});
|
||||
observer.observe(frame);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Fetched on every open rather than cached: the global column count and the
|
||||
// language may have been changed in Settings since the page builder loaded.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/admin/settings')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (cancelled || !data?.settings) return;
|
||||
setSite({
|
||||
columns: data.settings.grid?.columns ?? DEFAULT_GRID_COLUMNS,
|
||||
lang: data.settings.lang,
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const t = dictionaryFor(site.lang);
|
||||
// Only the two keys the cover grid reads; see buildCoverGridVars().
|
||||
const gridStyle = buildCoverGridVars(
|
||||
{ columns: sp.coverGrid?.columns, gap: sp.coverGrid?.gap },
|
||||
site.columns,
|
||||
) as React.CSSProperties;
|
||||
|
||||
const toPreviewAlbum = (alb: AlbumEntry, i: number): PreviewAlbum => {
|
||||
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
|
||||
return {
|
||||
key: `${alb.id}-${i}`,
|
||||
name: alb.title || immichAlb?.albumName || alb.id,
|
||||
assetCount: immichAlb?.assetCount ?? 0,
|
||||
thumb: alb.heroImage || immichAlb?.thumbnailAssetId || null,
|
||||
coverPosition: alb.coverPosition,
|
||||
};
|
||||
};
|
||||
|
||||
const sections = sp.sections ?? [];
|
||||
const allAlbums = (sections.length > 0 ? sections.flatMap((sec) => sec.albums) : sp.albums).map(
|
||||
toPreviewAlbum,
|
||||
);
|
||||
const totalPhotos = allAlbums.reduce((sum, a) => sum + a.assetCount, 0);
|
||||
const title = sp.title || sp.name;
|
||||
|
||||
const grid = (albums: PreviewAlbum[]) => (
|
||||
<div className="subpage-grid" style={gridStyle}>
|
||||
{albums.map((album, i) => (
|
||||
<div key={album.key} className="subpage-grid__item">
|
||||
<span className="subpage-grid__item-media">
|
||||
{album.thumb ? (
|
||||
// A plain <img> styled like next/image's `fill`: the admin
|
||||
// thumbnail route takes a raw asset ID, which the image loader
|
||||
// does not.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${album.thumb}`}
|
||||
alt=""
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
...(album.coverPosition ? { objectPosition: album.coverPosition } : {}),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="skeleton"
|
||||
style={{ display: 'block', width: '100%', height: '100%' }}
|
||||
/>
|
||||
)}
|
||||
<span className="subpage-grid__item-badge">{t.common.photos(album.assetCount)}</span>
|
||||
<span className="subpage-grid__item-overlay">
|
||||
<span className="subpage-grid__item-title">{album.name}</span>
|
||||
<span className="subpage-grid__item-count">{t.common.photos(album.assetCount)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="subpage-grid__item-caption">
|
||||
<span className="subpage-grid__item-index">{pad2(i + 1)}</span>
|
||||
<span className="subpage-grid__item-caption-title">{album.name}</span>
|
||||
<span className="subpage-grid__item-caption-count">
|
||||
{t.common.photos(album.assetCount)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="drawer-live-preview-panel">
|
||||
<div className="preview-hero-banner">
|
||||
<h2 className="preview-hero-title">{sp.title || sp.name || 'Untitled Page'}</h2>
|
||||
{sp.subtitle && <p className="preview-hero-sub">{sp.subtitle}</p>}
|
||||
</div>
|
||||
<div className="subpage-preview">
|
||||
<p className="subpage-preview__note">
|
||||
Rendered with the site’s saved theme preset at desktop width. Unsaved edits in this
|
||||
drawer show up here; theme changes show up once saved in Settings.
|
||||
</p>
|
||||
<div className="subpage-preview__frame" ref={frameRef}>
|
||||
{/* inert: a picture of the page, not a second copy of its links. */}
|
||||
<div
|
||||
className="subpage-preview__canvas"
|
||||
style={{ width: CANVAS_WIDTH, zoom }}
|
||||
inert
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="main">
|
||||
<div className="subpage-container">
|
||||
<header className="subpage-header">
|
||||
<div className="subpage-header__main">
|
||||
{index !== undefined && (
|
||||
<p className="subpage-header__kicker">
|
||||
{t.subpage.collectionKicker(pad2(index))}
|
||||
</p>
|
||||
)}
|
||||
{title && <h1 className="subpage-title">{title}</h1>}
|
||||
{sp.subtitle && <p className="subpage-subtitle">{sp.subtitle}</p>}
|
||||
</div>
|
||||
<p className="subpage-header__meta">
|
||||
{t.common.albums(allAlbums.length)} · {t.common.photos(totalPhotos)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{sp.sections && sp.sections.length > 0 ? (
|
||||
sp.sections.map((sec, sIdx) => (
|
||||
<div key={sIdx} className="preview-section-group">
|
||||
<h3 className="preview-section-title">{sec.title || 'Untitled Section'}</h3>
|
||||
{sec.description && <p className="preview-section-desc">{sec.description}</p>}
|
||||
<div className="preview-albums-grid">
|
||||
{sec.albums.map((alb, aIdx) => {
|
||||
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
|
||||
const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
|
||||
|
||||
return (
|
||||
<div key={aIdx} className="preview-album-tile">
|
||||
<div className="preview-album-cover">
|
||||
{thumb ? (
|
||||
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
|
||||
) : (
|
||||
<div className="subpage-tile-placeholder">
|
||||
<IconFolder />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="preview-album-title">
|
||||
{alb.title || immichAlb?.albumName || alb.id}
|
||||
{sections.length > 0 && (
|
||||
<nav className="subpage-toc">
|
||||
{sections.map((sec, i) => (
|
||||
<span key={i} className="subpage-toc__entry">
|
||||
<span className="subpage-toc__link">
|
||||
<span className="subpage-toc__num">{pad2(i + 1)}</span>
|
||||
<span className="subpage-toc__label">{sec.title}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{sections.length > 0
|
||||
? sections.map((sec, i) => (
|
||||
<section key={i} className="subpage-section">
|
||||
<header className="subpage-section__header">
|
||||
<h2 className="subpage-section__title">{sec.title}</h2>
|
||||
{sec.description && (
|
||||
<p className="subpage-section__desc">{sec.description}</p>
|
||||
)}
|
||||
<div className="subpage-section__rule" />
|
||||
</header>
|
||||
{grid(sec.albums.map(toPreviewAlbum))}
|
||||
</section>
|
||||
))
|
||||
: grid(allAlbums)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="preview-albums-grid">
|
||||
{sp.albums.map((alb, aIdx) => {
|
||||
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
|
||||
const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
|
||||
|
||||
return (
|
||||
<div key={aIdx} className="preview-album-tile">
|
||||
<div className="preview-album-cover">
|
||||
{thumb ? (
|
||||
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
|
||||
) : (
|
||||
<div className="subpage-tile-placeholder">
|
||||
<IconFolder />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="preview-album-title">
|
||||
{alb.title || immichAlb?.albumName || alb.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
30
app/admin/components/page-builder/findAlbumAddress.ts
Normal file
30
app/admin/components/page-builder/findAlbumAddress.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ActiveEditAlbumAddress, AlbumEntry, Subpage } from './types';
|
||||
|
||||
/**
|
||||
* Where an album sits in the page builder, as the address its drawer opens by.
|
||||
*
|
||||
* Used by the `/admin/pages?album=<id>` deep link from the diagnostics page. An
|
||||
* album can be listed more than once; the first place wins — standalone, then
|
||||
* each subpage's own albums, then its sections — which is also the order the
|
||||
* builder shows them in.
|
||||
*/
|
||||
export function findAlbumAddress(
|
||||
gallery: { albums: AlbumEntry[]; subpages: Subpage[] },
|
||||
albumId: string,
|
||||
): ActiveEditAlbumAddress | null {
|
||||
const standalone = gallery.albums.findIndex((a) => a.id === albumId);
|
||||
if (standalone !== -1) return { type: 'standalone', albumIndex: standalone };
|
||||
|
||||
for (const [subpageIndex, sp] of gallery.subpages.entries()) {
|
||||
const direct = sp.albums.findIndex((a) => a.id === albumId);
|
||||
if (direct !== -1) return { type: 'subpage', subpageIndex, albumIndex: direct };
|
||||
|
||||
for (const [sectionIndex, section] of (sp.sections ?? []).entries()) {
|
||||
const inSection = section.albums.findIndex((a) => a.id === albumId);
|
||||
if (inSection !== -1) {
|
||||
return { type: 'section', subpageIndex, sectionIndex, albumIndex: inSection };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { DoctorLevel } from '@/lib/admin/doctor';
|
||||
|
||||
/**
|
||||
* Fired on `window` by the diagnostics page after each run, with the doctor's
|
||||
* level as `detail`. The badge lives in the layout and would otherwise keep
|
||||
* showing the level from when the admin was opened until the next full load.
|
||||
*/
|
||||
export const DOCTOR_LEVEL_EVENT = 'folio:doctor-level';
|
||||
|
||||
/** The three tones `status-badge-btn` is styled for. */
|
||||
export type BadgeTone = 'connected' | 'disconnected' | 'unknown';
|
||||
|
||||
|
||||
5
app/admin/diagnostics/page.tsx
Normal file
5
app/admin/diagnostics/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import DiagnosticsView from '../components/DiagnosticsView';
|
||||
|
||||
export default function AdminDiagnosticsPage() {
|
||||
return <DiagnosticsView />;
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import SettingsEditor from '../../components/SettingsEditor';
|
||||
|
||||
export default async function AdminSettingsSectionPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ section: string }>;
|
||||
}) {
|
||||
const { section } = await params;
|
||||
return <SettingsEditor section={section} />;
|
||||
/**
|
||||
* The editor is rendered by the layout and reads the section from the URL
|
||||
* itself; this route only has to exist.
|
||||
*/
|
||||
export default function AdminSettingsSectionPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
19
app/admin/settings/layout.tsx
Normal file
19
app/admin/settings/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import SettingsEditor from '../components/SettingsEditor';
|
||||
|
||||
/**
|
||||
* The settings editor lives in the layout, not in the section pages.
|
||||
*
|
||||
* Each section is its own route, and a page remounts when the section changes.
|
||||
* With the editor in the page, switching from General to Theme threw away every
|
||||
* unsaved edit and re-read settings.yaml, without a word — the save bar simply
|
||||
* vanished. A layout stays mounted across its child routes, so the edits, and
|
||||
* the one save bar that covers them, survive the switch.
|
||||
*/
|
||||
export default function AdminSettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<SettingsEditor />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import SettingsEditor from '../components/SettingsEditor';
|
||||
|
||||
/** The editor is rendered by the layout; this route only has to exist. */
|
||||
export default function AdminSettingsPage() {
|
||||
return <SettingsEditor />;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -57,8 +57,9 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.picker-search input:focus {
|
||||
outline: none;
|
||||
.picker-search input:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--admin-border-focus);
|
||||
}
|
||||
|
||||
|
||||
@@ -155,8 +155,9 @@
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.asset-picker-uuid-input:focus {
|
||||
outline: none;
|
||||
.asset-picker-uuid-input:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--admin-border-focus);
|
||||
}
|
||||
|
||||
|
||||
@@ -117,10 +117,11 @@
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.admin-field input:focus,
|
||||
.admin-field select:focus,
|
||||
.admin-field textarea:focus {
|
||||
outline: none;
|
||||
.admin-field input:focus-visible,
|
||||
.admin-field select:focus-visible,
|
||||
.admin-field textarea:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--admin-border-focus);
|
||||
}
|
||||
|
||||
@@ -159,7 +160,8 @@
|
||||
}
|
||||
|
||||
.admin-listbox-trigger:focus-visible {
|
||||
outline: none;
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--admin-border-focus);
|
||||
}
|
||||
|
||||
|
||||
339
app/admin/styles/diagnostics.css
Normal file
339
app/admin/styles/diagnostics.css
Normal file
@@ -0,0 +1,339 @@
|
||||
/* The diagnostics page (/admin/diagnostics). */
|
||||
|
||||
/* Each check is one line with a severity dot: the point is scanning a list for
|
||||
the ones that are not green, not reading prose. Green ones recede. */
|
||||
|
||||
.diag-page {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.diag-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.diag-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.diag-sub {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.diag-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Overview tiles ── */
|
||||
.diag-vitals {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.diag-vitals {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.diag-vital {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
background: var(--admin-bg-raised);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.diag-vital-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.diag-vital-val {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.diag-vital-meta {
|
||||
min-height: 1em;
|
||||
font-size: 0.75rem;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.diag-vital-meta a {
|
||||
color: var(--admin-warning);
|
||||
}
|
||||
|
||||
/* ── Check groups ── */
|
||||
.diag-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.diag-group-title {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.diag-checks {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
background: var(--admin-bg-raised);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.diag-check {
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
.diag-check:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.diag-check-row {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.diag-check-row {
|
||||
grid-template-columns: 8px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.diag-check-side {
|
||||
grid-column: 2;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.diag-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 0.4rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.diag-vital-val .diag-dot {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.diag-check--ok .diag-dot,
|
||||
.diag-dot--ok {
|
||||
background: var(--admin-success);
|
||||
}
|
||||
|
||||
.diag-check--warn .diag-dot {
|
||||
background: var(--admin-warning);
|
||||
}
|
||||
|
||||
.diag-check--error .diag-dot,
|
||||
.diag-dot--error {
|
||||
background: var(--admin-error);
|
||||
}
|
||||
|
||||
.diag-check-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.diag-check-title {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.diag-check--ok .diag-check-title,
|
||||
.diag-check--unknown .diag-check-title {
|
||||
font-weight: 500;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.diag-check-detail {
|
||||
max-width: 70ch;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.diag-check-side {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* ── Alt-text list ── */
|
||||
.diag-alt {
|
||||
padding: 0 1rem 1rem calc(1rem + 8px + 0.75rem);
|
||||
}
|
||||
|
||||
.diag-coverage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--admin-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.diag-coverage-bar {
|
||||
flex: 1;
|
||||
max-width: 320px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--admin-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diag-coverage-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--admin-success);
|
||||
}
|
||||
|
||||
.diag-album {
|
||||
padding: 0.75rem 0;
|
||||
border-top: 1px dashed var(--admin-border);
|
||||
}
|
||||
|
||||
.diag-album-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.diag-album-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diag-album-path {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 400;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.diag-album-count {
|
||||
font-size: 0.72rem;
|
||||
color: var(--admin-warning);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.diag-thumbs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.diag-thumb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.diag-thumb img {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 6px;
|
||||
background: var(--admin-bg-elevated);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.diag-thumb:hover img {
|
||||
border-color: var(--admin-border-hover);
|
||||
}
|
||||
|
||||
.diag-thumb-name {
|
||||
overflow: hidden;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.68rem;
|
||||
color: var(--admin-text-muted);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.diag-thumb-more {
|
||||
aspect-ratio: 3 / 2;
|
||||
border: 1px dashed var(--admin-border);
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
color: var(--admin-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.diag-thumb-more:hover {
|
||||
border-color: var(--admin-border-hover);
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.diag-note {
|
||||
margin: 0.5rem 0 0;
|
||||
max-width: 70ch;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.diag-note code {
|
||||
padding: 0.05rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
background: var(--admin-bg-elevated);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.diag-thumb img {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -3,87 +3,32 @@
|
||||
later sheets override earlier ones, so the order there is the cascade. */
|
||||
|
||||
/* ── Drawer Live Preview ────────────────────────────────────── */
|
||||
.drawer-live-preview-panel {
|
||||
/* Only the frame is admin styling. Everything inside the canvas is the public
|
||||
page's own markup, styled by globals.css and the preset — so nothing here
|
||||
may target the subpage-* classes. */
|
||||
.subpage-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.preview-hero-banner {
|
||||
text-align: center;
|
||||
padding: 1.75rem 1rem;
|
||||
background: var(--admin-bg-elevated);
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--admin-border);
|
||||
}
|
||||
|
||||
.preview-hero-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
.subpage-preview__note {
|
||||
margin: 0;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.preview-hero-sub {
|
||||
font-size: 0.82rem;
|
||||
color: var(--admin-text-muted);
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
|
||||
.preview-section-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.preview-section-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.preview-section-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--admin-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-albums-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.preview-album-tile {
|
||||
background: var(--admin-bg-raised);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-album-cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
background: var(--admin-bg-elevated);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-album-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-album-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.4rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
|
||||
.subpage-preview__frame {
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The site's own page surface, as <body> paints it. */
|
||||
.subpage-preview__canvas {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -557,6 +557,7 @@ h3 .svg-icon {
|
||||
/* Modal Tabs */
|
||||
.backup-modal-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
@@ -706,6 +707,17 @@ h3 .svg-icon {
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.backup-badge.deleted {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--admin-error, #ef4444);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.backup-slug {
|
||||
font-size: 0.88rem;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.backup-item-meta code {
|
||||
font-family: monospace;
|
||||
font-size: 0.72rem;
|
||||
|
||||
@@ -108,13 +108,18 @@
|
||||
color: var(--admin-text, #f0f0f0) !important;
|
||||
font-size: 0.85rem !important;
|
||||
padding: 0.55rem 0.75rem !important;
|
||||
outline: none !important;
|
||||
transition: border-color 0.15s !important;
|
||||
}
|
||||
|
||||
.essay-block-editor input[type='text']:focus,
|
||||
.essay-block-editor textarea:focus,
|
||||
.essay-block-editor select:focus {
|
||||
/* The base rule used to carry `outline: none !important`, which removed the
|
||||
focus ring from these fields for good, not just on hover. It is gone; the
|
||||
ring is added on focus instead, with the same !important so it wins against
|
||||
the other !important declarations in this file. */
|
||||
.essay-block-editor input[type='text']:focus-visible,
|
||||
.essay-block-editor textarea:focus-visible,
|
||||
.essay-block-editor select:focus-visible {
|
||||
outline: var(--admin-focus-ring) !important;
|
||||
outline-offset: 1px !important;
|
||||
border-color: var(--admin-border-focus, #888888) !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -105,10 +105,13 @@
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.builder-search-input:focus {
|
||||
outline: none;
|
||||
/* The only field that already got this right (accent border plus shadow).
|
||||
It moves to the same ring as every other field, so focus looks the same
|
||||
throughout the admin panel. */
|
||||
.builder-search-input:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--admin-accent);
|
||||
box-shadow: 0 0 0 1px var(--admin-accent);
|
||||
background: var(--admin-bg);
|
||||
}
|
||||
|
||||
|
||||
@@ -268,8 +268,10 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Subpage Overview Grid */
|
||||
.subpage-grid {
|
||||
/* Subpage Overview Grid.
|
||||
Not `.subpage-grid`: that is the public page's cover grid, which globals.css
|
||||
styles everywhere — and which the drawer's Live Preview renders for real. */
|
||||
.subpage-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.75rem;
|
||||
@@ -410,8 +412,9 @@
|
||||
border-color: var(--admin-border);
|
||||
}
|
||||
|
||||
.subpage-name-input:focus {
|
||||
outline: none;
|
||||
.subpage-name-input:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--admin-border-focus);
|
||||
background: var(--admin-bg-input);
|
||||
}
|
||||
@@ -474,8 +477,9 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.section-title-input:focus {
|
||||
outline: none;
|
||||
.section-title-input:focus-visible {
|
||||
outline: var(--admin-focus-ring);
|
||||
outline-offset: 1px;
|
||||
border-color: var(--admin-border-hover);
|
||||
background: var(--admin-bg);
|
||||
}
|
||||
@@ -484,3 +488,9 @@
|
||||
font-size: 0.8rem !important;
|
||||
padding: 0.4rem 0.5rem !important;
|
||||
}
|
||||
|
||||
/* The tile a diagnostics link pointed at (`/admin/pages?album=<id>`). */
|
||||
.album-tile--linked {
|
||||
outline: 2px solid var(--admin-warning);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Sidebar nav, preset preview mockups, the status badge and its dropdown, grouped feature settings, the doctor report. */
|
||||
/* Sidebar nav, preset preview mockups, the status badge and its dropdown, grouped feature settings. */
|
||||
/* Split out of admin.css (#533). Loaded from admin.css in a fixed order —
|
||||
later sheets override earlier ones, so the order there is the cascade. */
|
||||
|
||||
@@ -427,68 +427,6 @@
|
||||
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 {
|
||||
@@ -691,7 +629,7 @@
|
||||
}
|
||||
|
||||
.status-val.update {
|
||||
color: var(--admin-warning, #f59e0b);
|
||||
color: var(--admin-warning);
|
||||
}
|
||||
|
||||
.status-val.unknown {
|
||||
|
||||
@@ -15,14 +15,24 @@
|
||||
--admin-bg-input: #161616;
|
||||
--admin-border: #2e2e2e;
|
||||
--admin-border-hover: #444444;
|
||||
--admin-border-focus: #666666;
|
||||
--admin-border-focus: #777777;
|
||||
--admin-text: #f0f0f0;
|
||||
--admin-text-secondary: #a0a0a0;
|
||||
--admin-text-muted: #707070;
|
||||
--admin-accent: #f59e0b;
|
||||
/* Focus used to show only as a border colour change from #2e2e2e to
|
||||
#666666, a contrast of 2.37:1 where WCAG 1.4.11 asks for 3:1 against
|
||||
adjacent colours, so keyboard users could barely see where they were.
|
||||
The border value now clears 3:1, and a real ring comes on top: the
|
||||
accent sits at 8.8:1 on the dark background. */
|
||||
--admin-focus-ring: 2px solid var(--admin-accent);
|
||||
/* One height for every control in the header row. */
|
||||
--admin-control-height: 36px;
|
||||
--admin-success: #4ade80;
|
||||
/* Warnings get their own token rather than the accent: a preset may recolour
|
||||
the accent (studio-modern makes it red), and a warning must not read as an
|
||||
error because of the theme. */
|
||||
--admin-warning: #f59e0b;
|
||||
--admin-error: #f87171;
|
||||
--admin-error-bg: #2d1111;
|
||||
--admin-error-border: #5c2020;
|
||||
@@ -40,12 +50,13 @@
|
||||
--admin-bg-input: #ffffff;
|
||||
--admin-border: #e0e0e0;
|
||||
--admin-border-hover: #c0c0c0;
|
||||
--admin-border-focus: #999999;
|
||||
--admin-border-focus: #7f7f7f;
|
||||
--admin-text: #1a1a1a;
|
||||
--admin-text-secondary: #555555;
|
||||
--admin-text-muted: #888888;
|
||||
--admin-accent: #d97706;
|
||||
--admin-success: #16a34a;
|
||||
--admin-warning: #b45309;
|
||||
--admin-error: #dc2626;
|
||||
--admin-error-bg: #fef2f2;
|
||||
--admin-error-border: #fecaca;
|
||||
|
||||
@@ -82,6 +82,14 @@ const ROUTES: {
|
||||
method: 'GET',
|
||||
args: () => [new NextRequest('http://localhost/api/admin/doctor')],
|
||||
},
|
||||
{
|
||||
// Lists album titles and raw asset UUIDs.
|
||||
name: 'GET /api/admin/alt-text',
|
||||
path: 'alt-text',
|
||||
load: () => import('../alt-text/route'),
|
||||
method: 'GET',
|
||||
args: () => [],
|
||||
},
|
||||
{
|
||||
name: 'GET /api/admin/assets',
|
||||
path: 'assets',
|
||||
|
||||
83
app/api/admin/alt-text/route.ts
Normal file
83
app/api/admin/alt-text/route.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { immich } from '@/lib/immich';
|
||||
import {
|
||||
albumPaths,
|
||||
buildAltTextReport,
|
||||
pickImmichWebUrl,
|
||||
type AltTextAlbumInput,
|
||||
} from '@/lib/admin/alt-text';
|
||||
|
||||
/**
|
||||
* Immich's *External domain* setting, or null when unset or unreadable. A
|
||||
* failure here must not cost the report — the links just fall back.
|
||||
*/
|
||||
async function immichExternalDomain(apiUrl: string, apiKey: string, timeoutMs: number) {
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/server/config`, {
|
||||
headers: { 'x-api-key': apiKey, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { externalDomain?: string };
|
||||
return body.externalDomain ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alt-text coverage of every published album, for the diagnostics page.
|
||||
*
|
||||
* Goes through `immich.getAlbum()`, so it shares the album cache with the
|
||||
* public site: a check right after browsing the gallery costs no Immich calls.
|
||||
* An album that fails to load is counted, not fatal — the rest still report.
|
||||
*/
|
||||
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.needsCredentials) {
|
||||
return NextResponse.json({ error: 'Not configured' }, { status: 503 });
|
||||
}
|
||||
|
||||
const paths = albumPaths(config.subpages);
|
||||
const inputs: AltTextAlbumInput[] = [];
|
||||
let unreadable = 0;
|
||||
|
||||
const [albums, externalDomain] = await Promise.all([
|
||||
Promise.all(config.albums.map((id) => immich.getAlbum(id).catch(() => undefined))),
|
||||
immichExternalDomain(config.immich.apiUrl, config.immich.apiKey, config.immichTimeoutMs),
|
||||
]);
|
||||
const immichLink = pickImmichWebUrl(externalDomain, config.immich.apiUrl);
|
||||
albums.forEach((album, i) => {
|
||||
if (!album) {
|
||||
// null is "Immich says it does not exist" — the doctor's album-ids check
|
||||
// already reports that. undefined is a failed request.
|
||||
if (album === undefined) unreadable++;
|
||||
return;
|
||||
}
|
||||
inputs.push({
|
||||
id: album.id,
|
||||
name: album.albumName,
|
||||
path: paths.get(config.albums[i]) ?? '/',
|
||||
assets: album.assets,
|
||||
});
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
...buildAltTextReport(inputs, config.exif.caption),
|
||||
unreadable,
|
||||
immichUrl: immichLink.url,
|
||||
immichUrlSource: immichLink.source,
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,24 @@ import { NextResponse } from 'next/server';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { listBackups, restoreBackup } from '@/lib/admin/yaml-service';
|
||||
import { listJournalBackups, restoreJournalBackup } from '@/lib/admin/journal-service';
|
||||
import { invalidateConfigCache } from '@/lib/config';
|
||||
import { immich } from '@/lib/immich';
|
||||
|
||||
export type BackupTarget = 'gallery' | 'settings' | 'about' | 'journal';
|
||||
|
||||
export interface BackupItem {
|
||||
filename: string;
|
||||
target: 'gallery' | 'settings';
|
||||
target: BackupTarget;
|
||||
timestamp: string | null;
|
||||
isPreRestore: boolean;
|
||||
/** Journal backups only: the entry the backup belongs to. */
|
||||
slug?: string;
|
||||
/** Journal backups only: the snapshot taken when the entry was deleted. */
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
|
||||
function parseBackupInfo(filename: string): BackupItem {
|
||||
const target = filename.startsWith('gallery.yaml') ? 'gallery' : 'settings';
|
||||
function parseBackupInfo(filename: string, target: BackupTarget): BackupItem {
|
||||
const isPreRestore = filename.includes('pre-restore');
|
||||
|
||||
// Extract timestamp from filename like filename.2026-05-31T17-30-00-000Z.bak
|
||||
@@ -37,7 +43,7 @@ function parseBackupInfo(filename: string): BackupItem {
|
||||
};
|
||||
}
|
||||
|
||||
/** GET: List all available backups for gallery and settings. */
|
||||
/** GET: List all available backups, grouped by the file they restore. */
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
@@ -47,16 +53,26 @@ export async function GET() {
|
||||
}
|
||||
|
||||
try {
|
||||
const galleryRaw = await listBackups('gallery.yaml');
|
||||
const settingsRaw = await listBackups('settings.yaml');
|
||||
|
||||
const gallery = galleryRaw.map(parseBackupInfo);
|
||||
const settings = settingsRaw.map(parseBackupInfo);
|
||||
const gallery = (await listBackups('gallery.yaml')).map((f) => parseBackupInfo(f, 'gallery'));
|
||||
const settings = (await listBackups('settings.yaml')).map((f) =>
|
||||
parseBackupInfo(f, 'settings'),
|
||||
);
|
||||
const about = (await listBackups('about.md')).map((f) => parseBackupInfo(f, 'about'));
|
||||
// Newest first across all entries, so a just-deleted entry sits on top.
|
||||
const journal = (await listJournalBackups())
|
||||
.map((b) => ({
|
||||
...parseBackupInfo(b.filename, 'journal'),
|
||||
slug: b.slug,
|
||||
isDeleted: b.kind === 'deleted',
|
||||
}))
|
||||
.sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''));
|
||||
|
||||
return NextResponse.json({
|
||||
backups: {
|
||||
gallery,
|
||||
settings,
|
||||
about,
|
||||
journal,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -76,7 +92,7 @@ export async function POST(req: Request) {
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { backupFilename } = body || {};
|
||||
const { backupFilename, target } = body || {};
|
||||
|
||||
if (!backupFilename || typeof backupFilename !== 'string') {
|
||||
return NextResponse.json({ error: 'backupFilename is required' }, { status: 400 });
|
||||
@@ -95,7 +111,13 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ error: 'Invalid backup file extension' }, { status: 400 });
|
||||
}
|
||||
|
||||
await restoreBackup(backupFilename);
|
||||
// Journal backups live in their own directory, and `<slug>.md.<ts>.bak`
|
||||
// cannot be told apart from an about.md backup by name alone.
|
||||
if (target === 'journal') {
|
||||
await restoreJournalBackup(backupFilename);
|
||||
} else {
|
||||
await restoreBackup(backupFilename);
|
||||
}
|
||||
|
||||
// Invalidate caches & revalidate pages
|
||||
invalidateConfigCache();
|
||||
|
||||
@@ -14,6 +14,22 @@ import { decodeAssetId } from '@/lib/tokens';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
|
||||
import { siteLockResponse } from '@/lib/auth';
|
||||
import { blankRanges, findMetadataRanges } from '@/lib/metadataStrip';
|
||||
|
||||
/**
|
||||
* Bumping this changes every original's ETag, so a revalidating cache fetches
|
||||
* the stripped bytes instead of replaying a pre-fix copy. Browsers hold these
|
||||
* URLs as `immutable` and will not revalidate at all, so a real rollout also
|
||||
* needs IMAGE_CACHE_VERSION bumped — that changes the URL itself.
|
||||
*/
|
||||
const STRIP_VERSION = 's1';
|
||||
|
||||
/**
|
||||
* Originals are buffered whole so their metadata can be blanked before a single
|
||||
* byte reaches the client. Anything larger is not worth holding in memory; those
|
||||
* fall back to the preview rendition, which Immich generates without EXIF.
|
||||
*/
|
||||
const MAX_STRIP_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
// ── Rate limiting ──────────────────────────────────
|
||||
@@ -65,7 +81,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
// `immutable`, so this only matters after expiry or a cache eviction, but a
|
||||
// matching ETag across two different URLs would be wrong either way.
|
||||
const cacheVersion = request.nextUrl.searchParams.get('v') ?? '';
|
||||
const etag = `W/"${token}-${size}${cacheVersion ? `-${cacheVersion}` : ''}"`;
|
||||
const etag = `W/"${token}-${size}-${STRIP_VERSION}${cacheVersion ? `-${cacheVersion}` : ''}"`;
|
||||
if (request.headers.get('if-none-match') === etag) {
|
||||
return new NextResponse(null, {
|
||||
status: 304,
|
||||
@@ -113,6 +129,57 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
contentType = 'application/octet-stream';
|
||||
}
|
||||
|
||||
// ── Metadaten aus Originalen entfernen ─────────────
|
||||
// Das Asset-Token autorisiert das Bild, nicht die Groesse: ein aus dem
|
||||
// oeffentlichen HTML gelesenes Thumbnail-Token plus ?size=original lieferte
|
||||
// bis hierher die unveraenderte Datei — mitsamt GPS. app/api/exif haelt
|
||||
// Koordinaten bewusst zurueck, und /api/map quantisiert sie; dieser Pfad ging
|
||||
// an beidem vorbei. Nur die Metadaten-Bytes werden genullt, die Bilddaten
|
||||
// bleiben bitgenau erhalten — deshalb kostet das keine Qualitaet.
|
||||
let body: BodyInit = result.stream;
|
||||
// Gesetzt, sobald wir gestrippte Bytes ausliefern — der Wert ist dann die
|
||||
// maßgebliche Content-Length.
|
||||
let strippedLength: number | null = null;
|
||||
if (size === 'original') {
|
||||
const declaredLength = result.contentLength ? parseInt(result.contentLength, 10) : NaN;
|
||||
const zuGross = Number.isFinite(declaredLength) && declaredLength > MAX_STRIP_BYTES;
|
||||
|
||||
const raw = zuGross ? null : new Uint8Array(await new Response(result.stream).arrayBuffer());
|
||||
const ranges =
|
||||
raw && raw.length <= MAX_STRIP_BYTES ? findMetadataRanges(raw, contentType) : null;
|
||||
|
||||
if (raw && ranges) {
|
||||
const blanked = blankRanges(raw, ranges);
|
||||
strippedLength = blanked.byteLength;
|
||||
// ArrayBuffer statt Uint8Array: nur der ist ein gueltiger BodyInit. Die
|
||||
// Zusicherung ist sicher — die Bytes stammen aus Response.arrayBuffer(),
|
||||
// also nie aus einem SharedArrayBuffer, den ArrayBufferLike mit einschliesst.
|
||||
body = blanked.buffer.slice(
|
||||
blanked.byteOffset,
|
||||
blanked.byteOffset + blanked.byteLength,
|
||||
) as ArrayBuffer;
|
||||
} else {
|
||||
// Unbekanntes Format, kaputter Container oder zu gross: lieber die
|
||||
// Vorschau ausliefern als ein Original, dessen Metadaten wir nicht
|
||||
// sicher finden. Stillschweigend durchreichen war genau der Fehler.
|
||||
console.warn(
|
||||
`[Image API] Original von ${assetId} (${contentType}) nicht strippbar — liefere Preview.`,
|
||||
);
|
||||
const preview = await immich.streamAsset(assetId, 'preview');
|
||||
if (!preview) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Asset not found' },
|
||||
{ status: 404, headers: { 'Cache-Control': 'no-store' } },
|
||||
);
|
||||
}
|
||||
body = preview.stream;
|
||||
contentType = preview.contentType.toLowerCase().startsWith('image/')
|
||||
? preview.contentType
|
||||
: 'image/jpeg';
|
||||
result = { ...preview, contentLength: preview.contentLength };
|
||||
}
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
// Images are immutable once uploaded to Immich — cache aggressively
|
||||
@@ -121,9 +188,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
'X-RateLimit-Remaining': String(remaining),
|
||||
};
|
||||
|
||||
if (result.contentLength) {
|
||||
// Das Nullen ist laengentreu, der Wert bleibt also gueltig; beim
|
||||
// Preview-Fallback stammt er vom Preview-Abruf.
|
||||
if (strippedLength !== null) {
|
||||
headers['Content-Length'] = String(strippedLength);
|
||||
} else if (result.contentLength) {
|
||||
headers['Content-Length'] = result.contentLength;
|
||||
}
|
||||
|
||||
return new NextResponse(result.stream, { headers });
|
||||
return new NextResponse(body, { headers });
|
||||
}
|
||||
|
||||
@@ -111,7 +111,13 @@
|
||||
/* 56px header + 48px original padding */
|
||||
}
|
||||
|
||||
/* Simple CSS Page Fade-In (Replaces Framer Motion) */
|
||||
/* Simple CSS Page Fade-In (Replaces Framer Motion).
|
||||
*
|
||||
* Opacity only — no `transform`. A transform on `.main` (even the identity
|
||||
* `translateY(0)` this keyframe used to end on, which `forwards` kept applied)
|
||||
* turns the element into the containing block for every `position: fixed`
|
||||
* descendant, so the proofing bar and its modal were pinned to the bottom of
|
||||
* the page instead of floating over it. */
|
||||
[data-transitions='true'] .main {
|
||||
animation: page-fade-in 0.5s ease-out forwards;
|
||||
}
|
||||
@@ -119,12 +125,10 @@
|
||||
@keyframes page-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1120,6 +1124,25 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.album-header__download {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.album-header__download:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.album-header__download svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
/* The gap the grid uses when settings.yaml does not set one. Declared rather
|
||||
than left to each layout's own `var(--grid-gap, N)` fallback, because those
|
||||
@@ -1516,13 +1539,14 @@
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
/* --text-muted is already the quietest colour that clears 4.5:1; an extra
|
||||
opacity on top halved that to about 2:1 in every light preset. */
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
transition: opacity var(--transition-fast);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.footer__legal-link:hover {
|
||||
opacity: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.footer__links {
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.6;
|
||||
/* Colour tokens rather than opacity: they are tuned for 4.5:1 per theme,
|
||||
an opacity on top of them is not. */
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.subpage-content {
|
||||
max-width: 600px;
|
||||
@@ -29,7 +31,7 @@
|
||||
.legal-section__title {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.8;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.legal-section__text {
|
||||
line-height: 1.6;
|
||||
@@ -40,5 +42,5 @@
|
||||
.legal-source {
|
||||
margin-top: 4rem;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.6;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -42,13 +42,18 @@
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
/* These rules used to read `--surface`, `--surface-subtle` and `--border`,
|
||||
none of which exist, so the cards always took their fallbacks: white veils
|
||||
of 3% (background) and 8% (border). Right on a dark background; in the
|
||||
light theme the card could not be told apart from the page and had no
|
||||
visible edge. */
|
||||
.journal-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--surface, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
|
||||
background: var(--bg-card, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.08));
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
@@ -67,7 +72,7 @@
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
background: var(--surface-subtle, rgba(255, 255, 255, 0.02));
|
||||
background: var(--bg-secondary, rgba(255, 255, 255, 0.02));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -143,7 +148,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border, rgba(255, 255, 255, 0.06));
|
||||
border-top: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.06));
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
text-transform: none;
|
||||
font-style: italic;
|
||||
margin-bottom: 48px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
[data-preset='editorial'] .hero__nav-link {
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
/* Fonts are loaded dynamically via layout.tsx based on theme config */
|
||||
|
||||
/* --text-muted missed the 4.5:1 that WCAG AA requires for text of this size
|
||||
in every preset, ranging from 2.33:1 (monograph) to 3.50:1 (studio-modern).
|
||||
It is not a decorative colour: it carries the hero subtitle, section
|
||||
labels, album metadata and the footer links, all between 0.6 and 0.9 rem.
|
||||
|
||||
Only lightness was raised; hue and saturation are unchanged, so the warm
|
||||
greys of editorial, classic and noir stay warm instead of drifting to
|
||||
neutral. Each value is checked against --bg-primary AND --bg-card, and the
|
||||
worse of the two counts.
|
||||
|
||||
In minimal and monograph this pushed --text-muted so close to
|
||||
--text-secondary that the two steps became indistinguishable (#828282 vs
|
||||
#888888). --text-secondary moved along there, restoring two distinct
|
||||
levels: about 7:1 for secondary, about 4.7:1 for muted. */
|
||||
|
||||
/* ── Design Tokens (Fallback Defaults) ───────────── */
|
||||
/* These are overridden at runtime by CSS vars injected from gallery.yaml theme config. */
|
||||
:root {
|
||||
@@ -11,7 +26,7 @@
|
||||
|
||||
--text-primary: #f5f5f0;
|
||||
--text-secondary: #a0a09a;
|
||||
--text-muted: #706f6a;
|
||||
--text-muted: #86857f;
|
||||
|
||||
--border-subtle: #2a2a28;
|
||||
--border-hover: #3a3a38;
|
||||
@@ -64,7 +79,7 @@
|
||||
|
||||
--text-primary: #f4f4f2;
|
||||
--text-secondary: #9c9c96;
|
||||
--text-muted: #6b6b66;
|
||||
--text-muted: #82827c;
|
||||
|
||||
--border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--border-hover: rgba(255, 255, 255, 0.2);
|
||||
@@ -97,7 +112,7 @@
|
||||
|
||||
--text-primary: #161614;
|
||||
--text-secondary: #5a5a55;
|
||||
--text-muted: #8a8a84;
|
||||
--text-muted: #73736e;
|
||||
|
||||
--border-subtle: rgba(0, 0, 0, 0.09);
|
||||
--border-hover: rgba(0, 0, 0, 0.25);
|
||||
@@ -112,8 +127,8 @@
|
||||
--bg-overlay: rgba(0, 0, 0, 0.98);
|
||||
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #888888;
|
||||
--text-muted: #555555;
|
||||
--text-secondary: #9e9e9e;
|
||||
--text-muted: #7c7c7c;
|
||||
|
||||
--border-subtle: #1a1a1a;
|
||||
--border-hover: #333333;
|
||||
@@ -128,7 +143,7 @@
|
||||
|
||||
--text-primary: #000000;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #64748b;
|
||||
--text-muted: #627083; /* 4.65:1 on the tinted header (#f6f6f1), not just on white */
|
||||
|
||||
--border-subtle: #eeeeee;
|
||||
--border-hover: #cccccc;
|
||||
@@ -144,7 +159,7 @@
|
||||
|
||||
--text-primary: #ede8e0;
|
||||
--text-secondary: #9a9086;
|
||||
--text-muted: #6b6359;
|
||||
--text-muted: #8c8275;
|
||||
|
||||
--border-subtle: #2a2620;
|
||||
--border-hover: #3a3530;
|
||||
@@ -175,7 +190,7 @@
|
||||
|
||||
--text-primary: #f0ece4;
|
||||
--text-secondary: #a09888;
|
||||
--text-muted: #706858;
|
||||
--text-muted: #897f6c;
|
||||
|
||||
--border-subtle: #2a2822;
|
||||
--border-hover: #3a3830;
|
||||
@@ -206,7 +221,7 @@
|
||||
|
||||
--text-primary: #f0ebe0;
|
||||
--text-secondary: #998f80;
|
||||
--text-muted: #665c50;
|
||||
--text-muted: #8a7c6c;
|
||||
|
||||
--border-subtle: #252228;
|
||||
--border-hover: #3a3540;
|
||||
@@ -236,8 +251,8 @@
|
||||
--bg-overlay: rgba(15, 15, 15, 0.98);
|
||||
|
||||
--text-primary: #e8e8e8;
|
||||
--text-secondary: #888888;
|
||||
--text-muted: #555555;
|
||||
--text-secondary: #a5a5a5;
|
||||
--text-muted: #828282;
|
||||
|
||||
--border-subtle: #222222;
|
||||
--border-hover: #3a3a3a;
|
||||
@@ -252,7 +267,7 @@
|
||||
|
||||
--text-primary: #111111;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #64748b;
|
||||
--text-muted: #627083; /* 4.65:1 on the tinted header (#f6f6f1), not just on white */
|
||||
|
||||
--border-subtle: #e5e5e5;
|
||||
--border-hover: #cccccc;
|
||||
|
||||
Reference in New Issue
Block a user