346 lines
12 KiB
TypeScript
346 lines
12 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: '#555555',
|
|
html: '#e34c26',
|
|
css: '#563d7c',
|
|
shell: '#89e051',
|
|
ruby: '#701516',
|
|
java: '#b07219',
|
|
};
|
|
|
|
function langColor(l: string | null) {
|
|
return LANG_COLOR[(l || '').toLowerCase()] || '#555555';
|
|
}
|
|
|
|
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`;
|
|
const months = Math.floor(days / 30);
|
|
return `${months}mo ago`;
|
|
}
|
|
|
|
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);
|
|
|
|
useEffect(() => {
|
|
getRepos(SITE.repoLimit)
|
|
.then(setRepos)
|
|
.catch((e) => setError(e instanceof Error ? e.message : String(e)));
|
|
}, []);
|
|
|
|
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]);
|
|
|
|
return (
|
|
<section
|
|
id="projects"
|
|
className="py-24 px-6"
|
|
aria-labelledby="projects-heading"
|
|
>
|
|
<div className="max-w-5xl mx-auto min-w-0">
|
|
<div className="flex items-end justify-between mb-8 gap-4">
|
|
<h2
|
|
id="projects-heading"
|
|
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-slate-900"
|
|
>
|
|
</h2>
|
|
|
|
<a
|
|
href={`${GITEA_URL}/${GITEA_USERNAME}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="hidden sm:inline-flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-900 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 className="glass rounded-[28px] p-5 sm:p-7 md:p-8">
|
|
{!repos && !error && (
|
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="rounded-2xl border border-slate-200/80 bg-white/70 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="rounded-2xl border border-slate-200/80 bg-white/80 p-10 text-center">
|
|
<p className="text-slate-600 text-sm mb-1">
|
|
Could not load repositories
|
|
</p>
|
|
<p className="text-slate-400 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) => {
|
|
const updated = repo.updated || (repo as any).updated_at;
|
|
|
|
return (
|
|
<button
|
|
key={repo.id}
|
|
type="button"
|
|
onClick={() => setActiveRepo(repo)}
|
|
className="rounded-2xl border border-slate-200/80 bg-white/75 p-5 flex flex-col gap-4 text-left transition-all duration-200 hover:border-slate-300 hover:shadow-md hover:-translate-y-[1px] 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-slate-50 border border-slate-200"
|
|
aria-hidden="true"
|
|
>
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="rgba(15,23,42,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>
|
|
|
|
<svg
|
|
className="text-slate-400 hover:text-slate-700 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-slate-900 mb-1 truncate">
|
|
{repo.name}
|
|
</p>
|
|
<p className="text-xs text-slate-600 leading-relaxed line-clamp-2">
|
|
{repo.description ? (
|
|
repo.description
|
|
) : (
|
|
<span className="italic text-slate-400">
|
|
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-slate-600">
|
|
<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-slate-500"
|
|
aria-label={`${repo.stars_count} stars`}
|
|
>
|
|
<svg
|
|
width="10"
|
|
height="10"
|
|
viewBox="0 0 24 24"
|
|
fill="currentColor"
|
|
aria-hidden="true"
|
|
className="text-amber-400"
|
|
>
|
|
<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-slate-500 ml-auto tabular-nums">
|
|
{timeAgo(updated)}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{activeRepo && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 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 bg-white/95 border border-slate-200 text-slate-900">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setActiveRepo(null);
|
|
setReadmeHtml(null);
|
|
setReadmeError(null);
|
|
}}
|
|
className="absolute top-3 right-3 text-slate-500 hover:text-slate-900"
|
|
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-slate-900 mb-1">
|
|
{activeRepo.name}
|
|
</h3>
|
|
<p className="text-xs text-slate-600">
|
|
{activeRepo.description ||
|
|
'This project does not have a description yet.'}
|
|
</p>
|
|
</header>
|
|
|
|
<div className="flex items-center gap-2 mb-4 text-xs flex-wrap">
|
|
<a
|
|
href={activeRepo.html_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-slate-900 text-slate-50 hover:bg-black transition-colors"
|
|
>
|
|
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 border border-slate-200 text-slate-900 hover:bg-slate-50 transition-colors"
|
|
>
|
|
View live
|
|
</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 border border-slate-200 text-slate-900 hover:bg-slate-50 transition-colors"
|
|
>
|
|
Wiki
|
|
</a>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto pr-1 text-xs leading-relaxed text-slate-800">
|
|
{readmeLoading && (
|
|
<p className="text-slate-500">Loading README…</p>
|
|
)}
|
|
|
|
{readmeError && (
|
|
<p className="text-slate-500">
|
|
Could not load README: {readmeError}
|
|
</p>
|
|
)}
|
|
|
|
{readmeHtml && (
|
|
<div
|
|
className="prose prose-sm max-w-none text-slate-900"
|
|
dangerouslySetInnerHTML={{ __html: readmeHtml }}
|
|
/>
|
|
)}
|
|
|
|
{!readmeLoading && !readmeError && !readmeHtml && (
|
|
<p className="text-slate-500">
|
|
No README content available for this repository.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|