Files
moments/ui/src/pages/ProjectPage.tsx
rob thijssen ba216580ea 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>
2026-05-05 15:28:15 +03:00

145 lines
4.0 KiB
TypeScript

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, 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: () =>
fetchEvents({
sources: source ? [source as Source] : undefined,
repo,
limit: 500,
}),
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 (
<>
<Row className="mb-3">
<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%' }}>
{eventsQ.isLoading
? 'loading...'
: eventsQ.isError
? `error: ${(eventsQ.error as Error).message}`
: `${events.length} ${events.length === 1 ? 'activity' : 'activities'}`}
</p>
<VerticalTimeline>
{events.map((item) => (
<TimelineEntry key={item.id} item={item} />
))}
</VerticalTimeline>
</Col>
</Row>
</>
);
}
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';
}