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,56 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
interface Props {
|
||||
direction: "vertical" | "horizontal"; // vertical = a vertical bar that resizes width
|
||||
onResize: (delta: number) => void;
|
||||
onEnd?: () => void;
|
||||
onReset?: () => void;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/** Drag handle between two panes. Calls onResize with the pointer delta since the last event. */
|
||||
export function Splitter({ direction, onResize, onEnd, onReset, ariaLabel }: Props) {
|
||||
const last = useRef(0);
|
||||
const active = useRef(false);
|
||||
return (
|
||||
<div
|
||||
className={`splitter ${direction}`}
|
||||
role="separator"
|
||||
aria-orientation={direction === "vertical" ? "vertical" : "horizontal"}
|
||||
aria-label={ariaLabel ?? "Resize panes"}
|
||||
tabIndex={0}
|
||||
onDoubleClick={onReset}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
active.current = true;
|
||||
last.current = direction === "vertical" ? e.clientX : e.clientY;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
document.body.style.cursor = direction === "vertical" ? "col-resize" : "row-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!active.current) return;
|
||||
const pos = direction === "vertical" ? e.clientX : e.clientY;
|
||||
const delta = pos - last.current;
|
||||
if (delta) {
|
||||
last.current = pos;
|
||||
onResize(delta);
|
||||
}
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!active.current) return;
|
||||
active.current = false;
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
onEnd?.();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowUp") onResize(-24);
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") onResize(24);
|
||||
}}
|
||||
>
|
||||
<span className="splitter-grip" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { EmailAddress } from "@/jmap/types";
|
||||
import { avatarColor, initials } from "@/lib/address";
|
||||
import { useContacts } from "@/store/contacts";
|
||||
import { contactPhoto } from "@/lib/contacts";
|
||||
|
||||
export function Avatar({ who, size, className }: { who: EmailAddress | { name?: string | null; email?: string } | string | null | undefined; size?: "sm" | "lg" | "xl"; className?: string }) {
|
||||
const email = typeof who === "string" ? who : (who?.email ?? "");
|
||||
const name = typeof who === "string" ? who : (who?.name ?? who?.email ?? "");
|
||||
const photo = useContacts((s) => {
|
||||
if (!email || !s.loaded) return null;
|
||||
const c = s.lookupByEmail(email);
|
||||
return c && s.accountId ? contactPhoto(c, s.accountId) : null;
|
||||
});
|
||||
return (
|
||||
<span className={`avatar ${size ?? ""} ${className ?? ""}`} style={{ background: photo ? "transparent" : avatarColor(email || name) }} aria-hidden="true">
|
||||
{photo ? <img src={photo} alt="" loading="lazy" /> : initials({ name, email })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Switch({ checked, onChange, label, hint, disabled }: { checked: boolean; onChange: (v: boolean) => void; label?: ReactNode; hint?: ReactNode; disabled?: boolean }) {
|
||||
const sw = (
|
||||
<button type="button" role="switch" aria-checked={checked} className="switch" onClick={() => !disabled && onChange(!checked)} disabled={disabled} />
|
||||
);
|
||||
if (!label) return sw;
|
||||
return (
|
||||
<div className="switch-row">
|
||||
<div className="switch-text">
|
||||
<span>{label}</span>
|
||||
{hint && <span className="hint">{hint}</span>}
|
||||
</div>
|
||||
{sw}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ size = "md", label }: { size?: "md" | "lg"; label?: string }) {
|
||||
return (
|
||||
<div className="row" style={{ justifyContent: "center", padding: 16, gap: 10 }}>
|
||||
<span className={`spinner ${size === "lg" ? "lg" : ""}`} />
|
||||
{label && <span className="muted">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({ icon, title, children }: { icon?: ReactNode; title: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="empty">
|
||||
{icon}
|
||||
<h3>{title}</h3>
|
||||
{children && <p>{children}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMediaQuery(q: string): boolean {
|
||||
const [m, setM] = useState(() => window.matchMedia(q).matches);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(q);
|
||||
const fn = () => setM(mq.matches);
|
||||
mq.addEventListener("change", fn);
|
||||
return () => mq.removeEventListener("change", fn);
|
||||
}, [q]);
|
||||
return m;
|
||||
}
|
||||
|
||||
export const useIsMobile = () => useMediaQuery("(max-width: 768px)");
|
||||
export const useIsNarrow = () => useMediaQuery("(max-width: 900px)");
|
||||
|
||||
export function Kbd({ keys }: { keys: string }) {
|
||||
return (
|
||||
<span className="keys">
|
||||
{keys.split(" ").map((k, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && <span className="muted" style={{ margin: "0 3px" }}>then</span>}
|
||||
{k.split("+").map((p, j) => (
|
||||
<kbd key={j} className="kbd" style={{ marginRight: 2 }}>
|
||||
{p === "mod" ? (navigator.platform.includes("Mac") ? "⌘" : "Ctrl") : p === "shift" ? "⇧" : p === "enter" ? "↵" : p === "esc" ? "Esc" : p}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ColorSwatches({ value, onChange, colors }: { value: string | null | undefined; onChange: (c: string) => void; colors?: string[] }) {
|
||||
const list = colors ?? CALENDAR_COLORS;
|
||||
return (
|
||||
<div className="swatches">
|
||||
{list.map((c) => (
|
||||
<button key={c} type="button" className={`swatch ${value?.toLowerCase() === c ? "active" : ""}`} style={{ background: c }} onClick={() => onChange(c)} aria-label={c} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CALENDAR_COLORS = ["#0f766e", "#2563eb", "#7c3aed", "#db2777", "#dc2626", "#ea580c", "#ca8a04", "#16a34a", "#0891b2", "#4b5563", "#9333ea", "#be123c"];
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type CSSProperties } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export interface Anchor {
|
||||
x: number;
|
||||
y: number;
|
||||
w?: number;
|
||||
h?: number;
|
||||
}
|
||||
|
||||
export function anchorFromEl(el: Element | null): Anchor | null {
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { x: r.left, y: r.top, w: r.width, h: r.height };
|
||||
}
|
||||
|
||||
interface PopoverProps {
|
||||
anchor: Anchor | null;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
align?: "start" | "end";
|
||||
/** Prefer opening below (default) or above. */
|
||||
side?: "bottom" | "top" | "right";
|
||||
width?: number | string;
|
||||
style?: CSSProperties;
|
||||
closeOnClick?: boolean;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/** Generic anchored popover rendered in a portal; closes on outside click / Escape. */
|
||||
export function Popover({ anchor, onClose, children, className, align = "start", side = "bottom", width, style, closeOnClick = true, role = "menu" }: PopoverProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number; maxHeight: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !ref.current) return;
|
||||
const el = ref.current;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const aw = anchor.w ?? 0;
|
||||
const ah = anchor.h ?? 0;
|
||||
let left = align === "end" ? anchor.x + aw - rect.width : anchor.x;
|
||||
let top = side === "top" ? anchor.y - rect.height - 4 : anchor.y + ah + 4;
|
||||
if (side === "right") {
|
||||
left = anchor.x + aw + 4;
|
||||
top = anchor.y;
|
||||
}
|
||||
if (left + rect.width > vw - 8) left = Math.max(8, vw - rect.width - 8);
|
||||
if (left < 8) left = 8;
|
||||
let maxHeight = Math.min(vh - 16, 560);
|
||||
if (top + rect.height > vh - 8) {
|
||||
// flip above if there is room, else clamp
|
||||
const above = anchor.y - rect.height - 4;
|
||||
if (above >= 8 && side !== "right") top = above;
|
||||
else {
|
||||
top = Math.max(8, vh - rect.height - 8);
|
||||
maxHeight = vh - top - 8;
|
||||
}
|
||||
}
|
||||
if (top < 8) top = 8;
|
||||
setPos({ left, top, maxHeight });
|
||||
}, [anchor, align, side]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return;
|
||||
const onDown = (e: MouseEvent | TouchEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const onScroll = () => onClose();
|
||||
// Defer so the opening click doesn't immediately close.
|
||||
const t = window.setTimeout(() => {
|
||||
document.addEventListener("mousedown", onDown, true);
|
||||
document.addEventListener("touchstart", onDown, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
window.addEventListener("resize", onScroll);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(t);
|
||||
document.removeEventListener("mousedown", onDown, true);
|
||||
document.removeEventListener("touchstart", onDown, true);
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
window.removeEventListener("resize", onScroll);
|
||||
};
|
||||
}, [anchor, onClose]);
|
||||
|
||||
if (!anchor) return null;
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role={role}
|
||||
className={`popover ${className ?? ""}`}
|
||||
style={{ left: pos?.left ?? -9999, top: pos?.top ?? -9999, visibility: pos ? "visible" : "hidden", width, maxHeight: pos?.maxHeight, ...style }}
|
||||
onClick={(e) => {
|
||||
if (closeOnClick && (e.target as HTMLElement).closest(".menu-item")) onClose();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export interface MenuItemProps {
|
||||
icon?: ReactNode;
|
||||
label: ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
kbd?: string;
|
||||
active?: boolean;
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
export function MenuItem({ icon, label, onClick, disabled, danger, kbd, active, checked }: MenuItemProps) {
|
||||
return (
|
||||
<button type="button" className={`menu-item ${danger ? "danger" : ""} ${active ? "active" : ""}`} onClick={onClick} disabled={disabled} role="menuitem">
|
||||
{checked !== undefined ? <span style={{ width: 16, display: "inline-flex" }}>{checked ? "✓" : ""}</span> : icon}
|
||||
<span className="grow truncate">{label}</span>
|
||||
{kbd && <span className="menu-kbd">{kbd}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuSep() {
|
||||
return <div className="menu-sep" />;
|
||||
}
|
||||
|
||||
export function MenuTitle({ children }: { children: ReactNode }) {
|
||||
return <div className="menu-title">{children}</div>;
|
||||
}
|
||||
|
||||
/** Hook to manage a menu anchored to a trigger element. */
|
||||
export function useMenu() {
|
||||
const [anchor, setAnchor] = useState<Anchor | null>(null);
|
||||
return {
|
||||
anchor,
|
||||
open: (e: { currentTarget: Element } | Element) => setAnchor(anchorFromEl("currentTarget" in e ? e.currentTarget : e)),
|
||||
openAt: (x: number, y: number) => setAnchor({ x, y, w: 0, h: 0 }),
|
||||
close: () => setAnchor(null),
|
||||
isOpen: anchor !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Simple tooltip via title-like hover with delay. */
|
||||
export function Tooltip({ text, children }: { text: string; children: ReactNode }) {
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const timer = useRef<number | null>(null);
|
||||
return (
|
||||
<span
|
||||
style={{ display: "inline-flex" }}
|
||||
onMouseEnter={(e) => {
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
timer.current = window.setTimeout(() => setPos({ x: r.left + r.width / 2, y: r.bottom + 6 }), 500);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
setPos(null);
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
if (timer.current) window.clearTimeout(timer.current);
|
||||
setPos(null);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{pos && createPortal(<div className="tooltip" style={{ left: Math.max(8, Math.min(pos.x, window.innerWidth - 8)), top: pos.y, transform: "translateX(-50%)" }}>{text}</div>, document.body)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user