Files
dbx/docs/components/issues/IssueSubmissionClient.tsx

520 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<IssueType, string> = {
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<SelectedImage[]>([]);
const [draftId, setDraftId] = useState("");
const [draftExpiresAt, setDraftExpiresAt] = useState(0);
const [issueType, setIssueType] = useState<IssueType>("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<SubmitResponse | null>(null);
const imagesRef = useRef<SelectedImage[]>([]);
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<HTMLDivElement>) {
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<HTMLDivElement>) {
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<T>(response: Response, hasImages = false): Promise<T> {
let data: Record<string, unknown> = {};
try {
data = (await response.json()) as Record<string, unknown>;
} 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<DraftResponse> {
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<DraftResponse>(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<SubmitResponse>(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 (
<section className="issue-shell issue-success-shell">
<div className="issue-success-card">
<span className="issue-success-icon"><CheckCircle2 size={30} /></span>
<h1>{t.successTitle}</h1>
<p>{t.successText(success.issueNumber)}</p>
<div className="issue-success-actions">
<a href={success.issueUrl} target="_blank" rel="noopener noreferrer" className="issue-primary-button">
{t.openIssue}<ArrowUpRight size={17} />
</a>
<button type="button" className="issue-secondary-button" onClick={resetForm}>
<RotateCcw size={16} />{t.another}
</button>
</div>
</div>
</section>
);
}
return (
<section className="issue-shell">
<div className="issue-layout">
<div className="issue-workbench" onPaste={pasteImages}>
<section className="issue-public-note">
<div className="issue-warning-heading"><ShieldAlert size={15} /><strong>{t.publicWarning}</strong></div>
<p>{t.publicNotice}</p>
</section>
<section className="issue-panel">
<div className="issue-panel-heading">
<span className="issue-step-number">1</span>
<h1>{t.draftStep}</h1>
</div>
<label className="issue-field">
<span>{t.descriptionLabel}</span>
<small>{t.descriptionHint}</small>
<textarea value={description} onChange={(event) => changeDescription(event.target.value)} placeholder={t.descriptionPlaceholder} maxLength={6000} rows={7} disabled={loading !== null} />
<em>{description.length} / 6000</em>
</label>
<div
className={`issue-upload-block${draggingImages ? " is-dragging" : ""}`}
onDragEnter={(event) => { event.preventDefault(); setDraggingImages(true); }}
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setDraggingImages(true); }}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDraggingImages(false);
}}
onDrop={dropImages}
>
<div><strong>{t.attachments}</strong><small>{t.attachmentHint}</small></div>
{images.length > 0 && (
<div className="issue-image-grid">
{images.map((image, index) => (
<div className="issue-image-preview" key={`${image.file.name}-${image.file.lastModified}-${index}`}>
<img src={image.url} alt={image.file.name} />
<button type="button" onClick={() => removeImage(index)} aria-label={t.removeImage} disabled={loading !== null}><X size={15} /></button>
<span>{Math.max(1, Math.round(image.file.size / 1024))} KB</span>
</div>
))}
</div>
)}
{images.length < 3 && (
<label className="issue-upload-button">
<ImagePlus size={17} />{t.addImages}
<input type="file" accept="image/png,image/jpeg,image/webp" multiple onChange={(event) => { addImages(event.target.files); event.currentTarget.value = ""; }} disabled={loading !== null} />
</label>
)}
</div>
<div className="issue-action-row">
<button type="button" className="issue-primary-button" onClick={generateDraft} disabled={loading !== null || description.trim().length < 4}>
{loading === "draft" ? <LoaderCircle className="issue-spinner" size={18} /> : <Sparkles size={18} />}
{loading === "draft" ? t.generating : draftId ? t.generateAgain : t.generate}
</button>
</div>
</section>
{draftId && (
<section className="issue-panel issue-preview-panel">
<div className="issue-panel-heading">
<span className="issue-step-number">2</span>
<h2>{t.previewStep}</h2>
</div>
<div className="issue-ai-summary"><Sparkles size={16} /><p>{summary}</p></div>
<div className="issue-preview-grid">
<label className="issue-field issue-type-field">
<span>{t.type}</span>
<select value={issueType} onChange={(event) => changeIssueType(event.target.value as IssueType)} disabled={loading !== null}>
<option value="bug">Bug</option>
<option value="feature">Feature</option>
<option value="question">Question</option>
<option value="compatibility">Compatibility</option>
</select>
</label>
<label className="issue-field issue-title-field">
<span>{t.titleLabel}</span>
<input value={title} onChange={(event) => { setTitle(event.target.value); setConfirmed(false); }} maxLength={160} disabled={loading !== null} />
</label>
</div>
<label className="issue-field">
<span>{t.bodyLabel}</span>
<small>{t.bodyHint}</small>
<textarea className="issue-body-editor" value={body} onChange={(event) => { setBody(event.target.value); setConfirmed(false); }} maxLength={12000} rows={18} disabled={loading !== null} />
<em>{body.length} / 12000</em>
</label>
<label className="issue-confirm-row">
<input type="checkbox" checked={confirmed} onChange={(event) => setConfirmed(event.target.checked)} disabled={loading !== null} />
<span aria-hidden="true"><Check size={14} /></span>
<p>{t.confirm}</p>
</label>
<button type="button" className="issue-submit-button" onClick={submitIssue} disabled={!confirmed || loading !== null || title.trim().length < 8 || body.trim().length < 20}>
{loading === "submit" ? <LoaderCircle className="issue-spinner" size={18} /> : <ArrowUpRight size={18} />}
{loading === "submit" ? t.submitting : t.submit}
</button>
</section>
)}
{error && <div className="issue-error" role="alert"><ShieldAlert size={18} /><span>{error}</span></div>}
</div>
</div>
</section>
);
}