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