外壳换成组件库:侧栏可拖宽,会话可排序(#105)。
桌面侧栏可在 200 到 360 像素之间拖宽,拖到最左收起,宽度记在本机。手机从左边滑出。会话在同一组里拖动排序,顺序写入可空的 sidebar_order,收藏仍置顶。账户菜单、设置、反馈和退出确认改用组件库。顶栏点数只显示图标和数字。启程清单还在。菜单、拖宽、拖动排序和翻页在首屏之后再载入。
自测:tsc 退出码 0;npm run lint 0 error、121 warning;改到的前端测试 205 项全过;npm run build 后首页仍是 static;首屏 gzip 630350,相对 8f7c0f7d 的 621226 为 +1.47%。test:db 92 项里 79 过、13 败,失败名与 Windows 已知清单一致,排序列在通过的 ok 37。没有跑全量 npm test,没有跑 Python 快速门。没有登录态,没有在浏览器里拖侧栏。
Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
8f7c0f7db4
commit
352d45b2ef
@@ -2,6 +2,13 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
formatAccountDeletionDate,
|
||||
parseAccountDeletionStatus,
|
||||
@@ -64,17 +71,17 @@ export function AccountDeletionGate({ signedIn }: Readonly<{ signedIn: boolean }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="account-deletion-gate" role="alertdialog" aria-modal="true" aria-labelledby="account-deletion-gate-title" aria-describedby="account-deletion-gate-body">
|
||||
<section>
|
||||
<h2 id="account-deletion-gate-title">账号注销中</h2>
|
||||
<p id="account-deletion-gate-body">
|
||||
<AlertDialog open onOpenChange={() => { /* Pending deletion stays until 撤销注销 or 退出登录. */ }}>
|
||||
<AlertDialogContent className="account-deletion-gate" aria-labelledby="account-deletion-gate-title" aria-describedby="account-deletion-gate-body">
|
||||
<AlertDialogTitle id="account-deletion-gate-title">账号注销中</AlertDialogTitle>
|
||||
<AlertDialogDescription id="account-deletion-gate-body">
|
||||
这个账号将在 {formatAccountDeletionDate(status.scheduledFor)} 永久删除。在那之前,你可以撤销注销,恢复全部内容。
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
<div className="dialog-actions">
|
||||
<button className="button-secondary" type="button" onClick={() => void leave()} disabled={busy}>退出登录</button>
|
||||
<button className="button-primary" type="button" onClick={() => void restore()} disabled={busy}>{busy ? "正在处理" : "撤销注销"}</button>
|
||||
<Button variant="outline" type="button" onClick={() => void leave()} disabled={busy}>退出登录</Button>
|
||||
<Button type="button" onClick={() => void restore()} disabled={busy}>{busy ? "正在处理" : "撤销注销"}</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
ACCOUNT_DELETION_CONFIRM_WORD,
|
||||
ACCOUNT_DELETION_DELETED_ITEMS,
|
||||
@@ -55,9 +57,9 @@ export function AccountDeletionSection() {
|
||||
<small>{ACCOUNT_DELETION_GRACE_DAYS} 天内可以撤销,之后永久删除</small>
|
||||
</div>
|
||||
{!open ? (
|
||||
<button type="button" className="account-deletion-open" onClick={() => setOpen(true)}>
|
||||
<Button type="button" className="account-deletion-open" variant="outline" onClick={() => setOpen(true)}>
|
||||
注销账号…
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="account-deletion-confirm">
|
||||
<p>提交后你会立刻退出登录,账号暂停使用。{ACCOUNT_DELETION_GRACE_DAYS} 天内重新登录可以撤销;{ACCOUNT_DELETION_GRACE_DAYS} 天后以下内容会被永久删除,无法恢复:</p>
|
||||
@@ -68,22 +70,22 @@ export function AccountDeletionSection() {
|
||||
<p className="account-deletion-contact">有疑问可以先联系我们:{LEGAL_ENTITY.contactEmail}</p>
|
||||
<label className="account-deletion-input">
|
||||
<span>输入「{ACCOUNT_DELETION_CONFIRM_WORD}」确认</span>
|
||||
<input
|
||||
<Input
|
||||
value={confirm}
|
||||
onChange={(event) => setConfirm(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<div className="dialog-actions">
|
||||
<button className="button-secondary" type="button" onClick={() => { setOpen(false); setConfirm(""); }} disabled={submitting}>取消</button>
|
||||
<button
|
||||
className="button-primary danger-primary"
|
||||
<Button variant="outline" type="button" onClick={() => { setOpen(false); setConfirm(""); }} disabled={submitting}>取消</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={submitting || !isAccountDeletionConfirmation(confirm)}
|
||||
>
|
||||
{submitting ? "正在提交" : "确认注销"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
|
||||
import { Settings, UserRound, WalletCards, X } from "lucide-react";
|
||||
import { memo, type MutableRefObject, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export type AccountSettingsDialog = "profile" | "chart-library" | "billing" | "general";
|
||||
|
||||
@@ -33,10 +45,7 @@ const settingsItems: ReadonlyArray<{
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* inside the content box; the list pane (账户与点数) stays full-bleed.
|
||||
*/
|
||||
const formPanes: ReadonlyArray<AccountSettingsDialog> = ["profile", "general"];
|
||||
|
||||
@@ -51,70 +60,76 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
|
||||
}>) {
|
||||
if (!open || dialog === null || model === null) return null;
|
||||
|
||||
const isSettingsDialog = dialog !== "logout";
|
||||
const isFormPane = dialog !== "logout" && formPanes.includes(dialog);
|
||||
if (dialog === "logout") {
|
||||
return (
|
||||
<AlertDialog open onOpenChange={(next) => { if (!next && !model.signingOut) model.close(); }}>
|
||||
<AlertDialogContent
|
||||
className={`account-modal ${model.dialogClass}`}
|
||||
ref={(node) => { model.overlayRef.current = node; }}
|
||||
aria-labelledby="account-dialog-title"
|
||||
>
|
||||
<header className="account-modal-header">
|
||||
<AlertDialogTitle id="account-dialog-title">{model.title}</AlertDialogTitle>
|
||||
</header>
|
||||
{model.renderLogout()}
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const isFormPane = formPanes.includes(dialog);
|
||||
const renderSettingsContent = () => {
|
||||
if (dialog === "profile") return model.renderProfile();
|
||||
if (dialog === "chart-library") return null;
|
||||
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"
|
||||
return (
|
||||
<Dialog open onOpenChange={(next) => { if (!next) model.close(); }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={`account-modal ${model.dialogClass} settings-modal max-w-none sm:max-w-none`}
|
||||
ref={(node) => { model.overlayRef.current = node; }}
|
||||
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>
|
||||
<span className="account-modal-eyebrow">设置</span>
|
||||
<DialogTitle id="account-dialog-title">{model.title}</DialogTitle>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
className="dialog-close"
|
||||
ref={model.closeButtonRef}
|
||||
aria-label="关闭"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={model.close}
|
||||
disabled={model.signingOut}
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
</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>
|
||||
<Tabs className="account-settings-shell" value={dialog} onValueChange={(next) => model.navigate(next as AccountSettingsDialog)}>
|
||||
<TabsList className="settings-dialog-nav" aria-label="设置分区">
|
||||
{settingsItems.map(({ dialog: itemDialog, label, icon: Icon }) => (
|
||||
<TabsTrigger
|
||||
className="settings-dialog-nav-item"
|
||||
key={itemDialog}
|
||||
value={itemDialog}
|
||||
aria-current={dialog === itemDialog ? "page" : undefined}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<div className={`settings-dialog-content${isFormPane ? " settings-dialog-content--form" : ""}`}>
|
||||
{renderSettingsContent()}
|
||||
</div>
|
||||
) : model.renderLogout()}
|
||||
</section>
|
||||
</div>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
// 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);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { FeedbackDialog } from "@/components/feedback-dialog";
|
||||
import { Menu } from "@base-ui/react/menu";
|
||||
import { SidebarCurrentHighlight } from "@/components/shell-motion";
|
||||
import { useSortableSessions } from "@/components/sidebar-sortable-loader";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu-lazy";
|
||||
import {
|
||||
CalendarDays,
|
||||
ChevronRight,
|
||||
@@ -161,6 +169,7 @@ export function AppSidebar({
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||
const accountMenuOpen = controls ? controlledMenuOpen : ownMenuOpen;
|
||||
const onAccountMenuOpenChange = controls ? controlledMenuOpenChange : setOwnMenuOpen;
|
||||
const sortableSessions = useSortableSessions();
|
||||
const hasMoreSessions = sessionControls?.hasMore ?? false;
|
||||
const onLoadMoreSessions = sessionControls?.onLoadMore;
|
||||
const favoriteSessions = sessions.filter((session) => session.pinned);
|
||||
@@ -248,6 +257,39 @@ export function AppSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
function renderSortableGroup(groupSessions: readonly SidebarSession[], indexOffset: number) {
|
||||
const onReorder = sessionControls?.onReorder;
|
||||
if (!onReorder || !sortableSessions || !sessionControls || !onSelectSession) {
|
||||
return groupSessions.map((session, index) => renderSession(session, indexOffset + index));
|
||||
}
|
||||
const Group = sortableSessions.SortableSessionGroup;
|
||||
return (
|
||||
<Group
|
||||
sessions={groupSessions}
|
||||
onReorder={onReorder}
|
||||
renderRow={(session) => ({
|
||||
session,
|
||||
active: session.id === activeSessionId,
|
||||
opening: session.id === openingSessionId,
|
||||
disabled: sessionControls.disabled,
|
||||
menuOpen: sessionControls.menuSessionId === session.id,
|
||||
onMenuOpenChange: (open: boolean) => sessionControls.onMenuSessionChange(open ? session.id : null),
|
||||
onSelect: () => {
|
||||
onSelectSession(session.id);
|
||||
closeDrawer();
|
||||
},
|
||||
onTogglePinned: () => sessionControls.onTogglePinned(session.id),
|
||||
onRename: () => sessionControls.onRename(session.id),
|
||||
onShare: () => sessionControls.onShare(session.id),
|
||||
onDelete: () => sessionControls.onDelete(session.id),
|
||||
rowRef: indexOffset + groupSessions.findIndex((item) => item.id === session.id) === 0
|
||||
? (element: HTMLButtonElement | null) => { firstSessionRef.current = element; }
|
||||
: undefined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* 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(
|
||||
@@ -258,16 +300,16 @@ export function AppSidebar({
|
||||
pointer: { onPointerEnter?: () => void; onPointerDown?: () => void } = {},
|
||||
) {
|
||||
return onOpen ? (
|
||||
<Menu.Item className={className} onClick={onOpen} {...pointer}>{content}</Menu.Item>
|
||||
<DropdownMenuItem className={className} onClick={onOpen} {...pointer}>{content}</DropdownMenuItem>
|
||||
) : (
|
||||
<Menu.LinkItem
|
||||
<DropdownMenuItem
|
||||
className={className}
|
||||
closeOnClick
|
||||
render={<AppLink href={accountDialogHref(dialog)} onClick={leaveChat} />}
|
||||
{...pointer}
|
||||
>
|
||||
{content}
|
||||
</Menu.LinkItem>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,28 +324,34 @@ export function AppSidebar({
|
||||
<SidebarMenuItem>
|
||||
{controls ? (
|
||||
<SidebarMenuButton
|
||||
className="new-chat"
|
||||
className="sidebar-nav-link"
|
||||
data-nav="new"
|
||||
type="button"
|
||||
tooltip="新对话"
|
||||
isActive={pathname === "/"}
|
||||
disabled={newChatDisabled}
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
{pathname === "/" ? <SidebarCurrentHighlight /> : null}
|
||||
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
|
||||
{showExpandedContent ? <span>{creatingSession ? "正在创建" : "新建对话"}</span> : null}
|
||||
</SidebarMenuButton>
|
||||
) : (
|
||||
<SidebarMenuLink className="new-chat" tooltip="新对话" href={newChatHref()} onClick={handleNewChatLink}>
|
||||
<SidebarMenuLink className="sidebar-nav-link" data-nav="new" tooltip="新对话" isActive={pathname === "/"} href={newChatHref()} onClick={handleNewChatLink}>
|
||||
{pathname === "/" ? <SidebarCurrentHighlight /> : null}
|
||||
<SquarePen size={18} strokeWidth={1.75} aria-hidden="true" />
|
||||
{showExpandedContent ? <span>新建对话</span> : null}
|
||||
</SidebarMenuLink>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
{NAV_PAGES.filter((page) => page.href !== "/synastry" || compatibilityEnabled).map(({ href, label, icon: Icon }) => (
|
||||
<SidebarMenuItem key={href}>
|
||||
{NAV_PAGES.filter((page) => page.href !== "/synastry" || compatibilityEnabled).map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
return (
|
||||
<SidebarMenuItem key={href} className="sidebar-nav-item">
|
||||
<SidebarMenuLink
|
||||
className="report-nav-button"
|
||||
className="sidebar-nav-link"
|
||||
tooltip={label}
|
||||
isActive={pathname === href || pathname.startsWith(`${href}/`)}
|
||||
isActive={active}
|
||||
href={href}
|
||||
onPointerEnter={() => prefetchSecondaryPage(href)}
|
||||
onPointerDown={() => prefetchSecondaryPage(href)}
|
||||
@@ -311,12 +359,14 @@ export function AppSidebar({
|
||||
{...(href === "/chart" ? { "data-onboarding-anchor": "sidebar-chart" } : {})}
|
||||
{...(href === "/ephemeris" ? { "data-onboarding-anchor": "ephemeris" } : {})}
|
||||
>
|
||||
{active ? <SidebarCurrentHighlight /> : null}
|
||||
<Icon size={18} strokeWidth={1.75} aria-hidden="true" />
|
||||
{showExpandedContent ? <span>{label}</span> : null}
|
||||
{href === "/chart" && showChartDot ? <OnboardingDot /> : null}
|
||||
</SidebarMenuLink>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
@@ -345,18 +395,20 @@ export function AppSidebar({
|
||||
<Star size={13} strokeWidth={1.75} aria-hidden="true" />
|
||||
收藏
|
||||
</p>
|
||||
{favoriteSessions.map((session, index) => renderSession(session, index))}
|
||||
{renderSortableGroup(favoriteSessions, 0)}
|
||||
</div>
|
||||
) : null}
|
||||
{historyGroups.map((group) => (
|
||||
{historyGroups.map((group) => {
|
||||
const earlier = historyGroups
|
||||
.slice(0, historyGroups.indexOf(group))
|
||||
.reduce((count, item) => count + item.sessions.length, 0);
|
||||
return (
|
||||
<div key={group.key}>
|
||||
<p className="sidebar-group-label">{group.label}</p>
|
||||
{group.sessions.map((session) => renderSession(
|
||||
session,
|
||||
favoriteSessions.length + historySessions.findIndex((item) => item.id === session.id),
|
||||
))}
|
||||
{renderSortableGroup(group.sessions, favoriteSessions.length + earlier)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{hasMoreSessions ? <div ref={loadMoreRef} aria-hidden className="session-list-sentinel" /> : null}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
@@ -387,9 +439,9 @@ export function AppSidebar({
|
||||
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"
|
||||
<DropdownMenu open={accountMenuOpen} onOpenChange={onAccountMenuOpenChange} modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
className="sidebar-account-trigger"
|
||||
ref={accountTriggerRef}
|
||||
type="button"
|
||||
>
|
||||
@@ -397,63 +449,58 @@ export function AppSidebar({
|
||||
? <UserAvatar avatar={account.avatar} size={32} className="profile-avatar" />
|
||||
: <span className="profile-initial" aria-hidden="true">{account.initial}</span>}
|
||||
{showExpandedContent ? <span><b>{account.name}</b></span> : null}
|
||||
{showExpandedContent ? <ChevronRight className={accountMenuOpen ? "chevron is-open" : "chevron"} aria-hidden="true" /> : null}
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner
|
||||
side={isMobile || state === "expanded" ? "top" : "right"}
|
||||
align={isMobile || state === "expanded" ? "end" : "center"}
|
||||
sideOffset={8}
|
||||
collisionPadding={12}
|
||||
>
|
||||
<Menu.Popup className="account-menu-popup" aria-label="账户菜单">
|
||||
<div className="account-menu-identity">
|
||||
{account.avatar
|
||||
? <UserAvatar avatar={account.avatar} size={40} className="account-menu-avatar" />
|
||||
: <span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>}
|
||||
<span><b>{account.name}</b><small>{account.email}</small></span>
|
||||
</div>
|
||||
{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.Item className="account-menu-item" onClick={() => { onAccountMenuOpenChange?.(false); setFeedbackOpen(true); }}>
|
||||
<MessageSquareWarning aria-hidden="true" /><span>反馈与投诉</span>
|
||||
</Menu.Item>
|
||||
<Menu.Separator className="account-menu-separator" />
|
||||
<ThemePreferenceMenu />
|
||||
<Menu.Separator className="account-menu-separator" />
|
||||
{accountEntry("logout", onOpenLogout, "account-menu-item account-menu-danger", (
|
||||
<><LogOut aria-hidden="true" /><span>退出登录</span></>
|
||||
))}
|
||||
{/* The documents the user agreed to, one tap away (2026-09-30). */}
|
||||
<div className="account-menu-legal">
|
||||
<Menu.LinkItem href="/terms" target="_blank" rel="noopener">用户协议</Menu.LinkItem>
|
||||
<span aria-hidden="true">·</span>
|
||||
<Menu.LinkItem href="/privacy" target="_blank" rel="noopener">隐私政策</Menu.LinkItem>
|
||||
</div>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="account-menu-popup w-auto min-w-56"
|
||||
aria-label="账户菜单"
|
||||
side={isMobile || state === "expanded" ? "top" : "right"}
|
||||
align={isMobile || state === "expanded" ? "end" : "center"}
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="account-menu-identity">
|
||||
{account.avatar
|
||||
? <UserAvatar avatar={account.avatar} size={40} className="account-menu-avatar" />
|
||||
: <span className="account-menu-avatar" aria-hidden="true">{account.initial}</span>}
|
||||
<span><b>{account.name}</b><small>{account.email}</small></span>
|
||||
</div>
|
||||
{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></>
|
||||
), {
|
||||
onPointerEnter: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
|
||||
onPointerDown: () => { prefetchBillingPanel(); prefetchPaymentPackages(); },
|
||||
})}
|
||||
<DropdownMenuItem className="account-menu-item" onClick={() => { onAccountMenuOpenChange?.(false); setFeedbackOpen(true); }}>
|
||||
<MessageSquareWarning aria-hidden="true" /><span>反馈与投诉</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="account-menu-separator" />
|
||||
<ThemePreferenceMenu />
|
||||
<DropdownMenuSeparator className="account-menu-separator" />
|
||||
{accountEntry("logout", onOpenLogout, "account-menu-item account-menu-danger", (
|
||||
<><LogOut aria-hidden="true" /><span>退出登录</span></>
|
||||
))}
|
||||
<div className="account-menu-legal">
|
||||
<DropdownMenuItem render={<a href="/terms" target="_blank" rel="noopener" />}>用户协议</DropdownMenuItem>
|
||||
<span aria-hidden="true">·</span>
|
||||
<DropdownMenuItem render={<a href="/privacy" target="_blank" rel="noopener" />}>隐私政策</DropdownMenuItem>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : signedOut ? (
|
||||
/* `/login` crosses an auth boundary, so it stays a document load. */
|
||||
<a className="profile-trigger" href="/login" onClick={leaveChat}>
|
||||
<a className="sidebar-account-trigger" href="/login" onClick={leaveChat}>
|
||||
<span className="profile-initial" aria-hidden="true">·</span>
|
||||
{showExpandedContent ? <span><b>去登录</b></span> : null}
|
||||
</a>
|
||||
) : (
|
||||
/* The account is still being read: the same 56px block, inert, with
|
||||
/* The account is still being read: the same block, inert, with
|
||||
no link and no spinner. It becomes the menu as soon as it lands. */
|
||||
<div className="profile-trigger" aria-hidden="true">
|
||||
<div className="sidebar-account-trigger" aria-hidden="true">
|
||||
<span className="profile-initial">·</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -277,7 +277,7 @@ export function BillingPanel({ account: seedAccount, highlightedPlan, initialTab
|
||||
<h2 id="membership-orders-title">全部订单</h2>
|
||||
<p>查看套餐与点数包的支付和到账状态。</p>
|
||||
</div>
|
||||
<button className="membership-orders-refresh" type="button" onClick={() => void fetchOrders()}>刷新</button>
|
||||
<Button className="membership-orders-refresh" type="button" variant="outline" onClick={() => void fetchOrders()}>刷新</Button>
|
||||
</div>
|
||||
{ordersState === "unavailable" && <p className="membership-empty">订单记录暂时不可用,请稍后刷新。</p>}
|
||||
{ordersState === "ready" && orders.length === 0 && <p className="membership-empty">暂无订单。</p>}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { Coins } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { motionPresets } from "@/lib/motion/presets";
|
||||
|
||||
export function CreditBalanceButton({
|
||||
credits,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
readonly credits: number | null;
|
||||
readonly label: string;
|
||||
readonly onClick: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
return (
|
||||
<Button variant="outline" className="credit-balance" type="button" aria-label={label} onClick={onClick}>
|
||||
<Coins className="credit-icon" aria-hidden="true" />
|
||||
<RollingValue value={credits} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function RollingValue({ value }: { readonly value: number | null }) {
|
||||
const [roll, setRoll] = useState({ value, direction: 1 });
|
||||
const nodeRef = useRef<HTMLSpanElement>(null);
|
||||
const skipEnter = useRef(true);
|
||||
if (value !== roll.value) {
|
||||
const decreased = value !== null && roll.value !== null && value < roll.value;
|
||||
setRoll({ value, direction: decreased ? -1 : 1 });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (skipEnter.current) {
|
||||
skipEnter.current = false;
|
||||
return;
|
||||
}
|
||||
const node = nodeRef.current;
|
||||
const preset = motionPresets.numberChange;
|
||||
const reduced = typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (!node || reduced || preset.reduced.instant) return;
|
||||
const direction = roll.direction;
|
||||
void import("motion").then((mod) => {
|
||||
if (!node.isConnected) return;
|
||||
void mod.animate(node, { y: [direction * 12, 0] }, {
|
||||
duration: preset.duration,
|
||||
ease: [...preset.ease],
|
||||
});
|
||||
}, () => undefined);
|
||||
}, [roll.direction, roll.value]);
|
||||
|
||||
return (
|
||||
<span className="credit-roll">
|
||||
<span
|
||||
key={value === null ? "empty" : String(value)}
|
||||
ref={nodeRef}
|
||||
className="credit-roll-value"
|
||||
>
|
||||
{value === null ? "—" : value}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore, type ComponentProps } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type MenuModule = typeof import("@/components/ui/dropdown-menu");
|
||||
|
||||
let loaded: MenuModule | null = null;
|
||||
let started = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (!started) {
|
||||
started = true;
|
||||
void import("@/components/ui/dropdown-menu").then((mod) => {
|
||||
loaded = mod;
|
||||
notify();
|
||||
}, () => {
|
||||
started = false;
|
||||
});
|
||||
}
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function useMenu(): MenuModule | null {
|
||||
return useSyncExternalStore(subscribe, () => loaded, () => null);
|
||||
}
|
||||
|
||||
export function DropdownMenu(props: ComponentProps<MenuModule["DropdownMenu"]>) {
|
||||
const menu = useMenu();
|
||||
if (!menu) return <>{props.children}</>;
|
||||
return <menu.DropdownMenu {...props} />;
|
||||
}
|
||||
|
||||
export function DropdownMenuTrigger(props: ComponentProps<MenuModule["DropdownMenuTrigger"]>) {
|
||||
const menu = useMenu();
|
||||
if (!menu) {
|
||||
const label = "aria-label" in props && typeof props["aria-label"] === "string" ? props["aria-label"] : undefined;
|
||||
const className = typeof props.className === "string" ? props.className : undefined;
|
||||
return (
|
||||
<Button type="button" variant="ghost" className={className} disabled={Boolean(props.disabled)} aria-label={label}>
|
||||
{props.children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return <menu.DropdownMenuTrigger {...props} />;
|
||||
}
|
||||
|
||||
export function DropdownMenuContent(props: ComponentProps<MenuModule["DropdownMenuContent"]>) {
|
||||
const menu = useMenu();
|
||||
if (!menu) return null;
|
||||
return <menu.DropdownMenuContent {...props} />;
|
||||
}
|
||||
|
||||
export function DropdownMenuItem(props: ComponentProps<MenuModule["DropdownMenuItem"]>) {
|
||||
const menu = useMenu();
|
||||
if (!menu) return null;
|
||||
return <menu.DropdownMenuItem {...props} />;
|
||||
}
|
||||
|
||||
export function DropdownMenuSeparator(props: ComponentProps<MenuModule["DropdownMenuSeparator"]>) {
|
||||
const menu = useMenu();
|
||||
if (!menu) return null;
|
||||
return <menu.DropdownMenuSeparator {...props} />;
|
||||
}
|
||||
|
||||
export function DropdownMenuGroup(props: ComponentProps<MenuModule["DropdownMenuGroup"]>) {
|
||||
const menu = useMenu();
|
||||
if (!menu) return null;
|
||||
return <menu.DropdownMenuGroup {...props} />;
|
||||
}
|
||||
|
||||
export function DropdownMenuLabel(props: ComponentProps<MenuModule["DropdownMenuLabel"]>) {
|
||||
const menu = useMenu();
|
||||
if (!menu) return null;
|
||||
return <menu.DropdownMenuLabel {...props} />;
|
||||
}
|
||||
|
||||
export function DropdownMenuRadioGroup(props: ComponentProps<MenuModule["DropdownMenuRadioGroup"]>) {
|
||||
const menu = useMenu();
|
||||
if (!menu) return null;
|
||||
return <menu.DropdownMenuRadioGroup {...props} />;
|
||||
}
|
||||
|
||||
export function DropdownMenuRadioItem(props: ComponentProps<MenuModule["DropdownMenuRadioItem"]>) {
|
||||
const menu = useMenu();
|
||||
if (!menu) return null;
|
||||
return <menu.DropdownMenuRadioItem {...props} />;
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { LEGAL_ENTITY } from "@/lib/legal-entity";
|
||||
import {
|
||||
FEEDBACK_BODY_MAX,
|
||||
@@ -24,13 +34,11 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
* stores the row for the admin console; the success view promises a reply
|
||||
* window and gives the contact email.
|
||||
*/
|
||||
export function FeedbackDialog({ accountEmail, sessionId, onClose }: Readonly<{
|
||||
export function FeedbackDialogBody({ accountEmail, sessionId, onClose }: Readonly<{
|
||||
accountEmail?: string | null;
|
||||
sessionId?: string | null;
|
||||
onClose: () => void;
|
||||
}>) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const titleId = useId();
|
||||
const [type, setType] = useState<FeedbackType>("problem");
|
||||
const [body, setBody] = useState("");
|
||||
const [contact, setContact] = useState(accountEmail ?? "");
|
||||
@@ -39,16 +47,6 @@ export function FeedbackDialog({ accountEmail, sessionId, onClose }: Readonly<{
|
||||
const [sent, setSent] = useState(false);
|
||||
const canAttach = Boolean(sessionId && UUID.test(sessionId));
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
const previous = document.activeElement;
|
||||
element?.showModal();
|
||||
return () => {
|
||||
element?.close();
|
||||
if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true });
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function submit() {
|
||||
const problem = feedbackBodyProblem(body);
|
||||
if (problem) {
|
||||
@@ -82,18 +80,7 @@ export function FeedbackDialog({ accountEmail, sessionId, onClose }: Readonly<{
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="feedback-dialog"
|
||||
aria-labelledby={titleId}
|
||||
onCancel={(event) => { event.preventDefault(); onClose(); }}
|
||||
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="feedback-dialog-panel">
|
||||
<header className="feedback-dialog-header">
|
||||
<h2 id={titleId}>反馈与投诉</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} aria-label="关闭反馈与投诉" title="关闭"><X aria-hidden="true" /></Button>
|
||||
</header>
|
||||
<>
|
||||
{sent ? (
|
||||
<div className="feedback-dialog-body feedback-dialog-done" role="status">
|
||||
<p>已收到,我们会在 {FEEDBACK_RESPONSE_WORKING_DAYS} 个工作日内处理。</p>
|
||||
@@ -102,16 +89,18 @@ export function FeedbackDialog({ accountEmail, sessionId, onClose }: Readonly<{
|
||||
</div>
|
||||
) : (
|
||||
<form className="feedback-dialog-body" onSubmit={(event) => { event.preventDefault(); void submit(); }}>
|
||||
<div className="feedback-dialog-types" role="radiogroup" aria-label="反馈类型">
|
||||
{FEEDBACK_TYPES.map((option) => (
|
||||
<button key={option} type="button" role="radio" aria-checked={type === option} onClick={() => setType(option)}>
|
||||
{FEEDBACK_TYPE_LABELS[option]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Tabs value={type} onValueChange={(next) => setType(next as FeedbackType)}>
|
||||
<TabsList className="feedback-dialog-types" aria-label="反馈类型">
|
||||
{FEEDBACK_TYPES.map((option) => (
|
||||
<TabsTrigger key={option} value={option}>
|
||||
{FEEDBACK_TYPE_LABELS[option]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<label className="feedback-dialog-field">
|
||||
<span>发生了什么</span>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={body}
|
||||
maxLength={FEEDBACK_BODY_MAX}
|
||||
rows={5}
|
||||
@@ -121,18 +110,35 @@ export function FeedbackDialog({ accountEmail, sessionId, onClose }: Readonly<{
|
||||
</label>
|
||||
<label className="feedback-dialog-field">
|
||||
<span>联系方式(选填)</span>
|
||||
<input value={contact} maxLength={200} onChange={(event) => setContact(event.target.value)} placeholder="邮箱或手机号" />
|
||||
<Input value={contact} maxLength={200} onChange={(event) => setContact(event.target.value)} placeholder="邮箱或手机号" />
|
||||
</label>
|
||||
{canAttach ? (
|
||||
<label className="feedback-dialog-check">
|
||||
<input type="checkbox" checked={attach} onChange={(event) => setAttach(event.target.checked)} />
|
||||
<Checkbox checked={attach} onCheckedChange={(checked) => setAttach(checked === true)} />
|
||||
<span>附上当前对话(只附编号,便于我们查看)</span>
|
||||
</label>
|
||||
) : null}
|
||||
<Button type="submit" disabled={sending}>{sending ? "正在提交…" : "提交"}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeedbackDialog(props: Readonly<{
|
||||
accountEmail?: string | null;
|
||||
sessionId?: string | null;
|
||||
onClose: () => void;
|
||||
}>) {
|
||||
return (
|
||||
<Dialog open onOpenChange={(next) => { if (!next) props.onClose(); }}>
|
||||
<DialogContent className="feedback-dialog sm:max-w-md" showCloseButton={false}>
|
||||
<DialogHeader className="feedback-dialog-header">
|
||||
<DialogTitle>反馈与投诉</DialogTitle>
|
||||
<Button variant="ghost" size="icon" onClick={props.onClose} aria-label="关闭反馈与投诉" title="关闭"><X aria-hidden="true" /></Button>
|
||||
</DialogHeader>
|
||||
<FeedbackDialogBody {...props} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UserAvatar } from "@/components/user-avatar";
|
||||
import { beamAvatarPalettes, type BeamAvatarPatch } from "@/lib/beam-avatar";
|
||||
import type { Account, Profile } from "@/lib/home-types";
|
||||
@@ -23,17 +24,18 @@ export function ProfilePanel({
|
||||
<section className="sheet-section avatar-section" aria-labelledby="avatar-section-title">
|
||||
<div className="section-heading">
|
||||
<b id="avatar-section-title">账户头像</b>
|
||||
<small>Beam 形象会在刷新和换设备后保持一致</small>
|
||||
</div>
|
||||
<div className="avatar-editor">
|
||||
<UserAvatar avatar={account.avatar} size={56} label="当前头像预览" />
|
||||
<div className="avatar-editor-controls">
|
||||
<div className="avatar-palette-list" role="radiogroup" aria-label="头像配色">
|
||||
{beamAvatarPalettes.map((palette, index) => (
|
||||
<button
|
||||
<Button
|
||||
key={palette.name}
|
||||
className="avatar-palette"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
role="radio"
|
||||
aria-checked={account.avatar?.palette === index}
|
||||
aria-label={palette.name}
|
||||
@@ -42,23 +44,24 @@ export function ProfilePanel({
|
||||
onClick={() => void persistAvatar({ action: "set_palette", palette: index })}
|
||||
>
|
||||
{palette.colors.map((color) => <span key={color} style={{ backgroundColor: color }} />)}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="button-secondary avatar-randomize"
|
||||
<Button
|
||||
className="avatar-randomize"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={avatarSaving}
|
||||
onClick={() => void persistAvatar({ action: "randomize" })}
|
||||
>
|
||||
{avatarSaving ? "保存中" : "换一个形象"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section className="sheet-section" aria-labelledby="account-info-title">
|
||||
<div className="section-heading"><b id="account-info-title">账户信息</b><small>登录与账户识别信息</small></div>
|
||||
<div className="section-heading"><b id="account-info-title">账户信息</b></div>
|
||||
<dl className="account-info-list">
|
||||
<div><dt>昵称</dt><dd>{profile.name.trim() || "尚未设置"}<small>与“我的星盘”名称同步</small></dd></div>
|
||||
<div><dt>登录邮箱</dt><dd>{account.user.email || "尚未读取邮箱"}</dd></div>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
|
||||
import type { PanelImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import {
|
||||
SIDEBAR_WIDTH_COLLAPSED,
|
||||
SIDEBAR_WIDTH_DEFAULT,
|
||||
SIDEBAR_WIDTH_MAX,
|
||||
SIDEBAR_WIDTH_MIN,
|
||||
SIDEBAR_WIDTH_TABLET,
|
||||
} from "@/lib/sidebar-state";
|
||||
import { useUiStore } from "@/stores/ui-store";
|
||||
|
||||
type ResizableModule = typeof import("@/components/ui/resizable");
|
||||
|
||||
let panels: ResizableModule | null = null;
|
||||
let started = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (!started) {
|
||||
started = true;
|
||||
void import("@/components/ui/resizable").then((mod) => {
|
||||
panels = mod;
|
||||
notify();
|
||||
}).catch(() => {
|
||||
started = false;
|
||||
});
|
||||
}
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function useResizablePanels(enabled: boolean): ResizableModule | null {
|
||||
return useSyncExternalStore(
|
||||
(listener) => (enabled ? subscribe(listener) : () => {}),
|
||||
() => (enabled ? panels : null),
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
export function ShellFrame({ sidebar, children }: { sidebar: ReactNode; children: ReactNode }) {
|
||||
const { isMobile, open, setOpen, viewport, ready } = useSidebar();
|
||||
const width = useUiStore((state) => state.sidebarWidth);
|
||||
const hydrated = useUiStore((state) => state.sidebarWidthHydrated);
|
||||
const hydrate = useUiStore((state) => state.hydrateSidebarWidth);
|
||||
const setSidebarWidth = useUiStore((state) => state.setSidebarWidth);
|
||||
const desktop = ready && !isMobile;
|
||||
const Resizable = useResizablePanels(desktop && hydrated);
|
||||
const panelRef = useRef<PanelImperativeHandle | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
useEffect(() => {
|
||||
const panel = panelRef.current;
|
||||
if (!panel || !Resizable) return;
|
||||
if (open && panel.isCollapsed()) panel.expand();
|
||||
if (!open && !panel.isCollapsed()) panel.collapse();
|
||||
}, [open, Resizable]);
|
||||
|
||||
if (!desktop) {
|
||||
return (
|
||||
<div className="shell-frame" data-shell-resize="mobile">
|
||||
{sidebar}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const expandedWidth = hydrated
|
||||
? width
|
||||
: viewport === "tablet" ? SIDEBAR_WIDTH_TABLET : SIDEBAR_WIDTH_DEFAULT;
|
||||
const pixelWidth = open ? expandedWidth : SIDEBAR_WIDTH_COLLAPSED;
|
||||
|
||||
if (!Resizable) {
|
||||
return (
|
||||
<div
|
||||
className="shell-frame"
|
||||
data-shell-resize="pending"
|
||||
style={{ ["--shell-sidebar-width" as string]: `${pixelWidth}px` }}
|
||||
>
|
||||
{sidebar}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function onLayoutChanged(_layout: Record<string, number>, meta: { isUserInteraction: boolean }) {
|
||||
if (!meta.isUserInteraction) return;
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
const pixels = panel.getSize().inPixels;
|
||||
if (panel.isCollapsed() || pixels < 120) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setOpen(true);
|
||||
setSidebarWidth(Math.round(pixels));
|
||||
}
|
||||
|
||||
return (
|
||||
<Resizable.ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
className="shell-frame"
|
||||
data-shell-resize="live"
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
>
|
||||
<Resizable.ResizablePanel
|
||||
id="app-sidebar"
|
||||
className="shell-sidebar-panel"
|
||||
panelRef={panelRef}
|
||||
collapsible
|
||||
collapsedSize={SIDEBAR_WIDTH_COLLAPSED}
|
||||
collapsedThreshold={SIDEBAR_WIDTH_MIN - SIDEBAR_WIDTH_COLLAPSED}
|
||||
minSize={SIDEBAR_WIDTH_MIN}
|
||||
maxSize={SIDEBAR_WIDTH_MAX}
|
||||
defaultSize={pixelWidth}
|
||||
groupResizeBehavior="preserve-pixel-size"
|
||||
>
|
||||
{sidebar}
|
||||
</Resizable.ResizablePanel>
|
||||
<Resizable.ResizableHandle className="shell-resize-handle" />
|
||||
<Resizable.ResizablePanel id="app-main" defaultSize="100%">
|
||||
{children}
|
||||
</Resizable.ResizablePanel>
|
||||
</Resizable.ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { loadDomAnimation, loadMotionReact, peekMotionReact, type MotionReact } from "@/lib/motion/load-react";
|
||||
import { motionPresets } from "@/lib/motion/presets";
|
||||
|
||||
const NAV = ["/", "/chart", "/ephemeris", "/reports", "/synastry", "/people"] as const;
|
||||
|
||||
function pageNavIndex(pathname: string): number {
|
||||
if (pathname === "/") return 0;
|
||||
const index = NAV.findIndex((href) => href !== "/" && (pathname === href || pathname.startsWith(`${href}/`)));
|
||||
return index === -1 ? 0 : index;
|
||||
}
|
||||
|
||||
function useMotionReact(): MotionReact | null {
|
||||
const [mod, setMod] = useState<MotionReact | null>(() => peekMotionReact());
|
||||
useEffect(() => {
|
||||
if (mod) return;
|
||||
let live = true;
|
||||
void loadMotionReact().then((next) => {
|
||||
if (live) setMod(next);
|
||||
}, () => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [mod]);
|
||||
return mod;
|
||||
}
|
||||
|
||||
/** Current-page wash. `layoutId` slides it once Motion has arrived. */
|
||||
export function SidebarCurrentHighlight() {
|
||||
const motion = useMotionReact();
|
||||
if (!motion) return <span className="sidebar-nav-current" />;
|
||||
return <SlidingHighlight motion={motion} />;
|
||||
}
|
||||
|
||||
function SlidingHighlight({ motion }: { readonly motion: MotionReact }) {
|
||||
const reduced = motion.useReducedMotion();
|
||||
const preset = motionPresets.selectionFollow;
|
||||
return (
|
||||
<motion.LazyMotion features={loadDomAnimation} strict>
|
||||
<motion.m.span
|
||||
layoutId="sidebar-current"
|
||||
className="sidebar-nav-current"
|
||||
transition={{
|
||||
duration: reduced ? preset.reduced.duration : preset.duration,
|
||||
ease: [...preset.ease],
|
||||
}}
|
||||
/>
|
||||
</motion.LazyMotion>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Page switch sits on the main pane grid. The grid element stays a plain div
|
||||
* until the first navigation after Motion loads, so the open page is not
|
||||
* remounted when the chunk arrives. Routing is not delayed.
|
||||
*/
|
||||
export function PageSwitch({ className, children }: { className: string; children: ReactNode }) {
|
||||
const pathname = usePathname() ?? "/";
|
||||
const motion = useMotionReact();
|
||||
const [frame, setFrame] = useState(() => ({ pathname, direction: 1, animated: false }));
|
||||
if (pathname !== frame.pathname) {
|
||||
const direction = pageNavIndex(pathname) >= pageNavIndex(frame.pathname) ? 1 : -1;
|
||||
setFrame({ pathname, direction, animated: motion !== null });
|
||||
}
|
||||
if (!frame.animated || motion === null) {
|
||||
return <div className={`${className} shell-page`}>{children}</div>;
|
||||
}
|
||||
return (
|
||||
<PagePresence motion={motion} className={className} pathname={frame.pathname} direction={frame.direction}>
|
||||
{children}
|
||||
</PagePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function PagePresence({
|
||||
motion,
|
||||
className,
|
||||
pathname,
|
||||
direction,
|
||||
children,
|
||||
}: {
|
||||
readonly motion: MotionReact;
|
||||
readonly className: string;
|
||||
readonly pathname: string;
|
||||
readonly direction: number;
|
||||
readonly children: ReactNode;
|
||||
}) {
|
||||
const reduced = motion.useReducedMotion();
|
||||
const enter = motionPresets.pageEnter;
|
||||
const exit = motionPresets.pageExit;
|
||||
const distance = reduced ? 0 : enter.distance;
|
||||
const enterDuration = reduced ? enter.reduced.duration : enter.duration;
|
||||
const exitDuration = reduced ? exit.reduced.duration : exit.duration;
|
||||
return (
|
||||
<motion.LazyMotion features={loadDomAnimation} strict>
|
||||
<motion.MotionConfig reducedMotion="user">
|
||||
<div className="shell-page-presence">
|
||||
<motion.AnimatePresence initial={false} mode="popLayout">
|
||||
<motion.m.div
|
||||
key={pathname}
|
||||
className={`${className} shell-page`}
|
||||
initial={{ opacity: 0, y: direction * distance }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, transition: { duration: exitDuration, ease: [...exit.ease] } }}
|
||||
transition={{ duration: enterDuration, ease: [...enter.ease] }}
|
||||
>
|
||||
{children}
|
||||
</motion.m.div>
|
||||
</motion.AnimatePresence>
|
||||
</div>
|
||||
</motion.MotionConfig>
|
||||
</motion.LazyMotion>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Menu } from "@base-ui/react/menu";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@@ -9,8 +8,15 @@ import {
|
||||
StarOff,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { forwardRef } from "react";
|
||||
import { forwardRef, type ReactNode } from "react";
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu-lazy";
|
||||
import { RECTIFICATION_SIDEBAR_OPENING_NOTE } from "@/lib/rectification-surface-state";
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||
import { sessionMutationMenuVisible } from "@/lib/chat-session-persistence";
|
||||
@@ -33,6 +39,7 @@ export type SidebarSessionControls = {
|
||||
readonly onRename: (sessionId: string) => void;
|
||||
readonly onShare: (sessionId: string) => void;
|
||||
readonly onDelete: (sessionId: string) => void;
|
||||
readonly onReorder?: (orderedIds: readonly string[]) => void;
|
||||
};
|
||||
|
||||
type SidebarSessionRowCommonProps = {
|
||||
@@ -52,15 +59,12 @@ type SidebarSessionRowInteractiveProps = SidebarSessionRowCommonProps & {
|
||||
readonly onRename: () => void;
|
||||
readonly onShare: () => void;
|
||||
readonly onDelete: () => void;
|
||||
readonly dragHandle?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only mode: the row is a plain link to `/?c=<id>` and nothing else.
|
||||
*
|
||||
* Same `.session-row > .session-main` markup as the interactive row, because a
|
||||
* second set of markup is exactly how the secondary pages ended up with rows
|
||||
* that had no 44px minimum, no padding and no current-item marker while still
|
||||
* reserving a 44px column for a menu button that was never rendered.
|
||||
* Same row markup as the interactive row, without the menu column.
|
||||
*/
|
||||
type SidebarSessionRowLinkProps = SidebarSessionRowCommonProps & {
|
||||
readonly href: string;
|
||||
@@ -87,7 +91,7 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
|
||||
|
||||
if (props.href !== undefined) {
|
||||
return (
|
||||
<div className="session-row" data-readonly="true">
|
||||
<div className="sidebar-session" data-readonly="true">
|
||||
<AppLink
|
||||
className="session-main"
|
||||
data-sidebar="menu-button"
|
||||
@@ -112,22 +116,24 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
|
||||
onRename,
|
||||
onShare,
|
||||
onDelete,
|
||||
dragHandle,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Menu.Root
|
||||
<DropdownMenu
|
||||
open={sessionMutationMenuVisible(menuOpen, disabled)}
|
||||
onOpenChange={onMenuOpenChange}
|
||||
modal={false}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div
|
||||
className="session-row"
|
||||
className="sidebar-session"
|
||||
data-sortable={dragHandle ? "true" : undefined}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
if (!disabled) onMenuOpenChange(true);
|
||||
}}
|
||||
>
|
||||
{dragHandle}
|
||||
<SidebarMenuButton
|
||||
ref={ref}
|
||||
className="session-main"
|
||||
@@ -140,29 +146,25 @@ export const SidebarSessionRow = forwardRef<HTMLButtonElement, SidebarSessionRow
|
||||
>
|
||||
<SessionRowBody session={session} opening={opening} />
|
||||
</SidebarMenuButton>
|
||||
<Menu.Trigger
|
||||
<DropdownMenuTrigger
|
||||
className="session-menu-trigger"
|
||||
type="button"
|
||||
aria-label={`${session.title} 更多操作`}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MoreHorizontal aria-hidden="true" />
|
||||
</Menu.Trigger>
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner className="session-actions-positioner" side="bottom" align="end" sideOffset={4} collisionPadding={12}>
|
||||
<Menu.Popup className="session-actions" aria-label={`${session.title} 操作`}>
|
||||
<Menu.Item className="session-action-item" onClick={onTogglePinned}>
|
||||
{session.pinned ? <StarOff aria-hidden="true" /> : <Star aria-hidden="true" />}
|
||||
<span>{session.pinned ? "取消收藏" : "收藏"}</span>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="session-action-item" onClick={onRename}><Pencil aria-hidden="true" /><span>重命名</span></Menu.Item>
|
||||
<Menu.Item className="session-action-item" onClick={onShare}><Share2 aria-hidden="true" /><span>转发</span></Menu.Item>
|
||||
<Menu.Separator className="session-actions-separator" />
|
||||
<Menu.Item className="session-action-item session-action-danger" onClick={onDelete}><Trash2 aria-hidden="true" /><span>删除</span></Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
<DropdownMenuContent className="sidebar-session-menu w-auto min-w-44" align="end" side="bottom" aria-label={`${session.title} 操作`}>
|
||||
<DropdownMenuItem className="session-action-item" onClick={onTogglePinned}>
|
||||
{session.pinned ? <StarOff aria-hidden="true" /> : <Star aria-hidden="true" />}
|
||||
<span>{session.pinned ? "取消收藏" : "收藏"}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="session-action-item" onClick={onRename}><Pencil aria-hidden="true" /><span>重命名</span></DropdownMenuItem>
|
||||
<DropdownMenuItem className="session-action-item" onClick={onShare}><Share2 aria-hidden="true" /><span>转发</span></DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="session-actions-separator" />
|
||||
<DropdownMenuItem className="session-action-item session-action-danger" onClick={onDelete}><Trash2 aria-hidden="true" /><span>删除</span></DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SidebarMenuItem } from "@/components/ui/sidebar";
|
||||
import {
|
||||
SidebarSessionRow,
|
||||
type SidebarSession,
|
||||
} from "@/components/sidebar-session-row";
|
||||
import { moveSessionIds } from "@/lib/session-groups";
|
||||
|
||||
type RowProps = {
|
||||
readonly session: SidebarSession;
|
||||
readonly active: boolean;
|
||||
readonly opening: boolean;
|
||||
readonly disabled: boolean;
|
||||
readonly menuOpen: boolean;
|
||||
readonly onMenuOpenChange: (open: boolean) => void;
|
||||
readonly onSelect: () => void;
|
||||
readonly onTogglePinned: () => void;
|
||||
readonly onRename: () => void;
|
||||
readonly onShare: () => void;
|
||||
readonly onDelete: () => void;
|
||||
readonly rowRef?: (element: HTMLButtonElement | null) => void;
|
||||
};
|
||||
|
||||
function SortableSessionRow({
|
||||
session,
|
||||
active,
|
||||
opening,
|
||||
disabled,
|
||||
menuOpen,
|
||||
onMenuOpenChange,
|
||||
onSelect,
|
||||
onTogglePinned,
|
||||
onRename,
|
||||
onShare,
|
||||
onDelete,
|
||||
rowRef,
|
||||
}: RowProps) {
|
||||
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
|
||||
id: session.id,
|
||||
});
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
>
|
||||
<SidebarSessionRow
|
||||
ref={rowRef}
|
||||
session={session}
|
||||
active={active}
|
||||
opening={opening}
|
||||
disabled={disabled}
|
||||
menuOpen={menuOpen}
|
||||
onMenuOpenChange={onMenuOpenChange}
|
||||
onSelect={onSelect}
|
||||
onTogglePinned={onTogglePinned}
|
||||
onRename={onRename}
|
||||
onShare={onShare}
|
||||
onDelete={onDelete}
|
||||
dragHandle={(
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="session-drag-handle"
|
||||
aria-label={`${session.title} 拖动排序`}
|
||||
disabled={disabled}
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function SortableSessionGroup({
|
||||
sessions,
|
||||
onReorder,
|
||||
renderRow,
|
||||
}: {
|
||||
readonly sessions: readonly SidebarSession[];
|
||||
readonly onReorder: (orderedIds: readonly string[]) => void;
|
||||
readonly renderRow: (session: SidebarSession) => RowProps;
|
||||
}) {
|
||||
const ids = sessions.map((session) => session.id);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
function onDragEnd(event: DragEndEvent) {
|
||||
const activeId = String(event.active.id);
|
||||
const overId = event.over ? String(event.over.id) : "";
|
||||
if (!overId || activeId === overId) return;
|
||||
onReorder(moveSessionIds(ids, activeId, overId));
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{sessions.map((session) => {
|
||||
const row = renderRow(session);
|
||||
return <SortableSessionRow key={session.id} {...row} />;
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import type { SortableSessionGroup } from "@/components/sidebar-session-sortable";
|
||||
|
||||
type SortableModule = {
|
||||
SortableSessionGroup: typeof SortableSessionGroup;
|
||||
};
|
||||
|
||||
let loaded: SortableModule | null = null;
|
||||
let started = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (!started) {
|
||||
started = true;
|
||||
void import("@/components/sidebar-session-sortable").then((mod) => {
|
||||
loaded = { SortableSessionGroup: mod.SortableSessionGroup };
|
||||
notify();
|
||||
}).catch(() => {
|
||||
started = false;
|
||||
});
|
||||
}
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** Client-only. The server snapshot stays null so the row list paints before dnd-kit arrives. */
|
||||
export function useSortableSessions(): SortableModule | null {
|
||||
return useSyncExternalStore(subscribe, snapshot, () => null);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Popover } from "@base-ui/react/popover";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { Check, ChevronDown, Plus } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
chatProfilePickerRows,
|
||||
type ChatProfileCatalogStatus,
|
||||
@@ -63,14 +64,14 @@ export function SubjectSwitcher({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) triggerRef.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger
|
||||
<PopoverTrigger
|
||||
ref={triggerRef}
|
||||
className="chat-header-chart chat-profile-picker-trigger"
|
||||
type="button"
|
||||
@@ -80,53 +81,52 @@ export function SubjectSwitcher({
|
||||
>
|
||||
<span className="chat-profile-picker-name">{label}</span>
|
||||
<ChevronDown className="chat-profile-picker-chevron" aria-hidden="true" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Positioner className="chat-profile-picker-positioner" side="bottom" align="start" sideOffset={8} collisionPadding={12}>
|
||||
<Popover.Popup
|
||||
id={listId}
|
||||
className="chat-profile-picker-popup"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
closePicker();
|
||||
}}
|
||||
>
|
||||
<Popover.Title className="chat-profile-picker-title">当前星盘</Popover.Title>
|
||||
{status === "loading" ? <OrbitWaiting className="chat-profile-picker-waiting" label="人物列表还在读" /> : null}
|
||||
{status === "failed" ? (
|
||||
<div className="chat-profile-picker-status" role="status">
|
||||
<p>人物列表暂时读不出来</p>
|
||||
<button className="chat-profile-picker-retry" type="button" onClick={onReload}>重试</button>
|
||||
</div>
|
||||
) : null}
|
||||
{status === "degraded" ? (
|
||||
<p className="chat-profile-picker-status" role="status">暂时读不到其他人</p>
|
||||
) : null}
|
||||
{deleted ? <p className="chat-profile-picker-status">这份资料已经删除,这段对话只能看。</p> : null}
|
||||
{rows.length > 0 ? (
|
||||
<div className="chat-profile-picker-options" role="group" aria-label="当前星盘">
|
||||
{rows.map((row) => (
|
||||
<ProfileRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
locked={locked}
|
||||
requestInFlight={requestInFlight}
|
||||
pending={pendingId === row.id}
|
||||
onChoose={() => void choose(row)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{showAddPerson ? (
|
||||
<div className="chat-profile-picker-add">
|
||||
<a href="/people">+ 添加一个人</a>
|
||||
</div>
|
||||
) : null}
|
||||
</Popover.Popup>
|
||||
</Popover.Positioner>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
id={listId}
|
||||
className="chat-profile-picker-popup"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
closePicker();
|
||||
}}
|
||||
>
|
||||
<PopoverTitle className="chat-profile-picker-title">当前星盘</PopoverTitle>
|
||||
{status === "loading" ? <OrbitWaiting className="chat-profile-picker-waiting" label="人物列表还在读" /> : null}
|
||||
{status === "failed" ? (
|
||||
<div className="chat-profile-picker-status" role="status">
|
||||
<p>人物列表暂时读不出来</p>
|
||||
<Button className="chat-profile-picker-retry" type="button" variant="ghost" onClick={onReload}>重试</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{status === "degraded" ? (
|
||||
<p className="chat-profile-picker-status" role="status">暂时读不到其他人</p>
|
||||
) : null}
|
||||
{deleted ? <p className="chat-profile-picker-status">这份资料已经删除,这段对话只能看。</p> : null}
|
||||
{rows.length > 0 ? (
|
||||
<div className="chat-profile-picker-options" role="group" aria-label="当前星盘">
|
||||
{rows.map((row) => (
|
||||
<ProfileRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
locked={locked}
|
||||
requestInFlight={requestInFlight}
|
||||
pending={pendingId === row.id}
|
||||
onChoose={() => void choose(row)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{showAddPerson ? (
|
||||
<div className="chat-profile-picker-add">
|
||||
<a href="/people"><Plus aria-hidden="true" />添加一个人</a>
|
||||
</div>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,9 +156,10 @@ function ProfileRow({
|
||||
|
||||
if (locked && !row.selected) {
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
className="chat-profile-picker-option"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={requestInFlight || pending}
|
||||
aria-label={`${row.name},新建会话并使用此人物`}
|
||||
onClick={onChoose}
|
||||
@@ -167,14 +168,15 @@ function ProfileRow({
|
||||
<b>{row.name}</b>
|
||||
<small>{requestInFlight ? "这段回答结束后再换" : "新建会话并使用此人物"}</small>
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
className="chat-profile-picker-option"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-pressed={row.selected}
|
||||
data-selected={row.selected ? "" : undefined}
|
||||
disabled={pending}
|
||||
@@ -185,6 +187,6 @@ function ProfileRow({
|
||||
<small>{row.detail}</small>
|
||||
</span>
|
||||
<Check className="chat-profile-picker-check" aria-hidden="true" />
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Menu } from "@base-ui/react/menu";
|
||||
import { Check, Monitor, Moon, Sun } from "lucide-react";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
} from "@/components/dropdown-menu-lazy";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
applyThemePreference,
|
||||
readThemePreference,
|
||||
@@ -38,23 +44,21 @@ export function ThemePreferencePanel() {
|
||||
<section className="theme-preference-panel" aria-labelledby="theme-preference-title">
|
||||
<div className="section-heading">
|
||||
<b id="theme-preference-title">外观主题</b>
|
||||
<small>选择 Jyotisha 的显示方式</small>
|
||||
</div>
|
||||
<div className="theme-preference-options" role="group" aria-label="外观主题">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
className="theme-preference-option"
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={preference === option.value}
|
||||
onClick={() => applyThemePreference(option.value)}
|
||||
>
|
||||
<OptionIcon value={option.value} />
|
||||
<span>{option.label}</span>
|
||||
{preference === option.value ? <Check aria-hidden="true" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Tabs
|
||||
className="theme-preference-options"
|
||||
value={preference}
|
||||
onValueChange={(next) => applyThemePreference(next as ThemePreference)}
|
||||
>
|
||||
<TabsList aria-label="外观主题">
|
||||
{options.map((option) => (
|
||||
<TabsTrigger className="theme-preference-option" key={option.value} value={option.value}>
|
||||
<OptionIcon value={option.value} />
|
||||
<span>{option.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -63,16 +67,16 @@ export function ThemePreferenceMenu() {
|
||||
const preference = useThemePreference();
|
||||
|
||||
return (
|
||||
<Menu.Group className="theme-menu">
|
||||
<Menu.GroupLabel className="theme-menu-label">外观</Menu.GroupLabel>
|
||||
<Menu.RadioGroup
|
||||
<DropdownMenuGroup className="theme-menu">
|
||||
<DropdownMenuLabel className="theme-menu-label">外观</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={preference}
|
||||
onValueChange={(next) => {
|
||||
applyThemePreference(next as ThemePreference);
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Menu.RadioItem
|
||||
<DropdownMenuRadioItem
|
||||
className="account-menu-item theme-menu-item"
|
||||
closeOnClick={false}
|
||||
key={option.value}
|
||||
@@ -80,12 +84,9 @@ export function ThemePreferenceMenu() {
|
||||
>
|
||||
<OptionIcon value={option.value} />
|
||||
<span>{option.label}</span>
|
||||
<Menu.RadioItemIndicator className="theme-menu-check">
|
||||
<Check aria-hidden="true" />
|
||||
</Menu.RadioItemIndicator>
|
||||
</Menu.RadioItem>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Group>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "react";
|
||||
|
||||
import { AppLink } from "@/components/app-link";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
|
||||
import { useSidebarViewport } from "@/hooks/use-sidebar-viewport";
|
||||
import {
|
||||
@@ -210,10 +211,14 @@ export function SidebarProvider({
|
||||
export function Sidebar({ id, className, ...props }: ComponentProps<"aside">) {
|
||||
const { isMobile, open, openMobile, setOpenMobile, setSidebarSurface } = useSidebarContext();
|
||||
const sidebar = <aside id={id ?? "chat-sidebar"} data-sidebar="sidebar" data-slot="sidebar" data-state={open ? "expanded" : "collapsed"} data-mobile-open={openMobile} className={cn("flex min-h-0 shrink-0 flex-col", className)} tabIndex={-1} {...props} ref={setSidebarSurface} />;
|
||||
return <>
|
||||
{sidebar}
|
||||
{isMobile && openMobile ? <button type="button" data-sidebar="scrim" data-slot="sidebar-scrim" aria-label="关闭聊天记录" className="sidebar-scrim" onClick={() => setOpenMobile(false)} /> : null}
|
||||
</>;
|
||||
if (!isMobile) return sidebar;
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile}>
|
||||
<SheetContent side="left" showCloseButton={false} className="sidebar-sheet w-[min(86vw,320px)] max-w-none gap-0 p-0 sm:max-w-none">
|
||||
{sidebar}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarHeader({ className, ...props }: ComponentProps<"div">) {
|
||||
|
||||
Reference in New Issue
Block a user