feat(ui): issues card
This commit is contained in:
parent
5cc0d75636
commit
c7db20d1e8
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue