Initial release: JMAP Webmail Client
A modern, privacy-focused webmail client built with Next.js and the JMAP protocol. Designed for Stalwart Mail Server. Features: - Full email operations (compose, reply, forward, threading) - Real-time push notifications - Dark/light theme support - Mobile responsive design - Keyboard shortcuts - Drag-and-drop organization - i18n (English/French) - Security-first (external content blocked, HTML sanitization)
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface ContextMenuState<T> {
|
||||
isOpen: boolean;
|
||||
position: Position;
|
||||
data: T | null;
|
||||
}
|
||||
|
||||
interface UseContextMenuReturn<T> {
|
||||
contextMenu: ContextMenuState<T>;
|
||||
openContextMenu: (e: React.MouseEvent, data: T) => void;
|
||||
closeContextMenu: () => void;
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const MENU_WIDTH = 200;
|
||||
const MENU_HEIGHT = 320; // Approximate max height
|
||||
|
||||
export function useContextMenu<T>(): UseContextMenuReturn<T> {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState<T>>({
|
||||
isOpen: false,
|
||||
position: { x: 0, y: 0 },
|
||||
data: null,
|
||||
});
|
||||
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const calculatePosition = useCallback((clientX: number, clientY: number): Position => {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let x = clientX;
|
||||
let y = clientY;
|
||||
|
||||
// Adjust for right edge
|
||||
if (x + MENU_WIDTH > viewportWidth - 10) {
|
||||
x = viewportWidth - MENU_WIDTH - 10;
|
||||
}
|
||||
|
||||
// Adjust for bottom edge
|
||||
if (y + MENU_HEIGHT > viewportHeight - 10) {
|
||||
y = viewportHeight - MENU_HEIGHT - 10;
|
||||
}
|
||||
|
||||
// Ensure minimum position
|
||||
x = Math.max(10, x);
|
||||
y = Math.max(10, y);
|
||||
|
||||
return { x, y };
|
||||
}, []);
|
||||
|
||||
const openContextMenu = useCallback((e: React.MouseEvent, data: T) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const position = calculatePosition(e.clientX, e.clientY);
|
||||
|
||||
setContextMenu({
|
||||
isOpen: true,
|
||||
position,
|
||||
data,
|
||||
});
|
||||
}, [calculatePosition]);
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenu((prev) => ({
|
||||
...prev,
|
||||
isOpen: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Close on escape key, click outside, and scroll
|
||||
useEffect(() => {
|
||||
if (!contextMenu.isOpen) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
// Add listeners with a slight delay to prevent immediate closing
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, [contextMenu.isOpen, closeContextMenu]);
|
||||
|
||||
return {
|
||||
contextMenu,
|
||||
openContextMenu,
|
||||
closeContextMenu,
|
||||
menuRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, DragEvent } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
|
||||
interface UseEmailDragOptions {
|
||||
email: Email;
|
||||
sourceMailboxId: string;
|
||||
}
|
||||
|
||||
interface UseEmailDragReturn {
|
||||
dragHandlers: {
|
||||
draggable: boolean;
|
||||
onDragStart: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDragEnd: (e: DragEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
isDragging: boolean;
|
||||
}
|
||||
|
||||
function createDragPreview(count: number): HTMLElement {
|
||||
const preview = document.createElement("div");
|
||||
preview.className = "drag-preview";
|
||||
preview.style.cssText = `
|
||||
position: fixed;
|
||||
top: -9999px;
|
||||
left: 0;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--color-primary, #3b82f6);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 9999;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
`;
|
||||
preview.textContent = count === 1 ? "1 email" : `${count} emails`;
|
||||
document.body.appendChild(preview);
|
||||
return preview;
|
||||
}
|
||||
|
||||
export function useEmailDrag({ email, sourceMailboxId }: UseEmailDragOptions): UseEmailDragReturn {
|
||||
const { selectedEmailIds, emails } = useEmailStore();
|
||||
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
// Determine which emails to drag:
|
||||
// - If current email is selected, drag all selected
|
||||
// - Otherwise, drag only this email
|
||||
const isSelected = selectedEmailIds.has(email.id);
|
||||
const emailsToDrag = isSelected
|
||||
? emails.filter(em => selectedEmailIds.has(em.id))
|
||||
: [email];
|
||||
|
||||
// Set data transfer
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData(
|
||||
"application/x-email-ids",
|
||||
JSON.stringify(emailsToDrag.map(em => em.id))
|
||||
);
|
||||
e.dataTransfer.setData(
|
||||
"text/plain",
|
||||
emailsToDrag.map(em => em.subject || "(no subject)").join(", ")
|
||||
);
|
||||
|
||||
// Create custom drag image
|
||||
const dragPreview = createDragPreview(emailsToDrag.length);
|
||||
e.dataTransfer.setDragImage(dragPreview, 0, 0);
|
||||
|
||||
// Clean up preview after drag starts (browser keeps a snapshot)
|
||||
requestAnimationFrame(() => {
|
||||
dragPreview.remove();
|
||||
});
|
||||
|
||||
startDrag(emailsToDrag, sourceMailboxId);
|
||||
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
endDrag();
|
||||
}, [endDrag]);
|
||||
|
||||
// Check if this specific email is being dragged
|
||||
const isThisEmailDragging = isDragging && draggedEmails.some(em => em.id === email.id);
|
||||
|
||||
return {
|
||||
dragHandlers: {
|
||||
draggable: true,
|
||||
onDragStart: handleDragStart,
|
||||
onDragEnd: handleDragEnd,
|
||||
},
|
||||
isDragging: isThisEmailDragging,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
|
||||
export interface KeyboardShortcutHandlers {
|
||||
// Navigation
|
||||
onNextEmail?: () => void;
|
||||
onPreviousEmail?: () => void;
|
||||
onOpenEmail?: () => void;
|
||||
onCloseEmail?: () => void;
|
||||
|
||||
// Email actions
|
||||
onReply?: () => void;
|
||||
onReplyAll?: () => void;
|
||||
onForward?: () => void;
|
||||
onToggleStar?: () => void;
|
||||
onArchive?: () => void;
|
||||
onDelete?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
|
||||
// Global actions
|
||||
onCompose?: () => void;
|
||||
onFocusSearch?: () => void;
|
||||
onShowHelp?: () => void;
|
||||
onRefresh?: () => void;
|
||||
|
||||
// Selection
|
||||
onSelectAll?: () => void;
|
||||
onDeselectAll?: () => void;
|
||||
|
||||
// Thread actions
|
||||
onToggleThreadExpansion?: () => void;
|
||||
}
|
||||
|
||||
export interface UseKeyboardShortcutsOptions {
|
||||
enabled?: boolean;
|
||||
emails: Email[];
|
||||
selectedEmailId?: string;
|
||||
handlers: KeyboardShortcutHandlers;
|
||||
}
|
||||
|
||||
// Check if user is typing in an input field
|
||||
function isInputFocused(): boolean {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
const isInput = tagName === "input" || tagName === "textarea" || tagName === "select";
|
||||
const isContentEditable = activeElement.getAttribute("contenteditable") === "true";
|
||||
|
||||
return isInput || isContentEditable;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled = true,
|
||||
emails,
|
||||
selectedEmailId,
|
||||
handlers,
|
||||
}: UseKeyboardShortcutsOptions) {
|
||||
const handlersRef = useRef(handlers);
|
||||
|
||||
// Keep handlers ref updated
|
||||
useEffect(() => {
|
||||
handlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
if (isInputFocused()) return;
|
||||
|
||||
const h = handlersRef.current;
|
||||
const key = event.key.toLowerCase();
|
||||
const hasModifier = event.ctrlKey || event.metaKey || event.altKey;
|
||||
|
||||
// Shortcuts that work with modifiers
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
switch (key) {
|
||||
case "a":
|
||||
// Ctrl/Cmd + A: Select all
|
||||
event.preventDefault();
|
||||
h.onSelectAll?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Shortcuts that should NOT work with modifiers
|
||||
if (hasModifier) return;
|
||||
|
||||
switch (key) {
|
||||
// Navigation
|
||||
case "j":
|
||||
case "arrowdown":
|
||||
event.preventDefault();
|
||||
h.onNextEmail?.();
|
||||
break;
|
||||
|
||||
case "k":
|
||||
case "arrowup":
|
||||
event.preventDefault();
|
||||
h.onPreviousEmail?.();
|
||||
break;
|
||||
|
||||
case "enter":
|
||||
case "o":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onOpenEmail?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "escape":
|
||||
event.preventDefault();
|
||||
h.onCloseEmail?.();
|
||||
h.onDeselectAll?.();
|
||||
break;
|
||||
|
||||
// Email actions (only when email is selected)
|
||||
case "r":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
h.onReplyAll?.();
|
||||
} else {
|
||||
h.onReply?.();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "a":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onReplyAll?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "f":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onForward?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "s":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onToggleStar?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "e":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onArchive?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "#":
|
||||
case "delete":
|
||||
case "backspace":
|
||||
if (selectedEmailId && (key === "#" || key === "delete" || key === "backspace")) {
|
||||
event.preventDefault();
|
||||
h.onDelete?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "u":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onMarkAsUnread?.();
|
||||
}
|
||||
break;
|
||||
|
||||
case "i":
|
||||
if (selectedEmailId && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
h.onMarkAsRead?.();
|
||||
}
|
||||
break;
|
||||
|
||||
// Global actions
|
||||
case "c":
|
||||
event.preventDefault();
|
||||
h.onCompose?.();
|
||||
break;
|
||||
|
||||
case "/":
|
||||
event.preventDefault();
|
||||
h.onFocusSearch?.();
|
||||
break;
|
||||
|
||||
case "?":
|
||||
event.preventDefault();
|
||||
h.onShowHelp?.();
|
||||
break;
|
||||
|
||||
case "g":
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
h.onRefresh?.();
|
||||
}
|
||||
break;
|
||||
|
||||
// Thread actions
|
||||
case "x":
|
||||
if (selectedEmailId) {
|
||||
event.preventDefault();
|
||||
h.onToggleThreadExpansion?.();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[selectedEmailId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [enabled, handleKeyDown]);
|
||||
|
||||
// Helper to get next/previous email
|
||||
const getAdjacentEmailIndex = useCallback(
|
||||
(direction: "next" | "previous"): number => {
|
||||
if (emails.length === 0) return -1;
|
||||
|
||||
if (!selectedEmailId) {
|
||||
// If no email selected, select first (for next) or last (for previous)
|
||||
return direction === "next" ? 0 : emails.length - 1;
|
||||
}
|
||||
|
||||
const currentIndex = emails.findIndex((e) => e.id === selectedEmailId);
|
||||
if (currentIndex === -1) return direction === "next" ? 0 : emails.length - 1;
|
||||
|
||||
if (direction === "next") {
|
||||
return currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
|
||||
} else {
|
||||
return currentIndex > 0 ? currentIndex - 1 : currentIndex;
|
||||
}
|
||||
},
|
||||
[emails, selectedEmailId]
|
||||
);
|
||||
|
||||
return { getAdjacentEmailIndex };
|
||||
}
|
||||
|
||||
// Shortcut definitions for the help modal
|
||||
export const KEYBOARD_SHORTCUTS = {
|
||||
navigation: [
|
||||
{ key: "j / ↓", description: "shortcuts.navigation.next_email" },
|
||||
{ key: "k / ↑", description: "shortcuts.navigation.previous_email" },
|
||||
{ key: "Enter / o", description: "shortcuts.navigation.open_email" },
|
||||
{ key: "Esc", description: "shortcuts.navigation.close_email" },
|
||||
],
|
||||
actions: [
|
||||
{ key: "r", description: "shortcuts.actions.reply" },
|
||||
{ key: "R / a", description: "shortcuts.actions.reply_all" },
|
||||
{ key: "f", description: "shortcuts.actions.forward" },
|
||||
{ key: "s", description: "shortcuts.actions.star" },
|
||||
{ key: "e", description: "shortcuts.actions.archive" },
|
||||
{ key: "# / Del", description: "shortcuts.actions.delete" },
|
||||
{ key: "u", description: "shortcuts.actions.mark_unread" },
|
||||
{ key: "Shift + I", description: "shortcuts.actions.mark_read" },
|
||||
],
|
||||
global: [
|
||||
{ key: "c", description: "shortcuts.global.compose" },
|
||||
{ key: "/", description: "shortcuts.global.search" },
|
||||
{ key: "?", description: "shortcuts.global.help" },
|
||||
{ key: "Shift + G", description: "shortcuts.global.refresh" },
|
||||
{ key: "Ctrl + A", description: "shortcuts.global.select_all" },
|
||||
],
|
||||
threads: [
|
||||
{ key: "x", description: "shortcuts.threads.expand_collapse" },
|
||||
],
|
||||
} as const;
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState, DragEvent } from "react";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
interface UseMailboxDropOptions {
|
||||
mailbox: Mailbox;
|
||||
onDropComplete?: () => void;
|
||||
}
|
||||
|
||||
interface UseMailboxDropReturn {
|
||||
dropHandlers: {
|
||||
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDragEnter: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDragLeave: (e: DragEvent<HTMLDivElement>) => void;
|
||||
onDrop: (e: DragEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
isDropTarget: boolean;
|
||||
isValidDropTarget: boolean;
|
||||
isInvalidDropTarget: boolean;
|
||||
}
|
||||
|
||||
export function useMailboxDrop({ mailbox, onDropComplete }: UseMailboxDropOptions): UseMailboxDropReturn {
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const { client } = useAuthStore();
|
||||
const { moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox } = useEmailStore();
|
||||
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
|
||||
|
||||
// Determine if this is a valid drop target
|
||||
const isValidTarget = useCallback(() => {
|
||||
if (!isDragging) return false;
|
||||
|
||||
// Cannot drop on same mailbox
|
||||
if (mailbox.id === sourceMailboxId) return false;
|
||||
|
||||
// Check if mailbox accepts items
|
||||
if (!mailbox.myRights?.mayAddItems) return false;
|
||||
|
||||
// Virtual nodes (shared folder headers) cannot be drop targets
|
||||
if (mailbox.id.startsWith("shared-")) return false;
|
||||
|
||||
// For shared mailboxes, check account compatibility
|
||||
if (mailbox.isShared && draggedEmails[0]) {
|
||||
// Get the source mailbox's account ID from the store
|
||||
const mailboxes = useEmailStore.getState().mailboxes;
|
||||
const sourceMb = mailboxes.find(mb => mb.id === sourceMailboxId);
|
||||
|
||||
// Cross-account moves are not supported
|
||||
if (sourceMb?.accountId !== mailbox.accountId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [isDragging, mailbox, sourceMailboxId, draggedEmails]);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
if (isValidTarget()) {
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
} else {
|
||||
e.dataTransfer.dropEffect = "none";
|
||||
}
|
||||
}, [isValidTarget]);
|
||||
|
||||
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Only leave if actually leaving the element (not entering a child)
|
||||
const relatedTarget = e.relatedTarget as Node | null;
|
||||
if (!e.currentTarget.contains(relatedTarget)) {
|
||||
setIsOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOver(false);
|
||||
|
||||
if (!client || !isValidTarget()) {
|
||||
endDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const emailIdsJson = e.dataTransfer.getData("application/x-email-ids");
|
||||
if (!emailIdsJson) {
|
||||
endDrag();
|
||||
return;
|
||||
}
|
||||
|
||||
const emailIds: string[] = JSON.parse(emailIdsJson);
|
||||
|
||||
// Get the destination mailbox ID (use originalId for shared folders)
|
||||
const destinationId = mailbox.originalId || mailbox.id;
|
||||
|
||||
// Move emails one by one (store handles counter updates)
|
||||
for (const emailId of emailIds) {
|
||||
await moveToMailbox(client, emailId, destinationId);
|
||||
}
|
||||
|
||||
// Clear selection if any selected emails were moved
|
||||
if (emailIds.some(id => selectedEmailIds.has(id))) {
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
// Refresh the current mailbox view
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
|
||||
// Show success toast
|
||||
if (emailIds.length === 1) {
|
||||
toast.success("Email moved", `Moved to ${mailbox.name}`);
|
||||
} else {
|
||||
toast.success("Emails moved", `${emailIds.length} emails moved to ${mailbox.name}`);
|
||||
}
|
||||
|
||||
onDropComplete?.();
|
||||
} catch (error) {
|
||||
console.error("Failed to move emails:", error);
|
||||
toast.error("Move failed", "Could not move emails to the selected folder");
|
||||
} finally {
|
||||
endDrag();
|
||||
}
|
||||
}, [client, mailbox, isValidTarget, moveToMailbox, selectedEmailIds, clearSelection, fetchEmails, selectedMailbox, endDrag, onDropComplete]);
|
||||
|
||||
const valid = isValidTarget();
|
||||
|
||||
return {
|
||||
dropHandlers: {
|
||||
onDragOver: handleDragOver,
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
},
|
||||
isDropTarget: isOver && isDragging,
|
||||
isValidDropTarget: isOver && valid,
|
||||
isInvalidDropTarget: isOver && isDragging && !valid,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
|
||||
// Tailwind v4 breakpoints
|
||||
const BREAKPOINTS = {
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
"2xl": 1536,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* SSR-safe media query hook
|
||||
* Returns false during SSR to prevent hydration mismatch
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
setMatches(mediaQuery.matches);
|
||||
|
||||
const handler = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handler);
|
||||
return () => mediaQuery.removeEventListener("change", handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect device type and sync with UI store
|
||||
* Uses Tailwind breakpoints: mobile < 768px, tablet 768-1024px, desktop > 1024px
|
||||
*/
|
||||
export function useDeviceDetection() {
|
||||
const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore();
|
||||
|
||||
const isMobileQuery = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
|
||||
const isTabletQuery = useMediaQuery(
|
||||
`(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)`
|
||||
);
|
||||
const isDesktopQuery = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
|
||||
|
||||
useEffect(() => {
|
||||
setDeviceType(isMobileQuery, isTabletQuery, isDesktopQuery);
|
||||
}, [isMobileQuery, isTabletQuery, isDesktopQuery, setDeviceType]);
|
||||
|
||||
return { isMobile, isTablet, isDesktop };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience hooks for specific breakpoints
|
||||
*/
|
||||
export function useIsMobile() {
|
||||
return useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
|
||||
}
|
||||
|
||||
export function useIsTablet() {
|
||||
return useMediaQuery(
|
||||
`(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)`
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsDesktop() {
|
||||
return useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
|
||||
}
|
||||
|
||||
export function useBreakpoint(breakpoint: keyof typeof BREAKPOINTS) {
|
||||
return useMediaQuery(`(min-width: ${BREAKPOINTS[breakpoint]}px)`);
|
||||
}
|
||||
Reference in New Issue
Block a user