From 83e29b3ef138b440fd17e5aff05757e46154d715 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Fri, 7 Aug 2026 13:15:04 +0200 Subject: [PATCH] feat: P2.2 Create Appointment from Email + P2.4 Calendar Dashlet + P2.12 Action Wheel + P2.14 Share Files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- app/(main)/[locale]/calendar/page.tsx | 29 ++- app/(main)/[locale]/files/page.tsx | 27 +++ components/calendar/calendar-month-view.tsx | 65 +++++- components/calendar/event-modal.tsx | 23 +- components/calendar/mini-calendar-dashlet.tsx | 196 ++++++++++++++++ components/contacts/contact-list.tsx | 72 +++++- components/email/email-composer.tsx | 20 +- components/email/email-list.tsx | 107 ++++++++- components/email/email-viewer.tsx | 48 ++++ components/files/file-browser.tsx | 136 +++++++++-- components/layout/sidebar.tsx | 28 +++ components/ui/radial-menu.tsx | 216 ++++++++++++++++++ locales/en/common.json | 2 + stores/calendar-store.ts | 6 + 14 files changed, 933 insertions(+), 42 deletions(-) create mode 100644 components/calendar/mini-calendar-dashlet.tsx create mode 100644 components/ui/radial-menu.tsx diff --git a/app/(main)/[locale]/calendar/page.tsx b/app/(main)/[locale]/calendar/page.tsx index e9c70d31..23eaebbd 100644 --- a/app/(main)/[locale]/calendar/page.tsx +++ b/app/(main)/[locale]/calendar/page.tsx @@ -97,6 +97,7 @@ export default function CalendarPage() { setSelectedDate, setViewMode, toggleCalendarVisibility, updateCalendar, shareCalendar, removeCalendar, clearCalendarEvents, refreshAllSubscriptions, icalSubscriptions, + newEventPrefill, setNewEventPrefill, } = useCalendarStore(); const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled')); const calendarTasksEnabled = usePolicyStore((s) => s.isFeatureEnabled('calendarTasksEnabled')); @@ -464,6 +465,19 @@ export default function CalendarPage() { setShowEventModal(true); }, [selectedDate, setSelectedDate]); + useEffect(() => { + if (!newEventPrefill) return; + setEditEvent(null); + if (newEventPrefill.date) { + const d = new Date(newEventPrefill.date); + if (!isNaN(d.getTime())) { + setDefaultModalDate(d); + setSelectedDate(d); + } + } + setShowEventModal(true); + }, [newEventPrefill, setSelectedDate]); + const openEditModal = useCallback((event: CalendarEvent) => { setEditEvent(event); setDefaultModalDate(undefined); @@ -1198,6 +1212,9 @@ export default function CalendarPage() { onContextMenuEvent={handleContextMenuEvent} onContextMenuEmpty={handleContextMenuEmpty} onCreateAtTime={openCreateModal} + onEditEvent={openEditModal} + onDeleteEvent={handleDeleteContextMenu} + onDuplicateEvent={handleDuplicateContextMenu} firstDayOfWeek={firstDayOfWeek} isMobile={isMobile} pendingPreview={pendingPreview} @@ -1490,10 +1507,14 @@ export default function CalendarPage() { onDelete={handleDeleteEvent} onDuplicate={handleDuplicateEvent} onRsvp={handleRsvp} - onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} + onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); setNewEventPrefill(null); }} onPreviewChange={setPendingPreview} currentUserEmails={currentUserEmails} isMobile={false} + prefillTitle={editEvent ? undefined : newEventPrefill?.title} + prefillDescription={editEvent ? undefined : newEventPrefill?.description} + prefillParticipants={editEvent ? undefined : newEventPrefill?.participants} + prefillDate={editEvent ? undefined : newEventPrefill?.date} /> )} @@ -1620,9 +1641,13 @@ export default function CalendarPage() { onDelete={handleDeleteEvent} onDuplicate={handleDuplicateEvent} onRsvp={handleRsvp} - onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} + onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); setNewEventPrefill(null); }} currentUserEmails={currentUserEmails} isMobile={true} + prefillTitle={editEvent ? undefined : newEventPrefill?.title} + prefillDescription={editEvent ? undefined : newEventPrefill?.description} + prefillParticipants={editEvent ? undefined : newEventPrefill?.participants} + prefillDate={editEvent ? undefined : newEventPrefill?.date} /> )} diff --git a/app/(main)/[locale]/files/page.tsx b/app/(main)/[locale]/files/page.tsx index 88f24225..55e85f70 100644 --- a/app/(main)/[locale]/files/page.tsx +++ b/app/(main)/[locale]/files/page.tsx @@ -11,6 +11,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useEmailStore } from "@/stores/email-store"; import { useFileStore } from "@/stores/file-store"; +import { useProTabStore } from "@/stores/pro-tab-store"; import { toast } from "@/stores/toast-store"; import { cn, formatFileSize } from "@/lib/utils"; import { NavigationRail } from "@/components/layout/navigation-rail"; @@ -411,6 +412,31 @@ export default function FilesPage() { await shareResource(id, principalId, rights); }, [shareResource]); + const handleSendAsAttachment = useCallback((names: string[]) => { + const store = useFileStore.getState(); + const fileAtts = names + .map((name) => { + const r = store.resources.find((res) => res.name === name); + if (!r || r.isDirectory || !r.blobId) return null; + return { + blobId: r.blobId, + name: r.name, + type: r.contentType || "application/octet-stream", + size: r.contentLength, + }; + }) + .filter(Boolean) as Array<{ blobId: string; name: string; type: string; size: number }>; + + if (fileAtts.length === 0) return; + + useProTabStore.getState().openComposeTab({ + sessionId: Date.now(), + mode: "compose", + replyTo: { attachments: fileAtts }, + title: fileAtts.length === 1 ? fileAtts[0].name : `${fileAtts.length} attachments`, + }); + }, []); + // Pro shell only: all connected accounts are equal top-level entries at // the root. The root path "/" itself is a cross-account picker - no // account's files are shown until the user enters one. @@ -554,6 +580,7 @@ export default function FilesPage() { ownAccountId={filesAccountId} sharingEnabled={sharingEnabled} onShare={handleShare} + onSendAsAttachment={handleSendAsAttachment} /> )} diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 1843352f..3e3e8c5f 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -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(null); + // Radial menu state + const [radialMenuOpen, setRadialMenuOpen] = useState(false); + const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 }); + const [radialMenuEvent, setRadialMenuEvent] = useState(null); + + const closeRadialMenu = useCallback(() => { + setRadialMenuOpen(false); + }, []); + + const radialMenuItems = useMemo(() => { + if (!radialMenuEvent) return []; + const ev = radialMenuEvent; + const items: RadialMenuItem[] = []; + if (onEditEvent) { + items.push({ + id: "edit", + icon: , + label: t("edit"), + onClick: () => { onEditEvent(ev); }, + }); + } + if (onDeleteEvent) { + items.push({ + id: "delete", + icon: , + label: t("delete"), + onClick: () => { onDeleteEvent(ev); }, + destructive: true, + }); + } + if (onDuplicateEvent) { + items.push({ + id: "duplicate", + icon: , + 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, 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({ ))} + + ); } diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index cd65425d..755aef07 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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 })); diff --git a/components/calendar/mini-calendar-dashlet.tsx b/components/calendar/mini-calendar-dashlet.tsx new file mode 100644 index 00000000..52e5248f --- /dev/null +++ b/components/calendar/mini-calendar-dashlet.tsx @@ -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(); + 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 ( +
+
+ + + +
+ +
+ {dayHeaders.map((dh) => ( +
+ {t(`days.${dh}`)} +
+ ))} +
+ +
+ {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 ( + + ); + })} +
+
+ ); +} diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 0d9f23c7..6dbdb6ad 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -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(); + + // Radial menu state + const [radialMenuOpen, setRadialMenuOpen] = useState(false); + const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 }); + const [radialMenuContact, setRadialMenuContact] = useState(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(() => { + if (!radialMenuContact) return []; + const c = radialMenuContact; + const items: RadialMenuItem[] = []; + items.push({ + id: "edit", + icon: , + label: t("edit"), + onClick: () => { onEditContact(c.id); }, + }); + items.push({ + id: "delete", + icon: , + 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: , + 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: , + label: t("export"), + onClick: () => { onBulkExport(); }, + }); + return items; + }, [radialMenuContact, t, onEditContact, onDeleteContact, onBulkExport]); + const [filtersOpen, setFiltersOpen] = useState(false); const [filters, setFilters] = useState(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({ )} + {/* Radial Action Menu */} + + {contextMenu.data && ( | null>(null); const [attachments, setAttachments] = useState(() => { - 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 []; }); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 9251f78c..eb3c198e 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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(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(() => { + 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: , + label: t("../context_menu.reply"), + onClick: () => { act(onReply)!(); }, + }); + } + if (onReplyAll) { + items.push({ + id: "reply-all", + icon: , + label: t("../context_menu.reply_all"), + onClick: () => { act(onReplyAll)!(); }, + }); + } + if (onForward) { + items.push({ + id: "forward", + icon: , + label: t("../context_menu.forward"), + onClick: () => { act(onForward)!(); }, + }); + } + if (onToggleStar) { + items.push({ + id: "star", + icon: , + label: isStarred ? t("../context_menu.unstar") : t("../context_menu.star"), + onClick: () => { act(onToggleStar)!(); }, + }); + } + if (onMarkAsRead) { + items.push({ + id: "mark-read", + icon: isUnread ? : , + label: isUnread ? t("../context_menu.mark_read") : t("../context_menu.mark_unread"), + onClick: () => { onMarkAsRead(email, !isUnread); }, + }); + } + if (onArchive) { + items.push({ + id: "archive", + icon: , + label: t("../context_menu.archive"), + onClick: () => { act(onArchive)!(); }, + }); + } + if (onDelete) { + items.push({ + id: "delete", + icon: , + label: t("../context_menu.delete"), + onClick: () => { act(onDelete)!(); }, + destructive: true, + }); + } + if (onMoveToMailbox) { + items.push({ + id: "move", + icon: , + 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({ )} + {/* Radial Action Menu */} + + {/* Context Menu */} {contextMenuEmail && ( 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(); + 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({ {showToolbarLabels && {t('forward')}} + {createAppointmentVisible && ( + + )} )} diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 10fbd330..d714f391 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -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; + /** 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(null); + + const closeRadialMenu = useCallback(() => { + setRadialMenuOpen(false); + }, []); + + const radialMenuItems = useMemo(() => { + if (!radialMenuResourceName) return []; + const name = radialMenuResourceName; + const resource = resources.find((r) => r.name === name); + const items: RadialMenuItem[] = []; + items.push({ + id: "rename", + icon: , + label: t("rename"), + onClick: () => { setRenameTarget(name); }, + }); + items.push({ + id: "delete", + icon: , + label: t("delete"), + onClick: () => { onDelete(name); }, + destructive: true, + }); + if (resource && !resource.isDirectory) { + items.push({ + id: "download", + icon: , + label: t("download"), + onClick: () => { onDownload(name); }, + }); + } + if (canShare(resource)) { + items.push({ + id: "share", + icon: , + label: t("share"), + onClick: () => { if (resource?.id) setShareTargetId(resource.id); }, + }); + } + if (resource && !resource.isDirectory) { + items.push({ + id: "send-as-attachment", + icon: , + 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 */}
- {selectedResources.size > 1 && ( - <> - - - - )} + {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 && ( + <> + + + + )} + {hasFiles && onSendAsAttachment && ( + + )} + + ); + })()} {clipboard && (
diff --git a/components/ui/radial-menu.tsx b/components/ui/radial-menu.tsx new file mode 100644 index 00000000..35f74c78 --- /dev/null +++ b/components/ui/radial-menu.tsx @@ -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(-1); + const [animatingIn, setAnimatingIn] = useState(false); + const menuRef = useRef(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( + <> +
+ +
+
+ +
+ + {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 ( +
+ +
+ ); + })} +
+ , + document.body + ); +} diff --git a/locales/en/common.json b/locales/en/common.json index 5ab934c2..55187ebf 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -301,6 +301,7 @@ "view_source": "View source", "export_email": "Export as .eml", "forward_as_attachment": "Forward as attachment", + "create_appointment": "Create Appointment", "import_email": "Import .eml or .zip", "keyboard_shortcuts": "Keyboard shortcuts (?)", "email_source": "Email Source", @@ -3080,6 +3081,7 @@ "delete_confirm_title": "Delete resource", "delete_confirm_message": "Are you sure you want to delete \"{name}\"? This cannot be undone.", "download": "Download", + "send_as_attachment": "Send as Attachment", "name": "Name", "size": "Size", "modified": "Modified", diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index a831e026..a4be3f98 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -251,6 +251,9 @@ interface CalendarStore { refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise; refreshAllSubscriptions: (client: IJMAPClient) => Promise; isSubscriptionCalendar: (calendarId: string) => boolean; + + newEventPrefill: { title?: string; description?: string; participants?: { name?: string; email: string }[]; date?: string } | null; + setNewEventPrefill: (prefill: { title?: string; description?: string; participants?: { name?: string; email: string }[]; date?: string } | null) => void; } const initialState = { @@ -265,6 +268,7 @@ const initialState = { error: null as string | null, dateRange: null as { start: string; end: string } | null, icalSubscriptions: [] as ICalSubscription[], + newEventPrefill: null as { title?: string; description?: string; participants?: { name?: string; email: string }[]; date?: string } | null, }; function getSafeCalendarViewMode(value: unknown): CalendarViewMode { @@ -1009,6 +1013,8 @@ export const useCalendarStore = create()( return get().icalSubscriptions.some(s => s.calendarId === calendarId); }, + setNewEventPrefill: (prefill) => set({ newEventPrefill: prefill }), + addICalSubscription: async (client, url, name, color, refreshInterval = 60) => { // Normalize webcal(s):// → https:// so the server-side fetcher // (which only accepts http/https) doesn't reject every refresh.