ss
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
# ========== 与 payjsapi/digital/nomadvip 同源 ==========
|
||||
APP_ENV=development
|
||||
NEXT_PUBLIC_APP_ENV=development
|
||||
NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn
|
||||
POCKETBASE_JOIN_COLLECTION=solan
|
||||
POCKETBASE_EMAIL=xiaoshuang.eric@gmail.com
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# Linux 生产环境 - nomadro.cn
|
||||
APP_ENV=production
|
||||
NEXT_PUBLIC_APP_ENV=production
|
||||
ROOT_DOMAIN=nomadro.cn
|
||||
NEXT_PUBLIC_ROOT_DOMAIN=nomadro.cn
|
||||
NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.nomadro.cn
|
||||
|
||||
@@ -4,6 +4,7 @@ import { use, useEffect, useState } from "react";
|
||||
import { Link, useLocale } from "@/i18n/navigation";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import ContentSubmissionModal from "@/app/components/ContentSubmissionModal";
|
||||
|
||||
interface ContentItem {
|
||||
slug: string;
|
||||
@@ -33,6 +34,7 @@ export default function EbookPage({ params }: { params: Promise<{ slug: string }
|
||||
const locale = useLocale();
|
||||
const [item, setItem] = useState<ContentItem | null>(null);
|
||||
const [cities, setCities] = useState<CityLite[]>([]);
|
||||
const [submissionOpen, setSubmissionOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ item: ContentItem; cities: CityLite[] }>(`/api/content/${slug}`)
|
||||
@@ -89,6 +91,13 @@ export default function EbookPage({ params }: { params: Promise<{ slug: string }
|
||||
<Link href="/dashboard" className="rounded-full bg-[#ff4d4f] px-5 py-3 text-sm font-semibold text-white hover:bg-[#ff7a45]">
|
||||
用城市数据规划路线
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubmissionOpen(true)}
|
||||
className="rounded-full border border-[#ff4d4f] px-5 py-3 text-sm font-semibold text-[#ff4d4f] hover:bg-[#ff4d4f]/10"
|
||||
>
|
||||
{locale === "zh" ? "投稿电子书" : "Submit ebook"}
|
||||
</button>
|
||||
<Link href="/" className="rounded-full border border-gray-300 px-5 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">
|
||||
返回首页
|
||||
</Link>
|
||||
@@ -109,6 +118,12 @@ export default function EbookPage({ params }: { params: Promise<{ slug: string }
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<ContentSubmissionModal
|
||||
open={submissionOpen}
|
||||
locale={locale}
|
||||
submissionType="ebook"
|
||||
onClose={() => setSubmissionOpen(false)}
|
||||
/>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { Link, useLocale, useRouter } from "@/i18n/navigation";
|
||||
import { Link, useLocale } from "@/i18n/navigation";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import HeroSection from "../components/HeroSection";
|
||||
import FilterBar from "../components/FilterBar";
|
||||
import CityCard from "../components/CityCard";
|
||||
import CityModal from "../components/CityModal";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import ContentSubmissionModal from "@/app/components/ContentSubmissionModal";
|
||||
import { cities, type City } from "../data/cities";
|
||||
import { HOME_CONFIG } from "@/config";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
const {
|
||||
memberPhotos: fallbackMemberPhotos,
|
||||
travelingNow: fallbackTravelingNow,
|
||||
meetups: fallbackHomeMeetups,
|
||||
hotTopics: fallbackHotTopics,
|
||||
routes: fallbackRoutes,
|
||||
stats: fallbackStats,
|
||||
communityStats: fallbackCommunityStats,
|
||||
} = HOME_CONFIG;
|
||||
@@ -106,26 +102,6 @@ interface MeetupItem {
|
||||
venue?: string;
|
||||
}
|
||||
|
||||
interface TopicItem {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
views: number;
|
||||
}
|
||||
|
||||
interface RouteItem {
|
||||
id?: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
durationDays: number;
|
||||
budget: number;
|
||||
description: string;
|
||||
descriptionEn?: string;
|
||||
stops?: string[];
|
||||
cities?: City[];
|
||||
}
|
||||
|
||||
interface HomeStats {
|
||||
cities: string;
|
||||
nomads: string;
|
||||
@@ -153,14 +129,9 @@ export default function Home() {
|
||||
const [homeContent, setHomeContent] = useState<ContentItem[]>(FALLBACK_HOME_CONTENT);
|
||||
const [homeStats, setHomeStats] = useState<HomeStats>(fallbackStats);
|
||||
const [memberPhotos, setMemberPhotos] = useState(fallbackMemberPhotos);
|
||||
const [travelingNow, setTravelingNow] = useState(fallbackTravelingNow);
|
||||
const [homeMeetups, setHomeMeetups] = useState(fallbackHomeMeetups);
|
||||
const [hotTopics, setHotTopics] = useState(fallbackHotTopics);
|
||||
const [routes, setRoutes] = useState<RouteItem[]>([]);
|
||||
const [communityStats, setCommunityStats] = useState(fallbackCommunityStats);
|
||||
const [submissionOpen, setSubmissionOpen] = useState(false);
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation("home");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -196,19 +167,9 @@ export default function Home() {
|
||||
const profiles = (data.items || []).slice(0, 12);
|
||||
if (profiles.length) {
|
||||
setMemberPhotos(profiles.map((profile) => ({ name: profile.name, photo: profile.photo })));
|
||||
setTravelingNow(
|
||||
profiles.map((profile) => ({
|
||||
name: profile.name,
|
||||
photo: profile.photo,
|
||||
dest: profile.location || "远程",
|
||||
}))
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setMemberPhotos(fallbackMemberPhotos);
|
||||
setTravelingNow(fallbackTravelingNow);
|
||||
});
|
||||
.catch(() => setMemberPhotos(fallbackMemberPhotos));
|
||||
apiFetch<{ items: MeetupItem[] }>("/api/meetups")
|
||||
.then((data) => {
|
||||
const meetups = (data.items || []).slice(0, 6);
|
||||
@@ -224,25 +185,6 @@ export default function Home() {
|
||||
}
|
||||
})
|
||||
.catch(() => setHomeMeetups(fallbackHomeMeetups));
|
||||
apiFetch<{ items: TopicItem[] }>("/api/discussions")
|
||||
.then((data) => {
|
||||
const topics = [...(data.items || [])]
|
||||
.sort((a, b) => Number(b.views || 0) - Number(a.views || 0))
|
||||
.slice(0, 5);
|
||||
if (topics.length) {
|
||||
setHotTopics(
|
||||
topics.map((topic) => ({
|
||||
titleZh: topic.title,
|
||||
titleEn: topic.titleEn || topic.title,
|
||||
views: Number(topic.views || 0),
|
||||
}))
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => setHotTopics(fallbackHotTopics));
|
||||
apiFetch<{ items: RouteItem[] }>("/api/routes")
|
||||
.then((data) => setRoutes(data.items || []))
|
||||
.catch(() => setRoutes([]));
|
||||
}, []);
|
||||
|
||||
const filteredCities = useMemo(() => {
|
||||
@@ -271,38 +213,11 @@ export default function Home() {
|
||||
return result;
|
||||
}, [activeFilter, sortBy, liveCities]);
|
||||
|
||||
const displayRoutes = routes.length
|
||||
? routes
|
||||
: fallbackRoutes.map((route, index) => ({
|
||||
id: `fallback-${index}`,
|
||||
slug: route.titleEn.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||
title: route.titleZh,
|
||||
titleEn: route.titleEn,
|
||||
durationDays: Number(route.durationZh.match(/\d+/)?.[0] || 30),
|
||||
budget: Number(route.costZh.replace(/[^\d]/g, "")) || 3000,
|
||||
description: route.descZh,
|
||||
descriptionEn: route.descEn,
|
||||
stops: route.citiesZh,
|
||||
cities: route.citiesZh.map((name, i) => ({
|
||||
id: i,
|
||||
name,
|
||||
nameEn: route.citiesEn[i],
|
||||
icon: route.emojis[i],
|
||||
} as City)),
|
||||
}));
|
||||
|
||||
const communityContent = useMemo(
|
||||
() => homeContent.filter((item) => item.type === "ebook" || item.type === "video"),
|
||||
const homepageFeatureContent = useMemo(
|
||||
() => homeContent.slice(0, 4),
|
||||
[homeContent]
|
||||
);
|
||||
|
||||
const cityTags = [
|
||||
{ key: "dali", emoji: "🏯", zh: "大理", en: "Dali" },
|
||||
{ key: "chengdu", emoji: "🐼", zh: "成都", en: "Chengdu" },
|
||||
{ key: "shenzhen", emoji: "🌃", zh: "深圳", en: "Shenzhen" },
|
||||
{ key: "hangzhou", emoji: "⛲", zh: "杭州", en: "Hangzhou" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
<HeroSection />
|
||||
@@ -342,7 +257,7 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
<div className="items-grid">
|
||||
{homeContent.slice(0, 4).map((item, index) => {
|
||||
{homepageFeatureContent.map((item, index) => {
|
||||
const title = locale === "zh" ? item.title : item.titleEn || item.title;
|
||||
const subtitle = locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle;
|
||||
const cta = locale === "zh" ? item.ctaLabel : item.ctaLabelEn || item.ctaLabel;
|
||||
@@ -394,30 +309,7 @@ export default function Home() {
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubmissionOpen(true)}
|
||||
className="right-item block cursor-pointer text-left"
|
||||
style={{ gridColumn: "-2 / -1", gridRow: 5 }}
|
||||
>
|
||||
<div className="right-item-header">共创投稿</div>
|
||||
<div className="flex h-[calc(100%-38px)] flex-col justify-center p-3 sm:h-[calc(100%-42px)] sm:p-5">
|
||||
<span className="mb-2 inline-flex w-fit rounded-full bg-[#ff4d4f]/10 px-2.5 py-1 text-[10px] font-bold text-[#ff4d4f] sm:text-xs">
|
||||
UGC
|
||||
</span>
|
||||
<p className="text-xs font-bold leading-snug text-gray-900 dark:text-gray-100 sm:text-sm">
|
||||
投稿电子书 / 访谈视频
|
||||
</p>
|
||||
<p className="mt-2 line-clamp-3 text-[10px] leading-relaxed text-gray-500 dark:text-gray-400 sm:text-xs">
|
||||
用户上传内容后进入审核队列,通过后展示在首页内容区。
|
||||
</p>
|
||||
<span className="mt-3 inline-flex w-fit rounded-full bg-[#ff4d4f] px-3 py-1.5 text-[10px] font-semibold text-white sm:text-xs">
|
||||
提交审核
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Link href="/join" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 6 }}>
|
||||
<Link href="/join" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: homepageFeatureContent.length + 1 }}>
|
||||
<div className="right-item-header">{t("joinCommunity")}</div>
|
||||
<div className="flex flex-col items-center justify-center text-center p-2 sm:p-5 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
|
||||
<span className="text-2xl sm:text-5xl mb-1 sm:mb-3">💬</span>
|
||||
@@ -433,7 +325,7 @@ export default function Home() {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/dating" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 7 }}>
|
||||
<Link href="/dating" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: homepageFeatureContent.length + 2 }}>
|
||||
<div className="right-item-header">{t("newMembers")}</div>
|
||||
<div className="p-2 sm:p-5 flex flex-wrap gap-1 sm:gap-2 content-start">
|
||||
{memberPhotos.map((m) => (
|
||||
@@ -451,7 +343,7 @@ export default function Home() {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/meetups" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 8 }}>
|
||||
<Link href="/meetups" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: homepageFeatureContent.length + 3 }}>
|
||||
<div className="right-item-header">{t("latestEvents")}</div>
|
||||
<div className="p-1.5 sm:p-4 flex flex-col gap-0.5 sm:gap-2.5 text-[10px] sm:text-sm text-gray-700 dark:text-gray-300 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
|
||||
{homeMeetups.slice(0, 3).map((meetup) => (
|
||||
@@ -501,274 +393,11 @@ export default function Home() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="max-w-[1500px] mx-auto px-3 sm:px-6 md:px-10 pt-8 sm:pt-12 pb-4">
|
||||
<div className="mb-5 flex flex-col gap-3 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<span className="w-1 h-5 bg-[#ff4d4f] rounded-full" />
|
||||
电子书 / 访谈视频
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubmissionOpen(true)}
|
||||
className="w-fit rounded-lg bg-[#ff4d4f] px-4 py-2 text-sm font-bold text-white transition-colors hover:bg-[#ff7a45]"
|
||||
>
|
||||
投稿内容
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{communityContent.slice(0, 8).map((item) => {
|
||||
const title = locale === "zh" ? item.title : item.titleEn || item.title;
|
||||
const subtitle = locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle;
|
||||
const cta = locale === "zh" ? item.ctaLabel : item.ctaLabelEn || item.ctaLabel;
|
||||
const href = item.type === "video" ? `/videos/${item.slug}` : `/ebooks/${item.slug}`;
|
||||
return (
|
||||
<Link
|
||||
key={`${item.type}-${item.slug}`}
|
||||
href={href}
|
||||
className="group overflow-hidden rounded-lg bg-white shadow-sm ring-1 ring-gray-100 transition-transform hover:-translate-y-0.5 dark:bg-gray-900 dark:ring-gray-800"
|
||||
>
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-gray-100 dark:bg-gray-800">
|
||||
<img src={item.coverImage} alt={title} className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" />
|
||||
<span className="absolute left-3 top-3 rounded-full bg-white/90 px-2.5 py-1 text-[11px] font-bold text-gray-900">
|
||||
{item.type === "ebook" ? "电子书" : "访谈视频"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="line-clamp-2 text-sm font-bold leading-snug text-gray-900 dark:text-gray-100">{title}</h3>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-gray-500 dark:text-gray-400">{subtitle}</p>
|
||||
<span className="mt-4 inline-flex text-xs font-bold text-[#ff4d4f]">{cta}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="max-w-[1500px] mx-auto px-3 sm:px-6 md:px-10 pt-8 sm:pt-12 pb-4">
|
||||
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 mb-5 sm:mb-6 flex items-center gap-2">
|
||||
<span className="w-1 h-5 bg-[#ff4d4f] rounded-full" />
|
||||
{t("hotRoutes")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
|
||||
{displayRoutes.map((route) => (
|
||||
<Link
|
||||
key={route.slug}
|
||||
href={`/dashboard?route=${encodeURIComponent(route.slug)}`}
|
||||
className="community-card group cursor-pointer block"
|
||||
>
|
||||
<div className="h-2 bg-gradient-to-r from-[#ff4d4f] to-[#ff7a45]" />
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">
|
||||
{locale === "zh" ? route.title : route.titleEn || route.title}
|
||||
</h3>
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
|
||||
{route.durationDays} 天
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mb-3">
|
||||
{(route.cities || []).slice(0, 4).map((city, i) => (
|
||||
<span key={i} className="flex items-center gap-0.5 text-sm">
|
||||
<span>{city.icon || "📍"}</span>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{locale === "zh" ? city.name : city.nameEn || city.name}
|
||||
</span>
|
||||
{i < (route.cities || []).slice(0, 4).length - 1 && (
|
||||
<span className="text-gray-300 dark:text-gray-600 mx-1">→</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 leading-relaxed">
|
||||
{locale === "zh" ? route.description : route.descriptionEn || route.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
¥{route.budget.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-[#ff4d4f] font-medium group-hover:translate-x-1 transition-transform">
|
||||
{t("routeDetails")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="community" className="max-w-[1500px] mx-auto px-3 sm:px-6 md:px-10 py-8 sm:py-12">
|
||||
<h2 className="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 mb-5 sm:mb-6 flex items-center gap-2">
|
||||
<span className="w-1 h-5 bg-[#ff4d4f] rounded-full" />
|
||||
{t("communityDynamics")}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
|
||||
<Link href="/meetups" className="community-card block">
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm flex items-center gap-1.5">
|
||||
🍹 {t("recentMeetups")}
|
||||
</h3>
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
|
||||
{homeMeetups.length}{t("perMonth")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{homeMeetups.map((m) => (
|
||||
<div key={m.city} className="flex items-center justify-between py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">{m.emoji}</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-gray-200">{m.city}</p>
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-500">{m.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
|
||||
{m.count}{t("people")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="block w-full text-center text-xs font-medium text-[#ff4d4f] hover:text-[#ff7a45] mt-4 pt-3 border-t border-gray-100 dark:border-gray-700 transition-colors">
|
||||
{t("viewMore")}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="community-card">
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm flex items-center gap-1.5">
|
||||
🛩️ {t("travelingNow")}
|
||||
</h3>
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
|
||||
{travelingNow.length}{t("people")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{travelingNow.slice(0, 16).map((m) => (
|
||||
<img
|
||||
key={m.name}
|
||||
src={m.photo}
|
||||
alt={m.name}
|
||||
title={`${m.name} · ${m.dest}`}
|
||||
className="w-9 h-9 rounded-full shadow-sm cursor-pointer hover:scale-110 transition-transform object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href="/discuss" className="community-card block">
|
||||
<div className="p-4 sm:p-5">
|
||||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm flex items-center gap-1.5 mb-4">
|
||||
🔥 {t("hotDiscussions")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{hotTopics.slice(0, 5).map((t_item, i) => (
|
||||
<div key={t_item.titleZh} className="flex items-start gap-2.5 group">
|
||||
<span className={`text-xs font-bold mt-0.5 w-4 shrink-0 ${i < 3 ? "text-[#ff4d4f]" : "text-gray-300 dark:text-gray-600"}`}>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="min-w-0 text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 transition-colors line-clamp-1 leading-snug">
|
||||
{locale === "zh" ? t_item.titleZh : t_item.titleEn}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-500 mt-0.5">
|
||||
{t_item.views.toLocaleString()} {t("views")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="block w-full text-center text-xs font-medium text-[#ff4d4f] hover:text-[#ff7a45] mt-4 pt-3 border-t border-gray-100 dark:border-gray-700 transition-colors">
|
||||
{t("viewAllTopics")}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/services" className="community-card bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-950/30 dark:to-teal-950/30 block">
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="text-2xl">🛡️</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">{t("travelInsurance")}</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{t("travelInsuranceDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-emerald-700 dark:text-emerald-400 bg-emerald-100/50 dark:bg-emerald-900/30 rounded-lg px-3 py-2 mb-3">
|
||||
{t("insuranceCoverage")}
|
||||
</p>
|
||||
<div className="w-full text-center text-sm font-semibold text-emerald-700 dark:text-emerald-400 hover:text-emerald-800 dark:hover:text-emerald-300 bg-white/80 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 border border-emerald-200 dark:border-emerald-800 rounded-full py-2 transition-colors">
|
||||
{t("learnMore")}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/join" className="community-card bg-gradient-to-br from-violet-50 to-purple-50 dark:from-violet-950/30 dark:to-purple-950/30 block">
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="text-2xl">💬</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">{t("communityChat")}</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{locale === "zh" ? `本月 ${communityStats.messagesPerMonth} 条消息` : `${communityStats.messagesPerMonth} messages this month`}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex -space-x-2">
|
||||
{memberPhotos.slice(0, 6).map((m) => (
|
||||
<img
|
||||
key={m.name}
|
||||
src={m.photo}
|
||||
alt=""
|
||||
className="w-7 h-7 rounded-full border-2 border-white dark:border-gray-800 shadow-sm object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">{t("onlineNow")}</span>
|
||||
</div>
|
||||
<div className="w-full text-center text-sm font-semibold text-violet-700 dark:text-violet-400 hover:text-violet-800 dark:hover:text-violet-300 bg-white/80 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 border border-violet-200 dark:border-violet-800 rounded-full py-2 transition-colors">
|
||||
{t("joinChat")}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/tools" className="community-card bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30 block">
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="text-2xl">🏛️</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">{t("landingGuide")}</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{t("landingGuideDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{cityTags.map((tag) => (
|
||||
<button
|
||||
key={tag.key}
|
||||
onClick={() => router.push(`/city/${tag.key}`)}
|
||||
className="text-[11px] bg-amber-100/60 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full px-2 py-0.5 cursor-pointer hover:bg-amber-200 dark:hover:bg-amber-800 transition-colors"
|
||||
>
|
||||
{tag.emoji} {locale === "zh" ? tag.zh : tag.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full text-center text-sm font-semibold text-amber-700 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-300 bg-white/80 dark:bg-gray-800/80 hover:bg-white dark:hover:bg-gray-800 border border-amber-200 dark:border-amber-800 rounded-full py-2 transition-colors">
|
||||
{t("viewGuides")}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CityModal
|
||||
city={cityModal}
|
||||
isOpen={!!cityModal}
|
||||
onClose={() => setCityModal(null)}
|
||||
/>
|
||||
<ContentSubmissionModal open={submissionOpen} locale={locale} onClose={() => setSubmissionOpen(false)} />
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { use, useEffect, useState } from "react";
|
||||
import { Link, useLocale } from "@/i18n/navigation";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import ContentSubmissionModal from "@/app/components/ContentSubmissionModal";
|
||||
|
||||
interface ContentItem {
|
||||
slug: string;
|
||||
@@ -33,6 +34,7 @@ export default function VideoPage({ params }: { params: Promise<{ slug: string }
|
||||
const locale = useLocale();
|
||||
const [item, setItem] = useState<ContentItem | null>(null);
|
||||
const [cities, setCities] = useState<CityLite[]>([]);
|
||||
const [submissionOpen, setSubmissionOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ item: ContentItem; cities: CityLite[] }>(`/api/content/${slug}`)
|
||||
@@ -54,7 +56,16 @@ export default function VideoPage({ params }: { params: Promise<{ slug: string }
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0f172a] text-white">
|
||||
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Link href="/" className="text-sm text-white/60 hover:text-white">← 返回首页</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubmissionOpen(true)}
|
||||
className="rounded-full border border-[#ff7a45] px-4 py-2 text-sm font-semibold text-[#ffb088] hover:bg-[#ff7a45]/10"
|
||||
>
|
||||
{locale === "zh" ? "投稿访谈视频" : "Submit interview video"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 overflow-hidden rounded-2xl bg-black shadow-2xl">
|
||||
{item.mediaUrl ? (
|
||||
<video src={item.mediaUrl} poster={item.coverImage} controls playsInline className="aspect-video w-full bg-black object-contain" />
|
||||
@@ -94,6 +105,12 @@ export default function VideoPage({ params }: { params: Promise<{ slug: string }
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<ContentSubmissionModal
|
||||
open={submissionOpen}
|
||||
locale={locale}
|
||||
submissionType="video"
|
||||
onClose={() => setSubmissionOpen(false)}
|
||||
/>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ interface ContentSubmissionModalProps {
|
||||
open: boolean;
|
||||
locale: string;
|
||||
onClose: () => void;
|
||||
submissionType?: SubmissionType;
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
@@ -37,11 +38,20 @@ const EMPTY_FORM: FormState = {
|
||||
chaptersText: "",
|
||||
};
|
||||
|
||||
export default function ContentSubmissionModal({ open, locale, onClose }: ContentSubmissionModalProps) {
|
||||
export default function ContentSubmissionModal({ open, locale, onClose, submissionType }: ContentSubmissionModalProps) {
|
||||
const isZh = locale === "zh";
|
||||
const copy = useMemo(
|
||||
() => ({
|
||||
title: isZh ? "投稿电子书 / 访谈视频" : "Submit ebook / interview",
|
||||
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",
|
||||
@@ -66,11 +76,10 @@ export default function ContentSubmissionModal({ open, locale, onClose }: Conten
|
||||
uploadFailed: isZh ? "上传失败" : "Upload failed",
|
||||
fileTooLarge: isZh ? "文件过大" : "File too large",
|
||||
review: isZh ? "状态:待审核" : "Status: pending review",
|
||||
}),
|
||||
[isZh]
|
||||
);
|
||||
};
|
||||
}, [isZh, submissionType]);
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
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("");
|
||||
@@ -80,8 +89,9 @@ export default function ContentSubmissionModal({ open, locale, onClose }: Conten
|
||||
if (open) {
|
||||
setMessage("");
|
||||
setError("");
|
||||
setForm((prev) => ({ ...prev, type: initialType }));
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, initialType]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -164,7 +174,7 @@ export default function ContentSubmissionModal({ open, locale, onClose }: Conten
|
||||
}),
|
||||
});
|
||||
setMessage(copy.success);
|
||||
setForm({ ...EMPTY_FORM, type: form.type });
|
||||
setForm({ ...EMPTY_FORM, type: initialType });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "提交失败");
|
||||
} finally {
|
||||
@@ -194,6 +204,7 @@ export default function ContentSubmissionModal({ open, locale, onClose }: Conten
|
||||
</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
|
||||
@@ -210,6 +221,7 @@ export default function ContentSubmissionModal({ open, locale, onClose }: Conten
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* 本地开发不设置 domain,避免 IP 访问时 cookie 失效
|
||||
*/
|
||||
|
||||
import { IS_DEVELOPMENT, IS_PRODUCTION } from "@/config/runtime-env";
|
||||
|
||||
const COOKIE_NAME = "pb_session";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
@@ -16,18 +18,17 @@ export function getHostFromRequest(request: { headers: { get: (k: string) => str
|
||||
}
|
||||
|
||||
export function getCookieOptions(clear = false, requestHost?: string) {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const domain = process.env.AUTH_COOKIE_DOMAIN?.trim();
|
||||
const opts: Record<string, unknown> = {
|
||||
path: "/",
|
||||
maxAge: clear ? 0 : COOKIE_MAX_AGE,
|
||||
secure: isProd,
|
||||
secure: IS_PRODUCTION,
|
||||
sameSite: "lax" as const,
|
||||
httpOnly: true,
|
||||
};
|
||||
if (domain && isProd) {
|
||||
if (domain && IS_PRODUCTION) {
|
||||
opts.domain = domain;
|
||||
} else if (!isProd && requestHost && /^(\d{1,3}\.){3}\d{1,3}$/.test(requestHost)) {
|
||||
} else if (IS_DEVELOPMENT && requestHost && /^(\d{1,3}\.){3}\d{1,3}$/.test(requestHost)) {
|
||||
opts.domain = requestHost;
|
||||
}
|
||||
return opts;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { DEFAULT_PAYMENT_API_URL, DEFAULT_SITE_URL } from "@/config/digital/domain.config";
|
||||
import { IS_DEVELOPMENT } from "@/config/runtime-env";
|
||||
|
||||
/** 支付 API 地址(payjsapi) */
|
||||
export const PAYMENT_API_URL =
|
||||
@@ -14,4 +15,4 @@ export const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL || DEFAULT_SITE_URL;
|
||||
|
||||
/** 是否为本地开发 */
|
||||
export const IS_DEV = process.env.NODE_ENV === "development";
|
||||
export const IS_DEV = IS_DEVELOPMENT;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* 本地开发不设置 domain,避免 IP 访问时 cookie 失效
|
||||
*/
|
||||
|
||||
import { IS_DEVELOPMENT, IS_PRODUCTION } from "@/config/runtime-env";
|
||||
|
||||
const COOKIE_NAME = "pb_session";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
@@ -15,18 +17,17 @@ export function getHostFromRequest(request: { headers: { get: (k: string) => str
|
||||
}
|
||||
|
||||
export function getCookieOptions(clear = false, requestHost?: string) {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const domain = process.env.AUTH_COOKIE_DOMAIN?.trim();
|
||||
const opts: Record<string, unknown> = {
|
||||
path: "/",
|
||||
maxAge: clear ? 0 : COOKIE_MAX_AGE,
|
||||
secure: isProd,
|
||||
secure: IS_PRODUCTION,
|
||||
sameSite: "lax" as const,
|
||||
httpOnly: true,
|
||||
};
|
||||
if (domain && isProd) {
|
||||
if (domain && IS_PRODUCTION) {
|
||||
opts.domain = domain;
|
||||
} else if (!isProd && requestHost && /^(\d{1,3}\.){3}\d{1,3}$/.test(requestHost)) {
|
||||
} else if (IS_DEVELOPMENT && requestHost && /^(\d{1,3}\.){3}\d{1,3}$/.test(requestHost)) {
|
||||
opts.domain = requestHost;
|
||||
}
|
||||
return opts;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
import { IS_DEVELOPMENT } from "@/config/runtime-env";
|
||||
|
||||
export const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
(isDev ? "http://localhost:3001" : "https://nomadcna.cn");
|
||||
(IS_DEVELOPMENT ? "http://localhost:3001" : "https://nomadcna.cn");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
APP_ENV=development
|
||||
PB_URL=http://127.0.0.1:8090
|
||||
PB_ADMIN_EMAIL=admin@nomadcna.local
|
||||
PB_ADMIN_PASSWORD=NomadCNA123456
|
||||
|
||||
@@ -75,7 +75,8 @@ class Settings:
|
||||
)
|
||||
)
|
||||
self.upload_dir = Path(os.getenv("UPLOAD_DIR", "backend/uploads"))
|
||||
self.dev_auto_pay = _bool_env("DEV_AUTO_PAY", "true")
|
||||
self.app_env = os.getenv("APP_ENV", "development").strip().lower()
|
||||
self.dev_auto_pay = _bool_env("DEV_AUTO_PAY", "false")
|
||||
self.site_id = os.getenv("SITE_ID") or os.getenv("NEXT_PUBLIC_SITE_ID") or "meetup"
|
||||
self.base_url = (
|
||||
os.getenv("API_PUBLIC_BASE_URL")
|
||||
|
||||
@@ -4,6 +4,8 @@ set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
export PATH="/usr/local/bin:/usr/bin:/bin"
|
||||
export APP_ENV=production
|
||||
export NEXT_PUBLIC_APP_ENV=production
|
||||
export NODE_ENV=production
|
||||
export HOSTNAME="${NEXT_HOSTNAME:-0.0.0.0}"
|
||||
export PORT="${PORT:-4002}"
|
||||
|
||||
@@ -10,21 +10,21 @@
|
||||
* - NEXT_PUBLIC_SITE_URL: 站点根地址
|
||||
*/
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
import { IS_DEVELOPMENT, IS_PRODUCTION } from "../runtime-env";
|
||||
|
||||
/** 根域名,用于推导默认值。如 nomadro.com、hackrobot.cn */
|
||||
export const ROOT_DOMAIN =
|
||||
process.env.ROOT_DOMAIN || process.env.NEXT_PUBLIC_ROOT_DOMAIN || "hackrobot.cn";
|
||||
|
||||
/** 支付 API 默认地址。生产环境固定为 api.${ROOT_DOMAIN},避免 .env 中 127.0.0.1 导致支付失败 */
|
||||
export const DEFAULT_PAYMENT_API_URL = isDev
|
||||
/** 支付 API 默认地址。运行环境由 APP_ENV / NEXT_PUBLIC_APP_ENV 显式控制 */
|
||||
export const DEFAULT_PAYMENT_API_URL = IS_DEVELOPMENT
|
||||
? "http://127.0.0.1:8007"
|
||||
: `https://api.${ROOT_DOMAIN}`;
|
||||
|
||||
/** 生产环境若 PAYMENT_API_URL 指向 localhost,则忽略并使用 api 域名 */
|
||||
/** 线上环境若 PAYMENT_API_URL 指向 localhost,则忽略并使用 api 域名 */
|
||||
export function resolvePaymentApiUrl(): string {
|
||||
const url = process.env.PAYMENT_API_URL || DEFAULT_PAYMENT_API_URL;
|
||||
if (!isDev && /^(https?:\/\/)?(127\.0\.0\.1|localhost)(:\d+)?(\/|$)/i.test(url)) {
|
||||
if (IS_PRODUCTION && /^(https?:\/\/)?(127\.0\.0\.1|localhost)(:\d+)?(\/|$)/i.test(url)) {
|
||||
return `https://api.${ROOT_DOMAIN}`;
|
||||
}
|
||||
return url;
|
||||
@@ -34,7 +34,7 @@ export function resolvePaymentApiUrl(): string {
|
||||
export const DEFAULT_POCKETBASE_URL = `https://pocketbase.${ROOT_DOMAIN}`;
|
||||
|
||||
/** 站点根地址默认值 */
|
||||
export const DEFAULT_SITE_URL = isDev
|
||||
export const DEFAULT_SITE_URL = IS_DEVELOPMENT
|
||||
? "http://localhost:3001"
|
||||
: `https://${ROOT_DOMAIN}`;
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import { getThemeConfig } from "./site.config";
|
||||
import { IS_DEVELOPMENT } from "../runtime-env";
|
||||
import {
|
||||
DEFAULT_PAYMENT_API_URL,
|
||||
resolvePaymentApiUrl,
|
||||
DEFAULT_POCKETBASE_URL,
|
||||
DEFAULT_MINIO_HOST,
|
||||
@@ -64,7 +64,6 @@ export interface ServicesConfig {
|
||||
minio: MinioConfig;
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const PAYJSAPI_PORT = process.env.PAYJSAPI_PORT || "8007";
|
||||
|
||||
/** 站点标识,用于 VIP 按站点隔离。digital 专题站(ai.xx/android.xx)各自独立,如 digital_ai、digital_android */
|
||||
@@ -75,7 +74,7 @@ export const VIP_MASTER_SITE_ID = "vip";
|
||||
|
||||
export function getApiUrlFromRequest(hostHeader: string | null): string | null {
|
||||
if (!hostHeader) return null;
|
||||
if (!isDev) return resolvePaymentApiUrl();
|
||||
if (!IS_DEVELOPMENT) return resolvePaymentApiUrl();
|
||||
const hostname = hostHeader.split(":")[0];
|
||||
return `http://${hostname}:${PAYJSAPI_PORT}`;
|
||||
}
|
||||
|
||||
17
config/runtime-env.ts
Normal file
17
config/runtime-env.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type RuntimeEnv = "development" | "production" | "test";
|
||||
|
||||
function normalizeRuntimeEnv(value?: string): RuntimeEnv {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (normalized === "production" || normalized === "prod") return "production";
|
||||
if (normalized === "test") return "test";
|
||||
return "development";
|
||||
}
|
||||
|
||||
export const RUNTIME_ENV = normalizeRuntimeEnv(
|
||||
process.env.APP_ENV ||
|
||||
process.env.NEXT_PUBLIC_APP_ENV ||
|
||||
process.env.NODE_ENV
|
||||
);
|
||||
|
||||
export const IS_DEVELOPMENT = RUNTIME_ENV === "development";
|
||||
export const IS_PRODUCTION = RUNTIME_ENV === "production";
|
||||
Reference in New Issue
Block a user