Files
Jyotisha/frontend/src/components/subject-switcher.tsx
T

182 lines
5.5 KiB
TypeScript

"use client";
import { Popover } from "@base-ui/react/popover";
import { Check } from "lucide-react";
import { useId, useRef, useState } from "react";
import {
chatProfilePickerRows,
type ChatProfileCatalogStatus,
type ChatProfilePickerRow,
} from "@/lib/chat-profile-picker-model";
import { OrbitWaiting } from "@/components/orbit-waiting";
import type { ChartLibraryRecord } from "@/lib/home-types";
export type SubjectSwitcherProps = {
readonly library: readonly ChartLibraryRecord[];
readonly status: ChatProfileCatalogStatus;
readonly selectedId: string;
readonly label: string;
readonly locked?: boolean;
readonly requestInFlight?: boolean;
readonly deleted?: boolean;
readonly onChoose: (row: ChatProfilePickerRow) => Promise<boolean> | boolean;
readonly onReload: () => void;
};
export function SubjectSwitcher({
library,
status,
selectedId,
label,
locked = false,
requestInFlight = false,
deleted = false,
onChoose,
onReload,
}: SubjectSwitcherProps) {
const [open, setOpen] = useState(false);
const [pendingId, setPendingId] = useState<string | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const listId = useId();
const rows = chatProfilePickerRows({ status, library, selectedId }).slice(0, 5);
function closePicker() {
setOpen(false);
triggerRef.current?.focus({ preventScroll: true });
}
async function choose(row: ChatProfilePickerRow) {
if (!row.complete || pendingId) return;
if (row.selected) closePicker();
if (row.selected) return;
setPendingId(row.id);
try {
const ok = await onChoose(row);
if (ok) closePicker();
} finally {
setPendingId(null);
}
}
return (
<Popover.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) triggerRef.current?.focus({ preventScroll: true });
}}
>
<Popover.Trigger
ref={triggerRef}
className="chat-header-chart chat-profile-picker-trigger"
type="button"
aria-expanded={open}
aria-controls={listId}
aria-label={`当前星盘:${label}`}
>
<span className="chat-profile-picker-name">{label}</span>
</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}
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
function ProfileRow({
row,
locked,
requestInFlight,
pending,
onChoose,
}: {
readonly row: ChatProfilePickerRow;
readonly locked: boolean;
readonly requestInFlight: boolean;
readonly pending: boolean;
readonly onChoose: () => void;
}) {
if (!row.complete) {
return (
<div className="chat-profile-picker-option" aria-disabled="true">
<span className="chat-profile-picker-copy">
<b>{row.name}</b>
<small>{row.detail}</small>
</span>
</div>
);
}
if (locked && !row.selected) {
return (
<button
className="chat-profile-picker-option"
type="button"
disabled={requestInFlight || pending}
aria-label={`${row.name},新建会话并使用此人物`}
onClick={onChoose}
>
<span className="chat-profile-picker-copy">
<b>{row.name}</b>
<small>{requestInFlight ? "这段回答结束后再换" : "新建会话并使用此人物"}</small>
</span>
</button>
);
}
return (
<button
className="chat-profile-picker-option"
type="button"
aria-pressed={row.selected}
data-selected={row.selected ? "" : undefined}
disabled={pending}
onClick={onChoose}
>
<span className="chat-profile-picker-copy">
<b>{row.name}</b>
<small>{row.detail}</small>
</span>
<Check className="chat-profile-picker-check" aria-hidden="true" />
</button>
);
}