gitdataai/src/page/workspace/channel/repo-embed-card.tsx

190 lines
6.8 KiB
TypeScript

import { useEffect, useState } from "react";
import {
GitFork,
Loader2,
Star,
Clock,
Lock,
Globe,
} from "lucide-react";
import { api } from "@/client";
import RepoDrawer from "./repo-drawer";
import type { RepoLinkMatch } from "./repo-link-parser";
type RepoEmbedData = {
name: string;
description: string | null;
default_branch: string;
visibility: string;
updated_at: string;
language: string | null;
star_count: number;
fork_count: number;
topics: string[];
};
function languageColor(lang: string): string {
const map: Record<string, string> = {
Rust: "#DEA584", TypeScript: "#3178C6", JavaScript: "#F7DF1E",
Python: "#3572A5", Go: "#00ADD8", Java: "#B07219",
Kotlin: "#A97BFF", Swift: "#F05138", C: "#555555",
"C++": "#F34B7D", "C#": "#178600", Ruby: "#701516",
Zig: "#EC915C", Elixir: "#6E4A7E", Haskell: "#5E5086",
CSS: "#563D7C", HTML: "#E34C26", Shell: "#89E051",
PHP: "#4F5D95", Dart: "#00B4AB", Scala: "#C22D40",
R: "#198CE7", Lua: "#000080", Vue: "#41B883",
Svelte: "#FF3E00", MDX: "#FCB32C", Dockerfile: "#384D54",
Makefile: "#427819", Markdown: "#083FA1", Nix: "#7E7EFF",
OCaml: "#3BE133", "Objective-C": "#438EFF", Perl: "#0298C3",
Erlang: "#B83998", CMake: "#DA3434", PowerShell: "#012456",
SQL: "#E38C00", Solidity: "#AA6746", Terraform: "#7B42BC",
};
return map[lang] ?? "#6B7280";
}
function formatCount(n: number): string {
if (n === 0) return "";
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return n.toString();
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const days = Math.floor(diff / 86400000);
if (days < 1) return "today";
if (days === 1) return "yesterday";
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(months / 12)}y ago`;
}
export default function RepoEmbedCard({ link }: { link: RepoLinkMatch }) {
const [data, setData] = useState<RepoEmbedData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(false);
api
.get<RepoEmbedData>(
`/api/v1/workspace/${link.workspace}/repos/${link.repo}/embed-card`,
)
.then((res) => {
if (!cancelled) setData(res.data);
})
.catch(() => {
if (!cancelled) setError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [link.workspace, link.repo]);
return (
<RepoDrawer repo={link.repo} workspace={link.workspace}>
<div
className="mt-2 block max-w-[420px] cursor-pointer rounded-xl border border-border/30 bg-muted/[0.03] p-4 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200 hover:border-primary/20 hover:bg-muted/[0.08] hover:shadow-sm"
>
{loading ? (
<div className="flex items-center gap-2 py-2 text-[13px] text-muted-foreground/50">
<Loader2 className="size-4 animate-spin" />
Loading repo info
</div>
) : error || !data ? (
<div className="flex items-center gap-2 py-2">
<div className="grid size-8 shrink-0 place-items-center rounded-lg bg-muted/40">
<div className="grid size-7 place-items-center rounded-md bg-gradient-to-br from-primary/30 to-primary/10 text-[10px] font-bold text-primary/70">
{link.repo.charAt(0).toUpperCase()}
</div>
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold text-foreground/70">
{link.workspace}/
<span className="text-primary/80">{link.repo}</span>
</p>
</div>
</div>
) : (
<>
<div className="flex items-start gap-3">
<div className="grid size-8 shrink-0 place-items-center rounded-lg bg-muted/40">
<div className="grid size-7 place-items-center rounded-md bg-gradient-to-br from-primary/30 to-primary/10 text-[10px] font-bold text-primary/70">
{data.name.charAt(0).toUpperCase()}
</div>
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold text-foreground/90">
{link.workspace}/
<span className="text-primary/80">{link.repo}</span>
</p>
{data.description && (
<p className="mt-0.5 line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
{data.description}
</p>
)}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground/50">
{data.language && (
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block size-2.5 rounded-full"
style={{ backgroundColor: languageColor(data.language) }}
/>
{data.language}
</span>
)}
{data.star_count > 0 && (
<span className="inline-flex items-center gap-1">
<Star className="size-3" />
{formatCount(data.star_count)}
</span>
)}
{data.fork_count > 0 && (
<span className="inline-flex items-center gap-1">
<GitFork className="size-3" />
{formatCount(data.fork_count)}
</span>
)}
<span className="inline-flex items-center gap-1">
{data.visibility === "private" ? (
<Lock className="size-3" />
) : (
<Globe className="size-3" />
)}
{data.visibility === "private" ? "private" : "public"}
</span>
<span className="inline-flex items-center gap-1">
<Clock className="size-3" />
{data.updated_at ? timeAgo(data.updated_at) : ""}
</span>
</div>
{data.topics.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{data.topics.slice(0, 5).map((t) => (
<span
className="inline-block rounded-full bg-primary/[0.06] px-2 py-0.5 text-[10px] font-medium text-primary/70"
key={t}
>
{t}
</span>
))}
</div>
)}
</>
)}
</div>
</RepoDrawer>
);
}