feat(ui): language distribution bar on project cards

Extract LanguageBar into a shared component used by both DashPage
(compact, bar only) and ProjectPage (full, with percentage labels).
Remove redundant forge source text from project cards since the
forge icon already indicates it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 07:13:41 +03:00
parent 6f30a61184
commit 111a2af573
3 changed files with 49 additions and 46 deletions

View File

@@ -0,0 +1,35 @@
export function LanguageBar({ languages, colorMap, compact }: {
languages: Record<string, number>;
colorMap: Record<string, string>;
compact?: boolean;
}) {
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={compact ? 'mb-1' : 'mt-2'}>
<div className="language-bar">
{sorted.map(([lang, bytes]) => (
<div
key={lang}
className="language-bar-segment"
style={{ width: `${(bytes / total) * 100}%`, backgroundColor: colorMap[lang] ?? '#8b8b8b' }}
title={`${lang} ${((bytes / total) * 100).toFixed(1)}%`}
/>
))}
</div>
{!compact && (
<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: colorMap[lang] ?? '#8b8b8b' }} />
{lang} {((bytes / total) * 100).toFixed(1)}%
</span>
))}
</div>
)}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import Col from 'react-bootstrap/Col';
import Row from 'react-bootstrap/Row'; import Row from 'react-bootstrap/Row';
import { fetchProjects, fetchRepoLanguages, type ProjectSummary } from '../api/client'; import { fetchProjects, fetchRepoLanguages, type ProjectSummary } from '../api/client';
import { LanguageBar } from '../components/LanguageBar';
import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph'; import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph';
import { LanguageStreamGraph } from '../components/LanguageStreamGraph'; import { LanguageStreamGraph } from '../components/LanguageStreamGraph';
@@ -31,6 +32,14 @@ export function DashPage() {
return map; return map;
}, [langsQ.data]); }, [langsQ.data]);
const langColors = useMemo(() => {
const map: Record<string, string> = {};
for (const e of langsQ.data ?? []) {
if (e.color && !map[e.language]) map[e.language] = e.color;
}
return map;
}, [langsQ.data]);
const projects = projectsQ.data ?? []; const projects = projectsQ.data ?? [];
const ranked = rankProjects(projects); const ranked = rankProjects(projects);
@@ -54,7 +63,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}`}>
<ProjectCard project={p} langs={langsByRepo.get(`${p.source}:${p.repo}`) ?? null} /> <ProjectCard project={p} langs={langsByRepo.get(`${p.source}:${p.repo}`) ?? null} colorMap={langColors} />
</Col> </Col>
))} ))}
</Row> </Row>
@@ -62,17 +71,12 @@ export function DashPage() {
); );
} }
function ProjectCard({ project: p, langs }: { project: ProjectSummary; langs: Record<string, number> | null }) { function ProjectCard({ project: p, langs, colorMap }: { project: ProjectSummary; langs: Record<string, number> | null; colorMap: Record<string, string> }) {
const topLangs = langs ? topLanguages(langs, 3) : null;
return ( 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"><img src={forgeIcon(p.source)} alt={p.source} className="forge-icon" />{p.repo}</h5> <h5 className="mb-1"><img src={forgeIcon(p.source)} alt={p.source} className="forge-icon" />{p.repo}</h5>
<small className="text-muted d-block mb-2"> {langs && <LanguageBar languages={langs} colorMap={colorMap} compact />}
{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>}
@@ -95,14 +99,6 @@ function forgeIcon(source: string): string {
} }
} }
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

@@ -7,6 +7,7 @@ import ReactMarkdown from 'react-markdown';
import { VerticalTimeline } from 'react-vertical-timeline-component'; import { VerticalTimeline } from 'react-vertical-timeline-component';
import { fetchEvents, fetchReadme, fetchRepoLanguages, fetchProjects, type Source } from '../api/client'; import { fetchEvents, fetchReadme, fetchRepoLanguages, fetchProjects, type Source } from '../api/client';
import { LanguageBar } from '../components/LanguageBar';
import { TimelineEntry } from '../components/TimelineEntry'; import { TimelineEntry } from '../components/TimelineEntry';
export function ProjectPage() { export function ProjectPage() {
@@ -125,32 +126,3 @@ function forgeIcon(source: string): string {
} }
} }
function LanguageBar({ languages, colorMap }: { languages: Record<string, number>; colorMap: Record<string, string> }) {
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: colorMap[lang] ?? '#8b8b8b' }}
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: colorMap[lang] ?? '#8b8b8b' }} />
{lang} {((bytes / total) * 100).toFixed(1)}%
</span>
))}
</div>
</div>
);
}