386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
"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>
|
|
);
|
|
}
|