"use client"; import { ArrowUpRight, Check, CheckCircle2, ImagePlus, LoaderCircle, RotateCcw, ShieldAlert, Sparkles, X } from "lucide-react"; import { useEffect, useRef, useState, type ClipboardEvent, type DragEvent } from "react"; import type { DocsLang } from "@/lib/i18n"; type IssueType = "bug" | "feature" | "question" | "compatibility"; type SelectedImage = { file: File; url: string; }; type DraftResponse = { draftId: string; expiresAt: number; preview: { type: IssueType; title: string; summary: string; body: string; }; rateLimit: { remaining: number; resetAt: number; }; }; type SubmitResponse = { issueNumber: number; issueUrl: string; }; const copy = { tr: { draftStep: "Sorunu anlatın", previewStep: "Issue önizlemesi", descriptionLabel: "Ne oldu?", descriptionHint: "Ne yaptığınızı, ne gördüğünüzü ve ne beklediğinizi yazın. Sürüm veya ortam ayrıntısını bilmiyorsanız tahmin etmeyin.", descriptionPlaceholder: "Örnek: macOS'ta bir PostgreSQL şemasını genişletince hata vermeden sürekli yükleniyor. Yeniden başlatmak ve yeniden bağlanmak işe yaramadı.", attachments: "Ekran görüntüleri (isteğe bağlı)", attachmentHint: "Ekran görüntülerini buraya seçin, sürükleyin ya da yapıştırın. PNG, JPEG veya WebP; en fazla 3 görsel ve her biri 5 MB.", addImages: "Ekran görüntüsü ekle", generate: "Issue taslağı hazırla", generateAgain: "Yeniden hazırla", generating: "Taslak hazırlanıyor", type: "Tür", titleLabel: "Başlık", bodyLabel: "GitHub gövdesi", bodyHint: "Tümüyle düzenlenebilir. Gönderim sırasında görseller ve anonim kaynak notu sona eklenir.", summary: "Taslak özeti", confirm: "Başlığı, gövdeyi ve ekran görüntülerini inceledim; bunların GitHub'da herkese açık görüneceğini anlıyorum.", submit: "Onayla ve herkese açık Issue oluştur", submitting: "Issue oluşturuluyor", publicWarning: "Göndermeden önce", publicNotice: "Parola, belirteç, bağlantı dizesi, maskelenmemiş IP, müşteri verisi veya kişisel veri yüklemeyin. Issue ve görseller herkese açık olacak.", successTitle: "Issue oluşturuldu", successText: (number: number) => `GitHub Issue #${number} oluşturuldu. Sonraki gelişmeler o herkese açık sayfada kaydedilecek.`, openIssue: "GitHub Issue'yu aç", another: "Bir tane daha gönder", removeImage: "Görseli kaldır", errors: { DESCRIPTION_REQUIRED: "Önce sorunu anlatın.", DESCRIPTION_TOO_SHORT: "Açıklama en az 4 karakter içermelidir.", DESCRIPTION_TOO_LONG: "Açıklamayı 6.000 karakterin altında tutun.", TOO_MANY_IMAGES: "En fazla 3 görsel yükleyebilirsiniz.", IMAGE_TOO_LARGE: "Her görsel en fazla 5 MB olmalıdır.", IMAGES_TOO_LARGE: "Görsellerin toplamı en fazla 12 MB olmalıdır.", IMAGE_TYPE_UNSUPPORTED: "Yalnızca gerçek PNG, JPEG ve WebP görselleri desteklenir.", RATE_LIMITED: "Çok fazla taslak denemesi yapıldı. Lütfen daha sonra tekrar deneyin.", AI_NOT_CONFIGURED: "Yapay zekâ taslak servisi henüz yapılandırılmadı.", AI_REQUEST_FAILED: "Yapay zekâ servisi geçici olarak kullanılamıyor. Daha sonra tekrar deneyin.", AI_REQUEST_TIMEOUT: "Ekran görüntüsü çözümlemesi çok uzun sürdü. Tekrar deneyin ya da daha az görsel ekleyin.", AI_TIMEOUT_TEXT_ONLY: "Yapay zekâ servisi zaman aşımına uğradı. Lütfen daha sonra tekrar deneyin.", AI_RESPONSE_INVALID: "Yapay zekâ geçersiz bir taslak döndürdü. Yeniden üretin.", DRAFT_EXPIRED: "Bu taslağın süresi doldu. Yenisini üretin.", DRAFT_SUBMITTING: "Bu taslak zaten gönderiliyor.", DRAFT_IMAGES_CHANGED: "Taslak üretildikten sonra ekran görüntüleri değişti. Taslağı yeniden üretin.", ISSUE_SESSION_EXPIRED: "Geçici oturumun süresi doldu. Taslağı yeniden üretin.", GITHUB_ISSUE_CREATE_FAILED: "GitHub Issue'yu oluşturamadı. Daha sonra tekrar deneyin.", ISSUE_IMAGE_UPLOAD_FAILED: "Ekran görüntüleri yüklenemedi. Daha sonra tekrar deneyin.", ISSUE_REQUEST_FAILED: "Gönderim başarısız. Daha sonra tekrar deneyin.", }, genericError: "İstek başarısız oldu. Daha sonra tekrar deneyin.", clientImageError: "En fazla 3 PNG, JPEG veya WebP görseli kullanın; her biri en çok 5 MB.", retryAfter: (minutes: number) => `Yaklaşık ${minutes} dakika sonra tekrar deneyin.`, }, cn: { draftStep: "问题描述", previewStep: "Issue 预览", descriptionLabel: "发生了什么?", descriptionHint: "建议包含:你做了什么、看到了什么、期望怎样。版本和环境不知道也没关系,不要猜。", descriptionPlaceholder: "例如:macOS 上连接 PostgreSQL 后,展开 schema 一直转圈,没有错误提示。重启和重新连接都试过了。", attachments: "截图(可选)", attachmentHint: "点击添加、拖入或直接粘贴截图。支持 PNG、JPEG 和 WebP,最多 3 张,单张不超过 5 MB。", addImages: "添加截图", generate: "整理为 Issue 草稿", generateAgain: "重新整理", generating: "正在整理", type: "类型", titleLabel: "标题", bodyLabel: "GitHub 正文", bodyHint: "可以自由修改。最终提交时会在末尾追加图片和匿名来源说明。", summary: "整理摘要", confirm: "我已检查标题、正文和截图,并理解这些内容将公开显示在 GitHub。", submit: "确认并公开创建 Issue", submitting: "正在创建 Issue", publicWarning: "提交前请确认", publicNotice: "请勿上传密码、Token、连接串、未脱敏 IP、客户或隐私数据;提交后 Issue 和图片将公开可见。", successTitle: "Issue 已创建", successText: (number: number) => `GitHub Issue #${number} 已创建。后续进展会公开记录在该页面。`, openIssue: "查看 GitHub Issue", another: "再提交一个", removeImage: "移除图片", errors: { DESCRIPTION_REQUIRED: "请先填写问题描述。", DESCRIPTION_TOO_SHORT: "描述至少需要 4 个字符。", DESCRIPTION_TOO_LONG: "描述过长,请控制在 6000 字符以内。", TOO_MANY_IMAGES: "最多只能上传 3 张图片。", IMAGE_TOO_LARGE: "单张图片不能超过 5 MB。", IMAGES_TOO_LARGE: "图片总大小不能超过 12 MB。", IMAGE_TYPE_UNSUPPORTED: "仅支持真实的 PNG、JPEG 或 WebP 图片。", RATE_LIMITED: "生成草稿过于频繁,请稍后再试。", AI_NOT_CONFIGURED: "AI 草稿服务尚未配置,请联系维护者。", AI_REQUEST_FAILED: "AI 服务暂时不可用,请稍后重试。", AI_REQUEST_TIMEOUT: "截图分析时间较长,请重试;如果仍然超时,可以减少截图数量。", AI_TIMEOUT_TEXT_ONLY: "AI 服务响应超时,请稍后重试。", AI_RESPONSE_INVALID: "AI 返回的草稿格式异常,请重新生成。", DRAFT_EXPIRED: "草稿已过期,请重新生成。", DRAFT_SUBMITTING: "这个草稿正在提交,请不要重复点击。", DRAFT_IMAGES_CHANGED: "草稿生成后截图发生变化,请重新生成草稿。", ISSUE_SESSION_EXPIRED: "临时会话已过期,请重新生成草稿。", GITHUB_ISSUE_CREATE_FAILED: "GitHub 暂时未能创建 Issue,请稍后重试。", ISSUE_IMAGE_UPLOAD_FAILED: "截图上传失败,请稍后重试。", ISSUE_REQUEST_FAILED: "提交失败,请稍后重试。", }, genericError: "请求失败,请稍后重试。", clientImageError: "仅支持 PNG、JPEG 或 WebP,最多 3 张,单张不超过 5 MB。", retryAfter: (minutes: number) => `大约 ${minutes} 分钟后可再次生成。`, }, en: { draftStep: "Describe the issue", previewStep: "Issue preview", descriptionLabel: "What happened?", descriptionHint: "Include what you did, what you saw, and what you expected. If you do not know a version or environment detail, do not guess.", descriptionPlaceholder: "Example: On macOS, expanding a PostgreSQL schema keeps loading without an error. Restarting and reconnecting did not help.", attachments: "Screenshots (optional)", attachmentHint: "Choose, drag, or paste screenshots here. PNG, JPEG, or WebP; up to 3 images and 5 MB each.", addImages: "Add screenshots", generate: "Prepare Issue draft", generateAgain: "Prepare again", generating: "Preparing draft", type: "Type", titleLabel: "Title", bodyLabel: "GitHub body", bodyHint: "Fully editable. Images and an anonymous-source note are appended when submitted.", summary: "Draft summary", confirm: "I reviewed the title, body, and screenshots and understand that they will be publicly visible on GitHub.", submit: "Confirm and create public Issue", submitting: "Creating Issue", publicWarning: "Before submitting", publicNotice: "Do not upload passwords, tokens, connection strings, unredacted IPs, customer data, or personal data. The Issue and images will be public.", successTitle: "Issue created", successText: (number: number) => `GitHub Issue #${number} was created. Follow-up progress will be recorded on that public page.`, openIssue: "Open GitHub Issue", another: "Submit another", removeImage: "Remove image", errors: { DESCRIPTION_REQUIRED: "Describe the problem first.", DESCRIPTION_TOO_SHORT: "The description must contain at least 4 characters.", DESCRIPTION_TOO_LONG: "Keep the description under 6,000 characters.", TOO_MANY_IMAGES: "You can upload up to 3 images.", IMAGE_TOO_LARGE: "Each image must be 5 MB or smaller.", IMAGES_TOO_LARGE: "Images must be 12 MB or smaller in total.", IMAGE_TYPE_UNSUPPORTED: "Only real PNG, JPEG, and WebP images are supported.", RATE_LIMITED: "Too many draft attempts. Please try again later.", AI_NOT_CONFIGURED: "The AI drafting service has not been configured yet.", AI_REQUEST_FAILED: "The AI service is temporarily unavailable. Try again later.", AI_REQUEST_TIMEOUT: "Screenshot analysis took too long. Try again, or attach fewer screenshots.", AI_TIMEOUT_TEXT_ONLY: "The AI service timed out. Please try again later.", AI_RESPONSE_INVALID: "The AI returned an invalid draft. Generate it again.", DRAFT_EXPIRED: "This draft expired. Generate a new one.", DRAFT_SUBMITTING: "This draft is already being submitted.", DRAFT_IMAGES_CHANGED: "The screenshots changed after drafting. Generate the draft again.", ISSUE_SESSION_EXPIRED: "The temporary session expired. Generate the draft again.", GITHUB_ISSUE_CREATE_FAILED: "GitHub could not create the Issue. Try again later.", ISSUE_IMAGE_UPLOAD_FAILED: "The screenshots could not be uploaded. Try again later.", ISSUE_REQUEST_FAILED: "Submission failed. Try again later.", }, genericError: "The request failed. Try again later.", clientImageError: "Use up to 3 PNG, JPEG, or WebP images, no more than 5 MB each.", retryAfter: (minutes: number) => `Try again in about ${minutes} minutes.`, }, }; const issuePrefixes: Record = { bug: "[Bug]", feature: "[Feature]", question: "[Question]", compatibility: "[Compatibility]", }; class ResponseError extends Error { constructor( message: string, public readonly code: string, ) { super(message); this.name = "ResponseError"; } } function responseErrorMessage(lang: DocsLang, code: string, retryAfter?: number, hasImages = false): string { const t = copy[lang]; const key = code === "AI_REQUEST_TIMEOUT" && !hasImages ? "AI_TIMEOUT_TEXT_ONLY" : code; const base = t.errors[key as keyof typeof t.errors] ?? t.genericError; if (code !== "RATE_LIMITED" || !retryAfter) return base; return `${base} ${t.retryAfter(Math.max(1, Math.ceil(retryAfter / 60)))}`; } export function IssueSubmissionClient({ lang }: { lang: DocsLang }) { const t = copy[lang]; const [description, setDescription] = useState(""); const [images, setImages] = useState([]); const [draftId, setDraftId] = useState(""); const [draftExpiresAt, setDraftExpiresAt] = useState(0); const [issueType, setIssueType] = useState("bug"); const [title, setTitle] = useState(""); const [body, setBody] = useState(""); const [summary, setSummary] = useState(""); const [confirmed, setConfirmed] = useState(false); const [loading, setLoading] = useState<"draft" | "submit" | null>(null); const [draggingImages, setDraggingImages] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(null); const imagesRef = useRef([]); useEffect(() => { imagesRef.current = images; }, [images]); useEffect(() => { return () => imagesRef.current.forEach((image) => URL.revokeObjectURL(image.url)); }, []); function clearDraft() { setDraftId(""); setDraftExpiresAt(0); setTitle(""); setBody(""); setSummary(""); setConfirmed(false); setSuccess(null); } function changeDescription(value: string) { setDescription(value); if (draftId) clearDraft(); setError(""); } function addImages(files: FileList | File[] | null) { if (!files) return; const candidates = Array.from(files); if (candidates.length === 0) return; const valid = candidates.every((file) => ["image/png", "image/jpeg", "image/webp"].includes(file.type) && file.size <= 5 * 1024 * 1024); const totalBytes = [...images.map((image) => image.file), ...candidates].reduce((total, file) => total + file.size, 0); if (!valid || images.length + candidates.length > 3 || totalBytes > 12 * 1024 * 1024) { setError(t.clientImageError); return; } if (draftId) clearDraft(); setImages((current) => [...current, ...candidates.map((file) => ({ file, url: URL.createObjectURL(file) }))]); setError(""); } function pasteImages(event: ClipboardEvent) { const pastedImages = Array.from(event.clipboardData.items) .filter((item) => item.kind === "file" && item.type.startsWith("image/")) .map((item) => item.getAsFile()) .filter((file): file is File => file !== null); if (pastedImages.length === 0) return; event.preventDefault(); addImages(pastedImages); } function dropImages(event: DragEvent) { event.preventDefault(); setDraggingImages(false); addImages(event.dataTransfer.files); } function removeImage(index: number) { if (draftId) clearDraft(); setImages((current) => { const target = current[index]; if (target) URL.revokeObjectURL(target.url); return current.filter((_, currentIndex) => currentIndex !== index); }); } function changeIssueType(nextType: IssueType) { const withoutPrefix = title.replace(/^\[(?:Bug|Feature|Question|Compatibility)]\s*/i, "").trim(); setIssueType(nextType); setTitle(`${issuePrefixes[nextType]} ${withoutPrefix}`.trim()); setConfirmed(false); } async function parseResponse(response: Response, hasImages = false): Promise { let data: Record = {}; try { data = (await response.json()) as Record; } catch { data = {}; } if (!response.ok) { const code = typeof data.error === "string" ? data.error : "ISSUE_REQUEST_FAILED"; const retryAfter = typeof data.retryAfter === "number" ? data.retryAfter : undefined; throw new ResponseError(responseErrorMessage(lang, code, retryAfter, hasImages), code); } return data as T; } async function requestDraft(): Promise { const form = new FormData(); form.set("description", description); form.set("language", lang); imagesRef.current.forEach((image) => form.append("images", image.file)); const response = await fetch("/api/issues/draft", { method: "POST", body: form }); return parseResponse(response, imagesRef.current.length > 0); } async function generateDraft() { setLoading("draft"); setError(""); setSuccess(null); try { let data: DraftResponse; try { data = await requestDraft(); } catch (requestError) { // The AI gateway is slow to admit requests far more often than it is // down, so a timed-out text-only draft gets one silent retry. Timed-out // drafts with screenshots are not retried: 90s already elapsed and the // timeout message offers removing screenshots instead. if (!(requestError instanceof ResponseError) || requestError.code !== "AI_REQUEST_TIMEOUT" || imagesRef.current.length > 0) throw requestError; data = await requestDraft(); } setDraftId(data.draftId); setDraftExpiresAt(data.expiresAt); setIssueType(data.preview.type); setTitle(data.preview.title); setBody(data.preview.body); setSummary(data.preview.summary); setConfirmed(false); } catch (requestError) { setError(requestError instanceof Error ? requestError.message : t.genericError); } finally { setLoading(null); } } async function submitIssue() { if (!draftId || !confirmed) return; if (draftExpiresAt <= Date.now()) { setError(responseErrorMessage(lang, "DRAFT_EXPIRED")); return; } setLoading("submit"); setError(""); const form = new FormData(); form.set("draftId", draftId); form.set("type", issueType); form.set("title", title); form.set("body", body); images.forEach((image) => form.append("images", image.file)); try { const response = await fetch("/api/issues/submit", { method: "POST", body: form }); setSuccess(await parseResponse(response)); } catch (requestError) { setError(requestError instanceof Error ? requestError.message : t.genericError); } finally { setLoading(null); } } function resetForm() { images.forEach((image) => URL.revokeObjectURL(image.url)); setDescription(""); setImages([]); clearDraft(); setError(""); } if (success) { return (

{t.successTitle}

{t.successText(success.issueNumber)}

{t.openIssue}
); } return (
{t.publicWarning}

{t.publicNotice}

1

{t.draftStep}