feat(ui): migrate Flutter UI library to Next.js 16 (App Router)

Migrate all 87 Dart files from /Users/chase/Documents/cozsweet/lib/ui/
to TypeScript React components, replacing Flutter-native features with
Web equivalents and reimplementing the BLoC pattern with React
Context + useReducer.

Highlights
- 1:1 BLoC -> Context+useReducer+side-effects mapping for auth, chat,
  user, and sidebar features (4 providers wired in root-providers.tsx)
- 500px MobileShell primitive eliminates per-screen layout boilerplate
- Headless Dialog primitive (createPortal + ESC + scroll lock) powers
  quota / pwa-install / username / pronouns / subscription / other-options
  dialogs
- New integrations/: facebook-sdk, google-identity, pwa-install,
  media-recorder (Web Speech API + MediaRecorder), browser-detect,
  chat-websocket (singleton), message-queue (throttled send)
- CSS Modules + existing design tokens (no new styling system);
  extended with breakpoints.css and dimensions.css
- Custom hooks: useBreakpoint (useSyncExternalStore),
  useFullVisibility (IntersectionObserver), usePwaInstall,
  usePullToRefresh
- Vitest + jsdom; 15 unit tests (chat-reducer, auth-validators)
- React 19.2.4, Next.js 16.2.7, Zod 4, lottie-react, classnames

Workarounds
- next.config.ts: experimental.staticGenerationRetryCount = 0 to
  bypass Next.js 16.2.7 /_global-error workStore prerender bug
- src/app/global-error.tsx: required Client Component
- src/types/globals.d.ts: centralized window.google / FB /
  SpeechRecognition declarations

