Files
gitlab-instance-0a899031_cn…/app/components/ContentSubmissionModal.tsx
2026-06-07 23:18:53 +08:00

366 lines
16 KiB
TypeScript

"use client";
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
import { apiFetch, apiUrl } from "@/app/lib/api-client";
import { mediaUrl } from "@/app/lib/media";
type SubmissionType = "ebook" | "video";
interface FormState {
type: SubmissionType;
title: string;
subtitle: string;
description: string;
coverImage: string;
mediaUrl: string;
authorName: string;
authorEmail: string;
chaptersText: string;
}
interface ContentSubmissionModalProps {
open: boolean;
locale: string;
onClose: () => void;
submissionType?: SubmissionType;
}
const MB = 1024 * 1024;
const EMPTY_FORM: FormState = {
type: "ebook",
title: "",
subtitle: "",
description: "",
coverImage: "",
mediaUrl: "",
authorName: "",
authorEmail: "",
chaptersText: "",
};
export default function ContentSubmissionModal({ open, locale, onClose, submissionType }: ContentSubmissionModalProps) {
const isZh = locale === "zh";
const initialType = submissionType || "ebook";
const copy = useMemo(() => {
let title = isZh ? "投稿电子书 / 访谈视频" : "Submit ebook / interview";
if (submissionType === "ebook") {
title = isZh ? "投稿电子书" : "Submit ebook";
}
if (submissionType === "video") {
title = isZh ? "投稿访谈视频" : "Submit interview video";
}
return {
title,
close: isZh ? "关闭" : "Close",
ebook: isZh ? "电子书" : "Ebook",
video: isZh ? "访谈视频" : "Interview video",
name: isZh ? "作者昵称" : "Author name",
email: isZh ? "联系邮箱" : "Contact email",
contentTitle: isZh ? "标题" : "Title",
subtitle: isZh ? "一句话简介" : "Short subtitle",
description: isZh ? "内容简介" : "Description",
cover: isZh ? "封面图" : "Cover image",
coverUrl: isZh ? "封面图 URL" : "Cover image URL",
file: isZh ? "内容文件" : "Content file",
fileUrl: isZh ? "电子书 PDF/EPUB 或视频直链" : "PDF/EPUB or direct video URL",
chapters: isZh ? "目录 / 章节" : "Chapters",
chaptersHint: isZh ? "每行一个章节,可选" : "One chapter per line, optional",
uploadCover: isZh ? "上传封面" : "Upload cover",
uploadFile: isZh ? "上传文件" : "Upload file",
uploading: isZh ? "上传中..." : "Uploading...",
submit: isZh ? "提交审核" : "Submit for review",
submitting: isZh ? "提交中..." : "Submitting...",
success: isZh ? "已提交,审核通过后会展示在首页。" : "Submitted. It will appear after review.",
required: isZh ? "请填写标题、简介、封面、内容文件和联系邮箱。" : "Please complete title, description, cover, file and email.",
uploadFailed: isZh ? "上传失败" : "Upload failed",
fileTooLarge: isZh ? "文件过大" : "File too large",
review: isZh ? "状态:待审核" : "Status: pending review",
};
}, [isZh, submissionType]);
const [form, setForm] = useState<FormState>({ ...EMPTY_FORM, type: initialType });
const [uploading, setUploading] = useState<"coverImage" | "mediaUrl" | null>(null);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState("");
useEffect(() => {
if (open) {
setMessage("");
setError("");
setForm((prev) => ({ ...prev, type: initialType }));
}
}, [open, initialType]);
if (!open) return null;
const set = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
setError("");
};
const mediaMaxSize = form.type === "video" ? 200 * MB : 100 * MB;
const contentAccept =
form.type === "video"
? "video/mp4,video/webm,video/quicktime"
: "application/pdf,application/epub+zip,.pdf,.epub,.mobi,.azw3";
async function uploadFile(event: ChangeEvent<HTMLInputElement>, target: "coverImage" | "mediaUrl") {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (!file) return;
const limit = target === "coverImage" ? 20 * MB : mediaMaxSize;
if (file.size > limit) {
setError(`${copy.fileTooLarge},最大 ${Math.round(limit / MB)}MB`);
return;
}
setUploading(target);
setError("");
try {
const data = new FormData();
data.append("file", file);
const res = await fetch(apiUrl("/api/upload-media?purpose=content"), {
method: "POST",
body: data,
credentials: "include",
});
const json = (await res.json().catch(() => ({}))) as { ok?: boolean; url?: string; detail?: string; error?: string };
if (!res.ok || !json.ok || !json.url) {
throw new Error(json.detail || json.error || copy.uploadFailed);
}
setForm((prev) => ({ ...prev, [target]: json.url || "" }));
} catch (err) {
setError(err instanceof Error ? err.message : copy.uploadFailed);
} finally {
setUploading(null);
}
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const title = form.title.trim();
const description = form.description.trim();
const coverImage = form.coverImage.trim();
const mediaUrl = form.mediaUrl.trim();
const authorEmail = form.authorEmail.trim();
if (!title || !description || !coverImage || !mediaUrl || !authorEmail) {
setError(copy.required);
return;
}
setSubmitting(true);
setError("");
setMessage("");
try {
await apiFetch("/api/content/submissions", {
method: "POST",
body: JSON.stringify({
type: form.type,
title,
subtitle: form.subtitle.trim(),
description,
coverImage,
mediaUrl,
authorName: form.authorName.trim(),
authorEmail,
chapters: form.chaptersText
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => ({ title: line })),
}),
});
setMessage(copy.success);
setForm({ ...EMPTY_FORM, type: initialType });
} catch (err) {
setError(err instanceof Error ? err.message : "提交失败");
} finally {
setSubmitting(false);
}
}
const busy = submitting || Boolean(uploading);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-3 py-5 backdrop-blur-sm" role="dialog" aria-modal="true">
<div className="max-h-[92vh] w-full max-w-3xl overflow-y-auto rounded-lg bg-white shadow-2xl dark:bg-gray-950">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-gray-100 bg-white px-4 py-3 dark:border-gray-800 dark:bg-gray-950 sm:px-6">
<div>
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 sm:text-lg">{copy.title}</h2>
<p className="mt-0.5 text-xs font-medium text-[#ff4d4f]">{copy.review}</p>
</div>
<button
type="button"
onClick={onClose}
disabled={busy}
aria-label={copy.close}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 text-xl leading-none text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-900 disabled:opacity-50 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-gray-900 dark:hover:text-gray-100"
>
x
</button>
</div>
<form onSubmit={submit} className="space-y-5 px-4 py-5 sm:px-6">
{!submissionType && (
<div className="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1 dark:bg-gray-900">
{(["ebook", "video"] as SubmissionType[]).map((type) => (
<button
key={type}
type="button"
onClick={() => set("type", type)}
className={`rounded-md px-3 py-2 text-sm font-bold transition-colors ${
form.type === type
? "bg-[#ff4d4f] text-white shadow-sm"
: "text-gray-600 hover:bg-white dark:text-gray-300 dark:hover:bg-gray-800"
}`}
>
{type === "ebook" ? copy.ebook : copy.video}
</button>
))}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.name}</span>
<input
value={form.authorName}
onChange={(event) => set("authorName", event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
placeholder="Nomad"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.email}</span>
<input
type="email"
value={form.authorEmail}
onChange={(event) => set("authorEmail", event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
placeholder="you@example.com"
required
/>
</label>
</div>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.contentTitle}</span>
<input
value={form.title}
onChange={(event) => set("title", event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
required
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.subtitle}</span>
<input
value={form.subtitle}
onChange={(event) => set("subtitle", event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.description}</span>
<textarea
value={form.description}
onChange={(event) => set("description", event.target.value)}
className="min-h-28 w-full resize-y rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm leading-6 text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
required
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-1.5 flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.cover}</span>
<label className="cursor-pointer rounded-lg bg-gray-900 px-3 py-1.5 text-xs font-bold text-white transition-colors hover:bg-[#ff4d4f] dark:bg-gray-100 dark:text-gray-900">
{uploading === "coverImage" ? copy.uploading : copy.uploadCover}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
disabled={busy}
onChange={(event) => void uploadFile(event, "coverImage")}
/>
</label>
</div>
<input
value={form.coverImage}
onChange={(event) => set("coverImage", event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
placeholder={copy.coverUrl}
required
/>
{form.coverImage && (
<img src={mediaUrl(form.coverImage)} alt="" className="mt-3 h-32 w-full rounded-lg object-cover ring-1 ring-gray-100 dark:ring-gray-800" />
)}
</div>
<div>
<div className="mb-1.5 flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.file}</span>
<label className="cursor-pointer rounded-lg bg-gray-900 px-3 py-1.5 text-xs font-bold text-white transition-colors hover:bg-[#ff4d4f] dark:bg-gray-100 dark:text-gray-900">
{uploading === "mediaUrl" ? copy.uploading : copy.uploadFile}
<input
type="file"
accept={contentAccept}
className="hidden"
disabled={busy}
onChange={(event) => void uploadFile(event, "mediaUrl")}
/>
</label>
</div>
<input
value={form.mediaUrl}
onChange={(event) => set("mediaUrl", event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
placeholder={copy.fileUrl}
required
/>
<p className="mt-2 text-xs text-gray-400 dark:text-gray-500">Max {Math.round(mediaMaxSize / MB)}MB</p>
</div>
</div>
<label className="block">
<span className="mb-1.5 block text-sm font-semibold text-gray-700 dark:text-gray-300">{copy.chapters}</span>
<textarea
value={form.chaptersText}
onChange={(event) => set("chaptersText", event.target.value)}
className="min-h-20 w-full resize-y rounded-lg border border-gray-200 bg-white px-3 py-2.5 text-sm leading-6 text-gray-900 outline-none transition-colors focus:border-[#ff4d4f] dark:border-gray-800 dark:bg-gray-900 dark:text-gray-100"
placeholder={copy.chaptersHint}
/>
</label>
{error && <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm font-medium text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-200">{error}</div>}
{message && <div className="rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-medium text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200">{message}</div>}
<div className="flex flex-col-reverse gap-3 border-t border-gray-100 pt-4 dark:border-gray-800 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
disabled={busy}
className="rounded-lg border border-gray-200 px-5 py-2.5 text-sm font-bold text-gray-700 transition-colors hover:bg-gray-50 disabled:opacity-50 dark:border-gray-800 dark:text-gray-300 dark:hover:bg-gray-900"
>
{copy.close}
</button>
<button
type="submit"
disabled={busy}
className="rounded-lg bg-[#ff4d4f] px-5 py-2.5 text-sm font-bold text-white transition-colors hover:bg-[#ff7a45] disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? copy.submitting : copy.submit}
</button>
</div>
</form>
</div>
</div>
);
}