change theme and cleanup added favicon
This commit is contained in:
142
app/globals.css
142
app/globals.css
@@ -7,18 +7,23 @@
|
|||||||
--font-geist-sans: 'Geist', system-ui, sans-serif;
|
--font-geist-sans: 'Geist', system-ui, sans-serif;
|
||||||
--font-geist-mono: 'Geist Mono', monospace;
|
--font-geist-mono: 'Geist Mono', monospace;
|
||||||
|
|
||||||
--bg: #070707;
|
/* Light base */
|
||||||
--s1: #0f0f0f;
|
--bg: #f9fafb;
|
||||||
--s2: #161616;
|
|
||||||
--s3: #1e1e1e;
|
|
||||||
--s4: #262626;
|
|
||||||
|
|
||||||
--border: rgba(255,255,255,0.07);
|
/* Light surface steps (for shadows / borders if needed) */
|
||||||
--border-h: rgba(255,255,255,0.14);
|
--s1: #ffffff;
|
||||||
|
--s2: #f3f4f6;
|
||||||
|
--s3: #e5e7eb;
|
||||||
|
--s4: #d1d5db;
|
||||||
|
|
||||||
--text: #f0f0f0;
|
/* Borders tuned for light theme */
|
||||||
--muted: #888;
|
--border: rgba(15,23,42,0.08);
|
||||||
--faint: #3d3d3d;
|
--border-h: rgba(15,23,42,0.16);
|
||||||
|
|
||||||
|
/* Dark text on light bg */
|
||||||
|
--text: #020617; /* almost black */
|
||||||
|
--muted: #6b7280; /* slate-500 */
|
||||||
|
--faint: #e5e7eb; /* light divider */
|
||||||
|
|
||||||
--tr: 180ms cubic-bezier(.16,1,.3,1);
|
--tr: 180ms cubic-bezier(.16,1,.3,1);
|
||||||
--tr-slow: 320ms cubic-bezier(.16,1,.3,1);
|
--tr-slow: 320ms cubic-bezier(.16,1,.3,1);
|
||||||
@@ -44,10 +49,10 @@ body {
|
|||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection { background: rgba(255,255,255,0.12); color: #fff; }
|
::selection { background: rgba(15,23,42,0.12); color: #000; }
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 1.5px solid rgba(255,255,255,0.5);
|
outline: 1.5px solid rgba(15,23,42,0.4);
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -56,27 +61,25 @@ body {
|
|||||||
*, *::before, *::after { animation-duration:.01ms!important; transition-duration:.01ms!important; }
|
*, *::before, *::after { animation-duration:.01ms!important; transition-duration:.01ms!important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── GLASS CARD ── */
|
/* ── GLASS CARD (light, for all elements using .glass) ── */
|
||||||
.glass {
|
.glass {
|
||||||
background: rgba(255,255,255,0.025);
|
background: rgba(255,255,255,0.78);
|
||||||
backdrop-filter: blur(20px) saturate(150%);
|
backdrop-filter: blur(20px) saturate(140%);
|
||||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgba(0,0,0,0.6),
|
0 0 0 1px rgba(15,23,42,0.02),
|
||||||
0 8px 32px rgba(0,0,0,0.5),
|
0 10px 30px rgba(15,23,42,0.08),
|
||||||
0 32px 80px rgba(0,0,0,0.3),
|
inset 0 1px 0 rgba(255,255,255,0.9);
|
||||||
inset 0 1px 0 rgba(255,255,255,0.06);
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass:hover {
|
.glass:hover {
|
||||||
border-color: var(--border-h);
|
border-color: var(--border-h);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 0 1px rgba(0,0,0,0.6),
|
0 0 0 1px rgba(15,23,42,0.03),
|
||||||
0 12px 40px rgba(0,0,0,0.55),
|
0 14px 40px rgba(15,23,42,0.13),
|
||||||
0 40px 100px rgba(0,0,0,0.35),
|
inset 0 1px 0 rgba(255,255,255,1);
|
||||||
inset 0 1px 0 rgba(255,255,255,0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── NOISE TEXTURE (hero) ── */
|
/* ── NOISE TEXTURE (hero) ── */
|
||||||
@@ -88,17 +91,17 @@ body {
|
|||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
|
||||||
background-repeat: repeat;
|
background-repeat: repeat;
|
||||||
background-size: 256px 256px;
|
background-size: 256px 256px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
opacity: .6;
|
opacity: .45;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── DOT GRID BG ── */
|
/* ── DOT GRID BG ── */
|
||||||
.dot-grid {
|
.dot-grid {
|
||||||
background-image: radial-gradient(circle, rgba(255,255,255,0.08) 1px, transparent 1px);
|
background-image: radial-gradient(circle, rgba(15,23,42,0.06) 1px, transparent 1px);
|
||||||
background-size: 32px 32px;
|
background-size: 32px 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +114,7 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: .1em;
|
letter-spacing: .1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: rgba(255,255,255,0.35);
|
color: rgba(15,23,42,0.55);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.label::before {
|
.label::before {
|
||||||
@@ -119,7 +122,7 @@ body {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: rgba(255,255,255,0.3);
|
background: rgba(15,23,42,0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── SKELETON ── */
|
/* ── SKELETON ── */
|
||||||
@@ -153,20 +156,28 @@ body {
|
|||||||
}
|
}
|
||||||
.btn:hover { transform: translateY(-1px); }
|
.btn:hover { transform: translateY(-1px); }
|
||||||
.btn:active { transform: translateY(0); }
|
.btn:active { transform: translateY(0); }
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: rgba(255,255,255,0.95); color: #000;
|
background: #020617;
|
||||||
border-color: rgba(255,255,255,0.2);
|
color: #f9fafb;
|
||||||
box-shadow: 0 4px 20px rgba(255,255,255,0.08);
|
border-color: rgba(15,23,42,0.3);
|
||||||
|
box-shadow: 0 4px 20px rgba(15,23,42,0.15);
|
||||||
}
|
}
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: #fff;
|
background: #000;
|
||||||
box-shadow: 0 6px 28px rgba(255,255,255,0.14);
|
box-shadow: 0 6px 26px rgba(15,23,42,0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
background: rgba(255,255,255,0.04); color: var(--muted);
|
background: rgba(255,255,255,0.75);
|
||||||
|
color: var(--muted);
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
.btn-ghost:hover { background: rgba(255,255,255,0.07); color: var(--text); border-color: var(--border-h); }
|
.btn-ghost:hover {
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--border-h);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── CALENDAR CELLS ── */
|
/* ── CALENDAR CELLS ── */
|
||||||
.cal-cell {
|
.cal-cell {
|
||||||
@@ -189,62 +200,3 @@ body {
|
|||||||
background: var(--faint);
|
background: var(--faint);
|
||||||
}
|
}
|
||||||
.commit-line:last-child::before { display: none; }
|
.commit-line:last-child::before { display: none; }
|
||||||
|
|
||||||
.prose {
|
|
||||||
--tw-prose-body: rgba(226, 232, 240, 0.88); /* text-slate-200 */
|
|
||||||
--tw-prose-headings: #e5e7eb; /* text-slate-200 */
|
|
||||||
--tw-prose-links: #e5e7eb;
|
|
||||||
--tw-prose-links-hover: #ffffff;
|
|
||||||
--tw-prose-quotes: #e5e7eb;
|
|
||||||
--tw-prose-bold: #e5e7eb;
|
|
||||||
--tw-prose-bullets: rgba(148, 163, 184, 0.7);
|
|
||||||
--tw-prose-hr: rgba(148, 163, 184, 0.25);
|
|
||||||
--tw-prose-th-borders: rgba(148, 163, 184, 0.4);
|
|
||||||
--tw-prose-td-borders: rgba(148, 163, 184, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose.prose-invert {
|
|
||||||
color: var(--tw-prose-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose.prose-invert h1,
|
|
||||||
.prose.prose-invert h2,
|
|
||||||
.prose.prose-invert h3,
|
|
||||||
.prose.prose-invert h4 {
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose.prose-invert a {
|
|
||||||
text-decoration: none;
|
|
||||||
border-bottom: 1px dotted rgba(148, 163, 184, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose.prose-invert a:hover {
|
|
||||||
border-bottom-style: solid;
|
|
||||||
border-bottom-color: rgba(248, 250, 252, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose.prose-invert code {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
background: rgba(15, 23, 42, 0.9);
|
|
||||||
border-radius: 0.35rem;
|
|
||||||
padding: 0.1rem 0.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose.prose-invert pre {
|
|
||||||
background: radial-gradient(circle at top left, #020617, #020617);
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
|
||||||
padding: 0.9rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose.prose-invert pre code {
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose.prose-invert ul,
|
|
||||||
.prose.prose-invert ol {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import type { Metadata } from 'next';
|
|||||||
import { GeistSans } from 'geist/font/sans';
|
import { GeistSans } from 'geist/font/sans';
|
||||||
import { GeistMono } from 'geist/font/mono';
|
import { GeistMono } from 'geist/font/mono';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import TechBackdrop from './TechBackdrop';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'William March — Software Engineer',
|
title: 'williammarch.xyz',
|
||||||
description: 'Software engineer building precise, purposeful open-source tools.',
|
description: 'Software engineer building precise, purposeful open-source tools.',
|
||||||
metadataBase: new URL('https://williammarch.xyz'),
|
metadataBase: new URL('https://williammarch.xyz'),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
@@ -24,9 +23,8 @@ export const metadata: Metadata = {
|
|||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
|
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
|
||||||
<body className="bg-slate-950 text-slate-100 relative overflow-x-hidden">
|
<body className="white text-slate-100 relative overflow-x-hidden">
|
||||||
<div aria-hidden="true" className="fixed inset-0 -z-10">
|
<div aria-hidden="true" className="fixed inset-0 -z-10">
|
||||||
<TechBackdrop />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import Nav from '@/components/Nav';
|
|
||||||
import About from '@/components/About';
|
import About from '@/components/About';
|
||||||
import Projects from '@/components/Projects';
|
import Projects from '@/components/Projects';
|
||||||
import CommitSection from '@/components/CommitSection';
|
import CommitSection from '@/components/CommitSection';
|
||||||
@@ -13,7 +12,6 @@ export default function Page() {
|
|||||||
focus:text-sm focus:font-semibold">
|
focus:text-sm focus:font-semibold">
|
||||||
Skip to main content
|
Skip to main content
|
||||||
</a>
|
</a>
|
||||||
<Nav />
|
|
||||||
<main id="main">
|
<main id="main">
|
||||||
<About />
|
<About />
|
||||||
<Projects />
|
<Projects />
|
||||||
|
|||||||
@@ -2,31 +2,35 @@ import { SITE } from '@/lib/config';
|
|||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
return (
|
return (
|
||||||
<section id="about" className="py-24 px-6" aria-labelledby="about-heading">
|
<section
|
||||||
<div className="max-w-5xl mx-auto grid md:grid-cols-[auto_1fr] gap-16 items-start">
|
id="about"
|
||||||
{/* Avatar */}
|
className="py-24 px-6 bg-[var(--bg)]"
|
||||||
<div className="flex-shrink-0">
|
aria-labelledby="about-heading"
|
||||||
<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 className="max-w-5xl mx-auto">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div>
|
|
||||||
<span className="label">About</span>
|
<p className="text-slate-700 leading-relaxed max-w-[52ch]">
|
||||||
</div>
|
{SITE.about1}
|
||||||
<p className="text-white/50 leading-relaxed max-w-[52ch]">{SITE.about1}</p>
|
</p>
|
||||||
<p className="text-white/50 leading-relaxed max-w-[52ch]">{SITE.about2}</p>
|
<p className="text-slate-700 leading-relaxed max-w-[52ch]">
|
||||||
|
{SITE.about2}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-3">Stack</p>
|
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 mb-3">
|
||||||
|
Stack
|
||||||
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{SITE.skills.map(s => (
|
{SITE.skills.map((s) => (
|
||||||
<span key={s}
|
<span
|
||||||
className="px-3 py-1 text-xs font-medium text-white/40 bg-white/[0.04]
|
key={s}
|
||||||
border border-white/[0.07] rounded-full hover:text-white/60 hover:border-white/[0.12]
|
className="px-3 py-1 text-xs font-medium
|
||||||
transition-colors duration-150 cursor-default">
|
text-slate-600 bg-white/80
|
||||||
|
border border-slate-200 rounded-full
|
||||||
|
hover:text-slate-900 hover:border-slate-300
|
||||||
|
transition-colors duration-150 cursor-default"
|
||||||
|
>
|
||||||
{s}
|
{s}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,19 +7,20 @@ import type { GiteaRepo, GiteaCommit, HeatmapEntry } from '@/lib/types';
|
|||||||
const CELL = 12, GAP = 3, STEP = CELL + GAP;
|
const CELL = 12, GAP = 3, STEP = CELL + GAP;
|
||||||
const DAY_LABEL_W = 26;
|
const DAY_LABEL_W = 26;
|
||||||
const WEEKS = 52;
|
const WEEKS = 52;
|
||||||
// Luminosity levels (white-on-dark glow effect)
|
|
||||||
const LEVELS = [
|
const LEVELS = [
|
||||||
'rgba(255,255,255,0.04)', // 0
|
'rgba(15,23,42,0.05)',
|
||||||
'rgba(255,255,255,0.13)', // 1-2
|
'rgba(15,23,42,0.12)',
|
||||||
'rgba(255,255,255,0.28)', // 3-5
|
'rgba(15,23,42,0.22)',
|
||||||
'rgba(255,255,255,0.50)', // 6-9
|
'rgba(15,23,42,0.38)',
|
||||||
'rgba(255,255,255,0.80)', // 10+
|
'rgba(15,23,42,0.62)',
|
||||||
];
|
];
|
||||||
|
|
||||||
function getLevel(n: number) {
|
function getLevel(n: number) {
|
||||||
if (n === 0) return 0;
|
if (n === 0) return 0;
|
||||||
if (n <= 2) return 1;
|
if (n <= 2) return 1;
|
||||||
if (n <= 5) return 2;
|
if (n <= 5) return 2;
|
||||||
if (n <= 9) return 3;
|
if (n <= 9) return 3;
|
||||||
return 4;
|
return 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,31 +36,47 @@ function timeAgo(s: string) {
|
|||||||
return `${Math.floor(days / 30)}mo`;
|
return `${Math.floor(days / 30)}mo`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stat card ──
|
// ── Stat item ──
|
||||||
function Stat({ label, value, sub }: { label: string; value: string | number; sub: string }) {
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
sub: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="glass rounded-2xl px-5 py-4 flex flex-col gap-1">
|
<div className="min-w-0">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-[.1em] text-white/25">{label}</span>
|
<span className="block text-[10px] font-semibold uppercase tracking-[.14em] text-slate-400 mb-1">
|
||||||
<span className="text-[clamp(1.4rem,3vw,2rem)] font-black text-white tabular-nums leading-none">{value}</span>
|
{label}
|
||||||
<span className="text-xs text-white/30">{sub}</span>
|
</span>
|
||||||
|
<span className="block text-[clamp(1.4rem,3vw,2rem)] font-black text-slate-900 tabular-nums leading-none">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-slate-500 mt-1">{sub}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Contribution Calendar ──
|
// ── Contribution Calendar ──
|
||||||
function ContributionCalendar({ data }: { data: HeatmapEntry[] }) {
|
function ContributionCalendar({ data }: { data: HeatmapEntry[] }) {
|
||||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; date: string; count: number } | null>(null);
|
const [tooltip, setTooltip] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const dateMap = useMemo(() => {
|
const dateMap = useMemo(() => {
|
||||||
const m = new Map<string, number>();
|
const m = new Map<string, number>();
|
||||||
data.forEach(e => {
|
data.forEach((e) => {
|
||||||
const k = new Date(e.timestamp * 1000).toISOString().split('T')[0];
|
const k = new Date(e.timestamp * 1000).toISOString().split('T')[0];
|
||||||
m.set(k, (m.get(k) || 0) + e.contributions);
|
m.set(k, (m.get(k) || 0) + e.contributions);
|
||||||
});
|
});
|
||||||
return m;
|
return m;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
// Build 52-week grid starting from Sunday 51 weeks ago
|
|
||||||
const { weeks, monthLabels } = useMemo(() => {
|
const { weeks, monthLabels } = useMemo(() => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
@@ -88,6 +105,7 @@ function ContributionCalendar({ data }: { data: HeatmapEntry[] }) {
|
|||||||
}
|
}
|
||||||
weeks.push(week);
|
weeks.push(week);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { weeks, monthLabels };
|
return { weeks, monthLabels };
|
||||||
}, [dateMap]);
|
}, [dateMap]);
|
||||||
|
|
||||||
@@ -95,16 +113,19 @@ function ContributionCalendar({ data }: { data: HeatmapEntry[] }) {
|
|||||||
const svgH = 7 * STEP + 20;
|
const svgH = 7 * STEP + 20;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative min-w-0">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between gap-4 mb-4 flex-wrap">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[.1em] text-white/30">
|
<span className="text-[11px] font-semibold uppercase tracking-[.14em] text-slate-400">
|
||||||
Contribution Calendar · last 12 months
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-white/20">
|
<div className="flex items-center gap-1.5 text-[10px] text-slate-400">
|
||||||
<span>Less</span>
|
<span>Less</span>
|
||||||
{LEVELS.map((c, i) => (
|
{LEVELS.map((c, i) => (
|
||||||
<span key={i} className="rounded-sm inline-block w-2.5 h-2.5 flex-shrink-0"
|
<span
|
||||||
style={{ background: c, border: '1px solid rgba(255,255,255,0.06)' }} aria-hidden="true" />
|
key={i}
|
||||||
|
className="rounded-sm inline-block w-2.5 h-2.5 flex-shrink-0 border border-slate-200"
|
||||||
|
style={{ background: c }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
<span>More</span>
|
<span>More</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,35 +133,65 @@ function ContributionCalendar({ data }: { data: HeatmapEntry[] }) {
|
|||||||
|
|
||||||
<div className="overflow-x-auto pb-1">
|
<div className="overflow-x-auto pb-1">
|
||||||
<svg width={svgW} height={svgH} aria-label="Contribution calendar heatmap" role="img">
|
<svg width={svgW} height={svgH} aria-label="Contribution calendar heatmap" role="img">
|
||||||
{/* Month labels */}
|
|
||||||
{monthLabels.map((m, i) => (
|
{monthLabels.map((m, i) => (
|
||||||
<text key={i} x={m.x} y={10} fontSize={9} fill="rgba(255,255,255,0.25)"
|
<text
|
||||||
fontFamily="var(--font-geist-mono, monospace)">{m.label}</text>
|
key={i}
|
||||||
|
x={m.x}
|
||||||
|
y={10}
|
||||||
|
fontSize={9}
|
||||||
|
fill="rgba(15,23,42,0.35)"
|
||||||
|
fontFamily="var(--font-geist-mono, monospace)"
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</text>
|
||||||
))}
|
))}
|
||||||
{/* Day labels */}
|
|
||||||
{['', 'Mon', '', 'Wed', '', 'Fri', ''].map((d, i) => d ? (
|
{['', 'Mon', '', 'Wed', '', 'Fri', ''].map((d, i) =>
|
||||||
<text key={i} x={DAY_LABEL_W - 4} y={17 + i * STEP + CELL}
|
d ? (
|
||||||
fontSize={9} fill="rgba(255,255,255,0.2)" textAnchor="end"
|
<text
|
||||||
fontFamily="var(--font-geist-mono, monospace)">{d}</text>
|
key={i}
|
||||||
) : null)}
|
x={DAY_LABEL_W - 4}
|
||||||
{/* Cells */}
|
y={17 + i * STEP + CELL}
|
||||||
|
fontSize={9}
|
||||||
|
fill="rgba(15,23,42,0.28)"
|
||||||
|
textAnchor="end"
|
||||||
|
fontFamily="var(--font-geist-mono, monospace)"
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</text>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
|
||||||
<g transform={`translate(${DAY_LABEL_W}, 14)`}>
|
<g transform={`translate(${DAY_LABEL_W}, 14)`}>
|
||||||
{weeks.map((week, wi) =>
|
{weeks.map((week, wi) =>
|
||||||
week.map((day, di) => (
|
week.map((day, di) => (
|
||||||
<rect
|
<rect
|
||||||
key={day.key}
|
key={day.key}
|
||||||
x={wi * STEP} y={di * STEP}
|
x={wi * STEP}
|
||||||
width={CELL} height={CELL} rx={2.5}
|
y={di * STEP}
|
||||||
|
width={CELL}
|
||||||
|
height={CELL}
|
||||||
|
rx={2.5}
|
||||||
fill={LEVELS[getLevel(day.count)]}
|
fill={LEVELS[getLevel(day.count)]}
|
||||||
onMouseEnter={e => setTooltip({
|
onMouseEnter={(e) =>
|
||||||
x: e.clientX, y: e.clientY,
|
setTooltip({
|
||||||
date: day.date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }),
|
x: e.clientX,
|
||||||
count: day.count,
|
y: e.clientY,
|
||||||
})}
|
date: day.date.toLocaleDateString('en-GB', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
}),
|
||||||
|
count: day.count,
|
||||||
|
})
|
||||||
|
}
|
||||||
onMouseLeave={() => setTooltip(null)}
|
onMouseLeave={() => setTooltip(null)}
|
||||||
className="transition-opacity duration-100 hover:opacity-70 cursor-default"
|
className="transition-opacity duration-100 hover:opacity-70 cursor-default"
|
||||||
>
|
>
|
||||||
<title>{`${day.count} contribution${day.count !== 1 ? 's' : ''} on ${day.date.toLocaleDateString()}`}</title>
|
<title>
|
||||||
|
{`${day.count} contribution${day.count !== 1 ? 's' : ''} on ${day.date.toLocaleDateString()}`}
|
||||||
|
</title>
|
||||||
</rect>
|
</rect>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -148,22 +199,22 @@ function ContributionCalendar({ data }: { data: HeatmapEntry[] }) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
{tooltip && (
|
{tooltip && (
|
||||||
<div
|
<div
|
||||||
className="fixed z-50 pointer-events-none px-2.5 py-1.5 rounded-lg text-xs font-medium text-white
|
className="fixed z-50 pointer-events-none px-2.5 py-1.5 rounded-lg text-xs font-medium text-slate-900 bg-white/95 border border-slate-200 shadow-xl whitespace-nowrap"
|
||||||
bg-[#1a1a1a] border border-white/[0.1] shadow-xl whitespace-nowrap"
|
style={{ left: tooltip.x + 12, top: tooltip.y - 36 }}
|
||||||
style={{ left: tooltip.x + 12, top: tooltip.y - 36 }}>
|
>
|
||||||
<span className="font-bold">{tooltip.count}</span>
|
<span className="font-bold">{tooltip.count}</span>
|
||||||
{' contribution'}{tooltip.count !== 1 ? 's' : ''}
|
{' contribution'}
|
||||||
<span className="text-white/40 ml-1.5">{tooltip.date}</span>
|
{tooltip.count !== 1 ? 's' : ''}
|
||||||
|
<span className="text-slate-500 ml-1.5">{tooltip.date}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Monthly bar chart ──
|
// ── Monthly chart ──
|
||||||
function MonthlyChart({ data }: { data: HeatmapEntry[] }) {
|
function MonthlyChart({ data }: { data: HeatmapEntry[] }) {
|
||||||
const months = useMemo(() => {
|
const months = useMemo(() => {
|
||||||
return Array.from({ length: 12 }, (_, i) => {
|
return Array.from({ length: 12 }, (_, i) => {
|
||||||
@@ -172,7 +223,7 @@ function MonthlyChart({ data }: { data: HeatmapEntry[] }) {
|
|||||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
const label = d.toLocaleDateString('en-US', { month: 'short' });
|
const label = d.toLocaleDateString('en-US', { month: 'short' });
|
||||||
const count = data
|
const count = data
|
||||||
.filter(e => {
|
.filter((e) => {
|
||||||
const ed = new Date(e.timestamp * 1000);
|
const ed = new Date(e.timestamp * 1000);
|
||||||
return `${ed.getFullYear()}-${String(ed.getMonth() + 1).padStart(2, '0')}` === key;
|
return `${ed.getFullYear()}-${String(ed.getMonth() + 1).padStart(2, '0')}` === key;
|
||||||
})
|
})
|
||||||
@@ -181,30 +232,29 @@ function MonthlyChart({ data }: { data: HeatmapEntry[] }) {
|
|||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
const maxVal = Math.max(...months.map(m => m.count), 1);
|
const maxVal = Math.max(...months.map((m) => m.count), 1);
|
||||||
const barW = 100 / 12;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[.1em] text-white/30 block mb-5">
|
<span className="text-[11px] font-semibold uppercase tracking-[.14em] text-slate-400 block mb-5">
|
||||||
Monthly activity
|
Monthly activity
|
||||||
</span>
|
</span>
|
||||||
<div className="relative h-24 flex items-end gap-1.5" aria-label="Monthly commit activity bar chart" role="img">
|
<div className="relative h-24 flex items-end gap-1.5" aria-label="Monthly commit activity bar chart" role="img">
|
||||||
{months.map((m, i) => {
|
{months.map((m, i) => {
|
||||||
const pct = (m.count / maxVal) * 100;
|
const pct = (m.count / maxVal) * 100;
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex-1 flex flex-col items-center gap-1.5 group">
|
<div key={i} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
|
||||||
<div className="w-full relative" style={{ height: '80px' }}>
|
<div className="w-full relative" style={{ height: '80px' }}>
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 left-0 right-0 rounded-sm transition-all duration-300 ease-out"
|
className="absolute bottom-0 left-0 right-0 rounded-sm transition-all duration-300 ease-out bg-slate-900/10"
|
||||||
style={{
|
style={{
|
||||||
height: `${Math.max(pct, 2)}%`,
|
height: `${Math.max(pct, 2)}%`,
|
||||||
background: `rgba(255,255,255,${0.06 + (pct / 100) * 0.5})`,
|
opacity: 0.18 + (pct / 100) * 0.55,
|
||||||
}}
|
}}
|
||||||
title={`${m.label}: ${m.count} contributions`}
|
title={`${m.label}: ${m.count} contributions`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[8px] text-white/20 font-mono">{m.label}</span>
|
<span className="text-[9px] text-slate-400 font-mono">{m.label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -215,41 +265,49 @@ function MonthlyChart({ data }: { data: HeatmapEntry[] }) {
|
|||||||
|
|
||||||
// ── Recent commits ──
|
// ── Recent commits ──
|
||||||
function RecentCommits({ commits }: { commits: GiteaCommit[] }) {
|
function RecentCommits({ commits }: { commits: GiteaCommit[] }) {
|
||||||
if (!commits.length) return (
|
if (!commits.length) {
|
||||||
<p className="text-sm text-white/20 py-4">No recent commits.</p>
|
return <p className="text-sm text-slate-500 py-4">No recent commits.</p>;
|
||||||
);
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[.1em] text-white/30 block mb-5">
|
<span className="text-[11px] font-semibold uppercase tracking-[.14em] text-slate-400 block mb-5">
|
||||||
Recent commits
|
Recent commits
|
||||||
</span>
|
</span>
|
||||||
<ol className="flex flex-col" aria-label="Recent commit list">
|
|
||||||
{commits.slice(0, 10).map((c, i) => {
|
<ol className="grid gap-3" aria-label="Recent commit list">
|
||||||
|
{commits.slice(0, 10).map((c) => {
|
||||||
const sha = (c.sha || '').slice(0, 7);
|
const sha = (c.sha || '').slice(0, 7);
|
||||||
const msg = (c.commit?.message || '').split('\n')[0].slice(0, 64);
|
const msg = (c.commit?.message || '').split('\n')[0].slice(0, 72);
|
||||||
const date = c.created || c.commit?.author?.date;
|
const date = c.created || c.commit?.author?.date;
|
||||||
const isLast = i === Math.min(9, commits.length - 1);
|
|
||||||
return (
|
return (
|
||||||
<li key={c.sha} className={`commit-line relative flex gap-3 pb-4 ${isLast ? '' : ''}`}>
|
<li
|
||||||
<div className="mt-1.5 flex-shrink-0">
|
key={c.sha}
|
||||||
<span className="block w-2 h-2 rounded-full bg-white/20 relative z-10" aria-hidden="true" />
|
className="rounded-2xl border border-slate-200/80 bg-white/55 px-4 py-3 min-w-0"
|
||||||
</div>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-start gap-3 min-w-0">
|
||||||
<div className="flex items-baseline gap-2 mb-0.5 flex-wrap">
|
<span className="mt-1.5 block w-2 h-2 rounded-full bg-slate-300 flex-shrink-0" aria-hidden="true" />
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/25">
|
<div className="min-w-0 flex-1">
|
||||||
{c._repo}
|
<div className="flex items-baseline gap-2 mb-1 flex-wrap min-w-0">
|
||||||
</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-400">
|
||||||
<a
|
{c._repo}
|
||||||
href={c._repoUrl ? `${c._repoUrl}/commit/${c.sha}` : '#'}
|
</span>
|
||||||
target="_blank" rel="noopener noreferrer"
|
<a
|
||||||
className="font-mono text-[10px] text-white/20 hover:text-white/50 transition-colors px-1 py-0.5
|
href={c._repoUrl ? `${c._repoUrl}/commit/${c.sha}` : '#'}
|
||||||
rounded bg-white/[0.04] border border-white/[0.06]"
|
target="_blank"
|
||||||
aria-label={`View commit ${sha}`}>
|
rel="noopener noreferrer"
|
||||||
{sha}
|
className="font-mono text-[10px] text-slate-500 hover:text-slate-900 transition-colors px-1.5 py-0.5 rounded-md bg-slate-100 border border-slate-200"
|
||||||
</a>
|
aria-label={`View commit ${sha}`}
|
||||||
<span className="text-[10px] text-white/20 ml-auto">{date ? timeAgo(date) : ''}</span>
|
>
|
||||||
|
{sha}
|
||||||
|
</a>
|
||||||
|
<span className="text-[10px] text-slate-400 ml-auto">
|
||||||
|
{date ? timeAgo(date) : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-700 truncate leading-snug">{msg}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-white/60 truncate leading-snug">{msg}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -261,11 +319,11 @@ function RecentCommits({ commits }: { commits: GiteaCommit[] }) {
|
|||||||
|
|
||||||
// ── Main section ──
|
// ── Main section ──
|
||||||
export default function CommitSection() {
|
export default function CommitSection() {
|
||||||
const [repos, setRepos] = useState<GiteaRepo[]>([]);
|
const [repos, setRepos] = useState<GiteaRepo[]>([]);
|
||||||
const [heatmap, setHeatmap] = useState<HeatmapEntry[]>([]);
|
const [heatmap, setHeatmap] = useState<HeatmapEntry[]>([]);
|
||||||
const [commits, setCommits] = useState<GiteaCommit[]>([]);
|
const [commits, setCommits] = useState<GiteaCommit[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -289,65 +347,108 @@ export default function CommitSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="activity" className="py-24 px-6" aria-labelledby="activity-heading">
|
<section id="activity" className="py-24 px-6" aria-labelledby="activity-heading">
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto min-w-0">
|
||||||
<h2 id="activity-heading"
|
<h2
|
||||||
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-white mb-10">
|
id="activity-heading"
|
||||||
Contributions
|
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-slate-900 mb-8"
|
||||||
|
>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Stats row */}
|
<div className="relative glass rounded-[28px] p-5 sm:p-7 md:p-8 overflow-visible">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
<div className="relative grid gap-8 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
|
||||||
<Stat label="Contributions" value={loading ? '—' : stats.total.toLocaleString()} sub="this year" />
|
{/* Left side */}
|
||||||
<Stat label="Current streak" value={loading ? '—' : `${stats.currentStreak}d`} sub="days" />
|
<div className="min-w-0 grid gap-6">
|
||||||
<Stat label="Longest streak" value={loading ? '—' : `${stats.longestStreak}d`} sub="days" />
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 border-b border-slate-200/80 pb-6 min-w-0">
|
||||||
<Stat label="Active days" value={loading ? '—' : stats.activeDays} sub="this year" />
|
<Stat
|
||||||
</div>
|
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="min-w-0">
|
||||||
<div className="glass rounded-2xl p-5 sm:p-6 mb-4">
|
{loading ? (
|
||||||
{loading ? (
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="skel h-3 w-48" />
|
||||||
<div className="skel h-3 w-48" />
|
<div className="skel h-[120px] w-full rounded-xl" />
|
||||||
<div className="skel h-[120px] w-full rounded-xl" />
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="py-6">
|
||||||
|
<p className="text-slate-500 text-sm">Could not load heatmap</p>
|
||||||
|
<p className="text-slate-400 text-xs font-mono mt-1">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ContributionCalendar data={heatmap} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</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 */}
|
{/* Right side */}
|
||||||
<div className="grid lg:grid-cols-[1fr_360px] gap-4">
|
<div className="min-w-0 grid gap-6 lg:border-l lg:border-slate-200/80 lg:pl-8">
|
||||||
<div className="glass rounded-2xl p-5 sm:p-6">
|
<div className="min-w-0">
|
||||||
{loading
|
{loading ? (
|
||||||
? <div className="skel h-32 w-full rounded-xl" />
|
<div className="skel h-32 w-full rounded-xl" />
|
||||||
: <MonthlyChart data={heatmap} />
|
) : (
|
||||||
}
|
<MonthlyChart data={heatmap} />
|
||||||
</div>
|
)}
|
||||||
<div className="glass rounded-2xl p-5 sm:p-6 overflow-hidden">
|
</div>
|
||||||
{loading
|
|
||||||
? (
|
<div className="min-w-0">
|
||||||
<div className="flex flex-col gap-4">
|
{loading ? (
|
||||||
{[...Array(5)].map((_, i) => (
|
<div className="flex flex-col gap-4">
|
||||||
<div key={i} className="flex gap-3">
|
{[...Array(5)].map((_, i) => (
|
||||||
<div className="skel w-2 h-2 rounded-full mt-1.5 flex-shrink-0" />
|
<div key={i} className="flex gap-3">
|
||||||
<div className="flex-1">
|
<div className="skel w-2 h-2 rounded-full mt-1.5 flex-shrink-0" />
|
||||||
<div className="skel h-2.5 w-1/3 mb-1.5" />
|
<div className="flex-1 min-w-0">
|
||||||
<div className="skel h-3.5 w-5/6" />
|
<div className="skel h-2.5 w-1/3 mb-1.5" />
|
||||||
|
<div className="skel h-3.5 w-5/6" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
|
) : (
|
||||||
|
<RecentCommits commits={commits} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!!repos.length && (
|
||||||
|
<div className="pt-2 border-t border-slate-200/80 min-w-0">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[.14em] text-slate-400 block mb-4">
|
||||||
|
Projects
|
||||||
|
</span>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2.5 min-w-0">
|
||||||
|
{repos.slice(0, 6).map((repo) => (
|
||||||
|
<a
|
||||||
|
key={repo.id ?? repo.name}
|
||||||
|
href={repo.html_url || repo.website || '#'}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-2xl border border-slate-200/80 bg-white/50 px-3 py-3 text-sm text-slate-700 hover:text-slate-900 hover:border-slate-300 transition-colors min-w-0"
|
||||||
|
>
|
||||||
|
<span className="block truncate font-medium">{repo.name}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
: <RecentCommits commits={commits} />
|
</div>
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ export default function Contact() {
|
|||||||
|
|
||||||
widgetIdRef.current = window.turnstile.render(turnstileRef.current, {
|
widgetIdRef.current = window.turnstile.render(turnstileRef.current, {
|
||||||
sitekey: TURNSTILE_SITE_KEY,
|
sitekey: TURNSTILE_SITE_KEY,
|
||||||
theme: 'dark',
|
theme: 'light',
|
||||||
callback: token => {
|
callback: (token) => {
|
||||||
setTurnstileToken(token);
|
setTurnstileToken(token);
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
},
|
},
|
||||||
@@ -144,30 +144,26 @@ export default function Contact() {
|
|||||||
className="py-24 px-6"
|
className="py-24 px-6"
|
||||||
aria-labelledby="contact-heading"
|
aria-labelledby="contact-heading"
|
||||||
>
|
>
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto min-w-0">
|
||||||
<span className="label">Get in touch</span>
|
|
||||||
|
|
||||||
<h2
|
<h2
|
||||||
id="contact-heading"
|
id="contact-heading"
|
||||||
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-white mb-3"
|
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-slate-900 mb-3"
|
||||||
>
|
>
|
||||||
Let’s work together.
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-sm text-white/40 max-w-2xl mb-10">
|
<p className="text-sm text-slate-600 max-w-2xl mb-10">
|
||||||
Open to interesting roles, collaborations, and serious product ideas.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-[280px_1fr] gap-6">
|
<div className="grid md:grid-cols-[280px_1fr] gap-6 items-start">
|
||||||
{/* Left side contact cards */}
|
{/* Left side contact cards */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<a
|
<a
|
||||||
href={`mailto:${SITE.email}`}
|
href={`mailto:${SITE.email}`}
|
||||||
className="glass rounded-xl p-4 flex items-center gap-3 group hover:border-white/[0.14]
|
className="rounded-xl border border-slate-200/80 bg-white/80 p-4 flex items-center gap-3 group transition-all duration-150 hover:border-slate-300 hover:-translate-x-[1px]"
|
||||||
hover:-translate-x-[-4px] transition-all duration-150"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-lg bg-white/[0.05] flex items-center justify-center flex-shrink-0"
|
className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0 border border-slate-200"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -175,7 +171,7 @@ export default function Contact() {
|
|||||||
height="14"
|
height="14"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="rgba(255,255,255,0.5)"
|
stroke="rgba(15,23,42,0.6)"
|
||||||
strokeWidth="1.8"
|
strokeWidth="1.8"
|
||||||
>
|
>
|
||||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
@@ -183,10 +179,10 @@ export default function Contact() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[10px] text-white/25 uppercase tracking-wider">
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider">
|
||||||
Email
|
Email
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs font-medium text-white/55 truncate group-hover:text-white/80 transition-colors">
|
<p className="text-xs font-medium text-slate-800 truncate group-hover:text-slate-900 transition-colors">
|
||||||
{SITE.email}
|
{SITE.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,11 +192,10 @@ export default function Contact() {
|
|||||||
href={`${GITEA_URL}/${GITEA_USERNAME}`}
|
href={`${GITEA_URL}/${GITEA_USERNAME}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="glass rounded-xl p-4 flex items-center gap-3 group hover:border-white/[0.14]
|
className="rounded-xl border border-slate-200/80 bg-white/80 p-4 flex items-center gap-3 group transition-all duration-150 hover:border-slate-300 hover:translate-x-[1px]"
|
||||||
hover:translate-x-1 transition-all duration-150"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-lg bg-white/[0.05] flex items-center justify-center flex-shrink-0"
|
className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center flex-shrink-0 border border-slate-200"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -208,7 +203,7 @@ export default function Contact() {
|
|||||||
height="14"
|
height="14"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="rgba(255,255,255,0.5)"
|
stroke="rgba(15,23,42,0.6)"
|
||||||
strokeWidth="1.8"
|
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="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" />
|
||||||
@@ -216,10 +211,10 @@ export default function Contact() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[10px] text-white/25 uppercase tracking-wider">
|
<p className="text-[10px] text-slate-500 uppercase tracking-wider">
|
||||||
Gitea
|
Gitea
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs font-medium text-white/55 truncate group-hover:text-white/80 transition-colors">
|
<p className="text-xs font-medium text-slate-800 truncate group-hover:text-slate-900 transition-colors">
|
||||||
{GITEA_URL.replace(/^https?:\/\//, '')}/{GITEA_USERNAME}
|
{GITEA_URL.replace(/^https?:\/\//, '')}/{GITEA_USERNAME}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,7 +224,7 @@ export default function Contact() {
|
|||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="glass rounded-xl p-6 flex flex-col gap-4 relative"
|
className="glass rounded-2xl p-6 flex flex-col gap-4 relative border border-slate-200/80 bg-white/80"
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -255,7 +250,7 @@ export default function Contact() {
|
|||||||
|
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
<label className="flex flex-col gap-1.5">
|
<label className="flex flex-col gap-1.5">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/30">
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Name
|
Name
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
@@ -264,14 +259,14 @@ export default function Contact() {
|
|||||||
required
|
required
|
||||||
placeholder="Your name"
|
placeholder="Your name"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
className="bg-white/[0.03] border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
|
className="bg-white border border-slate-200 rounded-lg px-3 py-2.5 text-sm
|
||||||
text-white placeholder-white/20 outline-none transition-colors
|
text-slate-900 placeholder-slate-400 outline-none transition-colors
|
||||||
focus:border-white/[0.2] focus:ring-1 focus:ring-white/[0.1]"
|
focus:border-slate-400 focus:ring-1 focus:ring-slate-300"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex flex-col gap-1.5">
|
<label className="flex flex-col gap-1.5">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/30">
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Email
|
Email
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
@@ -280,15 +275,15 @@ export default function Contact() {
|
|||||||
required
|
required
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
className="bg-white/[0.03] border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
|
className="bg-white border border-slate-200 rounded-lg px-3 py-2.5 text-sm
|
||||||
text-white placeholder-white/20 outline-none transition-colors
|
text-slate-900 placeholder-slate-400 outline-none transition-colors
|
||||||
focus:border-white/[0.2] focus:ring-1 focus:ring-white/[0.1]"
|
focus:border-slate-400 focus:ring-1 focus:ring-slate-300"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex flex-col gap-1.5">
|
<label className="flex flex-col gap-1.5">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/30">
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Message
|
Message
|
||||||
</span>
|
</span>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -296,9 +291,9 @@ export default function Contact() {
|
|||||||
required
|
required
|
||||||
placeholder="What’s on your mind?"
|
placeholder="What’s on your mind?"
|
||||||
rows={5}
|
rows={5}
|
||||||
className="bg-white/[0.03] border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
|
className="bg-white border border-slate-200 rounded-lg px-3 py-2.5 text-sm
|
||||||
text-white placeholder-white/20 outline-none resize-none transition-colors
|
text-slate-900 placeholder-slate-400 outline-none resize-none transition-colors
|
||||||
focus:border-white/[0.2] focus:ring-1 focus:ring-white/[0.1]"
|
focus:border-slate-400 focus:ring-1 focus:ring-slate-300"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -306,7 +301,7 @@ export default function Contact() {
|
|||||||
{TURNSTILE_SITE_KEY ? (
|
{TURNSTILE_SITE_KEY ? (
|
||||||
<div ref={turnstileRef} />
|
<div ref={turnstileRef} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-red-300/80">
|
<p className="text-xs text-red-500">
|
||||||
Captcha is not configured correctly.
|
Captcha is not configured correctly.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -326,13 +321,13 @@ export default function Contact() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{status === 'sent' && (
|
{status === 'sent' && (
|
||||||
<p className="text-xs text-white/45">
|
<p className="text-xs text-slate-600">
|
||||||
Thanks — your message has been sent.
|
Thanks — your message has been sent.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'error' && errorMessage && (
|
{status === 'error' && errorMessage && (
|
||||||
<p className="text-xs text-red-300/80">{errorMessage}</p>
|
<p className="text-xs text-red-500">{errorMessage}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,15 +2,23 @@ import { GITEA_URL, GITEA_USERNAME } from '@/lib/config';
|
|||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-white/[0.05] py-8 px-6">
|
<footer className="border-t border-slate-200/80 py-8 px-6">
|
||||||
<div className="max-w-5xl mx-auto flex items-center justify-between flex-wrap gap-4
|
<div
|
||||||
text-xs text-white/20 font-mono">
|
className="max-w-5xl mx-auto flex items-center justify-between flex-wrap gap-4
|
||||||
|
text-xs text-slate-600 font-mono"
|
||||||
|
>
|
||||||
<span>© {new Date().getFullYear()} William March</span>
|
<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">
|
<a
|
||||||
|
href={`${GITEA_URL}/${GITEA_USERNAME}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-slate-900 transition-colors"
|
||||||
|
>
|
||||||
{GITEA_URL.replace(/^https?:\/\//, '')}/{GITEA_USERNAME}
|
{GITEA_URL.replace(/^https?:\/\//, '')}/{GITEA_USERNAME}
|
||||||
</a>
|
</a>
|
||||||
<span>williammarch.xyz</span>
|
|
||||||
|
<span className="text-slate-500">williammarch.xyz</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getRepos } from '@/lib/gitea';
|
import { getRepos } from '@/lib/gitea';
|
||||||
import type { GiteaRepo } from '@/lib/types';
|
import type { GiteaRepo } from '@/lib/types';
|
||||||
import { GITEA_URL, GITEA_USERNAME } from '@/lib/config';
|
import { SITE, GITEA_URL, GITEA_USERNAME } from '@/lib/config';
|
||||||
|
|
||||||
const LANG_COLOR: Record<string, string> = {
|
const LANG_COLOR: Record<string, string> = {
|
||||||
python: '#3572A5',
|
python: '#3572A5',
|
||||||
@@ -12,7 +12,7 @@ const LANG_COLOR: Record<string, string> = {
|
|||||||
go: '#00add8',
|
go: '#00add8',
|
||||||
rust: '#dea584',
|
rust: '#dea584',
|
||||||
'c++': '#f34b7d',
|
'c++': '#f34b7d',
|
||||||
c: '#555',
|
c: '#555555',
|
||||||
html: '#e34c26',
|
html: '#e34c26',
|
||||||
css: '#563d7c',
|
css: '#563d7c',
|
||||||
shell: '#89e051',
|
shell: '#89e051',
|
||||||
@@ -21,7 +21,7 @@ const LANG_COLOR: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function langColor(l: string | null) {
|
function langColor(l: string | null) {
|
||||||
return LANG_COLOR[(l || '').toLowerCase()] || '#555';
|
return LANG_COLOR[(l || '').toLowerCase()] || '#555555';
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeAgo(d: string | undefined | null) {
|
function timeAgo(d: string | undefined | null) {
|
||||||
@@ -41,8 +41,6 @@ function timeAgo(d: string | undefined | null) {
|
|||||||
return `${months}mo ago`;
|
return `${months}mo ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortKey = 'recent' | 'stars';
|
|
||||||
|
|
||||||
export default function Projects() {
|
export default function Projects() {
|
||||||
const [repos, setRepos] = useState<GiteaRepo[] | null>(null);
|
const [repos, setRepos] = useState<GiteaRepo[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -52,17 +50,12 @@ export default function Projects() {
|
|||||||
const [readmeLoading, setReadmeLoading] = useState(false);
|
const [readmeLoading, setReadmeLoading] = useState(false);
|
||||||
const [readmeError, setReadmeError] = useState<string | null>(null);
|
const [readmeError, setReadmeError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState<SortKey>('recent');
|
|
||||||
const [languageFilter, setLanguageFilter] = useState<string>('all');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Get all repos (or a high limit) instead of SITE.repoLimit
|
getRepos(SITE.repoLimit)
|
||||||
getRepos(100)
|
|
||||||
.then(setRepos)
|
.then(setRepos)
|
||||||
.catch(e => setError(e instanceof Error ? e.message : String(e)));
|
.catch((e) => setError(e instanceof Error ? e.message : String(e)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load README when a repo is opened
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeRepo) return;
|
if (!activeRepo) return;
|
||||||
setReadmeLoading(true);
|
setReadmeLoading(true);
|
||||||
@@ -70,12 +63,12 @@ export default function Projects() {
|
|||||||
setReadmeHtml(null);
|
setReadmeHtml(null);
|
||||||
|
|
||||||
fetch(`/api/gitea/readme?repo=${encodeURIComponent(activeRepo.name)}`)
|
fetch(`/api/gitea/readme?repo=${encodeURIComponent(activeRepo.name)}`)
|
||||||
.then(res => {
|
.then((res) => {
|
||||||
if (!res.ok) throw new Error(`README HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`README HTTP ${res.status}`);
|
||||||
return res.text();
|
return res.text();
|
||||||
})
|
})
|
||||||
.then(html => setReadmeHtml(html))
|
.then((html) => setReadmeHtml(html))
|
||||||
.catch(e =>
|
.catch((e) =>
|
||||||
setReadmeError(
|
setReadmeError(
|
||||||
e instanceof Error ? e.message : 'Could not load README',
|
e instanceof Error ? e.message : 'Could not load README',
|
||||||
),
|
),
|
||||||
@@ -83,259 +76,178 @@ export default function Projects() {
|
|||||||
.finally(() => setReadmeLoading(false));
|
.finally(() => setReadmeLoading(false));
|
||||||
}, [activeRepo]);
|
}, [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 (
|
return (
|
||||||
<section
|
<section
|
||||||
id="projects"
|
id="projects"
|
||||||
className="py-24 px-6"
|
className="py-24 px-6"
|
||||||
aria-labelledby="projects-heading"
|
aria-labelledby="projects-heading"
|
||||||
>
|
>
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto min-w-0">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
<div className="flex items-end justify-between mb-8 gap-4">
|
||||||
<div>
|
<h2
|
||||||
<h2
|
id="projects-heading"
|
||||||
id="projects-heading"
|
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-slate-900"
|
||||||
className="text-[clamp(1.8rem,4vw,2.5rem)] font-black tracking-tight text-white"
|
>
|
||||||
|
</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"
|
||||||
>
|
>
|
||||||
Projects
|
<path d="M7 17L17 7M7 7h10v10" />
|
||||||
</h2>
|
</svg>
|
||||||
<p className="text-xs text-white/40 mt-1">
|
</a>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Loading */}
|
<div className="glass rounded-[28px] p-5 sm:p-7 md:p-8">
|
||||||
{!filteredRepos && !error && (
|
{!repos && !error && (
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div key={i} className="glass rounded-2xl p-5 flex flex-col gap-3">
|
<div
|
||||||
<div className="skel h-4 w-1/2" />
|
key={i}
|
||||||
<div className="skel h-3 w-5/6" />
|
className="rounded-2xl border border-slate-200/80 bg-white/70 p-5 flex flex-col gap-3"
|
||||||
<div className="skel h-3 w-3/4" />
|
|
||||||
<div className="skel h-3 w-1/3 mt-auto" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</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">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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="skel h-4 w-1/2" />
|
||||||
<div
|
<div className="skel h-3 w-5/6" />
|
||||||
className="p-1.5 rounded-md bg-white/[0.04] border border-white/[0.07]"
|
<div className="skel h-3 w-3/4" />
|
||||||
aria-hidden="true"
|
<div className="skel h-3 w-1/3 mt-auto" />
|
||||||
>
|
</div>
|
||||||
<svg
|
))}
|
||||||
width="14"
|
</div>
|
||||||
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>
|
{error && (
|
||||||
<p className="text-sm font-semibold text-white mb-1 truncate">
|
<div className="rounded-2xl border border-slate-200/80 bg-white/80 p-10 text-center">
|
||||||
{repo.name}
|
<p className="text-slate-600 text-sm mb-1">
|
||||||
</p>
|
Could not load repositories
|
||||||
<p className="text-xs text-white/35 leading-relaxed line-clamp-2">
|
</p>
|
||||||
{repo.description ? (
|
<p className="text-slate-400 text-xs font-mono">{error}</p>
|
||||||
repo.description
|
</div>
|
||||||
) : (
|
)}
|
||||||
<span className="italic text-white/20">
|
|
||||||
No description
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mt-auto">
|
{repos && (
|
||||||
{repo.language && (
|
<div
|
||||||
<span className="flex items-center gap-1.5 text-xs text-white/30">
|
className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||||
<span
|
aria-live="polite"
|
||||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
>
|
||||||
style={{ background: langColor(repo.language) }}
|
{repos.map((repo) => {
|
||||||
aria-hidden="true"
|
const updated = repo.updated || (repo as any).updated_at;
|
||||||
/>
|
|
||||||
{repo.language}
|
return (
|
||||||
</span>
|
<button
|
||||||
)}
|
key={repo.id}
|
||||||
<span
|
type="button"
|
||||||
className="flex items-center gap-1 text-xs text-white/25"
|
onClick={() => setActiveRepo(repo)}
|
||||||
aria-label={`${repo.stars_count} stars`}
|
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'}`}
|
||||||
<svg
|
>
|
||||||
width="10"
|
<div className="flex items-start justify-between gap-2">
|
||||||
height="10"
|
<div
|
||||||
viewBox="0 0 24 24"
|
className="p-1.5 rounded-md bg-slate-50 border border-slate-200"
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
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
|
||||||
</svg>
|
width="14"
|
||||||
{repo.stars_count}
|
height="14"
|
||||||
</span>
|
viewBox="0 0 24 24"
|
||||||
<span className="text-xs text-white/20 ml-auto tabular-nums">
|
fill="none"
|
||||||
{timeAgo(updated)}
|
stroke="rgba(15,23,42,0.5)"
|
||||||
</span>
|
strokeWidth="1.8"
|
||||||
</div>
|
>
|
||||||
</button>
|
<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>
|
||||||
)}
|
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* Popout with README + Live + Wiki (unchanged) */}
|
|
||||||
{activeRepo && (
|
{activeRepo && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 px-4"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={`${activeRepo.name} details`}
|
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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -343,7 +255,7 @@ export default function Projects() {
|
|||||||
setReadmeHtml(null);
|
setReadmeHtml(null);
|
||||||
setReadmeError(null);
|
setReadmeError(null);
|
||||||
}}
|
}}
|
||||||
className="absolute top-3 right-3 text-white/40 hover:text-white/80"
|
className="absolute top-3 right-3 text-slate-500 hover:text-slate-900"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -359,62 +271,67 @@ export default function Projects() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<header className="mb-3 pr-8">
|
<header className="mb-3 pr-8">
|
||||||
<h3 className="text-sm font-semibold text-white mb-1">
|
<h3 className="text-sm font-semibold text-slate-900 mb-1">
|
||||||
{activeRepo.name}
|
{activeRepo.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-white/40">
|
<p className="text-xs text-slate-600">
|
||||||
{activeRepo.description ||
|
{activeRepo.description ||
|
||||||
'This project does not have a description yet.'}
|
'This project does not have a description yet.'}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mb-4 text-xs">
|
<div className="flex items-center gap-2 mb-4 text-xs flex-wrap">
|
||||||
<a
|
<a
|
||||||
href={activeRepo.html_url}
|
href={activeRepo.html_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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
|
View code
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{activeRepo.website &&
|
{activeRepo.website &&
|
||||||
activeRepo.website.trim().length > 0 && (
|
activeRepo.website.trim().length > 0 && (
|
||||||
<a
|
<a
|
||||||
href={activeRepo.website}
|
href={activeRepo.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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
|
View live
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={`https://git.williammarch.xyz/${GITEA_USERNAME}/${activeRepo.name}/wiki`}
|
href={`https://git.williammarch.xyz/${GITEA_USERNAME}/${activeRepo.name}/wiki`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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
|
Wiki
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto pr-1 text-xs leading-relaxed text-white/70">
|
<div className="flex-1 overflow-y-auto pr-1 text-xs leading-relaxed text-slate-800">
|
||||||
{readmeLoading && (
|
{readmeLoading && (
|
||||||
<p className="text-white/40">Loading README…</p>
|
<p className="text-slate-500">Loading README…</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{readmeError && (
|
{readmeError && (
|
||||||
<p className="text-white/40">
|
<p className="text-slate-500">
|
||||||
Could not load README: {readmeError}
|
Could not load README: {readmeError}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{readmeHtml && (
|
{readmeHtml && (
|
||||||
<div
|
<div
|
||||||
className="prose prose-invert prose-sm max-w-none"
|
className="prose prose-sm max-w-none text-slate-900"
|
||||||
dangerouslySetInnerHTML={{ __html: readmeHtml }}
|
dangerouslySetInnerHTML={{ __html: readmeHtml }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!readmeLoading && !readmeError && !readmeHtml && (
|
{!readmeLoading && !readmeError && !readmeHtml && (
|
||||||
<p className="text-white/40">
|
<p className="text-slate-500">
|
||||||
No README content available for this repository.
|
No README content available for this repository.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
BIN
public/android-chrome-192x192.png
Normal file
BIN
public/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
BIN
public/android-chrome-512x512.png
Normal file
BIN
public/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/favicon-16x16.png
Normal file
BIN
public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 362 B |
BIN
public/favicon-32x32.png
Normal file
BIN
public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 660 B |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1
public/site.webmanifest
Normal file
1
public/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
Reference in New Issue
Block a user