ihasmail 2.0: rebuild as Stalwart-first JMAP webmail
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.
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { create } from "zustand";
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
closeOnBackdrop?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Dialog({ open, onClose, title, children, footer, size = "md", closeOnBackdrop = true, className }: DialogProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.activeElement as HTMLElement | null;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
if (e.key === "Tab" && ref.current) {
|
||||
const focusables = ref.current.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]');
|
||||
if (!focusables.length) return;
|
||||
const first = focusables[0]!;
|
||||
const last = focusables[focusables.length - 1]!;
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
// autofocus first input
|
||||
window.setTimeout(() => {
|
||||
const el = ref.current?.querySelector<HTMLElement>("[autofocus],input,textarea,select,button.btn-primary");
|
||||
el?.focus();
|
||||
}, 10);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
prev?.focus?.();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
if (!open) return null;
|
||||
return createPortal(
|
||||
<div
|
||||
className="dialog-backdrop"
|
||||
onMouseDown={(e) => {
|
||||
if (closeOnBackdrop && e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className={`dialog ${size} ${className ?? ""}`} role="dialog" aria-modal="true" ref={ref}>
|
||||
{title !== undefined && (
|
||||
<div className="dialog-head">
|
||||
<h2>{title}</h2>
|
||||
<button className="icon-btn" onClick={onClose} aria-label="Close">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="dialog-body">{children}</div>
|
||||
{footer && <div className="dialog-foot">{footer}</div>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Imperative confirm / prompt ---------- */
|
||||
|
||||
interface ConfirmRequest {
|
||||
id: number;
|
||||
kind: "confirm" | "prompt";
|
||||
title: string;
|
||||
message?: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
resolve: (v: boolean | string | null) => void;
|
||||
}
|
||||
|
||||
const useConfirmStore = create<{ queue: ConfirmRequest[]; push(r: ConfirmRequest): void; pop(): void }>((set, get) => ({
|
||||
queue: [],
|
||||
push: (r) => set({ queue: [...get().queue, r] }),
|
||||
pop: () => set({ queue: get().queue.slice(1) }),
|
||||
}));
|
||||
|
||||
let reqId = 1;
|
||||
|
||||
export function confirmDialog(opts: { title: string; message?: ReactNode; confirmLabel?: string; cancelLabel?: string; danger?: boolean }): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
useConfirmStore.getState().push({ id: reqId++, kind: "confirm", ...opts, resolve: (v) => resolve(Boolean(v)) });
|
||||
});
|
||||
}
|
||||
|
||||
export function promptDialog(opts: { title: string; message?: ReactNode; defaultValue?: string; placeholder?: string; confirmLabel?: string }): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
useConfirmStore.getState().push({ id: reqId++, kind: "prompt", ...opts, resolve: (v) => resolve(typeof v === "string" ? v : null) });
|
||||
});
|
||||
}
|
||||
|
||||
export function ConfirmHost() {
|
||||
const req = useConfirmStore((s) => s.queue[0]);
|
||||
const pop = useConfirmStore((s) => s.pop);
|
||||
const [value, setValue] = useState("");
|
||||
useEffect(() => setValue(req?.defaultValue ?? ""), [req?.id, req?.defaultValue]);
|
||||
if (!req) return null;
|
||||
const done = (v: boolean | string | null) => {
|
||||
req.resolve(v);
|
||||
pop();
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => done(req.kind === "prompt" ? null : false)}
|
||||
title={req.title}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn" onClick={() => done(req.kind === "prompt" ? null : false)}>
|
||||
{req.cancelLabel ?? "Cancel"}
|
||||
</button>
|
||||
<button className={`btn ${req.danger ? "btn-danger" : "btn-primary"}`} onClick={() => done(req.kind === "prompt" ? value : true)}>
|
||||
{req.confirmLabel ?? (req.kind === "prompt" ? "OK" : "Confirm")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{req.message && <p style={{ marginTop: 0 }}>{req.message}</p>}
|
||||
{req.kind === "prompt" && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
done(value);
|
||||
}}
|
||||
>
|
||||
<input className="input" autoFocus value={value} placeholder={req.placeholder} onChange={(e) => setValue(e.target.value)} />
|
||||
</form>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user