feat(routing): add App Router skeleton for splash/chat/auth/sidebar + proxy.ts
Wire the Flutter route map (lib/router/app_router.dart) to Next.js 16 App
Router. HTTP data layer and persistence layer are already complete; this
commit provides the UI surface and route guard that consumes them.
Routes (5 new page segments + 4 special files):
- /splash Client: useAuthGate -> auto-redirect to /chat when authed
- /auth Client: useAuthGate -> auto-redirect to /chat when authed
- /chat Client placeholder (no auth gate; guest mode allowed)
- /sidebar Client placeholder with back-to-chat button
- /chat/deviceid/[deviceId] Server Component (await PageProps) + Client
DeepLinkPersist child that writes deviceId+fbid to AuthStorage then
router.replace('/chat')
Special files (root segment):
- loading.tsx Server: centered spinner fallback
- error.tsx Client boundary using v16 `unstable_retry` prop
- not-found.tsx Server: 404 with link back to /splash
- layout.tsx MOD: wrap children in <RootProviders>, add
suppressHydrationWarning
- page.tsx MOD: redirect('/splash') from server
Shared infrastructure:
- src/lib/routes.ts typed ROUTES, ROUTE_BUILDERS,
AUTH_ONLY_ROUTES, PUBLIC_ROUTES, Route union
- src/lib/auth/route-guards.ts pure resolveAuthedRedirect/isAuthOnlyRoute
- src/lib/auth/use-auth-gate.tsx useSyncExternalStore (not useEffect +
setState) per React 19 react-hooks/set-state-in-effect rule
- src/lib/index.ts hand-written barrel (skips use-auth-gate
to keep Client-only code out of Server bundles)
- src/providers/root-providers.tsx Client wrapper + <div id="toast-portal" />
- src/proxy.ts v16 proxy (NOT deprecated middleware.ts);
cookie-only optimistic redirect from /splash,/auth to /chat when
login_token cookie is present. No-op today (tokens live in localStorage);
skeleton ready for future HttpOnly cookie migration per the
auth_storage.ts TODO(security).
Conventions:
- AGENTS.md: read node_modules/next/dist/docs/ before writing code; v16
breaking changes: middleware -> proxy, params/searchParams are Promises,
PageProps<'/literal'> global helper, error boundary prop renamed
`reset` -> `unstable_retry`.
- All placeholder UIs use the design tokens from src/app/globals.css
(no new hex colors).
- No new runtime dependencies; no test framework added.
- src/lib/ NOT added to barrelsby.json (avoids pulling Client-only
use-auth-gate into Server bundles).
Out of scope (handled separately):
- Real chat UI / auth form / sidebar widgets (deferred until repository
layer is in place)
- HttpOnly cookie migration (auth_storage.ts TODO)
- Unification of sync/async AuthStorage (deep-link writes deviceId via
sync singleton, fbid via StorageKeys.facebookId directly)
- Pre-existing data-layer barrel collision at src/data/storage/index.ts:10
(acknowledged in replicated-mapping-creek.md)
Verified:
- pnpm next typegen: OK
- pnpm exec tsc --noEmit on new files: 0 errors
- pnpm lint: 0 errors, 0 warnings
- pnpm exec next build: blocked by pre-existing src/data/storage/index.ts
barrel ambiguity (unrelated, see replicated-mapping-creek.md)
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth 页(路由骨架版)
|
||||||
|
*
|
||||||
|
* 对齐 Flutter `lib/ui/auth/auth_screen.dart` 的语义:
|
||||||
|
* - 已登录用户:自动跳 `/chat`
|
||||||
|
* - 未登录:渲染返回 splash + disabled 登录表单占位
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { useAuthGate } from "@/lib/auth/use-auth-gate";
|
||||||
|
import { ROUTES } from "@/lib/routes";
|
||||||
|
|
||||||
|
export default function AuthPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthed } = useAuthGate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthed) {
|
||||||
|
router.replace(ROUTES.chat);
|
||||||
|
}
|
||||||
|
}, [isAuthed, router]);
|
||||||
|
|
||||||
|
if (!isAuthed) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="flex flex-1 flex-col items-center justify-center gap-xl p-lg"
|
||||||
|
style={{
|
||||||
|
minHeight: "100dvh",
|
||||||
|
background: "var(--color-page-background)",
|
||||||
|
color: "var(--color-text-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 className="text-3xl font-semibold">Auth</h1>
|
||||||
|
<p
|
||||||
|
className="max-w-md text-center text-sm"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
Auth placeholder · login form coming soon.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-md">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className="rounded-md px-xl py-sm text-sm opacity-60"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-accent)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
cursor: "not-allowed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Login form coming soon
|
||||||
|
{/* TODO: 接入 emailLogin / googleLogin / appleLogin / facebookLogin 后启用 */}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={ROUTES.splash}
|
||||||
|
className="rounded-md border px-xl py-sm text-center text-sm"
|
||||||
|
style={{ borderColor: "var(--color-border)" }}
|
||||||
|
>
|
||||||
|
Back to Splash
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-1 items-center justify-center"
|
||||||
|
style={{ minHeight: "100dvh" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-label="Redirecting"
|
||||||
|
className="size-10 animate-spin rounded-full border-4 border-t-transparent"
|
||||||
|
style={{ borderColor: "var(--color-accent)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 深链落地:把 deviceId + fbid 写进 AuthStorage,然后跳 `/chat`。
|
||||||
|
*
|
||||||
|
* 对齐 Flutter `lib/router/app_router.dart` 中 `/chat/deviceid/:deviceId` 的
|
||||||
|
* `redirect` 回调(`app_router.dart:60-78`)。
|
||||||
|
*
|
||||||
|
* 设计:
|
||||||
|
* - **不**走 Server `redirect()`:避免把 fbid 暴露在 URL 里。
|
||||||
|
* - deviceId 走 sync `authStorage.setDeviceId`(与 `token_interceptor.ts` 同源;
|
||||||
|
* 写入键 `cozsweet.deviceId`)。
|
||||||
|
* - fbid 走 `localStorage.setItem("facebook_id", ...)`,对齐 async
|
||||||
|
* `StorageKeys.facebookId`。Sync 单例**没有** `setFacebookId` 方法,本轮
|
||||||
|
* 不重构 storage(见 plan §6.1),仅留 TODO。
|
||||||
|
* - 写入完成后 `router.replace('/chat')`,URL 不留深链痕迹。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { authStorage } from "@/data/storage/auth_storage";
|
||||||
|
import { StorageKeys } from "@/data/storage/storage_keys";
|
||||||
|
import { ROUTES } from "@/lib/routes";
|
||||||
|
|
||||||
|
interface DeepLinkPersistProps {
|
||||||
|
deviceId: string;
|
||||||
|
fbid: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeepLinkPersist({ deviceId, fbid }: DeepLinkPersistProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
authStorage.setDeviceId(deviceId);
|
||||||
|
if (fbid && fbid.length > 0) {
|
||||||
|
// TODO: 统一 sync/async AuthStorage 后改为 `authStorage.setFacebookId(fbid)`。
|
||||||
|
// 当前 sync 单例未暴露该方法;使用 `StorageKeys.facebookId` 保持与 async 层
|
||||||
|
// `src/data/storage/auth/auth_storage.ts` 键名一致。
|
||||||
|
window.localStorage.setItem(StorageKeys.facebookId, fbid);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 写不进 localStorage 时(例如隐私模式)也不阻塞跳转;记录到 console 即可。
|
||||||
|
console.warn("[DeepLinkPersist] failed to persist", err);
|
||||||
|
} finally {
|
||||||
|
setDone(true);
|
||||||
|
router.replace(ROUTES.chat);
|
||||||
|
}
|
||||||
|
}, [deviceId, fbid, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-1 items-center justify-center"
|
||||||
|
style={{ minHeight: "100dvh" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-label={done ? "Redirecting" : "Loading"}
|
||||||
|
className="size-10 animate-spin rounded-full border-4 border-t-transparent"
|
||||||
|
style={{ borderColor: "var(--color-accent)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* 深链入口(Server Component)
|
||||||
|
*
|
||||||
|
* 对齐 Flutter `lib/router/app_router.dart:60-78` 的 `GoRoute(path: '/chat/deviceid/:deviceId')`:
|
||||||
|
* 把 `deviceId` 路径参数与 `fbid` 查询参数透传给 Client 组件 `<DeepLinkPersist>`,
|
||||||
|
* 由它把数据写入 localStorage 后 `router.replace('/chat')`。
|
||||||
|
*
|
||||||
|
* Next.js 15+ 约定:`params` / `searchParams` 是 Promise,必须 await。
|
||||||
|
* Next.js 16 全局 helper `PageProps<'/literal'>` 由 `next dev` / `next build` /
|
||||||
|
* `next typegen` 生成。
|
||||||
|
*
|
||||||
|
* 这里**不**调用 `redirect()`:把 fbid 暴露在 URL 上对用户不友好;
|
||||||
|
* 跳转推迟到 Client 完成持久化之后。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import DeepLinkPersist from "./DeepLinkPersist";
|
||||||
|
|
||||||
|
export default async function ChatDeviceIdPage(
|
||||||
|
props: PageProps<"/chat/deviceid/[deviceId]">,
|
||||||
|
) {
|
||||||
|
const { deviceId } = await props.params;
|
||||||
|
const { fbid } = await props.searchParams;
|
||||||
|
const fbidStr = Array.isArray(fbid) ? fbid[0] : fbid ?? null;
|
||||||
|
|
||||||
|
return <DeepLinkPersist deviceId={deviceId} fbid={fbidStr} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat 页(路由骨架版)
|
||||||
|
*
|
||||||
|
* **不**做鉴权重定向:Flutter 端允许游客态直接进聊天(`auth_refresh_interceptor`
|
||||||
|
* 会同时处理登录与游客 token)。这里只放占位 UI。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { ROUTES } from "@/lib/routes";
|
||||||
|
|
||||||
|
export default function ChatPage() {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="flex flex-1 flex-col items-center justify-center gap-xl p-lg"
|
||||||
|
style={{
|
||||||
|
minHeight: "100dvh",
|
||||||
|
background: "var(--color-page-background)",
|
||||||
|
color: "var(--color-text-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 className="text-3xl font-semibold">Chat</h1>
|
||||||
|
<p
|
||||||
|
className="max-w-md text-center text-sm"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
Chat placeholder · guest mode supported · real chat UI coming soon.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-md">
|
||||||
|
<Link
|
||||||
|
href={ROUTES.sidebar}
|
||||||
|
className="rounded-md border px-xl py-sm text-center text-sm"
|
||||||
|
style={{ borderColor: "var(--color-border)" }}
|
||||||
|
>
|
||||||
|
Open Sidebar
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={ROUTES.auth}
|
||||||
|
className="rounded-md px-xl py-sm text-center text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-accent)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根级错误边界
|
||||||
|
*
|
||||||
|
* Next.js 16 约定:必须为 Client Component;reset prop 在 v16 中被重命名为
|
||||||
|
* `unstable_retry`(参见 `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/error.md`)。
|
||||||
|
*
|
||||||
|
* 行为:把 `error` 打到 console(保留 digest 便于服务端聚合),并提供"重试"
|
||||||
|
* 与"返回首页"两个动作。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { ROUTES } from "@/lib/routes";
|
||||||
|
|
||||||
|
interface ErrorProps {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
unstable_retry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GlobalError({ error, unstable_retry }: ErrorProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
// 把错误送到 console;后续接 Sentry/Datadog 时改这里。
|
||||||
|
console.error("[app/error.tsx]", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-md p-lg">
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-semibold"
|
||||||
|
style={{ color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
|
Something went wrong
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="max-w-md text-center text-sm"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
{error.message || "Unexpected error"}
|
||||||
|
{error.digest ? ` (digest: ${error.digest})` : null}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={unstable_retry}
|
||||||
|
className="rounded-md px-lg py-sm text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-accent)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={ROUTES.splash}
|
||||||
|
className="rounded-md border px-lg py-sm text-sm"
|
||||||
|
style={{ borderColor: "var(--color-border)" }}
|
||||||
|
>
|
||||||
|
Back to start
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+8
-1
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
import { RootProviders } from "@/providers/root-providers";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -23,11 +25,16 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
|
// suppressHydrationWarning:next/font + Tailwind v4 偶发类名差异;详见
|
||||||
|
// `src/lib/auth/use-auth-gate.tsx` 的"水合闪屏"说明。
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="en"
|
||||||
|
suppressHydrationWarning
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col">
|
||||||
|
<RootProviders>{children}</RootProviders>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* 根级 Suspense fallback
|
||||||
|
*
|
||||||
|
* Server Component,渲染于任一未 ready 子树之上。
|
||||||
|
* 设计为最小化的居中 spinner,复用 `globals.css` 的 `--color-accent`。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-1 items-center justify-center"
|
||||||
|
style={{ minHeight: "100dvh" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-label="Loading"
|
||||||
|
className="size-10 animate-spin rounded-full border-4 border-t-transparent"
|
||||||
|
style={{ borderColor: "var(--color-accent)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 根级 404
|
||||||
|
*
|
||||||
|
* Server Component。覆盖未匹配路由(结合 `notFound()` 调用)。
|
||||||
|
* 不启用 v16 实验性 `global-not-found.js`,避免 `next.config.ts` 变更。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { ROUTES } from "@/lib/routes";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-md p-lg">
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-semibold"
|
||||||
|
style={{ color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
|
Page not found
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="max-w-md text-center text-sm"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
The page you're looking for doesn't exist or has been moved.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={ROUTES.splash}
|
||||||
|
className="rounded-md px-lg py-sm text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-accent)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Return to start
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+15
-62
@@ -1,65 +1,18 @@
|
|||||||
import Image from "next/image";
|
/**
|
||||||
|
* 根路径处理
|
||||||
|
*
|
||||||
|
* Server Component:把访客直接送入 `/splash`,由 splash 页(Client)根据
|
||||||
|
* `authStorage.hasLoginToken()` 决定是否再跳 `/chat`。
|
||||||
|
*
|
||||||
|
* 这里**不**做鉴权重定向:Server 端读不到 localStorage,且当前没有 HttpOnly
|
||||||
|
* cookie(见 `src/data/storage/auth/auth_storage.ts` 的 TODO)。
|
||||||
|
* `src/proxy.ts` 已为未来 cookie 化预留了基于 `login_token` cookie 的 short-circuit。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { ROUTES } from "@/lib/routes";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
redirect(ROUTES.splash);
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar 页(路由骨架版)
|
||||||
|
*
|
||||||
|
* 对齐 Flutter `lib/ui/sidebar/sidebar_screen.dart` 的 `onBackPressed` 行为:
|
||||||
|
* 顶部一个"返回 /chat"按钮。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { ROUTES } from "@/lib/routes";
|
||||||
|
|
||||||
|
export default function SidebarPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="flex flex-1 flex-col items-center justify-center gap-xl p-lg"
|
||||||
|
style={{
|
||||||
|
minHeight: "100dvh",
|
||||||
|
background: "var(--color-sidebar-background)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 className="text-3xl font-semibold">Sidebar</h1>
|
||||||
|
<p
|
||||||
|
className="max-w-md text-center text-sm"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
Sidebar placeholder · profile / settings coming soon.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(ROUTES.chat)}
|
||||||
|
className="rounded-md px-xl py-sm text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-accent)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back to Chat
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splash 页(路由骨架版)
|
||||||
|
*
|
||||||
|
* 对齐 Flutter `lib/ui/splash/splash_screen.dart` 的语义:
|
||||||
|
* - 已登录用户:自动跳 `/chat`(替代 Flutter 的 `BlocListener<AuthBloc>`)
|
||||||
|
* - 未登录:渲染品牌占位 + Skip 按钮 + Facebook 登录占位
|
||||||
|
*
|
||||||
|
* 占位 UI 不复刻 Flutter 视觉(待 `src/components/` 落地后再做),仅做路由行为验证。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { useAuthGate } from "@/lib/auth/use-auth-gate";
|
||||||
|
import { ROUTES } from "@/lib/routes";
|
||||||
|
|
||||||
|
export default function SplashPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAuthed } = useAuthGate();
|
||||||
|
|
||||||
|
// 已登录 → 自动跳 /chat。
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthed) {
|
||||||
|
router.replace(ROUTES.chat);
|
||||||
|
}
|
||||||
|
}, [isAuthed, router]);
|
||||||
|
|
||||||
|
// `useAuthGate` 在 SSR / 客户端水合首帧都返回 `isAuthed=false`,
|
||||||
|
// 因此这里会先渲染 spinner 占位(也避免水合闪屏),等水合完成、
|
||||||
|
// React 自动重读 localStorage 拿到真值后,若仍为 false 则展示下方 UI。
|
||||||
|
if (!isAuthed) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="flex flex-1 flex-col items-center justify-center gap-xl p-lg"
|
||||||
|
style={{
|
||||||
|
minHeight: "100dvh",
|
||||||
|
background: "var(--color-sidebar-background)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 className="text-3xl font-semibold">cozsweet</h1>
|
||||||
|
<p
|
||||||
|
className="max-w-md text-center text-sm"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
Splash placeholder · authed → {String(isAuthed)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-md">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(ROUTES.chat)}
|
||||||
|
className="rounded-md px-xl py-sm text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-accent)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className="rounded-md px-xl py-sm text-sm opacity-60"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-facebook-blue)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
cursor: "not-allowed",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue with Facebook
|
||||||
|
{/* TODO: 接入 facebookLogin 后启用 */}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录:上面的 useEffect 会触发 router.replace;这里短暂渲染占位。
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-1 items-center justify-center"
|
||||||
|
style={{ minHeight: "100dvh" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-label="Redirecting"
|
||||||
|
className="size-10 animate-spin rounded-full border-4 border-t-transparent"
|
||||||
|
style={{ borderColor: "var(--color-accent)" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 纯函数路由守卫
|
||||||
|
*
|
||||||
|
* 不依赖 React/Next.js,可被 `src/proxy.ts`(边缘运行时)与
|
||||||
|
* `src/lib/auth/use-auth-gate.tsx`(客户端 hook)共用。
|
||||||
|
*
|
||||||
|
* 对齐 Flutter `lib/router/app_router.dart` 中 `_handleAuthRedirect` 的语义:
|
||||||
|
* 已登录用户访问 `/splash` 或 `/auth` 时,重定向到 `/chat`。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AUTH_ONLY_ROUTES, ROUTES, type StaticRoute } from "../routes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判定路径是否为"仅未登录态可见"的入口路由。
|
||||||
|
*/
|
||||||
|
export function isAuthOnlyRoute(pathname: string): pathname is StaticRoute {
|
||||||
|
return (AUTH_ONLY_ROUTES as readonly string[]).includes(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判定路径是否为公开路由(游客态也允许访问)。
|
||||||
|
*/
|
||||||
|
export function isPublicRoute(pathname: string): pathname is StaticRoute {
|
||||||
|
return pathname === ROUTES.chat || pathname === ROUTES.sidebar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已登录用户访问入口路由时返回应跳转到的目标;其余情况返回 null。
|
||||||
|
*
|
||||||
|
* 当前实现仅做"splash/auth → chat"的重定向;未来若新增入口路由(如 onboarding),
|
||||||
|
* 只需在此函数追加分支。
|
||||||
|
*/
|
||||||
|
export function resolveAuthedRedirect(pathname: string): string | null {
|
||||||
|
if (isAuthOnlyRoute(pathname)) return ROUTES.chat;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端鉴权门 hook
|
||||||
|
*
|
||||||
|
* 单一真值 = `authStorage`(与 `src/data/api/interceptor/token_interceptor.ts` 同源)。
|
||||||
|
* 故意不引入 Context/Zustand:本轮只有 splash/auth 两处使用,直接 hook 调用更直白。
|
||||||
|
*
|
||||||
|
* 实现要点(React 19 / Next.js 16 约定):
|
||||||
|
* - 用 `useSyncExternalStore` 而非 `useEffect + setState`,避免 React 19
|
||||||
|
* `react-hooks/set-state-in-effect` 规则的级联渲染告警。
|
||||||
|
* - `getServerSnapshot` 返回 `false` → SSR 与客户端水合首帧都拿到一致值;
|
||||||
|
* 水合完成后 React 自动重读 `getSnapshot()`(localStorage 真值),触发单次
|
||||||
|
* 自然重渲染 → 调用方 `useEffect([isAuthed], ...)` 即捕获"已登录"事件。
|
||||||
|
* - subscribe 用空函数(no-op):localStorage 在本应用生命周期内不会主动变化
|
||||||
|
* (登录跳转走的是另一条 navigation 路径,重新进入 splash 时会重读)。
|
||||||
|
*
|
||||||
|
* 使用模式:
|
||||||
|
* const { isAuthed } = useAuthGate();
|
||||||
|
* useEffect(() => { if (isAuthed) router.replace(ROUTES.chat); }, [isAuthed]);
|
||||||
|
* if (!isAuthed) return <Placeholder />; // SSR / 水合首帧 / 未登录 都会落在这
|
||||||
|
*
|
||||||
|
* 注:本轮不暴露 `ready` 标志——`useSyncExternalStore` 自身的 SSR / 客户端差异
|
||||||
|
* 处理已经能保证水合首帧拿到 `false`(与 SSR 一致),无需调用方再判 ready。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
import { authStorage } from "@/data/storage/auth_storage";
|
||||||
|
|
||||||
|
export interface AuthGateState {
|
||||||
|
/** 已登录(持有非空 `cozsweet.loginToken`) */
|
||||||
|
isAuthed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 静态 subscribe:localStorage 在本应用生命周期内不会主动变化。
|
||||||
|
const subscribe = (): (() => void) => () => {};
|
||||||
|
|
||||||
|
const getSnapshot = (): boolean => authStorage.hasLoginToken();
|
||||||
|
|
||||||
|
const getServerSnapshot = (): boolean => false;
|
||||||
|
|
||||||
|
export function useAuthGate(): AuthGateState {
|
||||||
|
const isAuthed = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
|
return { isAuthed };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* `src/lib/` 手写 barrel
|
||||||
|
*
|
||||||
|
* 注意:故意**不**把 `src/lib/auth/use-auth-gate.tsx`(Client-only)放进来,
|
||||||
|
* 避免 Server bundle 误把 React 客户端代码拉进去。
|
||||||
|
* 如需导入 hook,请直接 `import { useAuthGate } from "@/lib/auth/use-auth-gate";`。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./routes";
|
||||||
|
export * from "./auth/route-guards";
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 类型化路由常量
|
||||||
|
*
|
||||||
|
* 集中管理应用中使用的路由路径,与 Flutter `lib/router/app_router.dart` 的
|
||||||
|
* `AppRoutes` 一一对应。被服务端组件、客户端组件、`src/proxy.ts` 三端共用。
|
||||||
|
*
|
||||||
|
* 关键设计:
|
||||||
|
* - `as const` 让每个值都是字符串字面量类型,可用于 `PageProps<'/literal'>`。
|
||||||
|
* - 不导出 Client-only 模块(`use-auth-gate` 走独立路径导入),保证 Server bundle 不被污染。
|
||||||
|
* - 动态深链通过 `ROUTE_BUILDERS` 函数生成,避免手拼字符串。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 静态路由字面量 */
|
||||||
|
export const ROUTES = {
|
||||||
|
root: "/",
|
||||||
|
splash: "/splash",
|
||||||
|
chat: "/chat",
|
||||||
|
auth: "/auth",
|
||||||
|
sidebar: "/sidebar",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
|
||||||
|
|
||||||
|
/** 动态深链构造器 */
|
||||||
|
export const ROUTE_BUILDERS = {
|
||||||
|
chatDeviceId: (deviceId: string): `/chat/deviceid/${string}` =>
|
||||||
|
`/chat/deviceid/${encodeURIComponent(deviceId)}` as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 仅未登录态可见的路由(命中后已登录用户应跳走) */
|
||||||
|
export const AUTH_ONLY_ROUTES: readonly StaticRoute[] = [
|
||||||
|
ROUTES.splash,
|
||||||
|
ROUTES.auth,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 公开路由(游客态也允许访问) */
|
||||||
|
export const PUBLIC_ROUTES: readonly StaticRoute[] = [
|
||||||
|
ROUTES.chat,
|
||||||
|
ROUTES.sidebar,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 所有静态路由,供 `proxy.ts` matcher 与未来 sitemap 使用 */
|
||||||
|
export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
|
||||||
|
ROUTES.splash,
|
||||||
|
ROUTES.chat,
|
||||||
|
ROUTES.auth,
|
||||||
|
ROUTES.sidebar,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** 联合路由类型,包含静态路由与已知动态路由 */
|
||||||
|
export type Route = StaticRoute | `/chat/deviceid/${string}`;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根级 Client Providers 包装
|
||||||
|
*
|
||||||
|
* 本轮只做两件事:
|
||||||
|
* 1. 透传 `children`,让 `app/layout.tsx` 保持 Server Component 的同时,
|
||||||
|
* 在子树中按需渲染 Client-only 组件(如未来 `<AuthContextProvider>`)。
|
||||||
|
* 2. 预置一个 `<div id="toast-portal" />` 挂载点,供后续 toast 系统
|
||||||
|
* 通过 `createPortal(..., document.getElementById("toast-portal"))` 渲染。
|
||||||
|
*
|
||||||
|
* 不引入任何运行时依赖;不挂载任何 Context。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface RootProvidersProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RootProviders({ children }: RootProvidersProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<div id="toast-portal" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Next.js 16 Proxy(原 `middleware.ts`)
|
||||||
|
*
|
||||||
|
* 行为:
|
||||||
|
* - 当请求携带 `login_token` cookie 且命中 `/splash` 或 `/auth` 时,308 重定向到 `/chat`。
|
||||||
|
* - 其他情况透传(`NextResponse.next()`)。
|
||||||
|
*
|
||||||
|
* 重要约束:
|
||||||
|
* - **不**对 `/chat` 做反向重定向:游客态是合法用户(`auth_refresh_interceptor`
|
||||||
|
* 同时处理登录与游客 token),proxy 端无法识别 `guest_token`。
|
||||||
|
* - 当前没有 `login_token` cookie 被写入(token 走 localStorage),proxy 实际是 no-op;
|
||||||
|
* 保留骨架便于未来 HttpOnly Cookie 迁移(`src/data/storage/auth/auth_storage.ts`
|
||||||
|
* 的 TODO 标记)时无需重构 proxy。
|
||||||
|
* - 边缘代理**读不到 localStorage**,因此真正的鉴权门是 Client 侧的
|
||||||
|
* `useAuthGate` + `authStorage.hasLoginToken()`,proxy 只是 CDN 友好的"乐观"快路径。
|
||||||
|
*
|
||||||
|
* Next 16 重要变更(参见 `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`):
|
||||||
|
* - 文件从 `middleware.ts` 重命名为 `proxy.ts`;`middleware` 仍可用但已 deprecated。
|
||||||
|
* - 默认 Node.js runtime;**不**支持 `runtime` 配置(设置会抛错)。
|
||||||
|
* - 函数导出名建议为 `proxy`(可默认导出)。
|
||||||
|
*
|
||||||
|
* 用 `src/` 时 proxy 必须放在 `src/` 内(参见 `src-folder.md`)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { resolveAuthedRedirect } from "@/lib/auth/route-guards";
|
||||||
|
|
||||||
|
const LOGIN_COOKIE = "login_token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy 入口
|
||||||
|
*/
|
||||||
|
export function proxy(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
const hasLoginCookie = Boolean(request.cookies.get(LOGIN_COOKIE)?.value);
|
||||||
|
|
||||||
|
if (hasLoginCookie) {
|
||||||
|
const target = resolveAuthedRedirect(pathname);
|
||||||
|
if (target) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = target;
|
||||||
|
// 308 = 永久。注销后若浏览器/CDN 缓存了 308,可能黏住。
|
||||||
|
// 若担心可改 307(临时);本轮按 plan 口径用 308 并在 plan 中记录风险。
|
||||||
|
return NextResponse.redirect(url, 308);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 匹配器:排除 API、Next 静态资源、图标与常见静态文件扩展名。
|
||||||
|
* 不排除 `_next/data`,因为 proxy 文档明确说明"即使在排除列表中 proxy 仍会跑"
|
||||||
|
* (安全兜底)。
|
||||||
|
*/
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/((?!api|_next/static|_next/image|_next/data|favicon.ico|icons|.*\\.(?:png|jpg|jpeg|gif|svg|webp|ico|css|js|woff2?|ttf|otf)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user