12c2ec213a
Support form now accepts up to 5 attachments (images, PDF, text, JSON, zip; 10MB each) via the existing S3/MinIO presigned-upload flow. New support_ticket_attachments table; attachment links get folded into the Gitea issue body and shown as thumbnails in both the user's ticket history and the admin support view. New presign endpoint (/api/v1/support/attachments/presign, rate-limited 20/hr) scoped to the uploading user rather than a ticket id, since the ticket doesn't exist yet at upload time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
181 lines
6.1 KiB
TypeScript
181 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { toast } from "sonner";
|
|
import { ExternalLink, FileText } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
|
|
type TicketStatus = "open" | "triaged" | "closed";
|
|
|
|
type Attachment = { id: string; contentType: string; url: string };
|
|
|
|
type Ticket = {
|
|
id: string;
|
|
userEmail: string;
|
|
username: string | null;
|
|
type: string;
|
|
title: string;
|
|
description: string;
|
|
status: TicketStatus;
|
|
giteaIssueUrl: string | null;
|
|
giteaError: string | null;
|
|
createdAt: string;
|
|
attachments: Attachment[];
|
|
};
|
|
|
|
function isImage(contentType: string) {
|
|
return contentType.startsWith("image/");
|
|
}
|
|
|
|
function formatDateTime(iso: string) {
|
|
return new Date(iso).toLocaleString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
const statusVariant: Record<TicketStatus, "default" | "secondary" | "outline"> = {
|
|
open: "default",
|
|
triaged: "secondary",
|
|
closed: "outline",
|
|
};
|
|
|
|
export function AdminSupportManager({ initialTickets }: { initialTickets: Ticket[] }) {
|
|
const [tickets, setTickets] = useState<Ticket[]>(initialTickets);
|
|
const [retrying, setRetrying] = useState<string | null>(null);
|
|
|
|
async function handleStatusChange(id: string, status: TicketStatus) {
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/support/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
setTickets((prev) => prev.map((t) => (t.id === id ? { ...t, status } : t)));
|
|
} catch {
|
|
toast.error("Failed to update status");
|
|
}
|
|
}
|
|
|
|
async function handleRetryGitea(id: string) {
|
|
setRetrying(id);
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/support/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ retryGitea: true }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
// Re-fetch the single ticket's fields isn't wired up server-side, so
|
|
// reload the list to pick up the new giteaIssueUrl/giteaError.
|
|
const listRes = await fetch("/api/v1/admin/support");
|
|
if (listRes.ok) {
|
|
const data = (await listRes.json()) as Ticket[];
|
|
setTickets(data);
|
|
}
|
|
toast.success("Retried Gitea issue creation");
|
|
} catch {
|
|
toast.error("Retry failed");
|
|
} finally {
|
|
setRetrying(null);
|
|
}
|
|
}
|
|
|
|
if (tickets.length === 0) {
|
|
return <p className="text-sm text-muted-foreground">No support tickets yet.</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="divide-y rounded-md border">
|
|
{tickets.map((ticket) => (
|
|
<div key={ticket.id} className="px-4 py-3 space-y-1.5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium">{ticket.title}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{ticket.username ? `@${ticket.username}` : ticket.userEmail} · {formatDateTime(ticket.createdAt)}
|
|
</p>
|
|
</div>
|
|
<Select value={ticket.status} onValueChange={(v) => { void handleStatusChange(ticket.id, v as TicketStatus); }}>
|
|
<SelectTrigger className="w-32 shrink-0">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="open">Open</SelectItem>
|
|
<SelectItem value="triaged">Triaged</SelectItem>
|
|
<SelectItem value="closed">Closed</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{ticket.description}</p>
|
|
{ticket.attachments.length > 0 && (
|
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
{ticket.attachments.map((a) => (
|
|
<Link
|
|
key={a.id}
|
|
href={a.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="relative h-12 w-12 rounded-md border overflow-hidden block"
|
|
>
|
|
{isImage(a.contentType) ? (
|
|
<Image src={a.url} unoptimized alt="Attachment" fill className="object-cover" />
|
|
) : (
|
|
<div className="h-full w-full flex items-center justify-center bg-muted/40">
|
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2 text-xs flex-wrap pt-1">
|
|
<Badge variant="outline" className="text-xs">{ticket.type}</Badge>
|
|
<Badge variant={statusVariant[ticket.status]} className="text-xs">{ticket.status}</Badge>
|
|
{ticket.giteaIssueUrl ? (
|
|
<Link
|
|
href={ticket.giteaIssueUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 text-primary hover:underline"
|
|
>
|
|
View Gitea issue
|
|
<ExternalLink className="h-3 w-3" />
|
|
</Link>
|
|
) : (
|
|
<>
|
|
{ticket.giteaError && (
|
|
<span className="text-destructive" title={ticket.giteaError}>Gitea issue failed</span>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 px-2 text-xs"
|
|
disabled={retrying === ticket.id}
|
|
onClick={() => { void handleRetryGitea(ticket.id); }}
|
|
>
|
|
{retrying === ticket.id ? "Retrying…" : "Create Gitea issue"}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|