diff --git a/ui/src/App.css b/ui/src/App.css index 4ee7f9b..7609ec5 100644 --- a/ui/src/App.css +++ b/ui/src/App.css @@ -97,6 +97,62 @@ a.hot-pink { font-size: 0.7rem; } +.project-card h5, +.project-card .text-muted, +.project-card span { + color: #ecf0f1; +} + +.language-bar { + display: flex; + height: 6px; + border-radius: 3px; + overflow: hidden; +} + +.language-bar-segment { + min-width: 2px; +} + +.language-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 4px; +} + +.project-readme { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + padding: 1.5rem; + font-size: 0.85rem; + line-height: 1.5; +} + +.project-readme h1, +.project-readme h2, +.project-readme h3 { + font-size: 1.1rem; + margin-top: 1rem; +} + +.project-readme pre { + background: rgba(0, 0, 0, 0.2); + padding: 0.75rem; + border-radius: 4px; + overflow-x: auto; +} + +.project-readme code { + font-size: 0.8rem; +} + +.project-readme img { + max-width: 100%; +} + .site-footer { margin-top: 3rem; padding: 1rem 0; diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index e5bc98f..ce4283a 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -101,3 +101,33 @@ export async function fetchProjects(): Promise { if (!resp.ok) throw new Error(`projects: HTTP ${resp.status}`); return resp.json(); } + +/** Fetch repo README as rendered HTML or raw markdown. */ +export async function fetchReadme(source: Source, host: string, repo: string): Promise { + const baseUrl = source === 'github' + ? `https://api.github.com/repos/${repo}/readme` + : source === 'gitea' + ? `https://${host}/api/v1/repos/${repo}/readme` + : null; + if (!baseUrl) return null; + + const resp = await fetch(baseUrl, { + headers: { 'Accept': 'application/vnd.github.raw+json' }, + }); + if (!resp.ok) return null; + return resp.text(); +} + +/** Fetch repo languages as { language: bytes } map. */ +export async function fetchLanguages(source: Source, host: string, repo: string): Promise | null> { + const baseUrl = source === 'github' + ? `https://api.github.com/repos/${repo}/languages` + : source === 'gitea' + ? `https://${host}/api/v1/repos/${repo}/languages` + : null; + if (!baseUrl) return null; + + const resp = await fetch(baseUrl); + if (!resp.ok) return null; + return resp.json(); +} diff --git a/ui/src/pages/DashPage.tsx b/ui/src/pages/DashPage.tsx index f44138f..58337db 100644 --- a/ui/src/pages/DashPage.tsx +++ b/ui/src/pages/DashPage.tsx @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'; import Col from 'react-bootstrap/Col'; import Row from 'react-bootstrap/Row'; -import { fetchProjects, type ProjectSummary } from '../api/client'; +import { fetchProjects, fetchLanguages, type ProjectSummary, type Source } from '../api/client'; export function DashPage() { const projectsQ = useQuery({ @@ -32,20 +32,7 @@ export function DashPage() { {ranked.map((p) => ( - -
-
{p.repo}
- {p.source} -
- {p.commit_count > 0 && {p.commit_count} commits} - {p.issue_count > 0 && {p.issue_count} issues} - {p.pr_count > 0 && {p.pr_count} prs} -
-
- {formatRange(p.first_activity, p.last_activity)} -
-
- + ))}
@@ -53,6 +40,46 @@ export function DashPage() { ); } +function ProjectCard({ project: p }: { project: ProjectSummary }) { + const langsQ = useQuery({ + queryKey: ['languages', p.source, p.host, p.repo], + queryFn: () => fetchLanguages(p.source as Source, p.host, p.repo), + enabled: p.source === 'github' || p.source === 'gitea', + staleTime: 10 * 60_000, + }); + + const langs = langsQ.data; + const topLangs = langs ? topLanguages(langs, 3) : null; + + return ( + +
+
{p.repo}
+ + {p.source} + {topLangs && ` ยท ${topLangs}`} + +
+ {p.commit_count > 0 && {p.commit_count} commits} + {p.issue_count > 0 && {p.issue_count} issues} + {p.pr_count > 0 && {p.pr_count} prs} +
+
+ {formatRange(p.first_activity, p.last_activity)} +
+
+ + ); +} + +function topLanguages(langs: Record, n: number): string { + return Object.entries(langs) + .sort(([, a], [, b]) => b - a) + .slice(0, n) + .map(([lang]) => lang.toLowerCase()) + .join(', '); +} + function formatRange(first: string | null, last: string | null): string { const fmt = (iso: string) => new Date(iso).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toLowerCase(); diff --git a/ui/src/pages/ProjectPage.tsx b/ui/src/pages/ProjectPage.tsx index e9406bd..756fb97 100644 --- a/ui/src/pages/ProjectPage.tsx +++ b/ui/src/pages/ProjectPage.tsx @@ -2,15 +2,26 @@ import { useParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import Col from 'react-bootstrap/Col'; import Row from 'react-bootstrap/Row'; +import ReactMarkdown from 'react-markdown'; import { VerticalTimeline } from 'react-vertical-timeline-component'; -import { fetchEvents, type Source } from '../api/client'; +import { fetchEvents, fetchReadme, fetchLanguages, fetchProjects, type Source } from '../api/client'; import { TimelineEntry } from '../components/TimelineEntry'; export function ProjectPage() { const { source, '*': repoPath } = useParams(); const repo = repoPath ?? ''; + const projectsQ = useQuery({ + queryKey: ['projects'], + queryFn: fetchProjects, + staleTime: 60_000, + }); + const project = projectsQ.data?.find( + (p) => p.source === source && p.repo === repo, + ); + const host = project?.host ?? ''; + const eventsQ = useQuery({ queryKey: ['project-events', source, repo], queryFn: () => @@ -22,7 +33,22 @@ export function ProjectPage() { refetchInterval: 60_000, }); + const readmeQ = useQuery({ + queryKey: ['readme', source, host, repo], + queryFn: () => fetchReadme(source as Source, host, repo), + enabled: !!host && (source === 'github' || source === 'gitea'), + staleTime: 5 * 60_000, + }); + + const langsQ = useQuery({ + queryKey: ['languages', source, host, repo], + queryFn: () => fetchLanguages(source as Source, host, repo), + enabled: !!host && (source === 'github' || source === 'gitea'), + staleTime: 5 * 60_000, + }); + const events = eventsQ.data ?? []; + const langs = langsQ.data; return ( <> @@ -30,8 +56,20 @@ export function ProjectPage() {

{repo}

{source} + {langs && } + + {readmeQ.data && ( + + +
+ {readmeQ.data} +
+ +
+ )} +

@@ -51,3 +89,56 @@ export function ProjectPage() { ); } + +function LanguageBar({ languages }: { languages: Record }) { + const total = Object.values(languages).reduce((a, b) => a + b, 0); + if (total === 0) return null; + + const sorted = Object.entries(languages).sort(([, a], [, b]) => b - a); + + return ( +

+
+ {sorted.map(([lang, bytes]) => ( +
+ ))} +
+
+ {sorted.slice(0, 8).map(([lang, bytes]) => ( + + + {lang} {((bytes / total) * 100).toFixed(1)}% + + ))} +
+
+ ); +} + +const LANG_COLORS: Record = { + Rust: '#dea584', + TypeScript: '#3178c6', + JavaScript: '#f1e05a', + Python: '#3572a5', + Go: '#00add8', + Shell: '#89e051', + HTML: '#e34c26', + CSS: '#563d7c', + C: '#555555', + 'C++': '#f34b7d', + Java: '#b07219', + Ruby: '#701516', + Nix: '#7e7eff', + Makefile: '#427819', + Dockerfile: '#384d54', + SCSS: '#c6538c', +}; + +function langColor(lang: string): string { + return LANG_COLORS[lang] ?? '#8b8b8b'; +}