Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-26 17:51:31 +07:00
co-authored by Claude Opus 5.5
commit 61d0a11dfa
371 changed files with 35836 additions and 0 deletions
+385
View File
@@ -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>
);
}