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:
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { ExternalLink, Paperclip, X, FileText } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -17,10 +18,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { uploadToPresignedPost } from "@/lib/upload-client";
|
||||
|
||||
type TicketType = "bug" | "suggestion" | "question";
|
||||
type TicketStatus = "open" | "triaged" | "closed";
|
||||
|
||||
type Attachment = { id: string; contentType: string; url: string };
|
||||
|
||||
type Ticket = {
|
||||
id: string;
|
||||
type: TicketType;
|
||||
@@ -29,8 +33,21 @@ type Ticket = {
|
||||
status: TicketStatus;
|
||||
giteaIssueUrl: string | null;
|
||||
createdAt: string;
|
||||
attachments: Attachment[];
|
||||
};
|
||||
|
||||
type PendingAttachment = {
|
||||
key: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
preview: string | null;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const MAX_ATTACHMENTS = 5;
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
const ACCEPT = "image/jpeg,image/png,image/webp,image/avif,image/gif,application/pdf,text/plain,application/json,application/zip";
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
@@ -39,6 +56,10 @@ function formatDate(iso: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function isImage(contentType: string) {
|
||||
return contentType.startsWith("image/");
|
||||
}
|
||||
|
||||
export function SupportManager({ initialTickets }: { initialTickets: Ticket[] }) {
|
||||
const t = useTranslations("support");
|
||||
const [tickets, setTickets] = useState<Ticket[]>(initialTickets);
|
||||
@@ -46,6 +67,54 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [uploadingCount, setUploadingCount] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleFiles(files: FileList) {
|
||||
const room = MAX_ATTACHMENTS - pendingAttachments.length;
|
||||
const toUpload = Array.from(files).slice(0, Math.max(0, room));
|
||||
if (files.length > toUpload.length) {
|
||||
toast.error(t("attachmentLimitReached", { count: MAX_ATTACHMENTS }));
|
||||
}
|
||||
setUploadingCount((c) => c + toUpload.length);
|
||||
for (const file of toUpload) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error(t("attachmentTooLarge", { name: file.name }));
|
||||
setUploadingCount((c) => c - 1);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/v1/support/attachments/presign", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: file.type, fileSize: file.size }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const { url, fields, key } = (await res.json()) as { url: string; fields: Record<string, string>; key: string };
|
||||
const uploaded = await uploadToPresignedPost(url, fields, file);
|
||||
if (!uploaded) throw new Error();
|
||||
setPendingAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key,
|
||||
contentType: file.type,
|
||||
sizeBytes: file.size,
|
||||
preview: isImage(file.type) ? URL.createObjectURL(file) : null,
|
||||
name: file.name,
|
||||
},
|
||||
]);
|
||||
} catch {
|
||||
toast.error(t("attachmentUploadFailed", { name: file.name }));
|
||||
} finally {
|
||||
setUploadingCount((c) => c - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removePending(key: string) {
|
||||
setPendingAttachments((prev) => prev.filter((a) => a.key !== key));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -55,7 +124,16 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
|
||||
const res = await fetch("/api/v1/support", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type, title: title.trim(), description: description.trim() }),
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
attachments: pendingAttachments.map((a) => ({
|
||||
key: a.key,
|
||||
contentType: a.contentType,
|
||||
sizeBytes: a.sizeBytes,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json()) as { error?: string };
|
||||
@@ -66,6 +144,7 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setType("bug");
|
||||
setPendingAttachments([]);
|
||||
toast.success(t("submitSuccess"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("submitFailed"));
|
||||
@@ -122,7 +201,60 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={submitting || title.trim().length < 3 || description.trim().length < 10}>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("attachmentsLabel")}</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{pendingAttachments.map((a) => (
|
||||
<div key={a.key} className="relative group h-16 w-16">
|
||||
{a.preview ? (
|
||||
<Image
|
||||
src={a.preview}
|
||||
unoptimized
|
||||
alt={a.name}
|
||||
fill
|
||||
className="rounded-lg object-cover border"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-lg border flex flex-col items-center justify-center gap-0.5 bg-muted/40 px-1">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-[9px] text-muted-foreground truncate w-full text-center">{a.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePending(a.key)}
|
||||
className="absolute -top-1.5 -right-1.5 rounded-full bg-destructive text-destructive-foreground p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title={t("removeAttachment")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pendingAttachments.length < MAX_ATTACHMENTS && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={uploadingCount > 0}
|
||||
className="h-16 w-16 rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 flex flex-col items-center justify-center gap-1 text-muted-foreground transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
<span className="text-[10px]">{uploadingCount > 0 ? t("attachmentUploading") : t("attachmentAdd")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("attachmentsHint", { count: MAX_ATTACHMENTS })}</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ACCEPT}
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && handleFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={submitting || uploadingCount > 0 || title.trim().length < 3 || description.trim().length < 10}>
|
||||
{submitting ? t("submitting") : t("submitButton")}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -142,6 +274,27 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
|
||||
</Badge>
|
||||
</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 text-muted-foreground pt-1">
|
||||
<Badge variant="outline" className="text-xs">{t(`type.${ticket.type}`)}</Badge>
|
||||
<span>{formatDate(ticket.createdAt)}</span>
|
||||
|
||||
Reference in New Issue
Block a user