Spaces:
Runtime error
Runtime error
File size: 5,370 Bytes
0649f1b 66de9c8 0649f1b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 | import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
import type { ChatSummary, Doc } from "../../api/types";
import { DocPicker } from "../DocPicker";
import { ChevronIcon } from "../icons/Icons";
/** Pick posts from a dropdown: a button that says how many are chosen, and a panel under it with the search box and the
* ticks. Replaces the list that would otherwise sit under the source dropdown. `single` picks one post from the whole
* archive (the picker loads the list itself). */
/** A button that opens a panel under it (or above it when the window is short below). Used by the posts and chats menus. */
export function MenuPanel({ label, disabled = false, ariaLabel, children }: {
label: string;
disabled?: boolean;
ariaLabel: string;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const [place, setPlace] = useState<CSSProperties>({});
const button = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
// Under the button, as wide as it (at least 440px), or above it when the window is short below.
useLayoutEffect(() => {
if (!open || !button.current) return;
const r = button.current.getBoundingClientRect();
const width = Math.min(Math.max(r.width, 440), window.innerWidth - 24);
const left = Math.min(Math.max(12, r.left), window.innerWidth - width - 12);
const below = window.innerHeight - r.bottom;
setPlace(below >= 360 || below >= r.top
? { top: r.bottom + 6, left, width, maxHeight: Math.max(240, below - 18) }
: { bottom: window.innerHeight - r.top + 6, left, width, maxHeight: Math.max(240, r.top - 18) });
}, [open]);
useEffect(() => {
if (!open) return;
const away = (e: MouseEvent) => {
const t = e.target as Node;
if (!panel.current?.contains(t) && !button.current?.contains(t)) setOpen(false);
};
const close = () => setOpen(false);
document.addEventListener("mousedown", away);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("mousedown", away);
window.removeEventListener("resize", close);
};
}, [open]);
return (
<>
<button type="button" ref={button} className={`sp-trigger${open ? " open" : ""}`} aria-haspopup="dialog" aria-expanded={open}
disabled={disabled} onClick={() => setOpen((o) => !o)}>
<span className="sp-trigger-text">{label}</span>
<ChevronIcon size={16} className={`chevron${open ? " open" : ""}`} />
</button>
{open && createPortal(
<div ref={panel} className="sp-panel" role="dialog" aria-label={ariaLabel} style={place}>
{children}
</div>,
document.body,
)}
</>
);
}
/** Pick posts from a dropdown: a button that says how many are chosen, and a panel under it with the search box and the
* ticks. `single` picks one post from the whole archive (the picker loads the list itself). */
export function PostsMenu({ docs, selected, onChange, max, single = false, loading = false }: {
docs?: Doc[] | null;
selected: string[];
onChange: (ids: string[]) => void;
max?: number;
single?: boolean;
loading?: boolean;
}) {
const total = docs?.length ?? 0;
const label = loading ? "Loading posts"
: single ? (selected.length ? "1 post selected" : "Pick a post")
: selected.length === 0 ? "Posts: none"
: `Posts: ${selected.length} of ${total}`;
return (
<MenuPanel label={label} ariaLabel="Choose posts" disabled={loading || (!single && total === 0)}>
{single
? <DocPicker selected={selected} onChange={onChange} single />
: <DocPicker docs={docs ?? []} selected={selected} onChange={onChange} max={max} count={false} />}
{!single && max && Number.isFinite(max) && <p className="muted small sp-note">Up to {max} posts. They are combined into one.</p>}
</MenuPanel>
);
}
/** Pick notebook chats from a dropdown, the same way: a button with the count, and the chats to tick in a panel. */
export function ChatsMenu({ chats, selected, onChange, max }: {
chats: ChatSummary[];
selected: string[];
onChange: (ids: string[]) => void;
max: number;
}) {
const toggle = (id: string) =>
onChange(selected.includes(id) ? selected.filter((x) => x !== id) : selected.length < max ? [...selected, id] : selected);
const label = chats.length === 0 ? "Chats: none yet" : selected.length === 0 ? "Chats: none" : `Chats: ${selected.length} of ${chats.length}`;
return (
<MenuPanel label={label} ariaLabel="Choose chats" disabled={chats.length === 0}>
<div className="picker">
<ul className="picker-list">
{chats.map((c) => (
<li key={c.id}>
<label className={`picker-row${selected.includes(c.id) ? " on" : ""}`}>
<input type="checkbox" checked={selected.includes(c.id)} disabled={!selected.includes(c.id) && selected.length >= max}
onChange={() => toggle(c.id)} />
<span className="picker-title">{c.title?.trim() || "Untitled chat"}</span>
</label>
</li>
))}
</ul>
</div>
<p className="muted small sp-note">Up to {max} chats. Questions come from what was said in them.</p>
</MenuPanel>
);
}
|