First concept

This commit is contained in:
will
2026-04-02 19:13:46 +01:00
commit 1da5da43e1
9785 changed files with 2077949 additions and 0 deletions

43
components/About.tsx Normal file
View File

@@ -0,0 +1,43 @@
import { SITE } from '@/lib/config';
export default function About() {
return (
<section id="about" className="py-24 px-6" aria-labelledby="about-heading">
<div className="max-w-5xl mx-auto grid md:grid-cols-[auto_1fr] gap-16 items-start">
{/* Avatar */}
<div className="flex-shrink-0">
<div className="glass w-40 h-40 md:w-48 md:h-48 flex items-center justify-center rounded-2xl overflow-hidden">
<span className="text-6xl font-black text-white/10 tracking-tighter select-none">WM</span>
</div>
</div>
{/* Body */}
<div className="flex flex-col gap-6">
<div>
<span className="label">About</span>
<h2 id="about-heading"
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-white leading-tight">
Building software<br className="hidden sm:block" /> that matters.
</h2>
</div>
<p className="text-white/50 leading-relaxed max-w-[52ch]">{SITE.about1}</p>
<p className="text-white/50 leading-relaxed max-w-[52ch]">{SITE.about2}</p>
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-3">Stack</p>
<div className="flex flex-wrap gap-2">
{SITE.skills.map(s => (
<span key={s}
className="px-3 py-1 text-xs font-medium text-white/40 bg-white/[0.04]
border border-white/[0.07] rounded-full hover:text-white/60 hover:border-white/[0.12]
transition-colors duration-150 cursor-default">
{s}
</span>
))}
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,382 @@
'use client';
import { useEffect, useState, useMemo } from 'react';
import { getRepos, getHeatmap, getRecentCommits, calcStats } from '@/lib/gitea';
import type { GiteaRepo, GiteaCommit, HeatmapEntry } from '@/lib/types';
// ── Calendar constants ──
const CELL = 12, GAP = 3, STEP = CELL + GAP;
const DAY_LABEL_W = 26;
const WEEKS = 52;
// Luminosity levels (white-on-dark glow effect)
const LEVELS = [
'rgba(255,255,255,0.04)', // 0
'rgba(255,255,255,0.13)', // 1-2
'rgba(255,255,255,0.28)', // 3-5
'rgba(255,255,255,0.50)', // 6-9
'rgba(255,255,255,0.80)', // 10+
];
function getLevel(n: number) {
if (n === 0) return 0;
if (n <= 2) return 1;
if (n <= 5) return 2;
if (n <= 9) return 3;
return 4;
}
function timeAgo(s: string) {
const d = Date.now() - new Date(s).getTime();
const m = Math.floor(d / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
const days = Math.floor(h / 24);
if (days < 30) return `${days}d`;
return `${Math.floor(days / 30)}mo`;
}
// ── Stat card ──
function Stat({ label, value, sub }: { label: string; value: string | number; sub: string }) {
return (
<div className="glass rounded-2xl px-5 py-4 flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-[.1em] text-white/25">{label}</span>
<span className="text-[clamp(1.4rem,3vw,2rem)] font-black text-white tabular-nums leading-none">{value}</span>
<span className="text-xs text-white/30">{sub}</span>
</div>
);
}
// ── Contribution Calendar ──
function ContributionCalendar({ data }: { data: HeatmapEntry[] }) {
const [tooltip, setTooltip] = useState<{ x: number; y: number; date: string; count: number } | null>(null);
const dateMap = useMemo(() => {
const m = new Map<string, number>();
data.forEach(e => {
const k = new Date(e.timestamp * 1000).toISOString().split('T')[0];
m.set(k, (m.get(k) || 0) + e.contributions);
});
return m;
}, [data]);
// Build 52-week grid starting from Sunday 51 weeks ago
const { weeks, monthLabels } = useMemo(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const startSunday = new Date(today);
startSunday.setDate(today.getDate() - today.getDay() - 51 * 7);
const weeks: { date: Date; key: string; count: number }[][] = [];
const monthLabels: { label: string; x: number }[] = [];
let lastMonth = -1;
const cur = new Date(startSunday);
for (let w = 0; w < WEEKS; w++) {
const week: { date: Date; key: string; count: number }[] = [];
for (let d = 0; d < 7; d++) {
const k = cur.toISOString().split('T')[0];
week.push({ date: new Date(cur), key: k, count: dateMap.get(k) || 0 });
cur.setDate(cur.getDate() + 1);
}
const mo = week[0].date.getMonth();
if (mo !== lastMonth) {
monthLabels.push({
label: week[0].date.toLocaleDateString('en-US', { month: 'short' }),
x: w * STEP + DAY_LABEL_W,
});
lastMonth = mo;
}
weeks.push(week);
}
return { weeks, monthLabels };
}, [dateMap]);
const svgW = DAY_LABEL_W + WEEKS * STEP;
const svgH = 7 * STEP + 20;
return (
<div className="relative">
<div className="flex items-center justify-between mb-4">
<span className="text-[11px] font-semibold uppercase tracking-[.1em] text-white/30">
Contribution Calendar · last 12 months
</span>
<div className="flex items-center gap-1.5 text-[10px] text-white/20">
<span>Less</span>
{LEVELS.map((c, i) => (
<span key={i} className="rounded-sm inline-block w-2.5 h-2.5 flex-shrink-0"
style={{ background: c, border: '1px solid rgba(255,255,255,0.06)' }} aria-hidden="true" />
))}
<span>More</span>
</div>
</div>
<div className="overflow-x-auto pb-1">
<svg width={svgW} height={svgH} aria-label="Contribution calendar heatmap" role="img">
{/* Month labels */}
{monthLabels.map((m, i) => (
<text key={i} x={m.x} y={10} fontSize={9} fill="rgba(255,255,255,0.25)"
fontFamily="var(--font-geist-mono, monospace)">{m.label}</text>
))}
{/* Day labels */}
{['', 'Mon', '', 'Wed', '', 'Fri', ''].map((d, i) => d ? (
<text key={i} x={DAY_LABEL_W - 4} y={17 + i * STEP + CELL}
fontSize={9} fill="rgba(255,255,255,0.2)" textAnchor="end"
fontFamily="var(--font-geist-mono, monospace)">{d}</text>
) : null)}
{/* Cells */}
<g transform={`translate(${DAY_LABEL_W}, 14)`}>
{weeks.map((week, wi) =>
week.map((day, di) => (
<rect
key={day.key}
x={wi * STEP} y={di * STEP}
width={CELL} height={CELL} rx={2.5}
fill={LEVELS[getLevel(day.count)]}
onMouseEnter={e => setTooltip({
x: e.clientX, y: e.clientY,
date: day.date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }),
count: day.count,
})}
onMouseLeave={() => setTooltip(null)}
className="transition-opacity duration-100 hover:opacity-70 cursor-default"
>
<title>{`${day.count} contribution${day.count !== 1 ? 's' : ''} on ${day.date.toLocaleDateString()}`}</title>
</rect>
))
)}
</g>
</svg>
</div>
{/* Tooltip */}
{tooltip && (
<div
className="fixed z-50 pointer-events-none px-2.5 py-1.5 rounded-lg text-xs font-medium text-white
bg-[#1a1a1a] border border-white/[0.1] shadow-xl whitespace-nowrap"
style={{ left: tooltip.x + 12, top: tooltip.y - 36 }}>
<span className="font-bold">{tooltip.count}</span>
{' contribution'}{tooltip.count !== 1 ? 's' : ''}
<span className="text-white/40 ml-1.5">{tooltip.date}</span>
</div>
)}
</div>
);
}
// ── Monthly bar chart ──
function MonthlyChart({ data }: { data: HeatmapEntry[] }) {
const months = useMemo(() => {
return Array.from({ length: 12 }, (_, i) => {
const d = new Date();
d.setMonth(d.getMonth() - 11 + i);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
const label = d.toLocaleDateString('en-US', { month: 'short' });
const count = data
.filter(e => {
const ed = new Date(e.timestamp * 1000);
return `${ed.getFullYear()}-${String(ed.getMonth() + 1).padStart(2, '0')}` === key;
})
.reduce((s, e) => s + e.contributions, 0);
return { label, count };
});
}, [data]);
const maxVal = Math.max(...months.map(m => m.count), 1);
const barW = 100 / 12;
return (
<div>
<span className="text-[11px] font-semibold uppercase tracking-[.1em] text-white/30 block mb-5">
Monthly activity
</span>
<div className="relative h-24 flex items-end gap-1.5" aria-label="Monthly commit activity bar chart" role="img">
{months.map((m, i) => {
const pct = (m.count / maxVal) * 100;
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1.5 group">
<div className="w-full relative" style={{ height: '80px' }}>
<div
className="absolute bottom-0 left-0 right-0 rounded-sm transition-all duration-300 ease-out"
style={{
height: `${Math.max(pct, 2)}%`,
background: `rgba(255,255,255,${0.06 + (pct / 100) * 0.5})`,
}}
title={`${m.label}: ${m.count} contributions`}
/>
</div>
<span className="text-[8px] text-white/20 font-mono">{m.label}</span>
</div>
);
})}
</div>
</div>
);
}
// ── Recent commits ──
function RecentCommits({ commits }: { commits: GiteaCommit[] }) {
if (!commits.length) return (
<p className="text-sm text-white/20 py-4">No recent commits.</p>
);
return (
<div>
<span className="text-[11px] font-semibold uppercase tracking-[.1em] text-white/30 block mb-5">
Recent commits
</span>
<ol className="flex flex-col" aria-label="Recent commit list">
{commits.slice(0, 10).map((c, i) => {
const sha = (c.sha || '').slice(0, 7);
const msg = (c.commit?.message || '').split('\n')[0].slice(0, 64);
const date = c.created || c.commit?.author?.date;
const isLast = i === Math.min(9, commits.length - 1);
return (
<li key={c.sha} className={`commit-line relative flex gap-3 pb-4 ${isLast ? '' : ''}`}>
<div className="mt-1.5 flex-shrink-0">
<span className="block w-2 h-2 rounded-full bg-white/20 relative z-10" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2 mb-0.5 flex-wrap">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/25">
{c._repo}
</span>
<a
href={c._repoUrl ? `${c._repoUrl}/commit/${c.sha}` : '#'}
target="_blank" rel="noopener noreferrer"
className="font-mono text-[10px] text-white/20 hover:text-white/50 transition-colors px-1 py-0.5
rounded bg-white/[0.04] border border-white/[0.06]"
aria-label={`View commit ${sha}`}>
{sha}
</a>
<span className="text-[10px] text-white/20 ml-auto">{date ? timeAgo(date) : ''}</span>
</div>
<p className="text-sm text-white/60 truncate leading-snug">{msg}</p>
</div>
</li>
);
})}
</ol>
</div>
);
}
// ── Main section ──
export default function CommitSection() {
const [repos, setRepos] = useState<GiteaRepo[]>([]);
const [heatmap, setHeatmap] = useState<HeatmapEntry[]>([]);
const [commits, setCommits] = useState<GiteaCommit[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const [r, h] = await Promise.all([getRepos(6), getHeatmap()]);
setRepos(r);
setHeatmap(h);
const c = await getRecentCommits(r);
setCommits(c);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Unknown error';
setError(msg);
} finally {
setLoading(false);
}
}
load();
}, []);
const stats = useMemo(() => calcStats(heatmap), [heatmap]);
return (
<section id="activity" className="py-24 px-6" aria-labelledby="activity-heading">
<div className="max-w-5xl mx-auto">
<span className="label">Gitea · Code activity</span>
<h2 id="activity-heading"
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-white mb-10">
Contributions
</h2>
{/* Stats row */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<Stat label="Contributions" value={loading ? '—' : stats.total.toLocaleString()} sub="this year" />
<Stat label="Current streak" value={loading ? '—' : `${stats.currentStreak}d`} sub="days" />
<Stat label="Longest streak" value={loading ? '—' : `${stats.longestStreak}d`} sub="days" />
<Stat label="Active days" value={loading ? '—' : stats.activeDays} sub="this year" />
</div>
{/* Calendar */}
<div className="glass rounded-2xl p-5 sm:p-6 mb-4">
{loading ? (
<div className="flex flex-col gap-3">
<div className="skel h-3 w-48" />
<div className="skel h-[120px] w-full rounded-xl" />
</div>
) : error ? (
<div className="text-center py-6">
<p className="text-white/30 text-sm">Could not load heatmap</p>
<p className="text-white/20 text-xs font-mono mt-1">{error}</p>
</div>
) : (
<ContributionCalendar data={heatmap} />
)}
</div>
{/* Monthly + commits */}
<div className="grid lg:grid-cols-[1fr_360px] gap-4">
<div className="glass rounded-2xl p-5 sm:p-6">
{loading
? <div className="skel h-32 w-full rounded-xl" />
: <MonthlyChart data={heatmap} />
}
</div>
<div className="glass rounded-2xl p-5 sm:p-6 overflow-hidden">
{loading
? (
<div className="flex flex-col gap-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex gap-3">
<div className="skel w-2 h-2 rounded-full mt-1.5 flex-shrink-0" />
<div className="flex-1">
<div className="skel h-2.5 w-1/3 mb-1.5" />
<div className="skel h-3.5 w-5/6" />
</div>
</div>
))}
</div>
)
: <RecentCommits commits={commits} />
}
</div>
</div>
{/* Repo bar chart */}
{!loading && repos.length > 0 && (
<div className="glass rounded-2xl p-5 sm:p-6 mt-4">
<span className="text-[11px] font-semibold uppercase tracking-[.1em] text-white/30 block mb-5">
Repository breakdown
</span>
<div className="flex flex-col gap-2.5">
{repos.slice(0, 6).map(r => {
const maxStars = Math.max(...repos.map(x => x.stars_count), 1);
const pct = (r.stars_count / maxStars) * 100;
return (
<div key={r.id} className="flex items-center gap-3 group">
<a href={r.html_url} target="_blank" rel="noopener noreferrer"
className="text-xs font-mono text-white/40 hover:text-white/70 transition-colors w-36 truncate shrink-0">
{r.name}
</a>
<div className="flex-1 h-1.5 bg-white/[0.04] rounded-full overflow-hidden">
<div className="h-full rounded-full bg-white/25 transition-all duration-500"
style={{ width: `${Math.max(pct, 2)}%` }} />
</div>
<span className="text-[10px] text-white/25 tabular-nums w-6 text-right">{r.stars_count}</span>
</div>
);
})}
</div>
</div>
)}
</div>
</section>
);
}

