130 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|