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:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { ExternalLink, ArrowRight } from "lucide-react";
|
||||
import { Heading } from "./heading";
|
||||
@@ -54,9 +54,9 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-3 mt-8">
|
||||
{categories.slice(0, 6).map((category) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
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"
|
||||
>
|
||||
{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">
|
||||
({category.toolCount})
|
||||
</span>
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</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">
|
||||
{tools.slice(0, 8).map((tool) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
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"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -103,7 +103,7 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
||||
<span>{t("visitWebsite")}</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -117,13 +117,13 @@ export function FeaturedTools({ tools, categories }: FeaturedToolsProps) {
|
||||
{/* 查看更多按钮 */}
|
||||
{tools.length > 0 && (
|
||||
<div className="flex justify-center mt-12">
|
||||
<Link
|
||||
href={`/${locale}/tools`}
|
||||
<LocaleLink
|
||||
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"
|
||||
>
|
||||
{t("viewAll")}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
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;
|
||||
/** 显式目标语言(可选,默认当前语言) */
|
||||
locale?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function LocaleLink({ href, children, ...props }: LocaleLinkProps) {
|
||||
const locale = useLocale();
|
||||
}) {
|
||||
const currentLocale = useLocale();
|
||||
const target = localeProp ?? currentLocale;
|
||||
|
||||
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 prefix = target === defaultLocale ? '' : `/${target}`;
|
||||
const finalHref = `${prefix}${normalizedHref === '/' ? '' : normalizedHref}`;
|
||||
|
||||
if (
|
||||
normalizedHref === `/${locale}` ||
|
||||
normalizedHref.startsWith(`/${locale}/`)
|
||||
) {
|
||||
return <Link href={normalizedHref} {...props}>{children}</Link>;
|
||||
}
|
||||
|
||||
const localizedHref = `/${locale}${normalizedHref === '/' ? '' : normalizedHref}`;
|
||||
|
||||
return <Link href={localizedHref} {...props}>{children}</Link>;
|
||||
return (
|
||||
<Link href={finalHref} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
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";
|
||||
|
||||
interface AdminDashboardProps {
|
||||
@@ -99,9 +99,9 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
|
||||
{statCards.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Link
|
||||
<LocaleLink
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -150,12 +150,12 @@ export function AdminDashboard({ stats, recentUsers }: AdminDashboardProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={`/${locale}/admin/users`}
|
||||
<LocaleLink
|
||||
href="/admin/users"
|
||||
className="block mt-4 text-center text-sm text-muted-foreground hover:text-hover-foreground hover:underline"
|
||||
>
|
||||
{t("viewAllUsers")}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
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="flex h-screen flex-col">
|
||||
<div className="h-[60px] px-4 border-b border-border flex items-center">
|
||||
<Link
|
||||
href={`/${locale}/dashboard`}
|
||||
<LocaleLink
|
||||
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"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
{t("backToDashboard")}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
|
||||
<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 + "/");
|
||||
|
||||
return (
|
||||
<Link
|
||||
<LocaleLink
|
||||
key={item.href}
|
||||
href={href}
|
||||
className={cn(
|
||||
@@ -102,7 +102,7 @@ export function AdminSidebar() {
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{t(item.title)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -123,8 +123,8 @@ export async function AlternativesView({
|
||||
{[base, ...alternatives].map((tool) => (
|
||||
<tr key={tool.id} className="border-b border-border/60 last:border-b-0">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/${locale}/tools/${tool.slug}`}
|
||||
<LocaleLink
|
||||
href={`/tools/${tool.slug}`}
|
||||
className="font-medium text-foreground hover:text-primary"
|
||||
>
|
||||
{tool.nameEn === base.nameEn && tool.id === base.id
|
||||
@@ -132,7 +132,7 @@ export async function AlternativesView({
|
||||
: locale === "zh" && tool.nameZh
|
||||
? tool.nameZh
|
||||
: tool.nameEn}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground tabular-nums">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@@ -311,12 +311,12 @@ export async function ComparisonView({
|
||||
{winnerLabel && (
|
||||
<>
|
||||
{" "}{tc("leaning", { name: winnerLabel })}{" "}
|
||||
<Link
|
||||
href={`/${locale}/tools/${slugA === toolA.slug ? slugA : slugB}`}
|
||||
<LocaleLink
|
||||
href={`/tools/${slugA === toolA.slug ? slugA : slugB}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
{winnerLabel}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
@@ -331,14 +331,14 @@ export async function ComparisonView({
|
||||
Compare
|
||||
</th>
|
||||
<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}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</th>
|
||||
<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}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter as useIntlRouter } from "@/i18n/routing";
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Mail, AlertCircle } from 'lucide-react';
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
@@ -15,7 +15,7 @@ export function EmailVerifiedGuard({
|
||||
children,
|
||||
requireEmailVerification = true
|
||||
}: EmailVerifiedGuardProps) {
|
||||
const router = useRouter();
|
||||
const router = useIntlRouter();
|
||||
const session = useSession();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('auth.emailVerifiedGuard');
|
||||
@@ -24,7 +24,7 @@ export function EmailVerifiedGuard({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!session.isPending && !session.data) {
|
||||
router.replace(`/${locale}/login`);
|
||||
router.replace(`/login`);
|
||||
}
|
||||
}, [router, session.data, session.isPending, locale]);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -97,9 +97,9 @@ export function LoginForm() {
|
||||
footer={
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
{t('noAccount')}{" "}
|
||||
<Link href={`/${locale}/signup`} className="text-foreground hover:underline">
|
||||
<LocaleLink href="/signup" className="text-foreground hover:underline">
|
||||
{t('signUpLink')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</p>
|
||||
}
|
||||
socialSlot={
|
||||
@@ -131,9 +131,9 @@ export function LoginForm() {
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<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')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
</FormShell>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ export function SessionGuard({ children }: { children: React.ReactNode }) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!session.isPending && !session.data) {
|
||||
router.replace(`/${locale}/login`);
|
||||
router.replace(`/login`);
|
||||
}
|
||||
}, [router, session.data, session.isPending]);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -60,7 +60,7 @@ export function SignupForm() {
|
||||
|
||||
// 开发模式(默认)注册即已验证 → 直接回跳原页面/首页;生产跳验证提示页
|
||||
if (process.env.NEXT_PUBLIC_DEV_SKIP_EMAIL_VERIFY === "false") {
|
||||
router.push(`/${locale}/check-email`);
|
||||
router.push(`/check-email`);
|
||||
} else {
|
||||
const redirect =
|
||||
typeof window !== "undefined"
|
||||
@@ -115,9 +115,9 @@ export function SignupForm() {
|
||||
footer={
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
{t('hasAccount')}{" "}
|
||||
<Link href={`/${locale}/login`} className="text-foreground hover:underline">
|
||||
<LocaleLink href="/login" className="text-foreground hover:underline">
|
||||
{t('signInLink')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</p>
|
||||
}
|
||||
socialSlot={
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ClaimToolButton({
|
||||
setOpen(false);
|
||||
} else if (result.error === "UNAUTHORIZED") {
|
||||
router.push(
|
||||
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
);
|
||||
} else if (result.error === "PENDING_EXISTS") {
|
||||
toast.info(t("pendingExists"));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
||||
import { useMotionValueEvent, useScroll } from "framer-motion";
|
||||
import { IoIosClose, IoIosMenu } from "react-icons/io";
|
||||
import { Link } from "next-view-transitions";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
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">
|
||||
{marketingNavigationKeys.map((navItem) => (
|
||||
<div key={navItem.key} className="w-full">
|
||||
<Link
|
||||
href={`/${locale}${navItem.href}`}
|
||||
<LocaleLink
|
||||
href={navItem.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="relative block w-full py-2 hover:opacity-70 transition-opacity"
|
||||
>
|
||||
<span className="block text-xl text-foreground font-semibold">
|
||||
{t(navItem.key)}
|
||||
</span>
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -95,20 +96,20 @@ export const MobileNavbar = () => {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/${locale}/dashboard`}
|
||||
<LocaleLink
|
||||
href="/dashboard"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('dashboard')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/profile`}
|
||||
</LocaleLink>
|
||||
<LocaleLink
|
||||
href="/profile"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-[15px] font-medium text-muted-foreground py-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('profile')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
@@ -126,7 +127,7 @@ export const MobileNavbar = () => {
|
||||
<div className="flex flex-col gap-2.5 w-full">
|
||||
<Button
|
||||
as={Link}
|
||||
href={`/${locale}/signup`}
|
||||
href="/signup"
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
@@ -135,7 +136,7 @@ export const MobileNavbar = () => {
|
||||
<Button
|
||||
variant="simple"
|
||||
as={Link}
|
||||
href={`/${locale}/login`}
|
||||
href="/login"
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { signOut, useSession } from "@/lib/auth-client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
@@ -43,18 +43,18 @@ export function UserMenu() {
|
||||
if (!session.data?.user) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
<LocaleLink
|
||||
href="/login"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('common.actions.signIn')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/signup`}
|
||||
</LocaleLink>
|
||||
<LocaleLink
|
||||
href="/signup"
|
||||
className="bg-primary text-primary-foreground text-sm px-4 py-1.5 rounded-full hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('common.actions.signUp')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,34 +103,34 @@ export function UserMenu() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/${locale}/dashboard`}
|
||||
<LocaleLink
|
||||
href="/dashboard"
|
||||
onClick={() => setIsOpen(false)}
|
||||
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" />
|
||||
{t('navigation.main.dashboard')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href={`/${locale}/admin`}
|
||||
<LocaleLink
|
||||
href="/admin"
|
||||
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"
|
||||
>
|
||||
<IconShield className="w-4 h-4 flex-shrink-0" />
|
||||
{t('Admin.sidebar.title')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/${locale}/profile`}
|
||||
<LocaleLink
|
||||
href="/profile"
|
||||
onClick={() => setIsOpen(false)}
|
||||
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" />
|
||||
{t('navigation.main.profile')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
|
||||
<div className="border-t border-border mt-1 pt-1">
|
||||
<button
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ReviewsSection({
|
||||
const openForm = async () => {
|
||||
if (!isAuthenticated) {
|
||||
router.push(
|
||||
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export function ReviewsSection({
|
||||
const onHelpful = async (reviewId: string) => {
|
||||
if (!isAuthenticated) {
|
||||
router.push(
|
||||
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -76,14 +76,14 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
|
||||
if (result.success) {
|
||||
toast.success(t("submitted"));
|
||||
router.push(`/${locale}/dashboard`);
|
||||
router.push(`/dashboard`);
|
||||
} else {
|
||||
if (result.error === "DUPLICATE") {
|
||||
setDuplicates(result.duplicates ?? []);
|
||||
setError(t("duplicateBlocked"));
|
||||
} else if (result.error === "UNAUTHORIZED") {
|
||||
router.push(
|
||||
`/${locale}/login?redirect=${encodeURIComponent("/submit")}`
|
||||
`/login?redirect=${encodeURIComponent("/submit")}`
|
||||
);
|
||||
} else {
|
||||
setError(t("error"));
|
||||
@@ -108,7 +108,7 @@ export function SubmitToolForm({ categories }: { categories: CategoryOption[] })
|
||||
key={dup.slug}
|
||||
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"
|
||||
onClick={() => router.push(`/${locale}/tools/${dup.slug}`)}
|
||||
onClick={() => router.push(`/tools/${dup.slug}`)}
|
||||
>
|
||||
{dup.name}
|
||||
</button>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
|
||||
if (!isAuthenticated) {
|
||||
toast(t("loginRequired"));
|
||||
router.push(
|
||||
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
`/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Heart, Loader2 } from "lucide-react";
|
||||
|
||||
@@ -45,19 +45,19 @@ export function FavoriteToolsSection() {
|
||||
) : tools.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-muted-foreground">{t("empty")}</p>
|
||||
<Link
|
||||
href={`/${locale}/tools`}
|
||||
<LocaleLink
|
||||
href="/tools"
|
||||
className="mt-3 inline-block text-sm font-medium text-foreground underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
{t("browseTools")}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{tools.map((tool) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
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"
|
||||
>
|
||||
{tool.logoUrl ? (
|
||||
@@ -82,7 +82,7 @@ export function FavoriteToolsSection() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter as useIntlRouter } from "@/i18n/routing";
|
||||
import { Search, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { FavoriteButton } from "@/features/tools/components/favorite-button";
|
||||
import { VoteButton } from "@/features/voting/components/vote-button";
|
||||
@@ -89,7 +90,7 @@ export function ToolsList({
|
||||
useCases = [],
|
||||
currentSort = "newest",
|
||||
}: ToolsListProps) {
|
||||
const router = useRouter();
|
||||
const router = useIntlRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [searchInput, setSearchInput] = useState(currentSearch || "");
|
||||
|
||||
@@ -102,7 +103,7 @@ export function ToolsList({
|
||||
params.delete("search");
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleCategoryChange = (categorySlug: string | null) => {
|
||||
@@ -113,7 +114,7 @@ export function ToolsList({
|
||||
params.delete("category");
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handlePricingChange = (pricing: string | null) => {
|
||||
@@ -124,7 +125,7 @@ export function ToolsList({
|
||||
params.delete("pricing");
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const currentUseCase = new URLSearchParams(searchParams.toString()).get("usecase") ?? undefined;
|
||||
@@ -137,7 +138,7 @@ export function ToolsList({
|
||||
params.set("usecase", slug);
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handleSortChange = (sort: string) => {
|
||||
@@ -148,13 +149,13 @@ export function ToolsList({
|
||||
params.delete("sort");
|
||||
}
|
||||
params.delete("page");
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("page", page.toString());
|
||||
router.push(`/${locale}/tools?${params.toString()}`);
|
||||
router.push(`/tools?${params.toString()}`);
|
||||
};
|
||||
|
||||
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">
|
||||
{tools.map((tool) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
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"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -342,7 +343,7 @@ export function ToolsList({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -353,7 +354,7 @@ export function ToolsList({
|
||||
{translations.noTools}
|
||||
</p>
|
||||
<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"
|
||||
>
|
||||
{translations.clearFilters}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { ToolFormModal } from "./tool-form-modal";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
|
||||
interface ToolData {
|
||||
id: string;
|
||||
@@ -225,12 +225,12 @@ export function ToolsTable({ tools: initialTools }: ToolsTableProps) {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Link
|
||||
href={`/${locale}/tools/${tool.slug}`} target="_blank"
|
||||
<LocaleLink
|
||||
href={`/tools/${tool.slug}`} target="_blank"
|
||||
className="font-medium text-foreground hover:text-primary hover:underline"
|
||||
>
|
||||
{tool.nameEn}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
{tool.nameZh && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{tool.nameZh}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter as useIntlRouter } from "@/i18n/routing";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronUp } from "lucide-react";
|
||||
@@ -41,7 +41,7 @@ export function VotesProvider({
|
||||
initialCounts?: Record<string, number>;
|
||||
}) {
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const router = useIntlRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("voting");
|
||||
const isAuthenticated = !!session.data?.user;
|
||||
@@ -65,7 +65,7 @@ export function VotesProvider({
|
||||
if (!isAuthenticated) {
|
||||
// 未登录 → 跳登录页,登录后自动返回原页面(redirect 参数)
|
||||
toast(t("loginRequired"));
|
||||
router.push(`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`);
|
||||
router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
|
||||
return;
|
||||
}
|
||||
if (pendingIds.has(toolId)) return;
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
@@ -18,6 +26,7 @@ export function SectionHeader({
|
||||
viewAllLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<div className={`mb-5 flex items-end justify-between gap-4 ${className ?? ""}`}>
|
||||
<div>
|
||||
@@ -29,13 +38,13 @@ export function SectionHeader({
|
||||
)}
|
||||
</div>
|
||||
{viewAllHref && (
|
||||
<LocaleLink
|
||||
href={viewAllHref}
|
||||
<Link
|
||||
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"
|
||||
>
|
||||
{viewAllLabel}
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</LocaleLink>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
"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 type { ThemeBrand } from "./types";
|
||||
|
||||
@@ -15,17 +24,18 @@ export interface ThemeFooterProps {
|
||||
* 说明:主题化页脚(品牌 + 分组内链 + Newsletter),内链服务 SEO Graph。
|
||||
*/
|
||||
export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<footer className="border-t border-border bg-background">
|
||||
<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="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">
|
||||
{brand.name.slice(0, 1)}
|
||||
</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">
|
||||
{brand.tagline}
|
||||
</p>
|
||||
@@ -41,12 +51,12 @@ export function ThemeFooter({ brand, groups }: ThemeFooterProps) {
|
||||
<ul className="mt-3 space-y-2">
|
||||
{group.links.map((link) => (
|
||||
<li key={link.href + link.label}>
|
||||
<LocaleLink
|
||||
href={link.href}
|
||||
<Link
|
||||
href={localizedHref(link.href, locale)}
|
||||
className="text-[0.85rem] text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{link.label}
|
||||
</LocaleLink>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -2,11 +2,20 @@
|
||||
|
||||
import { useState } from "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 { LanguageSwitcher } from "@/components/language-switcher";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import type { ThemeBrand } from "./types";
|
||||
@@ -38,14 +47,14 @@ export function ThemeHeader({
|
||||
}: ThemeHeaderProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const router = useRouter();
|
||||
const router = useIntlRouter();
|
||||
const locale = useLocale();
|
||||
const pathname = usePathname();
|
||||
|
||||
const submitSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
router.push(`/${locale}/tools?search=${encodeURIComponent(query.trim())}`);
|
||||
router.push(`/tools?search=${encodeURIComponent(query.trim())}`);
|
||||
};
|
||||
|
||||
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">
|
||||
{/* 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">
|
||||
{brand.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="text-[0.95rem] font-bold tracking-tight text-foreground">
|
||||
{brand.name}
|
||||
</span>
|
||||
</LocaleLink>
|
||||
</Link>
|
||||
|
||||
{/* Desktop 导航 */}
|
||||
<nav className="hidden items-center gap-1 lg:flex" aria-label="Main">
|
||||
{navItems.map((item) => (
|
||||
<LocaleLink
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
href={localizedHref(item.href, locale)}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1.5 text-[0.95rem] transition-colors",
|
||||
isActive(item.href)
|
||||
@@ -85,7 +94,7 @@ export function ThemeHeader({
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</LocaleLink>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -129,9 +138,9 @@ export function ThemeHeader({
|
||||
{open && (
|
||||
<nav className="animate-drawer-in border-t border-border bg-background px-4 py-3 lg:hidden" aria-label="Mobile">
|
||||
{navItems.map((item) => (
|
||||
<LocaleLink
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
href={localizedHref(item.href, locale)}
|
||||
className={cn(
|
||||
"block rounded-lg px-3 py-2.5 text-[0.95rem]",
|
||||
isActive(item.href)
|
||||
@@ -141,7 +150,7 @@ export function ThemeHeader({
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</LocaleLink>
|
||||
</Link>
|
||||
))}
|
||||
{showSearch && (
|
||||
<form onSubmit={submitSearch} className="mt-2 px-1 pb-2 md:hidden">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import { db } from "@/lib/db";
|
||||
import { category as categoryTable } from "@/lib/db/schema";
|
||||
import {
|
||||
@@ -126,9 +126,9 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
|
||||
<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
|
||||
<LocaleLink
|
||||
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"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
@@ -158,7 +158,7 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
|
||||
<span>{t("visitWebsite")}</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</FavoritesProvider>
|
||||
@@ -173,13 +173,13 @@ export default async function CategoryPage({ params }: CategoryPageProps) {
|
||||
{/* 查看全部 */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="mt-12 flex justify-center">
|
||||
<Link
|
||||
href={`/${locale}/tools?category=${slug}`}
|
||||
<LocaleLink
|
||||
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"
|
||||
>
|
||||
{t("categoryPage.viewAll", { count: total })}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import Image from "next/image";
|
||||
import { eq } from "drizzle-orm";
|
||||
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">
|
||||
<Link
|
||||
href={`/${locale}/tools`}
|
||||
<LocaleLink
|
||||
href="/tools"
|
||||
className="inline-flex items-center gap-1.5 hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
AI Tools
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
{primaryCategory && (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Link
|
||||
href={`/${locale}/category/${primaryCategory.slug}`}
|
||||
<LocaleLink
|
||||
href={`/category/${primaryCategory.slug}`}
|
||||
className="hover:text-foreground"
|
||||
>
|
||||
{getCategoryName(primaryCategory)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
@@ -349,13 +349,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relations.useCases.map((uc) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
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"
|
||||
>
|
||||
{pickName(uc, locale)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,13 +368,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relations.audiences.map((aud) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
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"
|
||||
>
|
||||
{pickName(aud, locale)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,13 +391,13 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tool.categories.map((category) => (
|
||||
<Link
|
||||
<LocaleLink
|
||||
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"
|
||||
>
|
||||
{getCategoryName(category)}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -448,12 +448,12 @@ export default async function ToolDetailPage({ params }: ToolDetailPageProps) {
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
{t("alternatives")}
|
||||
</h2>
|
||||
<Link
|
||||
href={`/${locale}/tools/${slug}-alternatives`}
|
||||
<LocaleLink
|
||||
href={`/tools/${slug}-alternatives`}
|
||||
className="text-xs font-medium text-muted-foreground hover:text-primary"
|
||||
>
|
||||
{t("seeAllAlternatives")}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
<FavoritesProvider>
|
||||
<VotesProvider>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use 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 { Button } from "@/components/button";
|
||||
import { Container } from "@/components/container";
|
||||
@@ -10,7 +10,7 @@ import { motion } from "framer-motion";
|
||||
import { FavoriteToolsSection } from "@/features/tools/components/favorite-tools-section";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const router = useIntlRouter();
|
||||
const session = useSession();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("dashboard");
|
||||
@@ -97,7 +97,7 @@ export default function DashboardPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start transition-colors"
|
||||
onClick={() => router.push(`/${locale}/profile`)}
|
||||
onClick={() => router.push(`/profile`)}
|
||||
>
|
||||
{t("cards.quickActions.editProfile")}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Mail, ArrowRight, RefreshCcw } from 'lucide-react';
|
||||
import { LocaleLink } from "@/components/locale-link";
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@@ -109,12 +110,12 @@ export default function CheckEmailPage() {
|
||||
)}
|
||||
|
||||
{/* Back to Login */}
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
<LocaleLink
|
||||
href="/login"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t('backToLogin')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
|
||||
{/* Security Note */}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
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 Link from 'next/link';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
|
||||
function VerifyEmailPageInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const router = useIntlRouter();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations('auth.verifyEmail');
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||
@@ -34,7 +36,7 @@ function VerifyEmailPageInner() {
|
||||
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push(`/${locale}/login`);
|
||||
router.push(`/login`);
|
||||
}, 3000);
|
||||
} else {
|
||||
setStatus('error');
|
||||
@@ -74,12 +76,12 @@ function VerifyEmailPageInner() {
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{message}
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
<LocaleLink
|
||||
href="/login"
|
||||
className="inline-block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{t('goToLogin')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -94,12 +96,12 @@ function VerifyEmailPageInner() {
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{message}
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
<LocaleLink
|
||||
href="/login"
|
||||
className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{t('goToLogin')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -111,18 +113,18 @@ function VerifyEmailPageInner() {
|
||||
{message}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
href={`/${locale}/login`}
|
||||
<LocaleLink
|
||||
href="/login"
|
||||
className="block px-6 py-3 bg-primary hover:bg-primary/90 text-primary-foreground font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{t('goToLogin')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/check-email`}
|
||||
</LocaleLink>
|
||||
<LocaleLink
|
||||
href="/check-email"
|
||||
className="block px-6 py-3 border border-border hover:bg-muted text-card-foreground font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{t('requestNew')}
|
||||
</Link>
|
||||
</LocaleLink>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user