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:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -62,10 +62,18 @@ docker-compose.override.yml
|
|||||||
content/settings.yaml
|
content/settings.yaml
|
||||||
content/gallery.yaml
|
content/gallery.yaml
|
||||||
content/about.md
|
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
|
# 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
|
# Datei ab, und die Backups von gallery.yaml/settings.yaml wuerden genau den
|
||||||
# Inhalt ins Repo tragen, den die drei Zeilen darueber heraushalten sollen.
|
# Inhalt ins Repo tragen, den die drei Zeilen darueber heraushalten sollen.
|
||||||
content/.backups/
|
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/analytics.json
|
||||||
content/*.screenshot-bak
|
content/*.screenshot-bak
|
||||||
# Credentials written by the /install wizard (never committed)
|
# Credentials written by the /install wizard (never committed)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import Image from 'next/image';
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
||||||
import { immich } from '@/lib/immich';
|
import { immich } from '@/lib/immich';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
import './about.css';
|
import './about.css';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -68,6 +70,9 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function AboutPage() {
|
export default async function AboutPage() {
|
||||||
|
const config = getConfig();
|
||||||
|
if (!config.aboutEnabled) notFound();
|
||||||
|
|
||||||
const { meta, body } = await getAboutContent();
|
const { meta, body } = await getAboutContent();
|
||||||
|
|
||||||
// Fetch ThumbHash for portrait placeholder
|
// Fetch ThumbHash for portrait placeholder
|
||||||
|
|||||||
@@ -1,493 +1,7 @@
|
|||||||
'use client';
|
/**
|
||||||
|
* The icon set moved to components/Icons.tsx so the public frontend can use it
|
||||||
import React from 'react';
|
* 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`
|
||||||
export interface IconProps extends React.SVGProps<SVGSVGElement> {
|
* imports keep working.
|
||||||
size?: number;
|
*/
|
||||||
className?: string;
|
export * from '@/components/Icons';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ interface Settings {
|
|||||||
opacity?: number;
|
opacity?: number;
|
||||||
position?: 'bottom-right' | 'bottom-left' | 'center';
|
position?: 'bottom-right' | 'bottom-left' | 'center';
|
||||||
};
|
};
|
||||||
|
about?: { enabled?: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||||
@@ -192,6 +193,7 @@ const SETTINGS_SECTIONS = [
|
|||||||
{ id: 'legal', label: 'Legal' },
|
{ id: 'legal', label: 'Legal' },
|
||||||
{ id: 'seo', label: 'SEO' },
|
{ id: 'seo', label: 'SEO' },
|
||||||
{ id: 'security', label: 'Security & Protection' },
|
{ id: 'security', label: 'Security & Protection' },
|
||||||
|
{ id: 'about', label: 'About' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SettingsEditorProps {
|
interface SettingsEditorProps {
|
||||||
@@ -215,6 +217,20 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
|||||||
const [faviconUploading, setFaviconUploading] = useState(false);
|
const [faviconUploading, setFaviconUploading] = useState(false);
|
||||||
const [faviconMessage, setFaviconMessage] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -236,6 +252,12 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
|||||||
}
|
}
|
||||||
}, [settings.theme?.preset, settings.theme?.accent]);
|
}, [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') => {
|
const toggleMode = (mode: 'dark' | 'light') => {
|
||||||
setCurrentMode(mode);
|
setCurrentMode(mode);
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
@@ -246,11 +268,25 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
|||||||
|
|
||||||
// ── Keyboard shortcut: ⌘+S / Ctrl+S ─────────────────────────
|
// ── Keyboard shortcut: ⌘+S / Ctrl+S ─────────────────────────
|
||||||
const handleSaveRef = useCallback(() => {
|
const handleSaveRef = useCallback(() => {
|
||||||
|
if (activeSection === 'about') {
|
||||||
|
if (aboutDirty && !aboutSaving) saveAboutContent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (dirty && !saving) {
|
if (dirty && !saving) {
|
||||||
handleSave();
|
handleSave();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dirty, saving, settings]);
|
}, [
|
||||||
|
dirty,
|
||||||
|
saving,
|
||||||
|
settings,
|
||||||
|
activeSection,
|
||||||
|
aboutDirty,
|
||||||
|
aboutSaving,
|
||||||
|
aboutMeta,
|
||||||
|
aboutBody,
|
||||||
|
aboutGearText,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-loading">
|
<div className="admin-loading">
|
||||||
@@ -357,13 +455,24 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-editor">
|
<div className="settings-editor">
|
||||||
<SaveBar
|
{/* The About panel edits about.md, not settings.yaml, so it drives the bar itself. */}
|
||||||
dirty={dirty}
|
{activeSection === 'about' ? (
|
||||||
saving={saving}
|
<SaveBar
|
||||||
saveMessage={saveMessage}
|
dirty={aboutDirty}
|
||||||
onSave={handleSave}
|
saving={aboutSaving}
|
||||||
label="Save Settings"
|
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">
|
<p className="settings-live-sync-note">
|
||||||
<Icons.IconRefresh size={13} /> Live Sync (No Docker restart required)
|
<Icons.IconRefresh size={13} /> Live Sync (No Docker restart required)
|
||||||
@@ -513,6 +622,22 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
|||||||
<span className="switch-slider" />
|
<span className="switch-slider" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div className="admin-field favicon-field">
|
<div className="admin-field favicon-field">
|
||||||
@@ -1409,6 +1534,77 @@ export default function SettingsEditor({ section }: SettingsEditorProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -135,6 +135,20 @@ const ROUTES: {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
args: () => [],
|
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',
|
name: 'GET /api/admin/journal',
|
||||||
path: 'journal',
|
path: 'journal',
|
||||||
|
|||||||
113
app/api/admin/about/route.ts
Normal file
113
app/api/admin/about/route.ts
Normal 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.' });
|
||||||
|
}
|
||||||
@@ -51,7 +51,10 @@
|
|||||||
border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
|
border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
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 {
|
.journal-card:hover {
|
||||||
@@ -84,7 +87,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
font-size: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.journal-card__content {
|
.journal-card__content {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { isAdminAuthenticated } from '@/lib/admin/auth';
|
|||||||
import { immich } from '@/lib/immich';
|
import { immich } from '@/lib/immich';
|
||||||
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
||||||
import { BackLink } from '@/components/BackLink';
|
import { BackLink } from '@/components/BackLink';
|
||||||
|
import { IconBook } from '@/components/Icons';
|
||||||
import './journal.css';
|
import './journal.css';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -102,7 +103,9 @@ export default async function JournalIndexPage() {
|
|||||||
blurDataURL={entry.blurDataURL}
|
blurDataURL={entry.blurDataURL}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="journal-card__cover-empty">📖</div>
|
<div className="journal-card__cover-empty">
|
||||||
|
<IconBook size={32} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -149,9 +149,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
<SubpageNav />
|
<SubpageNav />
|
||||||
<Link href="/about" className="header__nav-link">
|
{config.aboutEnabled && (
|
||||||
About
|
<Link href="/about" className="header__nav-link">
|
||||||
</Link>
|
About
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{config.map && (
|
{config.map && (
|
||||||
<Link href="/map" className="header__nav-link">
|
<Link href="/map" className="header__nav-link">
|
||||||
Map
|
Map
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { IconRefresh } from './Icons';
|
||||||
|
|
||||||
interface AdminDiagnosticBannerProps {
|
interface AdminDiagnosticBannerProps {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -39,10 +40,8 @@ export function AdminDiagnosticBanner({
|
|||||||
<code style={{ background: '#2c2c2e', padding: '2px 6px', borderRadius: '4px' }}>
|
<code style={{ background: '#2c2c2e', padding: '2px 6px', borderRadius: '4px' }}>
|
||||||
gallery.yaml
|
gallery.yaml
|
||||||
</code>
|
</code>
|
||||||
{configuredAlbumCount > 0
|
{configuredAlbumCount > 0 ? `, and configured with ${configuredAlbumCount} album(s)` : ''},
|
||||||
? `, and configured with ${configuredAlbumCount} album(s)`
|
but Immich returned no accessible albums or assets.
|
||||||
: ''}
|
|
||||||
, but Immich returned no accessible albums or assets.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{reason && (
|
{reason && (
|
||||||
@@ -64,17 +63,27 @@ export function AdminDiagnosticBanner({
|
|||||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#f2f2f7' }}>
|
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#f2f2f7' }}>
|
||||||
Possible Causes & Checklist:
|
Possible Causes & Checklist:
|
||||||
</h4>
|
</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>
|
<li>
|
||||||
<strong>Album Shared Status in Immich:</strong> Ensure the album in Immich is marked as{' '}
|
<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>
|
||||||
<li>
|
<li>
|
||||||
<strong>Cache Lag:</strong> If you just added the album or changed permissions, click{' '}
|
<strong>Cache Lag:</strong> If you just added the album or changed permissions, click{' '}
|
||||||
<em>Force Reload Fresh</em> below.
|
<em>Force Reload Fresh</em> below.
|
||||||
</li>
|
</li>
|
||||||
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +104,7 @@ export function AdminDiagnosticBanner({
|
|||||||
gap: '6px',
|
gap: '6px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
🔄 Force Reload Fresh (?fresh=1)
|
<IconRefresh size={14} aria-hidden="true" /> Force Reload Fresh (?fresh=1)
|
||||||
</a>
|
</a>
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
href="/admin"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react';
|
import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react';
|
||||||
|
import { IconPalette, IconX } from './Icons';
|
||||||
|
|
||||||
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
|
||||||
const FRAMES = ['none', 'passepartout', 'shadow'];
|
const FRAMES = ['none', 'passepartout', 'shadow'];
|
||||||
@@ -226,7 +227,11 @@ exifOnHover: ${exifOnHover}`;
|
|||||||
boxShadow: '0 4px 24px rgba(0,0,0,0.4)',
|
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>
|
</button>
|
||||||
|
|
||||||
{/* ── Panel — anchored bottom-left ────────── */}
|
{/* ── Panel — anchored bottom-left ────────── */}
|
||||||
@@ -260,7 +265,7 @@ exifOnHover: ${exifOnHover}`;
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: 700, fontSize: 13, letterSpacing: '0.02em' }}>
|
<span style={{ fontWeight: 700, fontSize: 13, letterSpacing: '0.02em' }}>
|
||||||
🎨 Theme Builder
|
<IconPalette size={13} aria-hidden="true" /> Theme Builder
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
510
components/Icons.tsx
Normal file
510
components/Icons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -143,6 +143,11 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
right: 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;
|
z-index: 1001;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
@@ -285,7 +290,9 @@
|
|||||||
letter-spacing: 0.1em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #ffffff;
|
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;
|
padding: 12px 16px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
@@ -306,7 +313,6 @@
|
|||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.close,
|
.close,
|
||||||
.nav,
|
.nav,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useExif } from '@/hooks/useExif';
|
|||||||
import { useSwipe } from '@/hooks/useSwipe';
|
import { useSwipe } from '@/hooks/useSwipe';
|
||||||
import styles from './Lightbox.module.css';
|
import styles from './Lightbox.module.css';
|
||||||
import { useProofing } from './ProofingContext';
|
import { useProofing } from './ProofingContext';
|
||||||
|
import { IconHeart } from './Icons';
|
||||||
|
|
||||||
export interface LightboxWatermark {
|
export interface LightboxWatermark {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -299,7 +300,8 @@ export function Lightbox({
|
|||||||
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
title={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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useProofing } from './ProofingContext';
|
import { useProofing } from './ProofingContext';
|
||||||
|
import { IconCheck, IconCopy, IconLink } from './Icons';
|
||||||
|
|
||||||
export function ProofingModal() {
|
export function ProofingModal() {
|
||||||
const proofing = useProofing();
|
const proofing = useProofing();
|
||||||
@@ -125,7 +126,15 @@ export function ProofingModal() {
|
|||||||
cursor: 'pointer',
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -145,7 +154,15 @@ export function ProofingModal() {
|
|||||||
cursor: 'pointer',
|
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>
|
</button>
|
||||||
|
|
||||||
{allowMailto && (
|
{allowMailto && (
|
||||||
|
|||||||
@@ -5,20 +5,27 @@ author: "Portfolio Photographer"
|
|||||||
date: "2026-08-05"
|
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 `` for a single photo — `:wide` or no suffix
|
||||||
|
give the narrower widths — and `` for a
|
||||||
|
side-by-side pair.
|
||||||
|
|
||||||
# Into the Wilderness
|
# 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.
|
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.
|
> Silence in the highlands isn't just the absence of sound — it is a physical weight that fills every horizon.
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Landmannalaugar & Beyond
|
## 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.
|
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.
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Reflection & Light
|
### 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.
|
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.
|
||||||
@@ -367,6 +367,7 @@ export function getConfig(): AppConfig {
|
|||||||
scrollToTop: false,
|
scrollToTop: false,
|
||||||
analytics: true,
|
analytics: true,
|
||||||
proofing: { enabled: false, allowMailto: false },
|
proofing: { enabled: false, allowMailto: false },
|
||||||
|
aboutEnabled: true,
|
||||||
albumOverrides: {},
|
albumOverrides: {},
|
||||||
albumDescriptions: {},
|
albumDescriptions: {},
|
||||||
albumPasswords: {},
|
albumPasswords: {},
|
||||||
@@ -468,6 +469,7 @@ export function getConfig(): AppConfig {
|
|||||||
},
|
},
|
||||||
protection: settings.protection,
|
protection: settings.protection,
|
||||||
watermark: settings.watermark,
|
watermark: settings.watermark,
|
||||||
|
aboutEnabled: settings.about?.enabled !== false,
|
||||||
albumOverrides,
|
albumOverrides,
|
||||||
albumDescriptions,
|
albumDescriptions,
|
||||||
albumPasswords,
|
albumPasswords,
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export interface AppConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
allowMailto: boolean;
|
allowMailto: boolean;
|
||||||
};
|
};
|
||||||
|
aboutEnabled: boolean;
|
||||||
albumOverrides: Record<string, string>;
|
albumOverrides: Record<string, string>;
|
||||||
albumDescriptions: Record<string, string>;
|
albumDescriptions: Record<string, string>;
|
||||||
albumPasswords: Record<string, string>;
|
albumPasswords: Record<string, string>;
|
||||||
@@ -263,6 +264,7 @@ export interface SettingsYaml {
|
|||||||
opacity?: number;
|
opacity?: number;
|
||||||
position?: 'bottom-right' | 'bottom-left' | 'center';
|
position?: 'bottom-right' | 'bottom-left' | 'center';
|
||||||
};
|
};
|
||||||
|
about?: { enabled?: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function slugify(name: string): string {
|
export function slugify(name: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user