Add cost compare, blog tables, cookie banner, and polish UI/SEO
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
aba3f7e2ec
commit
8d018d34e2
@ -31,6 +31,7 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://nomadweb.nomadro.com/api/v1}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://nomadweb.nomadro.com/api/v1}
|
||||||
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://nomadweb.nomadro.com}
|
||||||
container_name: nomadflow-web
|
container_name: nomadflow-web
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3055:3000"
|
- "127.0.0.1:3055:3000"
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
|||||||
@ -10,7 +10,9 @@ WORKDIR /app
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
ARG NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
ARG NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||||
|
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
|
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|||||||
@ -2,21 +2,9 @@ import Link from "next/link";
|
|||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import SiteShell from "@/components/SiteShell";
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import BlogMarkdown from "@/components/BlogMarkdown";
|
||||||
import BlogReadingProgress from "@/components/BlogReadingProgress";
|
import BlogReadingProgress from "@/components/BlogReadingProgress";
|
||||||
|
|
||||||
function renderMarkdown(content: string) {
|
|
||||||
return content.split("\n").map((line, i) => {
|
|
||||||
if (line.startsWith("## ")) return <h2 key={i}>{line.slice(3)}</h2>;
|
|
||||||
if (line.startsWith("### ")) return <h3 key={i}>{line.slice(4)}</h3>;
|
|
||||||
if (line.startsWith("| ")) return null; // skip table rows for simplicity
|
|
||||||
if (line.startsWith("- ")) return <li key={i}>{line.slice(2)}</li>;
|
|
||||||
if (line.match(/^\d+\. /)) return <li key={i}>{line.replace(/^\d+\. /, "")}</li>;
|
|
||||||
if (line.trim() === "") return <br key={i} />;
|
|
||||||
if (line.startsWith("**Q:")) return <p key={i}><strong>{line}</strong></p>;
|
|
||||||
return <p key={i}>{line}</p>;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BlogDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
export default async function BlogDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
let post;
|
let post;
|
||||||
@ -47,7 +35,7 @@ export default async function BlogDetailPage({ params }: { params: Promise<{ slu
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="blog-detail-content">
|
<div className="blog-detail-content">
|
||||||
{renderMarkdown(post.content)}
|
<BlogMarkdown content={post.content} />
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -191,7 +191,21 @@ img { max-width: 100%; display: block; }
|
|||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--text-primary);
|
background: var(--text-primary);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transition: all var(--transition);
|
transition: all 0.3s ease;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn.open span:nth-child(1) {
|
||||||
|
transform: translateY(7px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn.open span:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn.open span:nth-child(3) {
|
||||||
|
transform: translateY(-7px) rotate(-45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Hero ===== */
|
/* ===== Hero ===== */
|
||||||
@ -1167,9 +1181,8 @@ img { max-width: 100%; display: block; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.nav-links { display: none; }
|
.nav-links {
|
||||||
.nav-links.open {
|
display: none;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--nav-height);
|
top: var(--nav-height);
|
||||||
@ -1178,6 +1191,16 @@ img { max-width: 100%; display: block; }
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-bottom: var(--border-glass);
|
border-bottom: var(--border-glass);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.nav-links.open {
|
||||||
|
display: flex;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-menu-btn { display: flex; }
|
.mobile-menu-btn { display: flex; }
|
||||||
@ -4505,3 +4528,263 @@ img { max-width: 100%; display: block; }
|
|||||||
.currency-result-amount { font-size: 1.8rem; }
|
.currency-result-amount { font-size: 1.8rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Cost Compare ===== */
|
||||||
|
.cost-compare-section {
|
||||||
|
background: linear-gradient(180deg, transparent, rgba(78, 205, 196, 0.03), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-compare-card {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: var(--border-glass);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-compare-selectors {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-compare-select label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-city-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-city-chip {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 50px;
|
||||||
|
border: var(--border-glass);
|
||||||
|
background: var(--bg-glass);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-city-chip:hover,
|
||||||
|
.cost-city-chip.active {
|
||||||
|
border-color: rgba(78, 205, 196, 0.4);
|
||||||
|
background: rgba(78, 205, 196, 0.12);
|
||||||
|
color: var(--accent-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-compare-select select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: var(--border-glass);
|
||||||
|
background: var(--bg-glass);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-compare-bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-bar-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-bar-track {
|
||||||
|
height: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: width 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-bar-fill.home {
|
||||||
|
background: linear-gradient(90deg, #FF6B6B, #FFE66D);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-bar-fill.dest {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-bar-fill.dest.cheaper {
|
||||||
|
background: linear-gradient(90deg, #4ECDC4, #6ee7b7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-compare-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg-glass);
|
||||||
|
border: var(--border-glass);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-compare-result.saving {
|
||||||
|
background: rgba(78, 205, 196, 0.08);
|
||||||
|
border-color: rgba(78, 205, 196, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-result-emoji { font-size: 2rem; }
|
||||||
|
|
||||||
|
.cost-compare-result strong {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cost-compare-result p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Tools Filters ===== */
|
||||||
|
.tools-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-link {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--accent-3);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(78, 205, 196, 0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-link:hover {
|
||||||
|
background: rgba(78, 205, 196, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-link-static {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: var(--bg-glass);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Blog Tables ===== */
|
||||||
|
.blog-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: var(--border-glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-table th,
|
||||||
|
.blog-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-table th {
|
||||||
|
background: rgba(78, 205, 196, 0.08);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-table tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
.blog-table tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Cookie Consent ===== */
|
||||||
|
.cookie-consent {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 9000;
|
||||||
|
width: min(560px, calc(100% - 32px));
|
||||||
|
animation: toastIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookie-consent-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: var(--border-glass);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookie-emoji { font-size: 1.5rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.cookie-consent-inner p {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cookie-consent-inner a {
|
||||||
|
color: var(--accent-3);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cookie-consent-inner {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cost-compare-card { padding: 20px; }
|
||||||
|
.nav-links.open {
|
||||||
|
animation: menuSlide 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes menuSlide {
|
||||||
|
from { opacity: 0; transform: translateY(-10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,10 @@ import "./globals.css";
|
|||||||
import { AuthProvider } from "@/lib/auth";
|
import { AuthProvider } from "@/lib/auth";
|
||||||
import { ToastProvider } from "@/lib/toast";
|
import { ToastProvider } from "@/lib/toast";
|
||||||
|
|
||||||
|
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(SITE),
|
||||||
title: "NomadFlow · 数字游民旅居指南",
|
title: "NomadFlow · 数字游民旅居指南",
|
||||||
description: "探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。",
|
description: "探索全球旅居生活,发现最适合数字游民的目的地、工具与社区。",
|
||||||
keywords: ["数字游民", "旅居", "远程工作", "nomad", "digital nomad"],
|
keywords: ["数字游民", "旅居", "远程工作", "nomad", "digital nomad"],
|
||||||
@ -12,7 +15,15 @@ export const metadata: Metadata = {
|
|||||||
description: "用一行代码环游世界 🌏",
|
description: "用一行代码环游世界 🌏",
|
||||||
type: "website",
|
type: "website",
|
||||||
locale: "zh_CN",
|
locale: "zh_CN",
|
||||||
|
url: SITE,
|
||||||
|
siteName: "NomadFlow",
|
||||||
},
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "NomadFlow · 数字游民旅居指南",
|
||||||
|
description: "用一行代码环游世界 🌏",
|
||||||
|
},
|
||||||
|
manifest: "/manifest.webmanifest",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
18
frontend/src/app/manifest.ts
Normal file
18
frontend/src/app/manifest.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: "NomadFlow · 数字游民旅居指南",
|
||||||
|
short_name: "NomadFlow",
|
||||||
|
description: "探索全球旅居生活,发现最适合数字游民的目的地",
|
||||||
|
start_url: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#0a0e17",
|
||||||
|
theme_color: "#4ECDC4",
|
||||||
|
icons: [
|
||||||
|
{ src: "/globe.svg", sizes: "any", type: "image/svg+xml" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
||||||
|
|
||||||
export default function robots(): MetadataRoute.Robots {
|
export default function robots(): MetadataRoute.Robots {
|
||||||
return {
|
return {
|
||||||
rules: { userAgent: "*", allow: "/" },
|
rules: { userAgent: "*", allow: "/" },
|
||||||
sitemap: "https://nomadflow.io/sitemap.xml",
|
sitemap: `${SITE}/sitemap.xml`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,23 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
||||||
const DESTINATIONS = ["bali", "lisbon", "chiangmai", "mexico", "barcelona", "tokyo"];
|
const DESTINATIONS = ["bali", "lisbon", "chiangmai", "mexico", "barcelona", "tokyo"];
|
||||||
const BLOGS = ["chiangmai-guide-2026", "portugal-d7-visa", "nomad-tax-basics"];
|
const BLOGS = ["chiangmai-guide-2026", "portugal-d7-visa", "nomad-tax-basics"];
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
const base = "https://nomadflow.io";
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ url: base, lastModified: now, changeFrequency: "weekly", priority: 1 },
|
{ url: SITE, lastModified: now, changeFrequency: "weekly", priority: 1 },
|
||||||
{ url: `${base}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
|
{ url: `${SITE}/login`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
|
||||||
{ url: `${base}/profile`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
|
|
||||||
...DESTINATIONS.map((slug) => ({
|
...DESTINATIONS.map((slug) => ({
|
||||||
url: `${base}/destinations/${slug}`,
|
url: `${SITE}/destinations/${slug}`,
|
||||||
lastModified: now,
|
lastModified: now,
|
||||||
changeFrequency: "weekly" as const,
|
changeFrequency: "weekly" as const,
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
})),
|
})),
|
||||||
...BLOGS.map((slug) => ({
|
...BLOGS.map((slug) => ({
|
||||||
url: `${base}/blog/${slug}`,
|
url: `${SITE}/blog/${slug}`,
|
||||||
lastModified: now,
|
lastModified: now,
|
||||||
changeFrequency: "monthly" as const,
|
changeFrequency: "monthly" as const,
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
|
|||||||
85
frontend/src/components/BlogMarkdown.tsx
Normal file
85
frontend/src/components/BlogMarkdown.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
function parseTableRow(line: string): string[] {
|
||||||
|
return line.split("|").map((c) => c.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlogMarkdown({ content }: { content: string }) {
|
||||||
|
const elements = useMemo(() => {
|
||||||
|
const lines = content.split("\n");
|
||||||
|
const result: React.ReactNode[] = [];
|
||||||
|
let listItems: string[] = [];
|
||||||
|
let tableRows: string[][] = [];
|
||||||
|
let key = 0;
|
||||||
|
|
||||||
|
const flushList = () => {
|
||||||
|
if (listItems.length === 0) return;
|
||||||
|
result.push(
|
||||||
|
<ul key={`ul-${key++}`}>
|
||||||
|
{listItems.map((item, i) => <li key={i}>{item}</li>)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
listItems = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const flushTable = () => {
|
||||||
|
if (tableRows.length < 2) { tableRows = []; return; }
|
||||||
|
const [header, , ...body] = tableRows;
|
||||||
|
result.push(
|
||||||
|
<div key={`table-${key++}`} className="blog-table-wrap">
|
||||||
|
<table className="blog-table">
|
||||||
|
<thead>
|
||||||
|
<tr>{header.map((h, i) => <th key={i}>{h}</th>)}</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{body.map((row, ri) => (
|
||||||
|
<tr key={ri}>{row.map((cell, ci) => <td key={ci}>{cell}</td>)}</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
tableRows = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("|")) {
|
||||||
|
flushList();
|
||||||
|
const cells = parseTableRow(line);
|
||||||
|
if (cells.every((c) => /^[-:]+$/.test(c))) continue;
|
||||||
|
tableRows.push(cells);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushTable();
|
||||||
|
|
||||||
|
if (line.startsWith("## ")) {
|
||||||
|
flushList();
|
||||||
|
result.push(<h2 key={key++}>{line.slice(3)}</h2>);
|
||||||
|
} else if (line.startsWith("### ")) {
|
||||||
|
flushList();
|
||||||
|
result.push(<h3 key={key++}>{line.slice(4)}</h3>);
|
||||||
|
} else if (line.startsWith("- ")) {
|
||||||
|
listItems.push(line.slice(2));
|
||||||
|
} else if (line.match(/^\d+\. /)) {
|
||||||
|
listItems.push(line.replace(/^\d+\. /, ""));
|
||||||
|
} else if (line.trim() === "") {
|
||||||
|
flushList();
|
||||||
|
} else if (line.startsWith("**")) {
|
||||||
|
flushList();
|
||||||
|
result.push(<p key={key++}><strong>{line.replace(/\*\*/g, "")}</strong></p>);
|
||||||
|
} else {
|
||||||
|
flushList();
|
||||||
|
result.push(<p key={key++}>{line}</p>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushList();
|
||||||
|
flushTable();
|
||||||
|
return result;
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
return <>{elements}</>;
|
||||||
|
}
|
||||||
35
frontend/src/components/CookieConsent.tsx
Normal file
35
frontend/src/components/CookieConsent.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function CookieConsent() {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!localStorage.getItem("nomad-cookie-consent")) {
|
||||||
|
const timer = setTimeout(() => setVisible(true), 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const accept = () => {
|
||||||
|
localStorage.setItem("nomad-cookie-consent", "1");
|
||||||
|
setVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="cookie-consent">
|
||||||
|
<div className="cookie-consent-inner">
|
||||||
|
<span className="cookie-emoji">🍪</span>
|
||||||
|
<p>
|
||||||
|
我们使用 localStorage 保存主题偏好和行程数据,不会追踪你的浏览行为。
|
||||||
|
<Link href="/#faq">了解更多</Link>
|
||||||
|
</p>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={accept}>知道了 ✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
frontend/src/components/CostCompare.tsx
Normal file
119
frontend/src/components/CostCompare.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
|
||||||
|
const HOME_CITIES = [
|
||||||
|
{ name: "上海", cost: 12000, emoji: "🏙️" },
|
||||||
|
{ name: "北京", cost: 11500, emoji: "🏛️" },
|
||||||
|
{ name: "深圳", cost: 11000, emoji: "🌆" },
|
||||||
|
{ name: "广州", cost: 9000, emoji: "🌃" },
|
||||||
|
{ name: "成都", cost: 7000, emoji: "🐼" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
destinations: Destination[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CostCompare({ destinations }: Props) {
|
||||||
|
const [homeIdx, setHomeIdx] = useState(0);
|
||||||
|
const [destSlug, setDestSlug] = useState(destinations[0]?.slug || "");
|
||||||
|
|
||||||
|
const home = HOME_CITIES[homeIdx];
|
||||||
|
const dest = destinations.find((d) => d.slug === destSlug);
|
||||||
|
|
||||||
|
const comparison = useMemo(() => {
|
||||||
|
if (!dest) return null;
|
||||||
|
const diff = home.cost - dest.cost;
|
||||||
|
const pct = Math.round((diff / home.cost) * 100);
|
||||||
|
const yearlySave = diff * 12;
|
||||||
|
return { diff, pct, yearlySave, cheaper: diff > 0 };
|
||||||
|
}, [home, dest]);
|
||||||
|
|
||||||
|
if (!dest || !comparison) return null;
|
||||||
|
|
||||||
|
const maxCost = Math.max(home.cost, dest.cost);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section cost-compare-section" id="cost-compare">
|
||||||
|
<div className="container">
|
||||||
|
<div className="section-header reveal">
|
||||||
|
<span className="section-tag">💰 SAVINGS</span>
|
||||||
|
<h2>旅居能省多少钱?</h2>
|
||||||
|
<p>对比国内城市与海外旅居目的地的生活成本差异</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cost-compare-card reveal">
|
||||||
|
<div className="cost-compare-selectors">
|
||||||
|
<div className="cost-compare-select">
|
||||||
|
<label>🏠 国内基准城市</label>
|
||||||
|
<div className="cost-city-chips">
|
||||||
|
{HOME_CITIES.map((c, i) => (
|
||||||
|
<button
|
||||||
|
key={c.name}
|
||||||
|
className={`cost-city-chip${homeIdx === i ? " active" : ""}`}
|
||||||
|
onClick={() => setHomeIdx(i)}
|
||||||
|
>
|
||||||
|
{c.emoji} {c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cost-compare-select">
|
||||||
|
<label>🌍 旅居目的地</label>
|
||||||
|
<select value={destSlug} onChange={(e) => setDestSlug(e.target.value)}>
|
||||||
|
{destinations.map((d) => (
|
||||||
|
<option key={d.slug} value={d.slug}>{d.emoji} {d.name}, {d.country}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cost-compare-bars">
|
||||||
|
<div className="cost-bar-row">
|
||||||
|
<div className="cost-bar-label">
|
||||||
|
<span>{home.emoji} {home.name}</span>
|
||||||
|
<strong>¥{home.cost.toLocaleString()}/月</strong>
|
||||||
|
</div>
|
||||||
|
<div className="cost-bar-track">
|
||||||
|
<div className="cost-bar-fill home" style={{ width: `${(home.cost / maxCost) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cost-bar-row">
|
||||||
|
<div className="cost-bar-label">
|
||||||
|
<span>{dest.emoji} {dest.name}</span>
|
||||||
|
<strong>¥{dest.cost.toLocaleString()}/月</strong>
|
||||||
|
</div>
|
||||||
|
<div className="cost-bar-track">
|
||||||
|
<div
|
||||||
|
className={`cost-bar-fill dest${comparison.cheaper ? " cheaper" : ""}`}
|
||||||
|
style={{ width: `${(dest.cost / maxCost) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`cost-compare-result${comparison.cheaper ? " saving" : ""}`}>
|
||||||
|
{comparison.cheaper ? (
|
||||||
|
<>
|
||||||
|
<span className="cost-result-emoji">🎉</span>
|
||||||
|
<div>
|
||||||
|
<strong>每月可省 ¥{comparison.diff.toLocaleString()}</strong>
|
||||||
|
<p>比{home.name}便宜 {comparison.pct}% · 一年省 ¥{comparison.yearlySave.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="cost-result-emoji">💎</span>
|
||||||
|
<div>
|
||||||
|
<strong>每月多花 ¥{Math.abs(comparison.diff).toLocaleString()}</strong>
|
||||||
|
<p>但你能获得更好的生活质量、气候和游民社区 ✨</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,42 +1,48 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const SITE = process.env.NEXT_PUBLIC_SITE_URL || "https://nomadweb.nomadro.com";
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="footer">
|
<footer className="footer">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="footer-grid">
|
<div className="footer-grid">
|
||||||
<div className="footer-brand">
|
<div className="footer-brand">
|
||||||
<a href="#" className="logo"><span>NomadFlow</span></a>
|
<Link href="/" className="logo"><span>NomadFlow</span></Link>
|
||||||
<p>让每个人都能自由地工作和生活在这个星球上 🌏</p>
|
<p>让每个人都能自由地工作和生活在这个星球上 🌏</p>
|
||||||
<div className="social-links">
|
<div className="social-links">
|
||||||
<a href="#" aria-label="Twitter">𝕏</a>
|
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer" aria-label="Twitter">𝕏</a>
|
||||||
<a href="#" aria-label="Instagram">📷</a>
|
<a href="https://instagram.com" target="_blank" rel="noopener noreferrer" aria-label="Instagram">📷</a>
|
||||||
<a href="#" aria-label="YouTube">▶️</a>
|
<a href="https://youtube.com" target="_blank" rel="noopener noreferrer" aria-label="YouTube">▶️</a>
|
||||||
<a href="#" aria-label="Discord">💬</a>
|
<a href="https://discord.com" target="_blank" rel="noopener noreferrer" aria-label="Discord">💬</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<h4>探索</h4>
|
<h4>探索</h4>
|
||||||
<a href="#destinations">目的地</a>
|
<Link href="/#destinations">目的地</Link>
|
||||||
<a href="#calculator">费用计算器</a>
|
<Link href="/#cost-compare">省钱对比</Link>
|
||||||
<a href="#visa">签证指南</a>
|
<Link href="/#calculator">费用计算器</Link>
|
||||||
<a href="#blog">博客</a>
|
<Link href="/#visa">签证指南</Link>
|
||||||
|
<Link href="/#blog">博客</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<h4>技术栈</h4>
|
<h4>工具</h4>
|
||||||
<a href="#">Next.js</a>
|
<Link href="/#trip">行程规划</Link>
|
||||||
<a href="#">FastAPI</a>
|
<Link href="/#timezone">时区看板</Link>
|
||||||
<a href="#">PocketBase</a>
|
<Link href="/#nomad-score">就绪度测评</Link>
|
||||||
<a href="/api/docs" target="_blank">API 文档</a>
|
<Link href="/#currency">汇率换算</Link>
|
||||||
|
<a href={`${SITE}/docs`} target="_blank" rel="noopener noreferrer">API 文档</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-links">
|
<div className="footer-links">
|
||||||
<h4>联系</h4>
|
<h4>账户</h4>
|
||||||
<a href="#">关于我们</a>
|
<Link href="/login">登录 / 注册</Link>
|
||||||
<a href="#">合作伙伴</a>
|
<Link href="/profile">用户中心</Link>
|
||||||
<a href="#">隐私政策</a>
|
|
||||||
<a href="mailto:hello@nomadflow.io">hello@nomadflow.io</a>
|
<a href="mailto:hello@nomadflow.io">hello@nomadflow.io</a>
|
||||||
|
<a href="https://gitea.dsx2020.com/eric/nomadweb" target="_blank" rel="noopener noreferrer">开源仓库 ↗</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="footer-bottom">
|
<div className="footer-bottom">
|
||||||
<p>© 2026 NomadFlow. Made with ❤️ for Digital Nomads everywhere.</p>
|
<p>© 2026 NomadFlow · <a href={SITE}>{SITE.replace("https://", "")}</a></p>
|
||||||
<p className="footer-emoji-bar">🌴 ☀️ 🏄♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅</p>
|
<p className="footer-emoji-bar">🌴 ☀️ 🏄♂️ 💻 🌊 🗺️ ✈️ 🏝️ 🌅</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -28,6 +28,8 @@ import ScrollProgress from "./ScrollProgress";
|
|||||||
import SectionNav from "./SectionNav";
|
import SectionNav from "./SectionNav";
|
||||||
import CurrencyConverter from "./CurrencyConverter";
|
import CurrencyConverter from "./CurrencyConverter";
|
||||||
import NomadScore from "./NomadScore";
|
import NomadScore from "./NomadScore";
|
||||||
|
import CostCompare from "./CostCompare";
|
||||||
|
import CookieConsent from "./CookieConsent";
|
||||||
import KeyboardShortcuts from "./KeyboardShortcuts";
|
import KeyboardShortcuts from "./KeyboardShortcuts";
|
||||||
import BackToTop from "./BackToTop";
|
import BackToTop from "./BackToTop";
|
||||||
|
|
||||||
@ -100,6 +102,7 @@ export default function HomeClient(props: Props) {
|
|||||||
<TimezoneBoard destinations={props.destinations} />
|
<TimezoneBoard destinations={props.destinations} />
|
||||||
<TripPlanner destinations={props.destinations} />
|
<TripPlanner destinations={props.destinations} />
|
||||||
<Calculator destinations={props.destinations} />
|
<Calculator destinations={props.destinations} />
|
||||||
|
<CostCompare destinations={props.destinations} />
|
||||||
<CurrencyConverter />
|
<CurrencyConverter />
|
||||||
<NomadScore />
|
<NomadScore />
|
||||||
<Lifestyle />
|
<Lifestyle />
|
||||||
@ -115,6 +118,7 @@ export default function HomeClient(props: Props) {
|
|||||||
<MatcherTrigger onClick={() => setMatcherOpen(true)} />
|
<MatcherTrigger onClick={() => setMatcherOpen(true)} />
|
||||||
<DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} />
|
<DestinationMatcher destinations={props.destinations} open={matcherOpen} onClose={() => setMatcherOpen(false)} />
|
||||||
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
|
<KeyboardShortcuts onOpenMatcher={() => setMatcherOpen(true)} />
|
||||||
|
<CookieConsent />
|
||||||
|
|
||||||
{compareList.length > 0 && (
|
{compareList.length > 0 && (
|
||||||
<div className="compare-bar">
|
<div className="compare-bar">
|
||||||
|
|||||||
@ -50,6 +50,11 @@ export default function Navbar() {
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [isHome]);
|
}, [isHome]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = menuOpen ? "hidden" : "";
|
||||||
|
return () => { document.body.style.overflow = ""; };
|
||||||
|
}, [menuOpen]);
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
const next = theme === "dark" ? "light" : "dark";
|
const next = theme === "dark" ? "light" : "dark";
|
||||||
setTheme(next);
|
setTheme(next);
|
||||||
@ -94,7 +99,7 @@ export default function Navbar() {
|
|||||||
) : (
|
) : (
|
||||||
<Link href="/login" className="nav-login-btn">登录</Link>
|
<Link href="/login" className="nav-login-btn">登录</Link>
|
||||||
)}
|
)}
|
||||||
<button className="mobile-menu-btn" onClick={() => setMenuOpen(!menuOpen)} aria-label="菜单">
|
<button className={`mobile-menu-btn${menuOpen ? " open" : ""}`} onClick={() => setMenuOpen(!menuOpen)} aria-label="菜单">
|
||||||
<span /><span /><span />
|
<span /><span /><span />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ const SECTIONS = [
|
|||||||
{ id: "timezone", emoji: "🕐", label: "时区" },
|
{ id: "timezone", emoji: "🕐", label: "时区" },
|
||||||
{ id: "trip", emoji: "🗓️", label: "行程" },
|
{ id: "trip", emoji: "🗓️", label: "行程" },
|
||||||
{ id: "calculator", emoji: "🧮", label: "计算器" },
|
{ id: "calculator", emoji: "🧮", label: "计算器" },
|
||||||
|
{ id: "cost-compare", emoji: "💰", label: "省钱" },
|
||||||
{ id: "lifestyle", emoji: "💻", label: "生活" },
|
{ id: "lifestyle", emoji: "💻", label: "生活" },
|
||||||
{ id: "visa", emoji: "📋", label: "签证" },
|
{ id: "visa", emoji: "📋", label: "签证" },
|
||||||
{ id: "blog", emoji: "📝", label: "博客" },
|
{ id: "blog", emoji: "📝", label: "博客" },
|
||||||
|
|||||||
@ -1,28 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import type { Tool } from "@/lib/types";
|
import type { Tool } from "@/lib/types";
|
||||||
|
|
||||||
const ICONS: Record<string, string> = {
|
const ICONS: Record<string, string> = {
|
||||||
work: "💼", travel: "✈️", finance: "💳", connect: "🤝", health: "🏥", learn: "📚",
|
work: "💼", travel: "✈️", finance: "💳", connect: "🤝", health: "🏥", learn: "📚",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ key: "all", label: "🌏 全部" },
|
||||||
|
{ key: "work", label: "💼 协作" },
|
||||||
|
{ key: "travel", label: "✈️ 旅行" },
|
||||||
|
{ key: "finance", label: "💳 财务" },
|
||||||
|
{ key: "connect", label: "🤝 社交" },
|
||||||
|
{ key: "health", label: "🏥 健康" },
|
||||||
|
{ key: "learn", label: "📚 学习" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LINKS: Record<string, string> = {
|
||||||
|
"Slack": "https://slack.com",
|
||||||
|
"Notion": "https://notion.so",
|
||||||
|
"Figma": "https://figma.com",
|
||||||
|
"Zoom": "https://zoom.us",
|
||||||
|
"Skyscanner": "https://skyscanner.com",
|
||||||
|
"Nomad List": "https://nomadlist.com",
|
||||||
|
"SafetyWing": "https://safetywing.com",
|
||||||
|
"Wise": "https://wise.com",
|
||||||
|
"Revolut": "https://revolut.com",
|
||||||
|
"Coursera": "https://coursera.org",
|
||||||
|
"Duolingo": "https://duolingo.com",
|
||||||
|
};
|
||||||
|
|
||||||
export default function Tools({ tools }: { tools: Tool[] }) {
|
export default function Tools({ tools }: { tools: Tool[] }) {
|
||||||
|
const [filter, setFilter] = useState("all");
|
||||||
|
|
||||||
|
const filtered = useMemo(() =>
|
||||||
|
filter === "all" ? tools : tools.filter((t) => t.category === filter),
|
||||||
|
[tools, filter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="section tools" id="tools">
|
<section className="section tools" id="tools">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="section-header reveal">
|
<div className="section-header reveal">
|
||||||
<span className="section-tag">🛠️ TOOLKIT</span>
|
<span className="section-tag">🛠️ TOOLKIT</span>
|
||||||
<h2>游民必备工具箱</h2>
|
<h2>游民必备工具箱</h2>
|
||||||
<p>精选远程工作与旅居生活的效率神器</p>
|
<p>精选远程工作与旅居生活的效率神器 — 点击标签筛选</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="tools-grid">
|
|
||||||
{tools.map((t) => (
|
<div className="tools-filters reveal">
|
||||||
<div key={t.id} className="tool-card reveal" data-category={t.category}>
|
{CATEGORIES.map((c) => (
|
||||||
<span className="tool-emoji">{t.emoji || ICONS[t.category]}</span>
|
<button
|
||||||
<h3>{t.name}</h3>
|
key={c.key}
|
||||||
<p>{t.description}</p>
|
className={`filter-btn${filter === c.key ? " active" : ""}`}
|
||||||
<div className="tool-tags">{t.tags.map((tag) => <span key={tag}>{tag}</span>)}</div>
|
onClick={() => setFilter(c.key)}
|
||||||
</div>
|
>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="tools-grid">
|
||||||
|
{filtered.map((t, i) => {
|
||||||
|
const parts = t.description.split(" · ");
|
||||||
|
return (
|
||||||
|
<div key={t.id} className="tool-card reveal visible" style={{ animationDelay: `${i * 0.06}s` }}>
|
||||||
|
<span className="tool-emoji">{t.emoji || ICONS[t.category]}</span>
|
||||||
|
<h3>{t.name}</h3>
|
||||||
|
<div className="tool-links">
|
||||||
|
{parts.map((name) => {
|
||||||
|
const url = LINKS[name.trim()];
|
||||||
|
return url ? (
|
||||||
|
<a key={name} href={url} target="_blank" rel="noopener noreferrer" className="tool-link">
|
||||||
|
{name.trim()} ↗
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span key={name} className="tool-link-static">{name.trim()}</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="tool-tags">{t.tags.map((tag) => <span key={tag}>{tag}</span>)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user