feat: 用户收藏、定价筛选、分类落地页、ISR 与 Newsletter/Docker 完善
- 收藏功能:favorite server actions + FavoritesProvider/FavoriteButton(乐观更新、未登录引导登录),列表卡片/详情页收藏按钮,仪表盘"我的收藏" - 前台定价筛选(免费/免费增值/付费)与卡片定价徽标;首页"精选"改为查询 featured=true,不足时按最新补齐 - /category/[slug] 从 redirect 壳子改为真实落地页:分类双语描述 + 工具网格 + 完整 metadata + JSON-LD - ISR:首页/工具详情/分类页 revalidate 3600 + generateStaticParams,[locale] layout 补 generateStaticParams;verify-email 与 reset-password 的 useSearchParams 包 Suspense - 新增共享 ToolLogo 组件(next/image 优化 + data URL 降级 + 首字母占位),核心页面裸 img 全部替换;next.config 补 remotePatterns(R2 域名/Google favicon/https 通配) - getCategoriesWithCount 只统计 published 工具;featured-tools 文案迁移至 next-intl - Newsletter:footer 订阅表单 + 落库 + 可选同步 Resend Audience + 退订路由 - Docker:多阶段 Dockerfile(standalone)+ docker-compose(含 Postgres)+ 构建期数据库不可用的容错回退 - i18n:新增 pnpm i18n:check 一致性检查并补齐双语缺失键 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
89a9e318c2
commit
845503ad2f
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
*.md
|
||||||
|
.claude
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ==================== 依赖安装 ====================
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# ==================== 构建 ====================
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
# 构建期占位变量:镜像构建阶段连不上数据库时,
|
||||||
|
# 动态页面会自动回退为运行时按需生成(ISR),真实值在运行时注入
|
||||||
|
ENV DATABASE_URL="postgresql://build:build@127.0.0.1:5432/build"
|
||||||
|
ENV BETTER_AUTH_SECRET="build-time-placeholder-secret-32-chars!!"
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# ==================== 运行 ====================
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: toolso
|
||||||
|
POSTGRES_PASSWORD: toolso
|
||||||
|
POSTGRES_DB: toolso
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U toolso"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://toolso:toolso@db:5432/toolso
|
||||||
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-please-change-me-to-a-random-32-char-secret}
|
||||||
|
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
|
||||||
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
|
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-data:
|
||||||
|
|
||||||
|
# 首次启动后初始化数据库(在宿主机执行):
|
||||||
|
# DATABASE_URL=postgresql://toolso:toolso@localhost:5432/toolso pnpm db:push
|
||||||
|
# DATABASE_URL=postgresql://toolso:toolso@localhost:5432/toolso pnpm seed:tools
|
||||||
|
# DATABASE_URL=postgresql://toolso:toolso@localhost:5432/toolso pnpm admin:setup
|
||||||
+49
-5
@@ -22,7 +22,9 @@
|
|||||||
"update": "Update",
|
"update": "Update",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"next": "Next"
|
"next": "Next",
|
||||||
|
"contactSales": "Contact Sales",
|
||||||
|
"startTrial": "Start Free Trial"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -442,6 +444,11 @@
|
|||||||
"accountSettings": "Account Settings",
|
"accountSettings": "Account Settings",
|
||||||
"securitySettings": "Security Settings"
|
"securitySettings": "Security Settings"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"title": "My Favorites",
|
||||||
|
"empty": "No favorite tools yet.",
|
||||||
|
"browseTools": "Browse tools and save your favorites"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
@@ -459,8 +466,20 @@
|
|||||||
},
|
},
|
||||||
"accountSettings": {
|
"accountSettings": {
|
||||||
"title": "Account Settings",
|
"title": "Account Settings",
|
||||||
"memberSince": "Member Since"
|
"memberSince": "Member Since",
|
||||||
|
"lastLogin": "Last Login",
|
||||||
|
"accountType": "Account Type"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"nameLabel": "Name",
|
||||||
|
"namePlaceholder": "Enter your name",
|
||||||
|
"emailLabel": "Email",
|
||||||
|
"emailPlaceholder": "Enter your email",
|
||||||
|
"updateButton": "Update Profile",
|
||||||
|
"updating": "Updating...",
|
||||||
|
"updateSuccess": "Profile updated successfully!",
|
||||||
|
"updateError": "Failed to update profile. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
@@ -539,7 +558,8 @@
|
|||||||
"company": {
|
"company": {
|
||||||
"title": "Company",
|
"title": "Company",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"blog": "Blog"
|
"blog": "Blog",
|
||||||
|
"careers": "Careers"
|
||||||
},
|
},
|
||||||
"legal": {
|
"legal": {
|
||||||
"title": "Legal",
|
"title": "Legal",
|
||||||
@@ -557,7 +577,10 @@
|
|||||||
"title": "Subscribe to Newsletter",
|
"title": "Subscribe to Newsletter",
|
||||||
"description": "Get the latest updates and news directly in your inbox.",
|
"description": "Get the latest updates and news directly in your inbox.",
|
||||||
"placeholder": "Enter your email",
|
"placeholder": "Enter your email",
|
||||||
"subscribe": "Subscribe"
|
"subscribe": "Subscribe",
|
||||||
|
"success": "Subscribed! Thanks for joining.",
|
||||||
|
"invalidEmail": "Please enter a valid email address.",
|
||||||
|
"error": "Subscription failed. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
@@ -576,7 +599,21 @@
|
|||||||
"toolInfo": "Tool Info",
|
"toolInfo": "Tool Info",
|
||||||
"domain": "Domain",
|
"domain": "Domain",
|
||||||
"added": "Added",
|
"added": "Added",
|
||||||
"notFound": "Tool Not Found"
|
"notFound": "Tool Not Found",
|
||||||
|
"favorites": {
|
||||||
|
"add": "Save to Favorites",
|
||||||
|
"remove": "Remove from Favorites",
|
||||||
|
"loginRequired": "Please sign in to save favorites",
|
||||||
|
"error": "Action failed. Please try again."
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"free": "Free",
|
||||||
|
"freemium": "Freemium",
|
||||||
|
"paid": "Paid"
|
||||||
|
},
|
||||||
|
"categoryPage": {
|
||||||
|
"viewAll": "View all {count} tools"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"seo": {
|
"seo": {
|
||||||
"home": {
|
"home": {
|
||||||
@@ -623,5 +660,12 @@
|
|||||||
"title": "AI Tools Directory - Discover the Best AI Tools",
|
"title": "AI Tools Directory - Discover the Best AI Tools",
|
||||||
"description": "Explore our curated collection of AI tools. Find the best AI tools for productivity, creativity, and more."
|
"description": "Explore our curated collection of AI tools. Find the best AI tools for productivity, creativity, and more."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"featuredTools": {
|
||||||
|
"title": "Featured Tools",
|
||||||
|
"subtitle": "Curated collection of AI tools to boost your productivity",
|
||||||
|
"visitWebsite": "Visit website",
|
||||||
|
"empty": "No tools yet. Add some in the admin panel.",
|
||||||
|
"viewAll": "View All Tools"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-3
@@ -421,7 +421,8 @@
|
|||||||
"previous": "上一页",
|
"previous": "上一页",
|
||||||
"next": "下一页",
|
"next": "下一页",
|
||||||
"page": "第 {current} / {total} 页"
|
"page": "第 {current} / {total} 页"
|
||||||
}
|
},
|
||||||
|
"cancel": "取消"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -443,6 +444,11 @@
|
|||||||
"accountSettings": "账户设置",
|
"accountSettings": "账户设置",
|
||||||
"securitySettings": "安全设置"
|
"securitySettings": "安全设置"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"title": "我的收藏",
|
||||||
|
"empty": "还没有收藏的工具。",
|
||||||
|
"browseTools": "去浏览工具并收藏"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
@@ -579,7 +585,10 @@
|
|||||||
"title": "订阅邮件",
|
"title": "订阅邮件",
|
||||||
"description": "获取最新动态和资讯,直接发送到您的邮箱。",
|
"description": "获取最新动态和资讯,直接发送到您的邮箱。",
|
||||||
"placeholder": "输入您的邮箱",
|
"placeholder": "输入您的邮箱",
|
||||||
"subscribe": "订阅"
|
"subscribe": "订阅",
|
||||||
|
"success": "订阅成功!感谢加入。",
|
||||||
|
"invalidEmail": "请输入有效的邮箱地址。",
|
||||||
|
"error": "订阅失败,请重试。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
@@ -598,7 +607,21 @@
|
|||||||
"toolInfo": "工具信息",
|
"toolInfo": "工具信息",
|
||||||
"domain": "域名",
|
"domain": "域名",
|
||||||
"added": "添加时间",
|
"added": "添加时间",
|
||||||
"notFound": "工具未找到"
|
"notFound": "工具未找到",
|
||||||
|
"favorites": {
|
||||||
|
"add": "收藏",
|
||||||
|
"remove": "取消收藏",
|
||||||
|
"loginRequired": "请先登录后再收藏",
|
||||||
|
"error": "操作失败,请重试。"
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"free": "免费",
|
||||||
|
"freemium": "免费增值",
|
||||||
|
"paid": "付费"
|
||||||
|
},
|
||||||
|
"categoryPage": {
|
||||||
|
"viewAll": "查看该分类全部 {count} 个工具"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"seo": {
|
"seo": {
|
||||||
"home": {
|
"home": {
|
||||||
@@ -645,5 +668,12 @@
|
|||||||
"title": "AI 工具库 - 发现最好的 AI 工具",
|
"title": "AI 工具库 - 发现最好的 AI 工具",
|
||||||
"description": "浏览精选的 AI 工具集合。找到提升效率、激发创意的最佳 AI 工具。"
|
"description": "浏览精选的 AI 工具集合。找到提升效率、激发创意的最佳 AI 工具。"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"featuredTools": {
|
||||||
|
"title": "精选工具",
|
||||||
|
"subtitle": "精选优质 AI 工具,助力提升工作效率",
|
||||||
|
"visitWebsite": "访问网站",
|
||||||
|
"empty": "暂无工具,请先在后台添加",
|
||||||
|
"viewAll": "查看全部工具"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ const snapshotManagedPaths = fs.existsSync(pnpmManagedPath) ? [pnpmManagedPath]
|
|||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
// Docker 部署使用 standalone 输出
|
||||||
|
output: "standalone",
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
@@ -31,6 +33,25 @@ const nextConfig = {
|
|||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "lh3.googleusercontent.com",
|
hostname: "lh3.googleusercontent.com",
|
||||||
},
|
},
|
||||||
|
// Google favicon 服务(种子数据 logo 来源之一)
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "www.google.com",
|
||||||
|
},
|
||||||
|
// R2/S3 公共访问域名(上传的 logo/封面)
|
||||||
|
...(process.env.STORAGE_PUBLIC_URL
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: new URL(process.env.STORAGE_PUBLIC_URL).hostname,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
// 工具 logo 指向各工具官网的 favicon,域名不可枚举,放开 https 通配
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "**",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
pageExtensions: ["ts", "tsx", "mdx"],
|
pageExtensions: ["ts", "tsx", "mdx"],
|
||||||
|
|||||||
+2
-1
@@ -39,7 +39,8 @@
|
|||||||
"admin:setup": "tsx scripts/setup-admin.ts",
|
"admin:setup": "tsx scripts/setup-admin.ts",
|
||||||
"seed:tools": "tsx scripts/seed-tools.ts",
|
"seed:tools": "tsx scripts/seed-tools.ts",
|
||||||
"generate:blog-manifest": "node scripts/generate-blog-manifest.mjs",
|
"generate:blog-manifest": "node scripts/generate-blog-manifest.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"i18n:check": "node scripts/check-i18n.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.958.0",
|
"@aws-sdk/client-s3": "^3.958.0",
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* i18n 键一致性检查:对比 messages/ 下所有语言文件的键集合,
|
||||||
|
* 存在差异时打印缺失键并以非零码退出(可挂进 CI)。
|
||||||
|
*
|
||||||
|
* 用法: pnpm i18n:check
|
||||||
|
*/
|
||||||
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const messagesDir = join(dirname(fileURLToPath(import.meta.url)), "..", "messages");
|
||||||
|
|
||||||
|
function flattenKeys(obj, prefix = "") {
|
||||||
|
return Object.entries(obj).flatMap(([key, value]) => {
|
||||||
|
const path = prefix ? `${prefix}.${key}` : key;
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? flattenKeys(value, path)
|
||||||
|
: [path];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = readdirSync(messagesDir).filter((f) => f.endsWith(".json"));
|
||||||
|
const keySets = new Map(
|
||||||
|
files.map((f) => [
|
||||||
|
f,
|
||||||
|
new Set(flattenKeys(JSON.parse(readFileSync(join(messagesDir, f), "utf8")))),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const allKeys = new Set([...keySets.values()].flatMap((s) => [...s]));
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
for (const [file, keys] of keySets) {
|
||||||
|
const missing = [...allKeys].filter((k) => !keys.has(k));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
hasError = true;
|
||||||
|
console.error(`\n❌ ${file} 缺失 ${missing.length} 个键:`);
|
||||||
|
for (const key of missing.sort()) console.error(` - ${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`✅ i18n 键一致 (${files.join(", ")},共 ${allKeys.size} 个键)`);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { Suspense, useState, useEffect } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -24,7 +24,7 @@ const resetPasswordSchema = z.object({
|
|||||||
|
|
||||||
type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
function ResetPasswordPageInner() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -186,3 +186,11 @@ export default function ResetPasswordPage() {
|
|||||||
</FormShell>
|
</FormShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ResetPasswordPageInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,38 +1,187 @@
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getCategoryBySlug } from "@/features/tools/actions";
|
import Link from "next/link";
|
||||||
import { getLocale } from "next-intl/server";
|
import { db } from "@/lib/db";
|
||||||
|
import { category as categoryTable } from "@/lib/db/schema";
|
||||||
|
import {
|
||||||
|
getCategoryBySlug,
|
||||||
|
getPublishedTools,
|
||||||
|
} from "@/features/tools/actions";
|
||||||
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
import { locales } from "@/i18n.config";
|
||||||
|
import { ArrowRight, ExternalLink } from "lucide-react";
|
||||||
|
import { CollectionPageJsonLd } from "@/components/json-ld";
|
||||||
|
import { ToolLogo } from "@/components/tool-logo";
|
||||||
|
import {
|
||||||
|
FavoritesProvider,
|
||||||
|
FavoriteButton,
|
||||||
|
} from "@/features/tools/components/favorite-button";
|
||||||
|
import { absoluteUrl, generatePageMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
interface CategoryPageProps {
|
interface CategoryPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分类落地页走 ISR,工具增删时由 revalidatePath 触发更新
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
try {
|
||||||
|
const categories = await db
|
||||||
|
.select({ slug: categoryTable.slug })
|
||||||
|
.from(categoryTable);
|
||||||
|
|
||||||
|
return categories.flatMap((c) =>
|
||||||
|
locales.map((locale) => ({ locale, slug: c.slug }))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 构建时数据库不可用(如 Docker 构建),改为运行时按需生成
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: CategoryPageProps) {
|
export async function generateMetadata({ params }: CategoryPageProps) {
|
||||||
const { slug } = await params;
|
const { slug, locale } = await params;
|
||||||
const category = await getCategoryBySlug(slug);
|
const category = await getCategoryBySlug(slug);
|
||||||
|
const t = await getTranslations({ locale, namespace: "tools" });
|
||||||
|
|
||||||
if (!category) {
|
if (!category) {
|
||||||
return {
|
return { title: t("notFound") };
|
||||||
title: "Category Not Found",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const name =
|
||||||
title: `${category.nameEn} - AI Tools`,
|
locale === "zh" && category.nameZh ? category.nameZh : category.nameEn;
|
||||||
description: category.descriptionEn || `Explore ${category.nameEn} AI tools`,
|
const description =
|
||||||
};
|
locale === "zh" && category.descriptionZh
|
||||||
|
? category.descriptionZh
|
||||||
|
: category.descriptionEn;
|
||||||
|
|
||||||
|
return generatePageMetadata({
|
||||||
|
locale,
|
||||||
|
path: `/category/${slug}`,
|
||||||
|
title: `${name} - ${t("title")}`,
|
||||||
|
description: description || `${t("subtitle")} - ${name}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function CategoryPage({ params }: CategoryPageProps) {
|
export default async function CategoryPage({ params }: CategoryPageProps) {
|
||||||
const { slug } = await params;
|
const { slug, locale } = await params;
|
||||||
const locale = await getLocale();
|
setRequestLocale(locale);
|
||||||
const category = await getCategoryBySlug(slug);
|
const category = await getCategoryBySlug(slug);
|
||||||
|
|
||||||
if (!category) {
|
if (!category) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重定向到工具列表页并带上分类参数
|
const t = await getTranslations({ locale, namespace: "tools" });
|
||||||
redirect(`/${locale}/tools?category=${slug}`);
|
const { tools, total } = await getPublishedTools({
|
||||||
|
page: 1,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
categoryId: category.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const name =
|
||||||
|
locale === "zh" && category.nameZh ? category.nameZh : category.nameEn;
|
||||||
|
const description =
|
||||||
|
locale === "zh" && category.descriptionZh
|
||||||
|
? category.descriptionZh
|
||||||
|
: category.descriptionEn;
|
||||||
|
|
||||||
|
const getName = (tool: { nameEn: string; nameZh: string | null }) =>
|
||||||
|
locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn;
|
||||||
|
const getDescription = (tool: {
|
||||||
|
descriptionEn: string | null;
|
||||||
|
descriptionZh: string | null;
|
||||||
|
}) =>
|
||||||
|
locale === "zh" && tool.descriptionZh
|
||||||
|
? tool.descriptionZh
|
||||||
|
: tool.descriptionEn;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 pt-24 pb-16">
|
||||||
|
<CollectionPageJsonLd
|
||||||
|
name={name}
|
||||||
|
description={description || name}
|
||||||
|
url={absoluteUrl(locale, `/category/${slug}`)}
|
||||||
|
itemCount={total}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 分类头部 */}
|
||||||
|
<div className="mx-auto mb-12 max-w-2xl text-center">
|
||||||
|
{category.icon && <div className="text-5xl">{category.icon}</div>}
|
||||||
|
<h1 className="mt-4 text-4xl font-bold text-foreground">{name}</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-4 text-lg text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{t("foundTools", { count: total })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 工具网格 */}
|
||||||
|
<FavoritesProvider>
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<Link
|
||||||
|
key={tool.id}
|
||||||
|
href={`/${locale}/tools/${tool.slug}`}
|
||||||
|
className="group rounded-xl border border-border bg-background p-5 transition-all hover:border-foreground/20 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<ToolLogo src={tool.logoUrl} alt={getName(tool)} size={48} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="truncate font-semibold text-foreground group-hover:text-primary">
|
||||||
|
{getName(tool)}
|
||||||
|
</h3>
|
||||||
|
{tool.domain && (
|
||||||
|
<p className="truncate text-sm text-muted-foreground">
|
||||||
|
{tool.domain}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FavoriteButton toolId={tool.id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{getDescription(tool) && (
|
||||||
|
<p className="mt-3 line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{getDescription(tool)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tool.websiteUrl && (
|
||||||
|
<div className="mt-4 flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground">
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
<span>{t("visitWebsite")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FavoritesProvider>
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{tools.length === 0 && (
|
||||||
|
<div className="py-16 text-center text-muted-foreground">
|
||||||
|
{t("noTools")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 查看全部 */}
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<div className="mt-12 flex justify-center">
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/tools?category=${slug}`}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full bg-foreground px-6 py-3 font-medium text-background transition-opacity hover:opacity-90"
|
||||||
|
>
|
||||||
|
{t("categoryPage.viewAll", { count: total })}
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { CTA } from "@/components/cta";
|
|||||||
import { FeaturedTools } from "@/components/featured-tools";
|
import { FeaturedTools } from "@/components/featured-tools";
|
||||||
import { WebSiteJsonLd, OrganizationJsonLd } from "@/components/json-ld";
|
import { WebSiteJsonLd, OrganizationJsonLd } from "@/components/json-ld";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { getTranslations, getLocale } from 'next-intl/server';
|
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||||
import type { Locale } from "@/i18n.config";
|
import type { Locale } from "@/i18n.config";
|
||||||
import { generatePageMetadata } from "@/lib/metadata";
|
import { generatePageMetadata } from "@/lib/metadata";
|
||||||
import { getPublishedTools, getCategoriesWithCount } from "@/features/tools/actions";
|
import { getFeaturedTools, getCategoriesWithCount } from "@/features/tools/actions";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params
|
params
|
||||||
@@ -26,15 +26,29 @@ export async function generateMetadata({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Home() {
|
// 首页内容变化不频繁,ISR 每小时再生
|
||||||
const locale = await getLocale();
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export default async function Home({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: Locale }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
|
||||||
|
|
||||||
// 获取精选工具和分类数据
|
// 获取精选工具和分类数据(构建时数据库不可用则先渲染空态,运行时 ISR 补全)
|
||||||
const [{ tools }, categories] = await Promise.all([
|
let tools: Awaited<ReturnType<typeof getFeaturedTools>> = [];
|
||||||
getPublishedTools({ pageSize: 8 }),
|
let categories: Awaited<ReturnType<typeof getCategoriesWithCount>> = [];
|
||||||
getCategoriesWithCount(),
|
try {
|
||||||
]);
|
[tools, categories] = await Promise.all([
|
||||||
|
getFeaturedTools(8),
|
||||||
|
getCategoriesWithCount(),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
console.warn("home: database unavailable at build time");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@@ -1,22 +1,51 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { tool as toolTable } from "@/lib/db/schema";
|
||||||
import { getToolBySlug } from "@/features/tools/actions";
|
import { getToolBySlug } from "@/features/tools/actions";
|
||||||
import { getLocale, getTranslations } from "next-intl/server";
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
|
import { locales } from "@/i18n.config";
|
||||||
import { ExternalLink, ArrowLeft, Tag } from "lucide-react";
|
import { ExternalLink, ArrowLeft, Tag } from "lucide-react";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { SoftwareApplicationJsonLd } from "@/components/json-ld";
|
import { SoftwareApplicationJsonLd } from "@/components/json-ld";
|
||||||
import { generatePageMetadata } from "@/lib/metadata";
|
import { generatePageMetadata } from "@/lib/metadata";
|
||||||
|
import {
|
||||||
|
FavoritesProvider,
|
||||||
|
FavoriteButton,
|
||||||
|
} from "@/features/tools/components/favorite-button";
|
||||||
|
import { ToolLogo } from "@/components/tool-logo";
|
||||||
|
|
||||||
interface ToolDetailPageProps {
|
interface ToolDetailPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
|
locale: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 工具详情内容基本不变,ISR 每小时再生;后台变更会触发 revalidatePath
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
try {
|
||||||
|
const tools = await db
|
||||||
|
.select({ slug: toolTable.slug })
|
||||||
|
.from(toolTable)
|
||||||
|
.where(eq(toolTable.status, "published"));
|
||||||
|
|
||||||
|
return tools.flatMap((t) =>
|
||||||
|
locales.map((locale) => ({ locale, slug: t.slug }))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 构建时数据库不可用(如 Docker 构建),改为运行时按需生成
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: ToolDetailPageProps) {
|
export async function generateMetadata({ params }: ToolDetailPageProps) {
|
||||||
const { slug } = await params;
|
const { slug, locale } = await params;
|
||||||
const locale = await getLocale();
|
const t = await getTranslations({ locale, namespace: "tools" });
|
||||||
const t = await getTranslations("tools");
|
|
||||||
const tool = await getToolBySlug(slug);
|
const tool = await getToolBySlug(slug);
|
||||||
|
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
@@ -41,9 +70,9 @@ export async function generateMetadata({ params }: ToolDetailPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||||
const { slug } = await params;
|
const { slug, locale } = await params;
|
||||||
const locale = await getLocale();
|
setRequestLocale(locale);
|
||||||
const t = await getTranslations("tools");
|
const t = await getTranslations({ locale, namespace: "tools" });
|
||||||
const tool = await getToolBySlug(slug);
|
const tool = await getToolBySlug(slug);
|
||||||
|
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
@@ -91,19 +120,7 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
|||||||
<div className="lg:col-span-2 space-y-8">
|
<div className="lg:col-span-2 space-y-8">
|
||||||
{/* 工具头部 */}
|
{/* 工具头部 */}
|
||||||
<div className="flex items-start gap-6">
|
<div className="flex items-start gap-6">
|
||||||
{tool.logoUrl ? (
|
<ToolLogo src={tool.logoUrl} alt={name} size={80} className="rounded-2xl" />
|
||||||
<img
|
|
||||||
src={tool.logoUrl}
|
|
||||||
alt={name}
|
|
||||||
className="h-20 w-20 rounded-2xl object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-20 w-20 rounded-2xl bg-secondary flex items-center justify-center">
|
|
||||||
<span className="text-3xl font-bold text-muted-foreground">
|
|
||||||
{name.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-2">
|
<h1 className="text-3xl font-bold text-foreground mb-2">
|
||||||
{name}
|
{name}
|
||||||
@@ -117,10 +134,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
|||||||
{/* 封面图 */}
|
{/* 封面图 */}
|
||||||
{tool.coverImageUrl && (
|
{tool.coverImageUrl && (
|
||||||
<div className="rounded-xl overflow-hidden border border-border">
|
<div className="rounded-xl overflow-hidden border border-border">
|
||||||
<img
|
<Image
|
||||||
src={tool.coverImageUrl}
|
src={tool.coverImageUrl}
|
||||||
alt={name}
|
alt={name}
|
||||||
|
width={1200}
|
||||||
|
height={630}
|
||||||
className="w-full h-auto"
|
className="w-full h-auto"
|
||||||
|
sizes="(max-width: 1024px) 100vw, 66vw"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -199,6 +219,11 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 收藏按钮 */}
|
||||||
|
<FavoritesProvider>
|
||||||
|
<FavoriteButton toolId={tool.id} appearance="button" />
|
||||||
|
</FavoritesProvider>
|
||||||
|
|
||||||
{/* 工具信息卡片 */}
|
{/* 工具信息卡片 */}
|
||||||
<div className="bg-secondary rounded-xl p-6 space-y-4">
|
<div className="bg-secondary rounded-xl p-6 space-y-4">
|
||||||
<h3 className="font-semibold text-foreground">
|
<h3 className="font-semibold text-foreground">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getPublishedTools, getCategoriesWithCount } from "@/features/tools/actions";
|
import { getPublishedTools, getCategoriesWithCount } from "@/features/tools/actions";
|
||||||
import { ToolsList } from "@/features/tools/components/tools-list";
|
import { ToolsList } from "@/features/tools/components/tools-list";
|
||||||
|
import { FavoritesProvider } from "@/features/tools/components/favorite-button";
|
||||||
import { CollectionPageJsonLd } from "@/components/json-ld";
|
import { CollectionPageJsonLd } from "@/components/json-ld";
|
||||||
import { getLocale, getTranslations } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { generatePageMetadata } from "@/lib/metadata";
|
import { generatePageMetadata } from "@/lib/metadata";
|
||||||
@@ -24,6 +25,7 @@ interface ToolsPageProps {
|
|||||||
page?: string;
|
page?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
pricing?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +36,11 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
|
|||||||
const page = parseInt(params.page || "1", 10);
|
const page = parseInt(params.page || "1", 10);
|
||||||
const categorySlug = params.category;
|
const categorySlug = params.category;
|
||||||
const search = params.search;
|
const search = params.search;
|
||||||
|
const pricingParam = params.pricing;
|
||||||
|
const pricing =
|
||||||
|
pricingParam === "free" || pricingParam === "freemium" || pricingParam === "paid"
|
||||||
|
? pricingParam
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// 获取分类列表用于筛选
|
// 获取分类列表用于筛选
|
||||||
const categories = await getCategoriesWithCount();
|
const categories = await getCategoriesWithCount();
|
||||||
@@ -51,6 +58,7 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
|
|||||||
pageSize: 12,
|
pageSize: 12,
|
||||||
categoryId,
|
categoryId,
|
||||||
search,
|
search,
|
||||||
|
pricing,
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize);
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
@@ -64,6 +72,9 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
|
|||||||
foundTools: t("foundTools", { count: total }),
|
foundTools: t("foundTools", { count: total }),
|
||||||
visitWebsite: t("visitWebsite"),
|
visitWebsite: t("visitWebsite"),
|
||||||
noTools: t("noTools"),
|
noTools: t("noTools"),
|
||||||
|
pricingFree: t("pricing.free"),
|
||||||
|
pricingFreemium: t("pricing.freemium"),
|
||||||
|
pricingPaid: t("pricing.paid"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
|
||||||
@@ -87,17 +98,20 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ToolsList
|
<FavoritesProvider>
|
||||||
tools={tools}
|
<ToolsList
|
||||||
categories={categories}
|
tools={tools}
|
||||||
currentCategory={categorySlug}
|
categories={categories}
|
||||||
currentSearch={search}
|
currentCategory={categorySlug}
|
||||||
currentPage={page}
|
currentPricing={pricing}
|
||||||
totalPages={totalPages}
|
currentSearch={search}
|
||||||
total={total}
|
currentPage={page}
|
||||||
locale={locale}
|
totalPages={totalPages}
|
||||||
translations={translations}
|
total={total}
|
||||||
/>
|
locale={locale}
|
||||||
|
translations={translations}
|
||||||
|
/>
|
||||||
|
</FavoritesProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/button";
|
|||||||
import { Container } from "@/components/container";
|
import { Container } from "@/components/container";
|
||||||
import { Background } from "@/components/background";
|
import { Background } from "@/components/background";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { FavoriteToolsSection } from "@/features/tools/components/favorite-tools-section";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -134,6 +135,15 @@ export default function DashboardPage() {
|
|||||||
Sign Out
|
Sign Out
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 40, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ ease: "easeOut", duration: 0.5, delay: 0.2 }}
|
||||||
|
className="mt-6"
|
||||||
|
>
|
||||||
|
<FavoriteToolsSection />
|
||||||
|
</motion.div>
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import { Toaster } from "sonner";
|
|||||||
import Analytics from "@/analytics/analytics";
|
import Analytics from "@/analytics/analytics";
|
||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return locales.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
params
|
params
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { Suspense, useEffect, useState } from 'react';
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { CheckCircle2, XCircle, Loader2, Mail } from 'lucide-react';
|
import { CheckCircle2, XCircle, Loader2, Mail } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLocale, useTranslations } from 'next-intl';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function VerifyEmailPage() {
|
function VerifyEmailPageInner() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
@@ -140,4 +140,11 @@ export default function VerifyEmailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
export default function VerifyEmailPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<VerifyEmailPageInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { newsletterSubscription } from "@/lib/db/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退订链接处理:GET /api/newsletter/unsubscribe?token=xxx
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const token = req.nextUrl.searchParams.get("token");
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: "Missing token" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [subscription] = await db
|
||||||
|
.select()
|
||||||
|
.from(newsletterSubscription)
|
||||||
|
.where(eq(newsletterSubscription.unsubscribeToken, token))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return NextResponse.json({ error: "Invalid token" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.status !== "unsubscribed") {
|
||||||
|
await db
|
||||||
|
.update(newsletterSubscription)
|
||||||
|
.set({ status: "unsubscribed", unsubscribedAt: new Date() })
|
||||||
|
.where(eq(newsletterSubscription.id, subscription.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(
|
||||||
|
"<html><body style=\"font-family:sans-serif;text-align:center;padding-top:80px\"><h1>Unsubscribed</h1><p>You have been unsubscribed from the Toolso.AI newsletter.</p></body></html>",
|
||||||
|
{ headers: { "Content-Type": "text/html; charset=utf-8" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,56 +2,6 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { uploadImageFromUrl } from "@/lib/r2-storage";
|
import { uploadImageFromUrl } from "@/lib/r2-storage";
|
||||||
|
|
||||||
// Simple image dimension detection for common formats
|
|
||||||
async function getImageDimensions(buffer: Buffer, mimeType: string): Promise<{ width: number; height: number } | null> {
|
|
||||||
try {
|
|
||||||
// For JPEG
|
|
||||||
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') {
|
|
||||||
// JPEG starts with FF D8 FF
|
|
||||||
if (buffer[0] === 0xFF && buffer[1] === 0xD8) {
|
|
||||||
let offset = 2;
|
|
||||||
while (offset < buffer.length) {
|
|
||||||
if (buffer[offset] !== 0xFF) break;
|
|
||||||
|
|
||||||
const marker = buffer[offset + 1];
|
|
||||||
if (marker === 0xC0 || marker === 0xC2) { // SOF0 or SOF2
|
|
||||||
const height = buffer.readUInt16BE(offset + 5);
|
|
||||||
const width = buffer.readUInt16BE(offset + 7);
|
|
||||||
return { width, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += 2 + buffer.readUInt16BE(offset + 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For PNG
|
|
||||||
if (mimeType === 'image/png') {
|
|
||||||
// PNG starts with 89 50 4E 47
|
|
||||||
if (buffer[0] === 0x89 && buffer[1] === 0x50) {
|
|
||||||
const width = buffer.readUInt32BE(16);
|
|
||||||
const height = buffer.readUInt32BE(20);
|
|
||||||
return { width, height };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For WebP
|
|
||||||
if (mimeType === 'image/webp') {
|
|
||||||
// WebP starts with RIFF
|
|
||||||
if (buffer.toString('utf8', 0, 4) === 'RIFF') {
|
|
||||||
const width = buffer.readUInt16LE(26) + 1;
|
|
||||||
const height = buffer.readUInt16LE(28) + 1;
|
|
||||||
return { width, height };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error detecting image dimensions:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Authenticate user
|
// Authenticate user
|
||||||
@@ -87,21 +37,6 @@ export async function POST(req: NextRequest) {
|
|||||||
const bytes = await file.arrayBuffer();
|
const bytes = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(bytes);
|
const buffer = Buffer.from(bytes);
|
||||||
|
|
||||||
// For video generation, check minimum image dimensions
|
|
||||||
// Volcano Engine requires minimum 300px height
|
|
||||||
try {
|
|
||||||
// Simple dimension check for common formats
|
|
||||||
const dimensions = await getImageDimensions(buffer, file.type);
|
|
||||||
if (dimensions && (dimensions.width < 300 || dimensions.height < 300)) {
|
|
||||||
return NextResponse.json({
|
|
||||||
error: "Image must be at least 300x300 pixels for video generation"
|
|
||||||
}, { status: 400 });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Could not validate image dimensions:", err);
|
|
||||||
// Continue anyway if we can't determine dimensions
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate unique filename
|
// Generate unique filename
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const random = Math.random().toString(36).substring(7);
|
const random = Math.random().toString(36).substring(7);
|
||||||
|
|||||||
+17
-8
@@ -21,14 +21,23 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|||||||
{ path: '/cookies', changeFreq: 'monthly' as const, priority: 0.3 },
|
{ path: '/cookies', changeFreq: 'monthly' as const, priority: 0.3 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const [tools, categories, blogs] = await Promise.all([
|
// 构建环境可能连不上数据库(如 Docker 镜像构建),退化为仅静态路由,
|
||||||
db
|
// 运行时依靠 revalidate 补全动态条目
|
||||||
.select({ slug: tool.slug, updatedAt: tool.updatedAt })
|
let tools: { slug: string; updatedAt: Date }[] = []
|
||||||
.from(tool)
|
let categories: { slug: string; updatedAt: Date }[] = []
|
||||||
.where(eq(tool.status, 'published')),
|
let blogs: Awaited<ReturnType<typeof getAllBlogs>> = []
|
||||||
db.select({ slug: category.slug, updatedAt: category.updatedAt }).from(category),
|
try {
|
||||||
getAllBlogs(),
|
;[tools, categories, blogs] = await Promise.all([
|
||||||
])
|
db
|
||||||
|
.select({ slug: tool.slug, updatedAt: tool.updatedAt })
|
||||||
|
.from(tool)
|
||||||
|
.where(eq(tool.status, 'published')),
|
||||||
|
db.select({ slug: category.slug, updatedAt: category.updatedAt }).from(category),
|
||||||
|
getAllBlogs(),
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('sitemap: database unavailable, falling back to static routes only')
|
||||||
|
}
|
||||||
|
|
||||||
const dynamicRoutes = [
|
const dynamicRoutes = [
|
||||||
...tools.map((t) => ({
|
...tools.map((t) => ({
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useLocale } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { ExternalLink, ArrowRight } from "lucide-react";
|
import { ExternalLink, ArrowRight } from "lucide-react";
|
||||||
import { Heading } from "./heading";
|
import { Heading } from "./heading";
|
||||||
import { Subheading } from "./subheading";
|
import { Subheading } from "./subheading";
|
||||||
|
import { ToolLogo } from "./tool-logo";
|
||||||
|
|
||||||
interface ToolData {
|
interface ToolData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,6 +35,7 @@ interface FeaturedToolsProps {
|
|||||||
|
|
||||||
export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
const t = useTranslations("featuredTools");
|
||||||
|
|
||||||
const getName = (item: { nameEn: string; nameZh: string | null }) =>
|
const getName = (item: { nameEn: string; nameZh: string | null }) =>
|
||||||
locale === "zh" && item.nameZh ? item.nameZh : item.nameEn;
|
locale === "zh" && item.nameZh ? item.nameZh : item.nameEn;
|
||||||
@@ -45,14 +47,8 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-20">
|
<div className="relative z-20">
|
||||||
<Heading as="h2">
|
<Heading as="h2">{t("title")}</Heading>
|
||||||
{locale === "zh" ? "精选工具" : "Featured Tools"}
|
<Subheading className="text-center">{t("subtitle")}</Subheading>
|
||||||
</Heading>
|
|
||||||
<Subheading className="text-center">
|
|
||||||
{locale === "zh"
|
|
||||||
? "精选优质 AI 工具,助力提升工作效率"
|
|
||||||
: "Curated collection of AI tools to boost your productivity"}
|
|
||||||
</Subheading>
|
|
||||||
|
|
||||||
{/* 分类导航 */}
|
{/* 分类导航 */}
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
@@ -82,19 +78,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
|||||||
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
|
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{tool.logoUrl ? (
|
<ToolLogo src={tool.logoUrl} alt={getName(tool)} size={48} />
|
||||||
<img
|
|
||||||
src={tool.logoUrl}
|
|
||||||
alt={getName(tool)}
|
|
||||||
className="h-12 w-12 rounded-lg object-cover flex-shrink-0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-12 w-12 rounded-lg bg-secondary flex items-center justify-center flex-shrink-0">
|
|
||||||
<span className="text-xl font-bold text-muted-foreground">
|
|
||||||
{getName(tool).charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-foreground group-hover:text-primary truncate">
|
<h3 className="font-semibold text-foreground group-hover:text-primary truncate">
|
||||||
{getName(tool)}
|
{getName(tool)}
|
||||||
@@ -116,7 +100,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
|||||||
{tool.websiteUrl && (
|
{tool.websiteUrl && (
|
||||||
<div className="mt-4 flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground">
|
<div className="mt-4 flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground">
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
<span>{locale === "zh" ? "访问网站" : "Visit website"}</span>
|
<span>{t("visitWebsite")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -126,7 +110,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
|||||||
{/* 空状态 */}
|
{/* 空状态 */}
|
||||||
{tools.length === 0 && (
|
{tools.length === 0 && (
|
||||||
<div className="text-center py-16 text-muted-foreground">
|
<div className="text-center py-16 text-muted-foreground">
|
||||||
{locale === "zh" ? "暂无工具,请先在后台添加" : "No tools yet. Add some in the admin panel."}
|
{t("empty")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -137,7 +121,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
|||||||
href={`/${locale}/tools`}
|
href={`/${locale}/tools`}
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-foreground text-background hover:opacity-90 transition-opacity font-medium"
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-full bg-foreground text-background hover:opacity-90 transition-opacity font-medium"
|
||||||
>
|
>
|
||||||
{locale === "zh" ? "查看全部工具" : "View All Tools"}
|
{t("viewAll")}
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import React from "react";
|
|||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { LocaleLink } from './locale-link';
|
import { LocaleLink } from './locale-link';
|
||||||
|
import { NewsletterForm } from '@/features/marketing/components/newsletter-form';
|
||||||
|
|
||||||
export const Footer = () => {
|
export const Footer = () => {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -55,6 +56,7 @@ export const Footer = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>{t('common.brand.copyright')}</div>
|
<div>{t('common.brand.copyright')}</div>
|
||||||
<div className="mt-2">{t('common.brand.allRightsReserved')}</div>
|
<div className="mt-2">{t('common.brand.allRightsReserved')}</div>
|
||||||
|
<NewsletterForm />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-10 items-start mt-10 md:mt-0">
|
<div className="grid grid-cols-3 gap-10 items-start mt-10 md:mt-0">
|
||||||
<div className="flex justify-center space-y-4 flex-col mt-4">
|
<div className="flex justify-center space-y-4 flex-col mt-4">
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ToolLogoProps {
|
||||||
|
src: string | null;
|
||||||
|
alt: string;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具 Logo:远程图走 next/image 优化;
|
||||||
|
* data: URL(未配置 R2 时的上传回退)next/image 不支持,降级为原生 img;
|
||||||
|
* 无图时显示首字母占位。
|
||||||
|
*/
|
||||||
|
export function ToolLogo({ src, alt, size = 48, className }: ToolLogoProps) {
|
||||||
|
const style = { width: size, height: size };
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-shrink-0 items-center justify-center rounded-lg bg-secondary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-bold text-muted-foreground"
|
||||||
|
style={{ fontSize: size * 0.4 }}
|
||||||
|
>
|
||||||
|
{alt.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (src.startsWith("data:")) {
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
style={style}
|
||||||
|
className={cn("flex-shrink-0 rounded-lg object-cover", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
style={style}
|
||||||
|
className={cn("flex-shrink-0 rounded-lg object-cover", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { newsletterSubscription } from "@/lib/db/schema";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
const emailSchema = z.string().email().max(255);
|
||||||
|
|
||||||
|
export async function subscribeNewsletter(email: string) {
|
||||||
|
const parsed = emailSchema.safeParse(email?.trim().toLowerCase());
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false as const, error: "INVALID_EMAIL" };
|
||||||
|
}
|
||||||
|
const normalizedEmail = parsed.data;
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const userId = session?.user?.id ?? null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(newsletterSubscription)
|
||||||
|
.where(eq(newsletterSubscription.email, normalizedEmail))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === "unsubscribed") {
|
||||||
|
await db
|
||||||
|
.update(newsletterSubscription)
|
||||||
|
.set({
|
||||||
|
status: "active",
|
||||||
|
subscribedAt: new Date(),
|
||||||
|
unsubscribedAt: null,
|
||||||
|
userId: userId ?? existing.userId,
|
||||||
|
})
|
||||||
|
.where(eq(newsletterSubscription.id, existing.id));
|
||||||
|
}
|
||||||
|
return { success: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(newsletterSubscription).values({
|
||||||
|
id: nanoid(),
|
||||||
|
email: normalizedEmail,
|
||||||
|
userId,
|
||||||
|
status: "active",
|
||||||
|
unsubscribeToken: nanoid(32),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Newsletter subscribe failed:", error);
|
||||||
|
return { success: false as const, error: "SAVE_FAILED" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可选:同步到 Resend Audience,失败不影响本地订阅结果
|
||||||
|
if (process.env.RESEND_API_KEY && process.env.RESEND_AUDIENCE_ID) {
|
||||||
|
try {
|
||||||
|
const { Resend } = await import("resend");
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||||
|
await resend.contacts.create({
|
||||||
|
email: normalizedEmail,
|
||||||
|
audienceId: process.env.RESEND_AUDIENCE_ID,
|
||||||
|
unsubscribed: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to sync contact to Resend:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true as const };
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { subscribeNewsletter } from "@/features/marketing/actions/newsletter-actions";
|
||||||
|
|
||||||
|
export function NewsletterForm() {
|
||||||
|
const t = useTranslations("footer.newsletter");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!email || pending) return;
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
const result = await subscribeNewsletter(email);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(t("success"));
|
||||||
|
setEmail("");
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
result.error === "INVALID_EMAIL" ? t("invalidEmail") : t("error")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8 max-w-sm">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">{t("title")}</h4>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t("description")}</p>
|
||||||
|
<form onSubmit={handleSubmit} className="mt-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder={t("placeholder")}
|
||||||
|
className="min-w-0 flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="flex items-center gap-1 rounded-lg bg-foreground px-4 py-2 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
|
{t("subscribe")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { category, toolCategory } from "@/lib/db/schema";
|
import { category, toolCategory, tool } from "@/lib/db/schema";
|
||||||
import { eq, asc, sql } from "drizzle-orm";
|
import { eq, asc, and, sql } from "drizzle-orm";
|
||||||
import { isAdmin } from "@/lib/auth/admin";
|
import { isAdmin } from "@/lib/auth/admin";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
@@ -43,10 +43,14 @@ export async function getCategoriesWithCount() {
|
|||||||
const result = await db
|
const result = await db
|
||||||
.select({
|
.select({
|
||||||
category,
|
category,
|
||||||
toolCount: sql<number>`count(${toolCategory.toolId})`,
|
toolCount: sql<number>`count(${tool.id})`,
|
||||||
})
|
})
|
||||||
.from(category)
|
.from(category)
|
||||||
.leftJoin(toolCategory, eq(category.id, toolCategory.categoryId))
|
.leftJoin(toolCategory, eq(category.id, toolCategory.categoryId))
|
||||||
|
.leftJoin(
|
||||||
|
tool,
|
||||||
|
and(eq(toolCategory.toolId, tool.id), eq(tool.status, "published"))
|
||||||
|
)
|
||||||
.groupBy(category.id)
|
.groupBy(category.id)
|
||||||
.orderBy(asc(category.sortOrder), asc(category.nameEn));
|
.orderBy(asc(category.sortOrder), asc(category.nameEn));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { favorite, tool } from "@/lib/db/schema";
|
||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
async function getSessionUserId(): Promise<string | null> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
return session?.user?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收藏/取消收藏(登录用户)
|
||||||
|
*/
|
||||||
|
export async function toggleFavorite(toolId: string) {
|
||||||
|
const userId = await getSessionUserId();
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false as const, error: "UNAUTHORIZED" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ toolId: favorite.toolId })
|
||||||
|
.from(favorite)
|
||||||
|
.where(and(eq(favorite.userId, userId), eq(favorite.toolId, toolId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.delete(favorite)
|
||||||
|
.where(and(eq(favorite.userId, userId), eq(favorite.toolId, toolId)));
|
||||||
|
return { success: true as const, favorited: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(favorite).values({ userId, toolId }).onConflictDoNothing();
|
||||||
|
return { success: true as const, favorited: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前用户收藏的工具 ID 列表(未登录返回空)
|
||||||
|
*/
|
||||||
|
export async function getFavoriteToolIds(): Promise<string[]> {
|
||||||
|
const userId = await getSessionUserId();
|
||||||
|
if (!userId) return [];
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ toolId: favorite.toolId })
|
||||||
|
.from(favorite)
|
||||||
|
.where(eq(favorite.userId, userId));
|
||||||
|
|
||||||
|
return rows.map((r) => r.toolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前用户收藏的工具详情列表(仪表盘)
|
||||||
|
*/
|
||||||
|
export async function getFavoriteTools() {
|
||||||
|
const userId = await getSessionUserId();
|
||||||
|
if (!userId) return [];
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ tool, favoritedAt: favorite.createdAt })
|
||||||
|
.from(favorite)
|
||||||
|
.innerJoin(tool, eq(favorite.toolId, tool.id))
|
||||||
|
.where(eq(favorite.userId, userId))
|
||||||
|
.orderBy(desc(favorite.createdAt));
|
||||||
|
|
||||||
|
return rows.map((row) => ({ ...row.tool, favoritedAt: row.favoritedAt }));
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./tool-actions";
|
export * from "./tool-actions";
|
||||||
export * from "./category-actions";
|
export * from "./category-actions";
|
||||||
export * from "./tag-actions";
|
export * from "./tag-actions";
|
||||||
|
export * from "./favorite-actions";
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { tool, toolCategory, toolTag, category, tag } from "@/lib/db/schema";
|
import { tool, toolCategory, toolTag, category, tag } from "@/lib/db/schema";
|
||||||
import { eq, desc, and, ilike, inArray, sql } from "drizzle-orm";
|
import { eq, desc, and, inArray, sql } from "drizzle-orm";
|
||||||
import { isAdmin } from "@/lib/auth/admin";
|
import { isAdmin } from "@/lib/auth/admin";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
// ========== 类型定义 ==========
|
// ========== 类型定义 ==========
|
||||||
export type ToolStatus = "draft" | "published";
|
export type ToolStatus = "draft" | "published";
|
||||||
|
export type ToolPricing = "free" | "freemium" | "paid";
|
||||||
|
|
||||||
export interface CreateToolInput {
|
export interface CreateToolInput {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -20,11 +21,24 @@ export interface CreateToolInput {
|
|||||||
descriptionEn?: string;
|
descriptionEn?: string;
|
||||||
nameZh?: string;
|
nameZh?: string;
|
||||||
descriptionZh?: string;
|
descriptionZh?: string;
|
||||||
|
pricing?: ToolPricing;
|
||||||
|
featured?: boolean;
|
||||||
status?: ToolStatus;
|
status?: ToolStatus;
|
||||||
categoryIds?: string[];
|
categoryIds?: string[];
|
||||||
tagIds?: string[];
|
tagIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 路由实际为 /[locale]/tools/[slug],revalidatePath 必须带上动态段才能命中缓存
|
||||||
|
function revalidateToolPages(withDetail = false) {
|
||||||
|
revalidatePath("/[locale]", "page");
|
||||||
|
revalidatePath("/[locale]/tools", "page");
|
||||||
|
revalidatePath("/[locale]/category/[slug]", "page");
|
||||||
|
revalidatePath("/[locale]/admin/tools", "page");
|
||||||
|
if (withDetail) {
|
||||||
|
revalidatePath("/[locale]/tools/[slug]", "page");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateToolInput extends Partial<CreateToolInput> {
|
export interface UpdateToolInput extends Partial<CreateToolInput> {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
@@ -36,6 +50,7 @@ export interface ToolListParams {
|
|||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
tagId?: string;
|
tagId?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
pricing?: ToolPricing;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 查询操作 ==========
|
// ========== 查询操作 ==========
|
||||||
@@ -44,7 +59,7 @@ export interface ToolListParams {
|
|||||||
* 获取工具列表(前台)
|
* 获取工具列表(前台)
|
||||||
*/
|
*/
|
||||||
export async function getPublishedTools(params: ToolListParams = {}) {
|
export async function getPublishedTools(params: ToolListParams = {}) {
|
||||||
const { page = 1, pageSize = 12, categoryId, tagId, search } = params;
|
const { page = 1, pageSize = 12, categoryId, tagId, search, pricing } = params;
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
let toolIds: string[] | undefined;
|
let toolIds: string[] | undefined;
|
||||||
@@ -93,6 +108,10 @@ export async function getPublishedTools(params: ToolListParams = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pricing) {
|
||||||
|
conditions.push(eq(tool.pricing, pricing));
|
||||||
|
}
|
||||||
|
|
||||||
// 查询工具
|
// 查询工具
|
||||||
const [tools, countResult] = await Promise.all([
|
const [tools, countResult] = await Promise.all([
|
||||||
db
|
db
|
||||||
@@ -116,6 +135,31 @@ export async function getPublishedTools(params: ToolListParams = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页精选工具:featured 优先,不足时用最新发布补齐
|
||||||
|
*/
|
||||||
|
export async function getFeaturedTools(limit = 8) {
|
||||||
|
const featuredTools = await db
|
||||||
|
.select()
|
||||||
|
.from(tool)
|
||||||
|
.where(and(eq(tool.status, "published"), eq(tool.featured, true)))
|
||||||
|
.orderBy(desc(tool.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
if (featuredTools.length >= limit) {
|
||||||
|
return featuredTools;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fill = await db
|
||||||
|
.select()
|
||||||
|
.from(tool)
|
||||||
|
.where(and(eq(tool.status, "published"), eq(tool.featured, false)))
|
||||||
|
.orderBy(desc(tool.createdAt))
|
||||||
|
.limit(limit - featuredTools.length);
|
||||||
|
|
||||||
|
return [...featuredTools, ...fill];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据 slug 获取工具详情
|
* 根据 slug 获取工具详情
|
||||||
*/
|
*/
|
||||||
@@ -264,8 +308,7 @@ export async function createTool(input: CreateToolInput) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/tools");
|
revalidateToolPages();
|
||||||
revalidatePath("/tools");
|
|
||||||
return { success: true, id };
|
return { success: true, id };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,9 +358,7 @@ export async function updateTool(input: UpdateToolInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/tools");
|
revalidateToolPages(true);
|
||||||
revalidatePath("/tools");
|
|
||||||
revalidatePath(`/tools/${input.slug}`);
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,8 +373,7 @@ export async function deleteTool(id: string) {
|
|||||||
|
|
||||||
await db.delete(tool).where(eq(tool.id, id));
|
await db.delete(tool).where(eq(tool.id, id));
|
||||||
|
|
||||||
revalidatePath("/admin/tools");
|
revalidateToolPages(true);
|
||||||
revalidatePath("/tools");
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +391,6 @@ export async function updateToolStatus(id: string, status: ToolStatus) {
|
|||||||
.set({ status, updatedAt: new Date() })
|
.set({ status, updatedAt: new Date() })
|
||||||
.where(eq(tool.id, id));
|
.where(eq(tool.id, id));
|
||||||
|
|
||||||
revalidatePath("/admin/tools");
|
revalidateToolPages(true);
|
||||||
revalidatePath("/tools");
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Heart } from "lucide-react";
|
||||||
|
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
import {
|
||||||
|
getFavoriteToolIds,
|
||||||
|
toggleFavorite,
|
||||||
|
} from "@/features/tools/actions/favorite-actions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface FavoritesContextValue {
|
||||||
|
favoriteIds: Set<string>;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
toggle: (toolId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FavoritesContext = createContext<FavoritesContextValue | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端拉取收藏状态(页面本身可被 ISR 缓存,收藏状态不能进服务端渲染结果)
|
||||||
|
*/
|
||||||
|
export function FavoritesProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const session = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const locale = useLocale();
|
||||||
|
const t = useTranslations("tools.favorites");
|
||||||
|
const isAuthenticated = !!session.data?.user;
|
||||||
|
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session.isPending) return;
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setFavoriteIds(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getFavoriteToolIds().then((ids) => setFavoriteIds(new Set(ids)));
|
||||||
|
}, [isAuthenticated, session.isPending]);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
(toolId: string) => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
toast(t("loginRequired"));
|
||||||
|
router.push(`/${locale}/login`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 乐观更新,失败回滚
|
||||||
|
setFavoriteIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(toolId)) {
|
||||||
|
next.delete(toolId);
|
||||||
|
} else {
|
||||||
|
next.add(toolId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
toggleFavorite(toolId).then((result) => {
|
||||||
|
if (!result.success) {
|
||||||
|
setFavoriteIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(toolId)) {
|
||||||
|
next.delete(toolId);
|
||||||
|
} else {
|
||||||
|
next.add(toolId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
toast.error(t("error"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isAuthenticated, locale, router, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ favoriteIds, isAuthenticated, toggle }),
|
||||||
|
[favoriteIds, isAuthenticated, toggle]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FavoritesContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</FavoritesContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FavoriteButtonProps {
|
||||||
|
toolId: string;
|
||||||
|
appearance?: "icon" | "button";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FavoriteButton({
|
||||||
|
toolId,
|
||||||
|
appearance = "icon",
|
||||||
|
}: FavoriteButtonProps) {
|
||||||
|
const ctx = useContext(FavoritesContext);
|
||||||
|
const t = useTranslations("tools.favorites");
|
||||||
|
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
const favorited = ctx.favoriteIds.has(toolId);
|
||||||
|
|
||||||
|
if (appearance === "button") {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => ctx.toggle(toolId)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors",
|
||||||
|
favorited
|
||||||
|
? "border-red-200 bg-red-50 text-red-600 hover:bg-red-100 dark:border-red-900 dark:bg-red-950 dark:text-red-400"
|
||||||
|
: "border-border text-foreground hover:bg-hover"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Heart className={cn("h-4 w-4", favorited && "fill-current")} />
|
||||||
|
{favorited ? t("remove") : t("add")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={favorited ? t("remove") : t("add")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
ctx.toggle(toolId);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full p-2 transition-colors",
|
||||||
|
favorited
|
||||||
|
? "text-red-500 hover:text-red-600"
|
||||||
|
: "text-muted-foreground/50 hover:text-red-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Heart className={cn("h-4 w-4", favorited && "fill-current")} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { Heart, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { getFavoriteTools } from "@/features/tools/actions/favorite-actions";
|
||||||
|
|
||||||
|
interface FavoriteTool {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
domain: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
nameEn: string;
|
||||||
|
nameZh: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘"我的收藏"列表
|
||||||
|
*/
|
||||||
|
export function FavoriteToolsSection() {
|
||||||
|
const t = useTranslations("dashboard.favorites");
|
||||||
|
const locale = useLocale();
|
||||||
|
const [tools, setTools] = useState<FavoriteTool[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getFavoriteTools().then(setTools);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getName = (tool: FavoriteTool) =>
|
||||||
|
locale === "zh" && tool.nameZh ? tool.nameZh : tool.nameEn;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card/50 backdrop-blur-md rounded-2xl p-6 border border-border">
|
||||||
|
<h3 className="text-xl font-semibold text-card-foreground mb-4 flex items-center gap-2">
|
||||||
|
<Heart className="h-5 w-5 text-red-500" />
|
||||||
|
{t("title")}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{tools === null ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : tools.length === 0 ? (
|
||||||
|
<div className="py-6 text-center">
|
||||||
|
<p className="text-muted-foreground">{t("empty")}</p>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/tools`}
|
||||||
|
className="mt-3 inline-block text-sm font-medium text-foreground underline underline-offset-4 hover:no-underline"
|
||||||
|
>
|
||||||
|
{t("browseTools")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<Link
|
||||||
|
key={tool.id}
|
||||||
|
href={`/${locale}/tools/${tool.slug}`}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3 hover:border-foreground/20 transition-colors"
|
||||||
|
>
|
||||||
|
{tool.logoUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={tool.logoUrl}
|
||||||
|
alt={getName(tool)}
|
||||||
|
className="h-10 w-10 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary text-base font-bold text-muted-foreground">
|
||||||
|
{getName(tool).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium text-foreground">
|
||||||
|
{getName(tool)}
|
||||||
|
</p>
|
||||||
|
{tool.domain && (
|
||||||
|
<p className="truncate text-sm text-muted-foreground">
|
||||||
|
{tool.domain}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Search, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Search, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { FavoriteButton } from "@/features/tools/components/favorite-button";
|
||||||
|
import { ToolLogo } from "@/components/tool-logo";
|
||||||
|
|
||||||
interface ToolData {
|
interface ToolData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,6 +18,7 @@ interface ToolData {
|
|||||||
descriptionEn: string | null;
|
descriptionEn: string | null;
|
||||||
nameZh: string | null;
|
nameZh: string | null;
|
||||||
descriptionZh: string | null;
|
descriptionZh: string | null;
|
||||||
|
pricing: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -38,12 +41,16 @@ interface ToolsTranslations {
|
|||||||
foundTools: string;
|
foundTools: string;
|
||||||
visitWebsite: string;
|
visitWebsite: string;
|
||||||
noTools: string;
|
noTools: string;
|
||||||
|
pricingFree: string;
|
||||||
|
pricingFreemium: string;
|
||||||
|
pricingPaid: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolsListProps {
|
interface ToolsListProps {
|
||||||
tools: ToolData[];
|
tools: ToolData[];
|
||||||
categories: CategoryData[];
|
categories: CategoryData[];
|
||||||
currentCategory?: string;
|
currentCategory?: string;
|
||||||
|
currentPricing?: string;
|
||||||
currentSearch?: string;
|
currentSearch?: string;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
@@ -56,6 +63,7 @@ export function ToolsList({
|
|||||||
tools,
|
tools,
|
||||||
categories,
|
categories,
|
||||||
currentCategory,
|
currentCategory,
|
||||||
|
currentPricing,
|
||||||
currentSearch,
|
currentSearch,
|
||||||
currentPage,
|
currentPage,
|
||||||
totalPages,
|
totalPages,
|
||||||
@@ -90,6 +98,17 @@ export function ToolsList({
|
|||||||
router.push(`/${locale}/tools?${params.toString()}`);
|
router.push(`/${locale}/tools?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePricingChange = (pricing: string | null) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (pricing) {
|
||||||
|
params.set("pricing", pricing);
|
||||||
|
} else {
|
||||||
|
params.delete("pricing");
|
||||||
|
}
|
||||||
|
params.delete("page");
|
||||||
|
router.push(`/${locale}/tools?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
params.set("page", page.toString());
|
params.set("page", page.toString());
|
||||||
@@ -107,6 +126,12 @@ export function ToolsList({
|
|||||||
const getCategoryName = (category: CategoryData) =>
|
const getCategoryName = (category: CategoryData) =>
|
||||||
locale === "zh" && category.nameZh ? category.nameZh : category.nameEn;
|
locale === "zh" && category.nameZh ? category.nameZh : category.nameEn;
|
||||||
|
|
||||||
|
const pricingLabels: Record<string, string> = {
|
||||||
|
free: translations.pricingFree,
|
||||||
|
freemium: translations.pricingFreemium,
|
||||||
|
paid: translations.pricingPaid,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 搜索框和分类筛选 */}
|
{/* 搜索框和分类筛选 */}
|
||||||
@@ -163,6 +188,33 @@ export function ToolsList({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 第三行:定价筛选 */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePricingChange(null)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
!currentPricing
|
||||||
|
? "bg-foreground text-background"
|
||||||
|
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{translations.all}
|
||||||
|
</button>
|
||||||
|
{(["free", "freemium", "paid"] as const).map((pricing) => (
|
||||||
|
<button
|
||||||
|
key={pricing}
|
||||||
|
onClick={() => handlePricingChange(pricing)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
currentPricing === pricing
|
||||||
|
? "bg-foreground text-background"
|
||||||
|
: "bg-secondary text-muted-foreground hover:bg-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pricingLabels[pricing]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 工具网格 */}
|
{/* 工具网格 */}
|
||||||
@@ -174,19 +226,7 @@ export function ToolsList({
|
|||||||
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
|
className="group bg-background rounded-xl border border-border p-5 hover:shadow-lg transition-all hover:border-foreground/20"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{tool.logoUrl ? (
|
<ToolLogo src={tool.logoUrl} alt={getName(tool)} size={48} />
|
||||||
<img
|
|
||||||
src={tool.logoUrl}
|
|
||||||
alt={getName(tool)}
|
|
||||||
className="h-12 w-12 rounded-lg object-cover flex-shrink-0"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-12 w-12 rounded-lg bg-secondary flex items-center justify-center flex-shrink-0">
|
|
||||||
<span className="text-xl font-bold text-muted-foreground">
|
|
||||||
{getName(tool).charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-foreground group-hover:text-primary truncate">
|
<h3 className="font-semibold text-foreground group-hover:text-primary truncate">
|
||||||
{getName(tool)}
|
{getName(tool)}
|
||||||
@@ -197,6 +237,7 @@ export function ToolsList({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<FavoriteButton toolId={tool.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{getDescription(tool) && (
|
{getDescription(tool) && (
|
||||||
@@ -205,12 +246,21 @@ export function ToolsList({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tool.websiteUrl && (
|
<div className="mt-4 flex items-center justify-between">
|
||||||
<div className="mt-4 flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground">
|
{tool.websiteUrl ? (
|
||||||
<ExternalLink className="h-3 w-3" />
|
<div className="flex items-center gap-1 text-sm text-muted-foreground group-hover:text-foreground">
|
||||||
<span>{translations.visitWebsite}</span>
|
<ExternalLink className="h-3 w-3" />
|
||||||
</div>
|
<span>{translations.visitWebsite}</span>
|
||||||
)}
|
</div>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
{tool.pricing && pricingLabels[tool.pricing] && (
|
||||||
|
<span className="rounded-full bg-secondary px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
|
{pricingLabels[tool.pricing]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user