@tuturuuu/ui 0.30.2 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/biome.json +1 -1
- package/package.json +95 -49
- package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
- package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
- package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
- package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
- package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
- package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
- package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
- package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
- package/src/components/ui/custom/nav-link.tsx +7 -7
- package/src/components/ui/custom/nav.tsx +15 -63
- package/src/components/ui/custom/notification-card.tsx +361 -0
- package/src/components/ui/custom/notification-list.tsx +178 -0
- package/src/components/ui/custom/notification-popover-client.tsx +12 -780
- package/src/components/ui/custom/notification-runtime.tsx +23 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
- package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
- package/src/components/ui/custom/satellite-brand.tsx +122 -0
- package/src/components/ui/custom/satellite-content.tsx +148 -0
- package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
- package/src/components/ui/custom/satellite-language-item.tsx +26 -0
- package/src/components/ui/custom/satellite-navigation.tsx +86 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
- package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
- package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
- package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
- package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
- package/src/components/ui/custom/satellite-shell.tsx +50 -0
- package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
- package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
- package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
- package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
- package/src/components/ui/custom/sidebar-context.tsx +5 -14
- package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
- package/src/components/ui/custom/structure.tsx +14 -0
- package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
- package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
- package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
- package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
- package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
- package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
- package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
- package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
- package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
- package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
- package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
- package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
- package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
- package/src/components/ui/pwa-status.test.tsx +53 -0
- package/src/components/ui/pwa-status.tsx +113 -0
- package/src/components/ui/report-problem-dialog-content.tsx +350 -0
- package/src/components/ui/report-problem-dialog.tsx +6 -975
- package/src/components/ui/report-problem-model.ts +133 -0
- package/src/components/ui/report-problem-mutation.ts +122 -0
- package/src/components/ui/save-notification.test.tsx +36 -0
- package/src/components/ui/save-notification.tsx +17 -0
- package/src/components/ui/use-report-problem.ts +488 -0
- package/src/declarations.d.ts +2 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
- package/src/hooks/calendar-date-utils.ts +11 -0
- package/src/hooks/calendar-event-lookup.test.ts +77 -0
- package/src/hooks/calendar-event-lookup.ts +47 -0
- package/src/hooks/calendar-range-cache.ts +52 -0
- package/src/hooks/pwa-install-prompt.ts +44 -0
- package/src/hooks/use-calendar-sync.tsx +23 -100
- package/src/hooks/use-calendar.tsx +21 -77
- package/src/hooks/use-notifications.ts +9 -24
- package/src/hooks/use-open-initial-calendar-event.ts +27 -0
- package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
- package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
- package/src/hooks/use-workspace-presence.ts +0 -18
- package/src/lib/tasks-app-url.test.ts +25 -0
- package/src/lib/tasks-app-url.ts +8 -0
|
@@ -1,986 +1,295 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { ArrowLeft, Ellipsis, Plus } from '@tuturuuu/icons';
|
|
4
4
|
import type { Workspace } from '@tuturuuu/types';
|
|
5
5
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
6
|
-
import { Button } from '@tuturuuu/ui/button';
|
|
7
6
|
import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
|
|
8
7
|
import { useCalendarPreferences } from '@tuturuuu/ui/hooks/use-calendar-preferences';
|
|
9
|
-
import { usePopoverManager } from '@tuturuuu/ui/hooks/use-popover-manager';
|
|
10
8
|
import { useUserBooleanConfig } from '@tuturuuu/ui/hooks/use-user-config';
|
|
11
|
-
import {
|
|
12
|
-
HoverCard,
|
|
13
|
-
HoverCardContent,
|
|
14
|
-
HoverCardTrigger,
|
|
15
|
-
} from '@tuturuuu/ui/hover-card';
|
|
16
|
-
import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
|
|
17
9
|
import { isAllDayEvent } from '@tuturuuu/utils/calendar-utils';
|
|
18
10
|
import { cn } from '@tuturuuu/utils/format';
|
|
19
11
|
import { getTimeFormatPattern } from '@tuturuuu/utils/time-helper';
|
|
20
12
|
import {
|
|
13
|
+
addDays,
|
|
21
14
|
eachDayOfInterval,
|
|
22
15
|
endOfMonth,
|
|
23
|
-
|
|
24
|
-
getDay,
|
|
16
|
+
endOfWeek,
|
|
25
17
|
isSameMonth,
|
|
26
18
|
isToday,
|
|
19
|
+
startOfDay,
|
|
27
20
|
startOfMonth,
|
|
21
|
+
startOfWeek,
|
|
28
22
|
} from 'date-fns';
|
|
29
23
|
import dayjs from 'dayjs';
|
|
30
|
-
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
|
31
24
|
import timezone from 'dayjs/plugin/timezone';
|
|
32
|
-
import
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
isSpecialLunarDate,
|
|
38
|
-
} from '../../../../lib/lunar-calendar';
|
|
25
|
+
import utc from 'dayjs/plugin/utc';
|
|
26
|
+
import { useTranslations } from 'next-intl';
|
|
27
|
+
import { useMemo } from 'react';
|
|
28
|
+
import { formatLunarDay, getLunarDate } from '../../../../lib/lunar-calendar';
|
|
29
|
+
import { Button } from '../../button';
|
|
39
30
|
import { Popover, PopoverContent, PopoverTrigger } from '../../popover';
|
|
40
|
-
import {
|
|
31
|
+
import { calendarEventTone } from './calendar-event-tone';
|
|
32
|
+
import { calendarDraftDate } from './calendar-period';
|
|
41
33
|
import { useCalendarSettings } from './settings/settings-context';
|
|
42
34
|
|
|
35
|
+
dayjs.extend(utc);
|
|
43
36
|
dayjs.extend(timezone);
|
|
44
|
-
dayjs.extend(isSameOrAfter);
|
|
45
|
-
|
|
46
37
|
interface MonthCalendarProps {
|
|
47
38
|
date: Date;
|
|
39
|
+
readOnly?: boolean;
|
|
48
40
|
workspace?: Workspace;
|
|
49
41
|
visibleDates?: Date[];
|
|
50
42
|
viewedMonth?: Date;
|
|
51
43
|
locale?: string;
|
|
44
|
+
onDayClick?: (date: Date) => void;
|
|
52
45
|
}
|
|
53
46
|
|
|
54
|
-
|
|
47
|
+
function MonthEvent({
|
|
48
|
+
event,
|
|
49
|
+
day,
|
|
50
|
+
timePattern,
|
|
51
|
+
zone,
|
|
52
|
+
onOpen,
|
|
53
|
+
}: {
|
|
55
54
|
event: CalendarEvent;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return dominantColor;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
// Utility function for scroll shadow classes
|
|
94
|
-
const getScrollShadowClasses = (
|
|
95
|
-
scrollState: { top: boolean; bottom: boolean } | undefined
|
|
96
|
-
) => {
|
|
97
|
-
return cn(
|
|
98
|
-
scrollState?.top &&
|
|
99
|
-
'before:pointer-events-none before:absolute before:top-0 before:right-0 before:left-0 before:h-3 before:bg-linear-to-b before:from-muted/80 before:to-transparent',
|
|
100
|
-
scrollState?.bottom &&
|
|
101
|
-
'after:pointer-events-none after:absolute after:right-0 after:bottom-0 after:left-0 after:h-3 after:bg-linear-to-t after:from-muted/80 after:to-transparent'
|
|
55
|
+
day: Date;
|
|
56
|
+
timePattern: string;
|
|
57
|
+
zone?: string;
|
|
58
|
+
onOpen: (id: string) => void;
|
|
59
|
+
}) {
|
|
60
|
+
const t = useTranslations('calendar');
|
|
61
|
+
const title = event.title || t('views.untitled_event');
|
|
62
|
+
const allDay = isAllDayEvent(event);
|
|
63
|
+
const continued = new Date(event.start_at) < startOfDay(day);
|
|
64
|
+
return (
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={() => onOpen(event.id)}
|
|
68
|
+
title={title}
|
|
69
|
+
className={cn(
|
|
70
|
+
'flex w-full min-w-0 items-center gap-1 rounded px-1 py-1 text-left text-[10px] leading-tight transition-colors hover:brightness-110 focus-visible:outline-2 focus-visible:outline-ring sm:px-1.5 sm:text-xs',
|
|
71
|
+
calendarEventTone(event.color),
|
|
72
|
+
allDay && 'font-medium'
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
{continued ? (
|
|
76
|
+
<ArrowLeft className="size-2.5 shrink-0" />
|
|
77
|
+
) : (
|
|
78
|
+
!allDay && (
|
|
79
|
+
<span className="hidden shrink-0 tabular-nums opacity-70 lg:inline">
|
|
80
|
+
{(zone && zone !== 'auto'
|
|
81
|
+
? dayjs(event.start_at).tz(zone)
|
|
82
|
+
: dayjs(event.start_at)
|
|
83
|
+
).format(timePattern)}
|
|
84
|
+
</span>
|
|
85
|
+
)
|
|
86
|
+
)}
|
|
87
|
+
<span className="truncate">{title}</span>
|
|
88
|
+
</button>
|
|
102
89
|
);
|
|
103
|
-
}
|
|
90
|
+
}
|
|
104
91
|
|
|
105
|
-
export
|
|
92
|
+
export function MonthCalendar({
|
|
106
93
|
date,
|
|
94
|
+
readOnly = false,
|
|
95
|
+
viewedMonth = date,
|
|
107
96
|
visibleDates,
|
|
108
|
-
viewedMonth,
|
|
109
97
|
locale = 'en',
|
|
110
|
-
|
|
111
|
-
|
|
98
|
+
onDayClick,
|
|
99
|
+
}: MonthCalendarProps) {
|
|
100
|
+
const t = useTranslations('calendar');
|
|
101
|
+
const {
|
|
102
|
+
getCurrentEvents,
|
|
103
|
+
addEmptyEvent,
|
|
104
|
+
openModal,
|
|
105
|
+
readOnly: providerReadOnly,
|
|
106
|
+
} = useCalendar();
|
|
107
|
+
const cannotCreate = readOnly || providerReadOnly;
|
|
112
108
|
const { settings } = useCalendarSettings();
|
|
109
|
+
const { timeFormat, weekStartsOn } = useCalendarPreferences();
|
|
113
110
|
const { value: showLunar } = useUserBooleanConfig(
|
|
114
111
|
'SHOW_LUNAR_CALENDAR',
|
|
115
112
|
locale.startsWith('vi')
|
|
116
113
|
);
|
|
117
|
-
const { timeFormat } = useCalendarPreferences();
|
|
118
114
|
const timePattern = getTimeFormatPattern(timeFormat);
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
useEffect(() => {
|
|
139
|
-
setCurrDate(date);
|
|
140
|
-
}, [date]);
|
|
141
|
-
|
|
142
|
-
// Get first day of week from settings or infer from visibleDates
|
|
143
|
-
const firstDayOfWeek = useMemo(() => {
|
|
144
|
-
const settingValue = settings?.appearance?.firstDayOfWeek;
|
|
145
|
-
|
|
146
|
-
// If we have visibleDates, infer first day from the first date (most reliable)
|
|
147
|
-
// The visibleDates from calendar-content are already calculated with the correct first day
|
|
148
|
-
if (visibleDates && visibleDates.length > 0 && visibleDates[0]) {
|
|
149
|
-
return visibleDates[0].getDay();
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Fallback to settings
|
|
153
|
-
if (settingValue === 'sunday') return 0;
|
|
154
|
-
if (settingValue === 'saturday') return 6;
|
|
155
|
-
if (settingValue === 'monday') return 1;
|
|
156
|
-
|
|
157
|
-
// 'auto' or undefined - default to Monday (locale detection happens in calendar-content)
|
|
158
|
-
return 1;
|
|
159
|
-
}, [settings?.appearance?.firstDayOfWeek, visibleDates]);
|
|
160
|
-
|
|
161
|
-
// Get weekday labels based on first day of week
|
|
162
|
-
const weekdayLabels = useMemo(() => {
|
|
163
|
-
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
164
|
-
const reorderedDays = [...days];
|
|
165
|
-
|
|
166
|
-
// Reorder days based on first day of week
|
|
167
|
-
for (let i = 0; i < firstDayOfWeek; i++) {
|
|
168
|
-
const day = reorderedDays.shift();
|
|
169
|
-
if (day) reorderedDays.push(day);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return reorderedDays;
|
|
173
|
-
}, [firstDayOfWeek]);
|
|
174
|
-
|
|
175
|
-
// Get days in month
|
|
176
|
-
const monthStart = startOfMonth(currDate);
|
|
177
|
-
const monthEnd = endOfMonth(currDate);
|
|
178
|
-
const monthDays = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
|
179
|
-
|
|
180
|
-
// Calculate days needed to fill the calendar grid (weeks)
|
|
181
|
-
// Adjust startDay calculation based on first day of week
|
|
182
|
-
const startDay = (getDay(monthStart) - firstDayOfWeek + 7) % 7;
|
|
183
|
-
const endDay = 6 - ((getDay(monthEnd) - firstDayOfWeek + 7) % 7);
|
|
184
|
-
|
|
185
|
-
// Get days from previous month to fill first week
|
|
186
|
-
const prevMonthDays = [];
|
|
187
|
-
for (let i = startDay - 1; i >= 0; i--) {
|
|
188
|
-
const day =
|
|
189
|
-
tz === 'auto'
|
|
190
|
-
? dayjs(monthStart)
|
|
191
|
-
.subtract(i + 1, 'day')
|
|
192
|
-
.toDate()
|
|
193
|
-
: dayjs(monthStart)
|
|
194
|
-
.tz(tz)
|
|
195
|
-
.subtract(i + 1, 'day')
|
|
196
|
-
.toDate();
|
|
197
|
-
prevMonthDays.push(day);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Get days from next month to fill last week
|
|
201
|
-
const nextMonthDays = [];
|
|
202
|
-
for (let i = 0; i < endDay; i++) {
|
|
203
|
-
const day =
|
|
204
|
-
tz === 'auto'
|
|
205
|
-
? dayjs(monthEnd)
|
|
206
|
-
.add(i + 1, 'day')
|
|
207
|
-
.toDate()
|
|
208
|
-
: dayjs(monthEnd)
|
|
209
|
-
.tz(tz)
|
|
210
|
-
.add(i + 1, 'day')
|
|
211
|
-
.toDate();
|
|
212
|
-
nextMonthDays.push(day);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Use visibleDates if provided, otherwise fallback to old logic
|
|
216
|
-
const calendarDays = visibleDates ?? [
|
|
217
|
-
...prevMonthDays,
|
|
218
|
-
...monthDays,
|
|
219
|
-
...nextMonthDays,
|
|
220
|
-
];
|
|
221
|
-
|
|
222
|
-
// Create weeks (group by 7 days)
|
|
223
|
-
const weeks = useMemo(() => {
|
|
224
|
-
const result: Date[][] = [];
|
|
225
|
-
for (let i = 0; i < calendarDays.length; i += 7) {
|
|
226
|
-
result.push(calendarDays.slice(i, i + 7));
|
|
227
|
-
}
|
|
228
|
-
return result;
|
|
229
|
-
}, [calendarDays]);
|
|
230
|
-
|
|
231
|
-
// Calculate multi-day event segments for rendering as spanning bars
|
|
232
|
-
const multiDaySegments = useMemo(() => {
|
|
233
|
-
const segments: MultiDayEventSegment[] = [];
|
|
234
|
-
const processedInWeek = new Map<number, Set<string>>(); // week index -> set of event IDs
|
|
235
|
-
|
|
236
|
-
weeks.forEach((week, rowIndex) => {
|
|
237
|
-
const eventsInWeek = new Set<string>();
|
|
238
|
-
const weekSegments: Array<{
|
|
239
|
-
event: CalendarEvent;
|
|
240
|
-
startCol: number;
|
|
241
|
-
span: number;
|
|
242
|
-
}> = [];
|
|
243
|
-
|
|
244
|
-
// Find all multi-day events that overlap with this week
|
|
245
|
-
week.forEach((day) => {
|
|
246
|
-
const dayEvents = getCurrentEvents(day);
|
|
247
|
-
|
|
248
|
-
dayEvents.forEach((event) => {
|
|
249
|
-
if (!isAllDayEvent(event)) return;
|
|
250
|
-
if (eventsInWeek.has(event.id)) return;
|
|
251
|
-
|
|
252
|
-
const eventStart = dayjs(event.start_at);
|
|
253
|
-
const eventEnd = dayjs(event.end_at);
|
|
254
|
-
const durationDays = eventEnd.diff(eventStart, 'day');
|
|
255
|
-
|
|
256
|
-
// Only process multi-day events (spanning 2+ days)
|
|
257
|
-
if (durationDays <= 1) return;
|
|
258
|
-
|
|
259
|
-
// Find which columns this event spans in this week
|
|
260
|
-
let startCol = -1;
|
|
261
|
-
let endCol = -1;
|
|
262
|
-
|
|
263
|
-
week.forEach((weekDay, weekCol) => {
|
|
264
|
-
const weekDayStart = dayjs(weekDay).startOf('day');
|
|
265
|
-
const isInRange =
|
|
266
|
-
weekDayStart.isSameOrAfter(eventStart, 'day') &&
|
|
267
|
-
weekDayStart.isBefore(eventEnd, 'day');
|
|
268
|
-
|
|
269
|
-
if (isInRange) {
|
|
270
|
-
if (startCol === -1) startCol = weekCol;
|
|
271
|
-
endCol = weekCol;
|
|
272
|
-
}
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
if (startCol !== -1 && endCol !== -1 && endCol >= startCol) {
|
|
276
|
-
weekSegments.push({
|
|
277
|
-
event,
|
|
278
|
-
startCol,
|
|
279
|
-
span: endCol - startCol + 1,
|
|
280
|
-
});
|
|
281
|
-
eventsInWeek.add(event.id);
|
|
282
|
-
}
|
|
283
|
-
});
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
// Assign vertical positions to avoid overlaps
|
|
287
|
-
weekSegments.sort((a, b) => {
|
|
288
|
-
// Sort by start column, then by duration (longer first)
|
|
289
|
-
if (a.startCol !== b.startCol) return a.startCol - b.startCol;
|
|
290
|
-
return b.span - a.span;
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
const positions: number[] = [];
|
|
294
|
-
weekSegments.forEach((seg, idx) => {
|
|
295
|
-
// Simple greedy assignment - find first available position
|
|
296
|
-
let position = 0;
|
|
297
|
-
const usedPositions = new Set(
|
|
298
|
-
weekSegments.slice(0, idx).map((_, i) => {
|
|
299
|
-
const otherSeg = weekSegments[i];
|
|
300
|
-
const overlaps =
|
|
301
|
-
(otherSeg?.startCol || 0) < seg.startCol + seg.span &&
|
|
302
|
-
(otherSeg?.startCol || 0) + (otherSeg?.span || 0) > seg.startCol;
|
|
303
|
-
return overlaps ? positions[i] : -1;
|
|
304
|
-
})
|
|
305
|
-
);
|
|
306
|
-
|
|
307
|
-
while (usedPositions.has(position)) {
|
|
308
|
-
position++;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
positions.push(position);
|
|
312
|
-
segments.push({
|
|
313
|
-
...seg,
|
|
314
|
-
rowIndex,
|
|
315
|
-
position,
|
|
316
|
-
});
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
processedInWeek.set(rowIndex, eventsInWeek);
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
return segments;
|
|
323
|
-
}, [getCurrentEvents, weeks]);
|
|
324
|
-
|
|
325
|
-
// Get single-day all-day events for a day (multi-day events rendered separately as bars)
|
|
326
|
-
const getSingleDayAllDayEvents = useCallback(
|
|
327
|
-
(day: Date) => {
|
|
328
|
-
const dayEvents = getCurrentEvents(day);
|
|
329
|
-
const allDayEvents: CalendarEvent[] = [];
|
|
330
|
-
|
|
331
|
-
dayEvents.forEach((event) => {
|
|
332
|
-
if (isAllDayEvent(event)) {
|
|
333
|
-
const eventStart = dayjs(event.start_at);
|
|
334
|
-
const eventEnd = dayjs(event.end_at);
|
|
335
|
-
const durationDays = eventEnd.diff(eventStart, 'day');
|
|
336
|
-
|
|
337
|
-
// Only include single-day all-day events
|
|
338
|
-
if (durationDays <= 1) {
|
|
339
|
-
allDayEvents.push(event);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
// Sort by start time
|
|
345
|
-
allDayEvents.sort((a, b) => {
|
|
346
|
-
const aStart = new Date(a.start_at).getTime();
|
|
347
|
-
const bStart = new Date(b.start_at).getTime();
|
|
348
|
-
return aStart - bStart;
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
return allDayEvents;
|
|
352
|
-
},
|
|
353
|
-
[getCurrentEvents]
|
|
115
|
+
const firstDay = (visibleDates?.[0]?.getDay() ?? weekStartsOn) as
|
|
116
|
+
| 0
|
|
117
|
+
| 1
|
|
118
|
+
| 2
|
|
119
|
+
| 3
|
|
120
|
+
| 4
|
|
121
|
+
| 5
|
|
122
|
+
| 6;
|
|
123
|
+
const days = useMemo(
|
|
124
|
+
() =>
|
|
125
|
+
visibleDates?.length
|
|
126
|
+
? visibleDates
|
|
127
|
+
: eachDayOfInterval({
|
|
128
|
+
start: startOfWeek(startOfMonth(viewedMonth), {
|
|
129
|
+
weekStartsOn: firstDay,
|
|
130
|
+
}),
|
|
131
|
+
end: endOfWeek(endOfMonth(viewedMonth), { weekStartsOn: firstDay }),
|
|
132
|
+
}),
|
|
133
|
+
[visibleDates, viewedMonth, firstDay]
|
|
354
134
|
);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const getTimedEventsForDay = useCallback(
|
|
358
|
-
(day: Date) => {
|
|
359
|
-
const dayEvents = getCurrentEvents(day);
|
|
360
|
-
const timedEvents: CalendarEvent[] = [];
|
|
361
|
-
|
|
362
|
-
dayEvents.forEach((event) => {
|
|
363
|
-
if (!isAllDayEvent(event)) {
|
|
364
|
-
timedEvents.push(event);
|
|
365
|
-
}
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
// Sort by start time
|
|
369
|
-
timedEvents.sort((a, b) => {
|
|
370
|
-
const aStart = new Date(a.start_at).getTime();
|
|
371
|
-
const bStart = new Date(b.start_at).getTime();
|
|
372
|
-
return aStart - bStart;
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
return timedEvents;
|
|
376
|
-
},
|
|
377
|
-
[getCurrentEvents]
|
|
135
|
+
const weekdays = Array.from({ length: 7 }, (_, index) =>
|
|
136
|
+
addDays(days[0]!, index)
|
|
378
137
|
);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
addEmptyEvent(
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
const formatEventTime = (event: any) => {
|
|
391
|
-
try {
|
|
392
|
-
const start = new Date(event.start_at);
|
|
393
|
-
const end = new Date(event.end_at);
|
|
394
|
-
return `${format(start, timePattern)} - ${format(end, timePattern)}`;
|
|
395
|
-
} catch (_) {
|
|
396
|
-
return '';
|
|
397
|
-
}
|
|
398
|
-
};
|
|
399
|
-
|
|
400
|
-
// Get color styles for an event using dynamic color tokens
|
|
401
|
-
const getEventStyles = (
|
|
402
|
-
event: any
|
|
403
|
-
): { bg: string; text: string; border: string } => {
|
|
404
|
-
const colorMap: Record<
|
|
405
|
-
string,
|
|
406
|
-
{ bg: string; text: string; border: string }
|
|
407
|
-
> = {
|
|
408
|
-
blue: {
|
|
409
|
-
bg: 'bg-dynamic-blue/10',
|
|
410
|
-
text: 'text-dynamic-blue',
|
|
411
|
-
border: 'border-dynamic-blue/30',
|
|
412
|
-
},
|
|
413
|
-
red: {
|
|
414
|
-
bg: 'bg-dynamic-red/10',
|
|
415
|
-
text: 'text-dynamic-red',
|
|
416
|
-
border: 'border-dynamic-red/30',
|
|
417
|
-
},
|
|
418
|
-
green: {
|
|
419
|
-
bg: 'bg-dynamic-green/10',
|
|
420
|
-
text: 'text-dynamic-green',
|
|
421
|
-
border: 'border-dynamic-green/30',
|
|
422
|
-
},
|
|
423
|
-
purple: {
|
|
424
|
-
bg: 'bg-dynamic-purple/10',
|
|
425
|
-
text: 'text-dynamic-purple',
|
|
426
|
-
border: 'border-dynamic-purple/30',
|
|
427
|
-
},
|
|
428
|
-
yellow: {
|
|
429
|
-
bg: 'bg-dynamic-yellow/10',
|
|
430
|
-
text: 'text-dynamic-yellow',
|
|
431
|
-
border: 'border-dynamic-yellow/30',
|
|
432
|
-
},
|
|
433
|
-
orange: {
|
|
434
|
-
bg: 'bg-dynamic-orange/10',
|
|
435
|
-
text: 'text-dynamic-orange',
|
|
436
|
-
border: 'border-dynamic-orange/30',
|
|
437
|
-
},
|
|
438
|
-
pink: {
|
|
439
|
-
bg: 'bg-dynamic-pink/10',
|
|
440
|
-
text: 'text-dynamic-pink',
|
|
441
|
-
border: 'border-dynamic-pink/30',
|
|
442
|
-
},
|
|
443
|
-
cyan: {
|
|
444
|
-
bg: 'bg-dynamic-cyan/10',
|
|
445
|
-
text: 'text-dynamic-cyan',
|
|
446
|
-
border: 'border-dynamic-cyan/30',
|
|
447
|
-
},
|
|
448
|
-
indigo: {
|
|
449
|
-
bg: 'bg-dynamic-indigo/10',
|
|
450
|
-
text: 'text-dynamic-indigo',
|
|
451
|
-
border: 'border-dynamic-indigo/30',
|
|
452
|
-
},
|
|
453
|
-
gray: {
|
|
454
|
-
bg: 'bg-dynamic-gray/10',
|
|
455
|
-
text: 'text-dynamic-gray',
|
|
456
|
-
border: 'border-dynamic-gray/30',
|
|
457
|
-
},
|
|
458
|
-
};
|
|
459
|
-
const normalizedColor = normalizeColor(event.color || 'blue');
|
|
460
|
-
return (
|
|
461
|
-
normalizedColor && colorMap[normalizedColor]
|
|
462
|
-
? colorMap[normalizedColor]
|
|
463
|
-
: colorMap.blue
|
|
464
|
-
) as {
|
|
465
|
-
bg: string;
|
|
466
|
-
text: string;
|
|
467
|
-
border: string;
|
|
468
|
-
};
|
|
138
|
+
const showWeekends = settings?.appearance?.showWeekends !== false;
|
|
139
|
+
const visibleDays = days.filter(
|
|
140
|
+
(day) => showWeekends || ![0, 6].includes(day.getDay())
|
|
141
|
+
);
|
|
142
|
+
const columns = showWeekends ? 7 : 5;
|
|
143
|
+
const rows = Math.ceil(visibleDays.length / columns);
|
|
144
|
+
const addEvent = (day: Date) => {
|
|
145
|
+
if (cannotCreate) return;
|
|
146
|
+
addEmptyEvent(calendarDraftDate(day, settings?.timezone?.timezone));
|
|
469
147
|
};
|
|
470
|
-
|
|
471
|
-
// Memoize dominant color for each day
|
|
472
|
-
const dominantColorForDay = useMemo(() => {
|
|
473
|
-
const map: Record<string, string> = {};
|
|
474
|
-
for (const day of calendarDays) {
|
|
475
|
-
const events = getCurrentEvents(day);
|
|
476
|
-
map[day.toISOString()] = getDominantEventColor(events);
|
|
477
|
-
}
|
|
478
|
-
return map;
|
|
479
|
-
}, [calendarDays, getCurrentEvents]);
|
|
480
|
-
|
|
481
|
-
// Use the custom popover manager hook
|
|
482
|
-
const {
|
|
483
|
-
moreButtonRefs,
|
|
484
|
-
popoverContentRefs,
|
|
485
|
-
openPopoverIdx,
|
|
486
|
-
setOpenPopoverIdx,
|
|
487
|
-
scrollStates,
|
|
488
|
-
setPopoverHovered,
|
|
489
|
-
handlePopoverScroll,
|
|
490
|
-
} = usePopoverManager();
|
|
491
|
-
|
|
492
148
|
return (
|
|
493
|
-
<div
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
</div>
|
|
510
|
-
|
|
511
|
-
<div className="relative">
|
|
512
|
-
{weeks.map((week, weekIndex) => (
|
|
513
|
-
<div key={`week-${weekIndex}`} className="relative">
|
|
514
|
-
{/* Multi-day event bars for this week */}
|
|
515
|
-
<div className="pointer-events-none absolute inset-0 z-10">
|
|
516
|
-
{multiDaySegments
|
|
517
|
-
.filter((seg) => seg.rowIndex === weekIndex)
|
|
518
|
-
.map((segment) => {
|
|
519
|
-
const { event, startCol, span, position } = segment;
|
|
520
|
-
const { bg, text, border } = getEventStyles(event);
|
|
521
|
-
|
|
522
|
-
const eventHeight = LAYOUT_CONSTANTS.EVENT_HEIGHT;
|
|
523
|
-
const dayHeaderHeight = LAYOUT_CONSTANTS.DAY_HEADER_HEIGHT;
|
|
524
|
-
const containerPadding = LAYOUT_CONSTANTS.CONTAINER_PADDING;
|
|
525
|
-
const marginTop = LAYOUT_CONSTANTS.MARGIN_TOP;
|
|
526
|
-
const eventSpacing = LAYOUT_CONSTANTS.EVENT_SPACING;
|
|
527
|
-
|
|
528
|
-
const topOffset =
|
|
529
|
-
dayHeaderHeight +
|
|
530
|
-
containerPadding +
|
|
531
|
-
marginTop +
|
|
532
|
-
position * (eventHeight + eventSpacing);
|
|
533
|
-
|
|
534
|
-
const leftPercent = (startCol / 7) * 100;
|
|
535
|
-
const widthPercent = (span / 7) * 100;
|
|
536
|
-
|
|
537
|
-
return (
|
|
538
|
-
<HoverCard
|
|
539
|
-
key={`segment-${event.id}-${weekIndex}`}
|
|
540
|
-
openDelay={200}
|
|
541
|
-
closeDelay={100}
|
|
542
|
-
>
|
|
543
|
-
<HoverCardTrigger asChild>
|
|
544
|
-
<div
|
|
545
|
-
className={cn(
|
|
546
|
-
'pointer-events-auto absolute cursor-pointer truncate rounded-md border border-l-2 px-2 py-1 font-medium text-xs transition-all hover:shadow-sm',
|
|
547
|
-
bg,
|
|
548
|
-
text,
|
|
549
|
-
border
|
|
550
|
-
)}
|
|
551
|
-
style={{
|
|
552
|
-
left: `calc(${leftPercent}% + ${containerPadding}px)`,
|
|
553
|
-
width: `calc(${widthPercent}% - ${containerPadding * 2}px)`,
|
|
554
|
-
top: `${topOffset}px`,
|
|
555
|
-
height: `${eventHeight}px`,
|
|
556
|
-
lineHeight: `${eventHeight - 8}px`,
|
|
557
|
-
}}
|
|
558
|
-
onClick={() => openModal(event.id)}
|
|
559
|
-
>
|
|
560
|
-
{event.title || 'Untitled event'}
|
|
561
|
-
</div>
|
|
562
|
-
</HoverCardTrigger>
|
|
563
|
-
<HoverCardContent
|
|
564
|
-
side="right"
|
|
565
|
-
align="start"
|
|
566
|
-
className="w-80"
|
|
567
|
-
>
|
|
568
|
-
<div className="space-y-2">
|
|
569
|
-
<h4 className="wrap-break-word line-clamp-2 font-semibold">
|
|
570
|
-
{event.title || 'Untitled event'}
|
|
571
|
-
</h4>
|
|
572
|
-
{event.description && (
|
|
573
|
-
<div
|
|
574
|
-
className="text-muted-foreground text-sm [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-primary/80"
|
|
575
|
-
{...{
|
|
576
|
-
dangerouslySetInnerHTML: {
|
|
577
|
-
__html: event.description,
|
|
578
|
-
},
|
|
579
|
-
}}
|
|
580
|
-
/>
|
|
581
|
-
)}
|
|
582
|
-
<div className="flex items-center text-muted-foreground text-xs">
|
|
583
|
-
<Clock className="mr-1.5 h-3.5 w-3.5" />
|
|
584
|
-
<span>
|
|
585
|
-
All day • {dayjs(event.start_at).format('MMM D')}{' '}
|
|
586
|
-
-{' '}
|
|
587
|
-
{dayjs(event.end_at)
|
|
588
|
-
.subtract(1, 'day')
|
|
589
|
-
.format('MMM D')}
|
|
590
|
-
</span>
|
|
591
|
-
</div>
|
|
592
|
-
</div>
|
|
593
|
-
</HoverCardContent>
|
|
594
|
-
</HoverCard>
|
|
595
|
-
);
|
|
596
|
-
})}
|
|
149
|
+
<div
|
|
150
|
+
className="flex h-full min-h-0 flex-col bg-background"
|
|
151
|
+
data-calendar-view="month"
|
|
152
|
+
>
|
|
153
|
+
<div
|
|
154
|
+
className="grid shrink-0 border-b bg-muted/30"
|
|
155
|
+
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
|
156
|
+
>
|
|
157
|
+
{weekdays
|
|
158
|
+
.filter((day) => showWeekends || ![0, 6].includes(day.getDay()))
|
|
159
|
+
.map((day) => (
|
|
160
|
+
<div
|
|
161
|
+
key={day.getDay()}
|
|
162
|
+
className="px-2 py-3 text-center font-medium text-muted-foreground text-xs"
|
|
163
|
+
>
|
|
164
|
+
{day.toLocaleDateString(locale, { weekday: 'short' })}
|
|
597
165
|
</div>
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
<div
|
|
169
|
+
className="grid min-h-0 flex-1 overflow-auto"
|
|
170
|
+
style={{
|
|
171
|
+
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
172
|
+
gridTemplateRows: `repeat(${rows}, minmax(160px, 1fr))`,
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
{visibleDays.map((day) => {
|
|
176
|
+
const events = [...getCurrentEvents(day)].sort(
|
|
177
|
+
(a, b) =>
|
|
178
|
+
Number(isAllDayEvent(b)) - Number(isAllDayEvent(a)) ||
|
|
179
|
+
+new Date(a.start_at) - +new Date(b.start_at)
|
|
180
|
+
);
|
|
181
|
+
const currentMonth = isSameMonth(day, viewedMonth);
|
|
182
|
+
const label = day.toLocaleDateString(locale, { dateStyle: 'full' });
|
|
183
|
+
return (
|
|
184
|
+
<section
|
|
185
|
+
key={day.toISOString()}
|
|
186
|
+
aria-label={label}
|
|
187
|
+
className={cn(
|
|
188
|
+
'group min-w-0 border-r border-b p-1 sm:p-2',
|
|
189
|
+
!currentMonth && 'bg-muted/35 text-muted-foreground',
|
|
190
|
+
isToday(day) && 'bg-primary/5'
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
<div className="mb-1 flex min-h-7 items-center justify-between gap-1">
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
aria-label={label}
|
|
197
|
+
aria-current={isToday(day) ? 'date' : undefined}
|
|
198
|
+
onClick={() => onDayClick?.(day)}
|
|
199
|
+
className={cn(
|
|
200
|
+
'flex size-7 shrink-0 items-center justify-center rounded-full font-medium text-xs tabular-nums hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring',
|
|
201
|
+
isToday(day) &&
|
|
202
|
+
'bg-primary text-primary-foreground hover:bg-primary/90'
|
|
203
|
+
)}
|
|
204
|
+
>
|
|
205
|
+
{day.getDate()}
|
|
206
|
+
</button>
|
|
207
|
+
{showLunar && (
|
|
208
|
+
<span className="hidden text-[10px] text-muted-foreground sm:inline">
|
|
209
|
+
{formatLunarDay(getLunarDate(day))}
|
|
210
|
+
</span>
|
|
211
|
+
)}
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
disabled={cannotCreate}
|
|
215
|
+
aria-label={t('views.create_on_date', { date: label })}
|
|
216
|
+
onClick={() => addEvent(day)}
|
|
217
|
+
className="hidden size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent focus-visible:opacity-100 sm:flex sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100"
|
|
218
|
+
>
|
|
219
|
+
<Plus className="size-3" />
|
|
220
|
+
</button>
|
|
221
|
+
</div>
|
|
222
|
+
<div className="space-y-1">
|
|
223
|
+
{events.slice(0, 3).map((event) => (
|
|
224
|
+
<MonthEvent
|
|
225
|
+
key={event.id}
|
|
226
|
+
event={event}
|
|
227
|
+
day={day}
|
|
228
|
+
timePattern={timePattern}
|
|
229
|
+
zone={settings?.timezone?.timezone}
|
|
230
|
+
onOpen={openModal}
|
|
231
|
+
/>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
<Popover>
|
|
235
|
+
<PopoverTrigger asChild>
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
className="mt-1 w-full truncate rounded px-1 py-1 text-left text-[10px] text-muted-foreground hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring sm:text-xs"
|
|
239
|
+
aria-label={t('views.open_day', { date: label })}
|
|
634
240
|
>
|
|
635
|
-
<
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
)}
|
|
665
|
-
title={holidayName ?? undefined}
|
|
666
|
-
>
|
|
667
|
-
{formatLunarDay(lunar)}
|
|
668
|
-
</span>
|
|
669
|
-
);
|
|
670
|
-
})()}
|
|
671
|
-
</div>
|
|
672
|
-
<Tooltip>
|
|
673
|
-
<TooltipTrigger asChild>
|
|
674
|
-
<Button
|
|
675
|
-
variant="ghost"
|
|
676
|
-
size="icon"
|
|
677
|
-
className={cn(
|
|
678
|
-
'h-6 w-6 opacity-0 hover:bg-primary/10 hover:opacity-100 focus:opacity-100 group-hover:opacity-100',
|
|
679
|
-
isHidden && 'opacity-0 group-hover:opacity-50'
|
|
680
|
-
)}
|
|
681
|
-
onClick={() => handleAddEvent(day)}
|
|
682
|
-
disabled={isHidden}
|
|
683
|
-
>
|
|
684
|
-
<Plus className="h-3 w-3" />
|
|
685
|
-
</Button>
|
|
686
|
-
</TooltipTrigger>
|
|
687
|
-
<TooltipContent>Add event</TooltipContent>
|
|
688
|
-
</Tooltip>
|
|
689
|
-
</div>
|
|
690
|
-
|
|
691
|
-
{/* Events container with separated sections */}
|
|
692
|
-
<div className="mt-1.5 flex flex-col gap-1">
|
|
693
|
-
{/* Reserve space for multi-day event segments */}
|
|
694
|
-
{multiDaySegments
|
|
695
|
-
.filter((seg) => {
|
|
696
|
-
const week = weeks[seg.rowIndex];
|
|
697
|
-
if (!week) return false;
|
|
698
|
-
const isInRange =
|
|
699
|
-
dayIdx >= seg.startCol &&
|
|
700
|
-
dayIdx < seg.startCol + seg.span;
|
|
701
|
-
return seg.rowIndex === weekIndex && isInRange;
|
|
702
|
-
})
|
|
703
|
-
.sort((a, b) => a.position - b.position)
|
|
704
|
-
.map((seg) => (
|
|
705
|
-
<div
|
|
706
|
-
key={`placeholder-${seg.event.id}-${dayIdx}`}
|
|
707
|
-
className="pointer-events-none opacity-0"
|
|
708
|
-
style={{
|
|
709
|
-
height: `${LAYOUT_CONSTANTS.EVENT_HEIGHT}px`,
|
|
710
|
-
}}
|
|
711
|
-
aria-hidden="true"
|
|
712
|
-
/>
|
|
713
|
-
))}
|
|
714
|
-
|
|
715
|
-
{/* All-day events section */}
|
|
716
|
-
{getSingleDayAllDayEvents(day)
|
|
717
|
-
.slice(0, MAX_ALL_DAY_VISIBLE)
|
|
718
|
-
.map((event) => {
|
|
719
|
-
const { bg, text, border } = getEventStyles(event);
|
|
720
|
-
|
|
721
|
-
return (
|
|
722
|
-
<HoverCard
|
|
723
|
-
key={event.id}
|
|
724
|
-
openDelay={200}
|
|
725
|
-
closeDelay={100}
|
|
726
|
-
>
|
|
727
|
-
<HoverCardTrigger asChild>
|
|
728
|
-
<div
|
|
729
|
-
className={cn(
|
|
730
|
-
'group/event cursor-pointer truncate rounded-md border px-2 py-1 font-medium text-xs transition-all hover:shadow-sm',
|
|
731
|
-
bg,
|
|
732
|
-
text,
|
|
733
|
-
border,
|
|
734
|
-
!isCurrentMonth && 'opacity-50'
|
|
735
|
-
)}
|
|
736
|
-
onClick={() => openModal(event.id)}
|
|
737
|
-
>
|
|
738
|
-
{event.title || 'Untitled'}
|
|
739
|
-
</div>
|
|
740
|
-
</HoverCardTrigger>
|
|
741
|
-
<HoverCardContent
|
|
742
|
-
side="right"
|
|
743
|
-
align="start"
|
|
744
|
-
className="w-80"
|
|
745
|
-
>
|
|
746
|
-
<div className="space-y-2">
|
|
747
|
-
<h4 className="wrap-break-word line-clamp-2 font-semibold">
|
|
748
|
-
{event.title || 'Untitled event'}
|
|
749
|
-
</h4>
|
|
750
|
-
{event.description && (
|
|
751
|
-
<div
|
|
752
|
-
className="text-muted-foreground text-sm [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-primary/80"
|
|
753
|
-
{...{
|
|
754
|
-
dangerouslySetInnerHTML: {
|
|
755
|
-
__html: event.description,
|
|
756
|
-
},
|
|
757
|
-
}}
|
|
758
|
-
/>
|
|
759
|
-
)}
|
|
760
|
-
<div className="flex items-center text-muted-foreground text-xs">
|
|
761
|
-
<Clock className="mr-1.5 h-3.5 w-3.5" />
|
|
762
|
-
<span>All day</span>
|
|
763
|
-
</div>
|
|
764
|
-
</div>
|
|
765
|
-
</HoverCardContent>
|
|
766
|
-
</HoverCard>
|
|
767
|
-
);
|
|
768
|
-
})}
|
|
769
|
-
|
|
770
|
-
{/* Separator between all-day and timed events */}
|
|
771
|
-
{getSingleDayAllDayEvents(day).length > 0 &&
|
|
772
|
-
getTimedEventsForDay(day).length > 0 && (
|
|
773
|
-
<div className="my-0.5 border-border/50 border-t" />
|
|
774
|
-
)}
|
|
775
|
-
|
|
776
|
-
{/* Timed events section */}
|
|
777
|
-
{getTimedEventsForDay(day)
|
|
778
|
-
.slice(0, MAX_TIMED_VISIBLE)
|
|
779
|
-
.map((event) => {
|
|
780
|
-
const { bg, text, border } = getEventStyles(event);
|
|
781
|
-
const eventTime = format(
|
|
782
|
-
new Date(event.start_at),
|
|
783
|
-
'HH:mm'
|
|
784
|
-
);
|
|
785
|
-
|
|
786
|
-
return (
|
|
787
|
-
<HoverCard
|
|
788
|
-
key={event.id}
|
|
789
|
-
openDelay={200}
|
|
790
|
-
closeDelay={100}
|
|
791
|
-
>
|
|
792
|
-
<HoverCardTrigger asChild>
|
|
793
|
-
<div
|
|
794
|
-
className={cn(
|
|
795
|
-
'group/event flex cursor-pointer items-start gap-1.5 rounded-md border px-2 py-1 text-xs transition-all',
|
|
796
|
-
bg,
|
|
797
|
-
text,
|
|
798
|
-
border,
|
|
799
|
-
!isCurrentMonth && 'opacity-50',
|
|
800
|
-
'hover:shadow-sm'
|
|
801
|
-
)}
|
|
802
|
-
onClick={() => openModal(event.id)}
|
|
803
|
-
>
|
|
804
|
-
<span className="shrink-0 font-medium opacity-70">
|
|
805
|
-
{eventTime}
|
|
806
|
-
</span>
|
|
807
|
-
<span className="min-w-0 flex-1 truncate font-medium">
|
|
808
|
-
{event.title || 'Untitled'}
|
|
809
|
-
</span>
|
|
810
|
-
</div>
|
|
811
|
-
</HoverCardTrigger>
|
|
812
|
-
<HoverCardContent
|
|
813
|
-
side="right"
|
|
814
|
-
align="start"
|
|
815
|
-
className="w-80"
|
|
816
|
-
>
|
|
817
|
-
<div className="space-y-2">
|
|
818
|
-
<h4 className="wrap-break-word line-clamp-2 font-semibold">
|
|
819
|
-
{event.title || 'Untitled event'}
|
|
820
|
-
</h4>
|
|
821
|
-
{event.description && (
|
|
822
|
-
<div
|
|
823
|
-
className="text-muted-foreground text-sm [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2 hover:[&_a]:text-primary/80"
|
|
824
|
-
{...{
|
|
825
|
-
dangerouslySetInnerHTML: {
|
|
826
|
-
__html: event.description,
|
|
827
|
-
},
|
|
828
|
-
}}
|
|
829
|
-
/>
|
|
830
|
-
)}
|
|
831
|
-
<div className="flex items-center text-muted-foreground text-xs">
|
|
832
|
-
<Clock className="mr-1.5 h-3.5 w-3.5" />
|
|
833
|
-
<span>{formatEventTime(event)}</span>
|
|
834
|
-
</div>
|
|
835
|
-
</div>
|
|
836
|
-
</HoverCardContent>
|
|
837
|
-
</HoverCard>
|
|
838
|
-
);
|
|
839
|
-
})}
|
|
840
|
-
|
|
841
|
-
{/* Overflow indicator */}
|
|
842
|
-
{(() => {
|
|
843
|
-
const allDayEvents = getSingleDayAllDayEvents(day);
|
|
844
|
-
const timedEvents = getTimedEventsForDay(day);
|
|
845
|
-
const visibleCount =
|
|
846
|
-
Math.min(allDayEvents.length, MAX_ALL_DAY_VISIBLE) +
|
|
847
|
-
Math.min(timedEvents.length, MAX_TIMED_VISIBLE);
|
|
848
|
-
const totalCount =
|
|
849
|
-
allDayEvents.length + timedEvents.length;
|
|
850
|
-
const remainingCount = totalCount - visibleCount;
|
|
851
|
-
|
|
852
|
-
if (remainingCount <= 0) return null;
|
|
853
|
-
|
|
854
|
-
return (
|
|
855
|
-
<Popover
|
|
856
|
-
open={openPopoverIdx === globalDayIdx}
|
|
857
|
-
onOpenChange={(open) =>
|
|
858
|
-
setOpenPopoverIdx(open ? globalDayIdx : null)
|
|
859
|
-
}
|
|
860
|
-
>
|
|
861
|
-
<PopoverTrigger asChild>
|
|
862
|
-
<button
|
|
863
|
-
type="button"
|
|
864
|
-
ref={(el) => {
|
|
865
|
-
moreButtonRefs.current[globalDayIdx] = el;
|
|
866
|
-
}}
|
|
867
|
-
className={cn(
|
|
868
|
-
'w-full rounded-md bg-muted px-2 py-1 font-medium text-muted-foreground text-xs transition-colors hover:bg-muted/80',
|
|
869
|
-
!isCurrentMonth && 'opacity-50'
|
|
870
|
-
)}
|
|
871
|
-
onClick={() => setOpenPopoverIdx(globalDayIdx)}
|
|
872
|
-
>
|
|
873
|
-
+{remainingCount} more
|
|
874
|
-
</button>
|
|
875
|
-
</PopoverTrigger>
|
|
876
|
-
<PopoverContent
|
|
877
|
-
align="start"
|
|
878
|
-
className={cn(
|
|
879
|
-
'relative max-h-64 overflow-y-auto p-2 transition-none!',
|
|
880
|
-
getScrollShadowClasses(
|
|
881
|
-
scrollStates[globalDayIdx]
|
|
882
|
-
)
|
|
883
|
-
)}
|
|
884
|
-
style={{
|
|
885
|
-
width:
|
|
886
|
-
moreButtonRefs.current[globalDayIdx]
|
|
887
|
-
?.offsetWidth || undefined,
|
|
888
|
-
}}
|
|
889
|
-
>
|
|
890
|
-
<div
|
|
891
|
-
className="flex flex-col gap-1.5"
|
|
892
|
-
onScroll={(e) =>
|
|
893
|
-
handlePopoverScroll(e, globalDayIdx)
|
|
894
|
-
}
|
|
895
|
-
ref={(el) => {
|
|
896
|
-
popoverContentRefs.current[globalDayIdx] = el;
|
|
897
|
-
}}
|
|
898
|
-
onMouseEnter={() =>
|
|
899
|
-
setPopoverHovered((prev) => ({
|
|
900
|
-
...prev,
|
|
901
|
-
[globalDayIdx]: true,
|
|
902
|
-
}))
|
|
903
|
-
}
|
|
904
|
-
onMouseLeave={() =>
|
|
905
|
-
setPopoverHovered((prev) => ({
|
|
906
|
-
...prev,
|
|
907
|
-
[globalDayIdx]: false,
|
|
908
|
-
}))
|
|
909
|
-
}
|
|
910
|
-
>
|
|
911
|
-
{/* Overflow all-day events */}
|
|
912
|
-
{allDayEvents
|
|
913
|
-
.slice(MAX_ALL_DAY_VISIBLE)
|
|
914
|
-
.map((event) => {
|
|
915
|
-
const { bg, text, border } =
|
|
916
|
-
getEventStyles(event);
|
|
917
|
-
return (
|
|
918
|
-
<div
|
|
919
|
-
key={event.id}
|
|
920
|
-
className={cn(
|
|
921
|
-
'cursor-pointer truncate rounded-md border px-2 py-1 font-medium text-xs',
|
|
922
|
-
bg,
|
|
923
|
-
text,
|
|
924
|
-
border,
|
|
925
|
-
!isCurrentMonth && 'opacity-50'
|
|
926
|
-
)}
|
|
927
|
-
onClick={() => openModal(event.id)}
|
|
928
|
-
>
|
|
929
|
-
{event.title || 'Untitled'}
|
|
930
|
-
</div>
|
|
931
|
-
);
|
|
932
|
-
})}
|
|
933
|
-
|
|
934
|
-
{/* Separator in popover if needed */}
|
|
935
|
-
{allDayEvents.length > MAX_ALL_DAY_VISIBLE &&
|
|
936
|
-
timedEvents.length > MAX_TIMED_VISIBLE && (
|
|
937
|
-
<div className="my-0.5 border-border/50 border-t" />
|
|
938
|
-
)}
|
|
939
|
-
|
|
940
|
-
{/* Overflow timed events */}
|
|
941
|
-
{timedEvents
|
|
942
|
-
.slice(MAX_TIMED_VISIBLE)
|
|
943
|
-
.map((event) => {
|
|
944
|
-
const { bg, text, border } =
|
|
945
|
-
getEventStyles(event);
|
|
946
|
-
const eventTime = format(
|
|
947
|
-
new Date(event.start_at),
|
|
948
|
-
'HH:mm'
|
|
949
|
-
);
|
|
950
|
-
return (
|
|
951
|
-
<div
|
|
952
|
-
key={event.id}
|
|
953
|
-
className={cn(
|
|
954
|
-
'flex cursor-pointer items-start gap-1.5 rounded-md border px-2 py-1 text-xs',
|
|
955
|
-
bg,
|
|
956
|
-
text,
|
|
957
|
-
border,
|
|
958
|
-
!isCurrentMonth && 'opacity-50'
|
|
959
|
-
)}
|
|
960
|
-
onClick={() => openModal(event.id)}
|
|
961
|
-
>
|
|
962
|
-
<span className="shrink-0 font-medium opacity-70">
|
|
963
|
-
{eventTime}
|
|
964
|
-
</span>
|
|
965
|
-
<span className="min-w-0 flex-1 truncate font-medium">
|
|
966
|
-
{event.title || 'Untitled'}
|
|
967
|
-
</span>
|
|
968
|
-
</div>
|
|
969
|
-
);
|
|
970
|
-
})}
|
|
971
|
-
</div>
|
|
972
|
-
</PopoverContent>
|
|
973
|
-
</Popover>
|
|
974
|
-
);
|
|
975
|
-
})()}
|
|
976
|
-
</div>
|
|
241
|
+
<span className="sm:hidden" aria-hidden="true">
|
|
242
|
+
{events.length > 3 ? (
|
|
243
|
+
`+${events.length - 3}`
|
|
244
|
+
) : (
|
|
245
|
+
<Ellipsis className="size-3.5" />
|
|
246
|
+
)}
|
|
247
|
+
</span>
|
|
248
|
+
<span className="hidden sm:inline">
|
|
249
|
+
{events.length > 3
|
|
250
|
+
? t('views.more_events', { count: events.length - 3 })
|
|
251
|
+
: t('views.open_day_short')}
|
|
252
|
+
</span>
|
|
253
|
+
</button>
|
|
254
|
+
</PopoverTrigger>
|
|
255
|
+
<PopoverContent
|
|
256
|
+
className="max-h-[70dvh] w-80 max-w-[calc(100vw-2rem)] space-y-3 overflow-auto"
|
|
257
|
+
align="start"
|
|
258
|
+
>
|
|
259
|
+
<div className="flex items-center justify-between gap-2">
|
|
260
|
+
<h3 className="font-semibold text-sm">{label}</h3>
|
|
261
|
+
<Button
|
|
262
|
+
size="icon"
|
|
263
|
+
variant="ghost"
|
|
264
|
+
disabled={cannotCreate}
|
|
265
|
+
aria-label={t('views.create_on_date', { date: label })}
|
|
266
|
+
onClick={() => addEvent(day)}
|
|
267
|
+
>
|
|
268
|
+
<Plus className="size-4" />
|
|
269
|
+
</Button>
|
|
977
270
|
</div>
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
271
|
+
{events.length ? (
|
|
272
|
+
events.map((event) => (
|
|
273
|
+
<MonthEvent
|
|
274
|
+
key={event.id}
|
|
275
|
+
event={event}
|
|
276
|
+
day={day}
|
|
277
|
+
timePattern={timePattern}
|
|
278
|
+
zone={settings?.timezone?.timezone}
|
|
279
|
+
onOpen={openModal}
|
|
280
|
+
/>
|
|
281
|
+
))
|
|
282
|
+
) : (
|
|
283
|
+
<p className="text-muted-foreground text-sm">
|
|
284
|
+
{t('views.no_events')}
|
|
285
|
+
</p>
|
|
286
|
+
)}
|
|
287
|
+
</PopoverContent>
|
|
288
|
+
</Popover>
|
|
289
|
+
</section>
|
|
290
|
+
);
|
|
291
|
+
})}
|
|
983
292
|
</div>
|
|
984
293
|
</div>
|
|
985
294
|
);
|
|
986
|
-
}
|
|
295
|
+
}
|