fix: 全站 229 个 /en/ 重定向链接清零(SEO 重定向链 + 交互修复)

- 根因 1:LocaleLink 手动拼 ${locale} 前缀,英文站产生 /en/... 链接,
  再被 middleware 307 打回无前缀形式(每页最多 60+ 个重定向链)
- 根因 2:next-intl 的 Link 在 locale prop === 当前 locale 时透传 href
  不做前缀化(4.14 行为),无法作为修复载体
- 修复:
  · LocaleLink 重写为确定性实现(原生 Link + 显式 as-needed 前缀规则:
    en 无前缀 / zh 带 /zh)
  · ThemeHeader/ThemeFooter/Section 同样确定性前缀
  · 12 个文件的 router.push/replace(${locale}/...) 全部迁移 next-intl
    useRouter(zh 下 push /tools 自动变 /zh/tools,不再丢 locale)
  · Link→LocaleLink 批量转换(admin/导航/工具表等 15 文件)
  · 修复衍生 bug:16 处模板字符串 href 被转成字面量(href="...\${...}")
- 回归:五站×13 页 0 个 /en/ 链接、0 个字面量 href;zh 页 30 链接全部带
  /zh 前缀;点击链路 0 次 /en/ 请求
This commit is contained in:
AI Directory Platform
2026-09-10 04:35:57 +00:00
parent 442ce3a003
commit c00600b9a7
27 changed files with 238 additions and 194 deletions
+10 -10
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } 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";
@@ -54,9 +54,9 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
{categories.length > 0 && ( {categories.length > 0 && (
<div className="flex flex-wrap justify-center gap-3 mt-8"> <div className="flex flex-wrap justify-center gap-3 mt-8">
{categories.slice(0, 6).map((category) => ( {categories.slice(0, 6).map((category) => (
<Link <LocaleLink
key={category.id} key={category.id}
href={`/${locale}/tools?category=${category.slug}`} href={`/tools?category=${category.slug}`}
className="px-4 py-2 rounded-full bg-secondary text-foreground hover:bg-hover transition-colors text-sm font-medium" className="px-4 py-2 rounded-full bg-secondary text-foreground hover:bg-hover transition-colors text-sm font-medium"
> >
{category.icon && <span className="mr-1">{category.icon}</span>} {category.icon && <span className="mr-1">{category.icon}</span>}
@@ -64,7 +64,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
<span className="ml-1 text-muted-foreground"> <span className="ml-1 text-muted-foreground">
({category.toolCount}) ({category.toolCount})
</span> </span>
</Link> </LocaleLink>
))} ))}
</div> </div>
)} )}
@@ -72,9 +72,9 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
{/* 工具网格 */} {/* 工具网格 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mt-12"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mt-12">
{tools.slice(0, 8).map((tool) => ( {tools.slice(0, 8).map((tool) => (
<Link <LocaleLink
key={tool.id} key={tool.id}
href={`/${locale}/tools/${tool.slug}`} href={`/tools/${tool.slug}`}
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">
@@ -103,7 +103,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
<span>{t("visitWebsite")}</span> <span>{t("visitWebsite")}</span>
</div> </div>
)} )}
</Link> </LocaleLink>
))} ))}
</div> </div>
@@ -117,13 +117,13 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
{/* 查看更多按钮 */} {/* 查看更多按钮 */}
{tools.length > 0 && ( {tools.length > 0 && (
<div className="flex justify-center mt-12"> <div className="flex justify-center mt-12">
<Link <LocaleLink
href={`/${locale}/tools`} href="/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"
> >
{t("viewAll")} {t("viewAll")}
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </LocaleLink>
</div> </div>
)} )}
</div> </div>
+29 -18
View File
@@ -1,32 +1,43 @@
"use client"; "use client";
import Link from "next/link";
import { useLocale } from 'next-intl'; import { useLocale } from 'next-intl';
import { Link } from 'next-view-transitions'; import { defaultLocale } from '@/i18n.config';
interface LocaleLinkProps { /**
* # 组件 LocaleLink
* 说明:本地化链接(确定性实现)。
* href 规则:默认语言(en)无前缀,其他语言带 /{locale} 前缀——
* 与 middleware 的 localePrefix: 'as-needed' 完全一致,
* 站内链接不再产生 /en/... → 307 → /... 的重定向链(SEO/交互问题)。
* locale prop 用于显式指定目标语言(语言切换场景),默认取当前 locale。
*/
export function LocaleLink({
href,
locale: localeProp,
children,
...props
}: {
href: string; href: string;
/** 显式目标语言(可选,默认当前语言) */
locale?: string;
children: React.ReactNode; children: React.ReactNode;
className?: string;
[key: string]: any; [key: string]: any;
} }) {
const currentLocale = useLocale();
export function LocaleLink({ href, children, ...props }: LocaleLinkProps) { const target = localeProp ?? currentLocale;
const locale = useLocale();
if (href.startsWith('http') || href.startsWith('//')) { if (href.startsWith('http') || href.startsWith('//')) {
return <Link href={href} {...props}>{children}</Link>; return <a href={href} {...props}>{children}</a>;
} }
const normalizedHref = href.startsWith('/') ? href : `/${href}`; const normalizedHref = href.startsWith('/') ? href : `/${href}`;
const prefix = target === defaultLocale ? '' : `/${target}`;
const finalHref = `${prefix}${normalizedHref === '/' ? '' : normalizedHref}`;
if ( return (
normalizedHref === `/${locale}` || <Link href={finalHref} {...props}>
normalizedHref.startsWith(`/${locale}/`) {children}
) { </Link>
return <Link href={normalizedHref} {...props}>{children}</Link>; );
}
const localizedHref = `/${locale}${normalizedHref === '/' ? '' : normalizedHref}`;
return <Link href={localizedHref} {...props}>{children}</Link>;
} }
@@ -2,7 +2,7 @@
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Users, Activity, Wrench, Inbox, MessageSquare, Vote, BarChart3 } from "lucide-react"; import { Users, Activity, Wrench, Inbox, MessageSquare, Vote, BarChart3 } from "lucide-react";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { useLocale } from "next-intl"; import { useLocale } from "next-intl";
interface AdminDashboardProps { interface AdminDashboardProps {
@@ -99,9 +99,9 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
{statCards.map((stat) => { {statCards.map((stat) => {
const Icon = stat.icon; const Icon = stat.icon;
return ( return (
<Link <LocaleLink
key={stat.title} key={stat.title}
href={`/${locale}${stat.link}`} href={stat.link}
className="bg-card rounded-lg p-6 border border-border hover:shadow-lg transition-shadow" className="bg-card rounded-lg p-6 border border-border hover:shadow-lg transition-shadow"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -115,7 +115,7 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
<Icon className="h-6 w-6 text-white" /> <Icon className="h-6 w-6 text-white" />
</div> </div>
</div> </div>
</Link> </LocaleLink>
); );
})} })}
</div> </div>
@@ -150,12 +150,12 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
</div> </div>
))} ))}
</div> </div>
<Link <LocaleLink
href={`/${locale}/admin/users`} href="/admin/users"
className="block mt-4 text-center text-sm text-muted-foreground hover:text-hover-foreground hover:underline" className="block mt-4 text-center text-sm text-muted-foreground hover:text-hover-foreground hover:underline"
> >
{t("viewAllUsers")} {t("viewAllUsers")}
</Link> </LocaleLink>
</div> </div>
</div> </div>
</div> </div>
@@ -1,6 +1,6 @@
"use client"; "use client";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { Users, Home, Wrench, FolderTree, Tags, Inbox, ShieldQuestion, MessageSquare, Compass, BarChart3, Vote } from "lucide-react"; import { Users, Home, Wrench, FolderTree, Tags, Inbox, ShieldQuestion, MessageSquare, Compass, BarChart3, Vote } from "lucide-react";
@@ -73,13 +73,13 @@ export function AdminSidebar() {
<div className="w-64 bg-background border-r border-border sticky top-0"> <div className="w-64 bg-background border-r border-border sticky top-0">
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<div className="h-[60px] px-4 border-b border-border flex items-center"> <div className="h-[60px] px-4 border-b border-border flex items-center">
<Link <LocaleLink
href={`/${locale}/dashboard`} href="/dashboard"
className="flex items-center gap-2 w-full rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-hover hover:text-hover-foreground transition-colors" className="flex items-center gap-2 w-full rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-hover hover:text-hover-foreground transition-colors"
> >
<Home className="h-4 w-4" /> <Home className="h-4 w-4" />
{t("backToDashboard")} {t("backToDashboard")}
</Link> </LocaleLink>
</div> </div>
<nav className="flex-1 overflow-y-auto px-4 py-4 space-y-1"> <nav className="flex-1 overflow-y-auto px-4 py-4 space-y-1">
@@ -90,7 +90,7 @@ export function AdminSidebar() {
pathname === href || pathname.startsWith(href + "/"); pathname === href || pathname.startsWith(href + "/");
return ( return (
<Link <LocaleLink
key={item.href} key={item.href}
href={href} href={href}
className={cn( className={cn(
@@ -102,7 +102,7 @@ export function AdminSidebar() {
> >
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
{t(item.title)} {t(item.title)}
</Link> </LocaleLink>
); );
})} })}
</nav> </nav>
@@ -123,8 +123,8 @@ export async function AlternativesView({
{[base, ...alternatives].map((tool) => ( {[base, ...alternatives].map((tool) => (
<tr key={tool.id} className="border-b border-border/60 last:border-b-0"> <tr key={tool.id} className="border-b border-border/60 last:border-b-0">
<td className="px-4 py-3"> <td className="px-4 py-3">
<Link <LocaleLink
href={`/${locale}/tools/${tool.slug}`} href={`/tools/${tool.slug}`}
className="font-medium text-foreground hover:text-primary" className="font-medium text-foreground hover:text-primary"
> >
{tool.nameEn === base.nameEn && tool.id === base.id {tool.nameEn === base.nameEn && tool.id === base.id
@@ -132,7 +132,7 @@ export async function AlternativesView({
: locale === "zh" && tool.nameZh : locale === "zh" && tool.nameZh
? tool.nameZh ? tool.nameZh
: tool.nameEn} : tool.nameEn}
</Link> </LocaleLink>
</td> </td>
<td className="px-4 py-3 text-muted-foreground tabular-nums"> <td className="px-4 py-3 text-muted-foreground tabular-nums">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
@@ -311,12 +311,12 @@ export async function ComparisonView({
{winnerLabel && ( {winnerLabel && (
<> <>
{" "}{tc("leaning", { name: winnerLabel })}{" "} {" "}{tc("leaning", { name: winnerLabel })}{" "}
<Link <LocaleLink
href={`/${locale}/tools/${slugA === toolA.slug ? slugA : slugB}`} href={`/tools/${slugA === toolA.slug ? slugA : slugB}`}
className="font-medium text-primary hover:underline" className="font-medium text-primary hover:underline"
> >
{winnerLabel} {winnerLabel}
</Link> </LocaleLink>
. .
</> </>
)} )}
@@ -331,14 +331,14 @@ export async function ComparisonView({
Compare Compare
</th> </th>
<th className="px-4 py-3 text-left"> <th className="px-4 py-3 text-left">
<Link href={`/${locale}/tools/${slugA}`} className="font-semibold text-foreground hover:text-primary"> <LocaleLink href={`/tools/${slugA}`} className="font-semibold text-foreground hover:text-primary">
{nameA} {nameA}
</Link> </LocaleLink>
</th> </th>
<th className="px-4 py-3 text-left"> <th className="px-4 py-3 text-left">
<Link href={`/${locale}/tools/${slugB}`} className="font-semibold text-foreground hover:text-primary"> <LocaleLink href={`/tools/${slugB}`} className="font-semibold text-foreground hover:text-primary">
{nameB} {nameB}
</Link> </LocaleLink>
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -1,7 +1,7 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { useRouter } from "next/navigation"; import { useRouter as useIntlRouter } from "@/i18n/routing";
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { Mail, AlertCircle } from 'lucide-react'; import { Mail, AlertCircle } from 'lucide-react';
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
@@ -15,7 +15,7 @@ export function EmailVerifiedGuard({
children, children,
requireEmailVerification = true requireEmailVerification = true
}: EmailVerifiedGuardProps) { }: EmailVerifiedGuardProps) {
const router = useRouter(); const router = useIntlRouter();
const session = useSession(); const session = useSession();
const locale = useLocale(); const locale = useLocale();
const t = useTranslations('auth.emailVerifiedGuard'); const t = useTranslations('auth.emailVerifiedGuard');
@@ -24,7 +24,7 @@ export function EmailVerifiedGuard({
React.useEffect(() => { React.useEffect(() => {
if (!session.isPending && !session.data) { if (!session.isPending && !session.data) {
router.replace(`/${locale}/login`); router.replace(`/login`);
} }
}, [router, session.data, session.isPending, locale]); }, [router, session.data, session.isPending, locale]);
@@ -2,7 +2,7 @@
import * as React from "react"; import * as React from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -97,9 +97,9 @@ export function LoginForm() {
footer={ footer={
<p className="mt-4 text-center text-sm text-muted-foreground"> <p className="mt-4 text-center text-sm text-muted-foreground">
{t('noAccount')}{" "} {t('noAccount')}{" "}
<Link href={`/${locale}/signup`} className="text-foreground hover:underline"> <LocaleLink href="/signup" className="text-foreground hover:underline">
{t('signUpLink')} {t('signUpLink')}
</Link> </LocaleLink>
</p> </p>
} }
socialSlot={ socialSlot={
@@ -131,9 +131,9 @@ export function LoginForm() {
autoComplete="current-password" autoComplete="current-password"
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Link href={`/${locale}/forgot-password`} className="text-sm font-normal text-muted-foreground hover:text-foreground"> <LocaleLink href="/forgot-password" className="text-sm font-normal text-muted-foreground hover:text-foreground">
{t('forgotPassword')} {t('forgotPassword')}
</Link> </LocaleLink>
</div> </div>
</FormShell> </FormShell>
); );
@@ -13,7 +13,7 @@ export function SessionGuard({ children }: { children: React.ReactNode }) {
React.useEffect(() => { React.useEffect(() => {
if (!session.isPending && !session.data) { if (!session.isPending && !session.data) {
router.replace(`/${locale}/login`); router.replace(`/login`);
} }
}, [router, session.data, session.isPending]); }, [router, session.data, session.isPending]);
@@ -2,7 +2,7 @@
import * as React from "react"; import * as React from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -60,7 +60,7 @@ export function SignupForm() {
// 开发模式(默认)注册即已验证 → 直接回跳原页面/首页;生产跳验证提示页 // 开发模式(默认)注册即已验证 → 直接回跳原页面/首页;生产跳验证提示页
if (process.env.NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY === "false") { if (process.env.NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY === "false") {
router.push(`/${locale}/check-email`); router.push(`/check-email`);
} else { } else {
const redirect = const redirect =
typeof window !== "undefined" typeof window !== "undefined"
@@ -115,9 +115,9 @@ export function SignupForm() {
footer={ footer={
<p className="mt-4 text-center text-sm text-muted-foreground"> <p className="mt-4 text-center text-sm text-muted-foreground">
{t('hasAccount')}{" "} {t('hasAccount')}{" "}
<Link href={`/${locale}/login`} className="text-foreground hover:underline"> <LocaleLink href="/login" className="text-foreground hover:underline">
{t('signInLink')} {t('signInLink')}
</Link> </LocaleLink>
</p> </p>
} }
socialSlot={ socialSlot={
@@ -53,7 +53,7 @@ export function ClaimToolButton({
setOpen(false); setOpen(false);
} else if (result.error === "UNAUTHORIZED") { } else if (result.error === "UNAUTHORIZED") {
router.push( router.push(
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}` `/login?redirect=${encodeURIComponent(window.location.pathname)}`
); );
} else if (result.error === "PENDING_EXISTS") { } else if (result.error === "PENDING_EXISTS") {
toast.info(t("pendingExists")); toast.info(t("pendingExists"));
@@ -5,6 +5,7 @@ import { useState } from "react";
import { useMotionValueEvent, useScroll } from "framer-motion"; import { useMotionValueEvent, useScroll } from "framer-motion";
import { IoIosClose, IoIosMenu } from "react-icons/io"; import { IoIosClose, IoIosMenu } from "react-icons/io";
import { Link } from "next-view-transitions"; import { Link } from "next-view-transitions";
import { LocaleLink } from "@/components/locale-link";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
@@ -66,15 +67,15 @@ export const MobileNavbar = () => {
<div className="flex flex-col items-start justify-start gap-3 px-6 py-6 w-full overflow-y-auto flex-1"> <div className="flex flex-col items-start justify-start gap-3 px-6 py-6 w-full overflow-y-auto flex-1">
{marketingNavigationKeys.map((navItem) => ( {marketingNavigationKeys.map((navItem) => (
<div key={navItem.key} className="w-full"> <div key={navItem.key} className="w-full">
<Link <LocaleLink
href={`/${locale}${navItem.href}`} href={navItem.href}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="relative block w-full py-2 hover:opacity-70 transition-opacity" className="relative block w-full py-2 hover:opacity-70 transition-opacity"
> >
<span className="block text-xl text-foreground font-semibold"> <span className="block text-xl text-foreground font-semibold">
{t(navItem.key)} {t(navItem.key)}
</span> </span>
</Link> </LocaleLink>
</div> </div>
))} ))}
</div> </div>
@@ -95,20 +96,20 @@ export const MobileNavbar = () => {
</p> </p>
)} )}
</div> </div>
<Link <LocaleLink
href={`/${locale}/dashboard`} href="/dashboard"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors" className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors"
> >
{t('dashboard')} {t('dashboard')}
</Link> </LocaleLink>
<Link <LocaleLink
href={`/${locale}/profile`} href="/profile"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors" className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors"
> >
{t('profile')} {t('profile')}
</Link> </LocaleLink>
<button <button
onClick={async () => { onClick={async () => {
await signOut(); await signOut();
@@ -126,7 +127,7 @@ export const MobileNavbar = () => {
<div className="flex flex-col gap-2.5 w-full"> <div className="flex flex-col gap-2.5 w-full">
<Button <Button
as={Link} as={Link}
href={`/${locale}/signup`} href="/signup"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="w-full justify-center" className="w-full justify-center"
> >
@@ -135,7 +136,7 @@ export const MobileNavbar = () => {
<Button <Button
variant="simple" variant="simple"
as={Link} as={Link}
href={`/${locale}/login`} href="/login"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="w-full justify-center" className="w-full justify-center"
> >
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { signOut, useSession } from "@/lib/auth-client"; import { signOut, useSession } from "@/lib/auth-client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
@@ -43,18 +43,18 @@ export function UserMenu() {
if (!session.data?.user) { if (!session.data?.user) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <LocaleLink
href={`/${locale}/login`} href="/login"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" className="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
{t('common.actions.signIn')} {t('common.actions.signIn')}
</Link> </LocaleLink>
<Link <LocaleLink
href={`/${locale}/signup`} href="/signup"
className="bg-primary text-primary-foreground text-sm px-4 py-1.5 rounded-full hover:opacity-90 transition-opacity" className="bg-primary text-primary-foreground text-sm px-4 py-1.5 rounded-full hover:opacity-90 transition-opacity"
> >
{t('common.actions.signUp')} {t('common.actions.signUp')}
</Link> </LocaleLink>
</div> </div>
); );
} }
@@ -103,34 +103,34 @@ export function UserMenu() {
)} )}
</div> </div>
<Link <LocaleLink
href={`/${locale}/dashboard`} href="/dashboard"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors" className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors"
> >
<IconLayoutDashboard className="w-4 h-4" /> <IconLayoutDashboard className="w-4 h-4" />
{t('navigation.main.dashboard')} {t('navigation.main.dashboard')}
</Link> </LocaleLink>
{isAdmin && ( {isAdmin && (
<Link <LocaleLink
href={`/${locale}/admin`} href="/admin"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors whitespace-nowrap" className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors whitespace-nowrap"
> >
<IconShield className="w-4 h-4 flex-shrink-0" /> <IconShield className="w-4 h-4 flex-shrink-0" />
{t('Admin.sidebar.title')} {t('Admin.sidebar.title')}
</Link> </LocaleLink>
)} )}
<Link <LocaleLink
href={`/${locale}/profile`} href="/profile"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors" className="flex items-center gap-3 px-4 py-2 text-sm text-muted-foreground hover:bg-hover transition-colors"
> >
<IconUser className="w-4 h-4" /> <IconUser className="w-4 h-4" />
{t('navigation.main.profile')} {t('navigation.main.profile')}
</Link> </LocaleLink>
<div className="border-t border-border mt-1 pt-1"> <div className="border-t border-border mt-1 pt-1">
<button <button
@@ -53,7 +53,7 @@ export function ReviewsSection({
const openForm = async () => { const openForm = async () => {
if (!isAuthenticated) { if (!isAuthenticated) {
router.push( router.push(
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}` `/login?redirect=${encodeURIComponent(window.location.pathname)}`
); );
return; return;
} }
@@ -75,7 +75,7 @@ export function ReviewsSection({
const onHelpful = async (reviewId: string) => { const onHelpful = async (reviewId: string) => {
if (!isAuthenticated) { if (!isAuthenticated) {
router.push( router.push(
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}` `/login?redirect=${encodeURIComponent(window.location.pathname)}`
); );
return; return;
} }
@@ -76,14 +76,14 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
if (result.success) { if (result.success) {
toast.success(t("submitted")); toast.success(t("submitted"));
router.push(`/${locale}/dashboard`); router.push(`/dashboard`);
} else { } else {
if (result.error === "DUPLICATE") { if (result.error === "DUPLICATE") {
setDuplicates(result.duplicates ?? []); setDuplicates(result.duplicates ?? []);
setError(t("duplicateBlocked")); setError(t("duplicateBlocked"));
} else if (result.error === "UNAUTHORIZED") { } else if (result.error === "UNAUTHORIZED") {
router.push( router.push(
`/${locale}/login?redirect=${encodeURIComponent("/submit")}` `/login?redirect=${encodeURIComponent("/submit")}`
); );
} else { } else {
setError(t("error")); setError(t("error"));
@@ -108,7 +108,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
key={dup.slug} key={dup.slug}
type="button" type="button"
className="rounded-full bg-amber-100 px-2.5 py-1 text-[0.85rem] font-medium underline-offset-2 hover:underline dark:bg-amber-900/50" className="rounded-full bg-amber-100 px-2.5 py-1 text-[0.85rem] font-medium underline-offset-2 hover:underline dark:bg-amber-900/50"
onClick={() => router.push(`/${locale}/tools/${dup.slug}`)} onClick={() => router.push(`/tools/${dup.slug}`)}
> >
{dup.name} {dup.name}
</button> </button>
@@ -54,7 +54,7 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
if (!isAuthenticated) { if (!isAuthenticated) {
toast(t("loginRequired")); toast(t("loginRequired"));
router.push( router.push(
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}` `/login?redirect=${encodeURIComponent(window.location.pathname)}`
); );
return; return;
} }
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { Heart, Loader2 } from "lucide-react"; import { Heart, Loader2 } from "lucide-react";
@@ -45,19 +45,19 @@ export function FavoriteToolsSection() {
) : tools.length === 0 ? ( ) : tools.length === 0 ? (
<div className="py-6 text-center"> <div className="py-6 text-center">
<p className="text-muted-foreground">{t("empty")}</p> <p className="text-muted-foreground">{t("empty")}</p>
<Link <LocaleLink
href={`/${locale}/tools`} href="/tools"
className="mt-3 inline-block text-sm font-medium text-foreground underline underline-offset-4 hover:no-underline" className="mt-3 inline-block text-sm font-medium text-foreground underline underline-offset-4 hover:no-underline"
> >
{t("browseTools")} {t("browseTools")}
</Link> </LocaleLink>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{tools.map((tool) => ( {tools.map((tool) => (
<Link <LocaleLink
key={tool.id} key={tool.id}
href={`/${locale}/tools/${tool.slug}`} href={`/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" className="flex items-center gap-3 rounded-xl border border-border bg-background/60 p-3 hover:border-foreground/20 transition-colors"
> >
{tool.logoUrl ? ( {tool.logoUrl ? (
@@ -82,7 +82,7 @@ export function FavoriteToolsSection() {
</p> </p>
)} )}
</div> </div>
</Link> </LocaleLink>
))} ))}
</div> </div>
)} )}
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { useRouter, useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useRouter as useIntlRouter } from "@/i18n/routing";
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 { FavoriteButton } from "@/features/tools/components/favorite-button";
import { VoteButton } from "@/features/voting/components/vote-button"; import { VoteButton } from "@/features/voting/components/vote-button";
@@ -89,7 +90,7 @@ export function ToolsList({
useCases = [], useCases = [],
currentSort = "newest", currentSort = "newest",
}: ToolsListProps) { }: ToolsListProps) {
const router = useRouter(); const router = useIntlRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [searchInput, setSearchInput] = useState(currentSearch || ""); const [searchInput, setSearchInput] = useState(currentSearch || "");
@@ -102,7 +103,7 @@ export function ToolsList({
params.delete("search"); params.delete("search");
} }
params.delete("page"); params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`); router.push(`/tools?${params.toString()}`);
}; };
const handleCategoryChange = (categorySlug: string | null) => { const handleCategoryChange = (categorySlug: string | null) => {
@@ -113,7 +114,7 @@ export function ToolsList({
params.delete("category"); params.delete("category");
} }
params.delete("page"); params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`); router.push(`/tools?${params.toString()}`);
}; };
const handlePricingChange = (pricing: string | null) => { const handlePricingChange = (pricing: string | null) => {
@@ -124,7 +125,7 @@ export function ToolsList({
params.delete("pricing"); params.delete("pricing");
} }
params.delete("page"); params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`); router.push(`/tools?${params.toString()}`);
}; };
const currentUseCase = new URLSearchParams(searchParams.toString()).get("usecase") ?? undefined; const currentUseCase = new URLSearchParams(searchParams.toString()).get("usecase") ?? undefined;
@@ -137,7 +138,7 @@ export function ToolsList({
params.set("usecase", slug); params.set("usecase", slug);
} }
params.delete("page"); params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`); router.push(`/tools?${params.toString()}`);
}; };
const handleSortChange = (sort: string) => { const handleSortChange = (sort: string) => {
@@ -148,13 +149,13 @@ export function ToolsList({
params.delete("sort"); params.delete("sort");
} }
params.delete("page"); params.delete("page");
router.push(`/${locale}/tools?${params.toString()}`); router.push(`/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());
router.push(`/${locale}/tools?${params.toString()}`); router.push(`/tools?${params.toString()}`);
}; };
const getName = (tool: ToolData) => const getName = (tool: ToolData) =>
@@ -298,9 +299,9 @@ export function ToolsList({
{/* 工具网格 */} {/* 工具网格 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{tools.map((tool) => ( {tools.map((tool) => (
<Link <LocaleLink
key={tool.id} key={tool.id}
href={`/${locale}/tools/${tool.slug}`} href={`/tools/${tool.slug}`}
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">
@@ -342,7 +343,7 @@ export function ToolsList({
</span> </span>
)} )}
</div> </div>
</Link> </LocaleLink>
))} ))}
</div> </div>
@@ -353,7 +354,7 @@ export function ToolsList({
{translations.noTools} {translations.noTools}
</p> </p>
<button <button
onClick={() => router.push(`/${locale}/tools`)} onClick={() => router.push(`/tools`)}
className="mt-3 inline-flex items-center gap-1 text-[0.95rem] font-medium text-primary hover:underline" className="mt-3 inline-flex items-center gap-1 text-[0.95rem] font-medium text-primary hover:underline"
> >
{translations.clearFilters} {translations.clearFilters}
@@ -19,7 +19,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { ToolFormModal } from "./tool-form-modal"; import { ToolFormModal } from "./tool-form-modal";
import { useTranslations, useLocale } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
interface ToolData { interface ToolData {
id: string; id: string;
@@ -225,12 +225,12 @@ export function ToolsTable({ tools: initialTools }: ToolsTableProps) {
</div> </div>
)} )}
<div> <div>
<Link <LocaleLink
href={`/${locale}/tools/${tool.slug}`} target="_blank" href={`/tools/${tool.slug}`} target="_blank"
className="font-medium text-foreground hover:text-primary hover:underline" className="font-medium text-foreground hover:text-primary hover:underline"
> >
{tool.nameEn} {tool.nameEn}
</Link> </LocaleLink>
{tool.nameZh && ( {tool.nameZh && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{tool.nameZh} {tool.nameZh}
@@ -8,7 +8,7 @@ import {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { useRouter } from "next/navigation"; import { useRouter as useIntlRouter } from "@/i18n/routing";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
import { ChevronUp } from "lucide-react"; import { ChevronUp } from "lucide-react";
@@ -41,7 +41,7 @@ export function VotesProvider({
initialCounts?: Record<string, number>; initialCounts?: Record<string, number>;
}) { }) {
const session = useSession(); const session = useSession();
const router = useRouter(); const router = useIntlRouter();
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("voting"); const t = useTranslations("voting");
const isAuthenticated = !!session.data?.user; const isAuthenticated = !!session.data?.user;
@@ -65,7 +65,7 @@ export function VotesProvider({
if (!isAuthenticated) { if (!isAuthenticated) {
// 未登录 → 跳登录页,登录后自动返回原页面(redirect 参数) // 未登录 → 跳登录页,登录后自动返回原页面(redirect 参数)
toast(t("loginRequired")); toast(t("loginRequired"));
router.push(`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`); router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
return; return;
} }
if (pendingIds.has(toolId)) return; if (pendingIds.has(toolId)) return;
+13 -4
View File
@@ -1,4 +1,12 @@
import { LocaleLink } from "@/components/locale-link"; import Link from "next/link";
import { useLocale } from "next-intl";
import { defaultLocale } from "@/i18n.config";
/** 站内链接的确定性 locale 前缀 */
function localizedHref(href: string, locale: string): string {
const prefix = locale === defaultLocale ? "" : `/${locale}`;
return `${prefix}${href === "/" ? "" : href}`;
}
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
/** /**
@@ -18,6 +26,7 @@ export function SectionHeader({
viewAllLabel?: string; viewAllLabel?: string;
className?: string; className?: string;
}) { }) {
const locale = useLocale();
return ( return (
<div className={`mb-5 flex items-end justify-between gap-4 ${className ?? ""}`}> <div className={`mb-5 flex items-end justify-between gap-4 ${className ?? ""}`}>
<div> <div>
@@ -29,13 +38,13 @@ export function SectionHeader({
)} )}
</div> </div>
{viewAllHref && ( {viewAllHref && (
<LocaleLink <Link
href={viewAllHref} href={localizedHref(viewAllHref, locale)}
className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-primary" className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-primary"
> >
{viewAllLabel} {viewAllLabel}
<ArrowRight className="h-3 w-3" /> <ArrowRight className="h-3 w-3" />
</LocaleLink> </Link>
)} )}
</div> </div>
); );
+16 -6
View File
@@ -1,6 +1,15 @@
"use client"; "use client";
import { LocaleLink } from "@/components/locale-link"; import Link from "next/link";
import { useLocale } from "next-intl";
import { defaultLocale } from "@/i18n.config";
/** 站内链接的确定性 locale 前缀 */
function localizedHref(href: string, locale: string): string {
const prefix = locale === defaultLocale ? "" : `/${locale}`;
return `${prefix}${href === "/" ? "" : href}`;
}
import { NewsletterForm } from "@/features/marketing/components/newsletter-form"; import { NewsletterForm } from "@/features/marketing/components/newsletter-form";
import type { ThemeBrand } from "./types"; import type { ThemeBrand } from "./types";
@@ -15,17 +24,18 @@ export interface ThemeFooterProps {
* 说明:主题化页脚(品牌 + 分组内链 + Newsletter),内链服务 SEO Graph。 * 说明:主题化页脚(品牌 + 分组内链 + Newsletter),内链服务 SEO Graph。
*/ */
export function ThemeFooter({ brand, groups }: ThemeFooterProps) { export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
const locale = useLocale();
return ( return (
<footer className="border-t border-border bg-background"> <footer className="border-t border-border bg-background">
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6"> <div className="mx-auto max-w-7xl px-4 py-12 sm:px-6">
<div className="grid gap-10 md:grid-cols-4"> <div className="grid gap-10 md:grid-cols-4">
<div className="md:col-span-1"> <div className="md:col-span-1">
<LocaleLink href="/" className="flex items-center gap-2"> <Link href={localizedHref("/", locale)} className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground"> <span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
{brand.name.slice(0, 1)} {brand.name.slice(0, 1)}
</span> </span>
<span className="text-[0.95rem] font-bold text-foreground">{brand.name}</span> <span className="text-[0.95rem] font-bold text-foreground">{brand.name}</span>
</LocaleLink> </Link>
<p className="mt-3 text-[0.85rem] leading-relaxed text-muted-foreground"> <p className="mt-3 text-[0.85rem] leading-relaxed text-muted-foreground">
{brand.tagline} {brand.tagline}
</p> </p>
@@ -41,12 +51,12 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
<ul className="mt-3 space-y-2"> <ul className="mt-3 space-y-2">
{group.links.map((link) => ( {group.links.map((link) => (
<li key={link.href + link.label}> <li key={link.href + link.label}>
<LocaleLink <Link
href={link.href} href={localizedHref(link.href, locale)}
className="text-[0.85rem] text-muted-foreground transition-colors hover:text-foreground" className="text-[0.85rem] text-muted-foreground transition-colors hover:text-foreground"
> >
{link.label} {link.label}
</LocaleLink> </Link>
</li> </li>
))} ))}
</ul> </ul>
+21 -12
View File
@@ -2,11 +2,20 @@
import { useState } from "react"; import { useState } from "react";
import { Menu, Search, X } from "lucide-react"; import { Menu, Search, X } from "lucide-react";
import { LocaleLink } from "@/components/locale-link"; import Link from "next/link";
import { ModeToggle } from "@/components/mode-toggle"; import { ModeToggle } from "@/components/mode-toggle";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { UserMenu } from "@/features/navigation/components/user-menu"; import { UserMenu } from "@/features/navigation/components/user-menu";
import { useRouter, usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useRouter as useIntlRouter } from "@/i18n/routing";
import { locales, defaultLocale, type Locale } from "@/i18n.config";
/** 站内链接的确定性 locale 前缀:默认语言无前缀,其他语言带 /{locale} */
function localizedHref(href: string, locale: string): string {
const prefix = locale === defaultLocale ? "" : `/${locale}`;
const normalized = href === "/" ? "" : href;
return `${prefix}${normalized}`;
}
import { useLocale } from "next-intl"; import { useLocale } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ThemeBrand } from "./types"; import type { ThemeBrand } from "./types";
@@ -38,14 +47,14 @@ export function ThemeHeader({
}: ThemeHeaderProps) { }: ThemeHeaderProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const router = useRouter(); const router = useIntlRouter();
const locale = useLocale(); const locale = useLocale();
const pathname = usePathname(); const pathname = usePathname();
const submitSearch = (e: React.FormEvent) => { const submitSearch = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!query.trim()) return; if (!query.trim()) return;
router.push(`/${locale}/tools?search=${encodeURIComponent(query.trim())}`); router.push(`/tools?search=${encodeURIComponent(query.trim())}`);
}; };
const isActive = (href: string) => { const isActive = (href: string) => {
@@ -62,21 +71,21 @@ export function ThemeHeader({
> >
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6"> <div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-4 px-4 sm:px-6">
{/* Logo */} {/* Logo */}
<LocaleLink href="/" className="flex shrink-0 items-center gap-2"> <Link href={localizedHref("/", locale)} className="flex shrink-0 items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground"> <span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
{brand.name.slice(0, 1)} {brand.name.slice(0, 1)}
</span> </span>
<span className="text-[0.95rem] font-bold tracking-tight text-foreground"> <span className="text-[0.95rem] font-bold tracking-tight text-foreground">
{brand.name} {brand.name}
</span> </span>
</LocaleLink> </Link>
{/* Desktop 导航 */} {/* Desktop 导航 */}
<nav className="hidden items-center gap-1 lg:flex" aria-label="Main"> <nav className="hidden items-center gap-1 lg:flex" aria-label="Main">
{navItems.map((item) => ( {navItems.map((item) => (
<LocaleLink <Link
key={item.href} key={item.href}
href={item.href} href={localizedHref(item.href, locale)}
className={cn( className={cn(
"rounded-lg px-3 py-1.5 text-[0.95rem] transition-colors", "rounded-lg px-3 py-1.5 text-[0.95rem] transition-colors",
isActive(item.href) isActive(item.href)
@@ -85,7 +94,7 @@ export function ThemeHeader({
)} )}
> >
{item.label} {item.label}
</LocaleLink> </Link>
))} ))}
</nav> </nav>
@@ -129,9 +138,9 @@ export function ThemeHeader({
{open && ( {open && (
<nav className="animate-drawer-in border-t border-border bg-background px-4 py-3 lg:hidden" aria-label="Mobile"> <nav className="animate-drawer-in border-t border-border bg-background px-4 py-3 lg:hidden" aria-label="Mobile">
{navItems.map((item) => ( {navItems.map((item) => (
<LocaleLink <Link
key={item.href} key={item.href}
href={item.href} href={localizedHref(item.href, locale)}
className={cn( className={cn(
"block rounded-lg px-3 py-2.5 text-[0.95rem]", "block rounded-lg px-3 py-2.5 text-[0.95rem]",
isActive(item.href) isActive(item.href)
@@ -141,7 +150,7 @@ export function ThemeHeader({
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
{item.label} {item.label}
</LocaleLink> </Link>
))} ))}
{showSearch && ( {showSearch && (
<form onSubmit={submitSearch} className="mt-2 px-1 pb-2 md:hidden"> <form onSubmit={submitSearch} className="mt-2 px-1 pb-2 md:hidden">
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { category as categoryTable } from "@/lib/db/schema"; import { category as categoryTable } from "@/lib/db/schema";
import { import {
@@ -126,9 +126,9 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
<FavoritesProvider> <FavoritesProvider>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{tools.map((tool) => ( {tools.map((tool) => (
<Link <LocaleLink
key={tool.id} key={tool.id}
href={`/${locale}/tools/${tool.slug}`} href={`/tools/${tool.slug}`}
className="group rounded-xl border border-border bg-background p-5 transition-all hover:border-foreground/20 hover:shadow-lg" 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"> <div className="flex items-start gap-4">
@@ -158,7 +158,7 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
<span>{t("visitWebsite")}</span> <span>{t("visitWebsite")}</span>
</div> </div>
)} )}
</Link> </LocaleLink>
))} ))}
</div> </div>
</FavoritesProvider> </FavoritesProvider>
@@ -173,13 +173,13 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
{/* 查看全部 */} {/* 查看全部 */}
{total > PAGE_SIZE && ( {total > PAGE_SIZE && (
<div className="mt-12 flex justify-center"> <div className="mt-12 flex justify-center">
<Link <LocaleLink
href={`/${locale}/tools?category=${slug}`} href={`/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" 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 })} {t("categoryPage.viewAll", { count: total })}
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Link> </LocaleLink>
</div> </div>
)} )}
</div> </div>
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import { LocaleLink } from "@/components/locale-link";
import Image from "next/image"; import Image from "next/image";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@@ -250,22 +250,22 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
{/* 面包屑内链 */} {/* 面包屑内链 */}
<nav className="mb-8 flex items-center gap-1 text-xs text-muted-foreground"> <nav className="mb-8 flex items-center gap-1 text-xs text-muted-foreground">
<Link <LocaleLink
href={`/${locale}/tools`} href="/tools"
className="inline-flex items-center gap-1.5 hover:text-foreground" className="inline-flex items-center gap-1.5 hover:text-foreground"
> >
<ArrowLeft className="h-3.5 w-3.5" /> <ArrowLeft className="h-3.5 w-3.5" />
AI Tools AI Tools
</Link> </LocaleLink>
{primaryCategory && ( {primaryCategory && (
<> <>
<ChevronRight className="h-3 w-3" /> <ChevronRight className="h-3 w-3" />
<Link <LocaleLink
href={`/${locale}/category/${primaryCategory.slug}`} href={`/category/${primaryCategory.slug}`}
className="hover:text-foreground" className="hover:text-foreground"
> >
{getCategoryName(primaryCategory)} {getCategoryName(primaryCategory)}
</Link> </LocaleLink>
</> </>
)} )}
<ChevronRight className="h-3 w-3" /> <ChevronRight className="h-3 w-3" />
@@ -349,13 +349,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
</h2> </h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{relations.useCases.map((uc) => ( {relations.useCases.map((uc) => (
<Link <LocaleLink
key={uc.id} key={uc.id}
href={`/${locale}/ai-tools-for/${uc.slug}`} href={`/ai-tools-for/${uc.slug}`}
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground" className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground"
> >
{pickName(uc, locale)} {pickName(uc, locale)}
</Link> </LocaleLink>
))} ))}
</div> </div>
</div> </div>
@@ -368,13 +368,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
</h2> </h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{relations.audiences.map((aud) => ( {relations.audiences.map((aud) => (
<Link <LocaleLink
key={aud.id} key={aud.id}
href={`/${locale}/ai-tools-for/${aud.slug}`} href={`/ai-tools-for/${aud.slug}`}
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground" className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground"
> >
{pickName(aud, locale)} {pickName(aud, locale)}
</Link> </LocaleLink>
))} ))}
</div> </div>
</div> </div>
@@ -391,13 +391,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
</h2> </h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{tool.categories.map((category) => ( {tool.categories.map((category) => (
<Link <LocaleLink
key={category.id} key={category.id}
href={`/${locale}/category/${category.slug}`} href={`/category/${category.slug}`}
className="rounded-lg bg-secondary px-3.5 py-2 text-sm text-foreground transition-colors hover:bg-hover" className="rounded-lg bg-secondary px-3.5 py-2 text-sm text-foreground transition-colors hover:bg-hover"
> >
{getCategoryName(category)} {getCategoryName(category)}
</Link> </LocaleLink>
))} ))}
</div> </div>
</section> </section>
@@ -448,12 +448,12 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
<h2 className="text-xl font-semibold text-foreground"> <h2 className="text-xl font-semibold text-foreground">
{t("alternatives")} {t("alternatives")}
</h2> </h2>
<Link <LocaleLink
href={`/${locale}/tools/${slug}-alternatives`} href={`/tools/${slug}-alternatives`}
className="text-xs font-medium text-muted-foreground hover:text-primary" className="text-xs font-medium text-muted-foreground hover:text-primary"
> >
{t("seeAllAlternatives")} {t("seeAllAlternatives")}
</Link> </LocaleLink>
</div> </div>
<FavoritesProvider> <FavoritesProvider>
<VotesProvider> <VotesProvider>
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useSession, signOut } from "@/lib/auth-client"; import { useSession, signOut } from "@/lib/auth-client";
import { useRouter } from "next/navigation"; import { useRouter as useIntlRouter } from "@/i18n/routing";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Container } from "@/components/container"; import { Container } from "@/components/container";
@@ -10,7 +10,7 @@ import { motion } from "framer-motion";
import { FavoriteToolsSection } from "@/features/tools/components/favorite-tools-section"; import { FavoriteToolsSection } from "@/features/tools/components/favorite-tools-section";
export default function DashboardPage() { export default function DashboardPage() {
const router = useRouter(); const router = useIntlRouter();
const session = useSession(); const session = useSession();
const locale = useLocale(); const locale = useLocale();
const t = useTranslations("dashboard"); const t = useTranslations("dashboard");
@@ -97,7 +97,7 @@ export default function DashboardPage() {
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start transition-colors" className="w-full justify-start transition-colors"
onClick={() => router.push(`/${locale}/profile`)} onClick={() => router.push(`/profile`)}
> >
{t("cards.quickActions.editProfile")} {t("cards.quickActions.editProfile")}
</Button> </Button>
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { Mail, ArrowRight, RefreshCcw } from 'lucide-react'; import { Mail, ArrowRight, RefreshCcw } from 'lucide-react';
import { LocaleLink } from "@/components/locale-link";
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
@@ -109,12 +110,12 @@ export default function CheckEmailPage() {
)} )}
{/* Back to Login */} {/* Back to Login */}
<Link <LocaleLink
href={`/${locale}/login`} href="/login"
className="text-sm text-muted-foreground hover:text-foreground transition-colors" className="text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
{t('backToLogin')} {t('backToLogin')}
</Link> </LocaleLink>
</div> </div>
{/* Security Note */} {/* Security Note */}
@@ -1,14 +1,16 @@
'use client'; 'use client';
import { Suspense, useEffect, useState } from 'react'; import { Suspense, useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation'; import { LocaleLink } from "@/components/locale-link";
import { useSearchParams } from 'next/navigation';
import { useRouter as useIntlRouter } from '@/i18n/routing';
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';
function VerifyEmailPageInner() { function VerifyEmailPageInner() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useIntlRouter();
const locale = useLocale(); const locale = useLocale();
const t = useTranslations('auth.verifyEmail'); const t = useTranslations('auth.verifyEmail');
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
@@ -34,7 +36,7 @@ function VerifyEmailPageInner() {
// Redirect to login after 3 seconds // Redirect to login after 3 seconds
setTimeout(() => { setTimeout(() => {
router.push(`/${locale}/login`); router.push(`/login`);
}, 3000); }, 3000);
} else { } else {
setStatus('error'); setStatus('error');
@@ -74,12 +76,12 @@ function VerifyEmailPageInner() {
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
{message} {message}
</p> </p>
<Link <LocaleLink
href={`/${locale}/login`} href="/login"
className="inline-block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors" className="inline-block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
> >
{t('goToLogin')} {t('goToLogin')}
</Link> </LocaleLink>
</div> </div>
)} )}
@@ -94,12 +96,12 @@ function VerifyEmailPageInner() {
<p className="text-muted-foreground mb-6"> <p className="text-muted-foreground mb-6">
{message} {message}
</p> </p>
<Link <LocaleLink
href={`/${locale}/login`} href="/login"
className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors" className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
> >
{t('goToLogin')} {t('goToLogin')}
</Link> </LocaleLink>
</> </>
) : ( ) : (
<> <>
@@ -111,18 +113,18 @@ function VerifyEmailPageInner() {
{message} {message}
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<Link <LocaleLink
href={`/${locale}/login`} href="/login"
className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors" className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
> >
{t('goToLogin')} {t('goToLogin')}
</Link> </LocaleLink>
<Link <LocaleLink
href={`/${locale}/check-email`} href="/check-email"
className="block px-6 py-3 border border-border hover:bg-muted text-card-foreground font-medium rounded-lg transition-colors" className="block px-6 py-3 border border-border hover:bg-muted text-card-foreground font-medium rounded-lg transition-colors"
> >
{t('requestNew')} {t('requestNew')}
</Link> </LocaleLink>
</div> </div>
</> </>
)} )}