feat(ui): issues card

This commit is contained in:
null 2026-05-21 23:01:31 -05:00
parent 5cc0d75636
commit c7db20d1e8
3 changed files with 329 additions and 9 deletions

View File

@ -0,0 +1,290 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ExternalLink, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Markdown } from "@/components/atoms/Markdown";
import {
getForgejoIssue,
type ForgejoIssue,
type ForgejoIssueDetail,
} from "@/lib/api-forgejo";
type ForgejoIssueDetailDialogProps = {
issue: ForgejoIssue | null;
repositoryName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
};
const formatDateTime = (value: string | null | undefined): string => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
};
const objectList = (value: unknown): Record<string, unknown>[] => {
if (!Array.isArray(value)) return [];
return value.filter(
(item): item is Record<string, unknown> =>
typeof item === "object" && item !== null,
);
};
const asString = (value: unknown): string | null =>
typeof value === "string" && value.trim() ? value : null;
export function ForgejoIssueDetailDialog({
issue,
repositoryName,
open,
onOpenChange,
}: ForgejoIssueDetailDialogProps) {
const [detail, setDetail] = useState<ForgejoIssueDetail | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !issue) return;
let cancelled = false;
(async () => {
setIsLoading(true);
setError(null);
try {
const result = await getForgejoIssue(issue.id);
if (!cancelled) {
setDetail(result);
}
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error
? err.message
: "Could not load issue details from Pipeline.",
);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [issue, open]);
const comments = useMemo(
() => objectList(detail?.forgejo_comments_payload),
[detail?.forgejo_comments_payload],
);
const timeline = useMemo(
() => objectList(detail?.forgejo_timeline_payload),
[detail?.forgejo_timeline_payload],
);
const reactions = useMemo(
() => objectList(detail?.forgejo_reactions_payload),
[detail?.forgejo_reactions_payload],
);
if (!issue) return null;
const active = detail ?? issue;
const body = detail?.body ?? issue.body ?? issue.body_preview ?? "";
const stateVariant = active.state === "open" ? "success" : "default";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<DialogTitle className="break-words text-base sm:text-lg">
{active.title}
</DialogTitle>
<DialogDescription className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
<span className="font-medium text-strong">
{repositoryName}
</span>
<span className="font-mono">
#{active.forgejo_issue_number}
</span>
<Badge variant={stateVariant}>{active.state}</Badge>
<span>Opened {formatDateTime(active.forgejo_created_at)}</span>
<span>Updated {formatDateTime(active.forgejo_updated_at)}</span>
</DialogDescription>
</div>
<a
href={active.html_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-9 items-center gap-2 rounded-xl border border-[color:var(--border)] px-3 text-xs font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Open in Forgejo
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</DialogHeader>
{isLoading ? (
<div className="flex items-center gap-2 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
<Loader2 className="h-4 w-4 animate-spin text-[color:var(--accent)]" />
Loading issue details
</div>
) : null}
{error ? (
<div className="rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-sm text-[color:var(--danger)]">
{error}
</div>
) : null}
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="comments">
Comments ({comments.length})
</TabsTrigger>
<TabsTrigger value="timeline">
Timeline ({timeline.length})
</TabsTrigger>
<TabsTrigger value="reactions">
Reactions ({reactions.length})
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4">
{body ? (
<Markdown content={body} variant="comment" />
) : (
<p className="text-sm text-muted">No issue body provided.</p>
)}
</div>
</TabsContent>
<TabsContent value="comments">
<div className="space-y-3">
{comments.length === 0 ? (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
No comments on this issue.
</div>
) : (
comments.map((comment, idx) => {
const login =
asString(
comment.user &&
(comment.user as Record<string, unknown>).login,
) ?? "Unknown";
const bodyText = asString(comment.body) ?? "";
return (
<article
key={String(comment.id ?? idx)}
className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4"
>
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted">
<span className="font-medium text-strong">{login}</span>
<span>
{formatDateTime(asString(comment.created_at))}
</span>
</div>
{bodyText ? (
<Markdown content={bodyText} variant="comment" />
) : (
<p className="text-sm text-muted">No comment text.</p>
)}
</article>
);
})
)}
</div>
</TabsContent>
<TabsContent value="timeline">
<div className="space-y-2">
{timeline.length === 0 ? (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
No timeline events found.
</div>
) : (
timeline.map((event, idx) => {
const label =
asString(event.type) ??
asString(event.action) ??
asString(event.event) ??
"event";
const actor =
asString(
event.user &&
(event.user as Record<string, unknown>).login,
) ?? "system";
return (
<div
key={String(event.id ?? idx)}
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 text-sm"
>
<span className="font-medium text-strong">{label}</span>{" "}
<span className="text-muted">by {actor}</span>
<span className="ml-2 text-xs text-muted">
{formatDateTime(asString(event.created_at))}
</span>
</div>
);
})
)}
</div>
</TabsContent>
<TabsContent value="reactions">
<div className="space-y-2">
{reactions.length === 0 ? (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
No reactions on this issue.
</div>
) : (
reactions.map((reaction, idx) => {
const content = asString(reaction.content) ?? "reaction";
const login =
asString(
reaction.user &&
(reaction.user as Record<string, unknown>).login,
) ?? "Unknown";
return (
<div
key={String(reaction.id ?? idx)}
className="flex items-center justify-between rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 text-sm"
>
<span className="font-medium text-strong">{content}</span>
<span className="text-xs text-muted">{login}</span>
</div>
);
})
)}
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge";
import type { ForgejoIssue, ForgejoIssueLabel } from "@/lib/api-forgejo";
import type { ForgejoRepository } from "@/lib/api-forgejo";
import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog";
import { ForgejoIssueDetailDialog } from "@/components/git/ForgejoIssueDetailDialog";
import { cn } from "@/lib/utils";
/** Normalize a Forgejo label color to a valid 6-char hex string or null. */
@ -195,6 +196,8 @@ export function ForgejoIssuesTable({
}: ForgejoIssuesTableProps) {
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false);
const [issueToView, setIssueToView] = useState<ForgejoIssue | null>(null);
const [isIssueDetailOpen, setIsIssueDetailOpen] = useState(false);
const repositoryNameById = useMemo(() => {
const map = new Map<string, string>();
@ -282,15 +285,17 @@ export function ForgejoIssuesTable({
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<a
href={issue.html_url}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 break-words text-sm font-semibold text-strong hover:text-[color:var(--accent)] hover:underline"
title={issue.title}
<button
type="button"
className="min-w-0 break-words text-left text-sm font-semibold text-strong hover:text-[color:var(--accent)] hover:underline"
title={`Open ${repositoryName}#${issue.forgejo_issue_number} details`}
onClick={() => {
setIssueToView(issue);
setIsIssueDetailOpen(true);
}}
>
{issue.title}
</a>
</button>
{visibleLabels.map((label, i) => (
<LabelChip key={`${label.name}-${i}`} label={label} />
))}
@ -377,6 +382,22 @@ export function ForgejoIssuesTable({
}}
onCloseSuccess={onRefresh}
/>
<ForgejoIssueDetailDialog
issue={issueToView}
repositoryName={
issueToView
? (repositoryNameById.get(issueToView.repository_id) ??
issueToView.repository_id)
: "Repository"
}
open={isIssueDetailOpen}
onOpenChange={(nextOpen) => {
setIsIssueDetailOpen(nextOpen);
if (!nextOpen) {
setIssueToView(null);
}
}}
/>
</>
);
}

View File

@ -327,6 +327,13 @@ export interface ForgejoIssue {
updated_at: string;
}
export interface ForgejoIssueDetail extends ForgejoIssue {
forgejo_payload?: Record<string, unknown> | null;
forgejo_comments_payload?: Record<string, unknown>[];
forgejo_timeline_payload?: Record<string, unknown>[];
forgejo_reactions_payload?: Record<string, unknown>[];
}
export interface ForgejoIssueListResponse {
items: ForgejoIssue[];
total: number;
@ -358,8 +365,10 @@ export async function getForgejoIssues(params?: {
);
}
export async function getForgejoIssue(issueId: string): Promise<ForgejoIssue> {
return fetchJson<ForgejoIssue>(`/api/v1/forgejo/issues/${issueId}`);
export async function getForgejoIssue(
issueId: string,
): Promise<ForgejoIssueDetail> {
return fetchJson<ForgejoIssueDetail>(`/api/v1/forgejo/issues/${issueId}`);
}
export async function closeForgejoIssue(