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>
);
}