Initial commit: webmail client (M0/M1)

Three-pane webmail client.
- Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP
  sync, threading, search, drafts/outbox with undo, attachments, 2FA.
- Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane
  UI with rich-text composer, light/dark themes, resizable panels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 23:20:34 +02:00
co-authored by Claude Opus 4.8
commit a5ec0d21cb
174 changed files with 22239 additions and 0 deletions
@@ -0,0 +1,549 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types";
import { Icon } from "../Icon";
import { RecipientChips } from "./RecipientChips";
import { RichTextEditor } from "./RichTextEditor";
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB per file
export interface ComposerInit {
accountId: string;
threadId?: string | null;
replyToMessageId?: string | null;
kind: string; // reply | reply_all | forward | new
to?: string;
cc?: string;
subject?: string;
text?: string;
html?: string; // initial body when reopening an existing draft
draftId?: string; // when reopening an existing draft
attachmentIds?: string[]; // compose-attachment ids when reopening a draft
// A reply/forward quotes the original: this attribution line sits above the
// quoted message body (fetched from the original message's sanitized HTML).
quote?: { attribution: string; messageId: string };
}
// Build the composer seed from a saved draft (used to reopen it in place or in a
// popped-out window).
export function draftToInit(draft: DraftOut): ComposerInit {
return {
accountId: draft.account_id,
threadId: draft.thread_id,
replyToMessageId: draft.reply_to_message_id,
kind: draft.kind,
to: draft.payload.to.join(", "),
cc: draft.payload.cc.join(", "),
subject: draft.payload.subject,
text: draft.payload.text,
html: draft.payload.html || undefined,
attachmentIds: draft.payload.attachment_ids,
draftId: draft.id,
};
}
function splitList(value: string): string[] {
return value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
function htmlToText(html: string): string {
const div = document.createElement("div");
div.innerHTML = html;
return div.textContent ?? "";
}
function escapeHtml(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function textToHtml(text: string): string {
return text
.split("\n")
.map((line) => `<div>${escapeHtml(line) || "<br>"}</div>`)
.join("");
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// A single header icon-button (Attach / window controls / etc.).
function HeaderButton({
onClick,
title,
active = false,
children,
}: {
onClick?: () => void;
title: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
className={`flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${
active ? "bg-app-active text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
}`}
>
{children}
</button>
);
}
const caret = (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 6l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
export function Composer({
init,
onClose,
expanded = false,
onToggleExpand,
fullWindow = false,
}: {
init: ComposerInit;
onClose: () => void;
expanded?: boolean;
onToggleExpand?: () => void;
fullWindow?: boolean;
}) {
const t = useT();
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const accounts: AccountOut[] = accountsQuery.data ?? [];
const [accountId, setAccountId] = useState(init.accountId);
const [to, setTo] = useState(init.to ?? "");
const [cc, setCc] = useState(init.cc ?? "");
const [bcc, setBcc] = useState("");
const [showCc, setShowCc] = useState(Boolean(init.cc));
const [subject, setSubject] = useState(init.subject ?? "");
const [bodyHtml, setBodyHtml] = useState(init.html ?? "");
const [quoteHtml, setQuoteHtml] = useState("");
const [quoteResolved, setQuoteResolved] = useState(!init.quote);
const [showToolbar, setShowToolbar] = useState(true);
const [fromOpen, setFromOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
const [attachError, setAttachError] = useState("");
const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">("");
const [undo, setUndo] = useState<{ outboxId: string } | null>(null);
const draftId = useRef<string | null>(init.draftId ?? null);
const undoTimer = useRef<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const account = accounts.find((a) => a.id === accountId);
const fromName = account?.display_name || "";
const fromEmail = account?.email ?? "";
// Fetch the original message's sanitized body to quote in a reply/forward.
useEffect(() => {
const quote = init.quote;
if (!quote) return;
let cancelled = false;
(async () => {
try {
const resp = await fetch(api.messageHtmlUrl(quote.messageId), { credentials: "include" });
const doc = new DOMParser().parseFromString(await resp.text(), "text/html");
if (!cancelled) setQuoteHtml(doc.body?.innerHTML ?? "");
} catch {
/* leave the quote empty on failure */
} finally {
if (!cancelled) setQuoteResolved(true);
}
})();
return () => {
cancelled = true;
};
}, [init.quote]);
// Resolve attachment metadata when reopening a draft that already has some.
useEffect(() => {
const ids = init.attachmentIds ?? [];
if (ids.length === 0) return;
let cancelled = false;
(async () => {
try {
const rows = await api.composeAttachments(ids);
if (!cancelled) setAttachments(rows);
} catch {
/* ignore */
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const addFiles = async (files: FileList) => {
setAttachError("");
for (const file of Array.from(files)) {
if (file.size > MAX_ATTACHMENT_BYTES) {
setAttachError(t("compose.attachTooLarge"));
continue;
}
try {
const att = await api.uploadComposeAttachment(file);
setAttachments((prev) => [...prev, att]);
} catch (e) {
setAttachError(e instanceof ApiError ? e.message : t("compose.attachTooLarge"));
}
}
};
const removeAttachment = (id: string) =>
setAttachments((prev) => prev.filter((a) => a.id !== id));
// The editor's seed content: an empty line to type into, then (for replies)
// the attribution line and the quoted original inside a blockquote.
const initialHtml = useMemo(() => {
if (init.html) return init.html;
if (init.text) return textToHtml(init.text);
if (init.quote) {
return (
`<div><br></div>` +
`<div>${escapeHtml(init.quote.attribution)}</div>` +
`<blockquote>${quoteHtml}</blockquote>`
);
}
return `<div><br></div>`;
// Only the quote resolving should (re)compute this; it mounts the editor once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [quoteResolved]);
const bodyReady = quoteResolved;
const payload = (): DraftInput => {
const html = bodyHtml || initialHtml;
return {
account_id: accountId,
thread_id: init.threadId ?? null,
reply_to_message_id: init.replyToMessageId ?? null,
kind: init.kind,
payload: {
to: splitList(to),
cc: splitList(cc),
bcc: splitList(bcc),
subject,
html,
text: htmlToText(html),
attachment_ids: attachments.map((a) => a.id),
},
};
};
useEffect(() => {
if (undo) return;
const handle = window.setTimeout(async () => {
if (!to && !subject && !bodyHtml) return;
setStatus("saving");
try {
if (draftId.current) {
await api.updateDraft(draftId.current, payload());
} else {
draftId.current = (await api.createDraft(payload())).id;
}
setStatus("saved");
} catch {
setStatus("error");
}
}, 2000);
return () => window.clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountId, to, cc, bcc, subject, bodyHtml, attachments]);
useEffect(
() => () => {
if (undoTimer.current) window.clearTimeout(undoTimer.current);
},
[],
);
const send = async () => {
setStatus("sending");
try {
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
else await api.updateDraft(draftId.current, payload());
const res = await api.send(draftId.current);
setUndo({ outboxId: res.outbox_id });
undoTimer.current = window.setTimeout(onClose, 10000);
} catch (e) {
setStatus("error");
if (!(e instanceof ApiError)) throw e;
}
};
const doUndo = async () => {
if (!undo) return;
try {
await api.undoSend(undo.outboxId);
} catch {
/* too late */
}
if (undoTimer.current) window.clearTimeout(undoTimer.current);
setUndo(null);
setStatus("");
};
const discard = async () => {
if (draftId.current) {
try {
await api.deleteDraft(draftId.current);
} catch {
/* ignore */
}
draftId.current = null;
}
onClose();
};
const popOut = async () => {
// Persist first so the new window can load this exact draft.
try {
if (!draftId.current) draftId.current = (await api.createDraft(payload())).id;
else await api.updateDraft(draftId.current, payload());
} catch {
return;
}
// A window a quarter of the screen area (half width × half height), centered.
const w = Math.round(window.screen.width / 2);
const h = Math.round(window.screen.height / 2);
const left = Math.round((window.screen.width - w) / 2);
const top = Math.round((window.screen.height - h) / 2);
window.open(
`/compose/${draftId.current}`,
`compose-${draftId.current}`,
`width=${w},height=${h},left=${left},top=${top}`,
);
onClose();
};
if (undo) {
return (
<div className="m-3 flex items-center justify-between rounded-lg bg-app-fg px-4 py-2.5 text-sm text-app-surface shadow-lg">
<span>{t("compose.sent")}</span>
<button className="font-medium text-app-accent" onClick={doUndo}>
{t("compose.undo")}
</button>
</div>
);
}
return (
<div
className={`flex flex-col overflow-hidden border-app-border bg-app-elevated ${
fullWindow
? "h-full"
: `rounded-xl border shadow-2xl ${expanded ? "h-full" : ""}`
}`}
>
{/* Header: Send · Attach · formatting toggle · More · window controls */}
<div className="flex items-center gap-1.5 border-b border-app-border bg-app-surface px-3 py-2">
<button
type="button"
onClick={send}
disabled={status === "sending" || (!to && !cc)}
className="flex items-center gap-2 rounded-md bg-app-accent px-3 py-1.5 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.5 1.5L7.2 8.8" />
<path d="M14.5 1.5L9.8 14.5 7.2 8.8 1.5 6.2 14.5 1.5z" />
</svg>
{status === "sending" ? t("compose.sending") : t("compose.send")}
</button>
<HeaderButton onClick={() => fileInputRef.current?.click()} title={t("compose.attach")}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg>
{t("compose.attach")}
</HeaderButton>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length) void addFiles(e.target.files);
e.target.value = "";
}}
/>
<HeaderButton
onClick={() => setShowToolbar((v) => !v)}
title={t("compose.fmtBold")}
active={showToolbar}
>
<span className="text-sm font-semibold underline">A</span>
</HeaderButton>
<div className="relative">
<HeaderButton onClick={() => setMoreOpen((v) => !v)} title={t("compose.more")}>
{t("compose.more")}
</HeaderButton>
{moreOpen && (
<div className="absolute left-0 top-full z-30 mt-1 w-44 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl">
<button
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
onClick={() => {
setMoreOpen(false);
setShowCc(true);
}}
>
{t("compose.cc")} / {t("compose.bcc")}
</button>
<button
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
onClick={() => {
setMoreOpen(false);
void discard();
}}
>
{t("compose.discard")}
</button>
</div>
)}
</div>
<div className="ml-auto flex items-center gap-1 text-xs text-app-faint">
{status === "saving" && <span className="mr-1">{t("compose.savingDraft")}</span>}
{status === "saved" && <span className="mr-1">{t("compose.draftSaved")}</span>}
{status === "error" && <span className="mr-1 text-red-500">{t("compose.sendFailed")}</span>}
{!fullWindow && onToggleExpand && (
<HeaderButton
onClick={onToggleExpand}
title={expanded ? t("compose.collapse") : t("compose.expand")}
active={expanded}
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 2H2v4M10 2h4v4M6 14H2v-4M10 14h4v-4" />
</svg>
</HeaderButton>
)}
{!fullWindow && (
<HeaderButton onClick={popOut} title={t("compose.popOut")}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 3H3v10h10v-3M9 3h4v4M13 3L7 9" />
</svg>
</HeaderButton>
)}
<HeaderButton onClick={onClose} title={t("common.close")}>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</HeaderButton>
</div>
</div>
{/* From */}
<div className="relative flex items-center gap-2 border-b border-app-border px-3 py-2">
<span className="w-10 shrink-0 text-xs font-semibold text-app-fg">{t("compose.from")}</span>
<button
type="button"
onClick={() => accounts.length > 1 && setFromOpen((v) => !v)}
className="flex items-center gap-1.5 rounded px-1 py-0.5 text-sm hover:bg-app-hover"
>
{fromName && <span className="font-medium text-app-accent">{fromName}</span>}
<span className="text-app-muted">{fromName ? `<${fromEmail}>` : fromEmail}</span>
{accounts.length > 1 && <span className="text-app-faint">{caret}</span>}
</button>
<div className="ml-auto flex items-center gap-1 text-xs text-app-muted">
<Icon name="reply" size={13} />
{t("compose.replyLabel")}
</div>
{fromOpen && (
<div className="absolute left-12 top-full z-30 mt-1 w-80 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 shadow-xl">
{accounts.map((a) => (
<button
key={a.id}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-app-hover"
onClick={() => {
setAccountId(a.id);
setFromOpen(false);
}}
>
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
{a.display_name && <span className="font-medium text-app-fg">{a.display_name}</span>}
<span className="text-app-muted">{a.email}</span>
</button>
))}
</div>
)}
</div>
{/* Recipients */}
<RecipientChips
label={t("compose.to")}
value={to}
onChange={setTo}
autoFocus={init.kind === "new" || init.kind === "forward"}
rightSlot={
!showCc ? (
<button
type="button"
className="text-xs text-app-muted hover:text-app-fg"
onClick={() => setShowCc(true)}
>
{t("compose.cc")} {t("compose.bcc")}
</button>
) : undefined
}
/>
{showCc && (
<>
<RecipientChips label={t("compose.cc")} value={cc} onChange={setCc} />
<RecipientChips label={t("compose.bcc")} value={bcc} onChange={setBcc} />
</>
)}
<input
className="border-b border-app-border bg-transparent px-3 py-2 text-sm text-app-fg outline-none placeholder:text-app-faint"
placeholder={t("compose.subject")}
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
{(attachments.length > 0 || attachError) && (
<div className="flex flex-wrap items-center gap-2 border-b border-app-border px-3 py-2">
{attachments.map((att) => (
<span
key={att.id}
className="flex items-center gap-1.5 rounded border border-app-border bg-app-hover px-2 py-1 text-xs text-app-fg"
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.5 6.5l-5.2 5.2a2.5 2.5 0 0 1-3.5-3.5l5.4-5.4a1.6 1.6 0 0 1 2.3 2.3l-5.3 5.3a0.8 0.8 0 0 1-1.1-1.1l4.9-4.9" />
</svg>
<span className="max-w-[12rem] truncate">{att.filename}</span>
<span className="text-app-faint">{formatBytes(att.size_bytes)}</span>
<button
type="button"
onClick={() => removeAttachment(att.id)}
className="text-app-faint hover:text-app-fg"
aria-label={`remove ${att.filename}`}
>
✕
</button>
</span>
))}
{attachError && <span className="text-xs text-red-500">{attachError}</span>}
</div>
)}
{/* Body */}
{bodyReady ? (
<RichTextEditor initialHtml={initialHtml} onInput={setBodyHtml} showToolbar={showToolbar} />
) : (
<div className="min-h-[250px] flex-1 px-3 py-3 text-sm text-app-faint">…</div>
)}
</div>
);
}
@@ -0,0 +1,149 @@
import { useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import type { RecipientSuggestion } from "../../lib/types";
function splitList(value: string): string[] {
return value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
function parseAddr(token: string): { name: string; email: string } {
const m = token.match(/^(.*?)\s*<([^>]+)>$/);
if (m) return { name: m[1].trim(), email: m[2].trim() };
return { name: "", email: token };
}
// A recipient field rendering committed addresses as removable chips with an
// inline autocomplete input (Fastmail-style). The value stays a comma-joined
// string ("Name <email>" or bare email) so the draft payload is unchanged.
export function RecipientChips({
label,
value,
onChange,
rightSlot,
autoFocus,
}: {
label: string;
value: string;
onChange: (value: string) => void;
rightSlot?: React.ReactNode;
autoFocus?: boolean;
}) {
const t = useT();
const chips = splitList(value);
const [draft, setDraft] = useState("");
const [suggestions, setSuggestions] = useState<RecipientSuggestion[]>([]);
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
const commit = (token: string) => {
const tok = token.trim().replace(/,+$/, "").trim();
if (!tok) return;
onChange([...chips, tok].join(", "));
setDraft("");
setOpen(false);
setSuggestions([]);
};
const removeAt = (i: number) => onChange(chips.filter((_, idx) => idx !== i).join(", "));
useEffect(() => {
if (draft.trim().length < 2) {
setSuggestions([]);
return;
}
let cancelled = false;
const handle = window.setTimeout(async () => {
try {
const results = await api.suggestRecipients(draft.trim());
if (!cancelled) {
setSuggestions(results);
setOpen(results.length > 0);
}
} catch {
/* ignore */
}
}, 150);
return () => {
cancelled = true;
window.clearTimeout(handle);
};
}, [draft]);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
return (
<div ref={boxRef} className="relative flex items-start gap-2 border-b border-app-border px-3 py-1.5">
<label className="mt-1.5 w-10 shrink-0 text-xs font-semibold text-app-fg">{label}</label>
<div className="flex flex-1 flex-wrap items-center gap-1.5 py-0.5">
{chips.map((chip, i) => {
const { name, email } = parseAddr(chip);
return (
<span
key={`${chip}-${i}`}
className="flex items-center gap-1 rounded border border-app-border bg-app-hover px-2 py-0.5 text-xs"
>
{name && <span className="font-medium text-app-fg">{name}</span>}
<span className="text-app-muted">{name ? `<${email}>` : email}</span>
<button
type="button"
onClick={() => removeAt(i)}
className="ml-0.5 text-app-faint hover:text-app-fg"
aria-label={`remove ${email}`}
>
✕
</button>
</span>
);
})}
<input
className="min-w-[8rem] flex-1 bg-transparent py-1 text-sm text-app-fg outline-none placeholder:text-app-faint"
value={draft}
placeholder={chips.length === 0 ? t("compose.addRecipient") : ""}
autoFocus={autoFocus}
autoComplete="off"
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commit(draft);
} else if (e.key === "Backspace" && draft === "" && chips.length > 0) {
removeAt(chips.length - 1);
}
}}
onBlur={() => draft.trim() && commit(draft)}
onFocus={() => setOpen(suggestions.length > 0)}
/>
</div>
{rightSlot && <div className="mt-1 flex shrink-0 items-center gap-2">{rightSlot}</div>}
{open && (
<ul className="absolute left-12 top-full z-30 mt-1 max-h-56 w-80 overflow-y-auto rounded-lg border border-app-border bg-app-elevated shadow-xl">
{suggestions.map((s) => (
<li key={s.email}>
<button
type="button"
className="flex w-full flex-col px-3 py-1.5 text-left hover:bg-app-hover"
onMouseDown={(e) => {
e.preventDefault();
commit(s.name ? `${s.name} <${s.email}>` : s.email);
}}
>
{s.name && <span className="text-sm text-app-fg">{s.name}</span>}
<span className="text-xs text-app-muted">{s.email}</span>
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,426 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
// A lightweight WYSIWYG editor over a contenteditable div. The DOM is the source
// of truth (uncontrolled) so the caret never jumps; we read innerHTML on input.
// Formatting uses document.execCommand — deprecated but universally supported and
// the pragmatic choice for a compose surface of this size.
type Cmd =
| "bold"
| "italic"
| "underline"
| "strikeThrough"
| "insertUnorderedList"
| "insertOrderedList"
| "justifyLeft"
| "justifyCenter"
| "justifyRight";
const STATE_CMDS: Cmd[] = [
"bold",
"italic",
"underline",
"strikeThrough",
"insertUnorderedList",
"insertOrderedList",
"justifyLeft",
"justifyCenter",
"justifyRight",
];
// A fixed swatch palette (10 columns × 8 rows, row-major): a grayscale top row,
// then a saturated hue row followed by tints (lighter) and shades (darker).
const PALETTE: string[] = [
"#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#efefef", "#f3f3f3", "#ffffff",
"#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff", "#4a86e8", "#0000ff", "#9900ff", "#ff00ff",
"#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc", "#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc",
"#dd7e6b", "#ea9999", "#f9cb9c", "#ffe599", "#b6d7a8", "#a2c4c9", "#a4c2f4", "#9fc5e8", "#b4a7d6", "#d5a6bd",
"#cc4125", "#e06666", "#f6b26b", "#ffd966", "#93c47d", "#76a5af", "#6d9eeb", "#6fa8dc", "#8e7cc3", "#c27ba0",
"#a61c00", "#cc0000", "#e69138", "#f1c232", "#6aa84f", "#45818e", "#3c78d8", "#3d85c6", "#674ea7", "#a64d79",
"#85200c", "#990000", "#b45f06", "#bf9000", "#38761d", "#134f5c", "#1155cc", "#0b5394", "#351c75", "#741b47",
"#5b0f00", "#660000", "#783f04", "#7f6000", "#274e13", "#0c343d", "#1c4587", "#073763", "#20124d", "#4c1130",
];
function Svg({ children }: { children: React.ReactNode }) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
}
const ICONS = {
bulletList: (
<Svg>
<circle cx="3" cy="4" r="0.9" fill="currentColor" stroke="none" />
<circle cx="3" cy="8" r="0.9" fill="currentColor" stroke="none" />
<circle cx="3" cy="12" r="0.9" fill="currentColor" stroke="none" />
<path d="M6 4h8M6 8h8M6 12h8" />
</Svg>
),
numberList: (
<Svg>
<path d="M6 4h8M6 8h8M6 12h8" />
<path d="M2 3v2.5M2 5.5h1" strokeWidth="1.2" />
<path d="M1.6 10.5h1.4L1.6 12.5h1.6" strokeWidth="1.2" />
</Svg>
),
quote: (
<Svg>
<path d="M3 6.5C3 5 4 4.5 5 4.5M3 6.5v3.5h3V6.5H4.6C4.6 5.6 5 5.3 5.4 5.2" />
<path d="M9 6.5C9 5 10 4.5 11 4.5M9 6.5v3.5h3V6.5h-1.4C10.6 5.6 11 5.3 11.4 5.2" />
</Svg>
),
link: (
<Svg>
<path d="M6.5 9.5a2.5 2.5 0 0 0 3.5 0l2-2a2.5 2.5 0 0 0-3.5-3.5l-1 1" />
<path d="M9.5 6.5a2.5 2.5 0 0 0-3.5 0l-2 2a2.5 2.5 0 0 0 3.5 3.5l1-1" />
</Svg>
),
alignLeft: (
<Svg>
<path d="M2 4h12M2 7h8M2 10h11M2 13h7" />
</Svg>
),
alignCenter: (
<Svg>
<path d="M2 4h12M4 7h8M3 10h10M4 13h8" />
</Svg>
),
alignRight: (
<Svg>
<path d="M2 4h12M6 7h8M3 10h11M7 13h7" />
</Svg>
),
clear: (
<Svg>
<path d="M5 4h8M9 4l-2 9M4 13h5" />
<path d="M11 11l3 3M14 11l-3 3" strokeWidth="1.3" />
</Svg>
),
highlight: (
<Svg>
<path d="M4 11.5l4.5-4.5 3 3L7 14.5H4v-3z" />
<path d="M8.5 7l2.5-2.5 3 3L11.5 10" />
<path d="M3 16.5h7" strokeWidth="1.8" />
</Svg>
),
image: (
<Svg>
<rect x="2.5" y="3.5" width="11" height="9" rx="1" />
<circle cx="6" cy="6.5" r="1" fill="currentColor" stroke="none" />
<path d="M3 11l3-2.5 2.5 2 2-1.5L13.5 11" />
</Svg>
),
} as const;
export function RichTextEditor({
initialHtml,
onInput,
onReady,
showToolbar = true,
}: {
initialHtml: string;
onInput: (html: string) => void;
onReady?: (el: HTMLDivElement) => void;
showToolbar?: boolean;
}) {
const t = useT();
const ref = useRef<HTMLDivElement>(null);
const savedRange = useRef<Range | null>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const paletteRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<Record<string, boolean>>({});
const [colorMenu, setColorMenu] = useState<null | "fore" | "hilite">(null);
// Seed the editable region exactly once.
useEffect(() => {
const el = ref.current;
if (!el) return;
el.innerHTML = initialHtml;
onReady?.(el);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Remember the caret/selection so native pickers (color/file) that steal focus
// can restore where formatting should apply.
const saveSelection = useCallback(() => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0 && ref.current?.contains(sel.anchorNode)) {
savedRange.current = sel.getRangeAt(0).cloneRange();
}
}, []);
const restoreSelection = () => {
const sel = window.getSelection();
if (sel && savedRange.current) {
sel.removeAllRanges();
sel.addRange(savedRange.current);
}
};
const refreshActive = useCallback(() => {
const el = ref.current;
if (!el || document.activeElement !== el) return;
saveSelection();
const next: Record<string, boolean> = {};
for (const cmd of STATE_CMDS) {
try {
next[cmd] = document.queryCommandState(cmd);
} catch {
next[cmd] = false;
}
}
setActive(next);
}, [saveSelection]);
useEffect(() => {
document.addEventListener("selectionchange", refreshActive);
return () => document.removeEventListener("selectionchange", refreshActive);
}, [refreshActive]);
// Open/close the swatch palette (snapshotting the selection to apply color to).
const openColorMenu = (kind: "fore" | "hilite") => {
saveSelection();
setColorMenu((prev) => (prev === kind ? null : kind));
};
useEffect(() => {
if (!colorMenu) return;
const onDown = (e: MouseEvent) => {
if (paletteRef.current && !paletteRef.current.contains(e.target as Node)) {
setColorMenu(null);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [colorMenu]);
const emit = () => {
if (ref.current) onInput(ref.current.innerHTML);
};
const exec = (command: string, value?: string) => {
ref.current?.focus();
document.execCommand(command, false, value);
emit();
refreshActive();
};
const link = () => {
const url = window.prompt(t("compose.linkPrompt"), "https://");
if (url) exec("createLink", url);
};
const applyColor = (kind: "fore" | "hilite", color: string) => {
ref.current?.focus();
restoreSelection();
document.execCommand("styleWithCSS", false, "true");
if (kind === "fore") {
document.execCommand("foreColor", false, color);
} else if (!document.execCommand("hiliteColor", false, color)) {
document.execCommand("backColor", false, color);
}
document.execCommand("styleWithCSS", false, "false");
emit();
};
const insertImage = (file: File) => {
if (file.size > 5 * 1024 * 1024) {
window.alert(t("compose.imageTooLarge"));
return;
}
const reader = new FileReader();
reader.onload = () => {
ref.current?.focus();
restoreSelection();
document.execCommand("insertImage", false, String(reader.result));
emit();
};
reader.readAsDataURL(file);
};
const Btn = ({
onClick,
title,
isActive = false,
children,
}: {
onClick: () => void;
title: string;
isActive?: boolean;
children: React.ReactNode;
}) => (
<button
type="button"
title={title}
aria-label={title}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
className={`flex h-7 w-7 items-center justify-center rounded ${
isActive ? "bg-app-active text-app-fg" : "text-app-muted hover:bg-app-hover hover:text-app-fg"
}`}
>
{children}
</button>
);
const Sep = () => <span className="mx-1 h-5 w-px bg-app-border" />;
return (
<div className="flex min-h-0 flex-1 flex-col">
<div
className={`flex-wrap items-center gap-0.5 border-b border-app-border px-2 py-1.5 ${
showToolbar ? "flex" : "hidden"
}`}
>
<Btn onClick={() => exec("bold")} title={t("compose.fmtBold")} isActive={active.bold}>
<span className="text-sm font-bold">B</span>
</Btn>
<Btn onClick={() => exec("italic")} title={t("compose.fmtItalic")} isActive={active.italic}>
<span className="font-serif text-sm italic">I</span>
</Btn>
<Btn
onClick={() => exec("underline")}
title={t("compose.fmtUnderline")}
isActive={active.underline}
>
<span className="text-sm underline">U</span>
</Btn>
<Btn
onClick={() => exec("strikeThrough")}
title={t("compose.fmtStrike")}
isActive={active.strikeThrough}
>
<span className="text-sm line-through">S</span>
</Btn>
<Sep />
<Btn
onClick={() => exec("insertUnorderedList")}
title={t("compose.fmtBulletList")}
isActive={active.insertUnorderedList}
>
{ICONS.bulletList}
</Btn>
<Btn
onClick={() => exec("insertOrderedList")}
title={t("compose.fmtNumberList")}
isActive={active.insertOrderedList}
>
{ICONS.numberList}
</Btn>
<Btn onClick={() => exec("formatBlock", "blockquote")} title={t("compose.fmtQuote")}>
{ICONS.quote}
</Btn>
<Btn onClick={link} title={t("compose.fmtLink")}>
{ICONS.link}
</Btn>
<Sep />
<div ref={paletteRef} className="relative flex items-center gap-0.5">
<Btn onClick={() => openColorMenu("fore")} title={t("compose.fmtColor")}>
<span className="relative flex h-4 w-4 items-center justify-center text-sm font-semibold leading-none">
A
<span
className="absolute -bottom-0.5 left-0 h-[3px] w-full rounded"
style={{ background: "#e5484d" }}
/>
</span>
</Btn>
<Btn onClick={() => openColorMenu("hilite")} title={t("compose.fmtHighlight")}>
{ICONS.highlight}
</Btn>
{colorMenu && (
<div className="absolute left-0 top-full z-40 mt-1 rounded-lg border border-app-border bg-app-elevated p-2 shadow-xl">
<div className="grid grid-cols-[repeat(10,1rem)] gap-1">
{PALETTE.map((color) => (
<button
key={color}
type="button"
title={color}
aria-label={color}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
applyColor(colorMenu, color);
setColorMenu(null);
}}
className="h-4 w-4 rounded-sm border border-black/10 hover:ring-2 hover:ring-app-accent"
style={{ background: color }}
/>
))}
</div>
</div>
)}
</div>
<Btn
onClick={() => {
saveSelection();
imageInputRef.current?.click();
}}
title={t("compose.fmtImage")}
>
{ICONS.image}
</Btn>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) insertImage(f);
e.target.value = "";
}}
/>
<Sep />
<Btn
onClick={() => exec("justifyLeft")}
title={t("compose.fmtAlignLeft")}
isActive={active.justifyLeft}
>
{ICONS.alignLeft}
</Btn>
<Btn
onClick={() => exec("justifyCenter")}
title={t("compose.fmtAlignCenter")}
isActive={active.justifyCenter}
>
{ICONS.alignCenter}
</Btn>
<Btn
onClick={() => exec("justifyRight")}
title={t("compose.fmtAlignRight")}
isActive={active.justifyRight}
>
{ICONS.alignRight}
</Btn>
<Sep />
<Btn onClick={() => exec("removeFormat")} title={t("compose.fmtClear")}>
{ICONS.clear}
</Btn>
</div>
<div
ref={ref}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
data-testid="rich-editor"
onInput={(e) => onInput((e.target as HTMLDivElement).innerHTML)}
onKeyUp={refreshActive}
onMouseUp={refreshActive}
className="wmc-editor min-h-[250px] flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none"
/>
</div>
);
}