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>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 05:27:45 +01:00
1 parent b5de531d2e
commit 428e2df40a
25 files changed
+1106 -33

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { confirmDialog, toast } from "../../dialogs";
import { useT } from "../../i18n";
import { loadTemplates, placeholders, saveTemplates, type Template, type TemplateLists } from "../../templates";
import { Icon } from "../Icon";
function List({ scope, initial }: { scope: "personal" | "global"; initial: Template[] }) {
const t = useT();
const [list, setList] = useState<Template[]>(initial);
const [dirty, setDirty] = useState(false);
const set = (i: number, patch: Partial<Template>) => {
setList((l) => l.map((x, j) => (j === i ? { ...x, ...patch } : x)));
setDirty(true);
};
const save = () =>
saveTemplates(scope, list.filter((x) => x.name.trim() || x.text.trim()))
.then((l) => (setList(l), setDirty(false), toast(t("settings.saved"), "success")))
.catch((e) => toast(e.message));
const remove = async (i: number) => {
if (!(await confirmDialog({ message: t("templates.deleteConfirm", { name: list[i].name || "?" }), danger: true, confirmLabel: t("common.delete") }))) return;
setList((l) => l.filter((_, j) => j !== i));
setDirty(true);
};
return (
<div className="panel">
<h3>
<Icon name={scope === "global" ? "users" : "user"} /> {t(scope === "global" ? "templates.globalTitle" : "templates.personalTitle")}
</h3>
<div className="hint" style={{ marginBottom: 10 }}>
{t(scope === "global" ? "templates.globalHint" : "templates.personalHint")}
</div>
{list.map((x, i) => (
<div key={x.id || `new${i}`} className="tpl-edit">
<div className="row">
<input className="grow" value={x.name} placeholder={t("templates.name")} onChange={(e) => set(i, { name: e.target.value })} />
<button className="ghost small" onClick={() => remove(i)} aria-label={t("common.delete")}>
<Icon name="trash" />
</button>
</div>
<textarea rows={4} value={x.text} placeholder={t("templates.textPlaceholder")} onChange={(e) => set(i, { text: e.target.value })} />
{placeholders(x.text).length > 0 && (
<div className="tpl-ph">
{placeholders(x.text).map((p) => (
<span key={p.name} className="chip">
{p.name}
</span>
))}
</div>
)}
</div>
))}
<div className="row" style={{ marginTop: 8 }}>
<button onClick={() => (setList([...list, { id: "", name: "", text: "" }]), setDirty(true))}>
<Icon name="plus" /> {t("templates.add")}
</button>
<span className="grow" />
<button className="primary" disabled={!dirty} onClick={save}>
{t("common.save")}
</button>
</div>
</div>
);
}
/** Prompt templates: personal ones and (admins) the ones every user sees. */
export function Templates() {
const [lists, setLists] = useState<TemplateLists>();
useEffect(() => {
loadTemplates().then(setLists).catch((e) => toast(e.message));
}, []);
if (!lists) return null;
return (
<>
<List scope="personal" initial={lists.personal} />
{lists.can_edit_global && <List scope="global" initial={lists.global} />}
</>
);
}