Files
codecraft/web/src/components/TemplatePicker.tsx
T
Richard ZinkandClaude Opus 5.5 428e2df40a Add share target, prompt templates, command palette and session organisation
- PWA share target: shared text/links start a new chat or go into a
  recent session (chooser dialog)
- Prompt templates (personal + global for admins) with {{placeholders}}
  that are asked for on insert; managed under Settings → Templates
- Ctrl/Cmd+K command palette; Alt+N new session, Alt+Up/Down switch
  sessions, Ctrl+, settings
- Sessions can be pinned, archived and filed into folders; the list can
  be grouped by date or by project, groups are collapsible
- Fix: the "What's new" dialog was never shown to signed-in users

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 05:27:45 +01:00

124 lines
5.2 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "../dialogs";
import { useT } from "../i18n";
import { navigate } from "../router";
import { fill, loadTemplates, placeholders, type Template, type TemplateLists } from "../templates";
import { Icon } from "./Icon";
/** Choose a prompt template, fill its placeholders, return the text. */
export function TemplatePicker({ onPick, onClose }: { onPick: (text: string) => void; onClose: () => void }) {
const t = useT();
const [lists, setLists] = useState<TemplateLists>();
const [q, setQ] = useState("");
const [active, setActive] = useState(0);
const [chosen, setChosen] = useState<Template>();
const [values, setValues] = useState<Record<string, string>>({});
const first = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
useEffect(() => {
loadTemplates()
.then(setLists)
.catch((e) => (toast(e.message), onClose()));
const h = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const items = useMemo(() => {
const all = [...(lists?.personal ?? []).map((x) => ({ ...x, global: false })), ...(lists?.global ?? []).map((x) => ({ ...x, global: true }))];
const needle = q.trim().toLowerCase();
return needle ? all.filter((x) => x.name.toLowerCase().includes(needle) || x.text.toLowerCase().includes(needle)) : all;
}, [lists, q]);
const choose = (tpl: Template) => {
const ph = placeholders(tpl.text);
if (!ph.length) return onPick(tpl.text);
setChosen(tpl);
setValues(Object.fromEntries(ph.map((p) => [p.name, ""])));
setTimeout(() => first.current?.focus(), 0);
};
if (!lists) return null;
const ph = chosen ? placeholders(chosen.text) : [];
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal tpl-picker" role="dialog" aria-label={t("templates.title")} onClick={(e) => e.stopPropagation()}>
{!chosen ? (
<>
<h3>
<Icon name="template" /> {t("templates.title")}
</h3>
<input
autoFocus
style={{ width: "100%" }}
value={q}
placeholder={t("templates.filter")}
onChange={(e) => (setQ(e.target.value), setActive(0))}
onKeyDown={(e) => {
if (e.key === "ArrowDown") (e.preventDefault(), setActive((a) => Math.min(a + 1, items.length - 1)));
if (e.key === "ArrowUp") (e.preventDefault(), setActive((a) => Math.max(a - 1, 0)));
if (e.key === "Enter" && items[active]) (e.preventDefault(), choose(items[active]));
}}
/>
<div className="tpl-list">
{items.length === 0 && (
<div className="empty" style={{ padding: 16 }}>
{t("templates.none")}
</div>
)}
{items.map((x, i) => (
<button key={(x.global ? "g" : "p") + x.id} className={i === active ? "active" : ""} onClick={() => choose(x)} onMouseEnter={() => setActive(i)}>
<span className="tpl-name">
{x.name}
{x.global && <span className="chip">{t("templates.global")}</span>}
</span>
<span className="tpl-preview">{x.text.slice(0, 140)}</span>
</button>
))}
</div>
<div className="row" style={{ marginTop: 12 }}>
<button className="ghost small" onClick={() => (onClose(), navigate("/settings/templates"))}>
<Icon name="edit" /> {t("templates.manage")}
</button>
<span className="grow" />
<button onClick={onClose}>{t("common.cancel")}</button>
</div>
</>
) : (
<form onSubmit={(e) => (e.preventDefault(), onPick(fill(chosen.text, values)))}>
<h3>{chosen.name}</h3>
{ph.map((p, i) => (
<label key={p.name} className="tpl-field">
<span>{p.name}</span>
<textarea
ref={i === 0 ? (first as React.RefObject<HTMLTextAreaElement>) : undefined}
rows={2}
value={values[p.name] ?? ""}
placeholder={p.hint}
onChange={(e) => setValues({ ...values, [p.name]: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
/>
</label>
))}
<div className="row" style={{ marginTop: 12, justifyContent: "flex-end" }}>
<button type="button" onClick={() => setChosen(undefined)}>
{t("common.back")}
</button>
<button type="submit" className="primary">
{t("templates.insert")}
</button>
</div>
</form>
)}
</div>
</div>
);
}