mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-04 10:09:15 +08:00
520 lines
24 KiB
TypeScript
520 lines
24 KiB
TypeScript
"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>
|
||
);
|
||
}
|