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:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
/**
|
||||
* 通用 Dialog 基础组件(headless + 简单样式)
|
||||
*
|
||||
* 原始 Dart: 散落在各处的 `showDialog` + `Dialog` widget(quota_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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user