feat: visual polish & UX — fade-in, theme toggle, scroll-to-top, footer, cursor, badge

- Add FadeIn component (IntersectionObserver scroll-reveal + load animation)
- Add dark/light theme toggle (useSyncExternalStore + localStorage)
- Add scroll-to-top button (appears after 300px scroll)
- Add configurable footer with social links (gallery.yaml)
- Add crosshair cursor on photo grid items
- Add 'X photos' count badge on subpage cover images
- Add light theme color palette in tokens.css
This commit is contained in:
ralksta
2026-02-14 11:47:26 +01:00
parent e26f091595
commit 52fa16c1c8
57 changed files with 4790 additions and 1984 deletions

View File

@@ -12,77 +12,75 @@ import { immich } from '@/lib/immich';
import './about.css';
interface AboutFrontmatter {
portrait?: string;
name?: string;
location?: string;
gear?: string[];
portrait?: string;
name?: string;
location?: string;
gear?: string[];
}
function getAboutContent() {
const raw = readFileSync(join(process.cwd(), 'content', 'about.md'), 'utf-8');
const { data, content } = matter(raw);
return { meta: data as AboutFrontmatter, body: content.trim() };
const raw = readFileSync(join(process.cwd(), 'content', 'about.md'), 'utf-8');
const { data, content } = matter(raw);
return { meta: data as AboutFrontmatter, body: content.trim() };
}
export default async function AboutPage() {
const { meta, body } = getAboutContent();
const { meta, body } = getAboutContent();
// Fetch ThumbHash for portrait placeholder
const portraitAsset = meta.portrait
? await immich.getAssetInfo(meta.portrait)
: null;
const portraitPlaceholder = portraitAsset ? assetPlaceholder(portraitAsset) : null;
// Fetch ThumbHash for portrait placeholder
const portraitAsset = meta.portrait ? await immich.getAssetInfo(meta.portrait) : null;
const portraitPlaceholder = portraitAsset ? assetPlaceholder(portraitAsset) : null;
return (
<div className="about">
{/* ── Portrait ─────────────────────────────────── */}
<div className="about__portrait-col">
{meta.portrait ? (
<Image
src={imageUrl(meta.portrait, 'preview')}
alt={meta.name || 'Portrait'}
className="about__portrait"
fill
sizes="(max-width: 768px) 100vw, 40vw"
{...(portraitPlaceholder
? { placeholder: 'blur' as const, blurDataURL: portraitPlaceholder.blurDataURL }
: {})}
/>
) : (
<div className="about__portrait about__portrait--placeholder" />
)}
</div>
return (
<div className="about">
{/* ── Portrait ─────────────────────────────────── */}
<div className="about__portrait-col">
{meta.portrait ? (
<Image
src={imageUrl(meta.portrait, 'preview')}
alt={meta.name || 'Portrait'}
className="about__portrait"
fill
sizes="(max-width: 768px) 100vw, 40vw"
{...(portraitPlaceholder
? { placeholder: 'blur' as const, blurDataURL: portraitPlaceholder.blurDataURL }
: {})}
/>
) : (
<div className="about__portrait about__portrait--placeholder" />
)}
</div>
{/* ── Text ─────────────────────────────────────── */}
<div className="about__text-col">
{meta.name && <h1 className="about__name">{meta.name}</h1>}
{meta.location && (
<p className="about__location">{meta.location}</p>
)}
{/* ── Text ─────────────────────────────────────── */}
<div className="about__text-col">
{meta.name && <h1 className="about__name">{meta.name}</h1>}
{meta.location && <p className="about__location">{meta.location}</p>}
<hr className="about__rule" />
<hr className="about__rule" />
{body && (
<div className="about__bio">
{body.split('\n').filter(Boolean).map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
)}
{body && (
<div className="about__bio">
{body
.split('\n')
.filter(Boolean)
.map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
)}
{meta.gear && meta.gear.length > 0 && (
<>
<hr className="about__rule" />
<h2 className="about__section-label">Gear</h2>
<ul className="about__gear">
{meta.gear.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</>
)}
</div>
</div>
);
{meta.gear && meta.gear.length > 0 && (
<>
<hr className="about__rule" />
<h2 className="about__section-label">Gear</h2>
<ul className="about__gear">
{meta.gear.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</>
)}
</div>
</div>
);
}