release: sync main with dev (about editor, journal template, icon cleanup) (#449)

Brings `main` up to `dev` after #448 realigned the two branches.

`main` had #431 (portfolio features) but was missing everything that
landed on `dev` since:

- #443 — journal sample story ships as a template, not a live entry
- #445 — last emojis in the public frontend replaced with SVG icons
- #446 — About page toggle + content editor in the admin (@lancetm714's
work, rebased)
- #448 — the merge of `main` into `dev`, resolving the `Icons.tsx` split
and porting `IconEyeOff` into the shared icon file

After this merge both branches carry the same tree. CI on #448
(type-check + tests) passed on the merged state.

---------

Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
This commit is contained in:
Ralksta
2026-08-14 21:19:54 +02:00
committed by GitHub
parent 84c7eeef40
commit 6eda34cc50
18 changed files with 943 additions and 526 deletions

8
.gitignore vendored
View File

@@ -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)

View File

@@ -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<Metadata> {
}
export default async function AboutPage() {
const config = getConfig();
if (!config.aboutEnabled) notFound();
const { meta, body } = await getAboutContent();
// Fetch ThumbHash for portrait placeholder

View File

@@ -1,493 +1,7 @@
'use client';
import React from 'react';
export interface IconProps extends React.SVGProps<SVGSVGElement> {
size?: number;
className?: string;
strokeWidth?: number;
}
export function IconSun({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
);
}
export function IconMoon({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
);
}
export function IconPalette({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.92 0 1.7-.72 1.7-1.63 0-.44-.18-.85-.46-1.15-.27-.31-.44-.72-.44-1.22 0-.91.75-1.67 1.67-1.67h2.2 0c3.16 0 5.83-2.39 6.1-5.55A10.012 10.012 0 0 0 12 2Z" />
</svg>
);
}
export function IconTarget({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
);
}
export function IconFilm({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M7 3v18M17 3v18M3 7.5h4M3 12h18M3 16.5h4M17 7.5h4M17 16.5h4" />
</svg>
);
}
export function IconFrame({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<line x1="22" x2="2" y1="6" y2="6" />
<line x1="22" x2="2" y1="18" y2="18" />
<line x1="6" x2="6" y1="2" y2="22" />
<line x1="18" x2="18" y1="2" y2="22" />
</svg>
);
}
export function IconSparkles({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
<path d="M5 3v4M3 5h4M19 17v4M17 19h4" />
</svg>
);
}
export function IconGear({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export function IconCamera({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</svg>
);
}
export function IconMap({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" />
<line x1="9" x2="9" y1="3" y2="18" />
<line x1="15" x2="15" y1="6" y2="21" />
</svg>
);
}
export function IconGrid({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
);
}
export function IconColumns({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12 3v18M3 5h18v14H3z" />
</svg>
);
}
export function IconLink({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
);
}
export function IconScale({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z" />
<path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z" />
<path d="M7 21h10M12 3v18M3 7h18" />
</svg>
);
}
export function IconFileText({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4M10 9H8M16 13H8M16 17H8" />
</svg>
);
}
export function IconSearch({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
);
}
export function IconGlobe({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="12" cy="12" r="10" />
<line x1="2" x2="22" y1="12" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
);
}
export function IconEyeOff({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
<line x1="2" x2="22" y1="2" y2="22" />
</svg>
);
}
export function IconBan({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="12" cy="12" r="10" />
<line x1="4.93" x2="19.07" y1="4.93" y2="19.07" />
</svg>
);
}
export function IconArchive({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="20" height="5" x="2" y="3" rx="1" />
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4" />
</svg>
);
}
export function IconShieldCheck({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</svg>
);
}
export function IconRefresh({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 16h5v5" />
</svg>
);
}
export function IconChevronDown({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
export function IconFolder({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
);
}
export function IconTrash({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
);
}
export function IconPencil({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
);
}
export function IconLock({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
export function IconHeart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
);
}
/** Filled by default — it marks a state (favourite), not an action. */
export function IconStar({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14l-5-4.87 6.91-1.01Z" />
</svg>
);
}
export function IconImage({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);
}
export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
);
}
export function IconGripVertical({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="9" cy="12" r="1" fill="currentColor" />
<circle cx="9" cy="5" r="1" fill="currentColor" />
<circle cx="9" cy="19" r="1" fill="currentColor" />
<circle cx="15" cy="12" r="1" fill="currentColor" />
<circle cx="15" cy="5" r="1" fill="currentColor" />
<circle cx="15" cy="19" r="1" fill="currentColor" />
</svg>
);
}
export function IconX({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
export function IconPlus({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M5 12h14M12 5v14" />
</svg>
);
}
export function IconHome({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}
export function IconCopy({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
);
}
export function IconQuote({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2H4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h3c0 4-2 6-4 6" />
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2h-4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h3c0 4-2 6-4 6" />
</svg>
);
}
export function IconChevronUp({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m18 15-6-6-6 6" />
</svg>
);
}
export function IconArrowLeftRight({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m16 3 4 4-4 4" />
<path d="M20 7H4" />
<path d="m8 21-4-4 4-4" />
<path d="M4 17h16" />
</svg>
);
}
export function IconCalendar({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M8 2v4M16 2v4M3 10h18" />
</svg>
);
}
export function IconArrowDown({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12 5v14M19 12l-7 7-7-7" />
</svg>
);
}
export function IconArrowUp({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
);
}
/** An "A→Z" marker for alphabetical / filename ordering. */
export function IconSortAlpha({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M4 10 7 3l3 7M4.9 8.2h4.2" />
<path d="M14 3h6l-6 7h6" />
<path d="M17 14v7M14 18l3 3 3-3" />
</svg>
);
}
export function IconCheck({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
/** Open book — journal entries and photo essays. */
export function IconBook({
size = 16,
className = 'svg-icon',
strokeWidth = 2,
...props
}: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...props}
>
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</svg>
);
}
export function IconEye({
size = 16,
className = 'svg-icon',
strokeWidth = 2,
...props
}: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...props}
>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
/** Clock — reading time. */
export function IconClock({
size = 16,
className = 'svg-icon',
strokeWidth = 2,
...props
}: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...props}
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
);
}
/**
* 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';

View File

@@ -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 (
<div className="admin-loading">
@@ -357,13 +455,24 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
return (
<div className="settings-editor">
<SaveBar
dirty={dirty}
saving={saving}
saveMessage={saveMessage}
onSave={handleSave}
label="Save Settings"
/>
{/* 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"
/>
)}
<p className="settings-live-sync-note">
<Icons.IconRefresh size={13} /> Live Sync (No Docker restart required)
@@ -513,6 +622,22 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
<span className="switch-slider" />
</div>
</button>
<button
type="button"
className={`admin-toggle-card ${settings.about?.enabled !== false ? 'active' : ''}`}
onClick={() => update('about.enabled', settings.about?.enabled === false)}
>
<div className="toggle-card-info">
<span className="toggle-card-title">
<Icons.IconCamera size={16} /> About Page
</span>
<span className="toggle-card-desc">Show a portrait, bio, and gear section on your portfolio</span>
</div>
<div className={`switch-toggle ${settings.about?.enabled !== false ? 'on' : ''}`}>
<span className="switch-slider" />
</div>
</button>
</div>
<div className="admin-field favicon-field">
@@ -1409,6 +1534,77 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
)}
</div>
)}
{activeSection === 'about' && (
<div className="settings-panel">
<div className="settings-section-header">
<h3>
<Icons.IconCamera size={18} /> About Page
</h3>
<p className="settings-section-sub">
Edit the portrait, biography and gear shown on the About page. Use General to show
or hide the page itself.
</p>
</div>
{aboutLoading ? (
<div className="admin-spinner" style={{ margin: '2rem auto' }} />
) : (
<>
<div className="admin-field">
<label>Portrait Asset ID</label>
<input
value={aboutMeta.portrait || ''}
onChange={(e) => updateAboutMeta('portrait', e.target.value)}
placeholder="Immich asset UUID for the portrait photo"
/>
</div>
<div className="admin-field">
<label>Name</label>
<input
value={aboutMeta.name || ''}
onChange={(e) => updateAboutMeta('name', e.target.value)}
placeholder="Your name"
/>
</div>
<div className="admin-field">
<label>Location</label>
<input
value={aboutMeta.location || ''}
onChange={(e) => updateAboutMeta('location', e.target.value)}
placeholder="City, Country"
/>
</div>
<div className="admin-field">
<label>Gear (one per line)</label>
<textarea
value={aboutGearText}
onChange={(e) => {
setAboutGearText(e.target.value);
setAboutDirty(true);
setAboutMessage('');
}}
placeholder={`Leica Q3\nSummilux 35mm f/1.4`}
rows={4}
/>
</div>
<div className="admin-field">
<label>Biography (Markdown)</label>
<textarea
value={aboutBody}
onChange={(e) => {
setAboutBody(e.target.value);
setAboutDirty(true);
setAboutMessage('');
}}
placeholder="Photographer based in..."
rows={6}
/>
</div>
</>
)}
</div>
)}
</div>
</div>
</div>

View File

@@ -135,6 +135,20 @@ const ROUTES: {
method: 'DELETE',
args: () => [],
},
{
name: 'GET /api/admin/about',
path: 'about',
load: () => import('../about/route'),
method: 'GET',
args: () => [],
},
{
name: 'PUT /api/admin/about',
path: 'about',
load: () => import('../about/route'),
method: 'PUT',
args: () => [new Request('http://localhost/api/admin/about', { method: 'PUT', body: '{}' })],
},
{
name: 'GET /api/admin/journal',
path: 'journal',

View File

@@ -0,0 +1,113 @@
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { promises as fs } from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
const CONTENT_DIR = path.resolve(process.cwd(), 'content');
const FILENAME = 'about.md';
const MAX_BACKUPS = 10;
interface AboutMeta {
portrait?: string;
name?: string;
location?: string;
gear?: string[];
}
interface AboutBody {
meta?: AboutMeta;
body?: string;
}
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 filePath = path.join(CONTENT_DIR, FILENAME);
let meta: AboutMeta = {};
let body = '';
try {
const raw = await fs.readFile(filePath, 'utf-8');
const match = raw.match(/^(?:---\r?\n)([\s\S]*?)(?:\r?\n---\r?\n)([\s\S]*)$/);
if (match) {
try {
meta = (yaml.load(match[1]) || {}) as AboutMeta;
} catch (e) {
console.error('[Admin] Failed to parse about.md frontmatter', e);
}
body = match[2].trim();
}
} catch {
// File doesn't exist yet — return empty defaults
}
return NextResponse.json({ meta, body });
}
export async function PUT(request: Request) {
if (!isAdminEnabled()) {
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
}
if (!(await isAdminAuthenticated())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const data = (await request.json().catch(() => null)) as AboutBody | null;
if (!data) {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const meta = data.meta ?? {};
// Clean up empty values
for (const [k, v] of Object.entries(meta)) {
if (v === '' || v === undefined) delete meta[k as keyof AboutMeta];
}
if (meta.gear && meta.gear.length === 0) delete meta.gear;
const cleanMeta: Record<string, unknown> = { ...meta };
const frontmatter = yaml.dump(cleanMeta, { lineWidth: -1, noRefs: true }).trim();
const content = `---\n${frontmatter}\n---\n\n${data.body ?? ''}\n`;
const filePath = path.join(CONTENT_DIR, FILENAME);
// ── Backup existing file ────────────────────────────────────
try {
await fs.access(filePath);
const backupDir = path.join(CONTENT_DIR, '.backups');
await fs.mkdir(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
await fs.copyFile(filePath, path.join(backupDir, `${FILENAME}.${timestamp}.bak`));
// Prune old backups
const entries = await fs.readdir(backupDir);
const aboutBackups = entries.filter((e) => e.startsWith(FILENAME) && e.endsWith('.bak')).sort();
while (aboutBackups.length > MAX_BACKUPS) {
await fs.unlink(path.join(backupDir, aboutBackups.shift()!));
}
} catch {
// File doesn't exist yet — no backup needed, but make sure the dir exists
await fs.mkdir(CONTENT_DIR, { recursive: true });
}
// ── Atomic write (temp file + rename) ───────────────────────
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
await fs.writeFile(tmpPath, content, 'utf-8');
await fs.rename(tmpPath, filePath);
} catch (err) {
await fs.unlink(tmpPath).catch(() => {});
throw err;
}
revalidatePath('/about', 'layout');
return NextResponse.json({ success: true, message: 'About page saved.' });
}

View File

@@ -51,7 +51,10 @@
border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
border-radius: 12px;
overflow: hidden;
transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
transition:
transform 0.25s ease,
border-color 0.25s ease,
box-shadow 0.25s ease;
}
.journal-card:hover {
@@ -84,7 +87,6 @@
align-items: center;
justify-content: center;
opacity: 0.3;
font-size: 2rem;
}
.journal-card__content {

View File

@@ -7,6 +7,7 @@ import { isAdminAuthenticated } from '@/lib/admin/auth';
import { immich } from '@/lib/immich';
import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { BackLink } from '@/components/BackLink';
import { IconBook } from '@/components/Icons';
import './journal.css';
export const dynamic = 'force-dynamic';
@@ -102,7 +103,9 @@ export default async function JournalIndexPage() {
blurDataURL={entry.blurDataURL}
/>
) : (
<div className="journal-card__cover-empty">📖</div>
<div className="journal-card__cover-empty">
<IconBook size={32} aria-hidden="true" />
</div>
)}
</div>

View File

@@ -149,9 +149,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
Home
</Link>
<SubpageNav />
<Link href="/about" className="header__nav-link">
About
</Link>
{config.aboutEnabled && (
<Link href="/about" className="header__nav-link">
About
</Link>
)}
{config.map && (
<Link href="/map" className="header__nav-link">
Map

View File

@@ -1,4 +1,5 @@
import Link from 'next/link';
import { IconRefresh } from './Icons';
interface AdminDiagnosticBannerProps {
slug: string;
@@ -39,10 +40,8 @@ export function AdminDiagnosticBanner({
<code style={{ background: '#2c2c2e', padding: '2px 6px', borderRadius: '4px' }}>
gallery.yaml
</code>
{configuredAlbumCount > 0
? `, and configured with ${configuredAlbumCount} album(s)`
: ''}
, but Immich returned no accessible albums or assets.
{configuredAlbumCount > 0 ? `, and configured with ${configuredAlbumCount} album(s)` : ''},
but Immich returned no accessible albums or assets.
</p>
{reason && (
@@ -64,17 +63,27 @@ export function AdminDiagnosticBanner({
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#f2f2f7' }}>
Possible Causes & Checklist:
</h4>
<ul style={{ margin: 0, paddingLeft: '20px', fontSize: '13px', color: '#d1d1d6', lineHeight: 1.8 }}>
<ul
style={{
margin: 0,
paddingLeft: '20px',
fontSize: '13px',
color: '#d1d1d6',
lineHeight: 1.8,
}}
>
<li>
<strong>Album Shared Status in Immich:</strong> Ensure the album in Immich is marked as{' '}
<strong>Shared (Geteilt)</strong>. Unshared albums are excluded by Immich Folio for security.
<strong>Shared (Geteilt)</strong>. Unshared albums are excluded by Immich Folio for
security.
</li>
<li>
<strong>Cache Lag:</strong> If you just added the album or changed permissions, click{' '}
<em>Force Reload Fresh</em> below.
</li>
<li>
<strong>Album UUID:</strong> Check if the album UUID in your Page Builder matches the exact Immich Album ID.
<strong>Album UUID:</strong> Check if the album UUID in your Page Builder matches the
exact Immich Album ID.
</li>
</ul>
</div>
@@ -95,7 +104,7 @@ export function AdminDiagnosticBanner({
gap: '6px',
}}
>
🔄 Force Reload Fresh (?fresh=1)
<IconRefresh size={14} aria-hidden="true" /> Force Reload Fresh (?fresh=1)
</a>
<Link
href="/admin"

View File

@@ -15,6 +15,7 @@
'use client';
import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react';
import { IconPalette, IconX } from './Icons';
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const FRAMES = ['none', 'passepartout', 'shadow'];
@@ -226,7 +227,11 @@ exifOnHover: ${exifOnHover}`;
boxShadow: '0 4px 24px rgba(0,0,0,0.4)',
}}
>
{open ? '✕' : '🎨'}
{open ? (
<IconX size={18} aria-hidden="true" />
) : (
<IconPalette size={18} aria-hidden="true" />
)}
</button>
{/* ── Panel — anchored bottom-left ────────── */}
@@ -260,7 +265,7 @@ exifOnHover: ${exifOnHover}`;
}}
>
<span style={{ fontWeight: 700, fontSize: 13, letterSpacing: '0.02em' }}>
🎨 Theme Builder
<IconPalette size={13} aria-hidden="true" /> Theme Builder
</span>
<span
style={{

510
components/Icons.tsx Normal file
View File

@@ -0,0 +1,510 @@
'use client';
import React from 'react';
export interface IconProps extends React.SVGProps<SVGSVGElement> {
size?: number;
className?: string;
strokeWidth?: number;
}
export function IconSun({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
);
}
export function IconMoon({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
);
}
export function IconPalette({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.92 0 1.7-.72 1.7-1.63 0-.44-.18-.85-.46-1.15-.27-.31-.44-.72-.44-1.22 0-.91.75-1.67 1.67-1.67h2.2 0c3.16 0 5.83-2.39 6.1-5.55A10.012 10.012 0 0 0 12 2Z" />
</svg>
);
}
export function IconTarget({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
);
}
export function IconFilm({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M7 3v18M17 3v18M3 7.5h4M3 12h18M3 16.5h4M17 7.5h4M17 16.5h4" />
</svg>
);
}
export function IconFrame({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<line x1="22" x2="2" y1="6" y2="6" />
<line x1="22" x2="2" y1="18" y2="18" />
<line x1="6" x2="6" y1="2" y2="22" />
<line x1="18" x2="18" y1="2" y2="22" />
</svg>
);
}
export function IconSparkles({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
<path d="M5 3v4M3 5h4M19 17v4M17 19h4" />
</svg>
);
}
export function IconGear({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
export function IconCamera({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
<circle cx="12" cy="13" r="3" />
</svg>
);
}
export function IconMap({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" />
<line x1="9" x2="9" y1="3" y2="18" />
<line x1="15" x2="15" y1="6" y2="21" />
</svg>
);
}
export function IconGrid({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
);
}
export function IconColumns({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12 3v18M3 5h18v14H3z" />
</svg>
);
}
export function IconLink({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
);
}
export function IconScale({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z" />
<path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z" />
<path d="M7 21h10M12 3v18M3 7h18" />
</svg>
);
}
export function IconFileText({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4M10 9H8M16 13H8M16 17H8" />
</svg>
);
}
export function IconSearch({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
);
}
export function IconGlobe({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="12" cy="12" r="10" />
<line x1="2" x2="22" y1="12" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
);
}
export function IconBan({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="12" cy="12" r="10" />
<line x1="4.93" x2="19.07" y1="4.93" y2="19.07" />
</svg>
);
}
export function IconArchive({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="20" height="5" x="2" y="3" rx="1" />
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4" />
</svg>
);
}
export function IconShieldCheck({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
<path d="m9 12 2 2 4-4" />
</svg>
);
}
export function IconRefresh({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 16h5v5" />
</svg>
);
}
export function IconChevronDown({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
export function IconFolder({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
</svg>
);
}
export function IconTrash({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
);
}
export function IconPencil({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
);
}
export function IconLock({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
);
}
export function IconHeart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
);
}
/** Filled by default — it marks a state (favourite), not an action. */
export function IconStar({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14l-5-4.87 6.91-1.01Z" />
</svg>
);
}
export function IconImage({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);
}
export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
);
}
export function IconGripVertical({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<circle cx="9" cy="12" r="1" fill="currentColor" />
<circle cx="9" cy="5" r="1" fill="currentColor" />
<circle cx="9" cy="19" r="1" fill="currentColor" />
<circle cx="15" cy="12" r="1" fill="currentColor" />
<circle cx="15" cy="5" r="1" fill="currentColor" />
<circle cx="15" cy="19" r="1" fill="currentColor" />
</svg>
);
}
export function IconX({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
export function IconPlus({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M5 12h14M12 5v14" />
</svg>
);
}
export function IconHome({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}
export function IconCopy({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
);
}
export function IconQuote({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2H4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h3c0 4-2 6-4 6" />
<path d="M15 21c3 0 7-1 7-8V5c0-1.25-.75-2-2-2h-4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h3c0 4-2 6-4 6" />
</svg>
);
}
export function IconChevronUp({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m18 15-6-6-6 6" />
</svg>
);
}
export function IconArrowLeftRight({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="m16 3 4 4-4 4" />
<path d="M20 7H4" />
<path d="m8 21-4-4 4-4" />
<path d="M4 17h16" />
</svg>
);
}
export function IconCalendar({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M8 2v4M16 2v4M3 10h18" />
</svg>
);
}
export function IconArrowDown({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12 5v14M19 12l-7 7-7-7" />
</svg>
);
}
export function IconArrowUp({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
);
}
/** An "A→Z" marker for alphabetical / filename ordering. */
export function IconSortAlpha({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M4 10 7 3l3 7M4.9 8.2h4.2" />
<path d="M14 3h6l-6 7h6" />
<path d="M17 14v7M14 18l3 3 3-3" />
</svg>
);
}
export function IconCheck({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
/** Open book — journal entries and photo essays. */
export function IconBook({
size = 16,
className = 'svg-icon',
strokeWidth = 2,
...props
}: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...props}
>
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</svg>
);
}
export function IconEye({
size = 16,
className = 'svg-icon',
strokeWidth = 2,
...props
}: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...props}
>
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
/** Crossed-out eye — an unlisted page, hidden from the header menu. */
export function IconEyeOff({
size = 16,
className = 'svg-icon',
strokeWidth = 2,
...props
}: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...props}
>
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
<line x1="2" x2="22" y1="2" y2="22" />
</svg>
);
}
/** Clock — reading time. */
export function IconClock({
size = 16,
className = 'svg-icon',
strokeWidth = 2,
...props
}: IconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...props}
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
);
}

View File

@@ -143,6 +143,11 @@
position: fixed;
bottom: 24px;
right: 24px;
/* Icon and label sit on one line — the buttons carried an emoji before, which
needed no alignment of its own. */
display: inline-flex;
align-items: center;
gap: 6px;
z-index: 1001;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
@@ -285,7 +290,9 @@
letter-spacing: 0.1em;
text-transform: uppercase;
color: #ffffff;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9), 0 0 10px rgba(0, 0, 0, 0.7);
text-shadow:
0 1px 4px rgba(0, 0, 0, 0.9),
0 0 10px rgba(0, 0, 0, 0.7);
padding: 12px 16px;
user-select: none;
}
@@ -306,7 +313,6 @@
transform: translate(-50%, -50%);
}
@media (hover: none) {
.close,
.nav,

View File

@@ -18,6 +18,7 @@ import { useExif } from '@/hooks/useExif';
import { useSwipe } from '@/hooks/useSwipe';
import styles from './Lightbox.module.css';
import { useProofing } from './ProofingContext';
import { IconHeart } from './Icons';
export interface LightboxWatermark {
enabled?: boolean;
@@ -299,7 +300,8 @@ export function Lightbox({
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
>
{isFav ? '❤️ Saved' : '🤍 Favorite'}
<IconHeart size={14} fill={isFav ? 'currentColor' : 'none'} aria-hidden="true" />
{isFav ? 'Saved' : 'Favorite'}
</button>
)}

View File

@@ -2,6 +2,7 @@
import React, { useState } from 'react';
import { useProofing } from './ProofingContext';
import { IconCheck, IconCopy, IconLink } from './Icons';
export function ProofingModal() {
const proofing = useProofing();
@@ -125,7 +126,15 @@ export function ProofingModal() {
cursor: 'pointer',
}}
>
{copiedState === 'link' ? '✓ Link Copied!' : '🔗 Copy Shareable Link'}
{copiedState === 'link' ? (
<>
<IconCheck size={15} aria-hidden="true" /> Link Copied!
</>
) : (
<>
<IconLink size={15} aria-hidden="true" /> Copy Shareable Link
</>
)}
</button>
<button
@@ -145,7 +154,15 @@ export function ProofingModal() {
cursor: 'pointer',
}}
>
{copiedState === 'list' ? '✓ List Copied!' : '📋 Copy Text List (#1, #2...)'}
{copiedState === 'list' ? (
<>
<IconCheck size={15} aria-hidden="true" /> List Copied!
</>
) : (
<>
<IconCopy size={15} aria-hidden="true" /> Copy Text List (#1, #2...)
</>
)}
</button>
{allowMailto && (

View File

@@ -5,20 +5,27 @@ author: "Portfolio Photographer"
date: "2026-08-05"
---
Copy this file to `content/journal/<your-slug>.md` to start an entry by hand, or
create one under Journal in the admin panel — the studio writes this same
format. Delete this paragraph and the next one when you are done.
Photos are referenced by their Immich asset id, and those ids are specific to
your server, so this template ships without any. Add them in the studio via
Add Block → Photo, where the picker fills the id in for you. Written by hand it
is `![<asset-id>:fullbleed](Caption)` for a single photo — `:wide` or no suffix
give the narrower widths — and `![<asset-id>, <asset-id>](Caption)` for a
side-by-side pair.
# Into the Wilderness
The journey began at dawn along the F-roads winding deep into the interior. As civil infrastructure faded behind us, the landscape shifted dramatically into vast fields of black volcanic ash framed by vivid neon-green moss.
> Silence in the highlands isn't just the absence of sound — it is a physical weight that fills every horizon.
![1:fullbleed](Crossing the icy glacial melt rivers of Landmannalaugar at early morning)
## Landmannalaugar & Beyond
Walking through the rhyolite mountains, the earth fumes with geothermal vents. The color spectrum ranges from deep ochre and sulfur yellow to dark basalt gray.
![2, 3](Side-by-side: Thermal vent details and volcanic obsidian formations)
### Reflection & Light
As light broke through the storm clouds late in the afternoon, the contrast between the dark terrain and glowing peaks created a surreal atmosphere that felt entirely unearthly.

View File

@@ -367,6 +367,7 @@ export function getConfig(): AppConfig {
scrollToTop: false,
analytics: true,
proofing: { enabled: false, allowMailto: false },
aboutEnabled: true,
albumOverrides: {},
albumDescriptions: {},
albumPasswords: {},
@@ -468,6 +469,7 @@ export function getConfig(): AppConfig {
},
protection: settings.protection,
watermark: settings.watermark,
aboutEnabled: settings.about?.enabled !== false,
albumOverrides,
albumDescriptions,
albumPasswords,

View File

@@ -128,6 +128,7 @@ export interface AppConfig {
enabled: boolean;
allowMailto: boolean;
};
aboutEnabled: boolean;
albumOverrides: Record<string, string>;
albumDescriptions: Record<string, string>;
albumPasswords: Record<string, string>;
@@ -263,6 +264,7 @@ export interface SettingsYaml {
opacity?: number;
position?: 'bottom-right' | 'bottom-left' | 'center';
};
about?: { enabled?: boolean };
}
export function slugify(name: string): string {