diff --git a/docs/CONTRIBUTORS.md b/docs/CONTRIBUTORS.md index 45c0cb0682..c3a3fb0198 100644 --- a/docs/CONTRIBUTORS.md +++ b/docs/CONTRIBUTORS.md @@ -1,4 +1,6 @@ -# Contributor certificate OAuth setup +# Website Worker setup + +## Contributor certificate OAuth The contributors page treats an account as eligible only when it has authored at least one pull request merged into `t8y2/dbx`. Commit counts are displayed as an additional metric but do not grant eligibility by themselves. @@ -18,3 +20,29 @@ pnpm dlx wrangler secret put SESSION_SECRET `SESSION_SECRET` should be a randomly generated value of at least 32 bytes. The OAuth flow requests no repository scopes, reads the authenticated user's public GitHub identity, and discards the access token immediately afterward. For a non-production callback origin, also configure `GITHUB_OAUTH_CALLBACK_URL` as a Worker secret matching the callback registered in the OAuth App. + +## Anonymous Issue submission + +The `/issue` and `/issues` routes redirect to the localized anonymous feedback page. Creating a draft consumes one allowance from both a per-IP and a temporary-session rolling limit of 8 attempts per hour. Final submission does not consume another allowance. Cloudflare Turnstile is intentionally not used. + +Create a GitHub App installed only on `t8y2/dbx` with repository permission `Issues: Read and write`, then configure: + +```bash +pnpm dlx wrangler secret put GITHUB_APP_ID +pnpm dlx wrangler secret put GITHUB_APP_PRIVATE_KEY_B64 +pnpm dlx wrangler secret put ISSUE_RATE_LIMIT_SECRET +``` + +`GITHUB_APP_PRIVATE_KEY_B64` accepts the base64-encoded PEM downloaded from the GitHub App. Generate a single-line value with `base64 < private-key.pem | tr -d '\n'`. `ISSUE_RATE_LIMIT_SECRET` should be an independent random value of at least 32 bytes. `SESSION_SECRET` is used as a fallback only to avoid breaking an already deployed Worker. + +Configure an OpenAI-compatible multimodal chat-completions provider: + +```bash +pnpm dlx wrangler secret put ISSUE_AI_API_BASE +pnpm dlx wrangler secret put ISSUE_AI_API_KEY +pnpm dlx wrangler secret put ISSUE_AI_MODEL +``` + +`ISSUE_AI_API_BASE` may end at the host, `/v1`, or `/chat/completions`. The model must accept `image_url` data URLs when screenshots are attached. + +`docs/wrangler.json` binds `ISSUE_IMAGES` to the existing `dbx` R2 bucket and publishes generated image URLs under `https://dl.dbxio.com` by default. Set `ISSUE_IMAGE_PUBLIC_BASE_URL` if the bucket's public origin changes. Set `ISSUE_GITHUB_REPOSITORY` only when testing against a different repository; production defaults to `t8y2/dbx`. diff --git a/docs/app/[lang]/issue/page.tsx b/docs/app/[lang]/issue/page.tsx new file mode 100644 index 0000000000..67169985a8 --- /dev/null +++ b/docs/app/[lang]/issue/page.tsx @@ -0,0 +1,40 @@ +import type { Metadata } from "next"; +import { LandingFooter } from "@/components/landing/LandingFooter"; +import { LandingNav } from "@/components/landing/LandingNav"; +import { IssueSubmissionClient } from "@/components/issues/IssueSubmissionClient"; +import { buildMetadata } from "@/lib/metadata"; + +const metadata = { + en: { + title: "Submit an Issue", + description: "Describe a DBX problem or suggestion without a GitHub account. Review the AI-polished draft before publishing.", + }, + cn: { + title: "匿名提交 Issue", + description: "无需 GitHub 账号,简短描述 DBX 问题或建议,由 AI 整理并在你确认后公开提交。", + }, +}; + +export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise { + const { lang } = await params; + const locale = lang === "cn" ? "cn" : "en"; + return buildMetadata({ + title: metadata[locale].title, + description: metadata[locale].description, + path: `/${locale}/issue`, + lang: locale, + }); +} + +export default async function IssuePage({ params }: { params: Promise<{ lang: string }> }) { + const { lang } = await params; + const locale = lang === "cn" ? "cn" : "en"; + + return ( +
+ + + +
+ ); +} diff --git a/docs/app/global.css b/docs/app/global.css index 4d328f987d..59c9662f81 100644 --- a/docs/app/global.css +++ b/docs/app/global.css @@ -1300,6 +1300,524 @@ a { } } +.issue-page { + --issue-ink: #f4f7fb; + --issue-muted: #9aa9bc; + --issue-line: rgba(155, 176, 205, 0.18); + --issue-panel: rgba(17, 21, 29, 0.84); + min-height: 100vh; + background: #0b1120; +} + +.issue-page::before, +.issue-page::after { + display: none; +} + +.issue-shell { + width: min(860px, calc(100% - 48px)); + min-height: calc(100vh - 80px); + margin: 0 auto; + padding: 96px 0 88px; +} + +.issue-success-card h1 { + color: var(--issue-ink); + font-size: 2rem; + font-weight: 800; + letter-spacing: -0.035em; + line-height: 1.1; +} + +.issue-layout, +.issue-workbench { + display: grid; + gap: 16px; +} + +.issue-public-note, +.issue-panel, +.issue-success-card { + border: 1px solid var(--issue-line); + border-radius: 12px; + background: var(--issue-panel); +} + +.issue-public-note { + padding: 13px 15px; +} + +.issue-warning-heading { + display: flex; + align-items: center; + gap: 8px; + color: #dce5ed; + font-size: 12px; +} + +.issue-warning-heading svg { + color: #e8b85c; +} + +.issue-public-note p { + margin-top: 6px; + color: var(--issue-muted); + font-size: 11px; + line-height: 1.5; +} + +.issue-panel { + padding: 24px; +} + +.issue-preview-panel { + animation: landing-rise 0.25s ease-out both; +} + +.issue-panel-heading { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 22px; +} + +.issue-step-number { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border: 1px solid rgba(110, 168, 255, 0.3); + border-radius: 50%; + background: rgba(110, 168, 255, 0.08); + color: #8eb9ff; + font-size: 12px; + font-weight: 700; +} + +.issue-panel-heading h1, +.issue-panel-heading h2 { + color: var(--issue-ink); + font-size: 17px; + font-weight: 720; + letter-spacing: -0.015em; +} + +.issue-field { + position: relative; + display: grid; + gap: 7px; +} + +.issue-field > span, +.issue-upload-block strong { + color: #dce5ed; + font-size: 13px; + font-weight: 650; +} + +.issue-field small, +.issue-upload-block small { + display: block; + color: var(--issue-muted); + font-size: 12px; + line-height: 1.55; +} + +.issue-field textarea, +.issue-field input, +.issue-field select { + width: 100%; + border: 1px solid rgba(155, 176, 205, 0.22); + border-radius: 9px; + outline: none; + background: rgba(6, 12, 20, 0.72); + color: var(--issue-ink); + font-size: 14px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.issue-field textarea { + min-height: 150px; + resize: vertical; + padding: 13px 14px 32px; + line-height: 1.7; +} + +.issue-field input, +.issue-field select { + height: 42px; + padding: 0 12px; +} + +.issue-field select { + cursor: pointer; + color-scheme: dark; +} + +.issue-field textarea::placeholder, +.issue-field input::placeholder { + color: #637387; +} + +.issue-field textarea:focus, +.issue-field input:focus, +.issue-field select:focus { + border-color: rgba(110, 168, 255, 0.72); + box-shadow: 0 0 0 3px rgba(110, 168, 255, 0.1); +} + +.issue-field textarea:disabled, +.issue-field input:disabled, +.issue-field select:disabled { + cursor: not-allowed; + opacity: 0.68; +} + +.issue-field em { + position: absolute; + right: 12px; + bottom: 9px; + color: #66778a; + font-size: 10px; + font-style: normal; +} + +.issue-upload-block { + display: grid; + gap: 12px; + margin-top: 20px; + border: 1px dashed var(--issue-line); + border-radius: 9px; + padding: 16px; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.issue-upload-block.is-dragging { + border-color: rgba(110, 168, 255, 0.72); + background: rgba(110, 168, 255, 0.07); +} + +.issue-upload-button { + display: inline-flex; + width: fit-content; + min-height: 38px; + cursor: pointer; + align-items: center; + gap: 7px; + border: 1px solid var(--issue-line); + border-radius: 8px; + padding: 0 13px; + background: rgba(255, 255, 255, 0.035); + color: #d6e1ea; + font-size: 12px; + font-weight: 620; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.issue-upload-button:hover { + border-color: rgba(110, 168, 255, 0.48); + background: rgba(110, 168, 255, 0.07); +} + +.issue-upload-button input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.issue-image-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.issue-image-preview { + position: relative; + aspect-ratio: 16 / 10; + border: 1px solid var(--issue-line); + border-radius: 8px; + background: #060b12; + overflow: hidden; +} + +.issue-image-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.issue-image-preview button { + position: absolute; + top: 6px; + right: 6px; + display: grid; + width: 27px; + height: 27px; + cursor: pointer; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 7px; + background: rgba(4, 8, 14, 0.82); + color: white; +} + +.issue-image-preview span { + position: absolute; + right: 6px; + bottom: 6px; + border-radius: 4px; + padding: 2px 5px; + background: rgba(4, 8, 14, 0.78); + color: #c4cfda; + font-size: 9px; +} + +.issue-action-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 22px; +} + +.issue-primary-button, +.issue-secondary-button, +.issue-submit-button { + display: inline-flex; + min-height: 42px; + cursor: pointer; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 8px; + padding: 0 16px; + font-size: 13px; + font-weight: 680; + transition: background 0.15s ease, border-color 0.15s ease, opacity 0.15s ease; +} + +.issue-primary-button, +.issue-submit-button { + border: 1px solid #f2f5f8; + background: #f2f5f8; + color: #0b1120; +} + +.issue-primary-button:hover:not(:disabled), +.issue-submit-button:hover:not(:disabled) { + border-color: white; + background: white; +} + +.issue-secondary-button { + border: 1px solid var(--issue-line); + background: transparent; + color: #d6e0e8; +} + +.issue-secondary-button:hover:not(:disabled) { + border-color: rgba(155, 176, 205, 0.38); + background: rgba(255, 255, 255, 0.035); +} + +.issue-primary-button:disabled, +.issue-secondary-button:disabled, +.issue-submit-button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.issue-ai-summary { + display: flex; + gap: 9px; + margin-bottom: 20px; + border-radius: 8px; + padding: 11px 12px; + background: rgba(110, 168, 255, 0.07); + color: #b8c8d6; + font-size: 12px; + line-height: 1.65; +} + +.issue-ai-summary svg { + margin-top: 2px; + flex: none; + color: #8eb9ff; +} + +.issue-preview-grid { + display: grid; + grid-template-columns: 170px minmax(0, 1fr); + gap: 12px; + margin-bottom: 18px; +} + +.issue-body-editor { + min-height: 360px !important; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12.5px !important; +} + +.issue-confirm-row { + display: grid; + grid-template-columns: 20px 1fr; + column-gap: 10px; + align-items: start; + cursor: pointer; + margin-top: 18px; + border: 1px solid var(--issue-line); + border-radius: 9px; + padding: 13px; + background: rgba(255, 255, 255, 0.025); +} + +.issue-confirm-row input { + position: absolute; + opacity: 0; +} + +.issue-confirm-row > span { + display: grid; + width: 20px; + height: 20px; + place-items: center; + border: 1px solid rgba(155, 176, 205, 0.4); + border-radius: 5px; + color: transparent; +} + +.issue-confirm-row input:focus-visible + span { + outline: 2px solid #8eb9ff; + outline-offset: 2px; +} + +.issue-confirm-row input:checked + span { + border-color: #75d6a5; + background: #75d6a5; + color: #07110c; +} + +.issue-confirm-row p { + grid-column: 2; + color: #b9c6d2; + font-size: 12px; + line-height: 1.6; +} + +.issue-submit-button { + width: 100%; + margin-top: 12px; +} + +.issue-error { + display: flex; + align-items: flex-start; + gap: 9px; + border: 1px solid rgba(251, 113, 133, 0.3); + border-radius: 9px; + padding: 12px 14px; + background: rgba(159, 18, 57, 0.12); + color: #fecdd3; + font-size: 12px; + line-height: 1.6; +} + +.issue-error svg { + margin-top: 1px; + flex: none; + color: #fb7185; +} + +.issue-spinner { + animation: issue-spin 0.8s linear infinite; +} + +.issue-success-shell { + display: grid; + place-items: center; +} + +.issue-success-card { + width: min(620px, 100%); + padding: 40px; + text-align: center; +} + +.issue-success-card h1 { + margin-top: 18px; +} + +.issue-success-card > p { + max-width: 500px; + margin: 14px auto 0; + color: var(--issue-muted); + line-height: 1.7; +} + +.issue-success-icon { + display: grid; + width: 54px; + height: 54px; + place-items: center; + margin: 0 auto; + border-radius: 50%; + background: rgba(117, 214, 165, 0.1); + color: #75d6a5; +} + +.issue-success-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; + margin-top: 26px; +} + +@media (max-width: 640px) { + .issue-shell { + width: min(100% - 28px, 860px); + padding: 84px 0 72px; + } + + .issue-panel, + .issue-public-note { + padding: 18px; + } + + .issue-preview-grid { + grid-template-columns: 1fr; + } + + .issue-image-grid { + grid-template-columns: 1fr 1fr; + } + + .issue-action-row, + .issue-primary-button { + width: 100%; + } + + .issue-action-row { + align-items: stretch; + } + + .issue-success-card { + padding: 28px 20px; + } + + .issue-success-actions, + .issue-success-actions a, + .issue-success-actions button { + width: 100%; + } +} + +@keyframes issue-spin { + to { + transform: rotate(360deg); + } +} /* Keyframes */ @keyframes landing-rise { @@ -1376,4 +1894,12 @@ a { opacity: 1 !important; transform: none !important; } + + .issue-page *, + .issue-page *::before, + .issue-page *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } } diff --git a/docs/components/issues/IssueSubmissionClient.tsx b/docs/components/issues/IssueSubmissionClient.tsx new file mode 100644 index 0000000000..7f9e05ebd9 --- /dev/null +++ b/docs/components/issues/IssueSubmissionClient.tsx @@ -0,0 +1,436 @@ +"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"; + +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 = { + 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_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_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]", +}; + +function responseErrorMessage(lang: "cn" | "en", code: string, retryAfter?: number): string { + const t = copy[lang]; + const base = t.errors[code 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: "cn" | "en" }) { + 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): 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 Error(responseErrorMessage(lang, code, retryAfter)); + } + return data as T; + } + + async function generateDraft() { + setLoading("draft"); + setError(""); + setSuccess(null); + const form = new FormData(); + form.set("description", description); + form.set("language", lang); + images.forEach((image) => form.append("images", image.file)); + try { + const response = await fetch("/api/issues/draft", { method: "POST", body: form }); + const data = await parseResponse(response); + 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}

+
+ +