@@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useId, useState, useSyncExternalStore, type FormEvent, type ReactNode } from "react";
|
||||
|
||||
import { useMockUser } from "@/components/profile/use-mock-user";
|
||||
import {
|
||||
ACCOUNT_RULE_HINT,
|
||||
CREDENTIAL_MAX_LEN,
|
||||
isValidCredential,
|
||||
PASSWORD_RULE_HINT,
|
||||
} from "@/lib/auth/credentials";
|
||||
|
||||
type Mode = "login" | "register";
|
||||
|
||||
let currentMode: Mode | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function setDialogMode(mode: Mode | null) {
|
||||
currentMode = mode;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function openLoginDialog(mode: Mode = "login") {
|
||||
setDialogMode(mode);
|
||||
}
|
||||
|
||||
export function openRegisterDialog() {
|
||||
setDialogMode("register");
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
const getSnapshot = () => currentMode;
|
||||
const getServerSnapshot = () => null;
|
||||
|
||||
const desktopQuery = "(min-width: 1024px)";
|
||||
|
||||
function subscribeDesktop(callback: () => void) {
|
||||
const media = window.matchMedia(desktopQuery);
|
||||
media.addEventListener("change", callback);
|
||||
return () => media.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
const getDesktopSnapshot = () => window.matchMedia(desktopQuery).matches;
|
||||
const getDesktopServerSnapshot = () => false;
|
||||
|
||||
const emptyValues = { account: "", password: "", confirmPassword: "" };
|
||||
|
||||
type Field = keyof typeof emptyValues;
|
||||
type Errors = Partial<Record<Field, string>>;
|
||||
|
||||
function validate(mode: Mode, values: typeof emptyValues): Errors {
|
||||
const errors: Errors = {};
|
||||
if (!values.account) errors.account = "请输入账号";
|
||||
else if (!isValidCredential(values.account)) errors.account = ACCOUNT_RULE_HINT;
|
||||
|
||||
if (!values.password) errors.password = "请输入密码";
|
||||
else if (!isValidCredential(values.password))
|
||||
errors.password = mode === "login" ? "密码错误,请重新输入" : PASSWORD_RULE_HINT;
|
||||
|
||||
if (mode === "register") {
|
||||
if (!values.confirmPassword) errors.confirmPassword = "请再次输入密码";
|
||||
else if (values.confirmPassword !== values.password) errors.confirmPassword = "两次输入的密码不一致";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function mapAuthError(message: string, mode: Mode): Errors {
|
||||
if (/账户不存在|账号不存在|用户不存在/.test(message)) return { account: message };
|
||||
if (/已存在|重复|already exists/i.test(message)) return { account: "该账号已被注册" };
|
||||
if (/数据库|database/i.test(message)) return { account: "注册服务暂时异常,请稍后重试" };
|
||||
if (/密码|password/i.test(message)) return { password: message };
|
||||
if (/参数/.test(message)) return { account: message };
|
||||
return mode === "login" ? { password: message } : { account: message };
|
||||
}
|
||||
|
||||
export function LoginDialogHost() {
|
||||
const mode = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const isDesktop = useSyncExternalStore(subscribeDesktop, getDesktopSnapshot, getDesktopServerSnapshot);
|
||||
const { login, register } = useMockUser();
|
||||
const [values, setValues] = useState(emptyValues);
|
||||
const [errors, setErrors] = useState<Errors>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const close = useCallback(() => {
|
||||
if (submitting) return;
|
||||
setValues(emptyValues);
|
||||
setErrors({});
|
||||
setDialogMode(null);
|
||||
}, [submitting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mode) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => event.key === "Escape" && close();
|
||||
const { overflow } = document.body.style;
|
||||
document.body.style.overflow = "hidden";
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = overflow;
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [mode, close]);
|
||||
|
||||
if (!mode) return null;
|
||||
|
||||
const isLogin = mode === "login";
|
||||
const placeholderClass = isLogin ? "" : "lg:placeholder:text-[#746E85]";
|
||||
const switchMode = (next: Mode) => {
|
||||
if (submitting) return;
|
||||
setErrors({});
|
||||
setValues((current) => ({ ...current, confirmPassword: "" }));
|
||||
setDialogMode(next);
|
||||
};
|
||||
const update = (key: Field) => (value: string) => {
|
||||
setValues((current) => ({ ...current, [key]: value }));
|
||||
setErrors((current) => ({ ...current, [key]: undefined }));
|
||||
};
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const nextErrors = validate(mode, values);
|
||||
setErrors(nextErrors);
|
||||
if (Object.keys(nextErrors).length || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (isLogin) await login(values.account, values.password);
|
||||
else await register(values.account, values.password);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.info(isLogin ? "[auth-login]" : "[auth-register]", { account: values.account });
|
||||
}
|
||||
setValues(emptyValues);
|
||||
setErrors({});
|
||||
setDialogMode(null);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : isLogin ? "登录失败,请稍后重试" : "注册失败,请稍后重试";
|
||||
setErrors(mapAuthError(message, mode));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto bg-black/70 px-4 py-10"
|
||||
onClick={(event) => event.target === event.currentTarget && close()}
|
||||
>
|
||||
<div className={`my-auto flex flex-col items-center lg:gap-6 ${isLogin ? "gap-[11px]" : "gap-[7px]"}`}>
|
||||
<form
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="login-dialog-title"
|
||||
onSubmit={submit}
|
||||
className="flex w-[311px] flex-col items-center gap-4 rounded-3xl bg-[#0E0C17] px-[18px] py-6 lg:w-[456px] lg:gap-3.5 lg:bg-[#0A0915]/90 lg:px-8 lg:py-7 lg:shadow-[0_20px_50px_rgba(0,0,0,0.35)] lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-white/13"
|
||||
>
|
||||
<img src="/logo.svg" alt="海角社区" className="h-[33px] w-auto lg:h-12" />
|
||||
|
||||
{isLogin ? (
|
||||
<div className="flex gap-4 self-stretch lg:hidden" role="tablist">
|
||||
<TabButton active onClick={() => switchMode("login")}>
|
||||
登录
|
||||
</TabButton>
|
||||
<TabButton active={false} onClick={() => switchMode("register")}>
|
||||
注册
|
||||
</TabButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-4 self-stretch lg:gap-2">
|
||||
<h2
|
||||
id="login-dialog-title"
|
||||
className={`text-xl leading-[30px] font-bold text-white lg:not-sr-only lg:text-[30px] lg:leading-9 lg:font-semibold ${isLogin ? "sr-only" : ""}`}
|
||||
>
|
||||
{isLogin ? "登录" : "创建账号"}
|
||||
</h2>
|
||||
{isLogin ? (
|
||||
<p className="text-xs leading-[18px] text-[#A8A1B8] lg:text-sm lg:leading-5 lg:text-[#9D97AE]">
|
||||
登录后同步收藏、历史与会员权益
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs leading-[18px] text-[#9E94AD] lg:text-sm lg:leading-5 lg:text-[#A8A1B6]">
|
||||
<span className="lg:hidden">注册后同步收藏、历史与会员权益</span>
|
||||
<span className="max-lg:hidden">注册成功即可同步收藏与追剧记录</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AuthField
|
||||
label="账号"
|
||||
icon={<UserIcon />}
|
||||
placeholder="请输入账号"
|
||||
value={values.account}
|
||||
onChange={update("account")}
|
||||
autoComplete="username"
|
||||
placeholderClass={placeholderClass}
|
||||
error={errors.account}
|
||||
/>
|
||||
<AuthField
|
||||
label="密码"
|
||||
icon={<LockIcon />}
|
||||
placeholder="请输入密码"
|
||||
value={values.password}
|
||||
onChange={update("password")}
|
||||
autoComplete={isLogin ? "current-password" : "new-password"}
|
||||
secret
|
||||
placeholderClass={placeholderClass}
|
||||
error={errors.password}
|
||||
/>
|
||||
{isLogin ? null : (
|
||||
<AuthField
|
||||
label="确认密码"
|
||||
icon={<LockIcon />}
|
||||
placeholder={isDesktop ? "请再次输入密码" : "确认密码"}
|
||||
value={values.confirmPassword}
|
||||
onChange={update("confirmPassword")}
|
||||
autoComplete="new-password"
|
||||
secret
|
||||
placeholderClass={placeholderClass}
|
||||
error={errors.confirmPassword}
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="cursor-pointer h-11 self-stretch rounded-xl bg-[#F12B9E] text-base leading-[22px] font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-wait disabled:opacity-70 lg:h-12 lg:text-sm lg:leading-5 lg:font-semibold"
|
||||
>
|
||||
{submitting ? (
|
||||
isLogin ? "登录中…" : "注册中…"
|
||||
) : isLogin ? (
|
||||
<>
|
||||
<span className="lg:hidden">登录</span>
|
||||
<span className="max-lg:hidden">立即登录</span>
|
||||
</>
|
||||
) : (
|
||||
"立即注册"
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="flex h-[22px] items-center justify-center gap-2.5 self-stretch text-xs leading-[18px] lg:h-6 lg:text-sm lg:leading-5">
|
||||
<span className="text-[#9790A6] max-lg:hidden">{isLogin ? "还没有账号?" : "已有账号?"}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchMode(isLogin ? "register" : "login")}
|
||||
className="cursor-pointer font-bold text-[#F12B9E] hover:opacity-80 lg:font-semibold"
|
||||
>
|
||||
<span className="lg:hidden">{isLogin ? "没有账号?注册账号" : "已有账号? 去登录"}</span>
|
||||
<span className="max-lg:hidden">{isLogin ? "立即注册" : "去登录"}</span>
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p
|
||||
className={`self-stretch text-center text-xs leading-[18px] lg:hidden ${isLogin ? "text-[#7A738A]" : "text-[#9E94AD]"}`}
|
||||
>
|
||||
{isLogin ? "登录" : "注册"}即代表同意
|
||||
<Link href="/terms" onClick={close} className="text-[#A8A1B8]">
|
||||
《用户协议》
|
||||
</Link>
|
||||
和
|
||||
<Link href="/privacy" onClick={close} className="text-[#A8A1B8]">
|
||||
《隐私政策》
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<button type="button" aria-label="关闭" onClick={close} className="cursor-pointer size-11 lg:size-9">
|
||||
<svg viewBox="0 0 44 44" className="size-full" aria-hidden="true">
|
||||
<circle cx="22" cy="22" r="21" fill="none" stroke="white" strokeWidth="2" />
|
||||
<path d="M16 16l12 12M28 16L16 28" stroke="white" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={onClick}
|
||||
className={`cursor-pointer text-xl leading-[30px] ${active ? "font-bold text-white" : "text-[#656565]"}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthField({
|
||||
label,
|
||||
icon,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
autoComplete,
|
||||
secret = false,
|
||||
placeholderClass = "",
|
||||
error,
|
||||
}: {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
autoComplete: string;
|
||||
secret?: boolean;
|
||||
placeholderClass?: string;
|
||||
error?: string;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const errorId = useId();
|
||||
const outlineClass = error
|
||||
? "outline outline-1 -outline-offset-1 outline-[#FF4A5C]"
|
||||
: "focus-within:outline focus-within:outline-1 focus-within:-outline-offset-1 focus-within:outline-[#F12B9E]/60 lg:outline lg:outline-1 lg:-outline-offset-1 lg:outline-white/10 lg:focus-within:outline-[#F12B9E]/60";
|
||||
|
||||
return (
|
||||
<div data-component="login-dialog" className="flex flex-col gap-1.5 self-stretch">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="sr-only text-sm leading-5 font-semibold text-[#E0DDE8] lg:not-sr-only">{label}</span>
|
||||
<span className={`flex h-[50px] items-center gap-2 rounded-xl bg-white/7 px-3.5 lg:h-12 ${outlineClass}`}>
|
||||
{icon}
|
||||
<input
|
||||
type={secret && !visible ? "password" : "text"}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
maxLength={CREDENTIAL_MAX_LEN}
|
||||
autoComplete={autoComplete}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
className={`min-w-0 flex-1 bg-transparent text-sm leading-5 text-white outline-none placeholder:text-[#7A738A] ${placeholderClass}`}
|
||||
/>
|
||||
{secret ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={visible ? "隐藏密码" : "显示密码"}
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
className="cursor-pointer flex size-5 shrink-0 items-center justify-center"
|
||||
>
|
||||
<svg viewBox="0 0 20 16" className="h-[13.33px] w-[17.5px]" aria-hidden="true">
|
||||
<path
|
||||
d="M1 8s3.3-6 9-6 9 6 9 6-3.3 6-9 6-9-6-9-6z"
|
||||
fill="none"
|
||||
stroke="#A8A1B8"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="10" cy="8" r="2.6" fill="#A8A1B8" />
|
||||
{visible ? null : <path d="M3 15L17 1" stroke="#A8A1B8" strokeWidth="1.6" strokeLinecap="round" />}
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</label>
|
||||
{error ? (
|
||||
<p id={errorId} role="alert" className="text-xs leading-[18px] text-[#FF5C6E]">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className="size-5 shrink-0" aria-hidden="true">
|
||||
<circle cx="10" cy="7" r="3.25" fill="none" stroke="#A8A1B8" strokeWidth="1.5" />
|
||||
<path d="M3.75 16.5c.9-2.9 3.3-4.5 6.25-4.5s5.35 1.6 6.25 4.5" fill="none" stroke="#A8A1B8" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" className="size-5 shrink-0" aria-hidden="true">
|
||||
<rect x="4" y="8.5" width="12" height="8.5" rx="2" fill="none" stroke="#A8A1B8" strokeWidth="1.5" />
|
||||
<path d="M7 8.5V6.5a3 3 0 0 1 6 0v2" fill="none" stroke="#A8A1B8" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user