diff --git a/.gitignore b/.gitignore index 21a74fb..cffe548 100644 --- a/.gitignore +++ b/.gitignore @@ -62,10 +62,18 @@ docker-compose.override.yml content/settings.yaml content/gallery.yaml content/about.md +# Journal entries reference Immich asset UUIDs, which are specific to one +# server — committing an entry would publish ids from the author's instance. +# The .example alongside them stays tracked as a starting point. +content/journal/*.md +content/essays/*.md # Von der App zur Laufzeit geschrieben: yaml-service legt bis zu 10 Backups je # Datei ab, und die Backups von gallery.yaml/settings.yaml wuerden genau den # Inhalt ins Repo tragen, den die drei Zeilen darueber heraushalten sollen. content/.backups/ +# yaml-service writes next to the file it backs up, so the journal directory +# gets its own .backups/ — the top-level pattern above does not reach it. +content/**/.backups/ content/analytics.json content/*.screenshot-bak # Credentials written by the /install wizard (never committed) diff --git a/app/about/page.tsx b/app/about/page.tsx index 17e4f31..56293dc 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -10,6 +10,8 @@ import Image from 'next/image'; import type { Metadata } from 'next'; import { imageUrl, assetPlaceholder } from '@/lib/urls'; import { immich } from '@/lib/immich'; +import { getConfig } from '@/lib/config'; +import { notFound } from 'next/navigation'; import './about.css'; export const dynamic = 'force-dynamic'; @@ -68,6 +70,9 @@ export async function generateMetadata(): Promise { } export default async function AboutPage() { + const config = getConfig(); + if (!config.aboutEnabled) notFound(); + const { meta, body } = await getAboutContent(); // Fetch ThumbHash for portrait placeholder diff --git a/app/admin/components/Icons.tsx b/app/admin/components/Icons.tsx index 35456b1..ad0785c 100644 --- a/app/admin/components/Icons.tsx +++ b/app/admin/components/Icons.tsx @@ -1,493 +1,7 @@ -'use client'; - -import React from 'react'; - -export interface IconProps extends React.SVGProps { - size?: number; - className?: string; - strokeWidth?: number; -} - -export function IconSun({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconMoon({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -export function IconPalette({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - - - ); -} - -export function IconTarget({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - ); -} - -export function IconFilm({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconFrame({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - - ); -} - -export function IconSparkles({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconGear({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconCamera({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconMap({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - ); -} - -export function IconGrid({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - - ); -} - -export function IconColumns({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -export function IconLink({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconScale({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - ); -} - -export function IconFileText({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconSearch({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconGlobe({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - ); -} - -export function IconEyeOff({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - - ); -} - -export function IconBan({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconArchive({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconShieldCheck({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconRefresh({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - ); -} - -export function IconChevronDown({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -export function IconFolder({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -export function IconTrash({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -export function IconPencil({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -export function IconLock({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconHeart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -/** Filled by default — it marks a state (favourite), not an action. */ -export function IconStar({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -export function IconImage({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - ); -} - -export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - ); -} - -export function IconGripVertical({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - - - - ); -} - -export function IconX({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -export function IconPlus({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -export function IconHome({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconCopy({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconQuote({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconChevronUp({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -export function IconArrowLeftRight({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - - ); -} - -export function IconCalendar({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - ); -} - -export function IconArrowDown({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -export function IconArrowUp({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -/** An "A→Z" marker for alphabetical / filename ordering. */ -export function IconSortAlpha({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - - - ); -} - -export function IconCheck({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) { - return ( - - - - ); -} - -/** Open book — journal entries and photo essays. */ -export function IconBook({ - size = 16, - className = 'svg-icon', - strokeWidth = 2, - ...props -}: IconProps) { - return ( - - - - - ); -} - -export function IconEye({ - size = 16, - className = 'svg-icon', - strokeWidth = 2, - ...props -}: IconProps) { - return ( - - - - - ); -} - -/** Clock — reading time. */ -export function IconClock({ - size = 16, - className = 'svg-icon', - strokeWidth = 2, - ...props -}: IconProps) { - return ( - - - - - ); -} +/** + * The icon set moved to components/Icons.tsx so the public frontend can use it + * too — it had no admin-specific dependency, and the alternative was a second + * copy of the same SVG paths. Re-exported here so the admin's `./Icons` + * imports keep working. + */ +export * from '@/components/Icons'; diff --git a/app/admin/components/SettingsEditor.tsx b/app/admin/components/SettingsEditor.tsx index b5eb2ed..f36d3e0 100644 --- a/app/admin/components/SettingsEditor.tsx +++ b/app/admin/components/SettingsEditor.tsx @@ -70,6 +70,7 @@ interface Settings { opacity?: number; position?: 'bottom-right' | 'bottom-left' | 'center'; }; + about?: { enabled?: boolean }; } const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph']; @@ -192,6 +193,7 @@ const SETTINGS_SECTIONS = [ { id: 'legal', label: 'Legal' }, { id: 'seo', label: 'SEO' }, { id: 'security', label: 'Security & Protection' }, + { id: 'about', label: 'About' }, ]; interface SettingsEditorProps { @@ -215,6 +217,20 @@ export default function SettingsEditor({ section }: SettingsEditorProps) { const [faviconUploading, setFaviconUploading] = useState(false); const [faviconMessage, setFaviconMessage] = useState(''); + // About content state (independent of main settings save) + const [aboutMeta, setAboutMeta] = useState<{ + portrait?: string; + name?: string; + location?: string; + gear?: string[]; + }>({}); + const [aboutBody, setAboutBody] = useState(''); + const [aboutLoading, setAboutLoading] = useState(false); + const [aboutSaving, setAboutSaving] = useState(false); + const [aboutDirty, setAboutDirty] = useState(false); + const [aboutMessage, setAboutMessage] = useState(''); + const [aboutGearText, setAboutGearText] = useState(''); + useEffect(() => { loadSettings(); if (typeof window !== 'undefined') { @@ -236,6 +252,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 + useEffect(() => { + if (activeSection === 'about') loadAboutContent(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeSection]); + const toggleMode = (mode: 'dark' | 'light') => { setCurrentMode(mode); if (typeof document !== 'undefined') { @@ -246,11 +268,25 @@ 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(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dirty, saving, settings]); + }, [ + dirty, + saving, + settings, + activeSection, + aboutDirty, + aboutSaving, + aboutMeta, + aboutBody, + aboutGearText, + ]); useEffect(() => { function handleKeyDown(e: KeyboardEvent) { @@ -347,6 +383,68 @@ export default function SettingsEditor({ section }: SettingsEditorProps) { } } + async function loadAboutContent() { + setAboutLoading(true); + try { + const res = await fetch('/api/admin/about'); + if (res.ok) { + const data = await res.json(); + setAboutMeta(data.meta || {}); + setAboutBody(data.body || ''); + setAboutGearText(data.meta?.gear?.join('\n') || ''); + } + } catch (err) { + console.error('Failed to load about content:', err); + } finally { + setAboutLoading(false); + } + } + + async function saveAboutContent() { + setAboutSaving(true); + setAboutMessage(''); + const cleanedMeta = { ...aboutMeta }; + for (const [k, v] of Object.entries(cleanedMeta)) { + if (v === '' || v === undefined) delete cleanedMeta[k as keyof typeof cleanedMeta]; + } + const gearLines = aboutGearText + .split('\n') + .map((s) => s.trim()) + .filter(Boolean); + if (gearLines.length > 0) { + cleanedMeta.gear = gearLines; + } else { + delete cleanedMeta.gear; + } + + try { + const res = await fetch('/api/admin/about', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ meta: cleanedMeta, body: aboutBody }), + }); + if (res.ok) { + const data = await res.json(); + setAboutDirty(false); + setAboutMessage(data.message || 'Saved!'); + setTimeout(() => setAboutMessage(''), 4000); + } else { + const err = await res.json(); + setAboutMessage(`Error: ${err.error}`); + } + } catch { + setAboutMessage('Error: Failed to save'); + } finally { + setAboutSaving(false); + } + } + + function updateAboutMeta(key: string, value: unknown) { + setAboutMeta((m) => ({ ...m, [key]: value })); + setAboutDirty(true); + setAboutMessage(''); + } + if (loading) { return (
@@ -357,13 +455,24 @@ export default function SettingsEditor({ section }: SettingsEditorProps) { return (
- + {/* The About panel edits about.md, not settings.yaml, so it drives the bar itself. */} + {activeSection === 'about' ? ( + + ) : ( + + )}

Live Sync (No Docker restart required) @@ -513,6 +622,22 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {

+ +
@@ -1409,6 +1534,77 @@ export default function SettingsEditor({ section }: SettingsEditorProps) { )}
)} + + {activeSection === 'about' && ( +
+
+

+ About Page +

+

+ Edit the portrait, biography and gear shown on the About page. Use General to show + or hide the page itself. +

+
+ + {aboutLoading ? ( +
+ ) : ( + <> +
+ + updateAboutMeta('portrait', e.target.value)} + placeholder="Immich asset UUID for the portrait photo" + /> +
+
+ + updateAboutMeta('name', e.target.value)} + placeholder="Your name" + /> +
+
+ + updateAboutMeta('location', e.target.value)} + placeholder="City, Country" + /> +
+
+ +