Replace the FastAPI/HTMX prototype with a Node/Hono session proxy and a React 19/Vite SPA. Mail (conversation view, search operators, labels, sanitised HTML, privacy image proxy, invites, undo send, templates), calendar (month/week/day/agenda, invites, free/busy, categories, context menus), contacts (JSContact, groups, vCard), files, Sieve filter builder (incl. filter-from-message with retroactive apply), vacation, identities with default + Reply-To, PWA/mobile layout, push via SSE, in-memory mock Stalwart for dev, Docker + CI.
44 lines
2.8 KiB
TypeScript
44 lines
2.8 KiB
TypeScript
import { useState } from "react";
|
|
import { Plus, Trash2 } from "lucide-react";
|
|
import { useSettings, type Template } from "@/store/settings";
|
|
import { Dialog } from "@/ui/dialog";
|
|
import { RichEditor } from "../compose/RichEditor";
|
|
import { htmlToText } from "@/lib/text";
|
|
|
|
export function TemplatesSettings() {
|
|
const templates = useSettings((s) => s.settings.templates);
|
|
const update = useSettings((s) => s.update);
|
|
const [editing, setEditing] = useState<Template | null>(null);
|
|
return (
|
|
<div>
|
|
<h1>Templates</h1>
|
|
<p className="lead">Canned responses you can insert into any message from the composer's template button.</p>
|
|
{templates.map((t) => (
|
|
<div key={t.id} className="card clickable" onClick={() => setEditing(t)}>
|
|
<div className="card-head">
|
|
<h3>{t.name}</h3>
|
|
<button className="icon-btn sm danger" aria-label="Delete template" onClick={(e) => { e.stopPropagation(); update({ templates: templates.filter((x) => x.id !== t.id) }); }}><Trash2 size={16} /></button>
|
|
</div>
|
|
{t.subject && <div className="hint">Subject: {t.subject}</div>}
|
|
<div className="hint truncate">{htmlToText(t.html).slice(0, 140)}</div>
|
|
</div>
|
|
))}
|
|
<button className="btn" onClick={() => setEditing({ id: `t${Date.now()}`, name: "", subject: "", html: "" })}><Plus size={16} /> New template</button>
|
|
{editing && (
|
|
<Dialog open onClose={() => setEditing(null)} title={templates.some((t) => t.id === editing.id) ? "Edit template" : "New template"} size="lg" footer={<><button className="btn" onClick={() => setEditing(null)}>Cancel</button><button className="btn btn-primary" disabled={!editing.name.trim()} onClick={() => { const exists = templates.some((t) => t.id === editing.id); update({ templates: exists ? templates.map((t) => (t.id === editing.id ? editing : t)) : [...templates, editing] }); setEditing(null); }}>Save</button></>}>
|
|
<div className="field-row">
|
|
<div className="field"><label>Name</label><input className="input" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} autoFocus /></div>
|
|
<div className="field"><label>Subject (optional)</label><input className="input" value={editing.subject} onChange={(e) => setEditing({ ...editing, subject: e.target.value })} /></div>
|
|
</div>
|
|
<div className="field">
|
|
<label>Body</label>
|
|
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 8, minHeight: 200, display: "flex", flexDirection: "column" }}>
|
|
<RichEditor html={editing.html} onChange={(html) => setEditing({ ...editing, html })} showToolbar placeholder="Template text…" />
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|