Files
crm-wiki/frontend/src/Dialogs.tsx

159 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}