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:
35
ui/src/components/LanguageBar.tsx
Normal file
35
ui/src/components/LanguageBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -73,7 +74,7 @@ export function ProjectPage() {
|
|||||||
<Row className="mb-3">
|
<Row className="mb-3">
|
||||||
<Col>
|
<Col>
|
||||||
<h2><a href={repoUrl(source ?? '', host, repo)} target="_blank" rel="noopener noreferrer"><img src={forgeIcon(source ?? '')} alt={source} className="forge-icon" style={{ width: 24, height: 24 }} /></a>{repo}</h2>
|
<h2><a href={repoUrl(source ?? '', host, repo)} target="_blank" rel="noopener noreferrer"><img src={forgeIcon(source ?? '')} alt={source} className="forge-icon" style={{ width: 24, height: 24 }} /></a>{repo}</h2>
|
||||||
{langs && <LanguageBar languages={langs} colorMap={langColors} />}
|
{langs && <LanguageBar languages={langs} colorMap={langColors} />}
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user