This commit is contained in:
eric
2026-03-07 12:33:14 -06:00
parent 8b034e255b
commit 9bb8bcdf0b
28 changed files with 7231 additions and 78 deletions

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

100
README.md
View File

@@ -1,92 +1,36 @@
# cnomadcna This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
## Getting started ```bash
npm run dev
To make it easy for you to get started with GitLab, here's a list of recommended next steps. # or
yarn dev
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! # or
pnpm dev
## Add your files # or
bun dev
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin http://gitlab.yidooplanet.com/gitlab-instance-0a899031/cnomadcna.git
git branch -M main
git push -uf origin main
``` ```
## Integrate with your tools Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
- [ ] [Set up project integrations](http://gitlab.yidooplanet.com/gitlab-instance-0a899031/cnomadcna/-/settings/integrations) You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Collaborate with your team This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) ## Learn More
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
## Test and Deploy To learn more about Next.js, take a look at the following resources:
Use the built-in continuous integration in GitLab. - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
*** ## Deploy on Vercel
# Editing this README The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

189
app/components/CityCard.tsx Normal file
View File

@@ -0,0 +1,189 @@
"use client";
import { useState } from "react";
import { City } from "../data/cities";
function ratingToPercent(rating: string): number {
const m: Record<string, number> = {
极好: 95,
很好: 80,
: 60,
一般: 40,
: 20,
};
return m[rating] || 50;
}
function getBarColor(p: number): string {
if (p >= 60) return "#22c55e";
if (p >= 35) return "#eab308";
return "#ef4444";
}
function ScoreBar({
icon,
label,
percent,
animate,
}: {
icon: string;
label: string;
percent: number;
animate: boolean;
}) {
return (
<div className="flex items-center gap-2 sm:gap-3">
<span className="text-white/80 text-xs sm:text-sm w-14 sm:w-16 shrink-0 flex items-center gap-1">
<span>{icon}</span>
<span className="hidden sm:inline">{label}</span>
</span>
<div className="flex-1 h-2 sm:h-2.5 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-700 ease-out"
style={{
width: animate ? `${percent}%` : "0%",
backgroundColor: getBarColor(percent),
}}
/>
</div>
</div>
);
}
export default function CityCard({
city,
rank,
}: {
city: City;
rank: number;
}) {
const [hovered, setHovered] = useState(false);
const scores = [
{
icon: "⭐",
label: "综合",
percent: (city.overallScore / 5) * 100,
},
{
icon: "💵",
label: "费用",
percent: Math.max(15, 100 - city.costPerMonth / 200),
},
{
icon: "📡",
label: "网速",
percent: Math.min(95, (city.internetSpeed / 120) * 100),
},
{
icon: "👍",
label: "好评",
percent: ratingToPercent(city.liked),
},
{
icon: "👮",
label: "安全",
percent: ratingToPercent(city.safety),
},
];
return (
<div
className="city-card"
style={{
background: `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Icon watermark */}
<span className="absolute bottom-8 right-2 text-[70px] sm:text-[90px] opacity-[0.12] select-none pointer-events-none leading-none">
{city.icon}
</span>
{/* ===== Default state ===== */}
<div
className={`absolute inset-0 z-10 flex flex-col justify-between p-3 sm:p-4 transition-opacity duration-300 ${hovered ? "opacity-0" : "opacity-100"}`}
>
{/* Top row */}
<div className="flex items-start justify-between">
<span className="text-white/70 text-base sm:text-xl font-bold">
{rank}
</span>
<span className="text-[10px] sm:text-xs bg-white/20 backdrop-blur-sm rounded-full px-1.5 sm:px-2 py-0.5 sm:py-1 text-white flex items-center gap-0.5">
📡 {city.internetSpeed}Mbps
</span>
</div>
{/* Center: city name + country */}
<div className="text-center -mt-2">
<h3 className="text-xl sm:text-[28px] font-bold text-white drop-shadow-md tracking-wide">
{city.name}
</h3>
<p className="text-xs sm:text-sm text-white/60 mt-0.5">
{city.countryEmoji} {city.country}
</p>
</div>
{/* Bottom: temp + cost + nomads */}
<div>
<div className="flex items-center gap-1 mb-1.5">
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
👥 {city.nomadsNow}
</span>
<span className="text-[9px] sm:text-[11px] bg-white/15 backdrop-blur-sm rounded-full px-1.5 py-0.5 text-white/80">
🌡 {city.temperature}°C
</span>
</div>
<div className="flex items-end justify-between text-white text-xs sm:text-sm">
<span className="text-white/80 text-[10px] sm:text-xs line-clamp-1 max-w-[60%]">
{city.description}
</span>
<span className="font-semibold shrink-0">
¥{city.costPerMonth.toLocaleString()}/
</span>
</div>
</div>
</div>
{/* ===== Hover overlay (desktop only) ===== */}
<div
className={`absolute inset-0 z-20 bg-[#2a2a2a]/95 hidden sm:flex flex-col justify-between p-4 sm:p-5 transition-opacity duration-300 ${hovered ? "opacity-100" : "opacity-0 pointer-events-none"}`}
>
{/* Top icons */}
<div className="flex items-center justify-between">
<button className="text-white/50 hover:text-red-400 transition-colors text-base">
</button>
<button
className="text-white/30 hover:text-white text-sm transition-colors"
onClick={(e) => {
e.stopPropagation();
setHovered(false);
}}
>
</button>
</div>
{/* Score bars */}
<div className="space-y-2.5 sm:space-y-3">
{scores.map((s) => (
<ScoreBar
key={s.label}
icon={s.icon}
label={s.label}
percent={s.percent}
animate={hovered}
/>
))}
</div>
{/* Bottom: city info small */}
<div className="text-center text-white/40 text-xs">
{city.name} · {city.countryEmoji} {city.country}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { allTags } from "../data/cities";
interface FilterBarProps {
activeFilter: string;
onFilterChange: (filter: string) => void;
sortBy: string;
onSortChange: (sort: string) => void;
resultCount: number;
}
const sortOptions = [
{ value: "score", label: "综合评分" },
{ value: "cost-asc", label: "费用最低" },
{ value: "cost-desc", label: "费用最高" },
{ value: "internet", label: "网速最快" },
{ value: "safety", label: "最安全" },
{ value: "nomads", label: "游民最多" },
{ value: "temperature", label: "最温暖" },
];
const tagIcons: Record<string, string> = {
: "🌍",
: "🔥",
便: "💰",
: "📡",
: "☀️",
: "🛡️",
: "🏖️",
: "🏔️",
: "🌆",
: "🍜",
: "🎨",
线: "🏙️",
线: "⭐",
: "🏡",
: "🏛️",
: "🤝",
: "🏄",
};
export default function FilterBar({
activeFilter,
onFilterChange,
sortBy,
onSortChange,
resultCount,
}: FilterBarProps) {
return (
<div className="mb-6" id="cities">
{/* Top bar: result count + sort */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold text-gray-900">
</h2>
<span className="text-sm text-gray-400">
{resultCount}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400 hidden sm:block">:</span>
<select
value={sortBy}
onChange={(e) => onSortChange(e.target.value)}
className="text-sm bg-white border border-gray-200 rounded-lg px-3 py-1.5 text-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-200 cursor-pointer"
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
{/* Filter tags */}
<div className="flex gap-2 overflow-x-auto hide-scrollbar pb-2">
{allTags.map((tag) => (
<button
key={tag}
onClick={() => onFilterChange(tag)}
className={`filter-btn shrink-0 flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium border transition-all ${
activeFilter === tag
? "active border-gray-900"
: "border-gray-200 text-gray-600 hover:border-gray-300"
}`}
>
<span className="text-xs">{tagIcons[tag] || "🏷️"}</span>
<span>{tag}</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import { useState } from "react";
import Link from "next/link";
const avatarPhotos = [
"https://i.pravatar.cc/150?img=32",
"https://i.pravatar.cc/150?img=33",
"https://i.pravatar.cc/150?img=25",
"https://i.pravatar.cc/150?img=52",
"https://i.pravatar.cc/150?img=44",
"https://i.pravatar.cc/150?img=55",
"https://i.pravatar.cc/150?img=47",
"https://i.pravatar.cc/150?img=57",
"https://i.pravatar.cc/150?img=48",
"https://i.pravatar.cc/150?img=59",
"https://i.pravatar.cc/150?img=49",
];
const features = [
{ icon: "🍹", text: "每年在 30+ 城市参加线下聚会" },
{ icon: "❤️", text: "结识新朋友,约会交友" },
{ icon: "🧪", text: "研究目的地,找到最适合居住和工作的地方" },
{ icon: "🌎", text: "追踪你的旅行,记录你去过的地方" },
{ icon: "💬", text: "加入社群聊天,在旅途中找到你的归属" },
];
export default function HeroSection() {
const [email, setEmail] = useState("");
const handleJoin = (e: React.FormEvent) => {
e.preventDefault();
if (email.trim()) {
alert(`欢迎加入游牧中国!我们将发送确认邮件到 ${email}`);
}
};
return (
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-orange-50/70 via-white to-[#fafafa]" />
<div
className="absolute top-0 right-0 w-[500px] h-[500px] -translate-y-1/3 translate-x-1/4 opacity-40 rounded-full"
style={{
background:
"radial-gradient(circle, rgba(249,115,22,0.25) 0%, transparent 70%)",
}}
/>
<div
className="absolute bottom-0 left-0 w-[350px] h-[350px] translate-y-1/3 -translate-x-1/4 opacity-30 rounded-full"
style={{
background:
"radial-gradient(circle, rgba(139,92,246,0.2) 0%, transparent 70%)",
}}
/>
<div className="relative max-w-[1400px] mx-auto px-5 sm:px-10 py-10 sm:py-14 md:py-20">
<div className="flex flex-col lg:flex-row lg:items-start lg:gap-10 xl:gap-16">
{/* Left Column */}
<div className="flex-1 min-w-0">
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-orange-700 bg-orange-100 px-3 py-1.5 rounded-full mb-5">
🏆 · 2024
</span>
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight leading-tight mb-5">
<span className="hero-gradient-text"></span>
<br />
<span className="text-gray-900"></span>
</h1>
<p className="text-base sm:text-lg text-gray-500 max-w-2xl mb-7 leading-relaxed">
</p>
<div className="space-y-2.5 mb-8">
{features.map((f) => (
<div key={f.text} className="flex items-start gap-2.5">
<span className="text-base sm:text-lg shrink-0 mt-0.5">
{f.icon}
</span>
<span className="text-sm sm:text-base text-gray-600 underline decoration-gray-300 underline-offset-2">
{f.text}
</span>
</div>
))}
</div>
<div className="flex items-center gap-3 mb-8">
<div className="flex -space-x-2">
{avatarPhotos.slice(0, 8).map((src, i) => (
<img
key={i}
src={src}
alt=""
className="w-8 h-8 rounded-full border-2 border-white shadow-sm object-cover"
/>
))}
</div>
<span className="text-sm text-gray-500">
<strong className="text-gray-900">5,000+</strong>
</span>
</div>
<div className="pt-7 border-t border-gray-100">
<p className="text-xs text-gray-400 uppercase tracking-wider mb-3">
</p>
<div className="flex flex-wrap items-center gap-x-8 gap-y-2">
{["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"].map(
(name) => (
<span
key={name}
className="text-sm font-semibold text-gray-300 hover:text-gray-400 transition-colors cursor-default"
>
{name}
</span>
),
)}
</div>
</div>
</div>
{/* Right Column: Video + Email */}
<div className="lg:w-[360px] xl:w-[400px] shrink-0 mt-10 lg:mt-4">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden border border-gray-100">
<div className="relative h-48 sm:h-56 bg-gradient-to-br from-emerald-400 via-teal-500 to-cyan-600 flex items-center justify-center cursor-pointer group overflow-hidden">
<div className="absolute inset-0 opacity-20">
<div className="absolute top-4 left-4 w-20 h-16 rounded-lg bg-white/20" />
<div className="absolute bottom-6 right-6 w-24 h-14 rounded-lg bg-white/15" />
<div className="absolute top-1/2 left-1/3 w-16 h-16 rounded-full bg-white/10" />
</div>
<div className="absolute bottom-0 left-0 right-0 h-1/3 bg-gradient-to-t from-black/20 to-transparent" />
<button className="relative w-16 h-16 rounded-full bg-white/95 flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
<svg
className="w-7 h-7 text-emerald-600 ml-1"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
</button>
</div>
<form onSubmit={handleJoin} className="p-5">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="输入你的邮箱..."
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20 focus:border-[#ff4d4f] transition-colors mb-3"
/>
<button
type="submit"
className="w-full bg-[#ff4d4f] hover:bg-[#ff3333] text-white py-3 rounded-lg text-sm font-semibold transition-colors active:scale-[0.98]"
>
</button>
<p className="text-xs text-gray-400 text-center mt-3">
</p>
</form>
</div>
<div className="mt-4 flex flex-wrap gap-2 justify-center">
<Link
href="/meetups"
className="text-xs text-gray-400 hover:text-gray-600 bg-white rounded-full px-3 py-1.5 border border-gray-100 transition-colors"
>
🍹 线
</Link>
<Link
href="/dating"
className="text-xs text-gray-400 hover:text-gray-600 bg-white rounded-full px-3 py-1.5 border border-gray-100 transition-colors"
>
</Link>
<Link
href="/join"
className="text-xs text-gray-400 hover:text-gray-600 bg-white rounded-full px-3 py-1.5 border border-gray-100 transition-colors"
>
💬
</Link>
</div>
</div>
</div>
</div>
</section>
);
}

110
app/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
const navLinks = [
{ label: "城市", icon: "🏙️", href: "/" },
{ label: "旅行", icon: "✈️", href: "/meetups" },
{ label: "社区", icon: "💬", href: "/dating" },
{ label: "探索", icon: "🎒", href: "/#community" },
];
export default function Navbar() {
const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname();
function isActive(href: string) {
if (href.startsWith("/#")) return false;
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
}
return (
<nav className="sticky top-0 z-50 bg-white/80 backdrop-blur-lg border-b border-gray-100">
<div className="max-w-[1600px] mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 shrink-0">
<span className="text-2xl">🌏</span>
<span className="text-lg font-bold text-gray-900 tracking-tight">
</span>
</Link>
{/* Center Nav */}
<div className="hidden md:flex items-center gap-1">
{navLinks.map((link) => (
<Link
key={link.label}
href={link.href}
className={`flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-colors ${
isActive(link.href)
? "text-gray-900 bg-gray-100"
: "text-gray-500 hover:text-gray-900 hover:bg-gray-50"
}`}
>
<span>{link.icon}</span>
<span>{link.label}</span>
</Link>
))}
</div>
{/* Right Side */}
<div className="flex items-center gap-2 sm:gap-3">
<button className="hidden sm:block text-sm text-gray-600 hover:text-gray-900 px-3 py-2 rounded-full hover:bg-gray-50 transition-colors">
</button>
<Link
href="/join"
className="text-sm bg-[#ff4d4f] text-white px-4 py-2 rounded-full hover:bg-[#ff7a45] transition-colors font-medium"
>
</Link>
{/* Mobile hamburger */}
<button
className="md:hidden ml-1 p-2 rounded-lg hover:bg-gray-100"
onClick={() => setMobileOpen(!mobileOpen)}
>
{mobileOpen ? (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
</div>
{/* Mobile Menu */}
{mobileOpen && (
<div className="md:hidden border-t border-gray-100 bg-white px-4 py-3 space-y-1">
{navLinks.map((link) => (
<Link
key={link.label}
href={link.href}
className={`flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium ${
isActive(link.href)
? "text-gray-900 bg-gray-50"
: "text-gray-500 hover:bg-gray-50"
}`}
onClick={() => setMobileOpen(false)}
>
<span className="text-lg">{link.icon}</span>
<span>{link.label}</span>
</Link>
))}
<div className="pt-2 border-t border-gray-100">
<button className="w-full text-left px-4 py-3 text-sm text-gray-600 hover:bg-gray-50 rounded-xl">
</button>
</div>
</div>
)}
</nav>
);
}

245
app/components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,245 @@
const meetups = [
{ city: "清迈", emoji: "🇹🇭", date: "3月9日 周一", attendees: 12 },
{ city: "曼谷", emoji: "🇹🇭", date: "3月12日 周四", attendees: 8 },
{ city: "巴厘岛", emoji: "🇮🇩", date: "3月14日 周六", attendees: 15 },
{ city: "大理", emoji: "🇨🇳", date: "3月16日 周一", attendees: 6 },
{ city: "里斯本", emoji: "🇵🇹", date: "3月20日 周五", attendees: 23 },
];
const travelingNow = [
{ name: "小明", bg: "#f43f5e", destination: "清迈" },
{ name: "雨晴", bg: "#8b5cf6", destination: "巴厘岛" },
{ name: "阿杰", bg: "#3b82f6", destination: "曼谷" },
{ name: "小燕", bg: "#10b981", destination: "东京" },
{ name: "大伟", bg: "#f59e0b", destination: "里斯本" },
{ name: "佳琪", bg: "#ec4899", destination: "墨西哥城" },
{ name: "志远", bg: "#06b6d4", destination: "柏林" },
{ name: "心怡", bg: "#ef4444", destination: "首尔" },
{ name: "凯文", bg: "#84cc16", destination: "巴塞罗那" },
{ name: "子豪", bg: "#7c3aed", destination: "成都" },
{ name: "浩然", bg: "#0d9488", destination: "大理" },
{ name: "雅琪", bg: "#ea580c", destination: "台北" },
{ name: "天翔", bg: "#6366f1", destination: "布达佩斯" },
{ name: "美玲", bg: "#d946ef", destination: "梅德林" },
{ name: "俊杰", bg: "#14b8a6", destination: "普吉岛" },
{ name: "晓华", bg: "#f97316", destination: "吉隆坡" },
];
const newMembers = [
{ name: "林", bg: "#3b82f6" },
{ name: "张", bg: "#8b5cf6" },
{ name: "陈", bg: "#f43f5e" },
{ name: "周", bg: "#10b981" },
{ name: "吴", bg: "#f59e0b" },
{ name: "杨", bg: "#ec4899" },
{ name: "赵", bg: "#06b6d4" },
{ name: "黄", bg: "#ef4444" },
{ name: "孙", bg: "#84cc16" },
{ name: "马", bg: "#7c3aed" },
{ name: "朱", bg: "#0d9488" },
{ name: "胡", bg: "#ea580c" },
{ name: "郭", bg: "#6366f1" },
{ name: "何", bg: "#d946ef" },
{ name: "高", bg: "#14b8a6" },
{ name: "梁", bg: "#f97316" },
{ name: "郑", bg: "#a855f7" },
{ name: "罗", bg: "#22c55e" },
{ name: "谢", bg: "#e11d48" },
{ name: "韩", bg: "#0ea5e9" },
];
const hotTopics = [
{ title: "清迈最佳共享办公空间推荐 Top 10", views: 2340 },
{ title: "2026年数字游民签证政策汇总", views: 1890 },
{ title: "远程工作者的跨国税务规划指南", views: 1560 },
{ title: "东南亚 vs 拉美:哪个更适合游民?", views: 1230 },
{ title: "新手游民必备工具与装备清单", views: 980 },
];
export default function Sidebar() {
return (
<aside className="w-[320px] shrink-0 hidden lg:block">
<div className="sticky top-20 space-y-4 sidebar-scroll max-h-[calc(100vh-100px)] overflow-y-auto pr-1">
{/* Insurance / Visa Promo */}
<div className="rounded-2xl bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-100 p-5">
<div className="flex items-start gap-3 mb-3">
<span className="text-2xl">🛡</span>
<div>
<h3 className="font-bold text-gray-900 text-sm"></h3>
<p className="text-xs text-gray-500 mt-0.5">
</p>
</div>
</div>
<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">
</button>
<p className="text-[10px] text-gray-400 text-center mt-2">广</p>
</div>
{/* Upcoming Meetups */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-sm p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-gray-900 text-sm flex items-center gap-1.5">
🍹
</h3>
<span className="text-[11px] text-gray-400">8/</span>
</div>
<div className="space-y-3">
{meetups.map((m) => (
<div
key={m.city + m.date}
className="flex items-center justify-between py-2 border-b border-gray-50 last:border-0 last:pb-0"
>
<div className="flex items-center gap-2.5">
<span className="text-lg">{m.emoji}</span>
<div>
<p className="text-sm font-medium text-gray-800">
{m.city}
</p>
<p className="text-[11px] text-gray-400">{m.date}</p>
</div>
</div>
<span className="text-[11px] text-gray-400 bg-gray-50 rounded-full px-2 py-0.5">
{m.attendees}
</span>
</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">
</button>
</div>
{/* Traveling Now */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-sm p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-gray-900 text-sm flex items-center gap-1.5">
🛩
</h3>
<span className="text-[11px] text-gray-400">
{travelingNow.length}
</span>
</div>
<div className="flex flex-wrap gap-2">
{travelingNow.map((m) => (
<div key={m.name} className="group relative">
<div
className="w-9 h-9 rounded-full flex items-center justify-center text-xs text-white font-medium shadow-sm cursor-pointer hover:scale-110 transition-transform avatar-online"
style={{ backgroundColor: m.bg }}
title={`${m.name} · ${m.destination}`}
>
{m.name.charAt(0)}
</div>
</div>
))}
</div>
</div>
{/* New Members */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-sm p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-gray-900 text-sm flex items-center gap-1.5">
👋
</h3>
<span className="text-[11px] text-gray-400">86/</span>
</div>
<div className="flex flex-wrap gap-2">
{newMembers.map((m) => (
<div
key={m.name}
className="w-9 h-9 rounded-full flex items-center justify-center text-xs text-white font-medium shadow-sm cursor-pointer hover:scale-110 transition-transform"
style={{ backgroundColor: m.bg }}
>
{m.name}
</div>
))}
</div>
</div>
{/* Hot Topics */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-sm p-5">
<h3 className="font-bold text-gray-900 text-sm flex items-center gap-1.5 mb-4">
🔥
</h3>
<div className="space-y-3">
{hotTopics.map((t, i) => (
<a
key={t.title}
href="#"
className="flex items-start gap-2.5 group"
>
<span className="text-xs font-bold text-gray-300 mt-0.5 w-4 shrink-0">
{i + 1}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-700 group-hover:text-gray-900 transition-colors line-clamp-1 leading-snug">
{t.title}
</p>
<p className="text-[11px] text-gray-400 mt-0.5">
{t.views.toLocaleString()}
</p>
</div>
</a>
))}
</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">
</button>
</div>
{/* Chat CTA */}
<div className="rounded-2xl bg-gradient-to-br from-violet-50 to-purple-50 border border-violet-100 p-5">
<div className="flex items-start gap-3 mb-3">
<span className="text-2xl">💬</span>
<div>
<h3 className="font-bold text-gray-900 text-sm">
</h3>
<p className="text-xs text-gray-500 mt-0.5">
3,200+
</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">
</button>
</div>
{/* Visa Service */}
<div className="rounded-2xl bg-gradient-to-br from-amber-50 to-orange-50 border border-amber-100 p-5">
<div className="flex items-start gap-3 mb-3">
<span className="text-2xl">🏛</span>
<div>
<h3 className="font-bold text-gray-900 text-sm"></h3>
<p className="text-xs text-gray-500 mt-0.5">
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
<span className="text-[11px] bg-amber-100/60 text-amber-700 rounded-full px-2 py-0.5">
🇹🇭
</span>
<span className="text-[11px] bg-amber-100/60 text-amber-700 rounded-full px-2 py-0.5">
🇵🇹
</span>
<span className="text-[11px] bg-amber-100/60 text-amber-700 rounded-full px-2 py-0.5">
🇪🇸 西
</span>
<span className="text-[11px] bg-amber-100/60 text-amber-700 rounded-full px-2 py-0.5">
🇮🇩
</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">
</button>
<p className="text-[10px] text-gray-400 text-center mt-2">广</p>
</div>
</div>
</aside>
);
}

384
app/data/cities.ts Normal file
View File

@@ -0,0 +1,384 @@
export interface City {
id: number;
name: string;
country: string;
countryEmoji: string;
overallScore: number;
costPerMonth: number;
internetSpeed: number;
safety: string;
liked: string;
temperature: number;
humidity: number;
nomadsNow: number;
description: string;
gradientFrom: string;
gradientTo: string;
icon: string;
tags: string[];
}
export const cities: City[] = [
{
id: 1,
name: "大理",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.5,
costPerMonth: 3000,
internetSpeed: 50,
safety: "很好",
liked: "极好",
temperature: 16,
humidity: 60,
nomadsNow: 520,
description: "苍山洱海,文艺气息浓厚的慢生活之城",
gradientFrom: "#2563eb",
gradientTo: "#0891b2",
icon: "🏯",
tags: ["热门", "便宜", "宜居", "山城", "文化", "友好社区"],
},
{
id: 2,
name: "成都",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.4,
costPerMonth: 4500,
internetSpeed: 80,
safety: "很好",
liked: "极好",
temperature: 17,
humidity: 75,
nomadsNow: 680,
description: "天府之国,美食与慢节奏的完美融合",
gradientFrom: "#059669",
gradientTo: "#65a30d",
icon: "🐼",
tags: ["热门", "美食", "都市", "新一线", "文化", "友好社区"],
},
{
id: 3,
name: "深圳",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.1,
costPerMonth: 8000,
internetSpeed: 120,
safety: "极好",
liked: "很好",
temperature: 23,
humidity: 70,
nomadsNow: 850,
description: "科技创新之城,创业者的乐园",
gradientFrom: "#1d4ed8",
gradientTo: "#0284c7",
icon: "🌃",
tags: ["热门", "高速网络", "都市", "一线城市", "安全"],
},
{
id: 4,
name: "上海",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.0,
costPerMonth: 10000,
internetSpeed: 110,
safety: "极好",
liked: "好",
temperature: 17,
humidity: 65,
nomadsNow: 1200,
description: "国际大都市,东西方文化交汇的中心",
gradientFrom: "#b45309",
gradientTo: "#78716c",
icon: "🏙️",
tags: ["热门", "高速网络", "都市", "一线城市", "文化"],
},
{
id: 5,
name: "杭州",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.3,
costPerMonth: 6000,
internetSpeed: 100,
safety: "极好",
liked: "很好",
temperature: 17,
humidity: 70,
nomadsNow: 560,
description: "西湖之畔,互联网重镇与诗画江南",
gradientFrom: "#047857",
gradientTo: "#0d9488",
icon: "⛲",
tags: ["热门", "高速网络", "都市", "新一线", "宜居", "文化"],
},
{
id: 6,
name: "厦门",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.1,
costPerMonth: 5500,
internetSpeed: 70,
safety: "很好",
liked: "很好",
temperature: 21,
humidity: 75,
nomadsNow: 320,
description: "海滨花园城市,鼓浪屿的浪漫与闽南风情",
gradientFrom: "#ea580c",
gradientTo: "#0284c7",
icon: "🌊",
tags: ["海滨", "温暖", "美食", "宜居", "文化"],
},
{
id: 7,
name: "北京",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.8,
costPerMonth: 9000,
internetSpeed: 100,
safety: "极好",
liked: "好",
temperature: 14,
humidity: 50,
nomadsNow: 780,
description: "千年古都,政治文化中心的深厚底蕴",
gradientFrom: "#dc2626",
gradientTo: "#b91c1c",
icon: "🏛️",
tags: ["都市", "一线城市", "文化", "高速网络", "历史名城"],
},
{
id: 8,
name: "广州",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.9,
costPerMonth: 6500,
internetSpeed: 90,
safety: "很好",
liked: "好",
temperature: 23,
humidity: 80,
nomadsNow: 480,
description: "食在广州,千年商都的烟火气",
gradientFrom: "#f97316",
gradientTo: "#ef4444",
icon: "🌺",
tags: ["热门", "美食", "都市", "一线城市", "温暖"],
},
{
id: 9,
name: "重庆",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.7,
costPerMonth: 4000,
internetSpeed: 75,
safety: "很好",
liked: "好",
temperature: 18,
humidity: 80,
nomadsNow: 350,
description: "8D魔幻山城火锅与夜景的不夜城",
gradientFrom: "#ef4444",
gradientTo: "#c2410c",
icon: "🌶️",
tags: ["便宜", "美食", "山城", "都市", "文化"],
},
{
id: 10,
name: "昆明",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.2,
costPerMonth: 3500,
internetSpeed: 55,
safety: "好",
liked: "很好",
temperature: 16,
humidity: 55,
nomadsNow: 380,
description: "四季如春的春城,通往东南亚的门户",
gradientFrom: "#d946ef",
gradientTo: "#059669",
icon: "🌸",
tags: ["便宜", "温暖", "宜居", "友好社区", "户外运动"],
},
{
id: 11,
name: "三亚",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.5,
costPerMonth: 5000,
internetSpeed: 60,
safety: "好",
liked: "好",
temperature: 26,
humidity: 85,
nomadsNow: 250,
description: "中国的热带天堂,椰风海韵的度假胜地",
gradientFrom: "#0ea5e9",
gradientTo: "#06b6d4",
icon: "🏖️",
tags: ["海滨", "温暖", "户外运动"],
},
{
id: 12,
name: "丽江",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.0,
costPerMonth: 3200,
internetSpeed: 45,
safety: "好",
liked: "好",
temperature: 14,
humidity: 50,
nomadsNow: 280,
description: "玉龙雪山下的纳西古城,灵魂栖息地",
gradientFrom: "#6366f1",
gradientTo: "#7c3aed",
icon: "🏔️",
tags: ["便宜", "文化", "山城", "宜居", "历史名城"],
},
{
id: 13,
name: "西安",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.8,
costPerMonth: 4200,
internetSpeed: 70,
safety: "很好",
liked: "好",
temperature: 15,
humidity: 55,
nomadsNow: 230,
description: "十三朝古都,历史与美食交织的城市",
gradientFrom: "#78716c",
gradientTo: "#a16207",
icon: "🗿",
tags: ["便宜", "美食", "文化", "历史名城"],
},
{
id: 14,
name: "南京",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.9,
costPerMonth: 5500,
internetSpeed: 85,
safety: "很好",
liked: "好",
temperature: 16,
humidity: 70,
nomadsNow: 400,
description: "六朝古都,紫金山下的文化名城",
gradientFrom: "#7c3aed",
gradientTo: "#059669",
icon: "🍃",
tags: ["文化", "都市", "新一线", "历史名城"],
},
{
id: 15,
name: "长沙",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.6,
costPerMonth: 4500,
internetSpeed: 75,
safety: "好",
liked: "好",
temperature: 17,
humidity: 75,
nomadsNow: 290,
description: "娱乐之都,夜生活与美食的不眠城",
gradientFrom: "#f59e0b",
gradientTo: "#16a34a",
icon: "🎪",
tags: ["美食", "都市", "新一线", "友好社区"],
},
{
id: 16,
name: "青岛",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.7,
costPerMonth: 4800,
internetSpeed: 65,
safety: "很好",
liked: "好",
temperature: 13,
humidity: 65,
nomadsNow: 220,
description: "红瓦绿树碧海蓝天,啤酒飘香的海滨城市",
gradientFrom: "#2563eb",
gradientTo: "#ca8a04",
icon: "🍺",
tags: ["海滨", "美食", "宜居", "户外运动"],
},
{
id: 17,
name: "苏州",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 4.0,
costPerMonth: 5000,
internetSpeed: 80,
safety: "极好",
liked: "很好",
temperature: 17,
humidity: 70,
nomadsNow: 260,
description: "上有天堂下有苏杭,古典园林与现代新城",
gradientFrom: "#0d9488",
gradientTo: "#64748b",
icon: "🏡",
tags: ["文化", "宜居", "安全", "历史名城"],
},
{
id: 18,
name: "珠海",
country: "中国",
countryEmoji: "🇨🇳",
overallScore: 3.9,
costPerMonth: 5200,
internetSpeed: 70,
safety: "很好",
liked: "好",
temperature: 23,
humidity: 75,
nomadsNow: 180,
description: "浪漫之城,毗邻澳门的海滨花园",
gradientFrom: "#0891b2",
gradientTo: "#6366f1",
icon: "🎡",
tags: ["海滨", "温暖", "宜居", "安全"],
},
];
export const allTags = [
"全部",
"热门",
"便宜",
"高速网络",
"温暖",
"安全",
"海滨",
"山城",
"都市",
"美食",
"文化",
"一线城市",
"新一线",
"宜居",
"历史名城",
"友好社区",
"户外运动",
];

304
app/dating/page.tsx Normal file
View File

@@ -0,0 +1,304 @@
"use client";
import { useState } from "react";
type TabType = "friends" | "dating";
interface Profile {
id: string;
name: string;
age: number;
location: string;
tags: string[];
bio: string;
gradient: string;
initial: string;
avatarColor: string;
photo: string;
}
interface MatchCard {
id: string;
name: string;
location: string;
timeAgo: string;
initial: string;
color: string;
photo: string;
}
const MOCK_PROFILES: Profile[] = [
{
id: "1",
name: "林晓雨",
age: 28,
location: "大理",
tags: ["本科学历", "远程工作者", "数字游民", "咖啡爱好者"],
bio: "数字游民三年,目前在云南大理远程做产品设计。喜欢爬山、摄影,周末常去洱海边发呆。",
gradient: "from-rose-400 via-pink-400 to-fuchsia-400",
initial: "林",
avatarColor: "bg-rose-500",
photo: "https://i.pravatar.cc/150?img=1",
},
{
id: "2",
name: "陈浩然",
age: 32,
location: "成都",
tags: ["硕士学历", "独立开发者", "自由职业者", "素食主义"],
bio: "在成都住了两年,做独立开发。喜欢冲浪和冥想,寻找志同道合的旅伴一起探索世界。",
gradient: "from-amber-400 via-orange-400 to-red-400",
initial: "陈",
avatarColor: "bg-amber-500",
photo: "https://i.pravatar.cc/150?img=3",
},
{
id: "3",
name: "王思琪",
age: 26,
location: "深圳",
tags: ["本科学历", "内容创作者", "数字游民", "环保主义者"],
bio: "自由撰稿人,在深圳写写画画。热爱瑜伽和冥想,相信慢生活才是真正的奢侈。",
gradient: "from-emerald-400 via-teal-400 to-cyan-400",
initial: "王",
avatarColor: "bg-emerald-500",
photo: "https://i.pravatar.cc/150?img=5",
},
{
id: "4",
name: "张明远",
age: 30,
location: "杭州",
tags: ["MBA学历", "创业中", "自由职业者", "徒步爱好者"],
bio: "正在杭州创业,做远程团队协作工具。周末喜欢去西湖徒步,偶尔品鉴龙井茶。",
gradient: "from-violet-400 via-purple-400 to-indigo-400",
initial: "张",
avatarColor: "bg-violet-500",
photo: "https://i.pravatar.cc/150?img=7",
},
{
id: "5",
name: "李雅婷",
age: 27,
location: "厦门",
tags: ["本科学历", "UI设计师", "数字游民", "摄影爱好者"],
bio: "在厦门远程做UI设计业余时间探索闽南美食。喜欢用相机记录生活寻找一起探店的朋友。",
gradient: "from-rose-500 via-red-400 to-orange-500",
initial: "李",
avatarColor: "bg-rose-500",
photo: "https://i.pravatar.cc/150?img=9",
},
{
id: "6",
name: "刘子轩",
age: 33,
location: "昆明",
tags: ["博士学历", "数据科学家", "远程工作者", "户外爱好者"],
bio: "在昆明远程工作,热爱户外运动。正在探索云南各地,希望认识更多志同道合的朋友。",
gradient: "from-blue-400 via-indigo-400 to-violet-500",
initial: "刘",
avatarColor: "bg-blue-500",
photo: "https://i.pravatar.cc/150?img=11",
},
];
const MOCK_MATCHES: MatchCard[] = [
{ id: "m1", name: "周雨桐", location: "杭州", timeAgo: "2天前", initial: "周", color: "bg-pink-400", photo: "https://i.pravatar.cc/150?img=29" },
{ id: "m2", name: "吴俊杰", location: "成都", timeAgo: "1周前", initial: "吴", color: "bg-amber-400", photo: "https://i.pravatar.cc/150?img=34" },
{ id: "m3", name: "郑诗涵", location: "上海", timeAgo: "3周前", initial: "郑", color: "bg-emerald-400", photo: "https://i.pravatar.cc/150?img=36" },
{ id: "m4", name: "孙宇航", location: "深圳", timeAgo: "1月前", initial: "孙", color: "bg-violet-400", photo: "https://i.pravatar.cc/150?img=38" },
{ id: "m5", name: "赵梦琪", location: "厦门", timeAgo: "1年前", initial: "赵", color: "bg-rose-400", photo: "https://i.pravatar.cc/150?img=41" },
];
export default function DatingPage() {
const [activeTab, setActiveTab] = useState<TabType>("friends");
const [currentIndex, setCurrentIndex] = useState(0);
const currentProfile = MOCK_PROFILES[currentIndex];
const hasMoreProfiles = currentIndex < MOCK_PROFILES.length - 1;
const handleLike = () => {
if (hasMoreProfiles) {
setCurrentIndex((i) => i + 1);
} else {
setCurrentIndex(0);
}
};
const handleDislike = () => {
if (hasMoreProfiles) {
setCurrentIndex((i) => i + 1);
} else {
setCurrentIndex(0);
}
};
const badgeText =
activeTab === "dating"
? "你们都在寻找 🌹 约会"
: "你们都在寻找 🤝 交友";
return (
<div className="min-h-screen bg-[#fafafa]">
{/* Tabs */}
<div className="flex justify-center pt-6 pb-4 px-4">
<div className="inline-flex rounded-full bg-white p-1 shadow-sm border border-gray-100">
<button
onClick={() => setActiveTab("friends")}
className={`px-6 py-2.5 rounded-full text-sm font-medium transition-all duration-200 ${
activeTab === "friends"
? "bg-[#ff4d4f] text-white shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
</button>
<button
onClick={() => setActiveTab("dating")}
className={`px-6 py-2.5 rounded-full text-sm font-medium transition-all duration-200 ${
activeTab === "dating"
? "bg-[#ff4d4f] text-white shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
</button>
</div>
</div>
<div className="flex flex-col md:flex-row gap-6 max-w-7xl mx-auto px-4 pb-12">
{/* Left Sidebar - hidden on mobile */}
<aside className="hidden md:block w-72 flex-shrink-0">
<div className="sticky top-24">
<h2 className="text-lg font-semibold text-gray-900 mb-1">
💕
</h2>
<p className="text-sm text-gray-500 mb-4"></p>
<div className="space-y-3">
{MOCK_MATCHES.map((match) => (
<div
key={match.id}
className="flex items-center gap-3 p-3 rounded-xl bg-white border border-gray-100 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
>
<img
src={match.photo}
alt={match.name}
className="w-12 h-12 rounded-full object-cover shadow-sm flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">
{match.name}
</p>
<p className="text-xs text-gray-500 truncate">
{match.location}
</p>
</div>
<span className="text-xs text-gray-400 bg-gray-50 px-2 py-1 rounded-full flex-shrink-0">
{match.timeAgo}
</span>
</div>
))}
</div>
</div>
</aside>
{/* Center - Main Card */}
<main className="flex-1 min-w-0 flex flex-col items-center">
<div className="w-full max-w-md mx-auto">
<div
className={`relative rounded-2xl overflow-hidden bg-gradient-to-br ${currentProfile.gradient} p-8 pb-6 shadow-xl border border-white/20`}
>
{/* Top right: badge + report */}
<div className="absolute top-4 right-4 flex flex-col items-end gap-1">
<span className="px-3 py-1.5 rounded-full bg-white/90 text-gray-800 text-xs font-medium backdrop-blur-sm">
{badgeText}
</span>
<a
href="#"
className="text-[#ff4d4f] hover:text-red-600 text-sm font-medium"
>
</a>
</div>
{/* Avatar */}
<div className="flex justify-center mt-12 mb-6">
<img
src={currentProfile.photo}
alt={currentProfile.name}
className="w-28 h-28 rounded-full shadow-lg border-4 border-white/50 object-cover"
/>
</div>
{/* Name, age, location */}
<div className="text-center mb-4">
<h3 className="text-xl font-bold text-white drop-shadow-sm">
{currentProfile.name}{currentProfile.age}
</h3>
<p className="text-white/90 text-sm mt-1">
📍 {currentProfile.location}
</p>
</div>
{/* Tags */}
<div className="mb-4">
<p className="text-white/90 text-xs font-medium mb-2">
:
</p>
<div className="flex flex-wrap gap-2 justify-center">
{currentProfile.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 rounded-full bg-white/80 text-gray-800 text-xs font-medium"
>
{tag}
</span>
))}
</div>
</div>
{/* Bio */}
<p className="text-white/95 text-sm leading-relaxed text-center mb-8 px-2">
{currentProfile.bio}
</p>
{/* Action buttons */}
<div className="flex items-center justify-center gap-8">
<div className="flex flex-col items-center gap-2">
<button
onClick={handleDislike}
className="w-16 h-16 rounded-full bg-white shadow-lg flex items-center justify-center text-[#ff4d4f] hover:bg-red-50 hover:scale-105 active:scale-95 transition-all"
aria-label="不感兴趣"
>
<span className="text-2xl"></span>
</button>
<span className="text-xs text-white/80 font-medium">
</span>
</div>
<div className="flex flex-col items-center gap-2">
<button
onClick={handleLike}
className="w-16 h-16 rounded-full bg-[#22c55e] shadow-lg flex items-center justify-center text-white hover:bg-[#16a34a] hover:scale-105 active:scale-95 transition-all"
aria-label="喜欢"
>
<span className="text-2xl">💚</span>
</button>
<span className="text-xs text-white/80 font-medium"></span>
</div>
</div>
</div>
{/* Progress indicator */}
<p className="text-center text-sm text-gray-500 mt-4">
{currentIndex + 1} / {MOCK_PROFILES.length}
</p>
</div>
</main>
{/* Right spacer for desktop balance */}
<div className="hidden lg:block w-72 flex-shrink-0" />
</div>
</div>
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

227
app/globals.css Normal file
View File

@@ -0,0 +1,227 @@
@import "tailwindcss";
:root {
--background: #fafafa;
--foreground: #111827;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans, system-ui), "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
html {
scroll-behavior: smooth;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
/* ==================== Items Grid (mobile-first) ==================== */
.items-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 180px;
gap: 8px;
padding: 8px;
}
@media (min-width: 640px) {
.items-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 260px;
gap: 20px;
padding: 16px 20px;
}
}
@media (min-width: 1024px) {
.items-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-rows: 280px;
gap: 30px;
padding: 20px 40px;
}
}
@media (min-width: 1440px) {
.items-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 35px;
}
}
/* Pin right-items to the LAST explicit column */
.items-grid .right-item:nth-child(1) {
grid-column: -2 / -1;
grid-row: 1;
}
.items-grid .right-item:nth-child(2) {
grid-column: -2 / -1;
grid-row: 2;
}
.items-grid .right-item:nth-child(3) {
grid-column: -2 / -1;
grid-row: 3;
}
/* ==================== City Card ==================== */
.city-card {
height: 100%;
border-radius: 15px;
position: relative;
overflow: hidden;
cursor: pointer;
color: white;
box-sizing: border-box;
min-width: 0;
}
.city-card:hover {
animation: pulse 0.6s ease;
}
/* ==================== Right Item ==================== */
.right-item {
height: 100%;
border-radius: 15px;
position: relative;
overflow: hidden;
width: 100%;
min-width: 0;
box-sizing: border-box;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
}
.right-item:hover {
animation: pulse 0.6s ease;
}
.right-item-header {
width: 100%;
height: 38px;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(90deg, #ff4d4f, #ff7a45);
color: #fff;
}
@media (min-width: 640px) {
.right-item-header {
height: 42px;
font-size: 15px;
}
}
/* ==================== Hover / Pulse Animation ==================== */
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.03);
}
100% {
transform: scale(1);
}
}
/* ==================== Hero Gradient Text ==================== */
@keyframes gradient-shift {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
.hero-gradient-text {
background: linear-gradient(
135deg,
#f97316,
#ec4899,
#8b5cf6,
#3b82f6,
#f97316
);
background-size: 300% 300%;
animation: gradient-shift 6s ease infinite;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ==================== Filter Buttons ==================== */
.filter-btn {
transition: all 0.15s ease;
}
.filter-btn:hover {
background: #f3f4f6;
}
.filter-btn.active {
background: #111827;
color: white;
}
.filter-btn.active:hover {
background: #1f2937;
}
/* ==================== Community Cards ==================== */
.community-card {
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.03);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.community-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.04);
}
/* ==================== Utilities ==================== */
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

280
app/join/page.tsx Normal file
View File

@@ -0,0 +1,280 @@
"use client";
import { useState } from "react";
type FormState = {
nickname: string;
gender: string;
city: string;
occupation: string;
education: string;
wechat: string;
phone: string;
intro: string;
};
const OCCUPATIONS = [
"自由职业者",
"远程工程师",
"设计师",
"内容创作者",
"创业者",
"其他",
] as const;
const EDUCATIONS = ["高中", "大专", "本科", "硕士", "博士"] as const;
const initialForm: FormState = {
nickname: "",
gender: "",
city: "",
occupation: "",
education: "",
wechat: "",
phone: "",
intro: "",
};
export default function JoinPage() {
const [form, setForm] = useState<FormState>(initialForm);
const updateField = (field: keyof FormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!form.nickname.trim()) {
alert("请填写昵称");
return;
}
if (!form.wechat.trim()) {
alert("请填写微信号");
return;
}
alert("提交成功!我们将通过微信联系你。");
setForm(initialForm);
};
return (
<div className="min-h-screen bg-[#fafafa]">
{/* Top Section: Gradient Hero */}
<section className="relative overflow-hidden bg-gradient-to-br from-orange-400 via-orange-500 to-rose-500 px-4 pt-12 pb-16 sm:pt-16 sm:pb-20">
<div className="mx-auto max-w-2xl text-center">
<h1 className="text-3xl font-bold tracking-tight text-white drop-shadow-sm sm:text-4xl md:text-5xl">
</h1>
<p className="mt-3 text-base text-white/95 sm:text-lg md:text-xl">
</p>
</div>
</section>
{/* Form Card */}
<section className="relative z-10 mx-auto -mt-8 max-w-lg px-4 pb-12 sm:-mt-10 sm:px-6">
<div className="rounded-2xl bg-white p-6 shadow-lg sm:p-8">
<form onSubmit={handleSubmit} className="space-y-5">
{/* 昵称 */}
<div>
<label
htmlFor="nickname"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
<span className="text-[#ff4d4f]">*</span>
</label>
<input
id="nickname"
type="text"
value={form.nickname}
onChange={(e) => updateField("nickname", e.target.value)}
required
placeholder="请输入你的昵称"
className="w-full rounded-xl border border-gray-200 px-4 py-3 text-gray-900 placeholder-gray-400 transition-colors focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20"
/>
</div>
{/* 性别 */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700">
</label>
<div className="flex gap-6">
{["男", "女", "其他"].map((g) => (
<label
key={g}
className="flex cursor-pointer items-center gap-2"
>
<input
type="radio"
name="gender"
value={g}
checked={form.gender === g}
onChange={(e) => updateField("gender", e.target.value)}
className="h-4 w-4 border-gray-300 text-[#ff4d4f] focus:ring-[#ff4d4f]/20"
/>
<span className="text-sm text-gray-700">{g}</span>
</label>
))}
</div>
</div>
{/* 当前所在城市 */}
<div>
<label
htmlFor="city"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
</label>
<input
id="city"
type="text"
value={form.city}
onChange={(e) => updateField("city", e.target.value)}
placeholder="如:清迈、曼谷、大理"
className="w-full rounded-xl border border-gray-200 px-4 py-3 text-gray-900 placeholder-gray-400 transition-colors focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20"
/>
</div>
{/* 职业 */}
<div>
<label
htmlFor="occupation"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
</label>
<select
id="occupation"
value={form.occupation}
onChange={(e) => updateField("occupation", e.target.value)}
className="w-full rounded-xl border border-gray-200 px-4 py-3 text-gray-900 transition-colors focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20"
>
<option value=""></option>
{OCCUPATIONS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</div>
{/* 学历 */}
<div>
<label
htmlFor="education"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
</label>
<select
id="education"
value={form.education}
onChange={(e) => updateField("education", e.target.value)}
className="w-full rounded-xl border border-gray-200 px-4 py-3 text-gray-900 transition-colors focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20"
>
<option value=""></option>
{EDUCATIONS.map((e) => (
<option key={e} value={e}>
{e}
</option>
))}
</select>
</div>
{/* 微信号 */}
<div>
<label
htmlFor="wechat"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
<span className="text-[#ff4d4f]">*</span>
</label>
<input
id="wechat"
type="text"
value={form.wechat}
onChange={(e) => updateField("wechat", e.target.value)}
required
placeholder="用于联系你加入社群"
className="w-full rounded-xl border border-gray-200 px-4 py-3 text-gray-900 placeholder-gray-400 transition-colors focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20"
/>
</div>
{/* 手机号 */}
<div>
<label
htmlFor="phone"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
</label>
<div className="flex rounded-xl border border-gray-200 bg-white transition-colors focus-within:border-[#ff4d4f] focus-within:ring-2 focus-within:ring-[#ff4d4f]/20">
<span className="flex items-center border-r border-gray-200 px-4 py-3 text-sm text-gray-500">
+86
</span>
<input
id="phone"
type="tel"
value={form.phone}
onChange={(e) => updateField("phone", e.target.value)}
placeholder="选填"
className="flex-1 rounded-r-xl border-0 px-4 py-3 text-gray-900 placeholder-gray-400 focus:ring-0"
/>
</div>
</div>
{/* 简单介绍自己 */}
<div>
<label
htmlFor="intro"
className="mb-1.5 block text-sm font-medium text-gray-700"
>
</label>
<textarea
id="intro"
rows={3}
value={form.intro}
onChange={(e) => updateField("intro", e.target.value)}
placeholder="简单介绍一下你的背景、兴趣或加入原因..."
className="w-full resize-none rounded-xl border border-gray-200 px-4 py-3 text-gray-900 placeholder-gray-400 transition-colors focus:border-[#ff4d4f] focus:outline-none focus:ring-2 focus:ring-[#ff4d4f]/20"
/>
</div>
{/* Submit */}
<div className="pt-2">
<button
type="submit"
className="w-full rounded-xl bg-[#ff4d4f] px-4 py-3.5 font-semibold text-white transition-colors hover:bg-[#e64547] active:bg-[#cc3d3f]"
>
</button>
<p className="mt-3 text-center text-xs text-gray-500">
24
</p>
</div>
</form>
</div>
</section>
{/* Bottom: Trust Indicators */}
<section className="border-t border-gray-200 bg-white px-4 py-10">
<div className="mx-auto flex max-w-lg flex-wrap items-center justify-center gap-8 sm:gap-12">
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-gray-900">2,860+</span>
<span className="text-sm text-gray-600"></span>
</div>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-gray-900">30+</span>
<span className="text-sm text-gray-600"></span>
</div>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-gray-900"> 8+</span>
<span className="text-sm text-gray-600"></span>
</div>
</div>
</section>
</div>
);
}

37
app/layout.tsx Normal file
View File

@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Navbar from "./components/Navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "游牧中国 - 数字游民的理想目的地",
description:
"发现全球最适合远程工作和生活的城市,加入中国数字游民社区,探索远程办公新方式",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Navbar />
{children}
</body>
</html>
);
}

441
app/meetups/page.tsx Normal file
View File

@@ -0,0 +1,441 @@
"use client";
import { useState, useMemo } from "react";
/* ===== Types ===== */
interface Attendee {
name: string;
photo: string;
}
interface Meetup {
id: string;
city: string;
emoji: string;
date: string;
time: string;
venue: string;
address: string;
description: string;
rsvpCount: number;
gradientFrom: string;
gradientTo: string;
attendees: Attendee[];
isUpcoming: boolean;
}
/* ===== Mock Data ===== */
const allMeetups: Meetup[] = [
{
id: "1",
city: "大理",
emoji: "🏯",
date: "2026-03-15",
time: "14:00",
venue: "大理古城人民路咖啡馆",
address: "大理市大理古城人民路中段",
description: "苍山脚下,洱海边,与大理的慢生活游民们一起喝咖啡、晒太阳、聊人生。",
rsvpCount: 12,
gradientFrom: "#3b82f6",
gradientTo: "#4f46e5",
attendees: [
{ name: "林晓", photo: "https://i.pravatar.cc/150?img=1" },
{ name: "张明", photo: "https://i.pravatar.cc/150?img=2" },
{ name: "陈静", photo: "https://i.pravatar.cc/150?img=3" },
{ name: "周杰", photo: "https://i.pravatar.cc/150?img=4" },
{ name: "吴芳", photo: "https://i.pravatar.cc/150?img=5" },
],
isUpcoming: true,
},
{
id: "2",
city: "成都",
emoji: "🐼",
date: "2026-03-18",
time: "19:00",
venue: "方所书店",
address: "成都市锦江区中纱帽街8号远洋太古里",
description: "天府之国游民聚会,在文艺书店里品茶聊天,探讨成都的慢生活与远程工作可能性。",
rsvpCount: 9,
gradientFrom: "#22c55e",
gradientTo: "#10b981",
attendees: [
{ name: "杨帆", photo: "https://i.pravatar.cc/150?img=6" },
{ name: "赵磊", photo: "https://i.pravatar.cc/150?img=7" },
{ name: "黄薇", photo: "https://i.pravatar.cc/150?img=8" },
{ name: "孙浩", photo: "https://i.pravatar.cc/150?img=9" },
],
isUpcoming: true,
},
{
id: "3",
city: "深圳",
emoji: "🌃",
date: "2026-03-20",
time: "19:30",
venue: "WeWork 卓越前海壹号",
address: "深圳市南山区前海深港合作区卓越前海壹号",
description: "深圳数字游民线下见面会,探讨创业、远程工作与湾区发展机遇。",
rsvpCount: 15,
gradientFrom: "#ff4d4f",
gradientTo: "#ff7a45",
attendees: [
{ name: "李娜", photo: "https://i.pravatar.cc/150?img=10" },
{ name: "王强", photo: "https://i.pravatar.cc/150?img=11" },
{ name: "刘洋", photo: "https://i.pravatar.cc/150?img=12" },
{ name: "陈思", photo: "https://i.pravatar.cc/150?img=13" },
{ name: "郑凯", photo: "https://i.pravatar.cc/150?img=14" },
{ name: "何琳", photo: "https://i.pravatar.cc/150?img=15" },
],
isUpcoming: true,
},
{
id: "4",
city: "上海",
emoji: "🏙️",
date: "2026-03-22",
time: "19:00",
venue: "裸心社 新天地",
address: "上海市黄浦区马当路388号复兴SOHO广场",
description: "魔都数字游民月度聚会,在时尚共享空间交流国际化远程工作与创业心得。",
rsvpCount: 18,
gradientFrom: "#ec4899",
gradientTo: "#ef4444",
attendees: [
{ name: "许婷", photo: "https://i.pravatar.cc/150?img=16" },
{ name: "马超", photo: "https://i.pravatar.cc/150?img=17" },
{ name: "罗敏", photo: "https://i.pravatar.cc/150?img=18" },
{ name: "林晓", photo: "https://i.pravatar.cc/150?img=19" },
{ name: "张明", photo: "https://i.pravatar.cc/150?img=20" },
],
isUpcoming: true,
},
{
id: "5",
city: "杭州",
emoji: "⛲",
date: "2026-03-25",
time: "18:00",
venue: "梦想小镇·良仓咖啡",
address: "杭州市余杭区梦想小镇互联网村",
description: "西湖畔数字游民聚会,在创业氛围浓厚的梦想小镇交流产品与运营经验。",
rsvpCount: 11,
gradientFrom: "#06b6d4",
gradientTo: "#0d9488",
attendees: [
{ name: "陈静", photo: "https://i.pravatar.cc/150?img=21" },
{ name: "周杰", photo: "https://i.pravatar.cc/150?img=22" },
{ name: "吴芳", photo: "https://i.pravatar.cc/150?img=23" },
{ name: "杨帆", photo: "https://i.pravatar.cc/150?img=24" },
],
isUpcoming: true,
},
{
id: "6",
city: "厦门",
emoji: "🌊",
date: "2026-03-28",
time: "15:00",
venue: "鼓浪屿海角8号咖啡馆",
address: "厦门市思明区鼓浪屿龙头路",
description: "海岛慢生活聚会,在鼓浪屿的文艺咖啡馆里结识热爱海边远程工作的游民朋友。",
rsvpCount: 7,
gradientFrom: "#0ea5e9",
gradientTo: "#0284c7",
attendees: [
{ name: "赵磊", photo: "https://i.pravatar.cc/150?img=25" },
{ name: "黄薇", photo: "https://i.pravatar.cc/150?img=26" },
{ name: "孙浩", photo: "https://i.pravatar.cc/150?img=27" },
],
isUpcoming: true,
},
{
id: "7",
city: "北京",
emoji: "🏛️",
date: "2026-03-30",
time: "19:00",
venue: "氪空间 望京SOHO",
address: "北京市朝阳区望京街10号望京SOHO T3",
description: "首都数字游民聚会,在望京科技圈交流北漂远程工作与创业故事。",
rsvpCount: 14,
gradientFrom: "#6366f1",
gradientTo: "#4f46e5",
attendees: [
{ name: "李娜", photo: "https://i.pravatar.cc/150?img=28" },
{ name: "王强", photo: "https://i.pravatar.cc/150?img=29" },
{ name: "刘洋", photo: "https://i.pravatar.cc/150?img=30" },
{ name: "陈思", photo: "https://i.pravatar.cc/150?img=31" },
{ name: "郑凯", photo: "https://i.pravatar.cc/150?img=32" },
],
isUpcoming: true,
},
{
id: "8",
city: "昆明",
emoji: "🌸",
date: "2026-04-02",
time: "14:00",
venue: "翠湖边上咖啡馆",
address: "昆明市五华区翠湖东路",
description: "春城数字游民聚会,在四季如春的昆明享受慢节奏,交流旅居与远程工作心得。",
rsvpCount: 8,
gradientFrom: "#f472b6",
gradientTo: "#ec4899",
attendees: [
{ name: "何琳", photo: "https://i.pravatar.cc/150?img=33" },
{ name: "许婷", photo: "https://i.pravatar.cc/150?img=34" },
{ name: "马超", photo: "https://i.pravatar.cc/150?img=35" },
{ name: "罗敏", photo: "https://i.pravatar.cc/150?img=36" },
],
isUpcoming: true,
},
{
id: "9",
city: "广州",
emoji: "🌺",
date: "2026-04-05",
time: "19:00",
venue: "TIMETABLE 天德广场",
address: "广州市天河区临江大道395号天德广场",
description: "羊城数字游民聚会,在珠江新城的高端共享空间探讨粤港澳大湾区远程工作机遇。",
rsvpCount: 12,
gradientFrom: "#f59e0b",
gradientTo: "#ea580c",
attendees: [
{ name: "林晓", photo: "https://i.pravatar.cc/150?img=37" },
{ name: "张明", photo: "https://i.pravatar.cc/150?img=38" },
{ name: "陈静", photo: "https://i.pravatar.cc/150?img=39" },
{ name: "周杰", photo: "https://i.pravatar.cc/150?img=40" },
{ name: "吴芳", photo: "https://i.pravatar.cc/150?img=41" },
],
isUpcoming: true,
},
/* ===== 往期聚会 ===== */
{
id: "10",
city: "南京",
emoji: "🍃",
date: "2026-03-05",
time: "19:00",
venue: "先锋书店 五台山店",
address: "南京市鼓楼区广州路173号五台山体育馆地下",
description: "金陵数字游民聚会,在南京最具文艺气息的书店里交流古都文化与远程工作。",
rsvpCount: 6,
gradientFrom: "#84cc16",
gradientTo: "#65a30d",
attendees: [
{ name: "杨帆", photo: "https://i.pravatar.cc/150?img=42" },
{ name: "赵磊", photo: "https://i.pravatar.cc/150?img=43" },
],
isUpcoming: false,
},
{
id: "11",
city: "重庆",
emoji: "🌶️",
date: "2026-03-02",
time: "18:30",
venue: "洪崖洞观景咖啡馆",
address: "重庆市渝中区嘉滨路88号洪崖洞",
description: "山城数字游民聚会在魔幻8D城市里结识热爱火锅与远程工作的游民朋友。",
rsvpCount: 10,
gradientFrom: "#dc2626",
gradientTo: "#b91c1c",
attendees: [
{ name: "黄薇", photo: "https://i.pravatar.cc/150?img=44" },
{ name: "孙浩", photo: "https://i.pravatar.cc/150?img=45" },
{ name: "李娜", photo: "https://i.pravatar.cc/150?img=46" },
{ name: "王强", photo: "https://i.pravatar.cc/150?img=47" },
],
isUpcoming: false,
},
{
id: "12",
city: "长沙",
emoji: "🎪",
date: "2026-02-28",
time: "19:00",
venue: "梅溪湖创新中心 星巴克",
address: "长沙市岳麓区梅溪湖国际新城",
description: "星城数字游民聚会,在网红城市长沙交流新媒体、直播与远程工作新业态。",
rsvpCount: 8,
gradientFrom: "#f97316",
gradientTo: "#ea580c",
attendees: [
{ name: "刘洋", photo: "https://i.pravatar.cc/150?img=48" },
{ name: "陈思", photo: "https://i.pravatar.cc/150?img=49" },
{ name: "郑凯", photo: "https://i.pravatar.cc/150?img=50" },
],
isUpcoming: false,
},
];
/* ===== Date formatting ===== */
const weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
function formatMeetupDate(dateStr: string, time: string): string {
const d = new Date(dateStr + "T" + time);
const weekday = weekdays[d.getDay()];
const year = d.getFullYear();
const month = d.getMonth() + 1;
const day = d.getDate();
const timeFormatted = time.slice(0, 5);
return `${weekday}, ${year}${month}${day}日, ${timeFormatted}`;
}
/* ===== Meetup Card Component ===== */
function MeetupCard({ meetup }: { meetup: Meetup }) {
const formattedDate = formatMeetupDate(meetup.date, meetup.time);
return (
<article className="group bg-white rounded-2xl overflow-hidden shadow-[0_2px_8px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] hover:shadow-[0_12px_28px_rgba(0,0,0,0.12),0_0_0_1px_rgba(0,0,0,0.05)] hover:-translate-y-2 transition-all duration-300 cursor-pointer flex flex-col h-full">
{/* Gradient header */}
<div
className="h-24 sm:h-28 flex flex-col justify-end p-4 text-white"
style={{
background: `linear-gradient(135deg, ${meetup.gradientFrom}, ${meetup.gradientTo})`,
}}
>
<p className="text-xs sm:text-sm text-white/90 font-medium">
{formattedDate}
</p>
</div>
{/* Content */}
<div className="flex-1 flex flex-col p-4 sm:p-5">
{/* City + RSVP badge */}
<div className="flex items-center justify-between gap-3 mb-2">
<h3 className="text-xl sm:text-2xl font-bold text-gray-900">
{meetup.emoji} {meetup.city}
</h3>
<span className="shrink-0 bg-[#ff4d4f] text-white text-xs font-semibold px-2.5 py-1 rounded-full">
{meetup.rsvpCount}
</span>
</div>
{/* Venue */}
<p className="text-sm text-gray-600 font-medium mb-0.5">
@ {meetup.venue}
</p>
<p className="text-xs text-gray-500 mb-3">{meetup.address}</p>
{/* Description */}
<p className="text-sm text-gray-600 line-clamp-2 flex-1 mb-2">
{meetup.description}
</p>
<button className="text-sm font-medium text-[#ff4d4f] hover:text-[#ff7a45] transition-colors text-left w-fit">
</button>
{/* Attendee avatars */}
<div className="flex items-center gap-1 mt-4 pt-4 border-t border-gray-100">
<div className="flex -space-x-2">
{meetup.attendees.map((a) => (
<img
key={a.name}
src={a.photo}
alt={a.name}
className="w-7 h-7 sm:w-8 sm:h-8 rounded-full border-2 border-white shadow-sm object-cover"
title={a.name}
/>
))}
</div>
<span className="text-xs text-gray-400 ml-1">
</span>
</div>
</div>
</article>
);
}
/* ===== Page Component ===== */
export default function MeetupsPage() {
const [activeTab, setActiveTab] = useState<"upcoming" | "previous">("upcoming");
const upcomingMeetups = useMemo(
() => allMeetups.filter((m) => m.isUpcoming),
[]
);
const previousMeetups = useMemo(
() => allMeetups.filter((m) => !m.isUpcoming),
[]
);
const displayedMeetups =
activeTab === "upcoming" ? upcomingMeetups : previousMeetups;
const totalCount =
activeTab === "upcoming"
? upcomingMeetups.length
: previousMeetups.length;
return (
<div className="min-h-screen bg-[#fafafa]">
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 md:px-10 py-8 sm:py-12">
{/* Page header */}
<header className="mb-8">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 flex items-center gap-2">
<span className="w-1 h-8 bg-[#ff4d4f] rounded-full" />
线
</h1>
<p className="text-gray-500 mt-1 text-sm sm:text-base">
</p>
</header>
{/* Tabs */}
<div className="flex gap-1 p-1 bg-white rounded-xl shadow-sm border border-gray-100 mb-8 inline-flex">
<button
onClick={() => setActiveTab("upcoming")}
className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "upcoming"
? "bg-[#ff4d4f] text-white shadow-sm"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"
}`}
>
</button>
<button
onClick={() => setActiveTab("previous")}
className={`px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
activeTab === "previous"
? "bg-[#ff4d4f] text-white shadow-sm"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"
}`}
>
</button>
</div>
{/* Subtitle with count */}
<p className="text-gray-500 text-sm mb-6">
{activeTab === "upcoming" ? "共" : "共"}
<span className="font-semibold text-gray-700 mx-1">
{totalCount}
</span>
{activeTab === "upcoming" ? "场即将举办的聚会" : "场往期聚会"}
</p>
{/* Meetup cards grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 sm:gap-6">
{displayedMeetups.map((meetup) => (
<MeetupCard key={meetup.id} meetup={meetup} />
))}
</div>
{/* Empty state */}
{displayedMeetups.length === 0 && (
<div className="text-center py-16 sm:py-24">
<span className="text-5xl sm:text-6xl mb-4 block">🍹</span>
<p className="text-gray-500 text-base sm:text-lg">
{activeTab === "upcoming"
? "暂无即将举办的聚会"
: "暂无往期聚会记录"}
</p>
</div>
)}
</main>
</div>
);
}

527
app/page.tsx Normal file
View File

@@ -0,0 +1,527 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import HeroSection from "./components/HeroSection";
import FilterBar from "./components/FilterBar";
import CityCard from "./components/CityCard";
import { cities } from "./data/cities";
/* ===== Avatar photos ===== */
const memberPhotos = [
{ name: "林晓", photo: "https://i.pravatar.cc/150?img=5" },
{ name: "张浩", photo: "https://i.pravatar.cc/150?img=3" },
{ name: "陈悦", photo: "https://i.pravatar.cc/150?img=9" },
{ name: "周杰", photo: "https://i.pravatar.cc/150?img=7" },
{ name: "吴婷", photo: "https://i.pravatar.cc/150?img=1" },
{ name: "杨帅", photo: "https://i.pravatar.cc/150?img=12" },
{ name: "赵琪", photo: "https://i.pravatar.cc/150?img=16" },
{ name: "黄磊", photo: "https://i.pravatar.cc/150?img=11" },
{ name: "孙莉", photo: "https://i.pravatar.cc/150?img=20" },
];
const travelingNow = [
{ name: "小明", photo: "https://i.pravatar.cc/150?img=52", dest: "大理" },
{ name: "雨晴", photo: "https://i.pravatar.cc/150?img=44", dest: "成都" },
{ name: "阿杰", photo: "https://i.pravatar.cc/150?img=53", dest: "深圳" },
{ name: "小燕", photo: "https://i.pravatar.cc/150?img=47", dest: "杭州" },
{ name: "大伟", photo: "https://i.pravatar.cc/150?img=55", dest: "厦门" },
{ name: "佳琪", photo: "https://i.pravatar.cc/150?img=48", dest: "昆明" },
{ name: "志远", photo: "https://i.pravatar.cc/150?img=57", dest: "丽江" },
{ name: "心怡", photo: "https://i.pravatar.cc/150?img=49", dest: "三亚" },
{ name: "凯文", photo: "https://i.pravatar.cc/150?img=59", dest: "上海" },
{ name: "子豪", photo: "https://i.pravatar.cc/150?img=60", dest: "北京" },
{ name: "浩然", photo: "https://i.pravatar.cc/150?img=61", dest: "广州" },
{ name: "雅琪", photo: "https://i.pravatar.cc/150?img=21", dest: "南京" },
{ name: "天翔", photo: "https://i.pravatar.cc/150?img=67", dest: "长沙" },
{ name: "美玲", photo: "https://i.pravatar.cc/150?img=23", dest: "苏州" },
{ name: "俊杰", photo: "https://i.pravatar.cc/150?img=68", dest: "青岛" },
{ name: "晓华", photo: "https://i.pravatar.cc/150?img=26", dest: "珠海" },
];
const meetups = [
{ city: "大理", emoji: "🏯", date: "3月9日", count: 12 },
{ city: "成都", emoji: "🐼", date: "3月12日", count: 8 },
{ city: "深圳", emoji: "🌃", date: "3月14日", count: 15 },
{ city: "厦门", emoji: "🌊", date: "3月16日", count: 6 },
{ city: "杭州", emoji: "⛲", date: "3月20日", count: 23 },
];
const hotTopics = [
{ title: "大理最佳共享办公空间推荐 Top 10", views: 2340 },
{ title: "2026年数字游民友好城市排名", views: 1890 },
{ title: "远程工作者的税务规划指南", views: 1560 },
{ title: "成都 vs 大理:哪个更适合游民?", views: 1230 },
{ title: "新手游民必备工具与装备清单", views: 980 },
];
function safetyOrder(s: string): number {
const o: Record<string, number> = { 极好: 5, 很好: 4, : 3, 一般: 2, : 1 };
return o[s] || 0;
}
export default function Home() {
const [activeFilter, setActiveFilter] = useState("全部");
const [sortBy, setSortBy] = useState("score");
const filteredCities = useMemo(() => {
let result = [...cities];
if (activeFilter !== "全部") {
result = result.filter((c) => c.tags.includes(activeFilter));
}
result.sort((a, b) => {
switch (sortBy) {
case "cost-asc":
return a.costPerMonth - b.costPerMonth;
case "cost-desc":
return b.costPerMonth - a.costPerMonth;
case "internet":
return b.internetSpeed - a.internetSpeed;
case "safety":
return safetyOrder(b.safety) - safetyOrder(a.safety);
case "nomads":
return b.nomadsNow - a.nomadsNow;
case "temperature":
return b.temperature - a.temperature;
default:
return b.overallScore - a.overallScore;
}
});
return result;
}, [activeFilter, sortBy]);
return (
<div className="min-h-screen bg-[#fafafa]">
<HeroSection />
{/* Stats Strip */}
<div className="border-y border-gray-100 bg-white">
<div className="max-w-[1400px] mx-auto px-5 sm:px-10 py-4 flex flex-wrap items-center justify-center gap-6 sm:gap-12 text-center">
<div>
<span className="text-xl sm:text-2xl font-bold text-gray-900">18</span>
<p className="text-xs text-gray-400 mt-0.5"></p>
</div>
<div className="w-px h-8 bg-gray-100 hidden sm:block" />
<div>
<span className="text-xl sm:text-2xl font-bold text-gray-900">5,000+</span>
<p className="text-xs text-gray-400 mt-0.5"></p>
</div>
<div className="w-px h-8 bg-gray-100 hidden sm:block" />
<div>
<span className="text-xl sm:text-2xl font-bold text-gray-900">8+</span>
<p className="text-xs text-gray-400 mt-0.5">/</p>
</div>
<div className="w-px h-8 bg-gray-100 hidden sm:block" />
<div>
<span className="text-xl sm:text-2xl font-bold text-gray-900">¥3,000</span>
<p className="text-xs text-gray-400 mt-0.5">/</p>
</div>
</div>
</div>
{/* Filter Bar */}
<div className="px-3 sm:px-5 md:px-10 pt-5">
<FilterBar
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
sortBy={sortBy}
onSortChange={setSortBy}
resultCount={filteredCities.length}
/>
</div>
{/* Mixed Grid */}
<div className="items-grid">
<Link href="/join" className="right-item block">
<div className="right-item-header"></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>
<p className="text-[10px] sm:text-sm text-gray-600 mb-0.5">
</p>
<p className="text-[9px] sm:text-xs text-gray-400 mb-1.5 sm:mb-4">
3,200+ 线
</p>
<span className="bg-[#ff4d4f] text-white px-2.5 sm:px-6 py-1 sm:py-2 rounded-lg text-[10px] sm:text-sm font-semibold whitespace-nowrap">
</span>
</div>
</Link>
<Link href="/dating" className="right-item block">
<div className="right-item-header"></div>
<div className="p-2 sm:p-5 flex flex-wrap gap-1 sm:gap-2 content-start">
{memberPhotos.map((m) => (
<img
key={m.name}
src={m.photo}
alt={m.name}
title={m.name}
className="w-6 h-6 sm:w-10 sm:h-10 rounded-full border-2 border-white shadow-sm object-cover"
/>
))}
<div className="w-full mt-0.5 text-[9px] sm:text-xs text-gray-400 text-center">
86
</div>
</div>
</Link>
<Link href="/meetups" className="right-item block">
<div className="right-item-header"></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 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
<div className="flex items-center gap-1 bg-[#f5f6f7] rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-[#333]">
🗓 315
</div>
<div className="flex items-center gap-1 bg-[#f5f6f7] rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-[#333]">
📍
</div>
<div className="flex items-center gap-1 bg-[#f5f6f7] rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-[#333]">
👥 12
</div>
<div className="flex -space-x-1 mt-auto">
{memberPhotos.slice(0, 5).map((m) => (
<img
key={m.name}
src={m.photo}
alt=""
className="w-4 h-4 sm:w-7 sm:h-7 rounded-full border-2 border-white shadow-sm object-cover"
/>
))}
</div>
</div>
</Link>
{filteredCities.map((city, i) => (
<CityCard key={city.id} city={city} rank={i + 1} />
))}
</div>
{/* Empty State */}
{filteredCities.length === 0 && (
<div className="text-center py-16 sm:py-20">
<span className="text-4xl sm:text-5xl mb-4 block">🏜</span>
<p className="text-gray-500 text-base sm:text-lg">
</p>
<button
onClick={() => setActiveFilter("全部")}
className="mt-4 text-sm text-gray-700 bg-gray-200 px-5 py-2 rounded-full hover:bg-gray-300 transition-colors"
>
</button>
</div>
)}
{/* Popular Routes */}
<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 mb-5 sm:mb-6 flex items-center gap-2">
<span className="w-1 h-5 bg-[#ff4d4f] rounded-full" />
线
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{[
{
title: "云南慢生活线",
cities: ["昆明", "大理", "丽江"],
emojis: ["🌸", "🏯", "🏔️"],
duration: "3-6个月",
cost: "¥3,200/月起",
desc: "从春城昆明出发,沿着滇西北一路感受最纯粹的慢生活",
gradient: "from-purple-500 to-pink-500",
},
{
title: "科技创业线",
cities: ["深圳", "杭州", "上海"],
emojis: ["🌃", "⛲", "🏙️"],
duration: "2-4个月",
cost: "¥7,000/月起",
desc: "穿越中国三大科技中心,连接最前沿的创业生态",
gradient: "from-blue-500 to-cyan-500",
},
{
title: "美食文化线",
cities: ["成都", "重庆", "长沙"],
emojis: ["🐼", "🌶️", "🎪"],
duration: "2-3个月",
cost: "¥4,000/月起",
desc: "巴蜀到湘楚,用味蕾丈量中国最火辣的城市",
gradient: "from-orange-500 to-red-500",
},
].map((route) => (
<div
key={route.title}
className="community-card group cursor-pointer"
>
<div className={`h-2 bg-gradient-to-r ${route.gradient}`} />
<div className="p-4 sm:p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-gray-900 text-sm">
{route.title}
</h3>
<span className="text-[11px] text-gray-400 bg-gray-50 rounded-full px-2 py-0.5">
{route.duration}
</span>
</div>
<div className="flex items-center gap-1 mb-3">
{route.cities.map((city, i) => (
<span key={city} className="flex items-center gap-0.5 text-sm">
<span>{route.emojis[i]}</span>
<span className="font-medium text-gray-700">{city}</span>
{i < route.cities.length - 1 && (
<span className="text-gray-300 mx-1"></span>
)}
</span>
))}
</div>
<p className="text-xs text-gray-500 mb-3 leading-relaxed">
{route.desc}
</p>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-900">
{route.cost}
</span>
<span className="text-xs text-[#ff4d4f] font-medium group-hover:translate-x-1 transition-transform">
</span>
</div>
</div>
</div>
))}
</div>
</section>
{/* Community 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 mb-5 sm:mb-6 flex items-center gap-2">
<span className="w-1 h-5 bg-[#ff4d4f] rounded-full" />
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
{/* 近期聚会 */}
<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 text-sm flex items-center gap-1.5">
🍹
</h3>
<span className="text-[11px] text-gray-400 bg-gray-50 rounded-full px-2 py-0.5">
8/
</span>
</div>
<div className="space-y-2.5">
{meetups.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">{m.city}</p>
<p className="text-[11px] text-gray-400">{m.date}</p>
</div>
</div>
<span className="text-[11px] text-gray-400 bg-gray-50 rounded-full px-2 py-0.5">
{m.count}
</span>
</div>
))}
</div>
<Link
href="/meetups"
className="block w-full text-center text-xs font-medium text-[#ff4d4f] hover:text-[#ff7a45] mt-4 pt-3 border-t border-gray-100 transition-colors"
>
</Link>
</div>
</div>
{/* 正在旅行 */}
<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 text-sm flex items-center gap-1.5">
🛩
</h3>
<span className="text-[11px] text-gray-400 bg-gray-50 rounded-full px-2 py-0.5">
{travelingNow.length}
</span>
</div>
<div className="flex flex-wrap gap-2">
{travelingNow.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>
{/* 热门讨论 */}
<div className="community-card">
<div className="p-4 sm:p-5">
<h3 className="font-bold text-gray-900 text-sm flex items-center gap-1.5 mb-4">
🔥
</h3>
<div className="space-y-3">
{hotTopics.map((t, i) => (
<a key={t.title} href="#" 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"}`}>
{i + 1}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-700 group-hover:text-gray-900 transition-colors line-clamp-1 leading-snug">
{t.title}
</p>
<p className="text-[11px] text-gray-400 mt-0.5">
{t.views.toLocaleString()}
</p>
</div>
</a>
))}
</div>
<button className="w-full text-center text-xs font-medium text-[#ff4d4f] hover:text-[#ff7a45] mt-4 pt-3 border-t border-gray-100 transition-colors">
</button>
</div>
</div>
{/* 全球旅行保险 */}
<div className="community-card bg-gradient-to-br from-emerald-50 to-teal-50">
<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 text-sm"></h3>
<p className="text-xs text-gray-500 mt-0.5"></p>
</div>
</div>
<p className="text-xs text-emerald-700 bg-emerald-100/50 rounded-lg px-3 py-2 mb-3">
+ ¥15/
</p>
<button className="w-full text-center text-sm font-semibold text-emerald-700 hover:text-emerald-800 bg-white/80 hover:bg-white border border-emerald-200 rounded-full py-2 transition-colors">
</button>
</div>
</div>
{/* 社区聊天 */}
<div className="community-card bg-gradient-to-br from-violet-50 to-purple-50">
<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 text-sm"></h3>
<p className="text-xs text-gray-500 mt-0.5"> 3,200+ </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 shadow-sm object-cover"
/>
))}
</div>
<span className="text-xs text-gray-400">线</span>
</div>
<button className="w-full text-center text-sm font-semibold text-violet-700 hover:text-violet-800 bg-white/80 hover:bg-white border border-violet-200 rounded-full py-2 transition-colors">
</button>
</div>
</div>
{/* 签证/落户 */}
<div className="community-card bg-gradient-to-br from-amber-50 to-orange-50">
<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 text-sm"></h3>
<p className="text-xs text-gray-500 mt-0.5">
· ·
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{["🏯 大理", "🐼 成都", "🌃 深圳", "⛲ 杭州"].map((tag) => (
<span
key={tag}
className="text-[11px] bg-amber-100/60 text-amber-700 rounded-full px-2 py-0.5"
>
{tag}
</span>
))}
</div>
<button className="w-full text-center text-sm font-semibold text-amber-700 hover:text-amber-800 bg-white/80 hover:bg-white border border-amber-200 rounded-full py-2 transition-colors">
</button>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-t border-gray-200 bg-white">
<div className="max-w-[1400px] mx-auto px-5 sm:px-10 py-10 sm:py-12">
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8 mb-8">
<div>
<h4 className="font-bold text-gray-900 text-sm mb-3"></h4>
<ul className="space-y-2 text-sm text-gray-500">
<li><Link href="/join" className="hover:text-gray-700"></Link></li>
<li><Link href="/meetups" className="hover:text-gray-700">线</Link></li>
<li><Link href="/dating" className="hover:text-gray-700"></Link></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
</ul>
</div>
<div>
<h4 className="font-bold text-gray-900 text-sm mb-3"></h4>
<ul className="space-y-2 text-sm text-gray-500">
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
</ul>
</div>
<div>
<h4 className="font-bold text-gray-900 text-sm mb-3"></h4>
<ul className="space-y-2 text-sm text-gray-500">
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
</ul>
</div>
<div>
<h4 className="font-bold text-gray-900 text-sm mb-3"></h4>
<ul className="space-y-2 text-sm text-gray-500">
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700">使</a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
<li><a href="#" className="hover:text-gray-700"></a></li>
</ul>
</div>
</div>
<div className="pt-8 border-t border-gray-200 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="text-xl">🌏</span>
<span className="text-sm font-bold text-gray-900"></span>
</div>
<p className="text-xs text-gray-400">
© 2024-2026 NomadCNA. All rights reserved.
</p>
<div className="flex items-center gap-4 text-xs text-gray-400">
<span>CNY ¥</span>
<span>°C</span>
</div>
</div>
</div>
</footer>
</div>
);
}

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

1
homepage_content.html Normal file

File diff suppressed because one or more lines are too long

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "nextnomadcna",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

4037
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}