103
components/Contact.tsx Normal file
View File

@@ -0,0 +1,103 @@
'use client';
import { useState } from 'react';
import { SITE, GITEA_URL, GITEA_USERNAME } from '@/lib/config';
export default function Contact() {
const [status, setStatus] = useState<'idle'|'sent'>('idle');
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const name = fd.get('name') as string;
const email = fd.get('email') as string;
const message = fd.get('message') as string;
if (!name || !email || !message) return;
const sub = encodeURIComponent(`Message from ${name} via williammarch.xyz`);
const body = encodeURIComponent(`From: ${name} <${email}>\n\n${message}`);
window.location.href = `mailto:${SITE.email}?subject=${sub}&body=${body}`;
setStatus('sent');
}
return (
<section id="contact" className="py-24 px-6 bg-[#0a0a0a]" aria-labelledby="contact-heading">
<div className="max-w-5xl mx-auto">
<span className="label">Get in touch</span>
<h2 id="contact-heading"
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-white mb-10">
Let&rsquo;s work together.
</h2>
<div className="grid md:grid-cols-[280px_1fr] gap-6">
{/* Links */}
<div className="flex flex-col gap-3">
<a href={`mailto:${SITE.email}`}
className="glass rounded-xl p-4 flex items-center gap-3 group hover:border-white/[0.14]
hover:-translate-x-[-4px] transition-all duration-150">
<div className="w-8 h-8 rounded-lg bg-white/[0.05] flex items-center justify-center flex-shrink-0" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="1.8">
<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</div>
<div className="min-w-0">
<p className="text-[10px] text-white/25 uppercase tracking-wider">Email</p>
<p className="text-xs font-medium text-white/55 truncate group-hover:text-white/80 transition-colors">{SITE.email}</p>
</div>
</a>
<a href={`${GITEA_URL}/${GITEA_USERNAME}`}
target="_blank" rel="noopener noreferrer"
className="glass rounded-xl p-4 flex items-center gap-3 group hover:border-white/[0.14]
hover:translate-x-1 transition-all duration-150">
<div className="w-8 h-8 rounded-lg bg-white/[0.05] flex items-center justify-center flex-shrink-0" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="1.8">
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.2c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>
<path d="M9 18c-4.51 2-5-2-7-2"/>
</svg>
</div>
<div className="min-w-0">
<p className="text-[10px] text-white/25 uppercase tracking-wider">Gitea</p>
<p className="text-xs font-medium text-white/55 truncate group-hover:text-white/80 transition-colors font-mono">
{GITEA_URL.replace(/^https?:\/\//, '')}/{GITEA_USERNAME}
</p>
</div>
</a>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="glass rounded-xl p-6 flex flex-col gap-4" noValidate>
<div className="grid sm:grid-cols-2 gap-4">
<label className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/30">Name</span>
<input name="name" type="text" required placeholder="Your name" autoComplete="name"
className="bg-white/[0.03] border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
text-white placeholder-white/20 outline-none transition-colors
focus:border-white/[0.2] focus:ring-1 focus:ring-white/[0.1]" />
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/30">Email</span>
<input name="email" type="email" required placeholder="you@example.com" autoComplete="email"
className="bg-white/[0.03] border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
text-white placeholder-white/20 outline-none transition-colors
focus:border-white/[0.2] focus:ring-1 focus:ring-white/[0.1]" />
</label>
</div>
<label className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/30">Message</span>
<textarea name="message" required placeholder="What's on your mind?" rows={4}
className="bg-white/[0.03] border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
text-white placeholder-white/20 outline-none resize-none transition-colors
focus:border-white/[0.2] focus:ring-1 focus:ring-white/[0.1]" />
</label>
<div className="flex items-center gap-4">
<button type="submit" className="btn btn-primary">
{status === 'sent' ? 'Opening email…' : 'Send message'}
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</form>
</div>
</div>
</section>
);
}

17
components/Footer.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { GITEA_URL, GITEA_USERNAME } from '@/lib/config';
export default function Footer() {
return (
<footer className="border-t border-white/[0.05] py-8 px-6">
<div className="max-w-5xl mx-auto flex items-center justify-between flex-wrap gap-4
text-xs text-white/20 font-mono">
<span>© {new Date().getFullYear()} William March</span>
<a href={`${GITEA_URL}/${GITEA_USERNAME}`} target="_blank" rel="noopener noreferrer"
className="hover:text-white/40 transition-colors">
{GITEA_URL.replace(/^https?:\/\//, '')}/{GITEA_USERNAME}
</a>
<span>williammarch.xyz</span>
</div>
</footer>
);
}

56
components/Hero.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { SITE } from '@/lib/config';
export default function Hero() {
return (
<section className="relative min-h-[100dvh] flex items-center noise overflow-hidden" aria-labelledby="hero-name">
{/* Dot grid */}
<div className="absolute inset-0 dot-grid opacity-[0.35] pointer-events-none" aria-hidden="true" />
{/* Radial vignette */}
<div className="absolute inset-0 pointer-events-none"
style={{ background: 'radial-gradient(ellipse 80% 70% at 50% 60%, transparent 30%, #070707 100%)' }}
aria-hidden="true" />
<div className="relative z-10 w-full max-w-5xl mx-auto px-6 pt-28 pb-24">
<div className="flex flex-col gap-8 max-w-3xl">
{/* Status pill */}
<div className="flex items-center gap-2 self-start">
<span className="inline-flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium text-white/50
bg-white/[0.04] border border-white/[0.07]">
<span className="w-1.5 h-1.5 rounded-full bg-white/50 animate-pulse" aria-hidden="true" />
Software Engineer
</span>
</div>
{/* Name */}
<h1 id="hero-name"
className="text-[clamp(3.5rem,10vw,8rem)] font-black leading-[0.9] tracking-[-0.05em] text-white">
William<br />
<span className="text-white/25">March</span>
</h1>
{/* Tagline */}
<p className="text-[clamp(1rem,2vw,1.25rem)] text-white/40 max-w-[44ch] leading-relaxed font-light">
{SITE.tagline}
</p>
{/* CTAs */}
<div className="flex flex-wrap gap-3">
<a href="#projects" className="btn btn-primary">
View work
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</a>
<a href="#contact" className="btn btn-ghost">Get in touch</a>
</div>
</div>
{/* Scroll hint */}
<div className="absolute bottom-10 left-6 flex items-center gap-2 text-white/20 text-xs font-mono" aria-hidden="true">
<span className="w-[1px] h-8 bg-gradient-to-b from-transparent to-white/20" />
scroll
</div>
</div>
</section>
);
}

92
components/Nav.tsx Normal file
View File

@@ -0,0 +1,92 @@
'use client';
import { useEffect, useState } from 'react';
import { SITE, GITEA_URL, GITEA_USERNAME } from '@/lib/config';
export default function Nav() {
const [scrolled, setScrolled] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
const fn = () => setScrolled(window.scrollY > 24);
window.addEventListener('scroll', fn, { passive: true });
return () => window.removeEventListener('scroll', fn);
}, []);
const links = [
{ href: '#about', label: 'About' },
{ href: '#projects', label: 'Projects' },
{ href: '#activity', label: 'Activity' },
{ href: '#contact', label: 'Contact' },
];
const navBase = 'fixed top-0 left-0 right-0 z-50 h-[60px] flex items-center transition-all duration-300';
const navStyle = scrolled
? 'bg-[rgba(7,7,7,0.85)] backdrop-blur-[20px] border-b border-white/[0.06]'
: 'bg-transparent';
return (
<nav className={`${navBase} ${navStyle}`} aria-label="Main navigation">
<div className="w-full max-w-5xl mx-auto px-6 flex items-center justify-between gap-8">
<a href="#" className="flex items-center gap-2.5 group" aria-label="William March">
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" aria-hidden="true">
<rect width="26" height="26" rx="5" fill="rgba(255,255,255,0.9)"/>
<path d="M4 7.5L9 19L13 9.5L17 19L22 7.5"
stroke="#070707" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span className="text-sm font-semibold text-white/80 group-hover:text-white transition-colors duration-150 tracking-tight">
William March
</span>
</a>
<ul className="hidden md:flex items-center gap-7" role="list">
{links.map(l => (
<li key={l.href}>
<a href={l.href}
className="text-sm text-white/40 hover:text-white/80 transition-colors duration-150 font-medium">
{l.label}
</a>
</li>
))}
</ul>
<div className="hidden md:flex items-center gap-3">
<a
href={`${GITEA_URL}/${GITEA_USERNAME}`}
target="_blank" rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/70 transition-colors duration-150 font-mono"
aria-label="Gitea profile">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true">
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.2c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>
<path d="M9 18c-4.51 2-5-2-7-2"/>
</svg>
gitea
</a>
</div>
<button
className="md:hidden p-1.5 rounded-md text-white/50 hover:text-white/80 hover:bg-white/[0.05] transition-all"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Toggle menu" aria-expanded={mobileOpen}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
{mobileOpen
? <><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></>
: <><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></>
}
</svg>
</button>
</div>
{mobileOpen && (
<div className="absolute top-[60px] left-0 right-0 bg-[#0f0f0f] border-b border-white/[0.06] px-6 py-4 md:hidden">
{links.map(l => (
<a key={l.href} href={l.href}
onClick={() => setMobileOpen(false)}
className="block py-2.5 text-sm text-white/60 hover:text-white transition-colors">
{l.label}
</a>
))}
</div>
)}
</nav>
);
}

129
components/Projects.tsx Normal file
View File

@@ -0,0 +1,129 @@
'use client';
import { useEffect, useState } from 'react';
import { getRepos } from '@/lib/gitea';
import type { GiteaRepo } from '@/lib/types';
import { SITE, GITEA_URL, GITEA_USERNAME } from '@/lib/config';
const LANG_COLOR: Record<string, string> = {
python:'#3572A5',javascript:'#f1e05a',typescript:'#3178c6',
go:'#00add8',rust:'#dea584','c++':'#f34b7d',c:'#555',
html:'#e34c26',css:'#563d7c',shell:'#89e051',ruby:'#701516',java:'#b07219'
};
function langColor(l: string | null) {
return LANG_COLOR[(l||'').toLowerCase()] || '#555';
}
function timeAgo(d: string) {
const diff = Date.now() - new Date(d).getTime();
const m = Math.floor(diff / 60000);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const days = Math.floor(h / 24);
if (days < 30) return `${days}d ago`;
return `${Math.floor(days / 30)}mo ago`;
}
export default function Projects() {
const [repos, setRepos] = useState<GiteaRepo[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getRepos(SITE.repoLimit)
.then(setRepos)
.catch(e => setError(e.message));
}, []);
return (
<section id="projects" className="py-24 px-6 bg-[#0a0a0a]" aria-labelledby="projects-heading">
<div className="max-w-5xl mx-auto">
<span className="label">Gitea · Public repos</span>
<div className="flex items-end justify-between mb-10">
<h2 id="projects-heading"
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-white">
Recent Projects
</h2>
<a href={`${GITEA_URL}/${GITEA_USERNAME}`}
target="_blank" rel="noopener noreferrer"
className="hidden sm:flex items-center gap-1.5 text-xs text-white/30 hover:text-white/60 transition-colors font-mono">
All repos
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M7 17L17 7M7 7h10v10"/>
</svg>
</a>
</div>
{!repos && !error && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="glass rounded-2xl p-5 flex flex-col gap-3">
<div className="skel h-4 w-1/2" />
<div className="skel h-3 w-5/6" />
<div className="skel h-3 w-3/4" />
<div className="skel h-3 w-1/3 mt-auto" />
</div>
))}
</div>
)}
{error && (
<div className="glass rounded-2xl p-10 text-center">
<p className="text-white/30 text-sm mb-1">Could not load repositories</p>
<p className="text-white/20 text-xs font-mono">{error}</p>
</div>
)}
{repos && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4" aria-live="polite">
{repos.map(repo => (
<a
key={repo.id}
href={repo.html_url}
target="_blank" rel="noopener noreferrer"
className="glass rounded-2xl p-5 flex flex-col gap-4 group transition-all duration-200
hover:border-white/[0.14] hover:shadow-[0_0_0_1px_rgba(0,0,0,0.6),0_16px_48px_rgba(0,0,0,0.6)]
hover:-translate-y-[2px]"
aria-label={`${repo.name}: ${repo.description || 'No description'}`}>
<div className="flex items-start justify-between gap-2">
<div className="p-1.5 rounded-md bg-white/[0.04] border border-white/[0.07]" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.4)" strokeWidth="1.8">
<path d="M15 22v-4a4.8 4.8 0 0 0-1-3.2c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>
<path d="M9 18c-4.51 2-5-2-7-2"/>
</svg>
</div>
<svg className="text-white/20 group-hover:text-white/40 transition-colors" width="12" height="12"
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M7 17L17 7M7 7h10v10"/>
</svg>
</div>
<div>
<p className="text-sm font-semibold text-white mb-1 truncate">{repo.name}</p>
<p className="text-xs text-white/35 leading-relaxed line-clamp-2">
{repo.description || <span className="italic text-white/20">No description</span>}
</p>
</div>
<div className="flex items-center gap-4 mt-auto">
{repo.language && (
<span className="flex items-center gap-1.5 text-xs text-white/30">
<span className="w-2 h-2 rounded-full flex-shrink-0"
style={{ background: langColor(repo.language) }} aria-hidden="true" />
{repo.language}
</span>
)}
<span className="flex items-center gap-1 text-xs text-white/25" aria-label={`${repo.stars_count} stars`}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
{repo.stars_count}
</span>
<span className="text-xs text-white/20 ml-auto tabular-nums">{timeAgo(repo.updated)}</span>
</div>
</a>
))}
</div>
)}
</div>
</section>
);
}