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:
2026-06-09 10:16:39 +08:00
parent a240f5965d
commit c767322db6
17 changed files with 750 additions and 63 deletions
@@ -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>
);
}
+26
View File
@@ -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} />;
}
+53
View File
@@ -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>
);
}