291 lines
10 KiB
TypeScript
291 lines
10 KiB
TypeScript
"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>
|
|
);
|
|
}
|