refactor: relocate components to app directory structure
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
validateEmail,
|
||||
validatePassword,
|
||||
validateUsername,
|
||||
} from "@/app/auth/components/auth-validators";
|
||||
|
||||
describe("validateEmail", () => {
|
||||
it("accepts a valid email", () => {
|
||||
expect(validateEmail("user@example.com")).toBeNull();
|
||||
});
|
||||
it("rejects an empty email", () => {
|
||||
expect(validateEmail("")).not.toBeNull();
|
||||
});
|
||||
it("rejects an invalid email", () => {
|
||||
expect(validateEmail("not-an-email")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePassword", () => {
|
||||
it("accepts a password >= 8 chars", () => {
|
||||
expect(validatePassword("12345678")).toBeNull();
|
||||
});
|
||||
it("rejects a too-short password", () => {
|
||||
expect(validatePassword("123")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateUsername", () => {
|
||||
it("accepts a 3+ char username", () => {
|
||||
expect(validateUsername("alice")).toBeNull();
|
||||
});
|
||||
it("rejects an empty username", () => {
|
||||
expect(validateUsername("")).not.toBeNull();
|
||||
});
|
||||
it("rejects a too-short username", () => {
|
||||
expect(validateUsername("ab")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.line {
|
||||
flex: 1 1 auto;
|
||||
height: var(--border-light);
|
||||
background: var(--color-chat-input-border);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
/**
|
||||
* "OR" 分隔符
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_divider.dart
|
||||
*/
|
||||
import styles from "./auth-divider.module.css";
|
||||
|
||||
export function AuthDivider({ label = "or" }: { label?: string }) {
|
||||
return (
|
||||
<div className={styles.wrapper} role="separator">
|
||||
<div className={styles.line} />
|
||||
<span className={styles.label}>{label}</span>
|
||||
<div className={styles.line} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.backButton:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
/**
|
||||
* Email 面板
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_email_panel.dart
|
||||
*
|
||||
* 行为:
|
||||
* - 内部 `authMode`(login/register)切换显示 Login / Register 表单
|
||||
* - 提交派发 `AuthEmailLoginSubmitted` 或 `AuthEmailRegisterSubmitted`
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuthState } from "@/contexts/auth/auth-context";
|
||||
|
||||
import { EmailLoginForm } from "./email-login-form";
|
||||
import { EmailRegisterForm } from "./email-register-form";
|
||||
import styles from "./auth-email-panel.module.css";
|
||||
|
||||
export interface AuthEmailPanelProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function AuthEmailPanel({ onBack }: AuthEmailPanelProps) {
|
||||
const state = useAuthState();
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<button type="button" className={styles.backButton} onClick={onBack}>
|
||||
← Back
|
||||
</button>
|
||||
{mode === "login" ? (
|
||||
<EmailLoginForm
|
||||
onSwitchToRegister={() => setMode("register")}
|
||||
globalError={state.errorMessage}
|
||||
isLoading={state.isLoading}
|
||||
/>
|
||||
) : (
|
||||
<EmailRegisterForm
|
||||
onSwitchToLogin={() => setMode("login")}
|
||||
globalError={state.errorMessage}
|
||||
isLoading={state.isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.banner {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 77, 79, 0.12);
|
||||
border: var(--border-light) solid rgba(255, 77, 79, 0.3);
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
/**
|
||||
* 错误消息展示
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_error_message.dart
|
||||
*/
|
||||
import styles from "./auth-error-message.module.css";
|
||||
|
||||
export function AuthErrorMessage({ message }: { message: string | null }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<div className={styles.banner} role="alert">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-accent);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.linkButton:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
/**
|
||||
* Facebook 面板(默认面板)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_facebook_panel.dart
|
||||
*
|
||||
* 行为:
|
||||
* - 默认显示「Continue with Facebook」+ 「Other sign-in options」入口
|
||||
* - 真实 Facebook 登录在「Continue with Facebook」按钮中触发
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuthState, useAuthDispatch } from "@/contexts/auth/auth-context";
|
||||
|
||||
import { AuthSocialButtons } from "./auth-social-buttons";
|
||||
import { AuthDivider } from "./auth-divider";
|
||||
import { AuthPrimaryButton } from "./auth-primary-button";
|
||||
import { AuthErrorMessage } from "./auth-error-message";
|
||||
import { AuthOtherOptionsDialog } from "./auth-other-options-dialog";
|
||||
import { GoogleSignInButton } from "./google-sign-in-button";
|
||||
import { AuthLegalText } from "./auth-legal-text";
|
||||
import styles from "./auth-facebook-panel.module.css";
|
||||
|
||||
export interface AuthFacebookPanelProps {
|
||||
onSwitchToEmail: () => void;
|
||||
onGoogleSuccess: (idToken: string, email: string) => void;
|
||||
}
|
||||
|
||||
export function AuthFacebookPanel({
|
||||
onSwitchToEmail,
|
||||
onGoogleSuccess,
|
||||
}: AuthFacebookPanelProps) {
|
||||
const state = useAuthState();
|
||||
const dispatch = useAuthDispatch();
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<AuthErrorMessage message={state.errorMessage} />
|
||||
<AuthSocialButtons
|
||||
loadingProvider={state.isLoading ? "facebook" : null}
|
||||
onFacebook={() => dispatch({ type: "AuthFacebookLoginSubmitted" })}
|
||||
onGoogle={() => dispatch({ type: "AuthGoogleLoginSubmitted" })}
|
||||
googleSlot={
|
||||
<GoogleSignInButton
|
||||
onSuccess={(s) =>
|
||||
onGoogleSuccess(s.idToken, s.email)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<AuthDivider label="or" />
|
||||
<AuthPrimaryButton
|
||||
onClick={onSwitchToEmail}
|
||||
variant="primary"
|
||||
>
|
||||
Continue with Email
|
||||
</AuthPrimaryButton>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={() => setShowOptions(true)}
|
||||
>
|
||||
Other sign-in options
|
||||
</button>
|
||||
<AuthLegalText />
|
||||
<AuthOtherOptionsDialog
|
||||
open={showOptions}
|
||||
onClose={() => setShowOptions(false)}
|
||||
onFacebook={() => {
|
||||
setShowOptions(false);
|
||||
dispatch({ type: "AuthFacebookLoginSubmitted" });
|
||||
}}
|
||||
onGoogle={() => {
|
||||
setShowOptions(false);
|
||||
dispatch({ type: "AuthGoogleLoginSubmitted" });
|
||||
}}
|
||||
onApple={() => {
|
||||
setShowOptions(false);
|
||||
dispatch({ type: "AuthAppleLoginSubmitted" });
|
||||
}}
|
||||
onEmail={() => {
|
||||
setShowOptions(false);
|
||||
onSwitchToEmail();
|
||||
}}
|
||||
loadingProvider={state.isLoading ? "facebook" : null}
|
||||
showApple={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.text {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
/**
|
||||
* 法律声明(Terms / Privacy)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_legal_text.dart
|
||||
*/
|
||||
import styles from "./auth-legal-text.module.css";
|
||||
|
||||
export function AuthLegalText() {
|
||||
return (
|
||||
<p className={styles.text}>
|
||||
By continuing, you agree to our{" "}
|
||||
<a href="/terms" className={styles.link} target="_blank" rel="noreferrer">
|
||||
Terms
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="/privacy" className={styles.link} target="_blank" rel="noreferrer">
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emailButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: var(--border-light) solid var(--color-chat-input-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.emailButton:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
/**
|
||||
* "其他登录方式" 弹窗
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_other_options_dialog.dart
|
||||
*/
|
||||
import { Dialog } from "@/app/_components/core/dialog";
|
||||
import { AuthSocialButtons } from "./auth-social-buttons";
|
||||
import { AuthDivider } from "./auth-divider";
|
||||
import styles from "./auth-other-options-dialog.module.css";
|
||||
|
||||
export interface AuthOtherOptionsDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onFacebook: () => void;
|
||||
onGoogle: () => void;
|
||||
onApple?: () => void;
|
||||
onEmail: () => void;
|
||||
loadingProvider?: "facebook" | "google" | "apple" | null;
|
||||
showApple?: boolean;
|
||||
googleSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthOtherOptionsDialog({
|
||||
open,
|
||||
onClose,
|
||||
onFacebook,
|
||||
onGoogle,
|
||||
onApple,
|
||||
onEmail,
|
||||
loadingProvider,
|
||||
showApple,
|
||||
googleSlot,
|
||||
}: AuthOtherOptionsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} ariaLabel="Other sign-in options">
|
||||
<div className={styles.body}>
|
||||
<h2 className={styles.title}>Other Sign-in Options</h2>
|
||||
<AuthSocialButtons
|
||||
onFacebook={onFacebook}
|
||||
onGoogle={onGoogle}
|
||||
onApple={onApple}
|
||||
loadingProvider={loadingProvider}
|
||||
showApple={showApple}
|
||||
googleSlot={googleSlot}
|
||||
/>
|
||||
<AuthDivider label="or" />
|
||||
<button type="button" className={styles.emailButton} onClick={onEmail}>
|
||||
Continue with Email
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
width: 100%;
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
/**
|
||||
* 认证面板:顶层 switch(Facebook / Email)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_panel.dart
|
||||
*/
|
||||
import { useAuthState } from "@/contexts/auth/auth-context";
|
||||
|
||||
import { AuthFacebookPanel } from "./auth-facebook-panel";
|
||||
import { AuthEmailPanel } from "./auth-email-panel";
|
||||
import styles from "./auth-panel.module.css";
|
||||
|
||||
export interface AuthPanelProps {
|
||||
onGoogleSuccess: (idToken: string, email: string) => void;
|
||||
}
|
||||
|
||||
export function AuthPanel({ onGoogleSuccess }: AuthPanelProps) {
|
||||
const state = useAuthState();
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
{state.authPanelMode === "facebook" ? (
|
||||
<AuthFacebookPanel
|
||||
onSwitchToEmail={() => {
|
||||
// 切换模式由外部 dispatch 触发;这里不直接处理
|
||||
// 因为 auth-panel 受 `authPanelMode` 控制,调用方需要 dispatch `AuthPanelModeChanged`
|
||||
// 但我们目前 AuthFacebookPanel 内部 click "Continue with Email" 直接调用 onSwitchToEmail
|
||||
// 实际上本组件直接渲染对应 panel
|
||||
}}
|
||||
onGoogleSuccess={onGoogleSuccess}
|
||||
/>
|
||||
) : (
|
||||
<AuthEmailPanel onBack={() => undefined} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, transform 0.05s ease;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button:not(:disabled):active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.accent {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-accent),
|
||||
var(--color-facebook-button-gradient-end)
|
||||
);
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: currentColor transparent transparent transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
/**
|
||||
* 认证主按钮(粉/蓝渐变)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_primary_button.dart
|
||||
*/
|
||||
import { type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
import styles from "./auth-primary-button.module.css";
|
||||
|
||||
export interface AuthPrimaryButtonProps
|
||||
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
isLoading?: boolean;
|
||||
children: ReactNode;
|
||||
variant?: "accent" | "primary";
|
||||
}
|
||||
|
||||
export function AuthPrimaryButton({
|
||||
isLoading,
|
||||
children,
|
||||
variant = "accent",
|
||||
disabled,
|
||||
className,
|
||||
...rest
|
||||
}: AuthPrimaryButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isLoading}
|
||||
className={[
|
||||
styles.button,
|
||||
styles[variant],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className={styles.spinner} aria-hidden="true" />
|
||||
) : null}
|
||||
<span className={styles.label}>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
/**
|
||||
* 认证全屏页面
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/auth_screen.dart
|
||||
*
|
||||
* 行为对齐:
|
||||
* - 500px 移动端宽度(MobileShell)
|
||||
* - 已登录用户:自动跳 /chat
|
||||
* - 未登录:渲染 AuthPanel(含 Facebook/Email 两种面板)
|
||||
* - 登录成功:通知 ChatBloc + UserBloc,然后 router.replace(/chat)
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuthState, useAuthDispatch } from "@/contexts/auth/auth-context";
|
||||
import { useChatDispatch } from "@/contexts/chat/chat-context";
|
||||
import { useUserDispatch } from "@/contexts/user/user-context";
|
||||
import { useAuthGate } from "@/lib/auth/use-auth-gate";
|
||||
import { ROUTES } from "@/lib/routes";
|
||||
import { MobileShell } from "@/app/_components/core/mobile-shell";
|
||||
import { AuthPanel } from "@/app/auth/components/auth-panel";
|
||||
|
||||
export interface AuthScreenProps {
|
||||
/** 自定义背景(默认紫粉渐变)。 */
|
||||
background?: string;
|
||||
}
|
||||
|
||||
export function AuthScreen({
|
||||
background = "var(--color-sidebar-background)",
|
||||
}: AuthScreenProps) {
|
||||
const router = useRouter();
|
||||
const { isAuthed } = useAuthGate();
|
||||
const state = useAuthState();
|
||||
const authDispatch = useAuthDispatch();
|
||||
const chatDispatch = useChatDispatch();
|
||||
const userDispatch = useUserDispatch();
|
||||
const wasSuccess = useRef(false);
|
||||
|
||||
// 已登录 → 自动跳 /chat
|
||||
useEffect(() => {
|
||||
if (isAuthed) router.replace(ROUTES.chat);
|
||||
}, [isAuthed, router]);
|
||||
|
||||
// 登录成功 → 通知 Chat + User + 跳转
|
||||
useEffect(() => {
|
||||
if (state.isSuccess && !wasSuccess.current) {
|
||||
wasSuccess.current = true;
|
||||
chatDispatch({ type: "ChatAuthStatusChanged" });
|
||||
userDispatch({ type: "UserInit" });
|
||||
router.replace(ROUTES.chat);
|
||||
}
|
||||
}, [state.isSuccess, chatDispatch, userDispatch, router]);
|
||||
|
||||
return (
|
||||
<MobileShell background={background}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
padding: "var(--spacing-lg)",
|
||||
}}
|
||||
>
|
||||
<AuthPanel
|
||||
onGoogleSuccess={(idToken, email) => {
|
||||
authDispatch({ type: "AuthWebGoogleLoginSuccess", idToken, email });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</MobileShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: var(--border-light) solid var(--color-chat-input-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.facebook {
|
||||
background: var(--color-facebook-blue);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.google {
|
||||
background: #ffffff;
|
||||
color: #1f1f1f;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.apple {
|
||||
background: #000000;
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
/**
|
||||
* 社交登录按钮
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_social_button.dart
|
||||
*/
|
||||
import { type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
import styles from "./auth-social-button.module.css";
|
||||
|
||||
export interface AuthSocialButtonProps
|
||||
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
variant?: "facebook" | "google" | "apple" | "default";
|
||||
}
|
||||
|
||||
export function AuthSocialButton({
|
||||
icon,
|
||||
children,
|
||||
variant = "default",
|
||||
disabled,
|
||||
className,
|
||||
...rest
|
||||
}: AuthSocialButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={[styles.button, styles[variant], className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{icon ? <span className={styles.icon}>{icon}</span> : null}
|
||||
<span className={styles.label}>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: var(--border-light) solid var(--color-chat-input-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.facebook {
|
||||
background: var(--color-facebook-blue);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.google {
|
||||
background: #ffffff;
|
||||
color: #1f1f1f;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.apple {
|
||||
background: #000000;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.googleSlot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: var(--button-height);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
/**
|
||||
* 社交登录按钮组合
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_social_buttons.dart
|
||||
*
|
||||
* 包含 Facebook / Google / Apple 三个按钮的竖排列表。
|
||||
*/
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import styles from "./auth-social-buttons.module.css";
|
||||
|
||||
export interface AuthSocialButtonsProps {
|
||||
onFacebook: () => void;
|
||||
onGoogle: () => void;
|
||||
onApple?: () => void;
|
||||
loadingProvider?: "facebook" | "google" | "apple" | null;
|
||||
showApple?: boolean;
|
||||
/** 自定义子节点(如嵌入 GIS 按钮) */
|
||||
googleSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthSocialButtons({
|
||||
onFacebook,
|
||||
onGoogle,
|
||||
onApple,
|
||||
loadingProvider,
|
||||
showApple = false,
|
||||
googleSlot,
|
||||
}: AuthSocialButtonsProps) {
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.button, styles.facebook].join(" ")}
|
||||
onClick={onFacebook}
|
||||
disabled={loadingProvider !== null && loadingProvider !== "facebook"}
|
||||
>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
{/* Facebook "f" logo placeholder */}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13 22v-8h3l1-4h-4V7.5c0-1.2.4-2 2-2h2V2.2C16.6 2.1 15.5 2 14.3 2 11.5 2 9.5 3.7 9.5 7v3h-3v4h3v8h3.5z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>Continue with Facebook</span>
|
||||
</button>
|
||||
|
||||
{googleSlot ? (
|
||||
<div className={styles.googleSlot}>{googleSlot}</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.button, styles.google].join(" ")}
|
||||
onClick={onGoogle}
|
||||
disabled={loadingProvider !== null && loadingProvider !== "google"}
|
||||
>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
{/* Google "G" logo placeholder */}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22 12.2c0-.7-.1-1.4-.2-2H12v3.8h5.6c-.2 1.3-1 2.4-2 3.1v2.6h3.2c1.9-1.7 2.9-4.2 2.9-7.5z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 22c2.7 0 4.9-.9 6.5-2.4l-3.2-2.5c-.9.6-2 1-3.3 1-2.6 0-4.7-1.7-5.5-4.1H3.2v2.5C4.8 19.7 8.1 22 12 22z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 14a6 6 0 010-3.9V7.5H3.2a10 10 0 000 9l3.3-2.5z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.9c1.5 0 2.8.5 3.8 1.5l2.9-2.9C16.9 2.9 14.7 2 12 2 8.1 2 4.8 4.3 3.2 7.5L6.5 10c.8-2.4 2.9-4.1 5.5-4.1z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>Continue with Google</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showApple && onApple ? (
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.button, styles.apple].join(" ")}
|
||||
onClick={onApple}
|
||||
disabled={loadingProvider !== null && loadingProvider !== "apple"}
|
||||
>
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16.4 1.5c0 1.1-.4 2.1-1.1 2.9-.8.8-1.8 1.4-2.8 1.3-.1-1.1.4-2.2 1.1-3 .8-.8 1.9-1.3 2.8-1.2zM20 17.4c-.5 1.2-1.1 2.3-1.9 3.3-1 1.3-2.4 2.9-4.1 2.9-1.5 0-1.9-1-3.9-1-2 0-2.4 1-4 1-1.7 0-3-1.5-4-2.8-2.7-3.6-3-7.8-1.3-10 1.2-1.5 3-2.4 4.8-2.4 1.8 0 2.9 1 4.4 1 1.4 0 2.3-1 4.4-1 1.6 0 3.3.9 4.5 2.4-4 2.1-3.3 7.7.1 8.6z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span>Continue with Apple</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: var(--button-height);
|
||||
padding: 0 var(--spacing-lg);
|
||||
border: var(--border-light) solid var(--color-chat-input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-input-placeholder);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px rgba(248, 77, 150, 0.18);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
|
||||
.invalid:focus {
|
||||
box-shadow: 0 0 0 3px rgba(255, 77, 79, 0.18);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-error);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
/**
|
||||
* 通用文本输入框
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_text_field.dart
|
||||
*/
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
|
||||
import styles from "./auth-text-field.module.css";
|
||||
|
||||
export interface AuthTextFieldProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
type?: "text" | "email" | "password" | "tel";
|
||||
label?: string;
|
||||
errorMessage?: string | null;
|
||||
autoComplete?: string;
|
||||
maxLength?: number;
|
||||
disabled?: boolean;
|
||||
onSubmit?: () => void;
|
||||
onBlur?: (e: FocusEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const AuthTextField = forwardRef<HTMLInputElement, AuthTextFieldProps>(
|
||||
function AuthTextField(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = "text",
|
||||
label,
|
||||
errorMessage,
|
||||
autoComplete,
|
||||
maxLength,
|
||||
disabled,
|
||||
onSubmit,
|
||||
onBlur,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<label className={styles.wrapper}>
|
||||
{label ? <span className={styles.label}>{label}</span> : null}
|
||||
<input
|
||||
ref={ref}
|
||||
className={[styles.input, errorMessage ? styles.invalid : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(e.target.value)
|
||||
}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
autoComplete={autoComplete}
|
||||
maxLength={maxLength}
|
||||
disabled={disabled}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && onSubmit) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<span className={styles.error}>{errorMessage}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 认证表单校验
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/auth_validators.dart
|
||||
*
|
||||
* 业务行为:
|
||||
* - validateEmail:使用 Zod (项目已有依赖) 校验邮箱格式
|
||||
* - validatePassword:至少 8 位
|
||||
* - validateUsername:3-20 位字母数字下划线
|
||||
* - validateConfirmPassword:与 password 一致
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
export const emailSchema = z.string().trim().email("Invalid email address");
|
||||
export const passwordSchema = z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters");
|
||||
export const usernameSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(20, "Username must be at most 20 characters")
|
||||
.regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores");
|
||||
|
||||
export const validateEmail = (email: string): string | null => {
|
||||
const r = emailSchema.safeParse(email);
|
||||
return r.success ? null : r.error.issues[0]?.message ?? "Invalid email";
|
||||
};
|
||||
|
||||
export const validatePassword = (password: string): string | null => {
|
||||
const r = passwordSchema.safeParse(password);
|
||||
return r.success ? null : r.error.issues[0]?.message ?? "Invalid password";
|
||||
};
|
||||
|
||||
export const validateUsername = (username: string): string | null => {
|
||||
const r = usernameSchema.safeParse(username);
|
||||
return r.success ? null : r.error.issues[0]?.message ?? "Invalid username";
|
||||
};
|
||||
|
||||
export const validateConfirmPassword = (
|
||||
password: string,
|
||||
confirmPassword: string,
|
||||
): string | null => (password === confirmPassword ? null : "Passwords do not match");
|
||||
@@ -0,0 +1,26 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.linkButton:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
/**
|
||||
* 邮箱登录表单
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/email_login_form.dart
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuthDispatch } from "@/contexts/auth/auth-context";
|
||||
import {
|
||||
validateEmail,
|
||||
validatePassword,
|
||||
} from "@/app/auth/components/auth-validators";
|
||||
|
||||
import { AuthTextField } from "./auth-text-field";
|
||||
import { AuthPrimaryButton } from "./auth-primary-button";
|
||||
import { AuthErrorMessage } from "./auth-error-message";
|
||||
import styles from "./email-form.module.css";
|
||||
|
||||
export interface EmailLoginFormProps {
|
||||
onSwitchToRegister: () => void;
|
||||
onForgotPassword?: () => void;
|
||||
globalError?: string | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function EmailLoginForm({
|
||||
onSwitchToRegister,
|
||||
onForgotPassword,
|
||||
globalError,
|
||||
isLoading,
|
||||
}: EmailLoginFormProps) {
|
||||
const dispatch = useAuthDispatch();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [errors, setErrors] = useState<{ email?: string; password?: string }>(
|
||||
{},
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
const emailErr = validateEmail(email);
|
||||
const passwordErr = validatePassword(password);
|
||||
if (emailErr || passwordErr) {
|
||||
setErrors({ email: emailErr ?? undefined, password: passwordErr ?? undefined });
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
dispatch({ type: "AuthEmailLoginSubmitted", email, password });
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<AuthErrorMessage message={globalError ?? null} />
|
||||
<AuthTextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
errorMessage={errors.email ?? null}
|
||||
/>
|
||||
<AuthTextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
errorMessage={errors.password ?? null}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
{onForgotPassword ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={onForgotPassword}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
) : null}
|
||||
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
||||
Log In
|
||||
</AuthPrimaryButton>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={onSwitchToRegister}
|
||||
>
|
||||
Don't have an account? <span className={styles.link}>Sign up</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
/**
|
||||
* 邮箱注册表单
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/email_register_form.dart
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuthDispatch } from "@/contexts/auth/auth-context";
|
||||
import {
|
||||
validateConfirmPassword,
|
||||
validateEmail,
|
||||
validatePassword,
|
||||
validateUsername,
|
||||
} from "@/app/auth/components/auth-validators";
|
||||
|
||||
import { AuthTextField } from "./auth-text-field";
|
||||
import { AuthPrimaryButton } from "./auth-primary-button";
|
||||
import { AuthErrorMessage } from "./auth-error-message";
|
||||
import styles from "./email-form.module.css";
|
||||
|
||||
export interface EmailRegisterFormProps {
|
||||
onSwitchToLogin: () => void;
|
||||
globalError?: string | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function EmailRegisterForm({
|
||||
onSwitchToLogin,
|
||||
globalError,
|
||||
isLoading,
|
||||
}: EmailRegisterFormProps) {
|
||||
const dispatch = useAuthDispatch();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [errors, setErrors] = useState<
|
||||
Partial<Record<"email" | "password" | "username" | "confirmPassword", string>>
|
||||
>({});
|
||||
|
||||
const submit = () => {
|
||||
const emailErr = validateEmail(email);
|
||||
const passwordErr = validatePassword(password);
|
||||
const usernameErr = validateUsername(username);
|
||||
const confirmErr = validateConfirmPassword(password, confirmPassword);
|
||||
if (emailErr || passwordErr || usernameErr || confirmErr) {
|
||||
setErrors({
|
||||
email: emailErr ?? undefined,
|
||||
password: passwordErr ?? undefined,
|
||||
username: usernameErr ?? undefined,
|
||||
confirmPassword: confirmErr ?? undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setErrors({});
|
||||
dispatch({
|
||||
type: "AuthEmailRegisterSubmitted",
|
||||
email,
|
||||
password,
|
||||
username,
|
||||
confirmPassword,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<AuthErrorMessage message={globalError ?? null} />
|
||||
<AuthTextField
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
placeholder="cozsweet_user"
|
||||
autoComplete="username"
|
||||
errorMessage={errors.username ?? null}
|
||||
/>
|
||||
<AuthTextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
errorMessage={errors.email ?? null}
|
||||
/>
|
||||
<AuthTextField
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
errorMessage={errors.password ?? null}
|
||||
/>
|
||||
<AuthTextField
|
||||
label="Confirm Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={setConfirmPassword}
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
errorMessage={errors.confirmPassword ?? null}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
<AuthPrimaryButton type="submit" isLoading={isLoading}>
|
||||
Create Account
|
||||
</AuthPrimaryButton>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={onSwitchToLogin}
|
||||
>
|
||||
Already have an account? <span className={styles.link}>Log in</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: var(--button-height);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
/**
|
||||
* Google 登录按钮(GIS 渲染)
|
||||
*
|
||||
* 原始 Dart: lib/ui/auth/widgets/google_sign_in_web.dart
|
||||
*
|
||||
* 通过 `google.accounts.id.renderButton` 把 GIS 官方按钮渲染到指定 div。
|
||||
* 登录成功由 `setOnGoogleSignInSuccess` 设置的回调处理。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
renderGoogleButton,
|
||||
setOnGoogleSignInSuccess,
|
||||
type GoogleSignInSuccess,
|
||||
} from "@/integrations/google-identity";
|
||||
import styles from "./google-sign-in-button.module.css";
|
||||
|
||||
export interface GoogleSignInButtonProps {
|
||||
onSuccess: (success: GoogleSignInSuccess) => void;
|
||||
}
|
||||
|
||||
export function GoogleSignInButton({ onSuccess }: GoogleSignInButtonProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setOnGoogleSignInSuccess(onSuccess);
|
||||
return () => setOnGoogleSignInSuccess(null);
|
||||
}, [onSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
let cancelled = false;
|
||||
// GIS 异步加载,轮询直到可用
|
||||
const tryRender = () => {
|
||||
if (cancelled) return;
|
||||
if (window.google?.accounts?.id) {
|
||||
renderGoogleButton(ref.current!, { theme: "outline", size: "large" });
|
||||
} else {
|
||||
setTimeout(tryRender, 100);
|
||||
}
|
||||
};
|
||||
tryRender();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={ref} className={styles.slot} />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AuthScreen } from "@/components/auth/auth-screen";
|
||||
import { AuthScreen } from "@/app/auth/components/auth-screen";
|
||||
|
||||
export default function AuthPage() {
|
||||
return <AuthScreen />;
|
||||
|
||||
Reference in New Issue
Block a user