refactor: relocate components to app directory structure
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
validateEmail,
|
||||
validatePassword,
|
||||
validateUsername,
|
||||
} from "@/app/auth/components/auth-validators";
|
||||
|
||||
describe("validateEmail", () => {
|
||||
it("accepts a valid email", () => {
|
||||
expect(validateEmail("user@example.com")).toBeNull();
|
||||
});
|
||||
it("rejects an empty email", () => {
|
||||
expect(validateEmail("")).not.toBeNull();
|
||||
});
|
||||
it("rejects an invalid email", () => {
|
||||
expect(validateEmail("not-an-email")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePassword", () => {
|
||||
it("accepts a password >= 8 chars", () => {
|
||||
expect(validatePassword("12345678")).toBeNull();
|
||||
});
|
||||
it("rejects a too-short password", () => {
|
||||
expect(validatePassword("123")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateUsername", () => {
|
||||
it("accepts a 3+ char username", () => {
|
||||
expect(validateUsername("alice")).toBeNull();
|
||||
});
|
||||
it("rejects an empty username", () => {
|
||||
expect(validateUsername("")).not.toBeNull();
|
||||
});
|
||||
it("rejects a too-short username", () => {
|
||||
expect(validateUsername("ab")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.line {
|
||||
flex: 1 1 auto;
|
||||
height: var(--border-light);
|
||||
background: var(--color-chat-input-border);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
/**
|
||||
* "OR" 分隔符
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_divider.dart
|
||||
*/
|
||||
import styles from "./auth-divider.module.css";
|
||||
|
||||
export function AuthDivider({ label = "or" }: { label?: string }) {
|
||||
return (
|
||||
<div className={styles.wrapper} role="separator">
|
||||
<div className={styles.line} />
|
||||
<span className={styles.label}>{label}</span>
|
||||
<div className={styles.line} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.backButton:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
/**
|
||||
* Email 面板
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_email_panel.dart
|
||||
*
|
||||
* 行为:
|
||||
* - 内部 `authMode`(login/register)切换显示 Login / Register 表单
|
||||
* - 提交派发 `AuthEmailLoginSubmitted` 或 `AuthEmailRegisterSubmitted`
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuthState } from "@/contexts/auth/auth-context";
|
||||
|
||||
import { EmailLoginForm } from "./email-login-form";
|
||||
import { EmailRegisterForm } from "./email-register-form";
|
||||
import styles from "./auth-email-panel.module.css";
|
||||
|
||||
export interface AuthEmailPanelProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function AuthEmailPanel({ onBack }: AuthEmailPanelProps) {
|
||||
const state = useAuthState();
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<button type="button" className={styles.backButton} onClick={onBack}>
|
||||
← Back
|
||||
</button>
|
||||
{mode === "login" ? (
|
||||
<EmailLoginForm
|
||||
onSwitchToRegister={() => setMode("register")}
|
||||
globalError={state.errorMessage}
|
||||
isLoading={state.isLoading}
|
||||
/>
|
||||
) : (
|
||||
<EmailRegisterForm
|
||||
onSwitchToLogin={() => setMode("login")}
|
||||
globalError={state.errorMessage}
|
||||
isLoading={state.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.banner {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 77, 79, 0.12);
|
||||
border: var(--border-light) solid rgba(255, 77, 79, 0.3);
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
/**
|
||||
* 错误消息展示
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_error_message.dart
|
||||
*/
|
||||
import styles from "./auth-error-message.module.css";
|
||||
|
||||
export function AuthErrorMessage({ message }: { message: string | null }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<div className={styles.banner} role="alert">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-accent);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.linkButton:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
/**
|
||||
* Facebook 面板(默认面板)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_facebook_panel.dart
|
||||
*
|
||||
* 行为:
|
||||
* - 默认显示「Continue with Facebook」+ 「Other sign-in options」入口
|
||||
* - 真实 Facebook 登录在「Continue with Facebook」按钮中触发
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuthState, useAuthDispatch } from "@/contexts/auth/auth-context";
|
||||
|
||||
import { AuthSocialButtons } from "./auth-social-buttons";
|
||||
import { AuthDivider } from "./auth-divider";
|
||||
import { AuthPrimaryButton } from "./auth-primary-button";
|
||||
import { AuthErrorMessage } from "./auth-error-message";
|
||||
import { AuthOtherOptionsDialog } from "./auth-other-options-dialog";
|
||||
import { GoogleSignInButton } from "./google-sign-in-button";
|
||||
import { AuthLegalText } from "./auth-legal-text";
|
||||
import styles from "./auth-facebook-panel.module.css";
|
||||
|
||||
export interface AuthFacebookPanelProps {
|
||||
onSwitchToEmail: () => void;
|
||||
onGoogleSuccess: (idToken: string, email: string) => void;
|
||||
}
|
||||
|
||||
export function AuthFacebookPanel({
|
||||
onSwitchToEmail,
|
||||
onGoogleSuccess,
|
||||
}: AuthFacebookPanelProps) {
|
||||
const state = useAuthState();
|
||||
const dispatch = useAuthDispatch();
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<AuthErrorMessage message={state.errorMessage} />
|
||||
<AuthSocialButtons
|
||||
loadingProvider={state.isLoading ? "facebook" : null}
|
||||
onFacebook={() => dispatch({ type: "AuthFacebookLoginSubmitted" })}
|
||||
onGoogle={() => dispatch({ type: "AuthGoogleLoginSubmitted" })}
|
||||
googleSlot={
|
||||
<GoogleSignInButton
|
||||
onSuccess={(s) =>
|
||||
onGoogleSuccess(s.idToken, s.email)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<AuthDivider label="or" />
|
||||
<AuthPrimaryButton
|
||||
onClick={onSwitchToEmail}
|
||||
variant="primary"
|
||||
>
|
||||
Continue with Email
|
||||
</AuthPrimaryButton>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={() => setShowOptions(true)}
|
||||
>
|
||||
Other sign-in options
|
||||
</button>
|
||||
<AuthLegalText />
|
||||
<AuthOtherOptionsDialog
|
||||
open={showOptions}
|
||||
onClose={() => setShowOptions(false)}
|
||||
onFacebook={() => {
|
||||
setShowOptions(false);
|
||||
dispatch({ type: "AuthFacebookLoginSubmitted" });
|
||||
}}
|
||||
onGoogle={() => {
|
||||
setShowOptions(false);
|
||||
dispatch({ type: "AuthGoogleLoginSubmitted" });
|
||||
}}
|
||||
onApple={() => {
|
||||
setShowOptions(false);
|
||||
dispatch({ type: "AuthAppleLoginSubmitted" });
|
||||
}}
|
||||
onEmail={() => {
|
||||
setShowOptions(false);
|
||||
onSwitchToEmail();
|
||||
}}
|
||||
loadingProvider={state.isLoading ? "facebook" : null}
|
||||
showApple={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.text {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
/**
|
||||
* 法律声明(Terms / Privacy)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_legal_text.dart
|
||||
*/
|
||||
import styles from "./auth-legal-text.module.css";
|
||||
|
||||
export function AuthLegalText() {
|
||||
return (
|
||||
<p className={styles.text}>
|
||||
By continuing, you agree to our{" "}
|
||||
<a href="/terms" className={styles.link} target="_blank" rel="noreferrer">
|
||||
Terms
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="/privacy" className={styles.link} target="_blank" rel="noreferrer">
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emailButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: var(--border-light) solid var(--color-chat-input-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.emailButton:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
/**
|
||||
* "其他登录方式" 弹窗
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_other_options_dialog.dart
|
||||
*/
|
||||
import { Dialog } from "@/app/_components/core/dialog";
|
||||
import { AuthSocialButtons } from "./auth-social-buttons";
|
||||
import { AuthDivider } from "./auth-divider";
|
||||
import styles from "./auth-other-options-dialog.module.css";
|
||||
|
||||
export interface AuthOtherOptionsDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onFacebook: () => void;
|
||||
onGoogle: () => void;
|
||||
onApple?: () => void;
|
||||
onEmail: () => void;
|
||||
loadingProvider?: "facebook" | "google" | "apple" | null;
|
||||
showApple?: boolean;
|
||||
googleSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthOtherOptionsDialog({
|
||||
open,
|
||||
onClose,
|
||||
onFacebook,
|
||||
onGoogle,
|
||||
onApple,
|
||||
onEmail,
|
||||
loadingProvider,
|
||||
showApple,
|
||||
googleSlot,
|
||||
}: AuthOtherOptionsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} ariaLabel="Other sign-in options">
|
||||
<div className={styles.body}>
|
||||
<h2 className={styles.title}>Other Sign-in Options</h2>
|
||||
<AuthSocialButtons
|
||||
onFacebook={onFacebook}
|
||||
onGoogle={onGoogle}
|
||||
onApple={onApple}
|
||||
loadingProvider={loadingProvider}
|
||||
showApple={showApple}
|
||||
googleSlot={googleSlot}
|
||||
/>
|
||||
<AuthDivider label="or" />
|
||||
<button type="button" className={styles.emailButton} onClick={onEmail}>
|
||||
Continue with Email
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
width: 100%;
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
/**
|
||||
* 认证面板:顶层 switch(Facebook / Email)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_panel.dart
|
||||
*/
|
||||
import { useAuthState } from "@/contexts/auth/auth-context";
|
||||
|
||||
import { AuthFacebookPanel } from "./auth-facebook-panel";
|
||||
import { AuthEmailPanel } from "./auth-email-panel";
|
||||
import styles from "./auth-panel.module.css";
|
||||
|
||||
export interface AuthPanelProps {
|
||||
onGoogleSuccess: (idToken: string, email: string) => void;
|
||||
}
|
||||
|
||||
export function AuthPanel({ onGoogleSuccess }: AuthPanelProps) {
|
||||
const state = useAuthState();
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
{state.authPanelMode === "facebook" ? (
|
||||
<AuthFacebookPanel
|
||||
onSwitchToEmail={() => {
|
||||
// 切换模式由外部 dispatch 触发;这里不直接处理
|
||||
// 因为 auth-panel 受 `authPanelMode` 控制,调用方需要 dispatch `AuthPanelModeChanged`
|
||||
// 但我们目前 AuthFacebookPanel 内部 click "Continue with Email" 直接调用 onSwitchToEmail
|
||||
// 实际上本组件直接渲染对应 panel
|
||||
}}
|
||||
onGoogleSuccess={onGoogleSuccess}
|
||||
/>
|
||||
) : (
|
||||
<AuthEmailPanel onBack={() => undefined} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, transform 0.05s ease;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button:not(:disabled):active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.accent {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-accent),
|
||||
var(--color-facebook-button-gradient-end)
|
||||
);
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: currentColor transparent transparent transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
/**
|
||||
* 认证主按钮(粉/蓝渐变)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_primary_button.dart
|
||||
*/
|
||||
import { type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
import styles from "./auth-primary-button.module.css";
|
||||
|
||||
export interface AuthPrimaryButtonProps
|
||||
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
isLoading?: boolean;
|
||||
children: ReactNode;
|
||||
variant?: "accent" | "primary";
|
||||
}
|
||||
|
||||
export function AuthPrimaryButton({
|
||||
isLoading,
|
||||
children,
|
||||
variant = "accent",
|
||||
disabled,
|
||||
className,
|
||||
...rest
|
||||
}: AuthPrimaryButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isLoading}
|
||||
className={[
|
||||
styles.button,
|
||||
styles[variant],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className={styles.spinner} aria-hidden="true" />
|
||||
) : null}
|
||||
<span className={styles.label}>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
/**
|
||||
* 认证全屏页面
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/auth_screen.dart
|
||||
*
|
||||
* 行为对齐:
|
||||
* - 500px 移动端宽度(MobileShell)
|
||||
* - 已登录用户:自动跳 /chat
|
||||
* - 未登录:渲染 AuthPanel(含 Facebook/Email 两种面板)
|
||||
* - 登录成功:通知 ChatBloc + UserBloc,然后 router.replace(/chat)
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuthState, useAuthDispatch } from "@/contexts/auth/auth-context";
|
||||
import { useChatDispatch } from "@/contexts/chat/chat-context";
|
||||
import { useUserDispatch } from "@/contexts/user/user-context";
|
||||
import { useAuthGate } from "@/lib/auth/use-auth-gate";
|
||||
import { ROUTES } from "@/lib/routes";
|
||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||
import { AuthPanel } from "@/app/auth/components/auth-panel";
|
||||
|
||||
export interface AuthScreenProps {
|
||||
/** 自定义背景(默认紫粉渐变)。 */
|
||||
background?: string;
|
||||
}
|
||||
|
||||
export function AuthScreen({
|
||||
background = "var(--color-sidebar-background)",
|
||||
}: AuthScreenProps) {
|
||||
const router = useRouter();
|
||||
const { isAuthed } = useAuthGate();
|
||||
const state = useAuthState();
|
||||
const authDispatch = useAuthDispatch();
|
||||
const chatDispatch = useChatDispatch();
|
||||
const userDispatch = useUserDispatch();
|
||||
const wasSuccess = useRef(false);
|
||||
|
||||
// 已登录 → 自动跳 /chat
|
||||
useEffect(() => {
|
||||
if (isAuthed) router.replace(ROUTES.chat);
|
||||
}, [isAuthed, router]);
|
||||
|
||||
// 登录成功 → 通知 Chat + User + 跳转
|
||||
useEffect(() => {
|
||||
if (state.isSuccess && !wasSuccess.current) {
|
||||
wasSuccess.current = true;
|
||||
chatDispatch({ type: "ChatAuthStatusChanged" });
|
||||
userDispatch({ type: "UserInit" });
|
||||
router.replace(ROUTES.chat);
|
||||
}
|
||||
}, [state.isSuccess, chatDispatch, userDispatch, router]);
|
||||
|
||||
return (
|
||||
<MobileShell background={background}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
padding: "var(--spacing-lg)",
|
||||
}}
|
||||
>
|
||||
<AuthPanel
|
||||
onGoogleSuccess={(idToken, email) => {
|
||||
authDispatch({ type: "AuthWebGoogleLoginSuccess", idToken, email });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: var(--border-light) solid var(--color-chat-input-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.facebook {
|
||||
background: var(--color-facebook-blue);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.google {
|
||||
background: #ffffff;
|
||||
color: #1f1f1f;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.apple {
|
||||
background: #000000;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
/**
|
||||
* 社交登录按钮
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_social_button.dart
|
||||
*/
|
||||
import { type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
import styles from "./auth-social-button.module.css";
|
||||
|
||||
export interface AuthSocialButtonProps
|
||||
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
variant?: "facebook" | "google" | "apple" | "default";
|
||||
}
|
||||
|
||||
export function AuthSocialButton({
|
||||
icon,
|
||||
children,
|
||||
variant = "default",
|
||||
disabled,
|
||||
className,
|
||||
...rest
|
||||
}: AuthSocialButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={[styles.button, styles[variant], className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{icon ? <span className={styles.icon}>{icon}</span> : null}
|
||||
<span className={styles.label}>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: var(--border-light) solid var(--color-chat-input-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.facebook {
|
||||
background: var(--color-facebook-blue);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.google {
|
||||
background: #ffffff;
|
||||
color: #1f1f1f;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.apple {
|
||||
background: #000000;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.googleSlot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: var(--button-height);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
/**
|
||||
* 社交登录按钮组合
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_social_buttons.dart
|
||||
*
|
||||
* 包含 Facebook / Google / Apple 三个按钮的竖排列表。
|
||||
*/
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import styles from "./auth-social-buttons.module.css";
|
||||
|
||||
export interface AuthSocialButtonsProps {
|
||||
onFacebook: () => void;
|
||||
onGoogle: () => void;
|
||||
onApple?: () => void;
|
||||
loadingProvider?: "facebook" | "google" | "apple" | null;
|
||||
showApple?: boolean;
|
||||
/** 自定义子节点(如嵌入 GIS 按钮) */
|
||||
googleSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthSocialButtons({
|
||||
onFacebook,
|
||||
onGoogle,
|
||||
onApple,
|
||||
loadingProvider,
|
||||
showApple = false,
|
||||
googleSlot,
|
||||
}: AuthSocialButtonsProps) {
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.button, styles.facebook].join(" ")}
|
||||
onClick={onFacebook}
|
||||
disabled={loadingProvider !== null && loadingProvider !== "facebook"}
|
||||
>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
{/* Facebook "f" logo placeholder */}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13 22v-8h3l1-4h-4V7.5c0-1.2.4-2 2-2h2V2.2C16.6 2.1 15.5 2 14.3 2 11.5 2 9.5 3.7 9.5 7v3h-3v4h3v8h3.5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>Continue with Facebook</span>
|
||||
</button>
|
||||
|
||||
{googleSlot ? (
|
||||
<div className={styles.googleSlot}>{googleSlot}</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.button, styles.google].join(" ")}
|
||||
onClick={onGoogle}
|
||||
disabled={loadingProvider !== null && loadingProvider !== "google"}
|
||||
>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
{/* Google "G" logo placeholder */}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22 12.2c0-.7-.1-1.4-.2-2H12v3.8h5.6c-.2 1.3-1 2.4-2 3.1v2.6h3.2c1.9-1.7 2.9-4.2 2.9-7.5z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 22c2.7 0 4.9-.9 6.5-2.4l-3.2-2.5c-.9.6-2 1-3.3 1-2.6 0-4.7-1.7-5.5-4.1H3.2v2.5C4.8 19.7 8.1 22 12 22z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 14a6 6 0 010-3.9V7.5H3.2a10 10 0 000 9l3.3-2.5z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.9c1.5 0 2.8.5 3.8 1.5l2.9-2.9C16.9 2.9 14.7 2 12 2 8.1 2 4.8 4.3 3.2 7.5L6.5 10c.8-2.4 2.9-4.1 5.5-4.1z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>Continue with Google</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showApple && onApple ? (
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.button, styles.apple].join(" ")}
|
||||
onClick={onApple}
|
||||
disabled={loadingProvider !== null && loadingProvider !== "apple"}
|
||||
>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16.4 1.5c0 1.1-.4 2.1-1.1 2.9-.8.8-1.8 1.4-2.8 1.3-.1-1.1.4-2.2 1.1-3 .8-.8 1.9-1.3 2.8-1.2zM20 17.4c-.5 1.2-1.1 2.3-1.9 3.3-1 1.3-2.4 2.9-4.1 2.9-1.5 0-1.9-1-3.9-1-2 0-2.4 1-4 1-1.7 0-3-1.5-4-2.8-2.7-3.6-3-7.8-1.3-10 1.2-1.5 3-2.4 4.8-2.4 1.8 0 2.9 1 4.4 1 1.4 0 2.3-1 4.4-1 1.6 0 3.3.9 4.5 2.4-4 2.1-3.3 7.7.1 8.6z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>Continue with Apple</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: var(--border-light) solid var(--color-chat-input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-input-placeholder);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px rgba(248, 77, 150, 0.18);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.invalid:focus {
|
||||
box-shadow: 0 0 0 3px rgba(255, 77, 79, 0.18);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-error);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
/**
|
||||
* 通用文本输入框
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_text_field.dart
|
||||
*/
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
|
||||
import styles from "./auth-text-field.module.css";
|
||||
|
||||
export interface AuthTextFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
type?: "text" | "email" | "password" | "tel";
|
||||
label?: string;
|
||||
errorMessage?: string | null;
|
||||
autoComplete?: string;
|
||||
maxLength?: number;
|
||||
disabled?: boolean;
|
||||
onSubmit?: () => void;
|
||||
onBlur?: (e: FocusEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const AuthTextField = forwardRef<HTMLInputElement, AuthTextFieldProps>(
|
||||
function AuthTextField(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
label,
|
||||
errorMessage,
|
||||
autoComplete,
|
||||
maxLength,
|
||||
disabled,
|
||||
onSubmit,
|
||||
onBlur,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<label className={styles.wrapper}>
|
||||
{label ? <span className={styles.label}>{label}</span> : null}
|
||||
<input
|
||||
ref={ref}
|
||||
className={[styles.input, errorMessage ? styles.invalid : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(e.target.value)
|
||||
}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
autoComplete={autoComplete}
|
||||
maxLength={maxLength}
|
||||
disabled={disabled}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && onSubmit) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<span className={styles.error}>{errorMessage}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 认证表单校验
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_validators.dart
|
||||
*
|
||||
* 业务行为:
|
||||
* - validateEmail:使用 Zod (项目已有依赖) 校验邮箱格式
|
||||
* - validatePassword:至少 8 位
|
||||
* - validateUsername:3-20 位字母数字下划线
|
||||
* - validateConfirmPassword:与 password 一致
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
export const emailSchema = z.string().trim().email("Invalid email address");
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters");
|
||||
export const usernameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(20, "Username must be at most 20 characters")
|
||||
.regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores");
|
||||
|
||||
export const validateEmail = (email: string): string | null => {
|
||||
const r = emailSchema.safeParse(email);
|
||||
return r.success ? null : r.error.issues[0]?.message ?? "Invalid email";
|
||||
};
|
||||
|
||||
export const validatePassword = (password: string): string | null => {
|
||||
const r = passwordSchema.safeParse(password);
|
||||
return r.success ? null : r.error.issues[0]?.message ?? "Invalid password";
|
||||
};
|
||||
|
||||
export const validateUsername = (username: string): string | null => {
|
||||
const r = usernameSchema.safeParse(username);
|
||||
return r.success ? null : r.error.issues[0]?.message ?? "Invalid username";
|
||||
};
|
||||
|
||||
export const validateConfirmPassword = (
|
||||
password: string,
|
||||
confirmPassword: string,
|
||||
): string | null => (password === confirmPassword ? null : "Passwords do not match");
|
||||
@@ -0,0 +1,26 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.linkButton:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
/**
|
||||
* 邮箱登录表单
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/email_login_form.dart
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuthDispatch } from "@/contexts/auth/auth-context";
|
||||
import {
|
||||
validateEmail,
|
||||
validatePassword,
|
||||
} from "@/app/auth/components/auth-validators";
|
||||
|
||||
import { AuthTextField } from "./auth-text-field";
|
||||
import { AuthPrimaryButton } from "./auth-primary-button";
|
||||
import { AuthErrorMessage } from "./auth-error-message";
|
||||
import styles from "./email-form.module.css";
|
||||
|
||||
export interface EmailLoginFormProps {
|
||||
onSwitchToRegister: () => void;
|
||||
onForgotPassword?: () => void;
|
||||
globalError?: string | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function EmailLoginForm({
|
||||
onSwitchToRegister,
|
||||
onForgotPassword,
|
||||
globalError,
|
||||
isLoading,
|
||||
}: EmailLoginFormProps) {
|
||||
const dispatch = useAuthDispatch();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [errors, setErrors] = useState<{ email?: string; password?: string }>(
|
||||
{},
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
const emailErr = validateEmail(email);
|
||||
const passwordErr = validatePassword(password);
|
||||
if (emailErr || passwordErr) {
|
||||
setErrors({ email: emailErr ?? undefined, password: passwordErr ?? undefined });
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
dispatch({ type: "AuthEmailLoginSubmitted", email, password });
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<AuthErrorMessage message={globalError ?? null} />
|
||||
<AuthTextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
errorMessage={errors.email ?? null}
|
||||
/>
|
||||
<AuthTextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
errorMessage={errors.password ?? null}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
{onForgotPassword ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={onForgotPassword}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
) : null}
|
||||
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
||||
Log In
|
||||
</AuthPrimaryButton>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={onSwitchToRegister}
|
||||
>
|
||||
Don't have an account? <span className={styles.link}>Sign up</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
/**
|
||||
* 邮箱注册表单
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/email_register_form.dart
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuthDispatch } from "@/contexts/auth/auth-context";
|
||||
import {
|
||||
validateConfirmPassword,
|
||||
validateEmail,
|
||||
validatePassword,
|
||||
validateUsername,
|
||||
} from "@/app/auth/components/auth-validators";
|
||||
|
||||
import { AuthTextField } from "./auth-text-field";
|
||||
import { AuthPrimaryButton } from "./auth-primary-button";
|
||||
import { AuthErrorMessage } from "./auth-error-message";
|
||||
import styles from "./email-form.module.css";
|
||||
|
||||
export interface EmailRegisterFormProps {
|
||||
onSwitchToLogin: () => void;
|
||||
globalError?: string | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function EmailRegisterForm({
|
||||
onSwitchToLogin,
|
||||
globalError,
|
||||
isLoading,
|
||||
}: EmailRegisterFormProps) {
|
||||
const dispatch = useAuthDispatch();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [errors, setErrors] = useState<
|
||||
Partial<Record<"email" | "password" | "username" | "confirmPassword", string>>
|
||||
>({});
|
||||
|
||||
const submit = () => {
|
||||
const emailErr = validateEmail(email);
|
||||
const passwordErr = validatePassword(password);
|
||||
const usernameErr = validateUsername(username);
|
||||
const confirmErr = validateConfirmPassword(password, confirmPassword);
|
||||
if (emailErr || passwordErr || usernameErr || confirmErr) {
|
||||
setErrors({
|
||||
email: emailErr ?? undefined,
|
||||
password: passwordErr ?? undefined,
|
||||
username: usernameErr ?? undefined,
|
||||
confirmPassword: confirmErr ?? undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
dispatch({
|
||||
type: "AuthEmailRegisterSubmitted",
|
||||
email,
|
||||
password,
|
||||
username,
|
||||
confirmPassword,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<AuthErrorMessage message={globalError ?? null} />
|
||||
<AuthTextField
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
placeholder="cozsweet_user"
|
||||
autoComplete="username"
|
||||
errorMessage={errors.username ?? null}
|
||||
/>
|
||||
<AuthTextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
errorMessage={errors.email ?? null}
|
||||
/>
|
||||
<AuthTextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
errorMessage={errors.password ?? null}
|
||||
/>
|
||||
<AuthTextField
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={setConfirmPassword}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
errorMessage={errors.confirmPassword ?? null}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
||||
Create Account
|
||||
</AuthPrimaryButton>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={onSwitchToLogin}
|
||||
>
|
||||
Already have an account? <span className={styles.link}>Log in</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: var(--button-height);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
/**
|
||||
* Google 登录按钮(GIS 渲染)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/google_sign_in_web.dart
|
||||
*
|
||||
* 通过 `google.accounts.id.renderButton` 把 GIS 官方按钮渲染到指定 div。
|
||||
* 登录成功由 `setOnGoogleSignInSuccess` 设置的回调处理。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
renderGoogleButton,
|
||||
setOnGoogleSignInSuccess,
|
||||
type GoogleSignInSuccess,
|
||||
} from "@/integrations/google-identity";
|
||||
import styles from "./google-sign-in-button.module.css";
|
||||
|
||||
export interface GoogleSignInButtonProps {
|
||||
onSuccess: (success: GoogleSignInSuccess) => void;
|
||||
}
|
||||
|
||||
export function GoogleSignInButton({ onSuccess }: GoogleSignInButtonProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setOnGoogleSignInSuccess(onSuccess);
|
||||
return () => setOnGoogleSignInSuccess(null);
|
||||
}, [onSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
let cancelled = false;
|
||||
// GIS 异步加载,轮询直到可用
|
||||
const tryRender = () => {
|
||||
if (cancelled) return;
|
||||
if (window.google?.accounts?.id) {
|
||||
renderGoogleButton(ref.current!, { theme: "outline", size: "large" });
|
||||
} else {
|
||||
setTimeout(tryRender, 100);
|
||||
}
|
||||
};
|
||||
tryRender();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={ref} className={styles.slot} />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AuthScreen } from "@/components/auth/auth-screen";
|
||||
import { AuthScreen } from "@/app/auth/components/auth-screen";
|
||||
|
||||
export default function AuthPage() {
|
||||
return <AuthScreen />;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
background: var(--color-dark-background, #111);
|
||||
color: var(--color-text-primary, #fff);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-4, 16px) var(--spacing-5, 20px);
|
||||
border-bottom: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.08));
|
||||
background: var(--color-header-background, rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-lg, 18px);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.area {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4, 16px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3, 12px);
|
||||
}
|
||||
|
||||
.aiDisclosure {
|
||||
align-self: center;
|
||||
background: var(--color-surface-muted, rgba(255, 255, 255, 0.06));
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--spacing-2, 8px) var(--spacing-4, 16px);
|
||||
font-size: var(--font-size-sm, 12px);
|
||||
opacity: 0.75;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bubbleRowAi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-1, 4px);
|
||||
}
|
||||
|
||||
.bubbleRowUser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-1, 4px);
|
||||
}
|
||||
|
||||
.bubbleAi {
|
||||
max-width: 75%;
|
||||
background: var(--color-bubble-ai, rgba(255, 255, 255, 0.08));
|
||||
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bubbleUser {
|
||||
max-width: 75%;
|
||||
background: var(--color-bubble-user, #4f46e5);
|
||||
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bubbleImage {
|
||||
max-width: 220px;
|
||||
max-height: 220px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bubbleTime {
|
||||
font-size: var(--font-size-xs, 11px);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.typingDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
margin: 0 2px;
|
||||
animation: typingBlink 1.2s infinite;
|
||||
}
|
||||
.typingDot:nth-child(2) { animation-delay: 0.15s; }
|
||||
.typingDot:nth-child(3) { animation-delay: 0.3s; }
|
||||
|
||||
@keyframes typingBlink {
|
||||
0%, 60%, 100% { opacity: 0.2; }
|
||||
30% { opacity: 1; }
|
||||
}
|
||||
|
||||
.inputBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2, 8px);
|
||||
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
|
||||
border-top: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.12));
|
||||
background: var(--color-input-bar-background, rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1 1 auto;
|
||||
height: 40px;
|
||||
padding: 0 var(--spacing-3, 12px);
|
||||
border: 1px solid var(--color-chat-input-border, rgba(255, 255, 255, 0.2));
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-accent, #4f46e5);
|
||||
}
|
||||
|
||||
.sendButton {
|
||||
height: 40px;
|
||||
padding: 0 var(--spacing-4, 16px);
|
||||
background: var(--color-accent, #4f46e5);
|
||||
color: #fff;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sendButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dialogActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-2, 8px);
|
||||
margin-top: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
.dialogTitle {
|
||||
margin: 0 0 var(--spacing-2, 8px);
|
||||
font-size: var(--font-size-lg, 18px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dialogPrimary,
|
||||
.dialogSecondary {
|
||||
height: 36px;
|
||||
padding: 0 var(--spacing-4, 16px);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
font-weight: 600;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialogPrimary {
|
||||
background: var(--color-accent, #4f46e5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dialogSecondary {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border-subtle, rgba(255, 255, 255, 0.2));
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.overlayCard {
|
||||
background: var(--color-surface, #1f1f1f);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
padding: var(--spacing-5, 20px);
|
||||
max-width: 320px;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||
import { Dialog } from "@/app/_components/core/dialog";
|
||||
import { useChatDispatch, useChatState } from "@/contexts/chat/chat-context";
|
||||
import { GuestChatQuota } from "@/contexts/chat/chat-types";
|
||||
import type { UiMessage } from "@/models/chat/ui-message";
|
||||
|
||||
import styles from "./chat-screen.module.css";
|
||||
|
||||
/**
|
||||
* 聊天屏幕
|
||||
*
|
||||
* 原始 Dart: lib/ui/chat/chat_screen.dart
|
||||
*
|
||||
* 组成(自上而下):
|
||||
* - ChatHeader(标题 + 菜单)
|
||||
* - ChatArea(消息列表 + AI 披露 + 日期分隔 + 加载动画)
|
||||
* - ChatInputBar(输入框 + 发送 / 语音 / 菜单)
|
||||
* - QuotaDialog(游客配额告警弹窗)
|
||||
* - PwaInstallOverlay / BrowserHintOverlay(占位)
|
||||
*/
|
||||
export function ChatScreen() {
|
||||
const state = useChatState();
|
||||
const dispatch = useChatDispatch();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [quotaDialogOpen, setQuotaDialogOpen] = useState(false);
|
||||
const [quotaExhausted, setQuotaExhausted] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const [showPwaOverlay, setShowPwaOverlay] = useState(false);
|
||||
|
||||
// 初始化 + 切屏事件
|
||||
useEffect(() => {
|
||||
dispatch({ type: "ChatInit" });
|
||||
dispatch({ type: "ChatScreenVisible" });
|
||||
return () => {
|
||||
dispatch({ type: "ChatScreenInvisible" });
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// 配额触发监听
|
||||
const prevTriggerRef = useRef(state.quotaExceededTrigger);
|
||||
useEffect(() => {
|
||||
if (
|
||||
state.quotaExceededTrigger !== prevTriggerRef.current &&
|
||||
state.quotaExceededTrigger > 0
|
||||
) {
|
||||
setQuotaExhausted(true);
|
||||
setQuotaDialogOpen(true);
|
||||
}
|
||||
prevTriggerRef.current = state.quotaExceededTrigger;
|
||||
}, [state.quotaExceededTrigger]);
|
||||
|
||||
// 配额告警监听(剩余 = 5)
|
||||
const prevRemainingRef = useRef(state.guestRemainingQuota);
|
||||
useEffect(() => {
|
||||
if (
|
||||
state.isGuest &&
|
||||
state.guestRemainingQuota === GuestChatQuota.warningThreshold &&
|
||||
prevRemainingRef.current !== state.guestRemainingQuota
|
||||
) {
|
||||
setQuotaExhausted(false);
|
||||
setQuotaDialogOpen(true);
|
||||
}
|
||||
prevRemainingRef.current = state.guestRemainingQuota;
|
||||
}, [state.guestRemainingQuota, state.isGuest]);
|
||||
|
||||
// 滚动到底部
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [state.messages.length, state.isReplyingAI]);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim()) return;
|
||||
dispatch({ type: "ChatSendMessage", content: input });
|
||||
setInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<MobileShell>
|
||||
<div className={styles.shell}>
|
||||
<ChatHeader isGuest={state.isGuest} />
|
||||
|
||||
<main ref={scrollRef} className={styles.area}>
|
||||
<AiDisclosure />
|
||||
{state.messages.map((m, i) => (
|
||||
<MessageBubble key={`m-${i}`} message={m} />
|
||||
))}
|
||||
{state.isReplyingAI && <LoadingBubble />}
|
||||
</main>
|
||||
|
||||
<ChatInputBar
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={quotaDialogOpen}
|
||||
onClose={() => setQuotaDialogOpen(false)}
|
||||
ariaLabel={quotaExhausted ? "Quota exhausted" : "Running low"}
|
||||
>
|
||||
<h2 className={styles.dialogTitle}>
|
||||
{quotaExhausted ? "Quota exhausted" : "Running low"}
|
||||
</h2>
|
||||
<p>
|
||||
{quotaExhausted
|
||||
? "You've used all your free messages. Please sign in to continue."
|
||||
: "You have a few messages left. Sign in to keep chatting."}
|
||||
</p>
|
||||
<div className={styles.dialogActions}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuotaDialogOpen(false)}
|
||||
className={styles.dialogSecondary}
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuotaDialogOpen(false);
|
||||
window.location.href = "/auth";
|
||||
}}
|
||||
className={styles.dialogPrimary}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{showPwaOverlay && (
|
||||
<PwaInstallOverlay onClose={() => setShowPwaOverlay(false)} />
|
||||
)}
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatHeader({ isGuest }: { isGuest: boolean }) {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.title}>cozsweet</h1>
|
||||
<span className={styles.subtitle}>
|
||||
{isGuest ? "Guest mode" : "Signed in"}
|
||||
</span>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function AiDisclosure() {
|
||||
return (
|
||||
<div className={styles.aiDisclosure}>
|
||||
You're chatting with an AI companion.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: UiMessage }) {
|
||||
const isAI = message.isFromAI;
|
||||
return (
|
||||
<div
|
||||
className={isAI ? styles.bubbleRowAi : styles.bubbleRowUser}
|
||||
aria-label={isAI ? "AI message" : "User message"}
|
||||
>
|
||||
{message.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={message.imageUrl}
|
||||
alt=""
|
||||
className={styles.bubbleImage}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={isAI ? styles.bubbleAi : styles.bubbleUser}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
)}
|
||||
<time className={styles.bubbleTime}>{message.date}</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingBubble() {
|
||||
return (
|
||||
<div className={styles.bubbleRowAi} aria-label="AI typing">
|
||||
<div className={styles.bubbleAi}>
|
||||
<span className={styles.typingDot} />
|
||||
<span className={styles.typingDot} />
|
||||
<span className={styles.typingDot} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatInputBar({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onSubmit: (e: FormEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<form className={styles.inputBar} onSubmit={onSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.input}
|
||||
placeholder="Say something…"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label="Message"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.sendButton}
|
||||
disabled={!value.trim()}
|
||||
aria-label="Send"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PwaInstallOverlay({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<div className={styles.overlay} role="dialog" aria-label="Install app">
|
||||
<div className={styles.overlayCard}>
|
||||
<h2>Install cozsweet</h2>
|
||||
<p>Add to home screen for the best experience.</p>
|
||||
<button type="button" onClick={onClose}>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ChatScreen } from "@/components/chat/chat-screen";
|
||||
import { ChatScreen } from "@/app/chat/components/chat-screen";
|
||||
|
||||
export default function ChatPage() {
|
||||
return <ChatScreen />;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
background: var(--color-sidebar-background, #fafafa);
|
||||
color: var(--color-text-primary, #111);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3, 12px);
|
||||
padding: var(--spacing-4, 16px);
|
||||
border-bottom: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.08));
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
|
||||
.backButton {
|
||||
font-size: 20px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.backButton:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-lg, 18px);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1 1 auto;
|
||||
padding: var(--spacing-4, 16px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-5, 20px);
|
||||
}
|
||||
|
||||
.profileCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3, 12px);
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
padding: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent, #4f46e5);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarInitial {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.userInfo h2 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base, 16px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.userInfo p {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--font-size-sm, 13px);
|
||||
color: var(--color-text-secondary, #666);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.signInButton {
|
||||
flex-shrink: 0;
|
||||
background: var(--color-accent, #4f46e5);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm, 13px);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4, 16px);
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
|
||||
font-size: var(--font-size-xs, 11px);
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-secondary, #666);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.sectionList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sectionList li + li {
|
||||
border-top: 1px solid var(--color-border-subtle, rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
|
||||
.settingsItem,
|
||||
.dangerItem {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: var(--spacing-3, 12px) var(--spacing-4, 16px);
|
||||
font-size: var(--font-size-base, 14px);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settingsItem:hover,
|
||||
.dangerItem:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.dangerItem {
|
||||
color: var(--color-danger, #dc2626);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||
import { useUserDispatch, useUserState } from "@/contexts/user/user-context";
|
||||
|
||||
import styles from "./sidebar-screen.module.css";
|
||||
|
||||
/**
|
||||
* 侧边栏主屏幕
|
||||
*
|
||||
* 原始 Dart: lib/ui/sidebar/sidebar_screen.dart + profile_view.dart
|
||||
*/
|
||||
export function SidebarScreen() {
|
||||
const user = useUserState();
|
||||
const dispatch = useUserDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "UserInit" });
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<MobileShell>
|
||||
<div className={styles.shell}>
|
||||
<header className={styles.header}>
|
||||
<Link href="/chat" className={styles.backButton} aria-label="Back">
|
||||
←
|
||||
</Link>
|
||||
<h1 className={styles.title}>Profile</h1>
|
||||
</header>
|
||||
|
||||
<main className={styles.main}>
|
||||
<section className={styles.profileCard}>
|
||||
<div className={styles.avatar}>
|
||||
{user.avatarUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={user.avatarUrl} alt="" />
|
||||
) : (
|
||||
<span className={styles.avatarInitial}>
|
||||
{(user.currentUser?.username ?? "?").charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.userInfo}>
|
||||
<h2>{user.currentUser?.username ?? "Guest"}</h2>
|
||||
<p>{user.currentUser?.email ?? "Sign in to get started"}</p>
|
||||
</div>
|
||||
{user.currentUser ? null : (
|
||||
<Link href="/auth" className={styles.signInButton}>
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<SettingsList user={user} dispatch={dispatch} />
|
||||
</main>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsList({
|
||||
user,
|
||||
dispatch,
|
||||
}: {
|
||||
user: ReturnType<typeof useUserState>;
|
||||
dispatch: ReturnType<typeof useUserDispatch>;
|
||||
}) {
|
||||
const sections: Array<{
|
||||
title: string;
|
||||
items: Array<{ label: string; onClick: () => void; danger?: boolean }>;
|
||||
}> = [
|
||||
{
|
||||
title: "Account",
|
||||
items: [
|
||||
{ label: "Edit username", onClick: () => {} },
|
||||
{ label: "Edit pronouns", onClick: () => {} },
|
||||
{ label: "Subscription", onClick: () => {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Data",
|
||||
items: [
|
||||
{
|
||||
label: "Delete chat history",
|
||||
onClick: () => dispatch({ type: "UserDeleteChatHistory" }),
|
||||
},
|
||||
{
|
||||
label: "Delete account",
|
||||
onClick: () => dispatch({ type: "UserDeleteAccount" }),
|
||||
danger: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Session",
|
||||
items: user.currentUser
|
||||
? [
|
||||
{
|
||||
label: "Log out",
|
||||
onClick: () => dispatch({ type: "UserLogout" }),
|
||||
danger: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.settings}>
|
||||
{sections
|
||||
.filter((s) => s.items.length > 0)
|
||||
.map((s) => (
|
||||
<div key={s.title} className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>{s.title}</h3>
|
||||
<ul className={styles.sectionList}>
|
||||
{s.items.map((it) => (
|
||||
<li key={it.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={it.onClick}
|
||||
className={
|
||||
it.danger ? styles.dangerItem : styles.settingsItem
|
||||
}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SidebarScreen } from "@/components/sidebar/sidebar-screen";
|
||||
import { SidebarScreen } from "@/app/sidebar/components/sidebar-screen";
|
||||
|
||||
export default function SidebarPage() {
|
||||
return <SidebarScreen />;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 80% 20%, rgba(248, 77, 150, 0.25), transparent 60%),
|
||||
linear-gradient(180deg, #1a0f2a 0%, #0d0b14 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
/**
|
||||
* Splash 背景
|
||||
*
|
||||
* 原始 Dart: lib/ui/splash/widgets/splash_background.dart
|
||||
* 使用 `Assets.images.picBgHome.image(fit: BoxFit.cover)` 渲染全屏背景图。
|
||||
*
|
||||
* 当前实现:纯色 + CSS 渐变占位(待 `public/splash/bg.png` 资源到位后可切换为 `<Image>`)。
|
||||
*/
|
||||
import styles from "./splash-background.module.css";
|
||||
|
||||
export function SplashBackground() {
|
||||
return <div className={styles.bg} aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
/**
|
||||
* Splash 底部按钮组(Skip + Facebook 登录)
|
||||
*
|
||||
* 原始 Dart: lib/ui/splash/widgets/splash_button.dart
|
||||
*/
|
||||
import { useAuthState } from "@/contexts/auth/auth-context";
|
||||
|
||||
import { AuthSocialButtons } from "@/app/auth/components/auth-social-buttons";
|
||||
import { AuthPrimaryButton } from "@/app/auth/components/auth-primary-button";
|
||||
import { AuthDivider } from "@/app/auth/components/auth-divider";
|
||||
import { useAuthDispatch } from "@/contexts/auth/auth-context";
|
||||
import styles from "./splash-button.module.css";
|
||||
|
||||
export interface SplashButtonProps {
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function SplashButton({ onSkip }: SplashButtonProps) {
|
||||
const state = useAuthState();
|
||||
const dispatch = useAuthDispatch();
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<AuthSocialButtons
|
||||
loadingProvider={state.isLoading ? "facebook" : null}
|
||||
onFacebook={() => dispatch({ type: "AuthFacebookLoginSubmitted" })}
|
||||
onGoogle={() => dispatch({ type: "AuthGoogleLoginSubmitted" })}
|
||||
/>
|
||||
<AuthDivider label="or" />
|
||||
<AuthPrimaryButton onClick={onSkip} variant="primary">
|
||||
Skip — Continue as Guest
|
||||
</AuthPrimaryButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
color: var(--color-text-primary);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-athelas);
|
||||
font-size: var(--font-size-26);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
/**
|
||||
* Splash 内容文案
|
||||
*
|
||||
* 原始 Dart: lib/ui/splash/widgets/splash_content.dart
|
||||
*/
|
||||
import styles from "./splash-content.module.css";
|
||||
|
||||
export function SplashContent() {
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<h1 className={styles.title}>Chat with Elio, anytime, anywhere</h1>
|
||||
<p className={styles.subtitle}>
|
||||
A safe, private space to talk, share, and unwind — your AI companion who's always there.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
/**
|
||||
* Splash 加载状态
|
||||
*
|
||||
* 原始 Dart: lib/ui/splash/widgets/splash_loading.dart
|
||||
*
|
||||
* 包装 `<LoadingIndicator>`,splash 阶段可能用于等待 auth 检查。
|
||||
*/
|
||||
import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
|
||||
|
||||
export function SplashLoading() {
|
||||
return <LoadingIndicator size={48} color="var(--color-accent)" />;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--color-text-primary);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: var(--font-athelas);
|
||||
font-size: var(--font-size-title-x-large);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
/**
|
||||
* Splash Logo
|
||||
*
|
||||
* 原始 Dart: lib/ui/splash/widgets/splash_logo.dart
|
||||
*
|
||||
* 当前实现:纯文字 logo("cozsweet" + tagline)。
|
||||
* 资源:`Assets.images.icLogoHome`(待替换为 `<Image src="/splash/logo.svg" />`)。
|
||||
*/
|
||||
import styles from "./splash-logo.module.css";
|
||||
|
||||
export function SplashLogo() {
|
||||
return (
|
||||
<div className={styles.logo}>
|
||||
<span className={styles.brand}>cozsweet</span>
|
||||
<span className={styles.tagline}>Your exclusive AI boyfriend</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 30%;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(90deg, var(--color-accent), var(--color-facebook-button-gradient-end));
|
||||
animation: progress 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% {
|
||||
left: -30%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
/**
|
||||
* Splash 进度条
|
||||
*
|
||||
* 原始 Dart: lib/ui/splash/widgets/splash_progress.dart
|
||||
*
|
||||
* 当前实现:简单 CSS 动画进度条,0 → 100% 1.5s。
|
||||
*/
|
||||
import styles from "./splash-progress.module.css";
|
||||
|
||||
export function SplashProgress() {
|
||||
return (
|
||||
<div className={styles.wrapper} role="progressbar" aria-valuemin={0} aria-valuemax={100}>
|
||||
<div className={styles.bar} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gradientOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to top right,
|
||||
var(--color-accent),
|
||||
transparent 60%
|
||||
);
|
||||
opacity: 0.4;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
padding: var(--spacing-26) var(--spacing-26) var(--spacing-md);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.buttonArea {
|
||||
margin-top: var(--spacing-26);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
margin: var(--spacing-xxxl) 0 0 0;
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useAuthState } from "@/contexts/auth/auth-context";
|
||||
import { useChatDispatch } from "@/contexts/chat/chat-context";
|
||||
import { useUserDispatch } from "@/contexts/user/user-context";
|
||||
import { useAuthGate } from "@/lib/auth/use-auth-gate";
|
||||
import { ROUTES } from "@/lib/routes";
|
||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||
|
||||
import { SplashBackground } from "./splash-background";
|
||||
import { SplashLogo } from "./splash-logo";
|
||||
import { SplashContent } from "./splash-content";
|
||||
import { SplashButton } from "./splash-button";
|
||||
import styles from "./splash-screen.module.css";
|
||||
|
||||
export function SplashScreen() {
|
||||
const router = useRouter();
|
||||
const { isAuthed } = useAuthGate();
|
||||
const state = useAuthState();
|
||||
const wasSuccess = useRef(false);
|
||||
const chatDispatch = useChatDispatch();
|
||||
const userDispatch = useUserDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthed) router.replace(ROUTES.chat);
|
||||
}, [isAuthed, router]);
|
||||
|
||||
// 登录成功跳 /chat
|
||||
useEffect(() => {
|
||||
if (state.isSuccess && !wasSuccess.current) {
|
||||
wasSuccess.current = true;
|
||||
chatDispatch({ type: "ChatAuthStatusChanged" });
|
||||
userDispatch({ type: "UserInit" });
|
||||
router.replace(ROUTES.chat);
|
||||
}
|
||||
}, [state.isSuccess, chatDispatch, userDispatch, router]);
|
||||
|
||||
return (
|
||||
<MobileShell background="var(--color-sidebar-background)">
|
||||
<div className={styles.wrapper}>
|
||||
<SplashBackground />
|
||||
<div className={styles.gradientOverlay} aria-hidden="true" />
|
||||
<div className={styles.content}>
|
||||
<SplashLogo />
|
||||
<div className={styles.spacer} />
|
||||
<SplashContent />
|
||||
<div className={styles.buttonArea}>
|
||||
<SplashButton onSkip={() => router.push(ROUTES.chat)} />
|
||||
</div>
|
||||
<p className={styles.bottom}>
|
||||
Elio Silvestri, Your exclusive AI boyfriend
|
||||
<br />
|
||||
24/7 online | Chat | Companion | Heal | Sweet moments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SplashScreen } from "@/components/splash/splash-screen";
|
||||
import { SplashScreen } from "@/app/splash/components/splash-screen";
|
||||
|
||||
export default function SplashPage() {
|
||||
return <SplashScreen />;
|
||||
|
||||
Reference in New Issue
Block a user