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,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 />;