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 { ForgejoIssue, ForgejoIssueLabel } from "@/lib/api-forgejo";
|
||||||
import type { ForgejoRepository } from "@/lib/api-forgejo";
|
import type { ForgejoRepository } from "@/lib/api-forgejo";
|
||||||
import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog";
|
import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog";
|
||||||
|
import { ForgejoIssueDetailDialog } from "@/components/git/ForgejoIssueDetailDialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Normalize a Forgejo label color to a valid 6-char hex string or null. */
|
/** Normalize a Forgejo label color to a valid 6-char hex string or null. */
|
||||||
|
|
@ -195,6 +196,8 @@ export function ForgejoIssuesTable({
|
||||||
}: ForgejoIssuesTableProps) {
|
}: ForgejoIssuesTableProps) {
|
||||||
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
|
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
|
||||||
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false);
|
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false);
|
||||||
|
const [issueToView, setIssueToView] = useState<ForgejoIssue | null>(null);
|
||||||
|
const [isIssueDetailOpen, setIsIssueDetailOpen] = useState(false);
|
||||||
|
|
||||||
const repositoryNameById = useMemo(() => {
|
const repositoryNameById = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
|
|
@ -282,15 +285,17 @@ export function ForgejoIssuesTable({
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<a
|
<button
|
||||||
href={issue.html_url}
|
type="button"
|
||||||
target="_blank"
|
className="min-w-0 break-words text-left text-sm font-semibold text-strong hover:text-[color:var(--accent)] hover:underline"
|
||||||
rel="noopener noreferrer"
|
title={`Open ${repositoryName}#${issue.forgejo_issue_number} details`}
|
||||||
className="min-w-0 break-words text-sm font-semibold text-strong hover:text-[color:var(--accent)] hover:underline"
|
onClick={() => {
|
||||||
title={issue.title}
|
setIssueToView(issue);
|
||||||
|
setIsIssueDetailOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{issue.title}
|
{issue.title}
|
||||||
</a>
|
</button>
|
||||||
{visibleLabels.map((label, i) => (
|
{visibleLabels.map((label, i) => (
|
||||||
<LabelChip key={`${label.name}-${i}`} label={label} />
|
<LabelChip key={`${label.name}-${i}`} label={label} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -377,6 +382,22 @@ export function ForgejoIssuesTable({
|
||||||
}}
|
}}
|
||||||
onCloseSuccess={onRefresh}
|
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;
|
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 {
|
export interface ForgejoIssueListResponse {
|
||||||
items: ForgejoIssue[];
|
items: ForgejoIssue[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|
@ -358,8 +365,10 @@ export async function getForgejoIssues(params?: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getForgejoIssue(issueId: string): Promise<ForgejoIssue> {
|
export async function getForgejoIssue(
|
||||||
return fetchJson<ForgejoIssue>(`/api/v1/forgejo/issues/${issueId}`);
|
issueId: string,
|
||||||
|
): Promise<ForgejoIssueDetail> {
|
||||||
|
return fetchJson<ForgejoIssueDetail>(`/api/v1/forgejo/issues/${issueId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeForgejoIssue(
|
export async function closeForgejoIssue(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue