s
This commit is contained in:
@@ -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);
|
||||
|
||||
352
app/components/ContentSubmissionModal.tsx
Normal file
352
app/components/ContentSubmissionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user