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.
85 lines
2.6 KiB
TypeScript
85 lines
2.6 KiB
TypeScript
import { create } from "zustand";
|
|
import { X } from "lucide-react";
|
|
|
|
export interface Toast {
|
|
id: number;
|
|
message: string;
|
|
kind: "info" | "error" | "success";
|
|
action?: { label: string; onClick: () => void | Promise<void> };
|
|
duration: number;
|
|
progress?: boolean;
|
|
}
|
|
|
|
interface ToastState {
|
|
toasts: Toast[];
|
|
push(t: Omit<Toast, "id">): number;
|
|
dismiss(id: number): void;
|
|
}
|
|
|
|
let counter = 1;
|
|
const timers = new Map<number, number>();
|
|
|
|
export const useToasts = create<ToastState>((set, get) => ({
|
|
toasts: [],
|
|
push(t) {
|
|
const id = counter++;
|
|
set({ toasts: [...get().toasts.slice(-3), { ...t, id }] });
|
|
if (t.duration > 0) {
|
|
const timer = window.setTimeout(() => get().dismiss(id), t.duration);
|
|
timers.set(id, timer);
|
|
}
|
|
return id;
|
|
},
|
|
dismiss(id) {
|
|
const t = timers.get(id);
|
|
if (t) window.clearTimeout(t);
|
|
timers.delete(id);
|
|
set({ toasts: get().toasts.filter((x) => x.id !== id) });
|
|
},
|
|
}));
|
|
|
|
export const toast = {
|
|
show(message: string, opts: { action?: Toast["action"]; duration?: number; kind?: Toast["kind"]; progress?: boolean } = {}): number {
|
|
return useToasts.getState().push({ message, kind: opts.kind ?? "info", action: opts.action, duration: opts.duration ?? (opts.action ? 7000 : 4000), progress: opts.progress });
|
|
},
|
|
success(message: string, opts: { action?: Toast["action"]; duration?: number } = {}): number {
|
|
return toast.show(message, { ...opts, kind: "success" });
|
|
},
|
|
error(message: string, opts: { action?: Toast["action"]; duration?: number } = {}): number {
|
|
return toast.show(message, { ...opts, kind: "error", duration: opts.duration ?? 8000 });
|
|
},
|
|
dismiss(id: number) {
|
|
useToasts.getState().dismiss(id);
|
|
},
|
|
};
|
|
|
|
export function ToastHost() {
|
|
const toasts = useToasts((s) => s.toasts);
|
|
const dismiss = useToasts((s) => s.dismiss);
|
|
if (!toasts.length) return null;
|
|
return (
|
|
<div className="toast-host" role="status" aria-live="polite">
|
|
{toasts.map((t) => (
|
|
<div key={t.id} className={`toast toast-${t.kind}`}>
|
|
<span className="toast-msg">{t.message}</span>
|
|
{t.action && (
|
|
<button
|
|
className="toast-action"
|
|
onClick={() => {
|
|
void t.action!.onClick();
|
|
dismiss(t.id);
|
|
}}
|
|
>
|
|
{t.action.label}
|
|
</button>
|
|
)}
|
|
<button className="toast-close" aria-label="Dismiss" onClick={() => dismiss(t.id)}>
|
|
<X size={16} />
|
|
</button>
|
|
{t.progress && t.duration > 0 && <span className="toast-progress" style={{ animationDuration: `${t.duration}ms` }} />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|