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:
@@ -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;
|
||||
|
||||
@@ -101,3 +101,33 @@ export async function fetchProjects(): Promise<ProjectSummary[]> {
|
||||
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<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();
|
||||
}
|
||||
|
||||
@@ -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,10 +32,33 @@ export function DashPage() {
|
||||
<Row xs={1} md={2} lg={3} className="g-3">
|
||||
{ranked.map((p) => (
|
||||
<Col key={`${p.source}:${p.repo}`}>
|
||||
<ProjectCard project={p} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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}</small>
|
||||
<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>}
|
||||
@@ -46,13 +69,17 @@ export function DashPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
const fmt = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }).toLowerCase();
|
||||
|
||||
@@ -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() {
|
||||
<Col>
|
||||
<h2>{repo}</h2>
|
||||
<small className="text-muted">{source}</small>
|
||||
{langs && <LanguageBar languages={langs} />}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{readmeQ.data && (
|
||||
<Row className="mb-4">
|
||||
<Col>
|
||||
<div className="project-readme">
|
||||
<ReactMarkdown>{readmeQ.data}</ReactMarkdown>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
<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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user