update projects and contact form

This commit is contained in:
will
2026-04-02 23:02:19 +01:00
parent ea864a99c4
commit 746e51370d
9776 changed files with 953 additions and 2075549 deletions

View File

@@ -1,62 +1,211 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { getRepos } from '@/lib/gitea';
import type { GiteaRepo } from '@/lib/types';
import { SITE, GITEA_URL, GITEA_USERNAME } from '@/lib/config';
import { 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'
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';
return LANG_COLOR[(l || '').toLowerCase()] || '#555';
}
function timeAgo(d: string) {
const diff = Date.now() - new Date(d).getTime();
function timeAgo(d: string | undefined | null) {
if (!d) return '';
const t = new Date(d).getTime();
if (Number.isNaN(t)) return '';
const diff = Date.now() - t;
if (diff < 0) return 'just now';
const m = Math.floor(diff / 60000);
if (m < 1) return 'just now';
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`;
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
type SortKey = 'recent' | 'stars';
export default function Projects() {
const [repos, setRepos] = useState<GiteaRepo[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [activeRepo, setActiveRepo] = useState<GiteaRepo | null>(null);
const [readmeHtml, setReadmeHtml] = useState<string | null>(null);
const [readmeLoading, setReadmeLoading] = useState(false);
const [readmeError, setReadmeError] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<SortKey>('recent');
const [languageFilter, setLanguageFilter] = useState<string>('all');
useEffect(() => {
getRepos(SITE.repoLimit)
// Get all repos (or a high limit) instead of SITE.repoLimit
getRepos(100)
.then(setRepos)
.catch(e => setError(e.message));
.catch(e => setError(e instanceof Error ? e.message : String(e)));
}, []);
// Load README when a repo is opened
useEffect(() => {
if (!activeRepo) return;
setReadmeLoading(true);
setReadmeError(null);
setReadmeHtml(null);
fetch(`/api/gitea/readme?repo=${encodeURIComponent(activeRepo.name)}`)
.then(res => {
if (!res.ok) throw new Error(`README HTTP ${res.status}`);
return res.text();
})
.then(html => setReadmeHtml(html))
.catch(e =>
setReadmeError(
e instanceof Error ? e.message : 'Could not load README',
),
)
.finally(() => setReadmeLoading(false));
}, [activeRepo]);
// Derive list of languages for filter
const languages = useMemo(() => {
if (!repos) return [];
const set = new Set<string>();
repos.forEach(r => {
if (r.language) set.add(r.language);
});
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [repos]);
// Apply sort + language filter
const filteredRepos = useMemo(() => {
if (!repos) return null;
let list = [...repos];
if (languageFilter !== 'all') {
list = list.filter(
r => (r.language || '').toLowerCase() === languageFilter.toLowerCase(),
);
}
list.sort((a, b) => {
if (sortBy === 'stars') {
return b.stars_count - a.stars_count;
}
// recent: sort by updated/updated_at desc
const ad = new Date((a.updated || (a as any).updated_at) ?? '').getTime();
const bd = new Date((b.updated || (b as any).updated_at) ?? '').getTime();
return bd - ad;
});
return list;
}, [repos, sortBy, languageFilter]);
return (
<section id="projects" className="py-24 px-6 bg-[#0a0a0a]" aria-labelledby="projects-heading">
<section
id="projects"
className="py-24 px-6"
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 className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
<div>
<h2
id="projects-heading"
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-white"
>
Projects
</h2>
<p className="text-xs text-white/40 mt-1">
Open-source work, sorted by activity or stars.
</p>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs">
{/* Sort */}
<div className="flex items-center gap-1.5">
<span className="text-white/35">Sort:</span>
<button
type="button"
onClick={() => setSortBy('recent')}
className={`px-2.5 py-1 rounded-full border text-xs ${
sortBy === 'recent'
? 'border-white/40 bg-white/10 text-white'
: 'border-white/10 text-white/40 hover:border-white/25 hover:text-white/70'
}`}
>
Most recent
</button>
<button
type="button"
onClick={() => setSortBy('stars')}
className={`px-2.5 py-1 rounded-full border text-xs ${
sortBy === 'stars'
? 'border-white/40 bg-white/10 text-white'
: 'border-white/10 text-white/40 hover:border-white/25 hover:text-white/70'
}`}
>
Stars
</button>
</div>
{/* Language filter */}
<div className="flex items-center gap-1.5">
<span className="text-white/35">Language:</span>
<select
value={languageFilter}
onChange={e => setLanguageFilter(e.target.value)}
className="bg-white/5 border border-white/15 rounded-full px-2.5 py-1 text-xs text-white/80 focus:outline-none focus:ring-1 focus:ring-white/40"
>
<option value="all">All</option>
{languages.map(lang => (
<option key={lang} value={lang}>
{lang}
</option>
))}
</select>
</div>
{/* All repos link */}
<a
href={`${GITEA_URL}/${GITEA_USERNAME}`}
target="_blank"
rel="noopener noreferrer"
className="hidden sm:inline-flex items-center gap-1.5 text-xs text-white/30 hover:text-white/60 transition-colors"
>
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>
</div>
{!repos && !error && (
{/* Loading */}
{!filteredRepos && !error && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
{Array.from({ length: 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" />
@@ -67,60 +216,210 @@ export default function Projects() {
</div>
)}
{/* Error */}
{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>
<p className="text-white/30 text-sm mb-1">
Could not load repositories
</p>
<p className="text-white/20 text-xs">{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"/>
{/* Repos list */}
{filteredRepos && (
<div
className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4"
aria-live="polite"
>
{filteredRepos.map(repo => {
const updated = repo.updated || (repo as any).updated_at;
return (
<button
key={repo.id}
type="button"
onClick={() => setActiveRepo(repo)}
className="glass rounded-2xl p-5 flex flex-col gap-4 group transition-all duration-200 text-left
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] cursor-pointer"
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>
<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}
<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 ? (
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(updated)}
</span>
</div>
</button>
);
})}
</div>
)}
{/* Popout with README + Live + Wiki (unchanged) */}
{activeRepo && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4"
role="dialog"
aria-modal="true"
aria-label={`${activeRepo.name} details`}
>
<div className="glass max-w-2xl w-full rounded-2xl p-5 sm:p-6 relative max-h-[80vh] overflow-hidden flex flex-col">
<button
type="button"
onClick={() => {
setActiveRepo(null);
setReadmeHtml(null);
setReadmeError(null);
}}
className="absolute top-3 right-3 text-white/40 hover:text-white/80"
aria-label="Close"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 6l12 12M6 18L18 6" />
</svg>
</button>
<header className="mb-3 pr-8">
<h3 className="text-sm font-semibold text-white mb-1">
{activeRepo.name}
</h3>
<p className="text-xs text-white/40">
{activeRepo.description ||
'This project does not have a description yet.'}
</p>
</header>
<div className="flex items-center gap-2 mb-4 text-xs">
<a
href={activeRepo.html_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-white/5 border border-white/15 text-white/80 hover:bg-white/8"
>
View code
</a>
{activeRepo.website &&
activeRepo.website.trim().length > 0 && (
<a
href={activeRepo.website}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-white/5 border border-white/15 text-white/80 hover:bg-white/8"
>
View live
</a>
)}
<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>
))}
<a
href={`https://git.williammarch.xyz/${GITEA_USERNAME}/${activeRepo.name}/wiki`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-white/5 border border-white/15 text-white/80 hover:bg-white/8"
>
Wiki
</a>
</div>
<div className="flex-1 overflow-y-auto pr-1 text-xs leading-relaxed text-white/70">
{readmeLoading && (
<p className="text-white/40">Loading README</p>
)}
{readmeError && (
<p className="text-white/40">
Could not load README: {readmeError}
</p>
)}
{readmeHtml && (
<div
className="prose prose-invert prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: readmeHtml }}
/>
)}
{!readmeLoading && !readmeError && !readmeHtml && (
<p className="text-white/40">
No README content available for this repository.
</p>
)}
</div>
</div>
</div>
)}
</div>