122 lines
4.4 KiB
TypeScript
122 lines
4.4 KiB
TypeScript
"use client";
|
|
|
|
import { Settings, UserRound, Users, WalletCards, X } from "lucide-react";
|
|
import { memo, type MutableRefObject, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
|
|
export type AccountSettingsDialog = "profile" | "chart-library" | "billing" | "general";
|
|
|
|
export type AccountOverlayModel = Readonly<{
|
|
title: string;
|
|
dialogClass: string;
|
|
signingOut: boolean;
|
|
close: () => void;
|
|
navigate: (dialog: AccountSettingsDialog) => void;
|
|
overlayRef: MutableRefObject<HTMLElement | null>;
|
|
closeButtonRef: MutableRefObject<HTMLButtonElement | null>;
|
|
renderProfile: () => ReactNode;
|
|
renderChartLibrary: () => ReactNode;
|
|
renderBilling: () => ReactNode;
|
|
renderGeneral: () => ReactNode;
|
|
renderLogout: () => ReactNode;
|
|
}>;
|
|
|
|
const settingsItems: ReadonlyArray<{
|
|
dialog: AccountSettingsDialog;
|
|
label: string;
|
|
icon: typeof UserRound;
|
|
}> = [
|
|
{ dialog: "profile", label: "个人资料", icon: UserRound },
|
|
{ dialog: "chart-library", label: "星盘资料", icon: Users },
|
|
{ dialog: "billing", label: "账户与点数", icon: WalletCards },
|
|
{ dialog: "general", label: "通用设置", icon: Settings },
|
|
];
|
|
|
|
/**
|
|
* Panes whose body is a single column of form fields. They get a reading-width cap
|
|
* inside the content box; the list panes (星盘资料 / 账户与点数) stay full-bleed.
|
|
* The cap is purely inside `.settings-dialog-content`: every pane still renders under
|
|
* the same `.settings-modal` box, so the dialog cannot change size between panes
|
|
* (BUG-554 / BUG-698).
|
|
*/
|
|
const formPanes: ReadonlyArray<AccountSettingsDialog> = ["profile", "general"];
|
|
|
|
export const AccountDialogOverlay = memo(function AccountDialogOverlay({
|
|
open,
|
|
dialog,
|
|
model,
|
|
}: Readonly<{
|
|
open: boolean;
|
|
dialog: "profile" | "chart-library" | "billing" | "general" | "logout" | null;
|
|
model: AccountOverlayModel | null;
|
|
}>) {
|
|
if (!open || dialog === null || model === null) return null;
|
|
|
|
const isSettingsDialog = dialog !== "logout";
|
|
const isFormPane = dialog !== "logout" && formPanes.includes(dialog);
|
|
const renderSettingsContent = () => {
|
|
if (dialog === "profile") return model.renderProfile();
|
|
if (dialog === "chart-library") return model.renderChartLibrary();
|
|
if (dialog === "billing") return model.renderBilling();
|
|
return model.renderGeneral();
|
|
};
|
|
|
|
const overlay = (
|
|
<div className="account-modal-overlay" onMouseDown={model.close}>
|
|
<section
|
|
className={`account-modal ${model.dialogClass}`}
|
|
ref={model.overlayRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="account-dialog-title"
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
>
|
|
<header className="account-modal-header">
|
|
<div className="account-modal-heading">
|
|
{isSettingsDialog ? <span className="account-modal-eyebrow">设置</span> : null}
|
|
<h2 id="account-dialog-title">{model.title}</h2>
|
|
</div>
|
|
<button
|
|
className="dialog-close"
|
|
ref={model.closeButtonRef}
|
|
aria-label="关闭"
|
|
type="button"
|
|
onClick={model.close}
|
|
disabled={model.signingOut}
|
|
>
|
|
<X aria-hidden="true" />
|
|
</button>
|
|
</header>
|
|
{isSettingsDialog ? (
|
|
<div className="account-settings-shell">
|
|
<nav className="settings-dialog-nav" aria-label="设置分区">
|
|
{settingsItems.map(({ dialog: itemDialog, label, icon: Icon }) => (
|
|
<button
|
|
className="settings-dialog-nav-item"
|
|
key={itemDialog}
|
|
type="button"
|
|
aria-current={dialog === itemDialog ? "page" : undefined}
|
|
onClick={() => model.navigate(itemDialog)}
|
|
>
|
|
<Icon aria-hidden="true" />
|
|
<span>{label}</span>
|
|
</button>
|
|
))}
|
|
</nav>
|
|
<div
|
|
className={`settings-dialog-content${isFormPane ? " settings-dialog-content--form" : ""}`}
|
|
>
|
|
{renderSettingsContent()}
|
|
</div>
|
|
</div>
|
|
) : model.renderLogout()}
|
|
</section>
|
|
</div>
|
|
);
|
|
// BUG-968: SidebarInset is inert while the dialog is open. Portal to body so
|
|
// the dialog is not inside that subtree. SSR keeps the in-tree markup so
|
|
// renderToString contracts still see the dialog.
|
|
if (typeof document === "undefined") return overlay;
|
|
return createPortal(overlay, document.body);
|
|
});
|