feat: P2.2 Create Appointment from Email + P2.4 Calendar Dashlet + P2.12 Action Wheel + P2.14 Share Files
- P2.2: 'Create Appointment' button in email viewer → pre-fills event modal with subject, body, participants, date. calendar-store newEventPrefill state. - P2.4: MiniCalendarDashlet in sidebar bottom — month grid with event dots, day click navigates to calendar. Collapsible, respect firstDayOfWeek. - P2.12: Custom radial menu (components/ui/radial-menu.tsx) — circular SVG menu with keyboard nav, animations. Wired into email-list, contact-list, file-browser, calendar-month-view right-click handlers. - P2.14: 'Send as Attachment' button in file browser — opens compose tab with selected files pre-attached via Pro tab store.
This commit is contained in:
@@ -15,6 +15,8 @@ import { useUIStore } from "@/stores/ui-store";
|
||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
||||
import { Reply, ReplyAll, Forward, Star, Archive, FolderOpen } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
||||
@@ -141,6 +143,101 @@ export function EmailList({
|
||||
const contextMenuEmail = contextMenu.data
|
||||
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
|
||||
: null;
|
||||
|
||||
// Radial menu state
|
||||
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
||||
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
||||
const [radialMenuEmail, setRadialMenuEmail] = useState<Email | null>(null);
|
||||
|
||||
const openRadialMenu = useCallback((e: React.MouseEvent, email: Email) => {
|
||||
e.preventDefault();
|
||||
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
||||
setRadialMenuEmail(email);
|
||||
setRadialMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeRadialMenu = useCallback(() => {
|
||||
setRadialMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
||||
if (!radialMenuEmail) return [];
|
||||
const email = radialMenuEmail;
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
|
||||
const act = (fn?: (email: Email) => void) => fn ? () => { fn(email); } : undefined;
|
||||
|
||||
const items: RadialMenuItem[] = [];
|
||||
|
||||
if (onReply) {
|
||||
items.push({
|
||||
id: "reply",
|
||||
icon: <Reply className="w-5 h-5" />,
|
||||
label: t("../context_menu.reply"),
|
||||
onClick: () => { act(onReply)!(); },
|
||||
});
|
||||
}
|
||||
if (onReplyAll) {
|
||||
items.push({
|
||||
id: "reply-all",
|
||||
icon: <ReplyAll className="w-5 h-5" />,
|
||||
label: t("../context_menu.reply_all"),
|
||||
onClick: () => { act(onReplyAll)!(); },
|
||||
});
|
||||
}
|
||||
if (onForward) {
|
||||
items.push({
|
||||
id: "forward",
|
||||
icon: <Forward className="w-5 h-5" />,
|
||||
label: t("../context_menu.forward"),
|
||||
onClick: () => { act(onForward)!(); },
|
||||
});
|
||||
}
|
||||
if (onToggleStar) {
|
||||
items.push({
|
||||
id: "star",
|
||||
icon: <Star className="w-5 h-5" fill={isStarred ? "currentColor" : "none"} />,
|
||||
label: isStarred ? t("../context_menu.unstar") : t("../context_menu.star"),
|
||||
onClick: () => { act(onToggleStar)!(); },
|
||||
});
|
||||
}
|
||||
if (onMarkAsRead) {
|
||||
items.push({
|
||||
id: "mark-read",
|
||||
icon: isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />,
|
||||
label: isUnread ? t("../context_menu.mark_read") : t("../context_menu.mark_unread"),
|
||||
onClick: () => { onMarkAsRead(email, !isUnread); },
|
||||
});
|
||||
}
|
||||
if (onArchive) {
|
||||
items.push({
|
||||
id: "archive",
|
||||
icon: <Archive className="w-5 h-5" />,
|
||||
label: t("../context_menu.archive"),
|
||||
onClick: () => { act(onArchive)!(); },
|
||||
});
|
||||
}
|
||||
if (onDelete) {
|
||||
items.push({
|
||||
id: "delete",
|
||||
icon: <Trash2 className="w-5 h-5" />,
|
||||
label: t("../context_menu.delete"),
|
||||
onClick: () => { act(onDelete)!(); },
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
if (onMoveToMailbox) {
|
||||
items.push({
|
||||
id: "move",
|
||||
icon: <FolderOpen className="w-5 h-5" />,
|
||||
label: t("../context_menu.move_to"),
|
||||
onClick: () => { openContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: radialMenuPos.x, clientY: radialMenuPos.y } as React.MouseEvent, email); },
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [radialMenuEmail, radialMenuPos, t, onReply, onReplyAll, onForward, onToggleStar, onMarkAsRead, onArchive, onDelete, onMoveToMailbox, openContextMenu]);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -549,7 +646,7 @@ export function EmailList({
|
||||
onEmailSelect?.(email);
|
||||
}}
|
||||
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
|
||||
onContextMenu={openContextMenu}
|
||||
onContextMenu={(e, email) => { openContextMenu(e, email); openRadialMenu(e, email); }}
|
||||
onOpenConversation={onOpenConversation}
|
||||
onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined}
|
||||
onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined}
|
||||
@@ -581,6 +678,14 @@ export function EmailList({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Radial Action Menu */}
|
||||
<RadialMenu
|
||||
items={radialMenuItems}
|
||||
isOpen={radialMenuOpen}
|
||||
position={radialMenuPos}
|
||||
onClose={closeRadialMenu}
|
||||
/>
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenuEmail && (
|
||||
<EmailContextMenu
|
||||
|
||||
Reference in New Issue
Block a user