Files
williammarch.xyz/components/Projects.tsx
2026-04-02 19:13:46 +01:00

130 lines
5.9 KiB
TypeScript

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