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
+11 -9
View File
@@ -538,17 +538,19 @@ export function EmailComposer({
// requests with the same draftId. See bug #303.
const inflightSaveRef = useRef<Promise<string | null> | null>(null);
const [attachments, setAttachments] = useState<ComposerAttachment[]>(() => {
if (mode === 'forward' && replyTo?.attachments?.length) {
return replyTo.attachments
if (replyTo?.attachments?.length) {
let atts = replyTo.attachments;
if (mode === 'forward') {
// Skip inline cid-referenced images - they're embedded in the forwarded HTML body
// (matches the viewer's hideInlineImageAttachments logic).
.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')))
.map(att => ({
name: att.name || 'attachment',
type: att.type || 'application/octet-stream',
size: att.size,
blobId: att.blobId,
}));
atts = atts.filter(att => !(att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/')));
}
return atts.map(att => ({
name: att.name || 'attachment',
type: att.type || 'application/octet-stream',
size: att.size,
blobId: att.blobId,
}));
}
return [];
});
+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
+48
View File
@@ -76,6 +76,7 @@ import {
PlayCircle,
PenSquare,
CalendarClock,
CalendarPlus,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
@@ -88,6 +89,8 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner";
@@ -703,6 +706,9 @@ export function EmailViewer({
const isScheduled = email?.isScheduled === true;
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
const createAppointmentVisible = !isScheduled && !isDraft && calendarEnabled && !!email;
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
@@ -1029,6 +1035,34 @@ export function EmailViewer({
const { isMobile: isMobileDevice } = useDeviceDetection();
const router = useRouter();
const handleCreateAppointment = useCallback(() => {
if (!email) return;
const subject = email.subject ? `Re: ${email.subject}` : "";
const body = email.htmlBody?.[0]?.partId
? email.bodyValues?.[email.htmlBody[0].partId]?.value || ""
: "";
const participants: { name?: string; email: string }[] = [];
const seen = new Set<string>();
const addParticipant = (p?: { name?: string; email?: string }) => {
if (!p?.email) return;
const normalized = p.email.toLowerCase();
if (!seen.has(normalized)) {
seen.add(normalized);
participants.push({ name: p.name, email: p.email });
}
};
if (email.from) email.from.forEach(addParticipant);
if (email.to) email.to.forEach(addParticipant);
if (email.cc) email.cc.forEach(addParticipant);
useCalendarStore.getState().setNewEventPrefill({
title: subject,
description: body,
participants,
date: email.receivedAt,
});
router.push('/calendar');
}, [email, router]);
const handleViewContactSidebar = (contact: ContactCard | null, recipientEmail: string) => {
if (isMobileDevice) {
// No room for a sidebar on mobile - send the user to the contacts page
@@ -2909,6 +2943,20 @@ export function EmailViewer({
<Forward className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('forward')}</span>}
</Button>
{createAppointmentVisible && (
<Button
variant="ghost"
size="sm"
onClick={handleCreateAppointment}
data-overflow-item
data-overflow-priority="3.5"
className="hidden sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('create_appointment')}
>
<CalendarPlus className="w-4 h-4" />
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('create_appointment')}</span>}
</Button>
)}
</>)}
<PluginSlot name="toolbar-actions" />
</div>