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;
|
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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,10 +32,33 @@ 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}`}>
|
||||||
|
<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">
|
<Link to={`/project/${p.source}/${p.repo}`} className="text-decoration-none">
|
||||||
<div className="project-card p-3">
|
<div className="project-card p-3">
|
||||||
<h5 className="mb-1">{p.repo}</h5>
|
<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' }}>
|
<div className="d-flex gap-3" style={{ fontSize: '0.8rem' }}>
|
||||||
{p.commit_count > 0 && <span>{p.commit_count} commits</span>}
|
{p.commit_count > 0 && <span>{p.commit_count} commits</span>}
|
||||||
{p.issue_count > 0 && <span>{p.issue_count} issues</span>}
|
{p.issue_count > 0 && <span>{p.issue_count} issues</span>}
|
||||||
@@ -46,13 +69,17 @@ export function DashPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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 {
|
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();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user