Files
SRCmail/components/keyboard-shortcuts-modal.tsx
T
Linus Rath cf02f587de feat: resizable columns, nav rail overhaul, multi-select & drag-drop, UI polish
Resizable Columns
- Add ResizeHandle component with mouse drag, keyboard (Arrow keys),
  and double-click to reset to default width
- Add sidebarWidth/emailListWidth to ui-store with clamping + persistence
- Wire resize handles between sidebar/email-list panels on desktop

Navigation Rail Overhaul
- Move StorageQuotaCircle, push-status, sign-out from Sidebar to NavigationRail
- Interactive SVG ring with popover breakdown (used/free/total)
- Sidebar collapse state lifted to ui-store
- Show total email count per mailbox alongside unread badge

Email Multi-Selection & Drag-and-Drop
- Ctrl+Click (toggle) and Shift+Click (range) on all list items
- Add selectRangeEmails and lastSelectedEmailId to email-store
- Enable drag-and-drop on thread items and thread headers
- useEmailDrag accepts optional threadEmails for full-thread drag

Email Viewer Layout
- Remove card wrapper for cleaner full-width reading
- Always render HTML body when available
- Adjust skeleton loader to match flat layout

Modal & UI Polish
- Standardise backdrops, close buttons, padding, border-radius, transitions
- Migrate template-string classNames to cn() in settings
- Unify focus-ring token to ring-ring on form controls

i18n
- Add storage_used/free/total keys to all 8 locales

Dev Mock JMAP Server (new, gated by DEV_MOCK_JMAP=true)
- Session, Mailbox/Email/Thread/Identity CRUD, back-references, upload
- GET /download with Content-Disposition, GET /eventsource SSE

Tests (46 new)
- ui-store (13), email-selection (10), resize-handle (9), mock-server (14)
2026-03-09 16:46:25 +01:00

188 lines
6.4 KiB
TypeScript

"use client";
import { useTranslations } from "next-intl";
import { X, Keyboard } from "lucide-react";
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
import { cn } from "@/lib/utils";
import { useFocusTrap } from "@/hooks/use-focus-trap";
interface KeyboardShortcutsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
const t = useTranslations();
const modalRef = useFocusTrap({
isActive: isOpen,
onEscape: onClose,
restoreFocus: true,
});
if (!isOpen) return null;
return (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-dialog-title"
className={cn(
"bg-background border border-border rounded-lg shadow-xl",
"w-full max-w-2xl max-h-[90vh] overflow-hidden",
"animate-in zoom-in-95 duration-200"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<Keyboard className="w-5 h-5 text-muted-foreground" />
<h2 id="shortcuts-dialog-title" className="text-lg font-semibold text-foreground">
{t("shortcuts.title")}
</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={t("common.close")}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-4 md:p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Navigation Section */}
<section>
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.navigation")}
</h3>
<div className="space-y-2">
{KEYBOARD_SHORTCUTS.navigation.map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Actions Section */}
<section>
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.actions")}
</h3>
<div className="space-y-2">
{KEYBOARD_SHORTCUTS.actions.map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Global Section */}
<section className="md:col-span-2">
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.global")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{KEYBOARD_SHORTCUTS.global.map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Threads Section */}
<section className="md:col-span-2">
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.threads")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{KEYBOARD_SHORTCUTS.threads.map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
{/* Composer Section */}
<section className="md:col-span-2">
<h3 className="text-sm font-semibold text-foreground mb-3 uppercase tracking-wider">
{t("shortcuts.sections.composer")}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{KEYBOARD_SHORTCUTS.composer.map((shortcut) => (
<ShortcutRow
key={shortcut.key}
shortcutKey={shortcut.key}
description={t(shortcut.description)}
/>
))}
</div>
</section>
</div>
{/* Footer tip */}
<div className="mt-6 pt-4 border-t border-border">
<p className="text-sm text-muted-foreground text-center">
{t("shortcuts.tip")}
</p>
</div>
</div>
</div>
</div>
);
}
function ShortcutRow({
shortcutKey,
description,
}: {
shortcutKey: string;
description: string;
}) {
// Split keys by " / " to render multiple key badges
const keys = shortcutKey.split(" / ");
return (
<div className="flex items-center justify-between py-1.5">
<span className="text-sm text-muted-foreground">{description}</span>
<div className="flex items-center gap-1.5 ml-4">
{keys.map((key, index) => (
<span key={index}>
{index > 0 && <span className="text-muted-foreground/50 mx-1 text-xs">or</span>}
<kbd
className={cn(
"inline-flex items-center justify-center",
"px-2 py-0.5 text-xs font-mono font-medium",
"bg-muted border border-border rounded",
"text-foreground shadow-sm",
"min-w-[24px]"
)}
>
{key}
</kbd>
</span>
))}
</div>
</div>
);
}