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:
Bernd Rodler
2026-08-07 13:15:04 +02:00
parent 67f61f18d0
commit 83e29b3ef1
14 changed files with 933 additions and 42 deletions
+106 -1
View File
@@ -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