refactor: relocate components to app directory structure

This commit is contained in:
2026-06-09 14:43:10 +08:00
parent f060301c24
commit cda55c8f9b
61 changed files with 19 additions and 27 deletions
@@ -0,0 +1,22 @@
.button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: var(--radius-full);
background: transparent;
color: var(--color-text-primary);
cursor: pointer;
transition: background 0.15s ease;
}
.button:hover {
background: rgba(255, 255, 255, 0.08);
}
.button:focus-visible {
outline: var(--border-medium) solid var(--color-accent);
outline-offset: 2px;
}
@@ -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;
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
/**
* 通用 Dialog 基础组件(headless + 简单样式)
*
* 原始 Dart: 散落在各处的 `showDialog` + `Dialog` widgetquota_dialog, pwa_install_dialog,
* username_dialog, pronouns_dialog, subscription_dialog, environment_dialog, auth_other_options_dialog,
* facebook_login_dialog, external_browser_dialog)。
*
* 设计目标:~70 行覆盖所有 9 种 dialog 的通用逻辑(portal, ESC, scroll lock, click-outside),
* 调用方只关心内容布局。
*
* 注意:项目不引入 Radix/shadcn,纯依赖 React + CSS Modules。
*/
import { type ReactNode, useEffect } from "react";
import { createPortal } from "react-dom";
import styles from "./dialog.module.css";
export interface DialogProps {
open: boolean;
onClose: () => void;
children: ReactNode;
/** 内容最大宽度(px)。默认 360。 */
maxWidth?: number;
/** 蒙层透明度(0-1)。默认 0.5。 */
scrimOpacity?: number;
/** 禁用点击外部 / ESC 关闭。 */
persistent?: boolean;
/** ARIA 标签。 */
ariaLabel?: string;
}
export function Dialog({
open,
onClose,
children,
maxWidth = 360,
scrimOpacity = 0.5,
persistent = false,
ariaLabel,
}: DialogProps) {
useEffect(() => {
if (!open || typeof document === "undefined") return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !persistent) onClose();
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose, persistent]);
if (!open || typeof document === "undefined") return null;
return createPortal(
<div
className={styles.scrim}
style={{ background: `rgba(0, 0, 0, ${scrimOpacity})` }}
onClick={(e) => {
if (!persistent && e.target === e.currentTarget) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
className={styles.panel}
style={{ maxWidth }}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,26 @@
.wrapper {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-sm);
}
.spinner {
display: inline-block;
border-style: solid;
border-color: var(--color-text-secondary) transparent transparent
transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.label {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
@@ -0,0 +1,40 @@
"use client";
/**
* 通用加载指示器
*
* 原始 Dart: lib/ui/core/loading_indicator.dart
*
* 行为对齐 Flutter `CircularProgressIndicator`
* - `size`:圆圈直径(默认 32
* - `strokeWidth`:环宽(默认 3
* - `color`:环颜色(默认 `--color-accent`
* - `label`:可选的可视标签(环形下方文本)
*/
import styles from "./loading-indicator.module.css";
export interface LoadingIndicatorProps {
size?: number;
strokeWidth?: number;
color?: string;
label?: string;
}
export function LoadingIndicator({
size = 32,
strokeWidth = 3,
color,
label,
}: LoadingIndicatorProps) {
const style: React.CSSProperties = {
width: size,
height: size,
borderWidth: strokeWidth,
...(color ? { borderTopColor: color } : {}),
};
return (
<div className={styles.wrapper} role="status" aria-label="Loading">
<div className={styles.spinner} style={style} />
{label ? <span className={styles.label}>{label}</span> : null}
</div>
);
}
@@ -0,0 +1,19 @@
.shell {
display: flex;
flex: 1 1 auto;
align-items: stretch;
justify-content: center;
width: 100%;
min-height: 100dvh;
background: inherit;
}
.content {
position: relative;
display: flex;
flex: 1 1 auto;
flex-direction: column;
width: 100%;
max-width: 500px;
min-height: 100dvh;
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
/**
* 500px 移动端 Shell
*
* 原始 Dart: 每个屏幕的 `Scaffold → SafeArea → Center → ConstrainedBox(maxWidth: 500)` 模式
* `Breakpoints.mobileMaxWidth = 500.0`)。
*
* 集中封装,调用方仅需 `<MobileShell>{...}</MobileShell>` 即可获得统一的移动端宽度约束。
*/
import type { ReactNode } from "react";
import styles from "./mobile-shell.module.css";
export interface MobileShellProps {
children: ReactNode;
/** 自定义背景色(默认透明,继承 body 背景)。 */
background?: string;
/** 自定义 className(注入到内容容器上)。 */
className?: string;
}
export function MobileShell({
children,
background,
className,
}: MobileShellProps) {
return (
<div className={styles.shell}>
<div
className={[styles.content, className].filter(Boolean).join(" ")}
style={background ? { background } : undefined}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,101 @@
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.title {
margin: 0;
padding: 0 var(--spacing-md);
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-section-title);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.list {
margin: 0;
padding: 0;
list-style: none;
background: var(--color-settings-card-background);
border-radius: var(--radius-lg);
overflow: hidden;
}
.row {
display: flex;
align-items: center;
gap: var(--spacing-md);
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
border: none;
background: transparent;
color: var(--color-settings-text-primary);
font-size: var(--font-size-md);
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.row:hover {
background: rgba(255, 255, 255, 0.04);
}
.row:focus-visible {
outline: var(--border-medium) solid var(--color-accent);
outline-offset: -2px;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--color-chevron-icon);
flex-shrink: 0;
}
.body {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1 1 auto;
min-width: 0;
}
.rowTitle {
font-size: var(--font-size-md);
font-weight: 500;
color: var(--color-settings-text-primary);
}
.subtitle {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.trailing {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
flex-shrink: 0;
}
.chevron {
color: var(--color-chevron-icon);
flex-shrink: 0;
}
.divider {
height: var(--border-light);
background: var(--color-settings-divider);
margin: 0 var(--spacing-lg);
}
.destructive {
color: var(--color-destructive);
}
.destructive .rowTitle {
color: var(--color-destructive);
}
@@ -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;
}
+17
View File
@@ -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);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
/**
* 认证面板:顶层 switchFacebook / 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>
);
}
+74
View File
@@ -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 位
* - validateUsername3-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&apos;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 -1
View File
@@ -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;
}
+247
View File
@@ -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&apos;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 -1
View File
@@ -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 -1
View File
@@ -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&apos;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);
}
+19
View File
@@ -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 -1
View File
@@ -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 />;