366 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|