feat(ui): project readme, language bars, and per-card language summary

ProjectPage fetches README (raw markdown) and language breakdown from
GitHub/Gitea REST APIs, rendering the readme as markdown and languages
as a colored proportional bar with labels.

Dashboard cards lazily fetch top 3 languages per repo and display them
inline. Language color map covers common languages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 15:28:15 +03:00
parent 80f3f7c5cb
commit ba216580ea
4 changed files with 220 additions and 16 deletions

View File

@@ -97,6 +97,62 @@ a.hot-pink {
font-size: 0.7rem; 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 { .site-footer {
margin-top: 3rem; margin-top: 3rem;
padding: 1rem 0; padding: 1rem 0;

View File

@@ -101,3 +101,33 @@ export async function fetchProjects(): Promise<ProjectSummary[]> {
if (!resp.ok) throw new Error(`projects: HTTP ${resp.status}`); if (!resp.ok) throw new Error(`projects: HTTP ${resp.status}`);
return resp.json(); return resp.json();
} }
/** Fetch repo README as rendered HTML or raw markdown. */
export async function fetchReadme(source: Source, host: string, repo: string): Promise<string | null> {
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<Record<string, number> | 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();
}

View File

@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
import Col from 'react-bootstrap/Col'; import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row'; 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() { export function DashPage() {
const projectsQ = useQuery({ const projectsQ = useQuery({
@@ -32,20 +32,7 @@ export function DashPage() {
<Row xs={1} md={2} lg={3} className="g-3"> <Row xs={1} md={2} lg={3} className="g-3">
{ranked.map((p) => ( {ranked.map((p) => (
<Col key={`${p.source}:${p.repo}`}> <Col key={`${p.source}:${p.repo}`}>
<Link to={`/project/${p.source}/${p.repo}`} className="text-decoration-none"> <ProjectCard project={p} />
<div className="project-card p-3">
<h5 className="mb-1">{p.repo}</h5>
<small className="text-muted d-block mb-2">{p.source}</small>
<div className="d-flex gap-3" style={{ fontSize: '0.8rem' }}>
{p.commit_count > 0 && <span>{p.commit_count} commits</span>}
{p.issue_count > 0 && <span>{p.issue_count} issues</span>}
{p.pr_count > 0 && <span>{p.pr_count} prs</span>}
</div>
<div style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '0.25rem' }}>
{formatRange(p.first_activity, p.last_activity)}
</div>
</div>
</Link>
</Col> </Col>
))} ))}
</Row> </Row>
@@ -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 (
<Link to={`/project/${p.source}/${p.repo}`} className="text-decoration-none">
<div className="project-card p-3">
<h5 className="mb-1">{p.repo}</h5>
<small className="text-muted d-block mb-2">
{p.source}
{topLangs && ` · ${topLangs}`}
</small>
<div className="d-flex gap-3" style={{ fontSize: '0.8rem' }}>
{p.commit_count > 0 && <span>{p.commit_count} commits</span>}
{p.issue_count > 0 && <span>{p.issue_count} issues</span>}
{p.pr_count > 0 && <span>{p.pr_count} prs</span>}
</div>
<div style={{ fontSize: '0.75rem', opacity: 0.6, marginTop: '0.25rem' }}>
{formatRange(p.first_activity, p.last_activity)}
</div>
</div>
</Link>
);
}
function topLanguages(langs: Record<string, number>, 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 { function formatRange(first: string | null, last: string | null): string {
const fmt = (iso: string) => const fmt = (iso: string) =>
new Date(iso).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toLowerCase(); new Date(iso).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toLowerCase();

View File

@@ -2,15 +2,26 @@ import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import Col from 'react-bootstrap/Col'; import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row'; import Row from 'react-bootstrap/Row';
import ReactMarkdown from 'react-markdown';
import { VerticalTimeline } from 'react-vertical-timeline-component'; 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'; import { TimelineEntry } from '../components/TimelineEntry';
export function ProjectPage() { export function ProjectPage() {
const { source, '*': repoPath } = useParams(); const { source, '*': repoPath } = useParams();
const repo = repoPath ?? ''; 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({ const eventsQ = useQuery({
queryKey: ['project-events', source, repo], queryKey: ['project-events', source, repo],
queryFn: () => queryFn: () =>
@@ -22,7 +33,22 @@ export function ProjectPage() {
refetchInterval: 60_000, 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 events = eventsQ.data ?? [];
const langs = langsQ.data;
return ( return (
<> <>
@@ -30,8 +56,20 @@ export function ProjectPage() {
<Col> <Col>
<h2>{repo}</h2> <h2>{repo}</h2>
<small className="text-muted">{source}</small> <small className="text-muted">{source}</small>
{langs && <LanguageBar languages={langs} />}
</Col> </Col>
</Row> </Row>
{readmeQ.data && (
<Row className="mb-4">
<Col>
<div className="project-readme">
<ReactMarkdown>{readmeQ.data}</ReactMarkdown>
</div>
</Col>
</Row>
)}
<Row> <Row>
<Col> <Col>
<p style={{ fontSize: '85%' }}> <p style={{ fontSize: '85%' }}>
@@ -51,3 +89,56 @@ export function ProjectPage() {
</> </>
); );
} }
function LanguageBar({ languages }: { languages: Record<string, number> }) {
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 (
<div className="mt-2">
<div className="language-bar">
{sorted.map(([lang, bytes]) => (
<div
key={lang}
className="language-bar-segment"
style={{ width: `${(bytes / total) * 100}%`, backgroundColor: langColor(lang) }}
title={`${lang} ${((bytes / total) * 100).toFixed(1)}%`}
/>
))}
</div>
<div className="d-flex flex-wrap gap-2 mt-1" style={{ fontSize: '0.75rem' }}>
{sorted.slice(0, 8).map(([lang, bytes]) => (
<span key={lang}>
<span className="language-dot" style={{ backgroundColor: langColor(lang) }} />
{lang} {((bytes / total) * 100).toFixed(1)}%
</span>
))}
</div>
</div>
);
}
const LANG_COLORS: Record<string, string> = {
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';
}