feat(calendar): add drag-create, resize, recurring scope, quick-create, and duplication
- Click-drag on empty time slots to create events with pre-filled time range - Resize events by dragging bottom edge (15-min snap, optimistic JMAP update) - Recurring event edit/delete scope dialog (this/following/all occurrences) - Double-click quick event creation with inline title input - Event duplication button in modal (+1 day offset) - Shared interaction hook for pointer-based calendar interactions Fixes #13
This commit is contained in:
@@ -77,6 +77,11 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
|
|||||||
- Locale-aware date formatting (respects user's language)
|
- Locale-aware date formatting (respects user's language)
|
||||||
- Settings for first day of week, time format (12h/24h), and default view
|
- Settings for first day of week, time format (12h/24h), and default view
|
||||||
- Drag-and-drop rescheduling (week/day time snap, month date move)
|
- Drag-and-drop rescheduling (week/day time snap, month date move)
|
||||||
|
- Click-drag on empty time slots to create events with pre-filled time range
|
||||||
|
- Resize events by dragging bottom edge (15-minute snap)
|
||||||
|
- Double-click quick create with inline title input
|
||||||
|
- Event duplication (+1 day offset)
|
||||||
|
- Recurring event edit/delete scope (this event, this and following, all events)
|
||||||
- iCalendar (.ics) file import with event preview and bulk create
|
- iCalendar (.ics) file import with event preview and bulk create
|
||||||
- Real-time updates via JMAP push notifications
|
- Real-time updates via JMAP push notifications
|
||||||
- Event notifications with client-side alert evaluation and toast display
|
- Event notifications with client-side alert evaluation and toast display
|
||||||
|
|||||||
@@ -163,6 +163,11 @@ This document tracks the development status and planned features for JMAP Webmai
|
|||||||
- [x] Participant scheduling with iTIP invitations (organizer/attendee UI, RSVP buttons, contact autocomplete)
|
- [x] Participant scheduling with iTIP invitations (organizer/attendee UI, RSVP buttons, contact autocomplete)
|
||||||
- [x] Inline calendar invitation banner in email viewer (auto-detect .ics attachments, RSVP, import to calendar, cancellation display)
|
- [x] Inline calendar invitation banner in email viewer (auto-detect .ics attachments, RSVP, import to calendar, cancellation display)
|
||||||
- [x] Scheduling message support (sendSchedulingMessages flag for create/update/delete)
|
- [x] Scheduling message support (sendSchedulingMessages flag for create/update/delete)
|
||||||
|
- [x] Click-drag to create events (pointer-based time range selection, 15-min snap, visual overlay)
|
||||||
|
- [x] Event resize by dragging bottom edge handle (15-min snap, optimistic JMAP update)
|
||||||
|
- [x] Recurring event edit/delete scope dialog (this event / this and following / all events)
|
||||||
|
- [x] Double-click quick event creation (inline title input, PT1H default)
|
||||||
|
- [x] Event duplication button in modal (clones event +1 day, opens for editing)
|
||||||
|
|
||||||
### Email Filters
|
### Email Filters
|
||||||
- [x] JMAP Sieve Scripts (RFC 9661) with capability detection
|
- [x] JMAP Sieve Scripts (RFC 9661) with capability detection
|
||||||
|
|||||||
+328
-11
@@ -6,7 +6,7 @@ import { useTranslations } from "next-intl";
|
|||||||
import {
|
import {
|
||||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||||
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
|
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
|
||||||
format,
|
format, parseISO,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
@@ -22,9 +22,21 @@ import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
|||||||
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
||||||
import { EventModal } from "@/components/calendar/event-modal";
|
import { EventModal } from "@/components/calendar/event-modal";
|
||||||
|
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||||
|
import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog";
|
||||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
|
import { getUserParticipantId } from "@/lib/calendar-participants";
|
||||||
|
import { debug } from "@/lib/debug";
|
||||||
|
|
||||||
|
type PendingScopeAction =
|
||||||
|
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
|
||||||
|
| { type: "delete"; event: CalendarEvent; sendScheduling?: boolean };
|
||||||
|
|
||||||
|
function isRecurringEvent(event: CalendarEvent): boolean {
|
||||||
|
return (event.recurrenceRules?.length ?? 0) > 0 || event.recurrenceId != null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -49,7 +61,11 @@ export default function CalendarPage() {
|
|||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||||
|
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||||
const [miniMonth, setMiniMonth] = useState(new Date());
|
const [miniMonth, setMiniMonth] = useState(new Date());
|
||||||
|
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
||||||
|
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
||||||
|
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -149,9 +165,10 @@ export default function CalendarPage() {
|
|||||||
setSelectedDate(date);
|
setSelectedDate(date);
|
||||||
}, [setSelectedDate]);
|
}, [setSelectedDate]);
|
||||||
|
|
||||||
const openCreateModal = useCallback((date?: Date) => {
|
const openCreateModal = useCallback((date?: Date, endDate?: Date) => {
|
||||||
setEditEvent(null);
|
setEditEvent(null);
|
||||||
setDefaultModalDate(date || selectedDate);
|
setDefaultModalDate(date || selectedDate);
|
||||||
|
setDefaultModalEndDate(endDate);
|
||||||
setShowEventModal(true);
|
setShowEventModal(true);
|
||||||
}, [selectedDate]);
|
}, [selectedDate]);
|
||||||
|
|
||||||
@@ -161,10 +178,65 @@ export default function CalendarPage() {
|
|||||||
setShowEventModal(true);
|
setShowEventModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
|
const handleSelectEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
|
||||||
|
setDetailEvent(event);
|
||||||
|
setDetailAnchorRect(anchorRect);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeDetail = useCallback(() => {
|
||||||
|
setDetailEvent(null);
|
||||||
|
setDetailAnchorRect(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditFromDetail = useCallback(() => {
|
||||||
|
if (detailEvent) {
|
||||||
|
const ev = detailEvent;
|
||||||
|
closeDetail();
|
||||||
|
openEditModal(ev);
|
||||||
|
}
|
||||||
|
}, [detailEvent, closeDetail, openEditModal]);
|
||||||
|
|
||||||
|
const findMasterEvent = useCallback(async (occurrence: CalendarEvent): Promise<CalendarEvent | null> => {
|
||||||
|
if ((occurrence.recurrenceRules?.length ?? 0) > 0 && !occurrence.recurrenceId) {
|
||||||
|
return occurrence;
|
||||||
|
}
|
||||||
|
const master = events.find(e =>
|
||||||
|
e.uid === occurrence.uid && !e.recurrenceId && (e.recurrenceRules?.length ?? 0) > 0
|
||||||
|
);
|
||||||
|
if (master) return master;
|
||||||
|
if (!client) return null;
|
||||||
|
try {
|
||||||
|
const results = await client.queryCalendarEvents({ uid: occurrence.uid });
|
||||||
|
return results.find(e => !e.recurrenceId && (e.recurrenceRules?.length ?? 0) > 0) || null;
|
||||||
|
} catch (error) {
|
||||||
|
debug.error("Failed to query master event for UID:", occurrence.uid, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [events, client]);
|
||||||
|
|
||||||
|
const refetchCurrentRange = useCallback(async () => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
const { dateRange: currentRange } = useCalendarStore.getState();
|
||||||
|
if (currentRange) {
|
||||||
|
await fetchEvents(client, currentRange.start, currentRange.end);
|
||||||
|
}
|
||||||
|
}, [client, fetchEvents]);
|
||||||
|
|
||||||
|
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => {
|
||||||
|
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||||
try {
|
try {
|
||||||
if (editEvent) {
|
if (editEvent) {
|
||||||
|
if (isRecurringEvent(editEvent)) {
|
||||||
|
setPendingScopeAction({
|
||||||
|
type: "edit",
|
||||||
|
event: editEvent,
|
||||||
|
updates: data,
|
||||||
|
sendScheduling: sendSchedulingMessages,
|
||||||
|
});
|
||||||
|
setShowEventModal(false);
|
||||||
|
setEditEvent(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
|
await updateEvent(client, editEvent.id, data, sendSchedulingMessages);
|
||||||
toast.success(t("notifications.event_updated"));
|
toast.success(t("notifications.event_updated"));
|
||||||
} else {
|
} else {
|
||||||
@@ -186,15 +258,172 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [client, editEvent, createEvent, updateEvent, t]);
|
}, [client, editEvent, createEvent, updateEvent, t]);
|
||||||
|
|
||||||
|
const handleDuplicateEvent = useCallback(async (data: Partial<CalendarEvent>) => {
|
||||||
|
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||||
|
try {
|
||||||
|
const created = await createEvent(client, data);
|
||||||
|
if (!created) {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(t("notifications.event_duplicated"));
|
||||||
|
setEditEvent(created);
|
||||||
|
setDefaultModalDate(undefined);
|
||||||
|
} catch {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
setShowEventModal(false);
|
||||||
|
setEditEvent(null);
|
||||||
|
}
|
||||||
|
}, [client, createEvent, t]);
|
||||||
|
|
||||||
const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => {
|
const handleDeleteEvent = useCallback(async (id: string, sendSchedulingMessages?: boolean) => {
|
||||||
if (!client) return;
|
if (!client) { toast.error(t("notifications.event_error")); return; }
|
||||||
|
const eventToDelete = events.find(e => e.id === id) || editEvent;
|
||||||
|
if (eventToDelete && isRecurringEvent(eventToDelete)) {
|
||||||
|
setPendingScopeAction({
|
||||||
|
type: "delete",
|
||||||
|
event: eventToDelete,
|
||||||
|
sendScheduling: sendSchedulingMessages || undefined,
|
||||||
|
});
|
||||||
|
setShowEventModal(false);
|
||||||
|
setEditEvent(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await deleteEvent(client, id, sendSchedulingMessages);
|
await deleteEvent(client, id, sendSchedulingMessages);
|
||||||
toast.success(t("notifications.event_deleted"));
|
toast.success(t("notifications.event_deleted"));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("notifications.event_error"));
|
toast.error(t("notifications.event_error"));
|
||||||
}
|
}
|
||||||
}, [client, deleteEvent, t]);
|
}, [client, deleteEvent, events, editEvent, t]);
|
||||||
|
|
||||||
|
const truncateRecurrenceAtEvent = useCallback(async (event: CalendarEvent): Promise<{
|
||||||
|
master: CalendarEvent;
|
||||||
|
originalRules: CalendarEvent["recurrenceRules"];
|
||||||
|
} | null> => {
|
||||||
|
const master = await findMasterEvent(event);
|
||||||
|
if (!master) return null;
|
||||||
|
const originalRules = master.recurrenceRules
|
||||||
|
? JSON.parse(JSON.stringify(master.recurrenceRules))
|
||||||
|
: null;
|
||||||
|
const occurrenceDate = event.recurrenceId || event.start;
|
||||||
|
const untilDate = new Date(occurrenceDate);
|
||||||
|
untilDate.setSeconds(untilDate.getSeconds() - 1);
|
||||||
|
const until = format(untilDate, "yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
const truncatedRules = (master.recurrenceRules || []).map(rule => ({
|
||||||
|
...rule,
|
||||||
|
until,
|
||||||
|
count: null,
|
||||||
|
}));
|
||||||
|
await updateEvent(client!, master.id, { recurrenceRules: truncatedRules });
|
||||||
|
return { master, originalRules };
|
||||||
|
}, [client, findMasterEvent, updateEvent]);
|
||||||
|
|
||||||
|
const handleScopeSelect = useCallback(async (scope: RecurrenceEditScope) => {
|
||||||
|
if (!client || !pendingScopeAction) { toast.error(t("notifications.event_error")); return; }
|
||||||
|
const { type, event, sendScheduling } = pendingScopeAction;
|
||||||
|
const updates = type === "edit" ? pendingScopeAction.updates : undefined;
|
||||||
|
setPendingScopeAction(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (type === "edit" && updates) {
|
||||||
|
switch (scope) {
|
||||||
|
case "this":
|
||||||
|
await updateEvent(client, event.id, updates, sendScheduling);
|
||||||
|
break;
|
||||||
|
case "this_and_future": {
|
||||||
|
const result = await truncateRecurrenceAtEvent(event);
|
||||||
|
if (!result) {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { master, originalRules } = result;
|
||||||
|
const occurrenceStart = event.recurrenceId || event.start;
|
||||||
|
const newEventData: Partial<CalendarEvent> = {
|
||||||
|
title: master.title,
|
||||||
|
description: master.description,
|
||||||
|
duration: master.duration,
|
||||||
|
timeZone: master.timeZone,
|
||||||
|
calendarIds: { ...master.calendarIds },
|
||||||
|
status: master.status,
|
||||||
|
freeBusyStatus: master.freeBusyStatus,
|
||||||
|
privacy: master.privacy,
|
||||||
|
showWithoutTime: master.showWithoutTime,
|
||||||
|
...updates,
|
||||||
|
start: updates.start || occurrenceStart,
|
||||||
|
recurrenceRules: originalRules,
|
||||||
|
};
|
||||||
|
delete (newEventData as Record<string, unknown>).id;
|
||||||
|
delete (newEventData as Record<string, unknown>).uid;
|
||||||
|
delete (newEventData as Record<string, unknown>).recurrenceId;
|
||||||
|
try {
|
||||||
|
await createEvent(client, newEventData, sendScheduling);
|
||||||
|
} catch (createError) {
|
||||||
|
debug.error("Failed to create new series, rolling back master truncation:", createError);
|
||||||
|
try {
|
||||||
|
await updateEvent(client, master.id, { recurrenceRules: originalRules });
|
||||||
|
} catch (rollbackError) {
|
||||||
|
debug.error("Rollback of master event also failed:", rollbackError);
|
||||||
|
}
|
||||||
|
throw createError;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "all": {
|
||||||
|
const master = await findMasterEvent(event);
|
||||||
|
if (!master) {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allUpdates = { ...updates };
|
||||||
|
delete (allUpdates as Record<string, unknown>).recurrenceId;
|
||||||
|
await updateEvent(client, master.id, allUpdates, sendScheduling);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = scope;
|
||||||
|
throw new Error(`Unhandled scope: ${_exhaustive}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(t("notifications.event_updated"));
|
||||||
|
} else {
|
||||||
|
switch (scope) {
|
||||||
|
case "this":
|
||||||
|
await deleteEvent(client, event.id, sendScheduling);
|
||||||
|
break;
|
||||||
|
case "this_and_future": {
|
||||||
|
const result = await truncateRecurrenceAtEvent(event);
|
||||||
|
if (!result) {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "all": {
|
||||||
|
const master = await findMasterEvent(event);
|
||||||
|
if (!master) {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deleteEvent(client, master.id, sendScheduling);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = scope;
|
||||||
|
throw new Error(`Unhandled scope: ${_exhaustive}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(t("notifications.event_deleted"));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await refetchCurrentRange();
|
||||||
|
} catch {
|
||||||
|
debug.error("Failed to refresh calendar after scope operation");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
}
|
||||||
|
}, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, truncateRecurrenceAtEvent, refetchCurrentRange, t]);
|
||||||
|
|
||||||
const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => {
|
const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
@@ -206,11 +435,74 @@ export default function CalendarPage() {
|
|||||||
}
|
}
|
||||||
}, [client, rsvpEvent, t]);
|
}, [client, rsvpEvent, t]);
|
||||||
|
|
||||||
|
const handleDeleteFromDetail = useCallback(() => {
|
||||||
|
if (!detailEvent) return;
|
||||||
|
const hasParticipants = detailEvent.participants && Object.keys(detailEvent.participants).length > 0;
|
||||||
|
closeDetail();
|
||||||
|
handleDeleteEvent(detailEvent.id, hasParticipants || undefined);
|
||||||
|
}, [detailEvent, closeDetail, handleDeleteEvent]);
|
||||||
|
|
||||||
|
const handleDuplicateFromDetail = useCallback(async () => {
|
||||||
|
if (!detailEvent || !client) return;
|
||||||
|
const start = parseISO(detailEvent.start);
|
||||||
|
const newStart = addDays(start, 1);
|
||||||
|
const data: Partial<CalendarEvent> = {
|
||||||
|
title: detailEvent.title,
|
||||||
|
description: detailEvent.description,
|
||||||
|
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||||
|
duration: detailEvent.duration,
|
||||||
|
timeZone: detailEvent.timeZone,
|
||||||
|
showWithoutTime: detailEvent.showWithoutTime,
|
||||||
|
calendarIds: { ...detailEvent.calendarIds },
|
||||||
|
status: "confirmed",
|
||||||
|
freeBusyStatus: detailEvent.freeBusyStatus,
|
||||||
|
privacy: detailEvent.privacy,
|
||||||
|
};
|
||||||
|
if (detailEvent.locations) data.locations = structuredClone(detailEvent.locations);
|
||||||
|
if (detailEvent.recurrenceRules) data.recurrenceRules = structuredClone(detailEvent.recurrenceRules);
|
||||||
|
if (detailEvent.alerts) data.alerts = structuredClone(detailEvent.alerts);
|
||||||
|
if (detailEvent.participants) data.participants = structuredClone(detailEvent.participants);
|
||||||
|
closeDetail();
|
||||||
|
try {
|
||||||
|
const created = await createEvent(client, data);
|
||||||
|
if (created) {
|
||||||
|
toast.success(t("notifications.event_duplicated"));
|
||||||
|
openEditModal(created);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
}
|
||||||
|
}, [detailEvent, client, createEvent, closeDetail, openEditModal, t]);
|
||||||
|
|
||||||
|
const handleSaveNoteFromDetail = useCallback(async (note: string) => {
|
||||||
|
if (!detailEvent || !client) return;
|
||||||
|
const timestamp = format(new Date(), "yyyy-MM-dd HH:mm");
|
||||||
|
const separator = `\n\n--- ${timestamp} ---\n`;
|
||||||
|
const newDescription = detailEvent.description
|
||||||
|
? `${detailEvent.description}${separator}${note}`
|
||||||
|
: `--- ${timestamp} ---\n${note}`;
|
||||||
|
try {
|
||||||
|
await updateEvent(client, detailEvent.id, { description: newDescription });
|
||||||
|
setDetailEvent({ ...detailEvent, description: newDescription });
|
||||||
|
toast.success(t("detail.note_saved"));
|
||||||
|
} catch {
|
||||||
|
toast.error(t("notifications.event_error"));
|
||||||
|
}
|
||||||
|
}, [detailEvent, client, updateEvent, t]);
|
||||||
|
|
||||||
|
const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => {
|
||||||
|
if (!detailEvent || !client) return;
|
||||||
|
const participantId = getUserParticipantId(detailEvent, currentUserEmails);
|
||||||
|
if (!participantId) return;
|
||||||
|
closeDetail();
|
||||||
|
await handleRsvp(detailEvent.id, participantId, status);
|
||||||
|
}, [detailEvent, client, currentUserEmails, closeDetail, handleRsvp]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return;
|
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return;
|
||||||
if (showEventModal) return;
|
if (showEventModal || detailEvent) return;
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "ArrowLeft": e.preventDefault(); navigatePrev(); break;
|
case "ArrowLeft": e.preventDefault(); navigatePrev(); break;
|
||||||
@@ -225,7 +517,7 @@ export default function CalendarPage() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKey);
|
window.addEventListener("keydown", handleKey);
|
||||||
return () => window.removeEventListener("keydown", handleKey);
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal]);
|
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent]);
|
||||||
|
|
||||||
const visibleEvents = useMemo(() =>
|
const visibleEvents = useMemo(() =>
|
||||||
events.filter((e) => {
|
events.filter((e) => {
|
||||||
@@ -255,7 +547,7 @@ export default function CalendarPage() {
|
|||||||
events={visibleEvents}
|
events={visibleEvents}
|
||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
onSelectDate={handleSelectDate}
|
onSelectDate={handleSelectDate}
|
||||||
onSelectEvent={openEditModal}
|
onSelectEvent={handleSelectEvent}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -266,7 +558,7 @@ export default function CalendarPage() {
|
|||||||
events={visibleEvents}
|
events={visibleEvents}
|
||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
onSelectDate={handleSelectDate}
|
onSelectDate={handleSelectDate}
|
||||||
onSelectEvent={openEditModal}
|
onSelectEvent={handleSelectEvent}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
@@ -278,7 +570,7 @@ export default function CalendarPage() {
|
|||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
events={visibleEvents}
|
events={visibleEvents}
|
||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
onSelectEvent={openEditModal}
|
onSelectEvent={handleSelectEvent}
|
||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
/>
|
/>
|
||||||
@@ -289,7 +581,7 @@ export default function CalendarPage() {
|
|||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
events={visibleEvents}
|
events={visibleEvents}
|
||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
onSelectEvent={openEditModal}
|
onSelectEvent={handleSelectEvent}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -358,13 +650,31 @@ export default function CalendarPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{detailEvent && detailAnchorRect && (
|
||||||
|
<EventDetailPopover
|
||||||
|
event={detailEvent}
|
||||||
|
calendar={calendars.find(c => detailEvent.calendarIds[c.id])}
|
||||||
|
anchorRect={detailAnchorRect}
|
||||||
|
onEdit={handleEditFromDetail}
|
||||||
|
onDelete={handleDeleteFromDetail}
|
||||||
|
onDuplicate={handleDuplicateFromDetail}
|
||||||
|
onClose={closeDetail}
|
||||||
|
onSaveNote={handleSaveNoteFromDetail}
|
||||||
|
onRsvp={handleRsvpFromDetail}
|
||||||
|
currentUserEmails={currentUserEmails}
|
||||||
|
timeFormat={timeFormat}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{showEventModal && (
|
{showEventModal && (
|
||||||
<EventModal
|
<EventModal
|
||||||
event={editEvent}
|
event={editEvent}
|
||||||
calendars={calendars}
|
calendars={calendars}
|
||||||
defaultDate={defaultModalDate}
|
defaultDate={defaultModalDate}
|
||||||
|
defaultEndDate={defaultModalEndDate}
|
||||||
onSave={handleSaveEvent}
|
onSave={handleSaveEvent}
|
||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
@@ -378,6 +688,13 @@ export default function CalendarPage() {
|
|||||||
onClose={() => setShowImportModal(false)}
|
onClose={() => setShowImportModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<RecurrenceScopeDialog
|
||||||
|
isOpen={!!pendingScopeAction}
|
||||||
|
actionType={pendingScopeAction?.type || "edit"}
|
||||||
|
onSelect={handleScopeSelect}
|
||||||
|
onClose={() => setPendingScopeAction(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { format, parseISO, isToday, isTomorrow } from "date-fns";
|
|||||||
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
|
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { parseDuration, getEventColor } from "./event-card";
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
|
import { getEventEndDate } from "@/lib/calendar-utils";
|
||||||
import { getParticipantCount } from "@/lib/calendar-participants";
|
import { getParticipantCount } from "@/lib/calendar-participants";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ interface CalendarAgendaViewProps {
|
|||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
onSelectEvent: (event: CalendarEvent) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,17 +24,6 @@ interface DayGroup {
|
|||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventEndDate(event: CalendarEvent): Date {
|
|
||||||
const start = new Date(event.start);
|
|
||||||
if (!event.duration) return start;
|
|
||||||
const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0");
|
|
||||||
const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0");
|
|
||||||
const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0");
|
|
||||||
const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0");
|
|
||||||
const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000;
|
|
||||||
return new Date(start.getTime() + totalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarAgendaView({
|
export function CalendarAgendaView({
|
||||||
events,
|
events,
|
||||||
calendars,
|
calendars,
|
||||||
@@ -149,7 +139,7 @@ export function CalendarAgendaView({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={ev.id}
|
key={ev.id}
|
||||||
onClick={() => onSelectEvent(ev)}
|
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
|
||||||
className="w-full flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
|
className="w-full flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
|
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
|
||||||
|
|||||||
@@ -1,72 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useEffect, useRef, useState, useCallback, type DragEvent } from "react";
|
import { useMemo, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import { format, isToday, parseISO } from "date-fns";
|
import { format, isToday, parseISO } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard, parseDuration } from "./event-card";
|
||||||
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
|
import { getEventEndDate, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
|
||||||
import { toast } from "@/stores/toast-store";
|
|
||||||
|
|
||||||
interface CalendarDayViewProps {
|
interface CalendarDayViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
onSelectEvent: (event: CalendarEvent) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onCreateAtTime: (date: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR_HEIGHT = 64;
|
const HOUR_HEIGHT = 64;
|
||||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
|
||||||
function getEventEndDate(event: CalendarEvent): Date {
|
|
||||||
const start = new Date(event.start);
|
|
||||||
if (!event.duration) return start;
|
|
||||||
const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0");
|
|
||||||
const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0");
|
|
||||||
const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0");
|
|
||||||
const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0");
|
|
||||||
const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000;
|
|
||||||
return new Date(start.getTime() + totalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function layoutOverlappingEvents(events: CalendarEvent[]): { event: CalendarEvent; column: number; totalColumns: number }[] {
|
|
||||||
const sorted = [...events].sort((a, b) => {
|
|
||||||
const diff = new Date(a.start).getTime() - new Date(b.start).getTime();
|
|
||||||
if (diff !== 0) return diff;
|
|
||||||
return parseDuration(b.duration) - parseDuration(a.duration);
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns: { event: CalendarEvent; end: number }[][] = [];
|
|
||||||
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = [];
|
|
||||||
|
|
||||||
for (const event of sorted) {
|
|
||||||
const start = parseISO(event.start);
|
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
|
||||||
const endMin = startMin + Math.max(15, parseDuration(event.duration));
|
|
||||||
let placed = false;
|
|
||||||
for (let col = 0; col < columns.length; col++) {
|
|
||||||
if (columns[col].every(e => e.end <= startMin)) {
|
|
||||||
columns[col].push({ event, end: endMin });
|
|
||||||
result.push({ event, column: col, totalColumns: 0 });
|
|
||||||
placed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!placed) {
|
|
||||||
columns.push([{ event, end: endMin }]);
|
|
||||||
result.push({ event, column: columns.length - 1, totalColumns: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = columns.length;
|
|
||||||
result.forEach(r => r.totalColumns = total);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarDayView({
|
export function CalendarDayView({
|
||||||
selectedDate,
|
selectedDate,
|
||||||
events,
|
events,
|
||||||
@@ -78,6 +33,7 @@ export function CalendarDayView({
|
|||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dayKey = format(selectedDate, "yyyy-MM-dd");
|
||||||
|
|
||||||
const calendarMap = useMemo(() => {
|
const calendarMap = useMemo(() => {
|
||||||
const map = new Map<string, Calendar>();
|
const map = new Map<string, Calendar>();
|
||||||
@@ -125,6 +81,23 @@ export function CalendarDayView({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
dragCreate, handleGridPointerDown, handleGridPointerMove, handleGridPointerUp,
|
||||||
|
resizeVisual, handleResizePointerDown, handleResizePointerMove, handleResizePointerUp,
|
||||||
|
quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel,
|
||||||
|
dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop,
|
||||||
|
} = useTimeGridInteractions({
|
||||||
|
hourHeight: HOUR_HEIGHT,
|
||||||
|
calendars,
|
||||||
|
onCreateRange: onCreateAtTime,
|
||||||
|
errorMessages: {
|
||||||
|
resize: t("notifications.event_resize_error"),
|
||||||
|
move: t("notifications.event_move_error"),
|
||||||
|
created: t("notifications.event_created"),
|
||||||
|
error: t("notifications.event_error"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const formatHour = (h: number): string => {
|
const formatHour = (h: number): string => {
|
||||||
if (timeFormat === "12h") {
|
if (timeFormat === "12h") {
|
||||||
const d = new Date(2000, 0, 1, h);
|
const d = new Date(2000, 0, 1, h);
|
||||||
@@ -135,59 +108,6 @@ export function CalendarDayView({
|
|||||||
|
|
||||||
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
|
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
|
||||||
|
|
||||||
const [dropMinutes, setDropMinutes] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const snapMinutes = useCallback((e: DragEvent<HTMLDivElement>): number => {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
const y = e.clientY - rect.top;
|
|
||||||
const raw = (y / HOUR_HEIGHT) * 60;
|
|
||||||
return Math.max(0, Math.min(1425, Math.round(raw / 15) * 15));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formatSnapTime = useCallback((minutes: number): string => {
|
|
||||||
const h = Math.floor(minutes / 60);
|
|
||||||
const m = minutes % 60;
|
|
||||||
if (timeFormat === "12h") {
|
|
||||||
return `${h % 12 || 12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`;
|
|
||||||
}
|
|
||||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
||||||
}, [timeFormat]);
|
|
||||||
|
|
||||||
const handleDayDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
||||||
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = "move";
|
|
||||||
const minutes = snapMinutes(e);
|
|
||||||
setDropMinutes((prev) => prev === minutes ? prev : minutes);
|
|
||||||
}, [snapMinutes]);
|
|
||||||
|
|
||||||
const handleDayDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
||||||
const related = e.relatedTarget as Node | null;
|
|
||||||
if (!e.currentTarget.contains(related)) setDropMinutes(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDayDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDropMinutes(null);
|
|
||||||
const json = e.dataTransfer.getData("application/x-calendar-event");
|
|
||||||
if (!json) return;
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(json);
|
|
||||||
const minutes = snapMinutes(e);
|
|
||||||
const newStart = new Date(selectedDate);
|
|
||||||
newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
|
||||||
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
|
||||||
if (newStartISO === data.originalStart) return;
|
|
||||||
const client = useAuthStore.getState().client;
|
|
||||||
if (!client) return;
|
|
||||||
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
|
||||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
|
||||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
|
||||||
} catch {
|
|
||||||
toast.error(t("notifications.event_move_error"));
|
|
||||||
}
|
|
||||||
}, [snapMinutes, selectedDate, t]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
||||||
<div className="px-4 py-3 border-b border-border">
|
<div className="px-4 py-3 border-b border-border">
|
||||||
@@ -208,7 +128,7 @@ export function CalendarDayView({
|
|||||||
event={ev}
|
event={ev}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calendarMap.get(calId)}
|
||||||
variant="chip"
|
variant="chip"
|
||||||
onClick={() => onSelectEvent(ev)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -222,10 +142,14 @@ export function CalendarDayView({
|
|||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
className="text-xs text-muted-foreground text-right pr-3"
|
className="relative text-muted-foreground text-right pr-3"
|
||||||
style={{ height: HOUR_HEIGHT, lineHeight: `${HOUR_HEIGHT}px` }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
{formatHour(h)}
|
{h > 0 && (
|
||||||
|
<span className="absolute top-0 right-3 -translate-y-1/2 text-xs leading-none">
|
||||||
|
{formatHour(h)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -234,20 +158,20 @@ export function CalendarDayView({
|
|||||||
className="flex-1 relative border-l border-border"
|
className="flex-1 relative border-l border-border"
|
||||||
role="row"
|
role="row"
|
||||||
aria-label={t("views.day")}
|
aria-label={t("views.day")}
|
||||||
onDragOver={handleDayDragOver}
|
onPointerDown={(e) => handleGridPointerDown(e, dayKey, selectedDate)}
|
||||||
onDragLeave={handleDayDragLeave}
|
onPointerMove={handleGridPointerMove}
|
||||||
onDrop={handleDayDrop}
|
onPointerUp={handleGridPointerUp}
|
||||||
|
onDragOver={(e) => handleColumnDragOver(e, dayKey)}
|
||||||
|
onDragLeave={handleColumnDragLeave}
|
||||||
|
onDrop={(e) => handleColumnDrop(e, selectedDate)}
|
||||||
>
|
>
|
||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
role="gridcell"
|
role="gridcell"
|
||||||
aria-label={formatHour(h)}
|
aria-label={formatHour(h)}
|
||||||
onClick={() => {
|
onClick={() => handleSlotClick(selectedDate, h)}
|
||||||
const d = new Date(selectedDate);
|
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
|
||||||
d.setHours(h, 0, 0, 0);
|
|
||||||
onCreateAtTime(d);
|
|
||||||
}}
|
|
||||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
/>
|
/>
|
||||||
@@ -258,7 +182,8 @@ export function CalendarDayView({
|
|||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
const durMin = Math.max(15, parseDuration(ev.duration));
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||||
const height = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||||
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const calId = Object.keys(ev.calendarIds)[0];
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
@@ -266,16 +191,27 @@ export function CalendarDayView({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ev.id}
|
key={ev.id}
|
||||||
className="absolute z-10"
|
className="absolute z-10 group/event"
|
||||||
|
data-calendar-event
|
||||||
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 2, paddingRight: 2 }}
|
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 2, paddingRight: 2 }}
|
||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={ev}
|
event={ev}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calendarMap.get(calId)}
|
||||||
variant="block"
|
variant="block"
|
||||||
onClick={() => onSelectEvent(ev)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
draggable
|
draggable
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
data-resize-handle
|
||||||
|
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
|
aria-label={t("events.resize")}
|
||||||
|
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
||||||
|
onPointerMove={handleResizePointerMove}
|
||||||
|
onPointerUp={handleResizePointerUp}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-1 rounded-full bg-foreground/30 mb-0.5" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -292,17 +228,39 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dropMinutes !== null && (
|
{quickCreate?.dayKey === dayKey && (
|
||||||
|
<QuickEventInput
|
||||||
|
top={quickCreate.top}
|
||||||
|
onSubmit={handleQuickCreateSubmit}
|
||||||
|
onCancel={handleQuickCreateCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dragCreate && (
|
||||||
|
<div
|
||||||
|
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none bg-primary/15 border-2 border-primary/30 border-dashed"
|
||||||
|
style={{
|
||||||
|
top: (dragCreate.startMinutes / 60) * HOUR_HEIGHT,
|
||||||
|
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * HOUR_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
|
||||||
|
{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dropTarget?.dayKey === dayKey && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 right-0 z-30 pointer-events-none"
|
className="absolute left-0 right-0 z-30 pointer-events-none"
|
||||||
style={{ top: (dropMinutes / 60) * HOUR_HEIGHT }}
|
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-2.5 h-2.5 rounded-full bg-primary -ml-1" />
|
<div className="w-2.5 h-2.5 rounded-full bg-primary -ml-1" />
|
||||||
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||||
{formatSnapTime(dropMinutes)}
|
{formatSnapTime(dropTarget.minutes, timeFormat)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard } from "./event-card";
|
import { EventCard } from "./event-card";
|
||||||
|
import { getEventEndDate } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
@@ -18,21 +19,10 @@ interface CalendarMonthViewProps {
|
|||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
onSelectDate: (date: Date) => void;
|
onSelectDate: (date: Date) => void;
|
||||||
onSelectEvent: (event: CalendarEvent) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventEndDate(event: CalendarEvent): Date {
|
|
||||||
const start = new Date(event.start);
|
|
||||||
if (!event.duration) return start;
|
|
||||||
const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0");
|
|
||||||
const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0");
|
|
||||||
const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0");
|
|
||||||
const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0");
|
|
||||||
const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000;
|
|
||||||
return new Date(start.getTime() + totalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarMonthView({
|
export function CalendarMonthView({
|
||||||
selectedDate,
|
selectedDate,
|
||||||
events,
|
events,
|
||||||
@@ -192,7 +182,7 @@ export function CalendarMonthView({
|
|||||||
event={ev}
|
event={ev}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calendarMap.get(calId)}
|
||||||
variant="chip"
|
variant="chip"
|
||||||
onClick={() => onSelectEvent(ev)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
draggable
|
draggable
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useEffect, useRef, useState, useCallback, type DragEvent } from "react";
|
import { useMemo, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
import { useTranslations, useFormatter } from "next-intl";
|
||||||
import {
|
import {
|
||||||
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
|
startOfWeek, addDays, format, isSameDay, isToday, parseISO,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard, parseDuration } from "./event-card";
|
||||||
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
|
import { getEventEndDate, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
|
||||||
import { toast } from "@/stores/toast-store";
|
|
||||||
|
|
||||||
interface CalendarWeekViewProps {
|
interface CalendarWeekViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
onSelectDate: (date: Date) => void;
|
onSelectDate: (date: Date) => void;
|
||||||
onSelectEvent: (event: CalendarEvent) => void;
|
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||||
onCreateAtTime: (date: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
}
|
}
|
||||||
@@ -26,51 +26,6 @@ interface CalendarWeekViewProps {
|
|||||||
const HOUR_HEIGHT = 60;
|
const HOUR_HEIGHT = 60;
|
||||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
|
||||||
function getEventEndDate(event: CalendarEvent): Date {
|
|
||||||
const start = new Date(event.start);
|
|
||||||
if (!event.duration) return start;
|
|
||||||
const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0");
|
|
||||||
const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0");
|
|
||||||
const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0");
|
|
||||||
const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0");
|
|
||||||
const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000;
|
|
||||||
return new Date(start.getTime() + totalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function layoutOverlappingEvents(events: CalendarEvent[]): { event: CalendarEvent; column: number; totalColumns: number }[] {
|
|
||||||
const sorted = [...events].sort((a, b) => {
|
|
||||||
const diff = new Date(a.start).getTime() - new Date(b.start).getTime();
|
|
||||||
if (diff !== 0) return diff;
|
|
||||||
return parseDuration(b.duration) - parseDuration(a.duration);
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns: { event: CalendarEvent; end: number }[][] = [];
|
|
||||||
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = [];
|
|
||||||
|
|
||||||
for (const event of sorted) {
|
|
||||||
const start = parseISO(event.start);
|
|
||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
|
||||||
const endMin = startMin + Math.max(15, parseDuration(event.duration));
|
|
||||||
let placed = false;
|
|
||||||
for (let col = 0; col < columns.length; col++) {
|
|
||||||
if (columns[col].every(e => e.end <= startMin)) {
|
|
||||||
columns[col].push({ event, end: endMin });
|
|
||||||
result.push({ event, column: col, totalColumns: 0 });
|
|
||||||
placed = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!placed) {
|
|
||||||
columns.push([{ event, end: endMin }]);
|
|
||||||
result.push({ event, column: columns.length - 1, totalColumns: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = columns.length;
|
|
||||||
result.forEach(r => r.totalColumns = total);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CalendarWeekView({
|
export function CalendarWeekView({
|
||||||
selectedDate,
|
selectedDate,
|
||||||
events,
|
events,
|
||||||
@@ -137,8 +92,7 @@ export function CalendarWeekView({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const scrollTo = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
|
scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
|
||||||
scrollRef.current.scrollTop = scrollTo;
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -153,11 +107,22 @@ export function CalendarWeekView({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSlotClick = (day: Date, hour: number) => {
|
const {
|
||||||
const d = new Date(day);
|
dragCreate, handleGridPointerDown, handleGridPointerMove, handleGridPointerUp,
|
||||||
d.setHours(hour, 0, 0, 0);
|
resizeVisual, handleResizePointerDown, handleResizePointerMove, handleResizePointerUp,
|
||||||
onCreateAtTime(d);
|
quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel,
|
||||||
};
|
dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop,
|
||||||
|
} = useTimeGridInteractions({
|
||||||
|
hourHeight: HOUR_HEIGHT,
|
||||||
|
calendars,
|
||||||
|
onCreateRange: onCreateAtTime,
|
||||||
|
errorMessages: {
|
||||||
|
resize: t("notifications.event_resize_error"),
|
||||||
|
move: t("notifications.event_move_error"),
|
||||||
|
created: t("notifications.event_created"),
|
||||||
|
error: t("notifications.event_error"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const formatHour = (h: number): string => {
|
const formatHour = (h: number): string => {
|
||||||
if (timeFormat === "12h") {
|
if (timeFormat === "12h") {
|
||||||
@@ -167,61 +132,6 @@ export function CalendarWeekView({
|
|||||||
return format(new Date(2000, 0, 1, h), "HH:mm");
|
return format(new Date(2000, 0, 1, h), "HH:mm");
|
||||||
};
|
};
|
||||||
|
|
||||||
const [dropTarget, setDropTarget] = useState<{ dayKey: string; minutes: number } | null>(null);
|
|
||||||
|
|
||||||
const snapMinutes = useCallback((e: DragEvent<HTMLDivElement>): number => {
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
const y = e.clientY - rect.top;
|
|
||||||
const raw = (y / HOUR_HEIGHT) * 60;
|
|
||||||
return Math.max(0, Math.min(1425, Math.round(raw / 15) * 15));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formatSnapTime = useCallback((minutes: number): string => {
|
|
||||||
const h = Math.floor(minutes / 60);
|
|
||||||
const m = minutes % 60;
|
|
||||||
if (timeFormat === "12h") {
|
|
||||||
return `${h % 12 || 12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`;
|
|
||||||
}
|
|
||||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
|
||||||
}, [timeFormat]);
|
|
||||||
|
|
||||||
const handleColumnDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
|
|
||||||
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = "move";
|
|
||||||
const minutes = snapMinutes(e);
|
|
||||||
setDropTarget((prev) =>
|
|
||||||
prev?.dayKey === dayKey && prev?.minutes === minutes ? prev : { dayKey, minutes }
|
|
||||||
);
|
|
||||||
}, [snapMinutes]);
|
|
||||||
|
|
||||||
const handleColumnDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
||||||
const related = e.relatedTarget as Node | null;
|
|
||||||
if (!e.currentTarget.contains(related)) setDropTarget(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleColumnDrop = useCallback(async (e: DragEvent<HTMLDivElement>, day: Date) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDropTarget(null);
|
|
||||||
const json = e.dataTransfer.getData("application/x-calendar-event");
|
|
||||||
if (!json) return;
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(json);
|
|
||||||
const minutes = snapMinutes(e);
|
|
||||||
const newStart = new Date(day);
|
|
||||||
newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
|
||||||
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
|
||||||
if (newStartISO === data.originalStart) return;
|
|
||||||
const client = useAuthStore.getState().client;
|
|
||||||
if (!client) return;
|
|
||||||
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
|
||||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
|
||||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
|
||||||
} catch {
|
|
||||||
toast.error(t("notifications.event_move_error"));
|
|
||||||
}
|
|
||||||
}, [snapMinutes, t]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
|
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
|
||||||
{hasAllDay && (
|
{hasAllDay && (
|
||||||
@@ -243,7 +153,7 @@ export function CalendarWeekView({
|
|||||||
event={ev}
|
event={ev}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calendarMap.get(calId)}
|
||||||
variant="chip"
|
variant="chip"
|
||||||
onClick={() => onSelectEvent(ev)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -295,10 +205,14 @@ export function CalendarWeekView({
|
|||||||
{HOURS.map((h) => (
|
{HOURS.map((h) => (
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
className="text-[10px] text-muted-foreground text-right pr-2"
|
className="relative text-muted-foreground text-right pr-2"
|
||||||
style={{ height: HOUR_HEIGHT, lineHeight: `${HOUR_HEIGHT}px` }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
>
|
>
|
||||||
{formatHour(h)}
|
{h > 0 && (
|
||||||
|
<span className="absolute top-0 right-2 -translate-y-1/2 text-[10px] leading-none">
|
||||||
|
{formatHour(h)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -316,6 +230,9 @@ export function CalendarWeekView({
|
|||||||
className="relative border-r border-border last:border-r-0"
|
className="relative border-r border-border last:border-r-0"
|
||||||
role="row"
|
role="row"
|
||||||
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
|
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
|
||||||
|
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
|
||||||
|
onPointerMove={handleGridPointerMove}
|
||||||
|
onPointerUp={handleGridPointerUp}
|
||||||
onDragOver={(e) => handleColumnDragOver(e, key)}
|
onDragOver={(e) => handleColumnDragOver(e, key)}
|
||||||
onDragLeave={handleColumnDragLeave}
|
onDragLeave={handleColumnDragLeave}
|
||||||
onDrop={(e) => handleColumnDrop(e, day)}
|
onDrop={(e) => handleColumnDrop(e, day)}
|
||||||
@@ -326,6 +243,7 @@ export function CalendarWeekView({
|
|||||||
role="gridcell"
|
role="gridcell"
|
||||||
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
|
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
|
||||||
onClick={() => handleSlotClick(day, h)}
|
onClick={() => handleSlotClick(day, h)}
|
||||||
|
onDoubleClick={() => handleSlotDoubleClick(day, h)}
|
||||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||||
style={{ height: HOUR_HEIGHT }}
|
style={{ height: HOUR_HEIGHT }}
|
||||||
/>
|
/>
|
||||||
@@ -336,7 +254,8 @@ export function CalendarWeekView({
|
|||||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
const durMin = Math.max(15, parseDuration(ev.duration));
|
||||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||||
const height = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||||
|
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||||
const calId = Object.keys(ev.calendarIds)[0];
|
const calId = Object.keys(ev.calendarIds)[0];
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
@@ -344,16 +263,27 @@ export function CalendarWeekView({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ev.id}
|
key={ev.id}
|
||||||
className="absolute z-10"
|
className="absolute z-10 group/event"
|
||||||
|
data-calendar-event
|
||||||
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 1, paddingRight: 1 }}
|
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 1, paddingRight: 1 }}
|
||||||
>
|
>
|
||||||
<EventCard
|
<EventCard
|
||||||
event={ev}
|
event={ev}
|
||||||
calendar={calendarMap.get(calId)}
|
calendar={calendarMap.get(calId)}
|
||||||
variant="block"
|
variant="block"
|
||||||
onClick={() => onSelectEvent(ev)}
|
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||||
draggable
|
draggable
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
data-resize-handle
|
||||||
|
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||||
|
aria-label={t("events.resize")}
|
||||||
|
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
||||||
|
onPointerMove={handleResizePointerMove}
|
||||||
|
onPointerUp={handleResizePointerUp}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-1 rounded-full bg-foreground/30 mb-0.5" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -370,6 +300,28 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{quickCreate?.dayKey === key && (
|
||||||
|
<QuickEventInput
|
||||||
|
top={quickCreate.top}
|
||||||
|
onSubmit={handleQuickCreateSubmit}
|
||||||
|
onCancel={handleQuickCreateCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dragCreate?.dayKey === key && (
|
||||||
|
<div
|
||||||
|
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none bg-primary/15 border-2 border-primary/30 border-dashed"
|
||||||
|
style={{
|
||||||
|
top: (dragCreate.startMinutes / 60) * HOUR_HEIGHT,
|
||||||
|
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * HOUR_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
|
||||||
|
{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{dropTarget?.dayKey === key && (
|
{dropTarget?.dayKey === key && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 right-0 z-30 pointer-events-none"
|
className="absolute left-0 right-0 z-30 pointer-events-none"
|
||||||
@@ -380,7 +332,7 @@ export function CalendarWeekView({
|
|||||||
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||||
{formatSnapTime(dropTarget.minutes)}
|
{formatSnapTime(dropTarget.minutes, timeFormat)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface EventCardProps {
|
|||||||
event: CalendarEvent;
|
event: CalendarEvent;
|
||||||
calendar?: Calendar;
|
calendar?: Calendar;
|
||||||
variant: "chip" | "block";
|
variant: "chip" | "block";
|
||||||
onClick?: () => void;
|
onClick?: (anchorRect: DOMRect) => void;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
|
|||||||
if (variant === "chip") {
|
if (variant === "chip") {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -123,11 +123,12 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
|
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
{...dragProps}
|
{...dragProps}
|
||||||
|
data-calendar-event
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
|
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
|
||||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||||
isSelected && "ring-2 ring-primary",
|
isSelected && "ring-2 ring-primary",
|
||||||
isBeingDragged && "opacity-50"
|
isBeingDragged && "opacity-50"
|
||||||
|
|||||||
@@ -0,0 +1,609 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||||
|
Pencil, Trash2, Copy, Send, Check,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
|
import { parseDuration, getEventColor } from "./event-card";
|
||||||
|
import {
|
||||||
|
isOrganizer,
|
||||||
|
getUserParticipantId,
|
||||||
|
getUserStatus,
|
||||||
|
getParticipantList,
|
||||||
|
} from "@/lib/calendar-participants";
|
||||||
|
|
||||||
|
interface EventDetailPopoverProps {
|
||||||
|
event: CalendarEvent;
|
||||||
|
calendar?: Calendar;
|
||||||
|
anchorRect: DOMRect;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onDuplicate: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaveNote: (note: string) => void;
|
||||||
|
onRsvp?: (status: CalendarParticipant["participationStatus"]) => void;
|
||||||
|
currentUserEmails?: string[];
|
||||||
|
timeFormat?: "12h" | "24h";
|
||||||
|
}
|
||||||
|
|
||||||
|
const POPOVER_WIDTH = 360;
|
||||||
|
const POPOVER_GAP = 8;
|
||||||
|
const VIEWPORT_MARGIN = 12;
|
||||||
|
const MAX_HEIGHT = 480;
|
||||||
|
|
||||||
|
function computePosition(
|
||||||
|
anchorRect: DOMRect,
|
||||||
|
popoverHeight: number
|
||||||
|
): { top: number; left: number } {
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const clampedHeight = Math.min(popoverHeight, MAX_HEIGHT);
|
||||||
|
|
||||||
|
const clampTop = (top: number) =>
|
||||||
|
Math.min(Math.max(VIEWPORT_MARGIN, top), vh - clampedHeight - VIEWPORT_MARGIN);
|
||||||
|
const clampLeft = (left: number) =>
|
||||||
|
Math.min(Math.max(VIEWPORT_MARGIN, left), vw - POPOVER_WIDTH - VIEWPORT_MARGIN);
|
||||||
|
|
||||||
|
const rightLeft = anchorRect.right + POPOVER_GAP;
|
||||||
|
if (rightLeft + POPOVER_WIDTH + VIEWPORT_MARGIN <= vw) {
|
||||||
|
return { top: clampTop(anchorRect.top), left: rightLeft };
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftLeft = anchorRect.left - POPOVER_GAP - POPOVER_WIDTH;
|
||||||
|
if (leftLeft >= VIEWPORT_MARGIN) {
|
||||||
|
return { top: clampTop(anchorRect.top), left: leftLeft };
|
||||||
|
}
|
||||||
|
|
||||||
|
const belowTop = anchorRect.bottom + POPOVER_GAP;
|
||||||
|
if (belowTop + clampedHeight + VIEWPORT_MARGIN <= vh) {
|
||||||
|
return { top: belowTop, left: clampLeft(anchorRect.left) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const aboveTop = anchorRect.top - POPOVER_GAP - clampedHeight;
|
||||||
|
return {
|
||||||
|
top: Math.max(VIEWPORT_MARGIN, aboveTop),
|
||||||
|
left: clampLeft(anchorRect.left),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDurationDisplay(minutes: number): string {
|
||||||
|
if (minutes < 60) return `${minutes}min`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
if (m === 0) return `${h}h`;
|
||||||
|
return `${h}h${m}min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||||
|
if (!event.alerts) return null;
|
||||||
|
const first = Object.values(event.alerts)[0];
|
||||||
|
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
|
||||||
|
const offset = first.trigger.offset;
|
||||||
|
if (offset === "PT0S") return t("alerts.at_time");
|
||||||
|
const minMatch = offset.match(/-?PT?(\d+)M$/);
|
||||||
|
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
|
||||||
|
const hourMatch = offset.match(/-?PT?(\d+)H$/);
|
||||||
|
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
|
||||||
|
const dayMatch = offset.match(/-?P(\d+)D/);
|
||||||
|
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
|
||||||
|
if (!event.recurrenceRules?.length) return null;
|
||||||
|
const freq = event.recurrenceRules[0].frequency;
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
daily: t("recurrence.daily"),
|
||||||
|
weekly: t("recurrence.weekly"),
|
||||||
|
monthly: t("recurrence.monthly"),
|
||||||
|
yearly: t("recurrence.yearly"),
|
||||||
|
};
|
||||||
|
return labels[freq] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventDetailPopover({
|
||||||
|
event,
|
||||||
|
calendar,
|
||||||
|
anchorRect,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
|
onClose,
|
||||||
|
onSaveNote,
|
||||||
|
onRsvp,
|
||||||
|
currentUserEmails = [],
|
||||||
|
timeFormat = "24h",
|
||||||
|
}: EventDetailPopoverProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const noteInputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [noteText, setNoteText] = useState("");
|
||||||
|
const [noteExpanded, setNoteExpanded] = useState(false);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [isSavingNote, setIsSavingNote] = useState(false);
|
||||||
|
|
||||||
|
const color = getEventColor(event, calendar);
|
||||||
|
const startDate = parseISO(event.start);
|
||||||
|
const durationMinutes = parseDuration(event.duration);
|
||||||
|
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
|
||||||
|
|
||||||
|
const locationName = useMemo(() => {
|
||||||
|
if (!event.locations) return null;
|
||||||
|
return Object.values(event.locations)[0]?.name || null;
|
||||||
|
}, [event.locations]);
|
||||||
|
|
||||||
|
const virtualLocation = useMemo(() => {
|
||||||
|
if (!event.virtualLocations) return null;
|
||||||
|
const first = Object.values(event.virtualLocations)[0];
|
||||||
|
return first?.uri || null;
|
||||||
|
}, [event.virtualLocations]);
|
||||||
|
|
||||||
|
const participants = useMemo(() => getParticipantList(event), [event]);
|
||||||
|
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t), [event, t]);
|
||||||
|
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
|
||||||
|
|
||||||
|
const userIsOrganizer = useMemo(() => {
|
||||||
|
if (!event.participants) return true;
|
||||||
|
return isOrganizer(event, currentUserEmails);
|
||||||
|
}, [event, currentUserEmails]);
|
||||||
|
|
||||||
|
const isAttendeeMode = useMemo(() => {
|
||||||
|
if (!event.participants) return false;
|
||||||
|
return !event.isOrigin && !userIsOrganizer;
|
||||||
|
}, [event, userIsOrganizer]);
|
||||||
|
|
||||||
|
const userParticipantId = useMemo(
|
||||||
|
() => getUserParticipantId(event, currentUserEmails),
|
||||||
|
[event, currentUserEmails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const userCurrentStatus = useMemo(
|
||||||
|
() => getUserStatus(event, currentUserEmails),
|
||||||
|
[event, currentUserEmails]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatTime = useCallback(
|
||||||
|
(d: Date) => format(d, timeFormat === "12h" ? "h:mm a" : "HH:mm"),
|
||||||
|
[timeFormat]
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!popoverRef.current) return;
|
||||||
|
const height = popoverRef.current.offsetHeight;
|
||||||
|
setPosition(computePosition(anchorRect, height));
|
||||||
|
if (!ready) requestAnimationFrame(() => setReady(true));
|
||||||
|
}, [anchorRect, noteExpanded, showDeleteConfirm, ready]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
if (e.key === "e" && !noteExpanded) {
|
||||||
|
e.preventDefault();
|
||||||
|
onEdit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [onClose, onEdit, noteExpanded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleScroll = () => onClose();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
document.addEventListener("scroll", handleScroll, true);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
document.removeEventListener("scroll", handleScroll, true);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleSaveNote = useCallback(async () => {
|
||||||
|
const trimmed = noteText.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
setIsSavingNote(true);
|
||||||
|
try {
|
||||||
|
onSaveNote(trimmed);
|
||||||
|
setNoteText("");
|
||||||
|
setNoteExpanded(false);
|
||||||
|
} finally {
|
||||||
|
setIsSavingNote(false);
|
||||||
|
}
|
||||||
|
}, [noteText, onSaveNote]);
|
||||||
|
|
||||||
|
const handleNoteKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSaveNote();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
setNoteText("");
|
||||||
|
setNoteExpanded(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSaveNote]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasParticipants = participants.length > 0;
|
||||||
|
|
||||||
|
const popover = (
|
||||||
|
<div
|
||||||
|
ref={popoverRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={event.title || t("events.no_title")}
|
||||||
|
className="fixed z-[60] bg-background border border-border rounded-lg shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out"
|
||||||
|
style={{
|
||||||
|
width: POPOVER_WIDTH,
|
||||||
|
maxHeight: MAX_HEIGHT,
|
||||||
|
top: position?.top ?? -9999,
|
||||||
|
left: position?.left ?? -9999,
|
||||||
|
opacity: ready ? 1 : 0,
|
||||||
|
transform: ready ? "scale(1)" : "scale(0.95)",
|
||||||
|
visibility: position ? "visible" : "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Color accent bar */}
|
||||||
|
<div className="h-1 w-full" style={{ backgroundColor: color }} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2 px-4 pt-3 pb-1">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
<h3 className="text-base font-semibold truncate text-foreground">
|
||||||
|
{event.title || t("events.no_title")}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{calendar && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
|
||||||
|
{calendar.name}
|
||||||
|
{event.status === "tentative" && (
|
||||||
|
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
{t("detail.tentative")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{event.status === "cancelled" && (
|
||||||
|
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
|
||||||
|
{t("detail.cancelled")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded hover:bg-muted transition-colors flex-shrink-0 mt-0.5"
|
||||||
|
aria-label={t("form.cancel")}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-4 py-2 space-y-2.5 overflow-y-auto" style={{ maxHeight: MAX_HEIGHT - 140 }}>
|
||||||
|
{/* Date & Time */}
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{format(startDate, "EEE, MMM d, yyyy")}
|
||||||
|
</span>
|
||||||
|
{event.showWithoutTime ? (
|
||||||
|
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
{formatTime(startDate)} – {formatTime(endDate)}
|
||||||
|
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
{locationName && (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
|
{/^https?:\/\//i.test(locationName) ? (
|
||||||
|
<a
|
||||||
|
href={locationName}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sm text-primary hover:underline truncate"
|
||||||
|
title={locationName}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
try { return new URL(locationName).hostname; } catch { return locationName; }
|
||||||
|
})()}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-foreground">{locationName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Virtual Location / Meeting Link */}
|
||||||
|
{virtualLocation && (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
|
<a
|
||||||
|
href={virtualLocation}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sm text-primary hover:underline truncate"
|
||||||
|
title={virtualLocation}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
return new URL(virtualLocation).hostname;
|
||||||
|
} catch {
|
||||||
|
return virtualLocation;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Participants */}
|
||||||
|
{hasParticipants && (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<Users className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm min-w-0">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("participants.count", { count: participants.length })}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{participants.slice(0, 5).map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<span className="truncate text-foreground">
|
||||||
|
{p.name || p.email}
|
||||||
|
{p.isOrganizer && (
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
({t("participants.organizer").toLowerCase()})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ParticipantStatusBadge status={p.status} t={t} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{participants.length > 5 && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
+{participants.length - 5}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recurrence */}
|
||||||
|
{recurrenceLabel && (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<Repeat className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-foreground">{recurrenceLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reminder */}
|
||||||
|
{alertLabel && (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<Bell className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-foreground">{alertLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{event.description && (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3">
|
||||||
|
{event.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Note */}
|
||||||
|
{!isAttendeeMode && (
|
||||||
|
<div className="px-4 py-2 border-t border-border">
|
||||||
|
{noteExpanded ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
ref={noteInputRef}
|
||||||
|
value={noteText}
|
||||||
|
onChange={(e) => setNoteText(e.target.value)}
|
||||||
|
onKeyDown={handleNoteKeyDown}
|
||||||
|
placeholder={t("detail.add_note")}
|
||||||
|
rows={2}
|
||||||
|
autoFocus
|
||||||
|
className="w-full rounded-md border border-input bg-muted/30 px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setNoteText("");
|
||||||
|
setNoteExpanded(false);
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveNote}
|
||||||
|
disabled={!noteText.trim() || isSavingNote}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<Send className="w-3 h-3 mr-1" />
|
||||||
|
{t("detail.save_note")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setNoteExpanded(true)}
|
||||||
|
className="flex items-center gap-2 w-full text-sm text-muted-foreground hover:text-foreground transition-colors py-1"
|
||||||
|
>
|
||||||
|
<AlignLeft className="w-4 h-4" />
|
||||||
|
{t("detail.add_note")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RSVP Bar (for attendees) */}
|
||||||
|
{isAttendeeMode && onRsvp && userParticipantId && (
|
||||||
|
<div className="px-4 py-3 border-t border-border">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">
|
||||||
|
{t("participants.rsvp_label")}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
|
||||||
|
onClick={() => onRsvp("accepted")}
|
||||||
|
className={
|
||||||
|
userCurrentStatus === "accepted"
|
||||||
|
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600"
|
||||||
|
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />}
|
||||||
|
{t("participants.accepted")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
|
||||||
|
onClick={() => onRsvp("tentative")}
|
||||||
|
className={
|
||||||
|
userCurrentStatus === "tentative"
|
||||||
|
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600"
|
||||||
|
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />}
|
||||||
|
{t("participants.tentative")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
|
||||||
|
onClick={() => onRsvp("declined")}
|
||||||
|
className={
|
||||||
|
userCurrentStatus === "declined"
|
||||||
|
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600"
|
||||||
|
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />}
|
||||||
|
{t("participants.declined")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Bar */}
|
||||||
|
<div className="px-4 py-2.5 border-t border-border flex items-center gap-1.5">
|
||||||
|
{showDeleteConfirm ? (
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<span className="text-sm text-red-600 dark:text-red-400 flex-1">
|
||||||
|
{t("form.delete_confirm")}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 h-7 text-xs"
|
||||||
|
>
|
||||||
|
{t("events.delete")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
|
||||||
|
<Pencil className="w-3.5 h-3.5 mr-1" />
|
||||||
|
{t("events.edit")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDuplicate}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
title={t("events.duplicate")}
|
||||||
|
>
|
||||||
|
<Copy className="w-3.5 h-3.5 mr-1" />
|
||||||
|
{t("events.duplicate")}
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="h-7 text-xs text-red-600 dark:text-red-400"
|
||||||
|
title={t("events.delete")}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return createPortal(popover, document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParticipantStatusBadge({
|
||||||
|
status,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
status: CalendarParticipant["participationStatus"];
|
||||||
|
t: ReturnType<typeof useTranslations>;
|
||||||
|
}) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
accepted: "text-green-600 dark:text-green-400",
|
||||||
|
declined: "text-red-600 dark:text-red-400",
|
||||||
|
tentative: "text-amber-600 dark:text-amber-400",
|
||||||
|
"needs-action": "text-muted-foreground",
|
||||||
|
};
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
accepted: "participants.accepted",
|
||||||
|
declined: "participants.declined",
|
||||||
|
tentative: "participants.tentative",
|
||||||
|
"needs-action": "participants.needs_action",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`text-[10px] flex-shrink-0 ${colors[status] || ""}`}>
|
||||||
|
{t(labels[status] || labels["needs-action"])}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X, Trash2, Check, Users, CalendarDays } from "lucide-react";
|
import { X, Trash2, Check, Users, CalendarDays, Copy } from "lucide-react";
|
||||||
import { format, parseISO, addHours } from "date-fns";
|
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||||
import { parseDuration } from "./event-card";
|
import { parseDuration } from "./event-card";
|
||||||
import { ParticipantInput } from "./participant-input";
|
import { ParticipantInput } from "./participant-input";
|
||||||
@@ -22,8 +22,10 @@ interface EventModalProps {
|
|||||||
event?: CalendarEvent | null;
|
event?: CalendarEvent | null;
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
defaultDate?: Date;
|
defaultDate?: Date;
|
||||||
|
defaultEndDate?: Date;
|
||||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void;
|
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void;
|
||||||
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
|
||||||
|
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||||
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
currentUserEmails?: string[];
|
currentUserEmails?: string[];
|
||||||
@@ -59,8 +61,10 @@ export function EventModal({
|
|||||||
event,
|
event,
|
||||||
calendars,
|
calendars,
|
||||||
defaultDate,
|
defaultDate,
|
||||||
|
defaultEndDate,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
onRsvp,
|
onRsvp,
|
||||||
onClose,
|
onClose,
|
||||||
currentUserEmails = [],
|
currentUserEmails = [],
|
||||||
@@ -104,6 +108,7 @@ export function EventModal({
|
|||||||
if (event?.start) return parseISO(event.start);
|
if (event?.start) return parseISO(event.start);
|
||||||
if (defaultDate) {
|
if (defaultDate) {
|
||||||
const d = new Date(defaultDate);
|
const d = new Date(defaultDate);
|
||||||
|
if (defaultEndDate) return d;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
d.setHours(now.getHours() + 1, 0, 0, 0);
|
d.setHours(now.getHours() + 1, 0, 0, 0);
|
||||||
return d;
|
return d;
|
||||||
@@ -119,6 +124,7 @@ export function EventModal({
|
|||||||
const dur = parseDuration(event.duration);
|
const dur = parseDuration(event.duration);
|
||||||
return new Date(s.getTime() + dur * 60000);
|
return new Date(s.getTime() + dur * 60000);
|
||||||
}
|
}
|
||||||
|
if (defaultEndDate) return new Date(defaultEndDate);
|
||||||
return addHours(getInitialStart(), 1);
|
return addHours(getInitialStart(), 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -289,6 +295,29 @@ export function EventModal({
|
|||||||
onClose();
|
onClose();
|
||||||
}, [event, userParticipantId, onRsvp, onClose]);
|
}, [event, userParticipantId, onRsvp, onClose]);
|
||||||
|
|
||||||
|
const handleDuplicate = useCallback(() => {
|
||||||
|
if (!event || !onDuplicate) return;
|
||||||
|
const start = parseISO(event.start);
|
||||||
|
const newStart = addDays(start, 1);
|
||||||
|
const data: Partial<CalendarEvent> = {
|
||||||
|
title: event.title,
|
||||||
|
description: event.description,
|
||||||
|
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
|
||||||
|
duration: event.duration,
|
||||||
|
timeZone: event.timeZone,
|
||||||
|
showWithoutTime: event.showWithoutTime,
|
||||||
|
calendarIds: { ...event.calendarIds },
|
||||||
|
status: "confirmed",
|
||||||
|
freeBusyStatus: event.freeBusyStatus,
|
||||||
|
privacy: event.privacy,
|
||||||
|
};
|
||||||
|
if (event.locations) data.locations = structuredClone(event.locations);
|
||||||
|
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
|
||||||
|
if (event.alerts) data.alerts = structuredClone(event.alerts);
|
||||||
|
if (event.participants) data.participants = structuredClone(event.participants);
|
||||||
|
onDuplicate(data);
|
||||||
|
}, [event, onDuplicate]);
|
||||||
|
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -630,45 +659,56 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-t border-border">
|
<div className="flex items-center justify-between px-5 py-4 border-t border-border">
|
||||||
{isEdit && onDelete ? (
|
<div className="flex items-center gap-1">
|
||||||
showDeleteConfirm ? (
|
{isEdit && onDelete && (
|
||||||
<div className="flex items-center gap-2">
|
showDeleteConfirm ? (
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">
|
<div>
|
||||||
{t("form.delete_confirm")}
|
<span className="text-sm text-red-600 dark:text-red-400">
|
||||||
</span>
|
{t("form.delete_confirm")}
|
||||||
{hasParticipants && (
|
</span>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
{hasParticipants && (
|
||||||
{t("participants.cancel_notification")}
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
</p>
|
{t("participants.cancel_notification")}
|
||||||
)}
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
||||||
|
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
||||||
|
>
|
||||||
|
{t("events.delete")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
|
className="text-red-600 dark:text-red-400"
|
||||||
>
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
{t("events.delete")}
|
{t("events.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
|
)
|
||||||
{t("form.cancel")}
|
)}
|
||||||
</Button>
|
{isEdit && onDuplicate && !showDeleteConfirm && (
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={handleDuplicate}
|
||||||
className="text-red-600 dark:text-red-400"
|
aria-label={t("events.duplicate")}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
<Copy className="w-4 h-4 mr-1" />
|
||||||
{t("events.delete")}
|
{t("events.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)}
|
||||||
) : (
|
</div>
|
||||||
<div />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
interface QuickEventInputProps {
|
||||||
|
top: number;
|
||||||
|
onSubmit: (title: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickEventInput({ top, onSubmit, onCancel }: QuickEventInputProps) {
|
||||||
|
const t = useTranslations("calendar");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const submittedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmed = title.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
submittedRef.current = true;
|
||||||
|
onSubmit(trimmed);
|
||||||
|
} else {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, [title, onSubmit, onCancel]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
if (!submittedRef.current) onCancel();
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute left-1 right-1 z-40"
|
||||||
|
style={{ top }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder={t("quick_create.placeholder")}
|
||||||
|
aria-label={t("quick_create.aria_label")}
|
||||||
|
maxLength={500}
|
||||||
|
className="w-full px-2 py-1 text-xs rounded border border-primary bg-primary/10 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useId } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Repeat, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
export type RecurrenceEditScope = "this" | "this_and_future" | "all";
|
||||||
|
|
||||||
|
interface RecurrenceScopeDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
actionType: "edit" | "delete";
|
||||||
|
onSelect: (scope: RecurrenceEditScope) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecurrenceScopeDialog({
|
||||||
|
isOpen,
|
||||||
|
actionType,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: RecurrenceScopeDialogProps) {
|
||||||
|
const t = useTranslations("calendar.recurrence_scope");
|
||||||
|
const id = useId();
|
||||||
|
const [selected, setSelected] = useState<RecurrenceEditScope>("this");
|
||||||
|
|
||||||
|
const dialogRef = useFocusTrap({
|
||||||
|
isActive: isOpen,
|
||||||
|
onEscape: onClose,
|
||||||
|
restoreFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const isDelete = actionType === "delete";
|
||||||
|
|
||||||
|
const options: { value: RecurrenceEditScope; label: string }[] = [
|
||||||
|
{ value: "this", label: t("this_event") },
|
||||||
|
{ value: "this_and_future", label: t("this_and_future") },
|
||||||
|
{ value: "all", label: t("all_events") },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-[2px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={`${id}-title`}
|
||||||
|
aria-describedby={`${id}-desc`}
|
||||||
|
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-sm animate-in zoom-in-95 duration-200"
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<div className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
|
isDelete ? "bg-destructive/10" : "bg-primary/10"
|
||||||
|
}`}>
|
||||||
|
{isDelete ? (
|
||||||
|
<Trash2 className="w-5 h-5 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<Repeat className="w-5 h-5 text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 id={`${id}-title`} className="text-lg font-semibold">
|
||||||
|
{isDelete ? t("delete_title") : t("edit_title")}
|
||||||
|
</h2>
|
||||||
|
<p id={`${id}-desc`} className="text-sm text-muted-foreground mt-1">
|
||||||
|
{t("description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2" role="radiogroup" aria-labelledby={`${id}-title`}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer transition-colors ${
|
||||||
|
selected === option.value
|
||||||
|
? "bg-primary/10 border border-primary/30"
|
||||||
|
: "hover:bg-muted border border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`${id}-scope`}
|
||||||
|
value={option.value}
|
||||||
|
checked={selected === option.value}
|
||||||
|
onChange={() => setSelected(option.value)}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 px-6 pb-6">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={isDelete ? "destructive" : "default"}
|
||||||
|
onClick={() => onSelect(selected)}
|
||||||
|
>
|
||||||
|
{isDelete ? t("delete") : t("save")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import { toast } from "@/stores/toast-store";
|
||||||
|
import { debug } from "@/lib/debug";
|
||||||
|
import type { Calendar } from "@/lib/jmap/types";
|
||||||
|
|
||||||
|
interface DragCreateState {
|
||||||
|
dayKey: string;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResizeState {
|
||||||
|
eventId: string;
|
||||||
|
heightPx: number;
|
||||||
|
durationMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickCreateState {
|
||||||
|
dayKey: string;
|
||||||
|
day: Date;
|
||||||
|
hour: number;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DropTargetState {
|
||||||
|
dayKey: string;
|
||||||
|
minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTimeGridInteractionsOptions {
|
||||||
|
hourHeight: number;
|
||||||
|
calendars: Calendar[];
|
||||||
|
onCreateRange: (startDate: Date, endDate?: Date) => void;
|
||||||
|
errorMessages: {
|
||||||
|
resize: string;
|
||||||
|
move: string;
|
||||||
|
created: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTimeGridInteractions({
|
||||||
|
hourHeight,
|
||||||
|
calendars,
|
||||||
|
onCreateRange,
|
||||||
|
errorMessages,
|
||||||
|
}: UseTimeGridInteractionsOptions) {
|
||||||
|
const snapToMinutes = useCallback((clientY: number, containerTop: number): number => {
|
||||||
|
const raw = ((clientY - containerTop) / hourHeight) * 60;
|
||||||
|
return Math.max(0, Math.min(1440, Math.round(raw / 15) * 15));
|
||||||
|
}, [hourHeight]);
|
||||||
|
|
||||||
|
const wasDragging = useRef(false);
|
||||||
|
|
||||||
|
// --- Drag-to-create ---
|
||||||
|
const dragRef = useRef<{
|
||||||
|
dayKey: string;
|
||||||
|
dayDate: Date;
|
||||||
|
startMinutes: number;
|
||||||
|
pointerId: number;
|
||||||
|
startY: number;
|
||||||
|
captured: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [dragCreate, setDragCreate] = useState<DragCreateState | null>(null);
|
||||||
|
|
||||||
|
const handleGridPointerDown = useCallback((
|
||||||
|
e: PointerEvent<HTMLDivElement>,
|
||||||
|
dayKey: string,
|
||||||
|
dayDate: Date,
|
||||||
|
) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return;
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const minutes = snapToMinutes(e.clientY, rect.top);
|
||||||
|
|
||||||
|
dragRef.current = {
|
||||||
|
dayKey, dayDate, startMinutes: minutes,
|
||||||
|
pointerId: e.pointerId, startY: e.clientY, captured: false,
|
||||||
|
};
|
||||||
|
}, [snapToMinutes]);
|
||||||
|
|
||||||
|
const handleGridPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!dragRef.current) return;
|
||||||
|
|
||||||
|
if (!dragRef.current.captured) {
|
||||||
|
if (Math.abs(e.clientY - dragRef.current.startY) < 5) return;
|
||||||
|
dragRef.current.captured = true;
|
||||||
|
e.currentTarget.setPointerCapture(dragRef.current.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const currentMinutes = snapToMinutes(e.clientY, rect.top);
|
||||||
|
const start = Math.min(dragRef.current.startMinutes, currentMinutes);
|
||||||
|
const end = Math.max(dragRef.current.startMinutes, currentMinutes);
|
||||||
|
|
||||||
|
setDragCreate(end > start ? { dayKey: dragRef.current.dayKey, startMinutes: start, endMinutes: end } : null);
|
||||||
|
}, [snapToMinutes]);
|
||||||
|
|
||||||
|
const handleGridPointerUp = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
dragRef.current = null;
|
||||||
|
setDragCreate(null);
|
||||||
|
|
||||||
|
if (!drag || !drag.captured) return;
|
||||||
|
|
||||||
|
wasDragging.current = true;
|
||||||
|
requestAnimationFrame(() => { wasDragging.current = false; });
|
||||||
|
|
||||||
|
try { e.currentTarget.releasePointerCapture(drag.pointerId); } catch { /* may already be released */ }
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const endMinutes = snapToMinutes(e.clientY, rect.top);
|
||||||
|
const start = Math.min(drag.startMinutes, endMinutes);
|
||||||
|
const end = Math.max(drag.startMinutes, endMinutes);
|
||||||
|
|
||||||
|
if (end - start < 15) return;
|
||||||
|
|
||||||
|
const startDate = new Date(drag.dayDate);
|
||||||
|
startDate.setHours(Math.floor(start / 60), start % 60, 0, 0);
|
||||||
|
const endDate = new Date(drag.dayDate);
|
||||||
|
endDate.setHours(Math.floor(end / 60), end % 60, 0, 0);
|
||||||
|
onCreateRange(startDate, endDate);
|
||||||
|
}, [snapToMinutes, onCreateRange]);
|
||||||
|
|
||||||
|
// --- Resize ---
|
||||||
|
const resizeRef = useRef<{
|
||||||
|
eventId: string;
|
||||||
|
startY: number;
|
||||||
|
originalDurationMinutes: number;
|
||||||
|
originalHeightPx: number;
|
||||||
|
pointerId: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [resizeVisual, setResizeVisual] = useState<ResizeState | null>(null);
|
||||||
|
|
||||||
|
const handleResizePointerDown = useCallback((
|
||||||
|
eventId: string,
|
||||||
|
originalDurationMinutes: number,
|
||||||
|
e: PointerEvent,
|
||||||
|
) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const originalHeightPx = Math.max(20, (originalDurationMinutes / 60) * hourHeight);
|
||||||
|
resizeRef.current = {
|
||||||
|
eventId,
|
||||||
|
startY: e.clientY,
|
||||||
|
originalDurationMinutes,
|
||||||
|
originalHeightPx,
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
};
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
}, [hourHeight]);
|
||||||
|
|
||||||
|
const handleResizePointerMove = useCallback((e: PointerEvent) => {
|
||||||
|
if (!resizeRef.current) return;
|
||||||
|
|
||||||
|
const deltaY = e.clientY - resizeRef.current.startY;
|
||||||
|
const newHeightPx = Math.max(hourHeight / 4, resizeRef.current.originalHeightPx + deltaY);
|
||||||
|
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
|
||||||
|
const snappedHeight = (newDurationMinutes / 60) * hourHeight;
|
||||||
|
|
||||||
|
setResizeVisual({ eventId: resizeRef.current.eventId, heightPx: snappedHeight, durationMinutes: newDurationMinutes });
|
||||||
|
}, [hourHeight]);
|
||||||
|
|
||||||
|
const handleResizePointerUp = useCallback(async (e: PointerEvent) => {
|
||||||
|
const resize = resizeRef.current;
|
||||||
|
resizeRef.current = null;
|
||||||
|
|
||||||
|
if (!resize) return;
|
||||||
|
|
||||||
|
wasDragging.current = true;
|
||||||
|
requestAnimationFrame(() => { wasDragging.current = false; });
|
||||||
|
|
||||||
|
try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ }
|
||||||
|
|
||||||
|
const deltaY = e.clientY - resize.startY;
|
||||||
|
const newHeightPx = Math.max(hourHeight / 4, resize.originalHeightPx + deltaY);
|
||||||
|
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
|
||||||
|
|
||||||
|
if (newDurationMinutes === resize.originalDurationMinutes) {
|
||||||
|
setResizeVisual(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(newDurationMinutes / 60);
|
||||||
|
const mins = newDurationMinutes % 60;
|
||||||
|
let dur = "PT";
|
||||||
|
if (hours > 0) dur += `${hours}H`;
|
||||||
|
if (mins > 0) dur += `${mins}M`;
|
||||||
|
if (dur === "PT") dur = "PT0M";
|
||||||
|
|
||||||
|
const client = useAuthStore.getState().client;
|
||||||
|
if (!client) {
|
||||||
|
setResizeVisual(null);
|
||||||
|
toast.error(errorMessages.resize);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId);
|
||||||
|
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||||
|
await useCalendarStore.getState().updateEvent(client, resize.eventId, { duration: dur }, hasParticipants || undefined);
|
||||||
|
} catch (error) {
|
||||||
|
debug.error("Failed to resize event:", resize.eventId, error);
|
||||||
|
toast.error(errorMessages.resize);
|
||||||
|
} finally {
|
||||||
|
setResizeVisual(null);
|
||||||
|
}
|
||||||
|
}, [hourHeight, errorMessages.resize]);
|
||||||
|
|
||||||
|
// --- Click / Double-click / Quick-create ---
|
||||||
|
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const [quickCreate, setQuickCreate] = useState<QuickCreateState | null>(null);
|
||||||
|
|
||||||
|
const handleSlotClick = useCallback((day: Date, hour: number) => {
|
||||||
|
if (wasDragging.current) return;
|
||||||
|
if (clickTimerRef.current) {
|
||||||
|
clearTimeout(clickTimerRef.current);
|
||||||
|
clickTimerRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clickTimerRef.current = setTimeout(() => {
|
||||||
|
clickTimerRef.current = null;
|
||||||
|
const d = new Date(day);
|
||||||
|
d.setHours(hour, 0, 0, 0);
|
||||||
|
onCreateRange(d);
|
||||||
|
}, 250);
|
||||||
|
}, [onCreateRange]);
|
||||||
|
|
||||||
|
const handleSlotDoubleClick = useCallback((day: Date, hour: number) => {
|
||||||
|
if (wasDragging.current) return;
|
||||||
|
if (clickTimerRef.current) {
|
||||||
|
clearTimeout(clickTimerRef.current);
|
||||||
|
clickTimerRef.current = null;
|
||||||
|
}
|
||||||
|
const key = format(day, "yyyy-MM-dd");
|
||||||
|
setQuickCreate({ dayKey: key, day, hour, top: hour * hourHeight });
|
||||||
|
}, [hourHeight]);
|
||||||
|
|
||||||
|
const handleQuickCreateSubmit = useCallback(async (title: string) => {
|
||||||
|
if (!quickCreate) return;
|
||||||
|
const client = useAuthStore.getState().client;
|
||||||
|
if (!client) {
|
||||||
|
setQuickCreate(null);
|
||||||
|
toast.error(errorMessages.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const startDate = new Date(quickCreate.day);
|
||||||
|
startDate.setHours(quickCreate.hour, 0, 0, 0);
|
||||||
|
const startStr = format(startDate, "yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const defaultCal = calendars.find(c => c.isDefault) || calendars[0];
|
||||||
|
const created = await useCalendarStore.getState().createEvent(client, {
|
||||||
|
title,
|
||||||
|
start: startStr,
|
||||||
|
duration: "PT1H",
|
||||||
|
timeZone,
|
||||||
|
calendarIds: defaultCal ? { [defaultCal.id]: true } : {},
|
||||||
|
status: "confirmed",
|
||||||
|
freeBusyStatus: "busy",
|
||||||
|
privacy: "public",
|
||||||
|
});
|
||||||
|
setQuickCreate(null);
|
||||||
|
if (created) toast.success(errorMessages.created);
|
||||||
|
else toast.error(errorMessages.error);
|
||||||
|
} catch (error) {
|
||||||
|
debug.error("Failed to quick-create event:", error);
|
||||||
|
setQuickCreate(null);
|
||||||
|
toast.error(errorMessages.error);
|
||||||
|
}
|
||||||
|
}, [quickCreate, calendars, errorMessages.created, errorMessages.error]);
|
||||||
|
|
||||||
|
const handleQuickCreateCancel = useCallback(() => {
|
||||||
|
setQuickCreate(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- DnD drop ---
|
||||||
|
const [dropTarget, setDropTarget] = useState<DropTargetState | null>(null);
|
||||||
|
|
||||||
|
const snapDragMinutes = useCallback((e: DragEvent<HTMLDivElement>): number => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const raw = (y / hourHeight) * 60;
|
||||||
|
return Math.max(0, Math.min(1425, Math.round(raw / 15) * 15));
|
||||||
|
}, [hourHeight]);
|
||||||
|
|
||||||
|
const handleColumnDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
|
||||||
|
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "move";
|
||||||
|
const minutes = snapDragMinutes(e);
|
||||||
|
setDropTarget((prev) =>
|
||||||
|
prev?.dayKey === dayKey && prev?.minutes === minutes ? prev : { dayKey, minutes }
|
||||||
|
);
|
||||||
|
}, [snapDragMinutes]);
|
||||||
|
|
||||||
|
const handleColumnDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||||
|
const related = e.relatedTarget as Node | null;
|
||||||
|
if (!e.currentTarget.contains(related)) setDropTarget(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleColumnDrop = useCallback(async (e: DragEvent<HTMLDivElement>, day: Date) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDropTarget(null);
|
||||||
|
const json = e.dataTransfer.getData("application/x-calendar-event");
|
||||||
|
if (!json) return;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(json);
|
||||||
|
const minutes = snapDragMinutes(e);
|
||||||
|
const newStart = new Date(day);
|
||||||
|
newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
||||||
|
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
if (newStartISO === data.originalStart) return;
|
||||||
|
const client = useAuthStore.getState().client;
|
||||||
|
if (!client) {
|
||||||
|
toast.error(errorMessages.move);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const event = useCalendarStore.getState().events.find(ev => ev.id === data.eventId);
|
||||||
|
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||||
|
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
||||||
|
} catch {
|
||||||
|
toast.error(errorMessages.move);
|
||||||
|
}
|
||||||
|
}, [snapDragMinutes, errorMessages.move]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dragCreate,
|
||||||
|
handleGridPointerDown,
|
||||||
|
handleGridPointerMove,
|
||||||
|
handleGridPointerUp,
|
||||||
|
resizeVisual,
|
||||||
|
handleResizePointerDown,
|
||||||
|
handleResizePointerMove,
|
||||||
|
handleResizePointerUp,
|
||||||
|
wasDragging,
|
||||||
|
quickCreate,
|
||||||
|
handleSlotClick,
|
||||||
|
handleSlotDoubleClick,
|
||||||
|
handleQuickCreateSubmit,
|
||||||
|
handleQuickCreateCancel,
|
||||||
|
dropTarget,
|
||||||
|
handleColumnDragOver,
|
||||||
|
handleColumnDragLeave,
|
||||||
|
handleColumnDrop,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { parseISO } from "date-fns";
|
||||||
|
import { parseDuration } from "@/components/calendar/event-card";
|
||||||
|
import type { CalendarEvent } from "@/lib/jmap/types";
|
||||||
|
|
||||||
|
export function getEventEndDate(event: CalendarEvent): Date {
|
||||||
|
const start = new Date(event.start);
|
||||||
|
if (!event.duration) return start;
|
||||||
|
return new Date(start.getTime() + parseDuration(event.duration) * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutOverlappingEvents(
|
||||||
|
events: CalendarEvent[],
|
||||||
|
): { event: CalendarEvent; column: number; totalColumns: number }[] {
|
||||||
|
const sorted = [...events].sort((a, b) => {
|
||||||
|
const diff = new Date(a.start).getTime() - new Date(b.start).getTime();
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
return parseDuration(b.duration) - parseDuration(a.duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: { event: CalendarEvent; end: number }[][] = [];
|
||||||
|
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = [];
|
||||||
|
|
||||||
|
for (const event of sorted) {
|
||||||
|
const start = parseISO(event.start);
|
||||||
|
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||||
|
const endMin = startMin + Math.max(15, parseDuration(event.duration));
|
||||||
|
let placed = false;
|
||||||
|
for (let col = 0; col < columns.length; col++) {
|
||||||
|
if (columns[col].every(e => e.end <= startMin)) {
|
||||||
|
columns[col].push({ event, end: endMin });
|
||||||
|
result.push({ event, column: col, totalColumns: 0 });
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) {
|
||||||
|
columns.push([{ event, end: endMin }]);
|
||||||
|
result.push({ event, column: columns.length - 1, totalColumns: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = columns.length;
|
||||||
|
result.forEach(r => r.totalColumns = total);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): string {
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
if (timeFormat === "12h") {
|
||||||
|
return `${h % 12 || 12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`;
|
||||||
|
}
|
||||||
|
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
+31
-1
@@ -1177,9 +1177,21 @@
|
|||||||
"all_day": "Ganztägig",
|
"all_day": "Ganztägig",
|
||||||
"more": "+{count} weitere",
|
"more": "+{count} weitere",
|
||||||
"no_title": "(Kein Titel)",
|
"no_title": "(Kein Titel)",
|
||||||
|
"resize": "Termingröße ändern",
|
||||||
|
"duplicate": "Duplizieren",
|
||||||
"today_header": "Heute",
|
"today_header": "Heute",
|
||||||
"tomorrow_header": "Morgen"
|
"tomorrow_header": "Morgen"
|
||||||
},
|
},
|
||||||
|
"detail": {
|
||||||
|
"add_note": "Notiz hinzufügen...",
|
||||||
|
"save_note": "Speichern",
|
||||||
|
"note_saved": "Notiz hinzugefügt",
|
||||||
|
"open_link": "Link öffnen",
|
||||||
|
"meeting_link": "Besprechungslink",
|
||||||
|
"tentative": "Vorläufig",
|
||||||
|
"cancelled": "Abgesagt",
|
||||||
|
"delete_confirm": "Dieses Ereignis löschen?"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Titel",
|
"title": "Titel",
|
||||||
"description": "Beschreibung",
|
"description": "Beschreibung",
|
||||||
@@ -1230,6 +1242,17 @@
|
|||||||
"until": "Bis",
|
"until": "Bis",
|
||||||
"occurrences": "{count} Wiederholungen"
|
"occurrences": "{count} Wiederholungen"
|
||||||
},
|
},
|
||||||
|
"recurrence_scope": {
|
||||||
|
"edit_title": "Wiederkehrendes Ereignis bearbeiten",
|
||||||
|
"delete_title": "Wiederkehrendes Ereignis löschen",
|
||||||
|
"description": "Dies ist ein wiederkehrendes Ereignis. Welche Ereignisse möchten Sie ändern?",
|
||||||
|
"this_event": "Nur dieses Ereignis",
|
||||||
|
"this_and_future": "Dieses und folgende Ereignisse",
|
||||||
|
"all_events": "Alle Ereignisse",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"save": "Speichern",
|
||||||
|
"delete": "Löschen"
|
||||||
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"title": "Erinnerung",
|
"title": "Erinnerung",
|
||||||
"none": "Keine Erinnerung",
|
"none": "Keine Erinnerung",
|
||||||
@@ -1275,17 +1298,24 @@
|
|||||||
"calendar_created": "Kalender erstellt",
|
"calendar_created": "Kalender erstellt",
|
||||||
"calendar_deleted": "Kalender gelöscht",
|
"calendar_deleted": "Kalender gelöscht",
|
||||||
"event_move_error": "Termin konnte nicht verschoben werden",
|
"event_move_error": "Termin konnte nicht verschoben werden",
|
||||||
|
"event_resize_error": "Termingröße konnte nicht geändert werden",
|
||||||
"alert_title": "Bevorstehender Termin",
|
"alert_title": "Bevorstehender Termin",
|
||||||
"alert_now": "Beginnt jetzt",
|
"alert_now": "Beginnt jetzt",
|
||||||
"alert_in_minutes": "In {count} Min.",
|
"alert_in_minutes": "In {count} Min.",
|
||||||
"invitation_sent": "Einladungen gesendet",
|
"invitation_sent": "Einladungen gesendet",
|
||||||
"rsvp_updated": "Antwort aktualisiert",
|
"rsvp_updated": "Antwort aktualisiert",
|
||||||
"rsvp_error": "Antwort konnte nicht aktualisiert werden"
|
"rsvp_error": "Antwort konnte nicht aktualisiert werden",
|
||||||
|
"event_duplicated": "Termin dupliziert",
|
||||||
|
"event_error": "Termin konnte nicht gespeichert werden"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Kalender werden geladen...",
|
"loading_calendars": "Kalender werden geladen...",
|
||||||
"loading_events": "Termine werden geladen..."
|
"loading_events": "Termine werden geladen..."
|
||||||
},
|
},
|
||||||
|
"quick_create": {
|
||||||
|
"placeholder": "Titel des neuen Termins",
|
||||||
|
"aria_label": "Schnellerstellung eines Termins"
|
||||||
|
},
|
||||||
"nav_prev": "Zurück",
|
"nav_prev": "Zurück",
|
||||||
"nav_next": "Weiter",
|
"nav_next": "Weiter",
|
||||||
"import": {
|
"import": {
|
||||||
|
|||||||
+31
-1
@@ -1182,9 +1182,21 @@
|
|||||||
"all_day": "All day",
|
"all_day": "All day",
|
||||||
"more": "+{count} more",
|
"more": "+{count} more",
|
||||||
"no_title": "(No title)",
|
"no_title": "(No title)",
|
||||||
|
"resize": "Resize event",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
"today_header": "Today",
|
"today_header": "Today",
|
||||||
"tomorrow_header": "Tomorrow"
|
"tomorrow_header": "Tomorrow"
|
||||||
},
|
},
|
||||||
|
"detail": {
|
||||||
|
"add_note": "Add a note...",
|
||||||
|
"save_note": "Save",
|
||||||
|
"note_saved": "Note added",
|
||||||
|
"open_link": "Open link",
|
||||||
|
"meeting_link": "Meeting link",
|
||||||
|
"tentative": "Tentative",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"delete_confirm": "Delete this event?"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
@@ -1235,6 +1247,17 @@
|
|||||||
"until": "Until",
|
"until": "Until",
|
||||||
"occurrences": "{count} occurrences"
|
"occurrences": "{count} occurrences"
|
||||||
},
|
},
|
||||||
|
"recurrence_scope": {
|
||||||
|
"edit_title": "Edit recurring event",
|
||||||
|
"delete_title": "Delete recurring event",
|
||||||
|
"description": "This is a recurring event. Which events would you like to modify?",
|
||||||
|
"this_event": "This event only",
|
||||||
|
"this_and_future": "This and following events",
|
||||||
|
"all_events": "All events",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete"
|
||||||
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"title": "Reminder",
|
"title": "Reminder",
|
||||||
"none": "No reminder",
|
"none": "No reminder",
|
||||||
@@ -1280,17 +1303,24 @@
|
|||||||
"calendar_created": "Calendar created",
|
"calendar_created": "Calendar created",
|
||||||
"calendar_deleted": "Calendar deleted",
|
"calendar_deleted": "Calendar deleted",
|
||||||
"event_move_error": "Failed to move event",
|
"event_move_error": "Failed to move event",
|
||||||
|
"event_resize_error": "Failed to resize event",
|
||||||
"alert_title": "Upcoming event",
|
"alert_title": "Upcoming event",
|
||||||
"alert_now": "Starting now",
|
"alert_now": "Starting now",
|
||||||
"alert_in_minutes": "In {count} min",
|
"alert_in_minutes": "In {count} min",
|
||||||
"invitation_sent": "Invitations sent",
|
"invitation_sent": "Invitations sent",
|
||||||
"rsvp_updated": "Response updated",
|
"rsvp_updated": "Response updated",
|
||||||
"rsvp_error": "Failed to update response"
|
"rsvp_error": "Failed to update response",
|
||||||
|
"event_duplicated": "Event duplicated",
|
||||||
|
"event_error": "Failed to save event"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Loading calendars...",
|
"loading_calendars": "Loading calendars...",
|
||||||
"loading_events": "Loading events..."
|
"loading_events": "Loading events..."
|
||||||
},
|
},
|
||||||
|
"quick_create": {
|
||||||
|
"placeholder": "New event title",
|
||||||
|
"aria_label": "Quick create event"
|
||||||
|
},
|
||||||
"nav_prev": "Previous",
|
"nav_prev": "Previous",
|
||||||
"nav_next": "Next",
|
"nav_next": "Next",
|
||||||
"import": {
|
"import": {
|
||||||
|
|||||||
+31
-1
@@ -1177,9 +1177,21 @@
|
|||||||
"all_day": "Todo el día",
|
"all_day": "Todo el día",
|
||||||
"more": "+{count} más",
|
"more": "+{count} más",
|
||||||
"no_title": "(Sin título)",
|
"no_title": "(Sin título)",
|
||||||
|
"resize": "Redimensionar evento",
|
||||||
|
"duplicate": "Duplicar",
|
||||||
"today_header": "Hoy",
|
"today_header": "Hoy",
|
||||||
"tomorrow_header": "Mañana"
|
"tomorrow_header": "Mañana"
|
||||||
},
|
},
|
||||||
|
"detail": {
|
||||||
|
"add_note": "Añadir una nota...",
|
||||||
|
"save_note": "Guardar",
|
||||||
|
"note_saved": "Nota añadida",
|
||||||
|
"open_link": "Abrir enlace",
|
||||||
|
"meeting_link": "Enlace de reunión",
|
||||||
|
"tentative": "Provisional",
|
||||||
|
"cancelled": "Cancelado",
|
||||||
|
"delete_confirm": "¿Eliminar este evento?"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Título",
|
"title": "Título",
|
||||||
"description": "Descripción",
|
"description": "Descripción",
|
||||||
@@ -1230,6 +1242,17 @@
|
|||||||
"until": "Hasta",
|
"until": "Hasta",
|
||||||
"occurrences": "{count} repeticiones"
|
"occurrences": "{count} repeticiones"
|
||||||
},
|
},
|
||||||
|
"recurrence_scope": {
|
||||||
|
"edit_title": "Editar evento recurrente",
|
||||||
|
"delete_title": "Eliminar evento recurrente",
|
||||||
|
"description": "Este es un evento recurrente. ¿Qué eventos desea modificar?",
|
||||||
|
"this_event": "Solo este evento",
|
||||||
|
"this_and_future": "Este evento y los siguientes",
|
||||||
|
"all_events": "Todos los eventos",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"save": "Guardar",
|
||||||
|
"delete": "Eliminar"
|
||||||
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"title": "Recordatorio",
|
"title": "Recordatorio",
|
||||||
"none": "Sin recordatorio",
|
"none": "Sin recordatorio",
|
||||||
@@ -1275,17 +1298,24 @@
|
|||||||
"calendar_created": "Calendario creado",
|
"calendar_created": "Calendario creado",
|
||||||
"calendar_deleted": "Calendario eliminado",
|
"calendar_deleted": "Calendario eliminado",
|
||||||
"event_move_error": "Error al mover el evento",
|
"event_move_error": "Error al mover el evento",
|
||||||
|
"event_resize_error": "Error al redimensionar el evento",
|
||||||
"alert_title": "Evento próximo",
|
"alert_title": "Evento próximo",
|
||||||
"alert_now": "Comienza ahora",
|
"alert_now": "Comienza ahora",
|
||||||
"alert_in_minutes": "En {count} min",
|
"alert_in_minutes": "En {count} min",
|
||||||
"invitation_sent": "Invitaciones enviadas",
|
"invitation_sent": "Invitaciones enviadas",
|
||||||
"rsvp_updated": "Respuesta actualizada",
|
"rsvp_updated": "Respuesta actualizada",
|
||||||
"rsvp_error": "Error al actualizar la respuesta"
|
"rsvp_error": "Error al actualizar la respuesta",
|
||||||
|
"event_duplicated": "Evento duplicado",
|
||||||
|
"event_error": "Error al guardar el evento"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Cargando calendarios...",
|
"loading_calendars": "Cargando calendarios...",
|
||||||
"loading_events": "Cargando eventos..."
|
"loading_events": "Cargando eventos..."
|
||||||
},
|
},
|
||||||
|
"quick_create": {
|
||||||
|
"placeholder": "Título del nuevo evento",
|
||||||
|
"aria_label": "Creación rápida de evento"
|
||||||
|
},
|
||||||
"nav_prev": "Anterior",
|
"nav_prev": "Anterior",
|
||||||
"nav_next": "Siguiente",
|
"nav_next": "Siguiente",
|
||||||
"import": {
|
"import": {
|
||||||
|
|||||||
+31
-1
@@ -1177,9 +1177,21 @@
|
|||||||
"all_day": "Toute la journée",
|
"all_day": "Toute la journée",
|
||||||
"more": "+{count} de plus",
|
"more": "+{count} de plus",
|
||||||
"no_title": "(Sans titre)",
|
"no_title": "(Sans titre)",
|
||||||
|
"resize": "Redimensionner l'événement",
|
||||||
|
"duplicate": "Dupliquer",
|
||||||
"today_header": "Aujourd'hui",
|
"today_header": "Aujourd'hui",
|
||||||
"tomorrow_header": "Demain"
|
"tomorrow_header": "Demain"
|
||||||
},
|
},
|
||||||
|
"detail": {
|
||||||
|
"add_note": "Ajouter une note...",
|
||||||
|
"save_note": "Enregistrer",
|
||||||
|
"note_saved": "Note ajoutée",
|
||||||
|
"open_link": "Ouvrir le lien",
|
||||||
|
"meeting_link": "Lien de réunion",
|
||||||
|
"tentative": "Provisoire",
|
||||||
|
"cancelled": "Annulé",
|
||||||
|
"delete_confirm": "Supprimer cet événement ?"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Titre",
|
"title": "Titre",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
@@ -1230,6 +1242,17 @@
|
|||||||
"until": "Jusqu'au",
|
"until": "Jusqu'au",
|
||||||
"occurrences": "{count} occurrences"
|
"occurrences": "{count} occurrences"
|
||||||
},
|
},
|
||||||
|
"recurrence_scope": {
|
||||||
|
"edit_title": "Modifier l'événement récurrent",
|
||||||
|
"delete_title": "Supprimer l'événement récurrent",
|
||||||
|
"description": "Ceci est un événement récurrent. Quels événements souhaitez-vous modifier ?",
|
||||||
|
"this_event": "Cet événement uniquement",
|
||||||
|
"this_and_future": "Cet événement et les suivants",
|
||||||
|
"all_events": "Tous les événements",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"delete": "Supprimer"
|
||||||
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"title": "Rappel",
|
"title": "Rappel",
|
||||||
"none": "Aucun rappel",
|
"none": "Aucun rappel",
|
||||||
@@ -1275,17 +1298,24 @@
|
|||||||
"calendar_created": "Calendrier créé",
|
"calendar_created": "Calendrier créé",
|
||||||
"calendar_deleted": "Calendrier supprimé",
|
"calendar_deleted": "Calendrier supprimé",
|
||||||
"event_move_error": "Échec du déplacement de l'événement",
|
"event_move_error": "Échec du déplacement de l'événement",
|
||||||
|
"event_resize_error": "Échec du redimensionnement de l'événement",
|
||||||
"alert_title": "Événement à venir",
|
"alert_title": "Événement à venir",
|
||||||
"alert_now": "Commence maintenant",
|
"alert_now": "Commence maintenant",
|
||||||
"alert_in_minutes": "Dans {count} min",
|
"alert_in_minutes": "Dans {count} min",
|
||||||
"invitation_sent": "Invitations envoyées",
|
"invitation_sent": "Invitations envoyées",
|
||||||
"rsvp_updated": "Réponse mise à jour",
|
"rsvp_updated": "Réponse mise à jour",
|
||||||
"rsvp_error": "Échec de la mise à jour de la réponse"
|
"rsvp_error": "Échec de la mise à jour de la réponse",
|
||||||
|
"event_duplicated": "Événement dupliqué",
|
||||||
|
"event_error": "Échec de l'enregistrement de l'événement"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Chargement des calendriers...",
|
"loading_calendars": "Chargement des calendriers...",
|
||||||
"loading_events": "Chargement des événements..."
|
"loading_events": "Chargement des événements..."
|
||||||
},
|
},
|
||||||
|
"quick_create": {
|
||||||
|
"placeholder": "Titre du nouvel événement",
|
||||||
|
"aria_label": "Création rapide d'événement"
|
||||||
|
},
|
||||||
"nav_prev": "Précédent",
|
"nav_prev": "Précédent",
|
||||||
"nav_next": "Suivant",
|
"nav_next": "Suivant",
|
||||||
"import": {
|
"import": {
|
||||||
|
|||||||
+31
-1
@@ -1177,9 +1177,21 @@
|
|||||||
"all_day": "Tutto il giorno",
|
"all_day": "Tutto il giorno",
|
||||||
"more": "+{count} altri",
|
"more": "+{count} altri",
|
||||||
"no_title": "(Senza titolo)",
|
"no_title": "(Senza titolo)",
|
||||||
|
"resize": "Ridimensiona evento",
|
||||||
|
"duplicate": "Duplica",
|
||||||
"today_header": "Oggi",
|
"today_header": "Oggi",
|
||||||
"tomorrow_header": "Domani"
|
"tomorrow_header": "Domani"
|
||||||
},
|
},
|
||||||
|
"detail": {
|
||||||
|
"add_note": "Aggiungi una nota...",
|
||||||
|
"save_note": "Salva",
|
||||||
|
"note_saved": "Nota aggiunta",
|
||||||
|
"open_link": "Apri link",
|
||||||
|
"meeting_link": "Link riunione",
|
||||||
|
"tentative": "Provvisorio",
|
||||||
|
"cancelled": "Annullato",
|
||||||
|
"delete_confirm": "Eliminare questo evento?"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Titolo",
|
"title": "Titolo",
|
||||||
"description": "Descrizione",
|
"description": "Descrizione",
|
||||||
@@ -1230,6 +1242,17 @@
|
|||||||
"until": "Fino al",
|
"until": "Fino al",
|
||||||
"occurrences": "{count} ripetizioni"
|
"occurrences": "{count} ripetizioni"
|
||||||
},
|
},
|
||||||
|
"recurrence_scope": {
|
||||||
|
"edit_title": "Modifica evento ricorrente",
|
||||||
|
"delete_title": "Elimina evento ricorrente",
|
||||||
|
"description": "Questo è un evento ricorrente. Quali eventi desideri modificare?",
|
||||||
|
"this_event": "Solo questo evento",
|
||||||
|
"this_and_future": "Questo evento e i seguenti",
|
||||||
|
"all_events": "Tutti gli eventi",
|
||||||
|
"cancel": "Annulla",
|
||||||
|
"save": "Salva",
|
||||||
|
"delete": "Elimina"
|
||||||
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"title": "Promemoria",
|
"title": "Promemoria",
|
||||||
"none": "Nessun promemoria",
|
"none": "Nessun promemoria",
|
||||||
@@ -1275,17 +1298,24 @@
|
|||||||
"calendar_created": "Calendario creato",
|
"calendar_created": "Calendario creato",
|
||||||
"calendar_deleted": "Calendario eliminato",
|
"calendar_deleted": "Calendario eliminato",
|
||||||
"event_move_error": "Spostamento dell'evento non riuscito",
|
"event_move_error": "Spostamento dell'evento non riuscito",
|
||||||
|
"event_resize_error": "Ridimensionamento dell'evento non riuscito",
|
||||||
"alert_title": "Evento in arrivo",
|
"alert_title": "Evento in arrivo",
|
||||||
"alert_now": "Inizia ora",
|
"alert_now": "Inizia ora",
|
||||||
"alert_in_minutes": "Tra {count} min",
|
"alert_in_minutes": "Tra {count} min",
|
||||||
"invitation_sent": "Inviti inviati",
|
"invitation_sent": "Inviti inviati",
|
||||||
"rsvp_updated": "Risposta aggiornata",
|
"rsvp_updated": "Risposta aggiornata",
|
||||||
"rsvp_error": "Impossibile aggiornare la risposta"
|
"rsvp_error": "Impossibile aggiornare la risposta",
|
||||||
|
"event_duplicated": "Evento duplicato",
|
||||||
|
"event_error": "Salvataggio dell'evento non riuscito"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Caricamento calendari...",
|
"loading_calendars": "Caricamento calendari...",
|
||||||
"loading_events": "Caricamento eventi..."
|
"loading_events": "Caricamento eventi..."
|
||||||
},
|
},
|
||||||
|
"quick_create": {
|
||||||
|
"placeholder": "Titolo del nuovo evento",
|
||||||
|
"aria_label": "Creazione rapida evento"
|
||||||
|
},
|
||||||
"nav_prev": "Precedente",
|
"nav_prev": "Precedente",
|
||||||
"nav_next": "Successivo",
|
"nav_next": "Successivo",
|
||||||
"import": {
|
"import": {
|
||||||
|
|||||||
+31
-1
@@ -1177,9 +1177,21 @@
|
|||||||
"all_day": "終日",
|
"all_day": "終日",
|
||||||
"more": "他{count}件",
|
"more": "他{count}件",
|
||||||
"no_title": "(タイトルなし)",
|
"no_title": "(タイトルなし)",
|
||||||
|
"resize": "イベントのサイズ変更",
|
||||||
|
"duplicate": "複製",
|
||||||
"today_header": "今日",
|
"today_header": "今日",
|
||||||
"tomorrow_header": "明日"
|
"tomorrow_header": "明日"
|
||||||
},
|
},
|
||||||
|
"detail": {
|
||||||
|
"add_note": "メモを追加...",
|
||||||
|
"save_note": "保存",
|
||||||
|
"note_saved": "メモを追加しました",
|
||||||
|
"open_link": "リンクを開く",
|
||||||
|
"meeting_link": "会議リンク",
|
||||||
|
"tentative": "仮",
|
||||||
|
"cancelled": "キャンセル済み",
|
||||||
|
"delete_confirm": "このイベントを削除しますか?"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "タイトル",
|
"title": "タイトル",
|
||||||
"description": "説明",
|
"description": "説明",
|
||||||
@@ -1230,6 +1242,17 @@
|
|||||||
"until": "終了日",
|
"until": "終了日",
|
||||||
"occurrences": "{count}回"
|
"occurrences": "{count}回"
|
||||||
},
|
},
|
||||||
|
"recurrence_scope": {
|
||||||
|
"edit_title": "繰り返しイベントを編集",
|
||||||
|
"delete_title": "繰り返しイベントを削除",
|
||||||
|
"description": "これは繰り返しイベントです。どのイベントを変更しますか?",
|
||||||
|
"this_event": "このイベントのみ",
|
||||||
|
"this_and_future": "これ以降のすべてのイベント",
|
||||||
|
"all_events": "すべてのイベント",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"save": "保存",
|
||||||
|
"delete": "削除"
|
||||||
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"title": "リマインダー",
|
"title": "リマインダー",
|
||||||
"none": "リマインダーなし",
|
"none": "リマインダーなし",
|
||||||
@@ -1275,17 +1298,24 @@
|
|||||||
"calendar_created": "カレンダーを作成しました",
|
"calendar_created": "カレンダーを作成しました",
|
||||||
"calendar_deleted": "カレンダーを削除しました",
|
"calendar_deleted": "カレンダーを削除しました",
|
||||||
"event_move_error": "イベントの移動に失敗しました",
|
"event_move_error": "イベントの移動に失敗しました",
|
||||||
|
"event_resize_error": "イベントのサイズ変更に失敗しました",
|
||||||
"alert_title": "予定のイベント",
|
"alert_title": "予定のイベント",
|
||||||
"alert_now": "まもなく開始",
|
"alert_now": "まもなく開始",
|
||||||
"alert_in_minutes": "{count}分後",
|
"alert_in_minutes": "{count}分後",
|
||||||
"invitation_sent": "招待を送信しました",
|
"invitation_sent": "招待を送信しました",
|
||||||
"rsvp_updated": "回答を更新しました",
|
"rsvp_updated": "回答を更新しました",
|
||||||
"rsvp_error": "回答の更新に失敗しました"
|
"rsvp_error": "回答の更新に失敗しました",
|
||||||
|
"event_duplicated": "予定を複製しました",
|
||||||
|
"event_error": "予定の保存に失敗しました"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "カレンダーを読み込み中...",
|
"loading_calendars": "カレンダーを読み込み中...",
|
||||||
"loading_events": "予定を読み込み中..."
|
"loading_events": "予定を読み込み中..."
|
||||||
},
|
},
|
||||||
|
"quick_create": {
|
||||||
|
"placeholder": "新しい予定のタイトル",
|
||||||
|
"aria_label": "予定をすばやく作成"
|
||||||
|
},
|
||||||
"nav_prev": "前へ",
|
"nav_prev": "前へ",
|
||||||
"nav_next": "次へ",
|
"nav_next": "次へ",
|
||||||
"import": {
|
"import": {
|
||||||
|
|||||||
+31
-1
@@ -1177,9 +1177,21 @@
|
|||||||
"all_day": "Hele dag",
|
"all_day": "Hele dag",
|
||||||
"more": "+{count} meer",
|
"more": "+{count} meer",
|
||||||
"no_title": "(Geen titel)",
|
"no_title": "(Geen titel)",
|
||||||
|
"resize": "Evenement formaat wijzigen",
|
||||||
|
"duplicate": "Dupliceren",
|
||||||
"today_header": "Vandaag",
|
"today_header": "Vandaag",
|
||||||
"tomorrow_header": "Morgen"
|
"tomorrow_header": "Morgen"
|
||||||
},
|
},
|
||||||
|
"detail": {
|
||||||
|
"add_note": "Notitie toevoegen...",
|
||||||
|
"save_note": "Opslaan",
|
||||||
|
"note_saved": "Notitie toegevoegd",
|
||||||
|
"open_link": "Link openen",
|
||||||
|
"meeting_link": "Vergaderlink",
|
||||||
|
"tentative": "Voorlopig",
|
||||||
|
"cancelled": "Geannuleerd",
|
||||||
|
"delete_confirm": "Dit evenement verwijderen?"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Titel",
|
"title": "Titel",
|
||||||
"description": "Beschrijving",
|
"description": "Beschrijving",
|
||||||
@@ -1230,6 +1242,17 @@
|
|||||||
"until": "Tot",
|
"until": "Tot",
|
||||||
"occurrences": "{count} herhalingen"
|
"occurrences": "{count} herhalingen"
|
||||||
},
|
},
|
||||||
|
"recurrence_scope": {
|
||||||
|
"edit_title": "Terugkerend evenement bewerken",
|
||||||
|
"delete_title": "Terugkerend evenement verwijderen",
|
||||||
|
"description": "Dit is een terugkerend evenement. Welke evenementen wilt u wijzigen?",
|
||||||
|
"this_event": "Alleen dit evenement",
|
||||||
|
"this_and_future": "Dit en volgende evenementen",
|
||||||
|
"all_events": "Alle evenementen",
|
||||||
|
"cancel": "Annuleren",
|
||||||
|
"save": "Opslaan",
|
||||||
|
"delete": "Verwijderen"
|
||||||
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"title": "Herinnering",
|
"title": "Herinnering",
|
||||||
"none": "Geen herinnering",
|
"none": "Geen herinnering",
|
||||||
@@ -1275,17 +1298,24 @@
|
|||||||
"calendar_created": "Agenda aangemaakt",
|
"calendar_created": "Agenda aangemaakt",
|
||||||
"calendar_deleted": "Agenda verwijderd",
|
"calendar_deleted": "Agenda verwijderd",
|
||||||
"event_move_error": "Evenement verplaatsen mislukt",
|
"event_move_error": "Evenement verplaatsen mislukt",
|
||||||
|
"event_resize_error": "Evenement formaat wijzigen mislukt",
|
||||||
"alert_title": "Aankomend evenement",
|
"alert_title": "Aankomend evenement",
|
||||||
"alert_now": "Begint nu",
|
"alert_now": "Begint nu",
|
||||||
"alert_in_minutes": "Over {count} min",
|
"alert_in_minutes": "Over {count} min",
|
||||||
"invitation_sent": "Uitnodigingen verzonden",
|
"invitation_sent": "Uitnodigingen verzonden",
|
||||||
"rsvp_updated": "Reactie bijgewerkt",
|
"rsvp_updated": "Reactie bijgewerkt",
|
||||||
"rsvp_error": "Reactie kon niet worden bijgewerkt"
|
"rsvp_error": "Reactie kon niet worden bijgewerkt",
|
||||||
|
"event_duplicated": "Evenement gedupliceerd",
|
||||||
|
"event_error": "Evenement opslaan mislukt"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Agenda's laden...",
|
"loading_calendars": "Agenda's laden...",
|
||||||
"loading_events": "Evenementen laden..."
|
"loading_events": "Evenementen laden..."
|
||||||
},
|
},
|
||||||
|
"quick_create": {
|
||||||
|
"placeholder": "Titel nieuw evenement",
|
||||||
|
"aria_label": "Snel evenement aanmaken"
|
||||||
|
},
|
||||||
"nav_prev": "Vorige",
|
"nav_prev": "Vorige",
|
||||||
"nav_next": "Volgende",
|
"nav_next": "Volgende",
|
||||||
"import": {
|
"import": {
|
||||||
|
|||||||
+31
-1
@@ -1177,9 +1177,21 @@
|
|||||||
"all_day": "Dia inteiro",
|
"all_day": "Dia inteiro",
|
||||||
"more": "+{count} mais",
|
"more": "+{count} mais",
|
||||||
"no_title": "(Sem título)",
|
"no_title": "(Sem título)",
|
||||||
|
"resize": "Redimensionar evento",
|
||||||
|
"duplicate": "Duplicar",
|
||||||
"today_header": "Hoje",
|
"today_header": "Hoje",
|
||||||
"tomorrow_header": "Amanhã"
|
"tomorrow_header": "Amanhã"
|
||||||
},
|
},
|
||||||
|
"detail": {
|
||||||
|
"add_note": "Adicionar uma nota...",
|
||||||
|
"save_note": "Salvar",
|
||||||
|
"note_saved": "Nota adicionada",
|
||||||
|
"open_link": "Abrir link",
|
||||||
|
"meeting_link": "Link da reunião",
|
||||||
|
"tentative": "Provisório",
|
||||||
|
"cancelled": "Cancelado",
|
||||||
|
"delete_confirm": "Excluir este evento?"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Título",
|
"title": "Título",
|
||||||
"description": "Descrição",
|
"description": "Descrição",
|
||||||
@@ -1230,6 +1242,17 @@
|
|||||||
"until": "Até",
|
"until": "Até",
|
||||||
"occurrences": "{count} repetições"
|
"occurrences": "{count} repetições"
|
||||||
},
|
},
|
||||||
|
"recurrence_scope": {
|
||||||
|
"edit_title": "Editar evento recorrente",
|
||||||
|
"delete_title": "Excluir evento recorrente",
|
||||||
|
"description": "Este é um evento recorrente. Quais eventos você deseja modificar?",
|
||||||
|
"this_event": "Apenas este evento",
|
||||||
|
"this_and_future": "Este evento e os seguintes",
|
||||||
|
"all_events": "Todos os eventos",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"save": "Salvar",
|
||||||
|
"delete": "Excluir"
|
||||||
|
},
|
||||||
"alerts": {
|
"alerts": {
|
||||||
"title": "Lembrete",
|
"title": "Lembrete",
|
||||||
"none": "Sem lembrete",
|
"none": "Sem lembrete",
|
||||||
@@ -1275,17 +1298,24 @@
|
|||||||
"calendar_created": "Calendário criado",
|
"calendar_created": "Calendário criado",
|
||||||
"calendar_deleted": "Calendário excluído",
|
"calendar_deleted": "Calendário excluído",
|
||||||
"event_move_error": "Falha ao mover o evento",
|
"event_move_error": "Falha ao mover o evento",
|
||||||
|
"event_resize_error": "Falha ao redimensionar o evento",
|
||||||
"alert_title": "Evento próximo",
|
"alert_title": "Evento próximo",
|
||||||
"alert_now": "Começa agora",
|
"alert_now": "Começa agora",
|
||||||
"alert_in_minutes": "Em {count} min",
|
"alert_in_minutes": "Em {count} min",
|
||||||
"invitation_sent": "Convites enviados",
|
"invitation_sent": "Convites enviados",
|
||||||
"rsvp_updated": "Resposta atualizada",
|
"rsvp_updated": "Resposta atualizada",
|
||||||
"rsvp_error": "Falha ao atualizar resposta"
|
"rsvp_error": "Falha ao atualizar resposta",
|
||||||
|
"event_duplicated": "Evento duplicado",
|
||||||
|
"event_error": "Falha ao salvar o evento"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"loading_calendars": "Carregando calendários...",
|
"loading_calendars": "Carregando calendários...",
|
||||||
"loading_events": "Carregando eventos..."
|
"loading_events": "Carregando eventos..."
|
||||||
},
|
},
|
||||||
|
"quick_create": {
|
||||||
|
"placeholder": "Título do novo evento",
|
||||||
|
"aria_label": "Criação rápida de evento"
|
||||||
|
},
|
||||||
"nav_prev": "Anterior",
|
"nav_prev": "Anterior",
|
||||||
"nav_next": "Próximo",
|
"nav_next": "Próximo",
|
||||||
"import": {
|
"import": {
|
||||||
|
|||||||
Reference in New Issue
Block a user