feat: file/screenshot attachments on support tickets (v0.49.1)

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>
This commit is contained in:
Arnaud
2026-07-18 12:09:40 +02:00
parent 811d4cad42
commit 12c2ec213a
19 changed files with 5855 additions and 64 deletions
@@ -1,9 +1,10 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { toast } from "sonner";
import { ExternalLink } from "lucide-react";
import { ExternalLink, FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -16,6 +17,8 @@ import {
type TicketStatus = "open" | "triaged" | "closed";
type Attachment = { id: string; contentType: string; url: string };
type Ticket = {
id: string;
userEmail: string;
@@ -27,8 +30,13 @@ type Ticket = {
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",
@@ -113,6 +121,27 @@ export function AdminSupportManager({ initialTickets }: { initialTickets: Ticket
</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>