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 { fetchProjects, fetchRepoLanguages, type ProjectSummary } from '../api/client';
|
||||
import { LanguageBar } from '../components/LanguageBar';
|
||||
import { ContributionGraph, AllTimeGraph } from '../components/ContributionGraph';
|
||||
import { LanguageStreamGraph } from '../components/LanguageStreamGraph';
|
||||
|
||||
@@ -31,6 +32,14 @@ export function DashPage() {
|
||||
return map;
|
||||
}, [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 ranked = rankProjects(projects);
|
||||
|
||||
@@ -54,7 +63,7 @@ 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} langs={langsByRepo.get(`${p.source}:${p.repo}`) ?? null} />
|
||||
<ProjectCard project={p} langs={langsByRepo.get(`${p.source}:${p.repo}`) ?? null} colorMap={langColors} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
@@ -62,17 +71,12 @@ export function DashPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCard({ project: p, langs }: { project: ProjectSummary; langs: Record<string, number> | null }) {
|
||||
const topLangs = langs ? topLanguages(langs, 3) : null;
|
||||
|
||||
function ProjectCard({ project: p, langs, colorMap }: { project: ProjectSummary; langs: Record<string, number> | null; colorMap: Record<string, string> }) {
|
||||
return (
|
||||
<Link to={`/project/${p.source}/${p.repo}`} className="text-decoration-none">
|
||||
<div className="project-card p-3">
|
||||
<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">
|
||||
{p.source}
|
||||
{topLangs && ` · ${topLangs}`}
|
||||
</small>
|
||||
{langs && <LanguageBar languages={langs} colorMap={colorMap} compact />}
|
||||
<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>}
|
||||
@@ -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 {
|
||||
const fmt = (iso: string) =>
|
||||
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 { fetchEvents, fetchReadme, fetchRepoLanguages, fetchProjects, type Source } from '../api/client';
|
||||
import { LanguageBar } from '../components/LanguageBar';
|
||||
import { TimelineEntry } from '../components/TimelineEntry';
|
||||
|
||||
export function ProjectPage() {
|
||||
@@ -73,7 +74,7 @@ export function ProjectPage() {
|
||||
<Row className="mb-3">
|
||||
<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>
|
||||
{langs && <LanguageBar languages={langs} colorMap={langColors} />}
|
||||
{langs && <LanguageBar languages={langs} colorMap={langColors} />}
|
||||
</Col>
|
||||
</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