This commit is contained in:
root
2026-06-07 18:06:52 +08:00
parent 9de2465793
commit a1daa040ef
33 changed files with 1917 additions and 439 deletions

View File

@@ -74,12 +74,9 @@ interface CityDetailData {
export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
const { t } = useTranslation("cityModal");
const [activeTab, setActiveTab] = useState("scores");
const [mounted, setMounted] = useState(false);
const [favorited, setFavorited] = useState(false);
const [detailData, setDetailData] = useState<CityDetailData | null>(null);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!city || !isOpen) return;
const slug = city.slug || city.nameEn?.toLowerCase() || city.name.toLowerCase();
@@ -88,7 +85,7 @@ export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
.catch(() => setDetailData(null));
}, [city, isOpen]);
if (!isOpen || !city || !mounted) return null;
if (!isOpen || !city || typeof document === "undefined") return null;
const coverImage = city.coverImage ?? CITY_IMAGES[city.name] ?? `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`;
const reviewsCount = city.reviewsCount ?? city.nomadsNow * 4 + ((city.id * 97) % 500);

View File

@@ -0,0 +1,352 @@
"use client";
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
import { apiFetch, apiUrl } from "@/app/lib/api-client";
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;
}
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 }: ContentSubmissionModalProps) {
const isZh = locale === "zh";
const copy = useMemo(
() => ({
title: isZh ? "投稿电子书 / 访谈视频" : "Submit ebook / interview",
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]
);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
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("");
}
}, [open]);
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: form.type });
} 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">
<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={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>
);
}

View File

@@ -1,3 +1,5 @@
import { Link } from "@/i18n/navigation";
const meetups = [
{ city: "清迈", emoji: "🇹🇭", date: "3月9日 周一", attendees: 12 },
{ city: "曼谷", emoji: "🇹🇭", date: "3月12日 周四", attendees: 8 },
@@ -74,9 +76,9 @@ export default function Sidebar() {
<p className="text-xs text-emerald-700 bg-emerald-100/50 rounded-lg px-3 py-2 mb-3">
195+ ¥15/
</p>
<button className="w-full text-center text-sm font-semibold text-emerald-700 hover:text-emerald-800 bg-white border border-emerald-200 rounded-full py-2 transition-colors hover:bg-emerald-50">
<Link href="/services" className="block w-full text-center text-sm font-semibold text-emerald-700 hover:text-emerald-800 bg-white border border-emerald-200 rounded-full py-2 transition-colors hover:bg-emerald-50">
</button>
</Link>
<p className="text-[10px] text-gray-400 text-center mt-2">广</p>
</div>
@@ -109,9 +111,9 @@ export default function Sidebar() {
</div>
))}
</div>
<button className="w-full text-center text-xs font-medium text-gray-500 hover:text-gray-700 mt-3 pt-3 border-t border-gray-100 transition-colors">
<Link href="/meetups" className="block w-full text-center text-xs font-medium text-gray-500 hover:text-gray-700 mt-3 pt-3 border-t border-gray-100 transition-colors">
</button>
</Link>
</div>
{/* Traveling Now */}
@@ -167,9 +169,9 @@ export default function Sidebar() {
</h3>
<div className="space-y-3">
{hotTopics.map((t, i) => (
<a
<Link
key={t.title}
href="#"
href={`/discuss?q=${encodeURIComponent(t.title)}`}
className="flex items-start gap-2.5 group"
>
<span className="text-xs font-bold text-gray-300 mt-0.5 w-4 shrink-0">
@@ -183,12 +185,12 @@ export default function Sidebar() {
{t.views.toLocaleString()}
</p>
</div>
</a>
</Link>
))}
</div>
<button className="w-full text-center text-xs font-medium text-gray-500 hover:text-gray-700 mt-3 pt-3 border-t border-gray-100 transition-colors">
<Link href="/discuss" className="block w-full text-center text-xs font-medium text-gray-500 hover:text-gray-700 mt-3 pt-3 border-t border-gray-100 transition-colors">
</button>
</Link>
</div>
{/* Chat CTA */}
@@ -204,9 +206,9 @@ export default function Sidebar() {
</p>
</div>
</div>
<button className="w-full text-center text-sm font-semibold text-violet-700 hover:text-violet-800 bg-white border border-violet-200 rounded-full py-2 transition-colors hover:bg-violet-50">
<Link href="/join" className="block w-full text-center text-sm font-semibold text-violet-700 hover:text-violet-800 bg-white border border-violet-200 rounded-full py-2 transition-colors hover:bg-violet-50">
</button>
</Link>
</div>
{/* Visa Service */}
@@ -234,9 +236,9 @@ export default function Sidebar() {
🇮🇩
</span>
</div>
<button className="w-full text-center text-sm font-semibold text-amber-700 hover:text-amber-800 bg-white border border-amber-200 rounded-full py-2 transition-colors hover:bg-amber-50">
<Link href="/services" className="block w-full text-center text-sm font-semibold text-amber-700 hover:text-amber-800 bg-white border border-amber-200 rounded-full py-2 transition-colors hover:bg-amber-50">
</button>
</Link>
<p className="text-[10px] text-gray-400 text-center mt-2">广</p>
</div>
</div>

View File

@@ -80,17 +80,17 @@ export default memo(function SiteMenu({
titleKey: "help",
items: [
{ labelKey: "ideasBugs", href: "/feedback", icon: "💡" },
{ labelKey: "faq", href: "#", icon: "🆘" },
{ labelKey: "terms", href: "#", icon: "📄" },
{ labelKey: "changelog", href: "#", icon: "🚀" },
{ labelKey: "faq", href: "/help", icon: "🆘" },
{ labelKey: "terms", href: "/terms", icon: "📄" },
{ labelKey: "changelog", href: "/changelog", icon: "🚀" },
],
},
{
titleKey: "other",
items: [
{ labelKey: "digitalNomadGuide", href: "/digital", icon: "📖" },
{ labelKey: "remoteJobs", href: "#", icon: "💼" },
{ labelKey: "coworking", href: "#", icon: "🏢" },
{ labelKey: "remoteJobs", href: "/jobs", icon: "💼" },
{ labelKey: "coworking", href: "/services", icon: "🏢" },
],
},
];
@@ -100,7 +100,7 @@ export default memo(function SiteMenu({
{ labelKey: "settings", href: "/settings/notifications", icon: "⚙️" },
{ labelKey: "favorites", href: "/", icon: "❤️" },
{ labelKey: "pricing", href: "/pricing", icon: "👑" },
{ labelKey: "nomadInsurance", href: "#", icon: "🛡️", ad: true },
{ labelKey: "nomadInsurance", href: "/services", icon: "🛡️", ad: true },
];
const filteredSections = sections;

View File

@@ -1,30 +1,12 @@
"use client";
import { useTheme } from "@/app/context/ThemeContext";
import { useState, useEffect } from "react";
import { useTranslation } from "@/i18n/navigation";
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
const { t } = useTranslation("common");
const isDark = theme === "dark";
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<button
type="button"
className="rounded-lg p-2 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
aria-label={t("themeLight")}
>
<div className="h-5 w-5" />
</button>
);
}
return (
<button