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
+64 -1
View File
@@ -13,6 +13,8 @@ import { useSettingsStore } from "@/stores/settings-store";
import type { PendingEventPreview } from "./event-modal";
import { toast } from "@/stores/toast-store";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
import { Pencil, Trash2, Copy } from "lucide-react";
interface CalendarMonthViewProps {
selectedDate: Date;
@@ -25,6 +27,9 @@ interface CalendarMonthViewProps {
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime?: (date: Date) => void;
onEditEvent?: (event: CalendarEvent) => void;
onDeleteEvent?: (event: CalendarEvent) => void;
onDuplicateEvent?: (event: CalendarEvent) => void;
firstDayOfWeek?: number;
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
@@ -41,6 +46,9 @@ export function CalendarMonthView({
onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime,
onEditEvent,
onDeleteEvent,
onDuplicateEvent,
firstDayOfWeek = 1,
isMobile,
pendingPreview,
@@ -112,6 +120,54 @@ export function CalendarMonthView({
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
// Radial menu state
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
const [radialMenuEvent, setRadialMenuEvent] = useState<CalendarEvent | null>(null);
const closeRadialMenu = useCallback(() => {
setRadialMenuOpen(false);
}, []);
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
if (!radialMenuEvent) return [];
const ev = radialMenuEvent;
const items: RadialMenuItem[] = [];
if (onEditEvent) {
items.push({
id: "edit",
icon: <Pencil className="w-5 h-5" />,
label: t("edit"),
onClick: () => { onEditEvent(ev); },
});
}
if (onDeleteEvent) {
items.push({
id: "delete",
icon: <Trash2 className="w-5 h-5" />,
label: t("delete"),
onClick: () => { onDeleteEvent(ev); },
destructive: true,
});
}
if (onDuplicateEvent) {
items.push({
id: "duplicate",
icon: <Copy className="w-5 h-5" />,
label: t("duplicate"),
onClick: () => { onDuplicateEvent(ev); },
});
}
return items;
}, [radialMenuEvent, t, onEditEvent, onDeleteEvent, onDuplicateEvent]);
const handleRadialMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
setRadialMenuPos({ x: e.clientX, y: e.clientY });
setRadialMenuEvent(event);
setRadialMenuOpen(true);
onContextMenuEvent?.(e, event);
}, [onContextMenuEvent]);
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
e.preventDefault();
@@ -294,7 +350,7 @@ export function CalendarMonthView({
onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
onContextMenu={handleRadialMenuEvent}
draggable
className={isMobile ? "text-[10px] px-1" : undefined}
/>
@@ -306,6 +362,13 @@ export function CalendarMonthView({
</div>
))}
</div>
<RadialMenu
items={radialMenuItems}
isOpen={radialMenuOpen}
position={radialMenuPos}
onClose={closeRadialMenu}
/>
</div>
);
}
+20 -3
View File
@@ -49,6 +49,10 @@ interface EventModalProps {
onPreviewChange?: (preview: PendingEventPreview | null) => void;
currentUserEmails?: string[];
isMobile?: boolean;
prefillTitle?: string;
prefillDescription?: string;
prefillParticipants?: { name?: string; email: string }[];
prefillDate?: string;
}
function formatDateInput(d: Date): string {
@@ -178,6 +182,10 @@ export function EventModal({
onPreviewChange,
currentUserEmails = [],
isMobile = false,
prefillTitle,
prefillDescription,
prefillParticipants,
prefillDate,
}: EventModalProps) {
const t = useTranslations("calendar");
const locale = useLocale();
@@ -228,6 +236,10 @@ export function EventModal({
d.setHours(now.getHours() + 1, 0, 0, 0);
return d;
}
if (prefillDate) {
const d = new Date(prefillDate);
if (!isNaN(d.getTime())) return d;
}
const d = new Date();
d.setHours(d.getHours() + 1, 0, 0, 0);
return d;
@@ -244,8 +256,8 @@ export function EventModal({
return addHours(getInitialStart(), 1);
};
const [title, setTitle] = useState(event?.title || "");
const [description, setDescription] = useState(event?.description || "");
const [title, setTitle] = useState(event?.title || prefillTitle || "");
const [description, setDescription] = useState(event?.description || prefillDescription || "");
const [location, setLocation] = useState(
event?.locations ? Object.values(event.locations)[0]?.name || "" : ""
);
@@ -328,7 +340,12 @@ export function EventModal({
const [isSaving, setIsSaving] = useState(false);
const [attendees, setAttendees] = useState<{ name: string; email: string }[]>(() => {
if (!event?.participants) return [];
if (!event?.participants) {
if (prefillParticipants && prefillParticipants.length > 0) {
return prefillParticipants.map(p => ({ name: p.name || "", email: p.email }));
}
return [];
}
return existingParticipants
.filter(p => !p.isOrganizer)
.map(p => ({ name: p.name, email: p.email }));
@@ -0,0 +1,196 @@
"use client";
import { useState, useMemo, useCallback, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
format,
isToday,
isSameDay,
addMonths,
subMonths,
isSameMonth,
} from "date-fns";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { useAuthStore } from "@/stores/auth-store";
import { getEventDayBounds } from "@/lib/calendar-utils";
interface MiniCalendarDashletProps {
events?: { date: string; color?: string }[];
onDayClick?: (date: Date) => void;
selectedDate?: Date;
}
const ALL_DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
export function MiniCalendarDashlet({
events: propEvents,
onDayClick,
selectedDate: propSelectedDate,
}: MiniCalendarDashletProps) {
const t = useTranslations("calendar");
const router = useRouter();
const firstDayOfWeek = useSettingsStore((s) => s.firstDayOfWeek);
const storeSelectedDate = useCalendarStore((s) => s.selectedDate);
const storeEvents = useCalendarStore((s) => s.events);
const selectedDate = propSelectedDate ?? storeSelectedDate;
const client = useAuthStore((s) => s.client);
const [displayMonth, setDisplayMonth] = useState(() => new Date());
const weekStartsOn = useMemo(() => {
if (firstDayOfWeek === 0) return 0 as const;
if (firstDayOfWeek === 6) return 6 as const;
return 1 as const;
}, [firstDayOfWeek]);
useEffect(() => {
if (!client) return;
const start = format(startOfMonth(displayMonth), "yyyy-MM-dd'T'00:00:00");
const end = format(endOfMonth(displayMonth), "yyyy-MM-dd'T'23:59:59");
const { dateRange } = useCalendarStore.getState();
if (dateRange?.start === start && dateRange?.end === end) return;
useCalendarStore.getState().fetchEvents(client, start, end);
}, [displayMonth, client]);
const days = useMemo(() => {
const monthStart = startOfMonth(displayMonth);
const monthEnd = endOfMonth(displayMonth);
const calStart = startOfWeek(monthStart, { weekStartsOn });
const calEnd = endOfWeek(monthEnd, { weekStartsOn });
return eachDayOfInterval({ start: calStart, end: calEnd });
}, [displayMonth, weekStartsOn]);
const eventDates = useMemo(() => {
const set = new Set<string>();
for (const e of storeEvents) {
try {
const { startDay, endDay } = getEventDayBounds(e);
const cursor = new Date(startDay);
while (cursor <= endDay) {
set.add(format(cursor, "yyyy-MM-dd"));
cursor.setDate(cursor.getDate() + 1);
}
} catch {
/* skip */
}
}
if (propEvents) {
for (const e of propEvents) {
set.add(e.date);
}
}
return set;
}, [storeEvents, propEvents]);
const dayHeaders = useMemo(
() => [...ALL_DAY_KEYS.slice(weekStartsOn), ...ALL_DAY_KEYS.slice(0, weekStartsOn)],
[weekStartsOn],
);
const handlePrevMonth = useCallback(() => {
setDisplayMonth((prev) => subMonths(prev, 1));
}, []);
const handleNextMonth = useCallback(() => {
setDisplayMonth((prev) => addMonths(prev, 1));
}, []);
const handleGoToToday = useCallback(() => {
setDisplayMonth(new Date());
}, []);
const handleDayClick = useCallback(
(day: Date) => {
useCalendarStore.getState().setSelectedDate(day);
if (onDayClick) {
onDayClick(day);
} else {
router.push("/calendar");
}
},
[onDayClick, router],
);
return (
<div className="select-none px-2 py-1.5">
<div className="flex items-center justify-between mb-1">
<button
onClick={handlePrevMonth}
className="p-0.5 rounded hover:bg-muted transition-colors"
aria-label={t("nav_prev")}
>
<ChevronLeft className="w-3.5 h-3.5 text-muted-foreground" />
</button>
<button
onClick={handleGoToToday}
className="text-xs font-medium hover:bg-muted px-1.5 py-0.5 rounded transition-colors"
title={t("views.today")}
>
{format(displayMonth, "MMM yyyy")}
</button>
<button
onClick={handleNextMonth}
className="p-0.5 rounded hover:bg-muted transition-colors"
aria-label={t("nav_next")}
>
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
</button>
</div>
<div className="grid grid-cols-7 mb-0.5">
{dayHeaders.map((dh) => (
<div
key={dh}
className="text-center text-[9px] font-medium text-muted-foreground py-0.5"
>
{t(`days.${dh}`)}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-0">
{days.map((day) => {
const inMonth = isSameMonth(day, displayMonth);
const selected = isSameDay(day, selectedDate);
const today = isToday(day);
const dateStr = format(day, "yyyy-MM-dd");
const hasEvent = eventDates.has(dateStr);
const dotColor =
propEvents?.find((e) => e.date === dateStr && e.color)?.color ??
undefined;
return (
<button
key={day.toISOString()}
onClick={() => handleDayClick(day)}
className={cn(
"relative flex items-center justify-center w-6 h-6 text-[11px] rounded-full transition-colors mx-auto",
!inMonth && "text-muted-foreground/30",
inMonth && !selected && "hover:bg-muted",
today && !selected && "font-bold text-primary",
selected && "bg-primary text-primary-foreground",
)}
>
{day.getDate()}
{hasEvent && !selected && (
<span
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary"
style={dotColor ? { backgroundColor: dotColor } : undefined}
/>
)}
</button>
);
})}
</div>
</div>
);
}
+69 -3
View File
@@ -1,13 +1,14 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useState, useCallback } from "react";
import { useTranslations, useLocale } from "next-intl";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu } from "lucide-react";
import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, Pencil } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
import { cn } from "@/lib/utils";
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPhotoUri } from "@/stores/contact-store";
@@ -142,6 +143,63 @@ export function ContactList({
const density = useSettingsStore((state) => state.density);
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
// Radial menu state
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
const [radialMenuContact, setRadialMenuContact] = useState<ContactCard | null>(null);
const openRadialMenu = useCallback((e: React.MouseEvent, contact: ContactCard) => {
e.preventDefault();
setRadialMenuPos({ x: e.clientX, y: e.clientY });
setRadialMenuContact(contact);
setRadialMenuOpen(true);
}, []);
const closeRadialMenu = useCallback(() => {
setRadialMenuOpen(false);
}, []);
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
if (!radialMenuContact) return [];
const c = radialMenuContact;
const items: RadialMenuItem[] = [];
items.push({
id: "edit",
icon: <Pencil className="w-5 h-5" />,
label: t("edit"),
onClick: () => { onEditContact(c.id); },
});
items.push({
id: "delete",
icon: <Trash2 className="w-5 h-5" />,
label: t("delete"),
onClick: () => { onDeleteContact(c); },
destructive: true,
});
if (c.emails && Object.keys(c.emails).length > 0) {
const contactEmails = c.emails;
items.push({
id: "send-email",
icon: <Mail className="w-5 h-5" />,
label: t("send_email"),
onClick: () => {
const values = Object.values(contactEmails);
if (values[0]?.address) {
window.location.href = `mailto:${values[0].address}`;
}
},
});
}
items.push({
id: "export",
icon: <Download className="w-5 h-5" />,
label: t("export"),
onClick: () => { onBulkExport(); },
});
return items;
}, [radialMenuContact, t, onEditContact, onDeleteContact, onBulkExport]);
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
const activeFilters = countActiveFilters(filters);
@@ -571,7 +629,7 @@ export function ContactList({
e.stopPropagation();
onToggleSelection(contact.id);
}}
onContextMenu={(e, c) => openContextMenu(e, c)}
onContextMenu={(e, c) => { openContextMenu(e, c); openRadialMenu(e, c); }}
/>
);
return groupByLetter ? (
@@ -592,6 +650,14 @@ export function ContactList({
)}
</div>
{/* Radial Action Menu */}
<RadialMenu
items={radialMenuItems}
isOpen={radialMenuOpen}
position={radialMenuPos}
onClose={closeRadialMenu}
/>
{contextMenu.data && (
<ContactContextMenu
contact={contextMenu.data}
+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>
+113 -23
View File
@@ -11,7 +11,7 @@ import {
AlertCircle, Star, Clock, FolderUp,
FileArchive, FileSpreadsheet, Presentation, FileCode,
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
Menu, Users, Share2,
Menu, Users, Share2, MailPlus, Paperclip,
} from "lucide-react";
import { useIsDesktop } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
@@ -27,6 +27,7 @@ import { Avatar } from "@/components/ui/avatar";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import type { FileResource } from "@/stores/file-store";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNodeRights } from "@/lib/jmap/types";
@@ -106,6 +107,8 @@ interface FileBrowserProps {
sharingEnabled?: boolean;
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
/** Send selected files as email attachments - opens the composer with files pre-attached. */
onSendAsAttachment?: (names: string[]) => void;
}
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
@@ -384,6 +387,7 @@ export function FileBrowser({
ownAccountId,
sharingEnabled,
onShare,
onSendAsAttachment,
}: FileBrowserProps) {
const t = useTranslations("files");
const [showNewFolder, setShowNewFolder] = useState(false);
@@ -401,6 +405,60 @@ export function FileBrowser({
[sharingEnabled, onShare, client]);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
// Radial menu state
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
const [radialMenuResourceName, setRadialMenuResourceName] = useState<string | null>(null);
const closeRadialMenu = useCallback(() => {
setRadialMenuOpen(false);
}, []);
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
if (!radialMenuResourceName) return [];
const name = radialMenuResourceName;
const resource = resources.find((r) => r.name === name);
const items: RadialMenuItem[] = [];
items.push({
id: "rename",
icon: <Pencil className="w-5 h-5" />,
label: t("rename"),
onClick: () => { setRenameTarget(name); },
});
items.push({
id: "delete",
icon: <Trash2 className="w-5 h-5" />,
label: t("delete"),
onClick: () => { onDelete(name); },
destructive: true,
});
if (resource && !resource.isDirectory) {
items.push({
id: "download",
icon: <Download className="w-5 h-5" />,
label: t("download"),
onClick: () => { onDownload(name); },
});
}
if (canShare(resource)) {
items.push({
id: "share",
icon: <Share2 className="w-5 h-5" />,
label: t("share"),
onClick: () => { if (resource?.id) setShareTargetId(resource.id); },
});
}
if (resource && !resource.isDirectory) {
items.push({
id: "send-as-attachment",
icon: <Paperclip className="w-5 h-5" />,
label: t("send_as_attachment"),
onClick: () => {},
});
}
return items;
}, [radialMenuResourceName, resources, t, onDelete, onDownload, canShare]);
const [showNewTextFile, setShowNewTextFile] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -765,6 +823,9 @@ export function FileBrowser({
const handleContextMenu = (e: React.MouseEvent, name: string) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, name });
setRadialMenuPos({ x: e.clientX, y: e.clientY });
setRadialMenuResourceName(name);
setRadialMenuOpen(true);
};
// Adjust context menu position to stay within viewport
@@ -974,28 +1035,49 @@ export function FileBrowser({
{/* Action buttons */}
<div className="flex items-center gap-1 shrink-0">
{selectedResources.size > 1 && (
<>
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => onBatchDownload([...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory))}
>
<Download className="w-4 h-4 me-1" />
{t("download")} ({[...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory).length})
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive hover:text-destructive"
onClick={() => onBatchDelete([...selectedResources])}
>
<Trash2 className="w-4 h-4 me-1" />
{t("delete")} ({selectedResources.size})
</Button>
</>
)}
{selectedResources.size > 0 && (() => {
const fileNames = [...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory);
const hasFiles = fileNames.length > 0;
const showBatch = selectedResources.size > 1;
if (!showBatch && !hasFiles) return null;
return (
<>
{showBatch && (
<>
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => onBatchDownload(fileNames)}
>
<Download className="w-4 h-4 me-1" />
{t("download")} ({fileNames.length})
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive hover:text-destructive"
onClick={() => onBatchDelete([...selectedResources])}
>
<Trash2 className="w-4 h-4 me-1" />
{t("delete")} ({selectedResources.size})
</Button>
</>
)}
{hasFiles && onSendAsAttachment && (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => onSendAsAttachment(fileNames)}
>
<MailPlus className="w-4 h-4 me-1" />
{t("send_as_attachment")} {fileNames.length > 1 && `(${fileNames.length})`}
</Button>
)}
</>
);
})()}
{clipboard && (
<Button
variant="ghost"
@@ -1669,6 +1751,14 @@ export function FileBrowser({
</table>
)}
{/* Radial Action Menu */}
<RadialMenu
items={radialMenuItems}
isOpen={radialMenuOpen}
position={radialMenuPos}
onClose={closeRadialMenu}
/>
{/* Context menu */}
{contextMenu && (
<div
+28
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, ReactNode } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { MiniCalendarDashlet } from "@/components/calendar/mini-calendar-dashlet";
import { Button } from "@/components/ui/button";
import {
Inbox,
@@ -62,6 +63,7 @@ import { useUIStore } from "@/stores/ui-store";
import { useAuthStore } from "@/stores/auth-store";
import { useVacationStore } from "@/stores/vacation-store";
import { useSettingsStore, getKeywordVisibility } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
@@ -808,6 +810,12 @@ export function Sidebar({
return stored !== null ? JSON.parse(stored) : true;
} catch { return true; }
});
const [calendarDashletExpanded, setCalendarDashletExpanded] = useState(() => {
try {
const stored = localStorage.getItem('sidebarCalendarDashletExpanded');
return stored !== null ? JSON.parse(stored) : true;
} catch { return true; }
});
const [unifiedExpanded, setUnifiedExpanded] = useState(() => {
try {
const stored = localStorage.getItem('sidebarUnifiedExpanded');
@@ -840,6 +848,7 @@ export function Sidebar({
const emailKeywords = useSettingsStore(s => s.emailKeywords);
const nestedTags = useSettingsStore(s => s.nestedTags);
const isEmbedded = useIsEmbedded();
const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled'));
// The Pro shell owns the global chrome (rail + tab bar), so the sidebar's
// own AccountSwitcher would be a redundant second account UI in the same
// pane.
@@ -1054,6 +1063,13 @@ export function Sidebar({
return next;
});
};
const toggleCalendarDashlet = () => {
setCalendarDashletExpanded((prev: boolean) => {
const next = !prev;
try { localStorage.setItem('sidebarCalendarDashletExpanded', JSON.stringify(next)); } catch { /* */ }
return next;
});
};
const toggleShared = () => {
setSharedExpanded((prev: boolean) => {
const next = !prev;
@@ -1405,6 +1421,18 @@ export function Sidebar({
</div>
)}
{!isCollapsed && calendarEnabled && (
<div>
<SidebarSectionHeader
label={t("calendar")}
expanded={calendarDashletExpanded}
onToggle={toggleCalendarDashlet}
isCollapsed={isCollapsed}
/>
{calendarDashletExpanded && <MiniCalendarDashlet />}
</div>
)}
{!isCollapsed && <PluginSlot name="sidebar-widget" className="border-t border-border" />}
</div>
+216
View File
@@ -0,0 +1,216 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface RadialMenuItem {
id: string;
icon: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
}
interface RadialMenuProps {
items: RadialMenuItem[];
isOpen: boolean;
position: { x: number; y: number };
onClose: () => void;
size?: number;
}
export function RadialMenu({
items,
isOpen,
position,
onClose,
size = 200,
}: RadialMenuProps) {
const [mounted, setMounted] = useState(false);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const [animatingIn, setAnimatingIn] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isOpen) {
requestAnimationFrame(() => requestAnimationFrame(() => setAnimatingIn(true)));
} else {
setAnimatingIn(false);
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
setActiveIndex(-1);
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
if (e.key === "Enter" && activeIndex >= 0 && activeIndex < items.length) {
e.preventDefault();
const item = items[activeIndex];
if (!item.disabled) {
item.onClick();
onClose();
}
return;
}
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((prev) => {
let next = prev + 1;
if (next >= items.length) next = 0;
let loops = 0;
while (items[next]?.disabled && loops < items.length) {
next = next + 1 >= items.length ? 0 : next + 1;
loops++;
}
return next;
});
return;
}
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((prev) => {
let next = prev - 1;
if (next < 0) next = items.length - 1;
let loops = 0;
while (items[next]?.disabled && loops < items.length) {
next = next - 1 < 0 ? items.length - 1 : next - 1;
loops++;
}
return next;
});
return;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, activeIndex, items, onClose]);
const radius = size / 2 - 28;
const center = size / 2;
if (!mounted) return null;
return createPortal(
<>
<div
className={cn(
"fixed inset-0 z-[9998] bg-black/20 cursor-pointer transition-opacity duration-200",
animatingIn ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={onClose}
/>
<div
ref={menuRef}
className="fixed z-[9999]"
style={{
left: position.x - center,
top: position.y - center,
width: size,
height: size,
}}
role="menu"
aria-label="Action menu"
>
<div
className={cn(
"absolute rounded-full flex items-center justify-center transition-all duration-200 ease-out will-change-transform",
animatingIn ? "scale-100 opacity-100" : "scale-0 opacity-0"
)}
style={{
left: center - 24,
top: center - 24,
width: 48,
height: 48,
}}
>
<button
className="w-12 h-12 rounded-full bg-background border border-border shadow-lg flex items-center justify-center hover:bg-muted transition-colors cursor-pointer"
onClick={onClose}
aria-label="Close menu"
>
<X className="w-5 h-5 text-muted-foreground" />
</button>
</div>
{items.map((item, index) => {
const angle = (index / items.length) * 2 * Math.PI - Math.PI / 2;
const x = center + radius * Math.cos(angle);
const y = center + radius * Math.sin(angle);
const itemSize = 40;
return (
<div
key={item.id}
className={cn(
"absolute transition-all duration-200 ease-out will-change-transform",
animatingIn ? "scale-100 opacity-100" : "scale-0 opacity-0"
)}
style={{
left: x - itemSize / 2,
top: y - itemSize / 2,
width: itemSize,
height: itemSize,
transitionDelay: animatingIn ? `${index * 35}ms` : "0ms",
}}
>
<button
className={cn(
"group relative flex items-center justify-center w-full h-full rounded-full shadow-lg border border-border transition-all duration-150 cursor-pointer focus:outline-none",
item.disabled
? "opacity-30 cursor-not-allowed bg-muted"
: item.destructive
? "bg-destructive/10 text-destructive hover:scale-125 hover:bg-destructive hover:text-destructive-foreground hover:border-destructive"
: "bg-background text-foreground hover:scale-125 hover:bg-primary hover:text-primary-foreground hover:border-primary",
activeIndex === index && !item.disabled && "scale-125 ring-2 ring-primary"
)}
disabled={item.disabled}
onClick={(e) => {
e.stopPropagation();
if (item.disabled) return;
item.onClick();
onClose();
}}
onMouseEnter={() => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(-1)}
onFocus={() => setActiveIndex(index)}
onBlur={() => setActiveIndex(-1)}
role="menuitem"
aria-label={item.label}
tabIndex={activeIndex === index ? 0 : -1}
>
<span className="w-5 h-5 flex items-center justify-center [&>svg]:w-full [&>svg]:h-full">
{item.icon}
</span>
<span
className={cn(
"absolute -bottom-7 left-1/2 -translate-x-1/2 whitespace-nowrap text-[11px] font-medium leading-tight text-foreground bg-background/95 px-1.5 py-0.5 rounded shadow-sm border border-border/50",
"opacity-0 group-hover:opacity-100 transition-opacity duration-100 pointer-events-none",
activeIndex === index && !item.disabled && "opacity-100"
)}
>
{item.label}
</span>
</button>
</div>
);
})}
</div>
</>,
document.body
);
}