feat: 交互体验专项优化(7 项)
- 登录回跳贯通:邮箱登录成功后按 ?redirect= 回到原页面(投票/收藏/评论的登录拦截闭环) - 收藏按钮的登录跳转补上 redirect 参数 - 营销路由组 loading.tsx 骨架屏(客户端导航即时反馈) - 投票按钮/收藏爱心按下缩放微交互 - 移动端抽屉滑入动画(keyframes + animate-drawer-in) - 工具列表空状态加'清除搜索与筛选'按钮(en/zh) - 品牌 404 页(图标 + Back to home / Browse tools 双入口)
This commit is contained in:
@@ -645,7 +645,8 @@
|
|||||||
"sort": {
|
"sort": {
|
||||||
"latest": "Latest",
|
"latest": "Latest",
|
||||||
"trending": "Trending"
|
"trending": "Trending"
|
||||||
}
|
},
|
||||||
|
"clearFilters": "Clear search & filters"
|
||||||
},
|
},
|
||||||
"seo": {
|
"seo": {
|
||||||
"home": {
|
"home": {
|
||||||
|
|||||||
@@ -653,7 +653,8 @@
|
|||||||
"sort": {
|
"sort": {
|
||||||
"latest": "最新",
|
"latest": "最新",
|
||||||
"trending": "最热"
|
"trending": "最热"
|
||||||
}
|
},
|
||||||
|
"clearFilters": "清除搜索与筛选"
|
||||||
},
|
},
|
||||||
"seo": {
|
"seo": {
|
||||||
"home": {
|
"home": {
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ function collectRoutes() {
|
|||||||
entry.name === "page.tsx" ||
|
entry.name === "page.tsx" ||
|
||||||
entry.name === "route.ts" ||
|
entry.name === "route.ts" ||
|
||||||
entry.name === "layout.tsx" ||
|
entry.name === "layout.tsx" ||
|
||||||
|
entry.name === "loading.tsx" ||
|
||||||
entry.name === "not-found.tsx" ||
|
entry.name === "not-found.tsx" ||
|
||||||
entry.name === "manifest.ts" ||
|
entry.name === "manifest.ts" ||
|
||||||
entry.name === "sitemap.ts"
|
entry.name === "sitemap.ts"
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ export function LoginForm() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push(`/${locale}/`);
|
// 登录成功后回跳原页面(投票/收藏/评论的登录拦截会带 ?redirect= 参数)
|
||||||
|
const redirect =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? new URLSearchParams(window.location.search).get("redirect")
|
||||||
|
: null;
|
||||||
|
router.push(redirect || `/${locale}/`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(t('errors.loginFailed'));
|
setError(t('errors.loginFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ export function FavoritesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
(toolId: string) => {
|
(toolId: string) => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
toast(t("loginRequired"));
|
toast(t("loginRequired"));
|
||||||
router.push(`/${locale}/login`);
|
router.push(
|
||||||
|
`/${locale}/login?redirect=${encodeURIComponent(window.location.pathname)}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +163,7 @@ export function FavoriteButton({
|
|||||||
ctx.toggle(toolId);
|
ctx.toggle(toolId);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full p-2 transition-colors",
|
"rounded-full p-2 transition-all active:scale-125",
|
||||||
favorited
|
favorited
|
||||||
? "text-red-500 hover:text-red-600"
|
? "text-red-500 hover:text-red-600"
|
||||||
: "text-muted-foreground/50 hover:text-red-500"
|
: "text-muted-foreground/50 hover:text-red-500"
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface ToolsTranslations {
|
|||||||
pricingPaid: string;
|
pricingPaid: string;
|
||||||
sortLatest: string;
|
sortLatest: string;
|
||||||
sortTrending: string;
|
sortTrending: string;
|
||||||
|
clearFilters: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseCaseOption {
|
interface UseCaseOption {
|
||||||
@@ -351,6 +352,12 @@ export function ToolsList({
|
|||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{translations.noTools}
|
{translations.noTools}
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/${locale}/tools`)}
|
||||||
|
className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{translations.clearFilters}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export function VoteButton({
|
|||||||
aria-pressed={voted}
|
aria-pressed={voted}
|
||||||
aria-label={voted ? t("unvote") : t("vote")}
|
aria-label={voted ? t("unvote") : t("vote")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-fit min-w-[4.5rem] flex-col items-center gap-0.5 rounded-xl border px-3 py-2.5 transition-colors",
|
"flex h-fit min-w-[4.5rem] flex-col items-center gap-0.5 rounded-xl border px-3 py-2.5 transition-all active:scale-95",
|
||||||
voted
|
voted
|
||||||
? "border-primary/60 bg-primary/10 text-primary"
|
? "border-primary/60 bg-primary/10 text-primary"
|
||||||
: "border-border text-foreground hover:border-primary/40 hover:bg-primary/5"
|
: "border-border text-foreground hover:border-primary/40 hover:bg-primary/5"
|
||||||
@@ -205,7 +205,7 @@ export function VoteButton({
|
|||||||
onClick={() => ctx.toggle(toolId)}
|
onClick={() => ctx.toggle(toolId)}
|
||||||
aria-pressed={voted}
|
aria-pressed={voted}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition-colors",
|
"flex w-full items-center justify-center gap-2 rounded-lg border px-4 py-2.5 text-sm font-medium transition-all active:scale-[0.98]",
|
||||||
voted
|
voted
|
||||||
? "border-primary/60 bg-primary/10 text-primary"
|
? "border-primary/60 bg-primary/10 text-primary"
|
||||||
: "border-border text-foreground hover:bg-hover"
|
: "border-border text-foreground hover:bg-hover"
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export function ThemeHeader({
|
|||||||
|
|
||||||
{/* Mobile 抽屉 */}
|
{/* Mobile 抽屉 */}
|
||||||
{open && (
|
{open && (
|
||||||
<nav className="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
|
<LocaleLink
|
||||||
key={item.href}
|
key={item.href}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* # 组件 MarketingLoading
|
||||||
|
* 说明:营销路由组的加载骨架屏 —— 客户端导航时即时反馈,替代"冻结"的旧页面。
|
||||||
|
*/
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6">
|
||||||
|
<div className="mx-auto mb-4 h-8 w-64 animate-pulse rounded-lg bg-secondary" />
|
||||||
|
<div className="mx-auto mb-10 h-4 w-96 max-w-full animate-pulse rounded bg-secondary" />
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-28 animate-pulse rounded-xl border border-border bg-secondary/50"
|
||||||
|
style={{ animationDelay: `${i * 90}ms` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -92,6 +92,7 @@ export default async function ToolsPage({ searchParams }: ToolsPageProps) {
|
|||||||
pricingPaid: t("pricing.paid"),
|
pricingPaid: t("pricing.paid"),
|
||||||
sortLatest: t("sort.latest"),
|
sortLatest: t("sort.latest"),
|
||||||
sortTrending: t("sort.trending"),
|
sortTrending: t("sort.trending"),
|
||||||
|
clearFilters: t("clearFilters"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://toolso.ai';
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { defaultLocale } from '@/i18n.config';
|
import { defaultLocale } from '@/i18n.config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # 组件 NotFound
|
||||||
|
* 说明:品牌化 404 页(图标 + 双入口:首页 / 工具目录)。
|
||||||
|
*/
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<body>
|
<body style={{ margin: 0, fontFamily: 'system-ui, sans-serif', background: '#fafafa', color: '#0a0a0a' }}>
|
||||||
<div style={{ textAlign: 'center', padding: '100px' }}>
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px' }}>
|
||||||
<h1>404 - Page Not Found</h1>
|
<div style={{ textAlign: 'center', maxWidth: '420px' }}>
|
||||||
<p>The page you are looking for doesn't exist.</p>
|
<div style={{ width: 64, height: 64, margin: '0 auto 20px', borderRadius: 16, background: '#0a0a0a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Link href={`/${defaultLocale}`}>
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
Go to Homepage
|
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
|
||||||
</Link>
|
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, letterSpacing: '0.2em', color: '#737373', margin: '0 0 8px' }}>404</p>
|
||||||
|
<h1 style={{ fontSize: 24, margin: '0 0 8px' }}>Page not found</h1>
|
||||||
|
<p style={{ fontSize: 14, color: '#525252', lineHeight: 1.6, margin: '0 0 24px' }}>
|
||||||
|
The page you are looking for doesn't exist or may have moved.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
|
||||||
|
<Link href={`/${defaultLocale}`} style={{ display: 'inline-block', padding: '10px 20px', borderRadius: 8, background: '#0a0a0a', color: '#fff', textDecoration: 'none', fontSize: 14 }}>
|
||||||
|
Back to home
|
||||||
|
</Link>
|
||||||
|
<Link href={`/${defaultLocale}/tools`} style={{ display: 'inline-block', padding: '10px 20px', borderRadius: 8, border: '1px solid #e5e5e5', color: '#0a0a0a', textDecoration: 'none', fontSize: 14 }}>
|
||||||
|
Browse tools
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -104,3 +104,18 @@ html {
|
|||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 移动端抽屉滑入动画(交互反馈) */
|
||||||
|
@keyframes bu-drawer-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-drawer-in {
|
||||||
|
animation: bu-drawer-in 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user