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:
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 [];
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -251,6 +251,9 @@ interface CalendarStore {
|
||||
refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
|
||||
refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
|
||||
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<CalendarStore>()(
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user