159 lines
7.0 KiB
TypeScript
159 lines
7.0 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||
import type { Node } from "./api";
|
||
import { Icon } from "./Icon";
|
||
|
||
/** Аркуш CRM (#sheet-root > .backdrop + .sheet). */
|
||
export function Sheet(props: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode }) {
|
||
useEffect(() => {
|
||
const k = (e: KeyboardEvent) => { if (e.key === "Escape") props.onClose(); };
|
||
window.addEventListener("keydown", k);
|
||
document.body.classList.add("lock");
|
||
return () => { window.removeEventListener("keydown", k); document.body.classList.remove("lock"); };
|
||
}, [props.onClose]);
|
||
return (
|
||
<div id="sheet-root" className="open">
|
||
<div className="backdrop" onClick={props.onClose} />
|
||
<div className="sheet" role="dialog" aria-modal="true" aria-label={props.title}>
|
||
<div className="grab" />
|
||
<div className="top">
|
||
<span className="tt">{props.title}</span>
|
||
<button className="icon-btn x" onClick={props.onClose} title="Закрыть"><Icon name="x" /></button>
|
||
</div>
|
||
<div className="sc">{props.children}</div>
|
||
{props.footer && <div className="bot">{props.footer}</div>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function PromptDialog(props: {
|
||
title: string; label: string; initial?: string; ok: string;
|
||
onOk: (v: string) => Promise<void> | void; onClose: () => void;
|
||
}) {
|
||
const [v, setV] = useState(props.initial ?? "");
|
||
const [err, setErr] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const ref = useRef<HTMLInputElement>(null);
|
||
useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
|
||
const submit = async () => {
|
||
if (!v.trim()) { setErr("Введите название"); return; }
|
||
setBusy(true);
|
||
try { await props.onOk(v.trim()); props.onClose(); }
|
||
catch (e) { setErr((e as Error).message); setBusy(false); }
|
||
};
|
||
return (
|
||
<Sheet title={props.title} onClose={props.onClose} footer={<>
|
||
<span className="sp" />
|
||
<button className="btn ghost" onClick={props.onClose}>Отмена</button>
|
||
<button className="btn gold" disabled={busy} onClick={submit}>{props.ok}</button>
|
||
</>}>
|
||
<div className="f">
|
||
<label>{props.label}</label>
|
||
<div className="in big">
|
||
<input ref={ref} value={v} maxLength={200} onChange={(e) => setV(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === "Enter") void submit(); }} />
|
||
</div>
|
||
{err && <div className="err">{err}</div>}
|
||
</div>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
export function ConfirmDialog(props: {
|
||
title: string; text: ReactNode; ok: string; danger?: boolean;
|
||
onOk: () => Promise<void> | void; onClose: () => void;
|
||
}) {
|
||
const [err, setErr] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const go = async () => {
|
||
setBusy(true);
|
||
try { await props.onOk(); props.onClose(); }
|
||
catch (e) { setErr((e as Error).message); setBusy(false); }
|
||
};
|
||
return (
|
||
<Sheet title={props.title} onClose={props.onClose} footer={<>
|
||
<span className="sp" />
|
||
<button className="btn ghost" onClick={props.onClose}>Отмена</button>
|
||
<button className={props.danger ? "btn dng" : "btn gold"} disabled={busy} onClick={go}>{props.ok}</button>
|
||
</>}>
|
||
<div className="block mt-10">{props.text}</div>
|
||
{err && <div className="err">{err}</div>}
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
function pathTitle(n: Node, byId: Map<string, Node>) {
|
||
const parts: string[] = [];
|
||
let cur: Node | undefined = n;
|
||
let guard = 0;
|
||
while (cur && guard++ < 100) { parts.unshift(cur.title); cur = cur.parentId ? byId.get(cur.parentId) : undefined; }
|
||
return parts.join(" / ");
|
||
}
|
||
|
||
/** Выбор папки назначения для переноса. */
|
||
export function MoveDialog(props: {
|
||
node: Node; nodes: Node[]; onOk: (parentId: string | null) => Promise<void>; onClose: () => void;
|
||
}) {
|
||
const [err, setErr] = useState("");
|
||
const [q, setQ] = useState("");
|
||
const byId = useMemo(() => new Map(props.nodes.map((n) => [n.id, n])), [props.nodes]);
|
||
const options = useMemo(() => {
|
||
// Нельзя внутрь себя и своих потомков.
|
||
const banned = new Set<string>([props.node.id]);
|
||
let grew = true;
|
||
while (grew) {
|
||
grew = false;
|
||
for (const n of props.nodes) if (n.parentId && banned.has(n.parentId) && !banned.has(n.id)) { banned.add(n.id); grew = true; }
|
||
}
|
||
return props.nodes
|
||
.filter((n) => n.kind === "folder" && !banned.has(n.id))
|
||
.map((n) => ({ id: n.id, label: pathTitle(n, byId) }))
|
||
.sort((a, b) => a.label.localeCompare(b.label, "uk"));
|
||
}, [props.nodes, props.node.id, byId]);
|
||
const shown = options.filter((o) => o.label.toLowerCase().includes(q.toLowerCase()));
|
||
const pick = async (id: string | null) => {
|
||
try { await props.onOk(id); props.onClose(); } catch (e) { setErr((e as Error).message); }
|
||
};
|
||
return (
|
||
<Sheet title={`Переместить «${props.node.title}»`} onClose={props.onClose}>
|
||
<div className="f"><div className="in"><input placeholder="Найти папку…" value={q} onChange={(e) => setQ(e.target.value)} autoFocus /></div></div>
|
||
{err && <div className="err">{err}</div>}
|
||
<div className="mt-10">
|
||
<button className="mrow" disabled={props.node.parentId === null} onClick={() => pick(null)}>
|
||
<Icon name="home" /><div className="t">В корень{props.node.parentId === null && <small>уже здесь</small>}</div>
|
||
</button>
|
||
{shown.map((o) => (
|
||
<button key={o.id} className="mrow" disabled={props.node.parentId === o.id} onClick={() => pick(o.id)}>
|
||
<Icon name="folder" /><div className="t">{o.label}{props.node.parentId === o.id && <small>уже здесь</small>}</div>
|
||
</button>
|
||
))}
|
||
{shown.length === 0 && <div className="empty">Папок не найдено</div>}
|
||
</div>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
/** Выбор страницы для ссылки. */
|
||
export function PagePickDialog(props: { nodes: Node[]; exclude?: string; onPick: (n: Node) => void; onClose: () => void }) {
|
||
const [q, setQ] = useState("");
|
||
const byId = useMemo(() => new Map(props.nodes.map((n) => [n.id, n])), [props.nodes]);
|
||
const list = props.nodes
|
||
.filter((n) => n.kind === "page" && n.id !== props.exclude && n.title.toLowerCase().includes(q.toLowerCase()))
|
||
.map((n) => ({ n, label: pathTitle(n, byId) }))
|
||
.sort((a, b) => a.label.localeCompare(b.label, "uk"))
|
||
.slice(0, 100);
|
||
return (
|
||
<Sheet title="Ссылка на страницу" onClose={props.onClose}>
|
||
<div className="f"><div className="in"><input placeholder="Название страницы…" value={q} onChange={(e) => setQ(e.target.value)} autoFocus /></div></div>
|
||
<div className="mt-10">
|
||
{list.map(({ n, label }) => (
|
||
<button key={n.id} className="mrow" onClick={() => { props.onPick(n); props.onClose(); }}>
|
||
<Icon name="page" /><div className="t">{n.title}<small>{label}</small></div>
|
||
</button>
|
||
))}
|
||
{list.length === 0 && <div className="empty">Страниц не найдено</div>}
|
||
</div>
|
||
</Sheet>
|
||
);
|
||
}
|