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";
|
"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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user