Routes (/, /splash, /auth, /chat, /chat/deviceid/[deviceId],
/sidebar) all build and lint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-09 13:30:54 +08:00
parent b362945195
commit 8cab34864e
114 changed files with 6744 additions and 276 deletions
@@ -0,0 +1,22 @@
.button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: var(--radius-full);
background: transparent;
color: var(--color-text-primary);
cursor: pointer;
transition: background 0.15s ease;
}
.button:hover {
background: rgba(255, 255, 255, 0.08);
}
.button:focus-visible {
outline: var(--border-medium) solid var(--color-accent);
outline-offset: 2px;
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
/**
* 通用返回按钮
*
* 原始 Dart: lib/ui/auth/widgets/back_button.dart(被 auth/chat/sidebar 共用)。
*/
import { useRouter } from "next/navigation";
import { type MouseEvent } from "react";
import styles from "./auth-back-button.module.css";
export interface AuthBackButtonProps {
/** 自定义跳转目标(默认 history.back)。 */
href?: string;
/** 自定义点击处理(覆盖默认跳转)。 */
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
className?: string;
ariaLabel?: string;
}
export function AuthBackButton({
href,
onClick,
className,
ariaLabel = "Back",
}: AuthBackButtonProps) {
const router = useRouter();
return (
<button
type="button"
aria-label={ariaLabel}
className={[styles.button, className].filter(Boolean).join(" ")}
onClick={(e) => {
if (onClick) {
onClick(e);
return;
}
if (href) router.push(href);
else router.back();
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M15 18l-6-6 6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
);
}
+20
View File
@@ -0,0 +1,20 @@
.scrim {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md);
}
.panel {
position: relative;
width: 100%;
background: var(--color-dialog-background);
border: var(--border-light) solid var(--color-dialog-border);
border-radius: var(--radius-xl);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.45);
color: var(--color-text-primary);
overflow: hidden;
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
/**
* 通用 Dialog 基础组件(headless + 简单样式)
*
* 原始 Dart: 散落在各处的 `showDialog` + `Dialog` widgetquota_dialog, pwa_install_dialog,
* username_dialog, pronouns_dialog, subscription_dialog, environment_dialog, auth_other_options_dialog,
* facebook_login_dialog, external_browser_dialog)。
*
* 设计目标:~70 行覆盖所有 9 种 dialog 的通用逻辑(portal, ESC, scroll lock, click-outside),
* 调用方只关心内容布局。
*
* 注意:项目不引入 Radix/shadcn,纯依赖 React + CSS Modules。
*/
import { type ReactNode, useEffect } from "react";
import { createPortal } from "react-dom";
import styles from "./dialog.module.css";
export interface DialogProps {
open: boolean;
onClose: () => void;
children: ReactNode;
/** 内容最大宽度(px)。默认 360。 */
maxWidth?: number;
/** 蒙层透明度(0-1)。默认 0.5。 */
scrimOpacity?: number;
/** 禁用点击外部 / ESC 关闭。 */
persistent?: boolean;
/** ARIA 标签。 */
ariaLabel?: string;
}
export function Dialog({
open,
onClose,
children,
maxWidth = 360,
scrimOpacity = 0.5,
persistent = false,
ariaLabel,
}: DialogProps) {
useEffect(() => {
if (!open || typeof document === "undefined") return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !persistent) onClose();
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose, persistent]);
if (!open || typeof document === "undefined") return null;
return createPortal(
<div
className={styles.scrim}
style={{ background: `rgba(0, 0, 0, ${scrimOpacity})` }}
onClick={(e) => {
if (!persistent && e.target === e.currentTarget) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
className={styles.panel}
style={{ maxWidth }}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,26 @@
.wrapper {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-sm);
}
.spinner {
display: inline-block;
border-style: solid;
border-color: var(--color-text-secondary) transparent transparent
transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.label {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
/**
* 通用加载指示器
*
* 原始 Dart: lib/ui/core/loading_indicator.dart
*
* 行为对齐 Flutter `CircularProgressIndicator`
* - `size`:圆圈直径(默认 32
* - `strokeWidth`:环宽(默认 3
* - `color`:环颜色(默认 `--color-accent`
* - `label`:可选的可视标签(环形下方文本)
*/
import styles from "./loading-indicator.module.css";
export interface LoadingIndicatorProps {
size?: number;
strokeWidth?: number;
color?: string;
label?: string;
}
export function LoadingIndicator({
size = 32,
strokeWidth = 3,
color,
label,
}: LoadingIndicatorProps) {
const style: React.CSSProperties = {
width: size,
height: size,
borderWidth: strokeWidth,
...(color ? { borderTopColor: color } : {}),
};
return (
<div className={styles.wrapper} role="status" aria-label="Loading">
<div className={styles.spinner} style={style} />
{label ? <span className={styles.label}>{label}</span> : null}
</div>
);
}
@@ -0,0 +1,19 @@
.shell {
display: flex;
flex: 1 1 auto;
align-items: stretch;
justify-content: center;
width: 100%;
min-height: 100dvh;
background: inherit;
}
.content {
position: relative;
display: flex;
flex: 1 1 auto;
flex-direction: column;
width: 100%;
max-width: 500px;
min-height: 100dvh;
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
/**
* 500px 移动端 Shell
*
* 原始 Dart: 每个屏幕的 `Scaffold → SafeArea → Center → ConstrainedBox(maxWidth: 500)` 模式
* `Breakpoints.mobileMaxWidth = 500.0`)。
*
* 集中封装,调用方仅需 `<MobileShell>{...}</MobileShell>` 即可获得统一的移动端宽度约束。
*/
import type { ReactNode } from "react";
import styles from "./mobile-shell.module.css";
export interface MobileShellProps {
children: ReactNode;
/** 自定义背景色(默认透明,继承 body 背景)。 */
background?: string;
/** 自定义 className(注入到内容容器上)。 */
className?: string;
}
export function MobileShell({
children,
background,
className,
}: MobileShellProps) {
return (
<div className={styles.shell}>
<div
className={[styles.content, className].filter(Boolean).join(" ")}
style={background ? { background } : undefined}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,101 @@
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.title {
margin: 0;
padding: 0 var(--spacing-md);
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-section-title);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.list {
margin: 0;
padding: 0;
list-style: none;
background: var(--color-settings-card-background);
border-radius: var(--radius-lg);
overflow: hidden;
}
.row {
display: flex;
align-items: center;
gap: var(--spacing-md);
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
border: none;
background: transparent;
color: var(--color-settings-text-primary);
font-size: var(--font-size-md);
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.row:hover {
background: rgba(255, 255, 255, 0.04);
}
.row:focus-visible {
outline: var(--border-medium) solid var(--color-accent);
outline-offset: -2px;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--color-chevron-icon);
flex-shrink: 0;
}
.body {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1 1 auto;
min-width: 0;
}
.rowTitle {
font-size: var(--font-size-md);
font-weight: 500;
color: var(--color-settings-text-primary);
}
.subtitle {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.trailing {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
flex-shrink: 0;
}
.chevron {
color: var(--color-chevron-icon);
flex-shrink: 0;
}
.divider {
height: var(--border-light);
background: var(--color-settings-divider);
margin: 0 var(--spacing-lg);
}
.destructive {
color: var(--color-destructive);
}
.destructive .rowTitle {
color: var(--color-destructive);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
/**
* 设置项列表 Section
*
* 原始 Dart: lib/ui/core/settings_section.dart
*
* 渲染一组 SettingItem:图标 + 标题 + 副标题/值 + 右侧 chevron + 可选 divider。
*/
import { type ReactNode } from "react";
import styles from "./settings-section.module.css";
export interface SettingsItemModel {
id: string;
title: string;
subtitle?: string;
trailing?: string;
icon?: ReactNode;
onClick?: () => void;
destructive?: boolean;
}
export interface SettingsSectionProps {
title?: string;
items: SettingsItemModel[];
}
export function SettingsSection({ title, items }: SettingsSectionProps) {
return (
<section className={styles.section}>
{title ? <h3 className={styles.title}>{title}</h3> : null}
<ul className={styles.list}>
{items.map((item, idx) => (
<li key={item.id}>
<button
type="button"
onClick={item.onClick}
className={[
styles.row,
item.destructive ? styles.destructive : "",
]
.filter(Boolean)
.join(" ")}
>
{item.icon ? (
<span className={styles.icon}>{item.icon}</span>
) : null}
<span className={styles.body}>
<span className={styles.rowTitle}>{item.title}</span>
{item.subtitle ? (
<span className={styles.subtitle}>{item.subtitle}</span>
) : null}
</span>
{item.trailing ? (
<span className={styles.trailing}>{item.trailing}</span>
) : null}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className={styles.chevron}
>
<path
d="M9 6l6 6-6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{idx < items.length - 1 ? (
<div className={styles.divider} />
) : null}
</li>
))}
</ul>
</section>
);
}