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

@@ -1,122 +1,122 @@
/* ── About Page — Bauhaus / Leica Minimalist ──────── */
.about {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: calc(100vh - var(--header-height));
display: grid;
grid-template-columns: 1fr 1fr;
min-height: calc(100vh - var(--header-height));
}
/* ── Portrait Column ─────────────────────────────── */
.about__portrait-col {
position: relative;
overflow: hidden;
background: var(--bg-secondary);
position: relative;
overflow: hidden;
background: var(--bg-secondary);
}
.about__portrait {
width: 100%;
height: 100%;
object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
}
.about__portrait--placeholder {
width: 100%;
height: 100%;
background: var(--bg-secondary);
width: 100%;
height: 100%;
background: var(--bg-secondary);
}
/* ── Text Column ─────────────────────────────────── */
.about__text-col {
display: flex;
flex-direction: column;
justify-content: center;
padding: 80px 64px;
max-width: 560px;
display: flex;
flex-direction: column;
justify-content: center;
padding: 80px 64px;
max-width: 560px;
}
.about__name {
font-family: var(--font-sans);
font-size: 2.4rem;
font-weight: 300;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
font-family: var(--font-sans);
font-size: 2.4rem;
font-weight: 300;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 8px;
}
.about__location {
font-family: var(--font-sans);
font-size: 0.75rem;
font-weight: 400;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 0.75rem;
font-weight: 400;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-muted);
}
/* ── Thin Rule Dividers ──────────────────────────── */
.about__rule {
border: none;
border-top: 1px solid var(--border-subtle);
margin: 40px 0;
width: 60px;
border: none;
border-top: 1px solid var(--border-subtle);
margin: 40px 0;
width: 60px;
}
/* ── Bio ─────────────────────────────────────────── */
.about__bio p {
font-family: var(--font-sans);
font-size: 0.95rem;
font-weight: 300;
line-height: 1.8;
color: var(--text-secondary);
margin-bottom: 8px;
font-family: var(--font-sans);
font-size: 0.95rem;
font-weight: 300;
line-height: 1.8;
color: var(--text-secondary);
margin-bottom: 8px;
}
.about__bio p:last-child {
margin-bottom: 0;
margin-bottom: 0;
}
/* ── Gear Section ────────────────────────────────── */
.about__section-label {
font-family: var(--font-sans);
font-size: 0.65rem;
font-weight: 500;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 20px;
font-family: var(--font-sans);
font-size: 0.65rem;
font-weight: 500;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 20px;
}
.about__gear {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.about__gear li {
font-family: var(--font-sans);
font-size: 0.85rem;
font-weight: 300;
color: var(--text-secondary);
letter-spacing: 0.04em;
font-family: var(--font-sans);
font-size: 0.85rem;
font-weight: 300;
color: var(--text-secondary);
letter-spacing: 0.04em;
}
/* ── Responsive ──────────────────────────────────── */
@media (max-width: 768px) {
.about {
grid-template-columns: 1fr;
}
.about {
grid-template-columns: 1fr;
}
.about__portrait-col {
max-height: 50vh;
}
.about__portrait-col {
max-height: 50vh;
}
.about__text-col {
padding: 48px 24px 64px;
}
.about__text-col {
padding: 48px 24px 64px;
}
.about__name {
font-size: 1.8rem;
}
}
.about__name {
font-size: 1.8rem;
}
}

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>
);
}