236 lines
6.9 KiB
TypeScript
236 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo, useState } from "react";
|
|
|
|
import {
|
|
type ColumnDef,
|
|
getCoreRowModel,
|
|
useReactTable,
|
|
} from "@tanstack/react-table";
|
|
import { CircleDot, ExternalLink, XCircle } from "lucide-react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { DataTable } from "@/components/tables/DataTable";
|
|
import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog";
|
|
import type { ForgejoIssue, ForgejoIssueLabel } from "@/lib/api-forgejo";
|
|
|
|
/** Normalize a Forgejo label color to a valid 6-char hex string or null. */
|
|
function normalizeLabelColor(raw: string | null | undefined): string | null {
|
|
if (!raw) return null;
|
|
const hex = raw.replace(/^#+/, "");
|
|
return /^[0-9a-fA-F]{3,6}$/.test(hex) ? `#${hex.padEnd(6, "0")}` : null;
|
|
}
|
|
|
|
/** Return white or dark text based on WCAG relative luminance of a hex background. */
|
|
function labelTextColor(hex: string): string {
|
|
const h = hex.replace("#", "");
|
|
const r = parseInt(h.slice(0, 2), 16) / 255;
|
|
const g = parseInt(h.slice(2, 4), 16) / 255;
|
|
const b = parseInt(h.slice(4, 6), 16) / 255;
|
|
const lin = (c: number) =>
|
|
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
const L = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
return L > 0.179 ? "#1a1a1a" : "#ffffff";
|
|
}
|
|
|
|
function LabelChip({ label }: { label: ForgejoIssueLabel }) {
|
|
const bg = normalizeLabelColor(label.color);
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className="shrink-0 text-xs"
|
|
style={
|
|
bg
|
|
? { backgroundColor: bg, color: labelTextColor(bg), borderColor: bg }
|
|
: undefined
|
|
}
|
|
title={label.description ?? label.name}
|
|
>
|
|
{label.name}
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
export type ForgejoIssuesTableProps = {
|
|
issues: ForgejoIssue[];
|
|
isLoading?: boolean;
|
|
onRefresh: () => void;
|
|
};
|
|
|
|
export function ForgejoIssuesTable({
|
|
issues,
|
|
isLoading = false,
|
|
onRefresh,
|
|
}: ForgejoIssuesTableProps) {
|
|
const [closeIssueDialogOpen, setCloseIssueDialogOpen] = useState(false);
|
|
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
|
|
|
|
const handleCloseClick = useCallback((issue: ForgejoIssue) => {
|
|
setIssueToClose(issue);
|
|
setCloseIssueDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleCloseSuccess = () => {
|
|
onRefresh();
|
|
};
|
|
|
|
const columns: ColumnDef<ForgejoIssue>[] = useMemo(
|
|
() => [
|
|
{
|
|
accessorKey: "forgejo_issue_number",
|
|
header: "#",
|
|
cell: ({ row }) => (
|
|
<a
|
|
href={row.original.html_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 font-mono text-sm text-[color:var(--accent)] hover:underline"
|
|
>
|
|
#{row.original.forgejo_issue_number}
|
|
<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "title",
|
|
header: "Title",
|
|
cell: ({ row }) => (
|
|
<div
|
|
className="max-w-[28rem] truncate font-medium text-strong"
|
|
title={row.original.title}
|
|
>
|
|
{row.original.title}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "body_preview",
|
|
header: "Description",
|
|
cell: ({ row }) => {
|
|
const body = row.original.body_preview;
|
|
if (!body) return null;
|
|
const truncated = body.length > 120 ? body.slice(0, 120) + "…" : body;
|
|
return (
|
|
<div className="max-w-xs truncate text-sm text-muted" title={body}>
|
|
{truncated}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "state",
|
|
header: "State",
|
|
cell: ({ row }) => {
|
|
const state = row.original.state;
|
|
return (
|
|
<Badge variant={state === "open" ? "success" : "default"}>
|
|
{state}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "labels",
|
|
header: "Labels",
|
|
cell: ({ row }) => {
|
|
const labels = row.original.labels;
|
|
if (!labels || labels.length === 0) return null;
|
|
const visible = labels.slice(0, 3);
|
|
const overflow = labels.length - visible.length;
|
|
return (
|
|
<div className="flex max-w-[240px] flex-wrap gap-1">
|
|
{visible.map((label, i) => (
|
|
<LabelChip key={i} label={label} />
|
|
))}
|
|
{overflow > 0 && (
|
|
<span className="self-center text-xs text-muted">
|
|
+{overflow}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "author",
|
|
header: "Author",
|
|
cell: ({ row }) => (
|
|
<span className="block max-w-[160px] truncate text-muted">
|
|
{row.original.author || "Unknown"}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "forgejo_updated_at",
|
|
header: "Updated",
|
|
cell: ({ row }) => {
|
|
try {
|
|
return (
|
|
<span className="whitespace-nowrap text-muted">
|
|
{new Date(row.original.forgejo_updated_at).toLocaleDateString()}
|
|
</span>
|
|
);
|
|
} catch {
|
|
return (
|
|
<span className="text-muted">
|
|
{row.original.forgejo_updated_at}
|
|
</span>
|
|
);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "Actions",
|
|
cell: ({ row }) => {
|
|
const issue = row.original;
|
|
if (issue.state !== "open" || issue.is_pull_request) return null;
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 p-0 text-[color:var(--danger)] hover:bg-[color:rgba(248,113,113,0.08)]"
|
|
onClick={() => handleCloseClick(issue)}
|
|
title="Close issue"
|
|
>
|
|
<XCircle className="h-4 w-4" />
|
|
</Button>
|
|
);
|
|
},
|
|
},
|
|
],
|
|
[handleCloseClick],
|
|
);
|
|
const table = useReactTable({
|
|
data: issues,
|
|
columns,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<div className="overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] shadow-lush">
|
|
<DataTable
|
|
table={table}
|
|
isLoading={isLoading}
|
|
loadingLabel="Loading Git Project issues…"
|
|
tableClassName="min-w-[960px] w-full text-left text-sm"
|
|
emptyState={{
|
|
icon: <CircleDot className="h-12 w-12" />,
|
|
title: "No Git Project issues found",
|
|
description:
|
|
"Sync a repository to pull issues into Pipeline, or adjust your filters.",
|
|
}}
|
|
/>
|
|
</div>
|
|
<CloseForgejoIssueDialog
|
|
issue={issueToClose}
|
|
open={closeIssueDialogOpen}
|
|
onOpenChange={setCloseIssueDialogOpen}
|
|
onCloseSuccess={handleCloseSuccess}
|
|
/>
|
|
</>
|
|
);
|
|
}
|