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:
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// Keyboard shortcuts (SPEC §9.4). Ignores keystrokes while typing in inputs,
|
||||
// except Escape. Supports the `g i` chord (go to inbox).
|
||||
export interface Shortcuts {
|
||||
onSearch?: () => void;
|
||||
onCompose?: () => void;
|
||||
onReply?: () => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onArchive?: () => void;
|
||||
onGoInbox?: () => void;
|
||||
onEscape?: () => void;
|
||||
onNext?: () => void;
|
||||
onPrev?: () => void;
|
||||
}
|
||||
|
||||
function isTyping(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || el.isContentEditable;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(shortcuts: Shortcuts): void {
|
||||
const ref = useRef(shortcuts);
|
||||
ref.current = shortcuts;
|
||||
const pendingG = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const s = ref.current;
|
||||
if (e.key === "Escape") {
|
||||
s.onEscape?.();
|
||||
return;
|
||||
}
|
||||
if (isTyping(e.target)) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
if (pendingG.current) {
|
||||
pendingG.current = false;
|
||||
if (e.key === "i") {
|
||||
e.preventDefault();
|
||||
s.onGoInbox?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "/":
|
||||
e.preventDefault();
|
||||
s.onSearch?.();
|
||||
break;
|
||||
case "c":
|
||||
s.onCompose?.();
|
||||
break;
|
||||
case "r":
|
||||
s.onReply?.();
|
||||
break;
|
||||
case "a":
|
||||
s.onReplyAll?.();
|
||||
break;
|
||||
case "f":
|
||||
s.onForward?.();
|
||||
break;
|
||||
case "e":
|
||||
s.onArchive?.();
|
||||
break;
|
||||
case "j":
|
||||
s.onNext?.();
|
||||
break;
|
||||
case "k":
|
||||
s.onPrev?.();
|
||||
break;
|
||||
case "g":
|
||||
pendingG.current = true;
|
||||
window.setTimeout(() => (pendingG.current = false), 800);
|
||||
break;
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
}
|
||||
Reference in New Issue
Block a user