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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user