feat(sidebar): the avatar opens the account menu on every page (S2)
Off / the footer was a plain link home. It is now the same Menu.Root everywhere; off / its dialog entries link to /?account=<dialog>, which Home opens once its account is ready and drops from the URL. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8415fdca72
commit
c15def0c50
@@ -46,7 +46,7 @@ import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft";
|
||||
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
|
||||
import { bindCurrentSubjectAccount, readCurrentSubjectId, useCurrentSubjectProfile } from "@/lib/current-subject";
|
||||
import { DailyStarlanguageBinder } from "@/components/daily-starlanguage-binder";
|
||||
import { SynastryDeepLink } from "@/components/people-home-links";
|
||||
import { AccountDialogDeepLink, SynastryDeepLink } from "@/components/people-home-links";
|
||||
import { StarterEntries, StarterGreeting } from "@/components/starter-home";
|
||||
import {
|
||||
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
|
||||
@@ -1163,6 +1163,7 @@ export default function Home() {
|
||||
setDailyStarlanguage={setDailyStarlanguage}
|
||||
/>
|
||||
<SynastryDeepLink profile={profile} draft={synastryPanel.draftSynastryQuestionFromChart} />
|
||||
<AccountDialogDeepLink ready={hydrated && Boolean(account)} open={openAccountDialog} />
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{replyAnnouncement}</span>
|
||||
{pendingSessionDeletion ? (
|
||||
<div className="account-modal-overlay session-delete-overlay" role="presentation" onMouseDown={() => setPendingSessionDeletion(null)}>
|
||||
|
||||
@@ -16,9 +16,10 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Ref } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import { accountDialogHref, type AccountDialogIntent } from "@/lib/account-dialog-intent";
|
||||
import { newChatHref, sessionHref } from "@/lib/chat-session-url";
|
||||
import { prefetchSecondaryPage } from "@/lib/secondary-page-data";
|
||||
import { prefetchBillingPanel } from "@/lib/billing-panel-loader";
|
||||
@@ -57,9 +58,10 @@ export type SidebarAccount = {
|
||||
|
||||
/**
|
||||
* Everything only `/` can supply: the session-management layer with its
|
||||
* optimistic updates and rollback, and the account menu with the settings
|
||||
* dialogs behind it. Omit `controls` and the same sidebar renders read-only —
|
||||
* rows are links, the footer is a link, nothing writes.
|
||||
* optimistic updates and rollback, and the settings dialogs behind the
|
||||
* account menu. Omit `controls` and the same sidebar renders read-only — rows
|
||||
* are links, nothing writes — and the same account menu's dialog entries
|
||||
* become links to `/?account=<dialog>` (TASK-self-edit-avatar-menu-20260928 S2).
|
||||
*
|
||||
* `/chart`, `/ephemeris` and `/reports` used to carry a second sidebar
|
||||
* component of their own, deleted in this round. Two components meant two sets
|
||||
@@ -133,12 +135,12 @@ export function AppSidebar({
|
||||
Compiler rule treats every member of an object that carries a ref as a ref
|
||||
read during render, and `accountTriggerRef` lives in here. */
|
||||
const {
|
||||
accountMenuOpen = false,
|
||||
accountMenuOpen: controlledMenuOpen = false,
|
||||
accountTriggerRef,
|
||||
newChatDisabled = false,
|
||||
creatingSession = false,
|
||||
sessionControls,
|
||||
onAccountMenuOpenChange,
|
||||
onAccountMenuOpenChange: controlledMenuOpenChange,
|
||||
onNewChat,
|
||||
onSelectSession,
|
||||
onOpenProfile,
|
||||
@@ -146,6 +148,10 @@ export function AppSidebar({
|
||||
onOpenBilling,
|
||||
onOpenLogout,
|
||||
}: Partial<AppSidebarControls> = controls ?? {};
|
||||
/* Off `/` nothing controls the account menu, so it keeps its own open state (S2). */
|
||||
const [ownMenuOpen, setOwnMenuOpen] = useState(false);
|
||||
const accountMenuOpen = controls ? controlledMenuOpen : ownMenuOpen;
|
||||
const onAccountMenuOpenChange = controls ? controlledMenuOpenChange : setOwnMenuOpen;
|
||||
const hasMoreSessions = sessionControls?.hasMore ?? false;
|
||||
const onLoadMoreSessions = sessionControls?.onLoadMore;
|
||||
const favoriteSessions = sessions.filter((session) => session.pinned);
|
||||
@@ -228,6 +234,29 @@ export function AppSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
/* One entry of the account menu: on `/` the dialog opens in place; on
|
||||
another page the entry is a link to `/?account=<dialog>` (S2). */
|
||||
function accountEntry(
|
||||
dialog: AccountDialogIntent,
|
||||
onOpen: (() => void) | undefined,
|
||||
className: string,
|
||||
content: ReactNode,
|
||||
pointer: { onPointerEnter?: () => void; onPointerDown?: () => void } = {},
|
||||
) {
|
||||
return onOpen ? (
|
||||
<Menu.Item className={className} onClick={onOpen} {...pointer}>{content}</Menu.Item>
|
||||
) : (
|
||||
<Menu.LinkItem
|
||||
className={className}
|
||||
closeOnClick
|
||||
render={<AppLink href={accountDialogHref(dialog)} onClick={leaveChat} />}
|
||||
{...pointer}
|
||||
>
|
||||
{content}
|
||||
</Menu.LinkItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar className="sidebar" aria-label="对话导航">
|
||||
<SidebarHeader className="sidebar-header">
|
||||
@@ -333,7 +362,11 @@ export function AppSidebar({
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="sidebar-footer">
|
||||
{controls && account ? (
|
||||
{account ? (
|
||||
/* One account menu on every page. On `/` Home controls it and its
|
||||
entries open the dialogs in place; elsewhere it keeps its own open
|
||||
state and the dialog entries go to `/?account=<dialog>`, where Home
|
||||
opens that dialog (S2). Theme applies where the reader is. */
|
||||
<Menu.Root open={accountMenuOpen} onOpenChange={onAccountMenuOpenChange} modal={false}>
|
||||
<Menu.Trigger
|
||||
className="profile-trigger"
|
||||
@@ -360,26 +393,24 @@ export function AppSidebar({
|
||||
: <span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>}
|
||||
<span><b>{account.name}</b><small>{account.email}</small></span>
|
||||
</div>
|
||||
<Menu.Item className="account-menu-item" onClick={onOpenProfile}>
|
||||
<UserRound aria-hidden="true" /><span>个人资料</span><ChevronRight aria-hidden="true" />
|
||||
</Menu.Item>
|
||||
<Menu.Item className="account-menu-item" onClick={onOpenGeneral}>
|
||||
<Settings aria-hidden="true" /><span>通用设置</span><ChevronRight aria-hidden="true" />
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
className="account-menu-item"
|
||||
onPointerEnter={() => { prefetchBillingPanel(); prefetchPaymentPackages(); }}
|
||||
onPointerDown={() => { prefetchBillingPanel(); prefetchPaymentPackages(); }}
|
||||
onClick={onOpenBilling}
|
||||
>
|
||||
<WalletCards aria-hidden="true" /><span>账户与点数</span><small>{account.credits} 点</small>
|
||||
</Menu.Item>
|
||||
{accountEntry("profile", onOpenProfile, "account-menu-item", (
|
||||
<><UserRound aria-hidden="true" /><span>个人资料</span><ChevronRight aria-hidden="true" /></>
|
||||
))}
|
||||
{accountEntry("general", onOpenGeneral, "account-menu-item", (
|
||||
<><Settings aria-hidden="true" /><span>通用设置</span><ChevronRight aria-hidden="true" /></>
|
||||
))}
|
||||
{accountEntry("billing", onOpenBilling, "account-menu-item", (
|
||||
<><WalletCards aria-hidden="true" /><span>账户与点数</span><small>{account.credits} 点</small></>
|
||||
), {
|
||||
onPointerEnter: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
|
||||
onPointerDown: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
|
||||
})}
|
||||
<Menu.Separator className="account-menu-separator" />
|
||||
<ThemePreferenceMenu />
|
||||
<Menu.Separator className="account-menu-separator" />
|
||||
<Menu.Item className="account-menu-item account-menu-danger" onClick={onOpenLogout}>
|
||||
<LogOut aria-hidden="true" /><span>退出登录</span>
|
||||
</Menu.Item>
|
||||
{accountEntry("logout", onOpenLogout, "account-menu-item account-menu-danger", (
|
||||
<><LogOut aria-hidden="true" /><span>退出登录</span></>
|
||||
))}
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
@@ -391,20 +422,11 @@ export function AppSidebar({
|
||||
{showExpandedContent ? <span><b>去登录</b></span> : null}
|
||||
</a>
|
||||
) : (
|
||||
/* Read-only footer: the same 56px identity block, minus the chevron
|
||||
and the menu. The account menu and its dialogs live on `/`, so this
|
||||
goes there rather than growing a second copy of them. */
|
||||
<AppLink
|
||||
className="profile-trigger"
|
||||
href="/"
|
||||
aria-label={account ? `${account.name},打开账户` : "打开账户"}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
{account?.avatar
|
||||
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
|
||||
: <span className="profile-initial" aria-hidden="true">{account?.initial ?? "·"}</span>}
|
||||
{showExpandedContent && account ? <span><b>{account.name}</b></span> : null}
|
||||
</AppLink>
|
||||
/* The account is still being read: the same 56px block, inert, with
|
||||
no link and no spinner. It becomes the menu as soon as it lands. */
|
||||
<div className="profile-trigger" aria-hidden="true">
|
||||
<span className="profile-initial">·</span>
|
||||
</div>
|
||||
)}
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { ChartLibraryRecord, Profile, SynastryRelationshipType } from "@/lib/home-types";
|
||||
import {
|
||||
ACCOUNT_DIALOG_QUERY_KEY,
|
||||
parseAccountDialogIntent,
|
||||
type AccountDialogIntent,
|
||||
} from "@/lib/account-dialog-intent";
|
||||
import type { ChartLibraryRecord, OpenAccountDialogOptions, Profile, SynastryRelationshipType } from "@/lib/home-types";
|
||||
|
||||
function stripQuery(key: string) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -40,3 +45,29 @@ export function SynastryDeepLink(props: {
|
||||
}, [props.draft, props.profile]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `/?account=<dialog>` from the account menu on another page
|
||||
* (TASK-self-edit-avatar-menu-20260928 S2). The intent is read once on mount
|
||||
* and the parameter removed at once, so a reload or Back never reopens it;
|
||||
* the dialog opens when Home's account is ready. No waiting state of its own.
|
||||
*/
|
||||
export function AccountDialogDeepLink(props: {
|
||||
ready: boolean;
|
||||
open: (dialog: AccountDialogIntent, options?: OpenAccountDialogOptions) => void;
|
||||
}) {
|
||||
const { ready, open } = props;
|
||||
const intent = useRef<AccountDialogIntent | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (intent.current === undefined) {
|
||||
intent.current = parseAccountDialogIntent(window.location.search);
|
||||
stripQuery(ACCOUNT_DIALOG_QUERY_KEY);
|
||||
}
|
||||
if (!intent.current || !ready) return;
|
||||
const dialog = intent.current;
|
||||
intent.current = null;
|
||||
// Same source as the menu on `/` (use-home-shell-registration).
|
||||
open(dialog, dialog === "billing" ? { source: "account-menu" } : undefined);
|
||||
}, [ready, open]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* The account menu on every page (TASK-self-edit-avatar-menu-20260928 S2).
|
||||
*
|
||||
* The account dialogs live on `/`. Off `/`, the same menu's dialog entries
|
||||
* are links to `/?account=<dialog>`; Home opens that dialog once its account
|
||||
* is ready and drops the parameter, so a reload or Back never opens it again.
|
||||
* Theme needs no dialog and applies where the reader is.
|
||||
*
|
||||
* No dependencies: this ships with the shared sidebar.
|
||||
*/
|
||||
|
||||
export const ACCOUNT_DIALOG_QUERY_KEY = "account";
|
||||
export const ACCOUNT_DIALOG_INTENTS = ["profile", "general", "billing", "logout"] as const;
|
||||
export type AccountDialogIntent = (typeof ACCOUNT_DIALOG_INTENTS)[number];
|
||||
|
||||
export function accountDialogHref(dialog: AccountDialogIntent): string {
|
||||
return `/?${ACCOUNT_DIALOG_QUERY_KEY}=${dialog}`;
|
||||
}
|
||||
|
||||
/** The dialog the URL asks for; null when absent or not one of the four. */
|
||||
export function parseAccountDialogIntent(search: string): AccountDialogIntent | null {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
||||
const value = params.get(ACCOUNT_DIALOG_QUERY_KEY);
|
||||
return (ACCOUNT_DIALOG_INTENTS as readonly string[]).includes(value ?? "") ? value as AccountDialogIntent : null;
|
||||
}
|
||||
|
||||
/** The same query without the account intent (a malformed value is dropped too). */
|
||||
export function searchWithoutAccountDialogIntent(search: string): string {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
||||
params.delete(ACCOUNT_DIALOG_QUERY_KEY);
|
||||
const query = params.toString();
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
Reference in New Issue
Block a user