@valentinkolb/cloud 0.3.1 → 0.5.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/package.json +18 -8
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +119 -47
- package/src/_internal/runtime-context.ts +1 -0
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +113 -10
- package/src/api/index.ts +15 -25
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +4 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +4 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +64 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +49 -0
- package/src/shared/redirect.ts +52 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
|
@@ -0,0 +1,1291 @@
|
|
|
1
|
+
import { dates as calendar, type DateContext } from "@valentinkolb/stdlib";
|
|
2
|
+
import type { Accessor, JSX } from "solid-js";
|
|
3
|
+
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
|
|
4
|
+
import SegmentedControl from "../input/SegmentedControl";
|
|
5
|
+
|
|
6
|
+
export type CalendarView = "day" | "week" | "month" | "year" | "mobile-month";
|
|
7
|
+
|
|
8
|
+
export type CalendarEventColor = "blue" | "emerald" | "amber" | "red" | "violet" | "cyan" | "zinc";
|
|
9
|
+
|
|
10
|
+
export type CalendarEvent = {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
start: Date | string;
|
|
14
|
+
end?: Date | string;
|
|
15
|
+
allDay?: boolean;
|
|
16
|
+
color?: CalendarEventColor;
|
|
17
|
+
colorHex?: string;
|
|
18
|
+
href?: string;
|
|
19
|
+
dataSpaceItemId?: string;
|
|
20
|
+
meta?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
display?: "event" | "background";
|
|
23
|
+
location?: string;
|
|
24
|
+
calendarName?: string;
|
|
25
|
+
attendees?: CalendarAttendee[];
|
|
26
|
+
resources?: CalendarResource[];
|
|
27
|
+
recurrence?: CalendarRecurrence;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type CalendarAttendee = {
|
|
31
|
+
name: string;
|
|
32
|
+
status?: "accepted" | "declined" | "tentative" | "needs-action";
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type CalendarResource = {
|
|
36
|
+
name: string;
|
|
37
|
+
kind?: "room" | "equipment" | "link" | "other";
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type CalendarRecurrence = {
|
|
41
|
+
rrule: string;
|
|
42
|
+
exdate?: Array<Date | string>;
|
|
43
|
+
recurrenceId?: Date | string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type CalendarLabels = Partial<{
|
|
47
|
+
today: string;
|
|
48
|
+
day: string;
|
|
49
|
+
week: string;
|
|
50
|
+
month: string;
|
|
51
|
+
year: string;
|
|
52
|
+
allDay: string;
|
|
53
|
+
noEvents: string;
|
|
54
|
+
previous: string;
|
|
55
|
+
next: string;
|
|
56
|
+
}>;
|
|
57
|
+
|
|
58
|
+
export type CalendarEventRenderContext = {
|
|
59
|
+
compact: boolean;
|
|
60
|
+
fill: boolean;
|
|
61
|
+
start: Date;
|
|
62
|
+
end: Date;
|
|
63
|
+
allDay: boolean;
|
|
64
|
+
durationHours: number;
|
|
65
|
+
timeLabel: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type CalendarProps = {
|
|
69
|
+
date: Date | string;
|
|
70
|
+
events: CalendarEvent[];
|
|
71
|
+
view?: CalendarView;
|
|
72
|
+
views?: CalendarView[];
|
|
73
|
+
labels?: CalendarLabels;
|
|
74
|
+
/** stdlib date context used for timezone-aware rendering and calendar math. */
|
|
75
|
+
dateConfig?: DateContext;
|
|
76
|
+
/** Convenience override for dateConfig.timeZone. */
|
|
77
|
+
timeZone?: string;
|
|
78
|
+
firstDayOfWeek?: 0 | 1;
|
|
79
|
+
withWeekNumbers?: boolean;
|
|
80
|
+
startHour?: number;
|
|
81
|
+
endHour?: number;
|
|
82
|
+
visibleStartHour?: number;
|
|
83
|
+
visibleEndHour?: number;
|
|
84
|
+
allDayMaxHeightRem?: number;
|
|
85
|
+
hideAllDay?: boolean;
|
|
86
|
+
selectedDate?: Date | string;
|
|
87
|
+
selectedEventId?: string;
|
|
88
|
+
dayBadges?: Record<string, CalendarDayBadge>;
|
|
89
|
+
getViewHref?: (view: CalendarView) => string;
|
|
90
|
+
getDateHref?: (date: Date, view: CalendarView) => string;
|
|
91
|
+
getEventHref?: (event: CalendarEvent) => string | undefined;
|
|
92
|
+
renderEvent?: (event: CalendarEvent, context: CalendarEventRenderContext) => JSX.Element;
|
|
93
|
+
onViewChange?: (view: CalendarView) => void;
|
|
94
|
+
onDateChange?: (date: Date, view: CalendarView) => void;
|
|
95
|
+
onEventClick?: (event: CalendarEvent) => void;
|
|
96
|
+
onEventDrop?: (event: CalendarEvent, next: CalendarEventTimeChange) => void;
|
|
97
|
+
onEventResize?: (event: CalendarEvent, next: CalendarEventTimeChange) => void;
|
|
98
|
+
onEventDoubleClick?: (event: CalendarEvent) => void;
|
|
99
|
+
onSlotClick?: (slot: CalendarEventTimeChange) => void;
|
|
100
|
+
onSlotDoubleClick?: (slot: CalendarEventTimeChange) => void;
|
|
101
|
+
class?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type CalendarEventTimeChange = {
|
|
105
|
+
start: Date;
|
|
106
|
+
end: Date;
|
|
107
|
+
allDay?: boolean;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export type CalendarDayBadge = {
|
|
111
|
+
icon?: string;
|
|
112
|
+
label: string;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
type NormalizedEvent = CalendarEvent & {
|
|
116
|
+
startDate: Date;
|
|
117
|
+
endDate: Date;
|
|
118
|
+
dayKey: string;
|
|
119
|
+
sourceStartDate: Date;
|
|
120
|
+
sourceEndDate: Date;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
type CalendarPreview = CalendarEventTimeChange & {
|
|
124
|
+
id: string;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
type TimedEventLayout = {
|
|
128
|
+
event: NormalizedEvent;
|
|
129
|
+
lane: number;
|
|
130
|
+
lanes: number;
|
|
131
|
+
groupId: number;
|
|
132
|
+
groupStartDate: Date;
|
|
133
|
+
groupEndDate: Date;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
type TimedOverflowLayout = {
|
|
137
|
+
groupId: number;
|
|
138
|
+
hiddenEvents: NormalizedEvent[];
|
|
139
|
+
groupStartDate: Date;
|
|
140
|
+
groupEndDate: Date;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const labels: Required<CalendarLabels> = {
|
|
144
|
+
today: "Today",
|
|
145
|
+
day: "Day",
|
|
146
|
+
week: "Week",
|
|
147
|
+
month: "Month",
|
|
148
|
+
year: "Year",
|
|
149
|
+
allDay: "All day",
|
|
150
|
+
noEvents: "No events",
|
|
151
|
+
previous: "Previous",
|
|
152
|
+
next: "Next",
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const colorClass: Record<CalendarEventColor, string> = {
|
|
156
|
+
blue: "border-l-blue-500 text-blue-700 dark:border-l-blue-400 dark:text-blue-200",
|
|
157
|
+
emerald: "border-l-emerald-500 text-emerald-700 dark:border-l-emerald-400 dark:text-emerald-200",
|
|
158
|
+
amber: "border-l-amber-500 text-amber-700 dark:border-l-amber-400 dark:text-amber-200",
|
|
159
|
+
red: "border-l-red-500 text-red-700 dark:border-l-red-400 dark:text-red-200",
|
|
160
|
+
violet: "border-l-violet-500 text-violet-700 dark:border-l-violet-400 dark:text-violet-200",
|
|
161
|
+
cyan: "border-l-cyan-500 text-cyan-700 dark:border-l-cyan-400 dark:text-cyan-200",
|
|
162
|
+
zinc: "border-l-zinc-400 text-zinc-700 dark:border-l-zinc-500 dark:text-zinc-200",
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const dotClass: Record<CalendarEventColor, string> = {
|
|
166
|
+
blue: "bg-blue-500",
|
|
167
|
+
emerald: "bg-emerald-500",
|
|
168
|
+
amber: "bg-amber-500",
|
|
169
|
+
red: "bg-red-500",
|
|
170
|
+
violet: "bg-violet-500",
|
|
171
|
+
cyan: "bg-cyan-500",
|
|
172
|
+
zinc: "bg-zinc-400",
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const ownerDateConfig = (owner: CalendarProps): DateContext => ({
|
|
176
|
+
...owner.dateConfig,
|
|
177
|
+
timeZone: owner.timeZone ?? owner.dateConfig?.timeZone,
|
|
178
|
+
firstDayOfWeek: owner.firstDayOfWeek ?? owner.dateConfig?.firstDayOfWeek ?? owner.dateConfig?.weekStartsOn ?? 1,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const yearIndicatorClass = (date: Date, color: CalendarEventColor, context?: DateContext): string =>
|
|
182
|
+
calendar.isToday(date, context) ? "bg-white" : dotClass[color];
|
|
183
|
+
let activeDraggedEventId = "";
|
|
184
|
+
|
|
185
|
+
const parseDate = (value: Date | string): Date => {
|
|
186
|
+
if (value instanceof Date) return new Date(value);
|
|
187
|
+
const normalized = value.includes("T") ? value : value.replace(" ", "T");
|
|
188
|
+
const parsed = new Date(normalized);
|
|
189
|
+
if (!Number.isNaN(parsed.getTime())) return parsed;
|
|
190
|
+
const [year, month = "1", day = "1"] = value.split("-");
|
|
191
|
+
return new Date(Number(year), Number(month) - 1, Number(day), 12);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const weekNumber = (date: Date): number => {
|
|
195
|
+
const target = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
196
|
+
const day = target.getUTCDay() || 7;
|
|
197
|
+
target.setUTCDate(target.getUTCDate() + 4 - day);
|
|
198
|
+
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
|
|
199
|
+
return Math.ceil(((target.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
|
200
|
+
};
|
|
201
|
+
const zonedWeekNumber = (date: Date, context?: DateContext): number => {
|
|
202
|
+
if (!context?.timeZone) return weekNumber(date);
|
|
203
|
+
const [year = "1970", month = "1", day = "1"] = calendar.formatDateKey(date, context).split("-");
|
|
204
|
+
return weekNumber(new Date(Date.UTC(Number(year), Number(month) - 1, Number(day))));
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const formatTime = (date: Date, context?: DateContext): string => calendar.formatTime(date, context);
|
|
208
|
+
const formatDay = (date: Date, context?: DateContext): string =>
|
|
209
|
+
date.toLocaleDateString(context?.locale ?? "en", { weekday: "short", day: "numeric", timeZone: context?.timeZone });
|
|
210
|
+
const formatMonth = (date: Date, context?: DateContext): string => calendar.formatMonthYear(date, context);
|
|
211
|
+
const zonedYearMonth = (date: Date, context?: DateContext): { year: number; month: number } => {
|
|
212
|
+
const [year = "1970", month = "1"] = calendar.formatDateKey(date, context).split("-");
|
|
213
|
+
return { year: Number(year), month: Number(month) - 1 };
|
|
214
|
+
};
|
|
215
|
+
const zonedMonthDate = (year: number, month: number, context?: DateContext): Date => {
|
|
216
|
+
const value = `${year}-${String(month + 1).padStart(2, "0")}-01T12:00`;
|
|
217
|
+
if (!context?.timeZone) return parseDate(value);
|
|
218
|
+
return new Date(calendar.zonedDateTimeToInstant(value, context.timeZone, { disambiguation: "compatible" }));
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const startOfDay = (date: Date, context?: DateContext): Date => calendar.startOfDay(date, context);
|
|
222
|
+
const endOfDay = (date: Date, context?: DateContext): Date => calendar.endOfDay(date, context);
|
|
223
|
+
const isStartOfDay = (date: Date, context?: DateContext): boolean => date.getTime() === startOfDay(date, context).getTime();
|
|
224
|
+
const addMinutes = (date: Date, minutes: number): Date => new Date(date.getTime() + minutes * 60 * 1000);
|
|
225
|
+
const zonedHour = (date: Date, context?: DateContext): number => {
|
|
226
|
+
if (!context?.timeZone) return date.getHours() + date.getMinutes() / 60;
|
|
227
|
+
const value = calendar.instantToZonedInput(date, context.timeZone);
|
|
228
|
+
return Number(value.slice(11, 13)) + Number(value.slice(14, 16)) / 60;
|
|
229
|
+
};
|
|
230
|
+
const zonedSlot = (day: Date, hour: number, context?: DateContext): Date => {
|
|
231
|
+
const value = `${calendar.formatDateKey(day, context)}T${String(hour).padStart(2, "0")}:00`;
|
|
232
|
+
if (!context?.timeZone) return parseDate(value);
|
|
233
|
+
return new Date(calendar.zonedDateTimeToInstant(value, context.timeZone, { disambiguation: "compatible" }));
|
|
234
|
+
};
|
|
235
|
+
const roundToMinutes = (date: Date, minutes: number): Date => {
|
|
236
|
+
const next = new Date(date);
|
|
237
|
+
const rounded = Math.round(next.getMinutes() / minutes) * minutes;
|
|
238
|
+
next.setMinutes(rounded, 0, 0);
|
|
239
|
+
return next;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const normalizeEvents = (events: CalendarEvent[], context?: DateContext): NormalizedEvent[] =>
|
|
243
|
+
events.flatMap((event) => {
|
|
244
|
+
const startDate = parseDate(event.start);
|
|
245
|
+
const endDate = event.end ? parseDate(event.end) : new Date(startDate.getTime() + 60 * 60 * 1000);
|
|
246
|
+
const duration = Math.max(60 * 60 * 1000, endDate.getTime() - startDate.getTime());
|
|
247
|
+
const rangeEnd = endDate > startDate ? endDate : new Date(startDate.getTime() + duration);
|
|
248
|
+
const startKey = calendar.formatDateKey(startDate, context);
|
|
249
|
+
const endKey = calendar.formatDateKey(rangeEnd, context);
|
|
250
|
+
if (!event.allDay && startKey === endKey) {
|
|
251
|
+
return [
|
|
252
|
+
{
|
|
253
|
+
...event,
|
|
254
|
+
startDate,
|
|
255
|
+
endDate: rangeEnd,
|
|
256
|
+
sourceStartDate: startDate,
|
|
257
|
+
sourceEndDate: rangeEnd,
|
|
258
|
+
dayKey: startKey,
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
const lastDay =
|
|
263
|
+
event.allDay && isStartOfDay(rangeEnd, context)
|
|
264
|
+
? calendar.addDays(startOfDay(rangeEnd, context), -1, context)
|
|
265
|
+
: startOfDay(rangeEnd, context);
|
|
266
|
+
const days: NormalizedEvent[] = [];
|
|
267
|
+
for (let day = startOfDay(startDate, context); day <= lastDay; day = calendar.addDays(day, 1, context)) {
|
|
268
|
+
const segmentStart = day.getTime() === startOfDay(startDate, context).getTime() ? startDate : startOfDay(day, context);
|
|
269
|
+
const segmentEnd = day.getTime() === startOfDay(rangeEnd, context).getTime() ? rangeEnd : endOfDay(day, context);
|
|
270
|
+
days.push({
|
|
271
|
+
...event,
|
|
272
|
+
startDate: segmentStart,
|
|
273
|
+
endDate: segmentEnd,
|
|
274
|
+
sourceStartDate: startDate,
|
|
275
|
+
sourceEndDate: rangeEnd,
|
|
276
|
+
dayKey: calendar.formatDateKey(day, context),
|
|
277
|
+
allDay: event.allDay,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return days;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const eventHref = (props: CalendarProps, event: CalendarEvent): string | undefined => props.getEventHref?.(event) ?? event.href;
|
|
284
|
+
|
|
285
|
+
const draggedEventId = (event: DragEvent): string =>
|
|
286
|
+
activeDraggedEventId || event.dataTransfer?.getData("application/x-calendar-event") || event.dataTransfer?.getData("text/plain") || "";
|
|
287
|
+
|
|
288
|
+
const moveEventTo = (event: NormalizedEvent, target: Date, allDay = false, context?: DateContext): CalendarEventTimeChange => {
|
|
289
|
+
const duration = Math.max(30 * 60 * 1000, event.sourceEndDate.getTime() - event.sourceStartDate.getTime());
|
|
290
|
+
const start = allDay ? startOfDay(target, context) : new Date(target);
|
|
291
|
+
start.setSeconds(0, 0);
|
|
292
|
+
return { start, end: new Date(start.getTime() + duration), allDay };
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const previewSegments = (preview: CalendarPreview | null, days: Date[], context?: DateContext): NormalizedEvent[] =>
|
|
296
|
+
preview
|
|
297
|
+
? normalizeEvents(
|
|
298
|
+
[
|
|
299
|
+
{
|
|
300
|
+
id: `preview-${preview.id}`,
|
|
301
|
+
title: "Preview",
|
|
302
|
+
start: preview.start,
|
|
303
|
+
end: preview.end,
|
|
304
|
+
allDay: preview.allDay,
|
|
305
|
+
color: "blue",
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
context,
|
|
309
|
+
).filter((event) => days.some((day) => event.dayKey === calendar.formatDateKey(day, context)))
|
|
310
|
+
: [];
|
|
311
|
+
|
|
312
|
+
const timedEventLayouts = (events: NormalizedEvent[]): TimedEventLayout[] => {
|
|
313
|
+
const sorted = [...events].sort((a, b) => a.startDate.getTime() - b.startDate.getTime() || b.endDate.getTime() - a.endDate.getTime());
|
|
314
|
+
const groups: NormalizedEvent[][] = [];
|
|
315
|
+
let currentGroup: NormalizedEvent[] = [];
|
|
316
|
+
let currentGroupEnd = 0;
|
|
317
|
+
|
|
318
|
+
for (const event of sorted) {
|
|
319
|
+
const start = event.startDate.getTime();
|
|
320
|
+
const end = event.endDate.getTime();
|
|
321
|
+
if (currentGroup.length === 0 || start < currentGroupEnd) {
|
|
322
|
+
currentGroup.push(event);
|
|
323
|
+
currentGroupEnd = Math.max(currentGroupEnd, end);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
groups.push(currentGroup);
|
|
327
|
+
currentGroup = [event];
|
|
328
|
+
currentGroupEnd = end;
|
|
329
|
+
}
|
|
330
|
+
if (currentGroup.length > 0) groups.push(currentGroup);
|
|
331
|
+
|
|
332
|
+
return groups.flatMap((group, groupId) => {
|
|
333
|
+
const laneEnds: number[] = [];
|
|
334
|
+
const assigned = group.map((event) => {
|
|
335
|
+
const start = event.startDate.getTime();
|
|
336
|
+
const lane = laneEnds.findIndex((end) => end <= start);
|
|
337
|
+
const nextLane = lane >= 0 ? lane : laneEnds.length;
|
|
338
|
+
laneEnds[nextLane] = event.endDate.getTime();
|
|
339
|
+
return { event, lane: nextLane };
|
|
340
|
+
});
|
|
341
|
+
const lanes = Math.max(1, laneEnds.length);
|
|
342
|
+
const groupStartDate = new Date(Math.min(...group.map((event) => event.startDate.getTime())));
|
|
343
|
+
const groupEndDate = new Date(Math.max(...group.map((event) => event.endDate.getTime())));
|
|
344
|
+
return assigned.map((item) => ({ ...item, lanes, groupId, groupStartDate, groupEndDate }));
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const EventChip = (props: {
|
|
349
|
+
event: NormalizedEvent;
|
|
350
|
+
owner: CalendarProps;
|
|
351
|
+
href?: string;
|
|
352
|
+
compact?: boolean;
|
|
353
|
+
fill?: boolean;
|
|
354
|
+
}): JSX.Element => {
|
|
355
|
+
const dateConfig = () => ownerDateConfig(props.owner);
|
|
356
|
+
const color = () => props.event.color ?? "blue";
|
|
357
|
+
const style = () => (props.event.colorHex ? { "border-left-color": props.event.colorHex } : undefined);
|
|
358
|
+
const selected = () => props.owner.selectedEventId === props.event.id;
|
|
359
|
+
const className = () =>
|
|
360
|
+
`block min-w-0 rounded-lg border border-l-[3px] border-zinc-200 bg-white text-left leading-tight [box-shadow:var(--theme-bevel-top),var(--theme-bevel-bottom)] transition-[background-color,border-color,box-shadow] dark:border-zinc-700/70 dark:bg-zinc-900 ${props.compact ? "px-1 py-1" : "px-2 py-1.5"} ${props.fill ? "h-full" : ""} ${props.owner.onEventDrop ? "cursor-grab active:cursor-grabbing" : ""} ${props.event.display === "background" ? "opacity-60" : ""} ${props.event.colorHex ? "text-primary" : colorClass[color()]} ${selected() ? "border-blue-400 bg-blue-50 shadow-[inset_0_0_0_1px_rgb(59_130_246_/_0.26)] dark:border-blue-400 dark:bg-blue-950/45 dark:shadow-[inset_0_0_0_1px_rgb(96_165_250_/_0.30)]" : "hover:border-blue-500/40 hover:bg-blue-500/[0.04] dark:hover:border-blue-400/45 dark:hover:bg-blue-400/[0.06]"}`;
|
|
361
|
+
const durationHours = () => (props.event.endDate.getTime() - props.event.startDate.getTime()) / 3_600_000;
|
|
362
|
+
const showTime = () => !props.event.allDay && !props.compact && durationHours() >= 0.75;
|
|
363
|
+
const showLocation = () => Boolean(props.event.location && !props.compact && durationHours() >= 1.25);
|
|
364
|
+
const isInteractive = () => Boolean(props.owner.onEventClick || props.owner.onEventDoubleClick);
|
|
365
|
+
const timeLabel = () => `${formatTime(props.event.startDate, dateConfig())} - ${formatTime(props.event.endDate, dateConfig())}`;
|
|
366
|
+
const renderedEvent = () =>
|
|
367
|
+
props.owner.renderEvent?.(props.event, {
|
|
368
|
+
compact: props.compact ?? false,
|
|
369
|
+
fill: props.fill ?? false,
|
|
370
|
+
start: props.event.startDate,
|
|
371
|
+
end: props.event.endDate,
|
|
372
|
+
allDay: props.event.allDay ?? false,
|
|
373
|
+
durationHours: durationHours(),
|
|
374
|
+
timeLabel: timeLabel(),
|
|
375
|
+
});
|
|
376
|
+
const dragProps = () =>
|
|
377
|
+
props.owner.onEventDrop
|
|
378
|
+
? {
|
|
379
|
+
draggable: true,
|
|
380
|
+
onDragStart: (event: DragEvent) => {
|
|
381
|
+
activeDraggedEventId = props.event.id;
|
|
382
|
+
event.dataTransfer?.setData("application/x-calendar-event", props.event.id);
|
|
383
|
+
event.dataTransfer?.setData("text/plain", props.event.id);
|
|
384
|
+
},
|
|
385
|
+
onDragEnd: () => {
|
|
386
|
+
activeDraggedEventId = "";
|
|
387
|
+
},
|
|
388
|
+
}
|
|
389
|
+
: {};
|
|
390
|
+
const defaultContent = (
|
|
391
|
+
<>
|
|
392
|
+
<span class={`block truncate text-[11px] font-semibold ${selected() ? "text-blue-700 dark:text-blue-200" : "text-primary"}`}>
|
|
393
|
+
{props.event.title}
|
|
394
|
+
</span>
|
|
395
|
+
<Show when={showTime()}>
|
|
396
|
+
<span class="block truncate text-[10px] text-secondary">{timeLabel()}</span>
|
|
397
|
+
</Show>
|
|
398
|
+
<Show when={showLocation()}>
|
|
399
|
+
<span class="block truncate text-[10px] text-secondary">{props.event.location}</span>
|
|
400
|
+
</Show>
|
|
401
|
+
</>
|
|
402
|
+
);
|
|
403
|
+
const content = () => renderedEvent() ?? defaultContent;
|
|
404
|
+
let eventElement: HTMLAnchorElement | HTMLDivElement | undefined;
|
|
405
|
+
let clickTimer: ReturnType<typeof setTimeout> | undefined;
|
|
406
|
+
onCleanup(() => {
|
|
407
|
+
if (clickTimer) clearTimeout(clickTimer);
|
|
408
|
+
});
|
|
409
|
+
const onClick = (event: MouseEvent) => {
|
|
410
|
+
if (!props.owner.onEventClick) return;
|
|
411
|
+
event.preventDefault();
|
|
412
|
+
event.stopPropagation();
|
|
413
|
+
if (clickTimer) clearTimeout(clickTimer);
|
|
414
|
+
clickTimer = setTimeout(
|
|
415
|
+
() => {
|
|
416
|
+
clickTimer = undefined;
|
|
417
|
+
props.owner.onEventClick?.(props.event);
|
|
418
|
+
},
|
|
419
|
+
props.owner.onEventDoubleClick ? 220 : 0,
|
|
420
|
+
);
|
|
421
|
+
};
|
|
422
|
+
const onDoubleClick = (event: MouseEvent) => {
|
|
423
|
+
if (!props.owner.onEventDoubleClick) return;
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
event.stopPropagation();
|
|
426
|
+
if (clickTimer) {
|
|
427
|
+
clearTimeout(clickTimer);
|
|
428
|
+
clickTimer = undefined;
|
|
429
|
+
}
|
|
430
|
+
props.owner.onEventDoubleClick(props.event);
|
|
431
|
+
};
|
|
432
|
+
onMount(() => {
|
|
433
|
+
if (!props.owner.onEventDoubleClick || !eventElement) return;
|
|
434
|
+
const onNativeDoubleClick = (event: Event) => onDoubleClick(event as MouseEvent);
|
|
435
|
+
eventElement.addEventListener("dblclick", onNativeDoubleClick);
|
|
436
|
+
onCleanup(() => eventElement?.removeEventListener("dblclick", onNativeDoubleClick));
|
|
437
|
+
});
|
|
438
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
439
|
+
if (!isInteractive() || (event.key !== "Enter" && event.key !== " ")) return;
|
|
440
|
+
event.preventDefault();
|
|
441
|
+
(props.owner.onEventClick ?? props.owner.onEventDoubleClick)?.(props.event);
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
return props.href ? (
|
|
445
|
+
<a
|
|
446
|
+
ref={(element) => {
|
|
447
|
+
eventElement = element;
|
|
448
|
+
}}
|
|
449
|
+
href={props.href}
|
|
450
|
+
class={className()}
|
|
451
|
+
data-calendar-event=""
|
|
452
|
+
data-space-item-id={props.event.dataSpaceItemId}
|
|
453
|
+
style={style()}
|
|
454
|
+
onClick={onClick}
|
|
455
|
+
onKeyDown={onKeyDown}
|
|
456
|
+
aria-label={`${props.event.title}${props.event.allDay ? "" : `, ${formatTime(props.event.startDate, dateConfig())} to ${formatTime(props.event.endDate, dateConfig())}`}`}
|
|
457
|
+
{...dragProps()}
|
|
458
|
+
>
|
|
459
|
+
{content()}
|
|
460
|
+
</a>
|
|
461
|
+
) : (
|
|
462
|
+
<div
|
|
463
|
+
ref={(element) => {
|
|
464
|
+
eventElement = element;
|
|
465
|
+
}}
|
|
466
|
+
class={className()}
|
|
467
|
+
data-calendar-event=""
|
|
468
|
+
style={style()}
|
|
469
|
+
role="button"
|
|
470
|
+
tabIndex={isInteractive() ? 0 : undefined}
|
|
471
|
+
onClick={onClick}
|
|
472
|
+
onKeyDown={onKeyDown}
|
|
473
|
+
aria-label={`${props.event.title}${props.event.allDay ? "" : `, ${formatTime(props.event.startDate, dateConfig())} to ${formatTime(props.event.endDate, dateConfig())}`}`}
|
|
474
|
+
{...dragProps()}
|
|
475
|
+
>
|
|
476
|
+
{content()}
|
|
477
|
+
</div>
|
|
478
|
+
);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const slotInteractionProps = (owner: CalendarProps, slot: () => CalendarEventTimeChange) => {
|
|
482
|
+
const isSlotChild = (event: MouseEvent) =>
|
|
483
|
+
event.target instanceof Element && Boolean(event.target.closest("a,button,[data-calendar-event]"));
|
|
484
|
+
return {
|
|
485
|
+
onClick: (event: MouseEvent) => {
|
|
486
|
+
if (!owner.onSlotClick || isSlotChild(event)) return;
|
|
487
|
+
owner.onSlotClick(slot());
|
|
488
|
+
},
|
|
489
|
+
onDblClick: (event: MouseEvent) => {
|
|
490
|
+
if (!owner.onSlotDoubleClick || isSlotChild(event)) return;
|
|
491
|
+
owner.onSlotDoubleClick(slot());
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const dropTargetProps = (owner: CalendarProps, events: NormalizedEvent[], target: () => Date, allDay = false) => {
|
|
497
|
+
if (!owner.onEventDrop) return {};
|
|
498
|
+
return {
|
|
499
|
+
onDragOver: (event: DragEvent) => event.preventDefault(),
|
|
500
|
+
onDrop: (event: DragEvent) => {
|
|
501
|
+
event.preventDefault();
|
|
502
|
+
const id = draggedEventId(event);
|
|
503
|
+
const calendarEvent = events.find((candidate) => candidate.id === id);
|
|
504
|
+
if (!calendarEvent) return;
|
|
505
|
+
owner.onEventDrop?.(calendarEvent, moveEventTo(calendarEvent, target(), allDay, ownerDateConfig(owner)));
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const dropPreviewProps = (
|
|
511
|
+
owner: CalendarProps,
|
|
512
|
+
events: NormalizedEvent[],
|
|
513
|
+
preview: Accessor<string>,
|
|
514
|
+
setPreview: (value: string) => void,
|
|
515
|
+
key: string,
|
|
516
|
+
target: () => Date,
|
|
517
|
+
allDay = false,
|
|
518
|
+
) => {
|
|
519
|
+
if (!owner.onEventDrop) return {};
|
|
520
|
+
return {
|
|
521
|
+
onDragEnter: (event: DragEvent) => {
|
|
522
|
+
event.preventDefault();
|
|
523
|
+
setPreview(key);
|
|
524
|
+
},
|
|
525
|
+
onDragOver: (event: DragEvent) => {
|
|
526
|
+
event.preventDefault();
|
|
527
|
+
if (preview() !== key) setPreview(key);
|
|
528
|
+
},
|
|
529
|
+
onDragLeave: () => {
|
|
530
|
+
if (preview() === key) setPreview("");
|
|
531
|
+
},
|
|
532
|
+
onDrop: (event: DragEvent) => {
|
|
533
|
+
dropTargetProps(owner, events, target, allDay).onDrop?.(event);
|
|
534
|
+
setPreview("");
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const CalendarHeader = (props: { date: Date; view: CalendarView; labels: Required<CalendarLabels>; owner: CalendarProps }): JSX.Element => {
|
|
540
|
+
const dateConfig = () => ownerDateConfig(props.owner);
|
|
541
|
+
const previous = () => {
|
|
542
|
+
if (props.view === "year") return calendar.addMonths(props.date, -12, dateConfig());
|
|
543
|
+
if (props.view === "month" || props.view === "mobile-month") return calendar.addMonths(props.date, -1, dateConfig());
|
|
544
|
+
return calendar.addDays(props.date, props.view === "day" ? -1 : -7, dateConfig());
|
|
545
|
+
};
|
|
546
|
+
const next = () => {
|
|
547
|
+
if (props.view === "year") return calendar.addMonths(props.date, 12, dateConfig());
|
|
548
|
+
if (props.view === "month" || props.view === "mobile-month") return calendar.addMonths(props.date, 1, dateConfig());
|
|
549
|
+
return calendar.addDays(props.date, props.view === "day" ? 1 : 7, dateConfig());
|
|
550
|
+
};
|
|
551
|
+
const title = () => {
|
|
552
|
+
if (props.view === "year")
|
|
553
|
+
return new Intl.DateTimeFormat(dateConfig().locale ?? "en", { year: "numeric", timeZone: dateConfig().timeZone }).format(props.date);
|
|
554
|
+
if (props.view === "day")
|
|
555
|
+
return props.date.toLocaleDateString(dateConfig().locale ?? "en", {
|
|
556
|
+
weekday: "long",
|
|
557
|
+
month: "long",
|
|
558
|
+
day: "numeric",
|
|
559
|
+
year: "numeric",
|
|
560
|
+
timeZone: dateConfig().timeZone,
|
|
561
|
+
});
|
|
562
|
+
if (props.view === "week")
|
|
563
|
+
return `${formatDay(calendar.startOfWeek(props.date, dateConfig()), dateConfig())} - ${formatDay(calendar.addDays(calendar.startOfWeek(props.date, dateConfig()), 6, dateConfig()), dateConfig())}`;
|
|
564
|
+
return formatMonth(props.date, dateConfig());
|
|
565
|
+
};
|
|
566
|
+
const goDate = (date: Date) => props.owner.onDateChange?.(date, props.view);
|
|
567
|
+
const goView = (view: CalendarView) => {
|
|
568
|
+
if (props.owner.onViewChange) {
|
|
569
|
+
props.owner.onViewChange(view);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const href = props.owner.getViewHref?.(view);
|
|
573
|
+
if (href) window.location.href = href;
|
|
574
|
+
};
|
|
575
|
+
const navButton = (date: Date, icon: string, label: string) => {
|
|
576
|
+
const href = props.owner.getDateHref?.(date, props.view);
|
|
577
|
+
return props.owner.onDateChange ? (
|
|
578
|
+
<button type="button" aria-label={label} class="btn-segment-icon" onClick={() => goDate(date)}>
|
|
579
|
+
<i class={`ti ${icon}`} />
|
|
580
|
+
</button>
|
|
581
|
+
) : (
|
|
582
|
+
<a href={href ?? "#"} aria-label={label} class="btn-segment-icon">
|
|
583
|
+
<i class={`ti ${icon}`} />
|
|
584
|
+
</a>
|
|
585
|
+
);
|
|
586
|
+
};
|
|
587
|
+
const todayButton = () => {
|
|
588
|
+
const today = calendar.today(dateConfig());
|
|
589
|
+
const href = props.owner.getDateHref?.(today, props.view);
|
|
590
|
+
return props.owner.onDateChange ? (
|
|
591
|
+
<button type="button" class="btn-segment font-semibold" onClick={() => goDate(today)}>
|
|
592
|
+
{props.labels.today}
|
|
593
|
+
</button>
|
|
594
|
+
) : (
|
|
595
|
+
<a href={href ?? "#"} class="btn-segment font-semibold">
|
|
596
|
+
{props.labels.today}
|
|
597
|
+
</a>
|
|
598
|
+
);
|
|
599
|
+
};
|
|
600
|
+
const viewOptions = () =>
|
|
601
|
+
(
|
|
602
|
+
[
|
|
603
|
+
{ value: "day", label: props.labels.day },
|
|
604
|
+
{ value: "week", label: props.labels.week },
|
|
605
|
+
{ value: "month", label: props.labels.month },
|
|
606
|
+
{ value: "year", label: props.labels.year },
|
|
607
|
+
] satisfies Array<{ value: CalendarView; label: string }>
|
|
608
|
+
).filter((option) => !props.owner.views || props.owner.views.includes(option.value));
|
|
609
|
+
|
|
610
|
+
return (
|
|
611
|
+
<header class="grid gap-2 border-b border-zinc-100 bg-white p-2 dark:border-zinc-800/70 dark:bg-zinc-900 sm:grid-cols-[auto_1fr_auto] sm:items-center">
|
|
612
|
+
<div class="flex items-center gap-1.5">
|
|
613
|
+
{navButton(previous(), "ti-chevron-left", props.labels.previous)}
|
|
614
|
+
<div class="btn-segment min-w-36 px-4 font-semibold">{title()}</div>
|
|
615
|
+
{navButton(next(), "ti-chevron-right", props.labels.next)}
|
|
616
|
+
{todayButton()}
|
|
617
|
+
</div>
|
|
618
|
+
<div />
|
|
619
|
+
<div class="w-full sm:w-auto">
|
|
620
|
+
<SegmentedControl
|
|
621
|
+
value={() => (props.view === "mobile-month" ? "month" : props.view)}
|
|
622
|
+
onChange={goView}
|
|
623
|
+
ariaLabel="Calendar view"
|
|
624
|
+
options={viewOptions()}
|
|
625
|
+
/>
|
|
626
|
+
</div>
|
|
627
|
+
</header>
|
|
628
|
+
);
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
const MonthView = (props: {
|
|
632
|
+
owner: CalendarProps;
|
|
633
|
+
date: Date;
|
|
634
|
+
events: NormalizedEvent[];
|
|
635
|
+
labels: Required<CalendarLabels>;
|
|
636
|
+
}): JSX.Element => {
|
|
637
|
+
const dateConfig = () => ownerDateConfig(props.owner);
|
|
638
|
+
const [dropPreview, setDropPreview] = createSignal("");
|
|
639
|
+
const month = () => zonedYearMonth(props.date, dateConfig());
|
|
640
|
+
const weeks = () => calendar.getMonthGrid(month().year, month().month, dateConfig());
|
|
641
|
+
const weekdays = () => calendar.weekdays(dateConfig());
|
|
642
|
+
const todayKey = () => calendar.formatDateKey(new Date(), dateConfig());
|
|
643
|
+
const eventsByDay = createMemo(() => {
|
|
644
|
+
const grouped = new Map<string, NormalizedEvent[]>();
|
|
645
|
+
for (const event of props.events) {
|
|
646
|
+
const events = grouped.get(event.dayKey);
|
|
647
|
+
if (events) events.push(event);
|
|
648
|
+
else grouped.set(event.dayKey, [event]);
|
|
649
|
+
}
|
|
650
|
+
return grouped;
|
|
651
|
+
});
|
|
652
|
+
return (
|
|
653
|
+
<div>
|
|
654
|
+
<div
|
|
655
|
+
class={`grid ${props.owner.withWeekNumbers ? "grid-cols-[3rem_repeat(7,minmax(0,1fr))]" : "grid-cols-7"} border-b border-zinc-100 bg-white dark:border-zinc-800/70 dark:bg-zinc-900`}
|
|
656
|
+
>
|
|
657
|
+
<Show when={props.owner.withWeekNumbers}>
|
|
658
|
+
<div class="px-2 py-2 text-center text-[11px] font-semibold text-dimmed">Wk</div>
|
|
659
|
+
</Show>
|
|
660
|
+
<For each={weekdays()}>{(day) => <div class="px-2 py-2 text-center text-[11px] font-semibold text-dimmed">{day}</div>}</For>
|
|
661
|
+
</div>
|
|
662
|
+
<div class="grid divide-y divide-zinc-100 dark:divide-zinc-800/70">
|
|
663
|
+
<For each={weeks()}>
|
|
664
|
+
{(week) => (
|
|
665
|
+
<div
|
|
666
|
+
class={`grid min-h-24 ${props.owner.withWeekNumbers ? "grid-cols-[3rem_repeat(7,minmax(0,1fr))]" : "grid-cols-7"} divide-x divide-zinc-100 dark:divide-zinc-800/70`}
|
|
667
|
+
>
|
|
668
|
+
<Show when={props.owner.withWeekNumbers}>
|
|
669
|
+
<div class="flex items-start justify-center px-2 py-2 text-xs font-semibold text-dimmed">
|
|
670
|
+
{zonedWeekNumber(week[0]!, dateConfig())}
|
|
671
|
+
</div>
|
|
672
|
+
</Show>
|
|
673
|
+
<For each={week}>
|
|
674
|
+
{(day) => {
|
|
675
|
+
const dayKey = calendar.formatDateKey(day, dateConfig());
|
|
676
|
+
const events = eventsByDay().get(dayKey) ?? [];
|
|
677
|
+
const href = props.owner.getDateHref?.(day, "day");
|
|
678
|
+
const dayBadge = props.owner.dayBadges?.[dayKey];
|
|
679
|
+
const sameMonth = calendar.isSameMonth(day, props.date, dateConfig());
|
|
680
|
+
const isToday = dayKey === todayKey();
|
|
681
|
+
return (
|
|
682
|
+
<div
|
|
683
|
+
class={`relative min-w-0 p-1.5 ${sameMonth ? "" : "bg-zinc-50/60 dark:bg-zinc-950/25"}`}
|
|
684
|
+
classList={{
|
|
685
|
+
"bg-blue-500/10 ring-1 ring-inset ring-blue-400": dropPreview() === dayKey,
|
|
686
|
+
"cursor-pointer hover:bg-blue-500/5": Boolean(props.owner.onSlotClick || props.owner.onSlotDoubleClick),
|
|
687
|
+
}}
|
|
688
|
+
{...slotInteractionProps(props.owner, () => {
|
|
689
|
+
const start = startOfDay(day, dateConfig());
|
|
690
|
+
return { start, end: calendar.addDays(start, 1, dateConfig()), allDay: true };
|
|
691
|
+
})}
|
|
692
|
+
{...dropPreviewProps(
|
|
693
|
+
props.owner,
|
|
694
|
+
props.events,
|
|
695
|
+
dropPreview,
|
|
696
|
+
setDropPreview,
|
|
697
|
+
dayKey,
|
|
698
|
+
() => startOfDay(day, dateConfig()),
|
|
699
|
+
true,
|
|
700
|
+
)}
|
|
701
|
+
>
|
|
702
|
+
<div class="flex items-center gap-1">
|
|
703
|
+
<a
|
|
704
|
+
href={href ?? "#"}
|
|
705
|
+
class={`inline-flex h-6 min-w-6 items-center justify-center rounded-full px-1 text-xs font-semibold ${
|
|
706
|
+
isToday ? "bg-blue-500 text-white" : sameMonth ? "text-primary" : "text-dimmed"
|
|
707
|
+
}`}
|
|
708
|
+
>
|
|
709
|
+
{calendar.formatDayNumber(day, dateConfig())}
|
|
710
|
+
</a>
|
|
711
|
+
<Show when={dayBadge}>
|
|
712
|
+
{(badge) => (
|
|
713
|
+
<span class="hidden items-center gap-0.5 text-[10px] text-dimmed md:inline-flex">
|
|
714
|
+
<Show when={badge().icon}>{(icon) => <i class={`ti ti-${icon()} text-[10px]`} />}</Show>
|
|
715
|
+
{badge().label}
|
|
716
|
+
</span>
|
|
717
|
+
)}
|
|
718
|
+
</Show>
|
|
719
|
+
</div>
|
|
720
|
+
<div class="mt-1 hidden flex-col gap-1 md:flex">
|
|
721
|
+
<For each={events.slice(0, 3)}>
|
|
722
|
+
{(event) => <EventChip event={event} owner={props.owner} href={eventHref(props.owner, event)} compact />}
|
|
723
|
+
</For>
|
|
724
|
+
<Show when={events.length > 3}>
|
|
725
|
+
<a href={href ?? "#"} class="px-1 text-[11px] font-medium text-dimmed hover:text-primary">
|
|
726
|
+
+{events.length - 3} more
|
|
727
|
+
</a>
|
|
728
|
+
</Show>
|
|
729
|
+
</div>
|
|
730
|
+
<div class="mt-1 flex gap-0.5 md:hidden">
|
|
731
|
+
<For each={events.slice(0, 4)}>
|
|
732
|
+
{(event) => (
|
|
733
|
+
<span
|
|
734
|
+
class={`h-1.5 w-1.5 rounded-full ${event.colorHex ? "" : dotClass[event.color ?? "blue"]}`}
|
|
735
|
+
style={event.colorHex ? { "background-color": event.colorHex } : undefined}
|
|
736
|
+
/>
|
|
737
|
+
)}
|
|
738
|
+
</For>
|
|
739
|
+
</div>
|
|
740
|
+
</div>
|
|
741
|
+
);
|
|
742
|
+
}}
|
|
743
|
+
</For>
|
|
744
|
+
</div>
|
|
745
|
+
)}
|
|
746
|
+
</For>
|
|
747
|
+
</div>
|
|
748
|
+
</div>
|
|
749
|
+
);
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
const TimeGridView = (props: {
|
|
753
|
+
owner: CalendarProps;
|
|
754
|
+
date: Date;
|
|
755
|
+
events: NormalizedEvent[];
|
|
756
|
+
labels: Required<CalendarLabels>;
|
|
757
|
+
days: Date[];
|
|
758
|
+
}): JSX.Element => {
|
|
759
|
+
const dateConfig = () => ownerDateConfig(props.owner);
|
|
760
|
+
const gridStartHour = () => props.owner.visibleStartHour ?? 0;
|
|
761
|
+
const gridEndHour = () => props.owner.visibleEndHour ?? 23;
|
|
762
|
+
const businessStartHour = () => props.owner.startHour ?? 8;
|
|
763
|
+
const businessEndHour = () => props.owner.endHour ?? 18;
|
|
764
|
+
const hours = () => Array.from({ length: gridEndHour() - gridStartHour() + 1 }, (_, index) => gridStartHour() + index);
|
|
765
|
+
const [dropPreview, setDropPreview] = createSignal("");
|
|
766
|
+
const [timePreview, setTimePreview] = createSignal<CalendarPreview | null>(null);
|
|
767
|
+
const [expandedOverflow, setExpandedOverflow] = createSignal("");
|
|
768
|
+
let scrollContainer: HTMLDivElement | undefined;
|
|
769
|
+
let defaultHourMarker: HTMLDivElement | undefined;
|
|
770
|
+
const slotEnd = (start: Date) => addMinutes(start, 60);
|
|
771
|
+
const allDayKey = (day: Date) => `${calendar.formatDateKey(day, dateConfig())}-all-day`;
|
|
772
|
+
const previewEvents = () => previewSegments(timePreview(), props.days, dateConfig());
|
|
773
|
+
const timeDropProps = (key: string, target: () => Date, allDay = false) => {
|
|
774
|
+
if (!props.owner.onEventDrop) return {};
|
|
775
|
+
return {
|
|
776
|
+
onDragEnter: (event: DragEvent) => {
|
|
777
|
+
event.preventDefault();
|
|
778
|
+
const id = draggedEventId(event);
|
|
779
|
+
const calendarEvent = props.events.find((candidate) => candidate.id === id);
|
|
780
|
+
setDropPreview(key);
|
|
781
|
+
if (calendarEvent) setTimePreview({ id: calendarEvent.id, ...moveEventTo(calendarEvent, target(), allDay, dateConfig()) });
|
|
782
|
+
},
|
|
783
|
+
onDragOver: (event: DragEvent) => {
|
|
784
|
+
event.preventDefault();
|
|
785
|
+
const id = draggedEventId(event);
|
|
786
|
+
const calendarEvent = props.events.find((candidate) => candidate.id === id);
|
|
787
|
+
setDropPreview(key);
|
|
788
|
+
if (calendarEvent) setTimePreview({ id: calendarEvent.id, ...moveEventTo(calendarEvent, target(), allDay, dateConfig()) });
|
|
789
|
+
},
|
|
790
|
+
onDragLeave: () => {
|
|
791
|
+
if (dropPreview() === key) {
|
|
792
|
+
setDropPreview("");
|
|
793
|
+
setTimePreview(null);
|
|
794
|
+
}
|
|
795
|
+
},
|
|
796
|
+
onDrop: (event: DragEvent) => {
|
|
797
|
+
dropTargetProps(props.owner, props.events, target, allDay).onDrop?.(event);
|
|
798
|
+
setDropPreview("");
|
|
799
|
+
setTimePreview(null);
|
|
800
|
+
},
|
|
801
|
+
};
|
|
802
|
+
};
|
|
803
|
+
const timeRangeLayout = (startDate: Date, endDate: Date) => {
|
|
804
|
+
const start = zonedHour(startDate, dateConfig());
|
|
805
|
+
const end = zonedHour(endDate, dateConfig());
|
|
806
|
+
const visibleStart = Math.max(gridStartHour(), start);
|
|
807
|
+
const visibleEnd = Math.min(gridEndHour() + 1, end);
|
|
808
|
+
return {
|
|
809
|
+
top: Math.max(0, (visibleStart - gridStartHour()) * 4),
|
|
810
|
+
height: Math.max(2.5, (visibleEnd - visibleStart) * 4),
|
|
811
|
+
};
|
|
812
|
+
};
|
|
813
|
+
const eventLayout = (event: NormalizedEvent) => timeRangeLayout(event.startDate, event.endDate);
|
|
814
|
+
const isDayView = () => props.days.length === 1;
|
|
815
|
+
const visibleLaneCount = (lanes: number) => (isDayView() ? lanes : Math.min(lanes, 3));
|
|
816
|
+
const overflowLayouts = (layouts: TimedEventLayout[]): TimedOverflowLayout[] => {
|
|
817
|
+
if (isDayView()) return [];
|
|
818
|
+
const groups = new Map<number, TimedOverflowLayout>();
|
|
819
|
+
for (const layout of layouts) {
|
|
820
|
+
if (layout.lane < visibleLaneCount(layout.lanes)) continue;
|
|
821
|
+
const existing = groups.get(layout.groupId);
|
|
822
|
+
if (existing) existing.hiddenEvents.push(layout.event);
|
|
823
|
+
else
|
|
824
|
+
groups.set(layout.groupId, {
|
|
825
|
+
groupId: layout.groupId,
|
|
826
|
+
hiddenEvents: [layout.event],
|
|
827
|
+
groupStartDate: layout.groupStartDate,
|
|
828
|
+
groupEndDate: layout.groupEndDate,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
return [...groups.values()];
|
|
832
|
+
};
|
|
833
|
+
const dayColumnMinWidth = (layouts: TimedEventLayout[]) => {
|
|
834
|
+
if (!isDayView()) return undefined;
|
|
835
|
+
const lanes = Math.max(1, ...layouts.map((layout) => layout.lanes));
|
|
836
|
+
return `${Math.max(32, lanes * 12)}rem`;
|
|
837
|
+
};
|
|
838
|
+
const laneStyle = (layoutItem: TimedEventLayout) => {
|
|
839
|
+
if (isDayView()) {
|
|
840
|
+
const laneWidth = 100 / layoutItem.lanes;
|
|
841
|
+
return {
|
|
842
|
+
left: `calc(${layoutItem.lane * laneWidth}% + 0.25rem)`,
|
|
843
|
+
width: `calc(${laneWidth}% - 0.5rem)`,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
const visibleLanes = visibleLaneCount(layoutItem.lanes);
|
|
847
|
+
return {
|
|
848
|
+
left: `${layoutItem.lanes <= 1 ? 0 : (28 / Math.max(1, visibleLanes - 1)) * layoutItem.lane}%`,
|
|
849
|
+
width: `${layoutItem.lanes <= 1 ? 100 : layoutItem.lanes > visibleLanes ? 68 : 72}%`,
|
|
850
|
+
};
|
|
851
|
+
};
|
|
852
|
+
const currentTimeLine = (day: Date) => {
|
|
853
|
+
const now = new Date();
|
|
854
|
+
if (calendar.formatDateKey(day, dateConfig()) !== calendar.formatDateKey(now, dateConfig())) return null;
|
|
855
|
+
const hour = zonedHour(now, dateConfig());
|
|
856
|
+
if (hour < gridStartHour() || hour > gridEndHour() + 1) return null;
|
|
857
|
+
return (hour - gridStartHour()) * 4;
|
|
858
|
+
};
|
|
859
|
+
onMount(() => {
|
|
860
|
+
requestAnimationFrame(() => {
|
|
861
|
+
if (!scrollContainer || !defaultHourMarker) return;
|
|
862
|
+
const targetTop = defaultHourMarker.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top;
|
|
863
|
+
scrollContainer.scrollTo({ top: Math.max(0, scrollContainer.scrollTop + targetTop), behavior: "smooth" });
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
return (
|
|
867
|
+
<div class="flex min-h-0 min-w-160 flex-1 flex-col">
|
|
868
|
+
<div
|
|
869
|
+
class="grid border-b border-zinc-100 bg-white dark:border-zinc-800/70 dark:bg-zinc-900"
|
|
870
|
+
style={{ "grid-template-columns": `4rem repeat(${props.days.length}, minmax(0, 1fr))` }}
|
|
871
|
+
>
|
|
872
|
+
<div />
|
|
873
|
+
<For each={props.days}>
|
|
874
|
+
{(day) => {
|
|
875
|
+
const dayBadge = props.owner.dayBadges?.[calendar.formatDateKey(day, dateConfig())];
|
|
876
|
+
const today = () => calendar.isToday(day, dateConfig());
|
|
877
|
+
return (
|
|
878
|
+
<a
|
|
879
|
+
href={props.owner.getDateHref?.(day, "day") ?? "#"}
|
|
880
|
+
class="px-2 py-2 text-center text-[11px] font-semibold text-primary hover:text-blue-500"
|
|
881
|
+
>
|
|
882
|
+
<span
|
|
883
|
+
classList={{
|
|
884
|
+
"inline-flex rounded-full bg-blue-600 px-2.5 py-0.5 text-white": today(),
|
|
885
|
+
}}
|
|
886
|
+
>
|
|
887
|
+
{formatDay(day, dateConfig())}
|
|
888
|
+
</span>
|
|
889
|
+
<Show when={dayBadge}>
|
|
890
|
+
{(badge) => (
|
|
891
|
+
<span class="mt-0.5 flex items-center justify-center gap-0.5 text-[10px] font-medium text-dimmed">
|
|
892
|
+
<Show when={badge().icon}>{(icon) => <i class={`ti ti-${icon()} text-[10px]`} />}</Show>
|
|
893
|
+
{badge().label}
|
|
894
|
+
</span>
|
|
895
|
+
)}
|
|
896
|
+
</Show>
|
|
897
|
+
</a>
|
|
898
|
+
);
|
|
899
|
+
}}
|
|
900
|
+
</For>
|
|
901
|
+
</div>
|
|
902
|
+
<Show when={!props.owner.hideAllDay}>
|
|
903
|
+
<div
|
|
904
|
+
class="grid overflow-y-auto border-b border-zinc-200/80 bg-zinc-50/65 dark:border-zinc-800/80 dark:bg-zinc-950/40"
|
|
905
|
+
style={{
|
|
906
|
+
"grid-template-columns": `4rem repeat(${props.days.length}, minmax(0, 1fr))`,
|
|
907
|
+
"max-height": `${props.owner.allDayMaxHeightRem ?? 7}rem`,
|
|
908
|
+
}}
|
|
909
|
+
>
|
|
910
|
+
<div class="sticky top-0 bg-inherit px-2 py-2 text-center text-[11px] font-semibold text-dimmed">{props.labels.allDay}</div>
|
|
911
|
+
<For each={props.days}>
|
|
912
|
+
{(day) => {
|
|
913
|
+
const dayKey = calendar.formatDateKey(day, dateConfig());
|
|
914
|
+
const allDay = () => props.events.filter((event) => event.dayKey === dayKey && event.allDay);
|
|
915
|
+
const previewAllDay = previewEvents().filter((event) => event.dayKey === dayKey && event.allDay);
|
|
916
|
+
return (
|
|
917
|
+
<div
|
|
918
|
+
class="min-h-10 border-r border-zinc-100 p-1 dark:border-zinc-800/70"
|
|
919
|
+
classList={{
|
|
920
|
+
"rounded bg-blue-500/10 ring-1 ring-inset ring-blue-400": dropPreview() === allDayKey(day),
|
|
921
|
+
"cursor-pointer hover:bg-blue-500/5": Boolean(props.owner.onSlotClick || props.owner.onSlotDoubleClick),
|
|
922
|
+
}}
|
|
923
|
+
{...slotInteractionProps(props.owner, () => {
|
|
924
|
+
const start = startOfDay(day, dateConfig());
|
|
925
|
+
return { start, end: calendar.addDays(start, 1, dateConfig()), allDay: true };
|
|
926
|
+
})}
|
|
927
|
+
{...dropPreviewProps(
|
|
928
|
+
props.owner,
|
|
929
|
+
props.events,
|
|
930
|
+
dropPreview,
|
|
931
|
+
setDropPreview,
|
|
932
|
+
allDayKey(day),
|
|
933
|
+
() => startOfDay(day, dateConfig()),
|
|
934
|
+
true,
|
|
935
|
+
)}
|
|
936
|
+
>
|
|
937
|
+
<div class="flex flex-col gap-1">
|
|
938
|
+
<For each={previewAllDay}>
|
|
939
|
+
{(event) => (
|
|
940
|
+
<div class="rounded-lg border border-dashed border-blue-500 bg-blue-500/10 px-1.5 py-1 text-[10px] font-semibold text-blue-600">
|
|
941
|
+
{event.title}
|
|
942
|
+
</div>
|
|
943
|
+
)}
|
|
944
|
+
</For>
|
|
945
|
+
<For each={allDay()}>
|
|
946
|
+
{(event) => <EventChip event={event} owner={props.owner} href={eventHref(props.owner, event)} compact />}
|
|
947
|
+
</For>
|
|
948
|
+
</div>
|
|
949
|
+
</div>
|
|
950
|
+
);
|
|
951
|
+
}}
|
|
952
|
+
</For>
|
|
953
|
+
</div>
|
|
954
|
+
</Show>
|
|
955
|
+
<div ref={scrollContainer} class="min-h-0 flex-1 overflow-auto bg-white dark:bg-zinc-900">
|
|
956
|
+
<div class="grid" style={{ "grid-template-columns": `4rem repeat(${props.days.length}, minmax(0, 1fr))` }}>
|
|
957
|
+
<div class="border-r border-zinc-100 dark:border-zinc-800/70">
|
|
958
|
+
<For each={hours()}>
|
|
959
|
+
{(hour) => (
|
|
960
|
+
<div
|
|
961
|
+
ref={(element) => {
|
|
962
|
+
if (hour === businessStartHour()) defaultHourMarker = element;
|
|
963
|
+
}}
|
|
964
|
+
class="h-16 border-b border-zinc-100 bg-zinc-50/60 pr-2 pt-1 text-right text-[11px] text-dimmed dark:border-zinc-800/70 dark:bg-zinc-950/35"
|
|
965
|
+
classList={{ "bg-zinc-100/70 dark:bg-zinc-950/70": hour < businessStartHour() || hour > businessEndHour() }}
|
|
966
|
+
>
|
|
967
|
+
{`${hour}`.padStart(2, "0")}:00
|
|
968
|
+
</div>
|
|
969
|
+
)}
|
|
970
|
+
</For>
|
|
971
|
+
</div>
|
|
972
|
+
<For each={props.days}>
|
|
973
|
+
{(day) => {
|
|
974
|
+
const dayKey = calendar.formatDateKey(day, dateConfig());
|
|
975
|
+
const timed = () => props.events.filter((event) => event.dayKey === dayKey && !event.allDay);
|
|
976
|
+
const layouts = () => timedEventLayouts(timed());
|
|
977
|
+
return (
|
|
978
|
+
<div
|
|
979
|
+
class="relative min-h-full border-r border-zinc-100 dark:border-zinc-800/70"
|
|
980
|
+
style={{ "min-width": dayColumnMinWidth(layouts()) }}
|
|
981
|
+
>
|
|
982
|
+
<Show when={currentTimeLine(day)}>
|
|
983
|
+
{(top) => (
|
|
984
|
+
<div class="pointer-events-none absolute inset-x-0 z-40 border-t border-red-500" style={{ top: `${top()}rem` }} />
|
|
985
|
+
)}
|
|
986
|
+
</Show>
|
|
987
|
+
<For each={hours()}>
|
|
988
|
+
{(hour) => (
|
|
989
|
+
<div
|
|
990
|
+
class="relative h-16 border-b border-zinc-100 dark:border-zinc-800/70"
|
|
991
|
+
classList={{
|
|
992
|
+
"bg-blue-500/10 ring-1 ring-inset ring-blue-400": dropPreview() === `${dayKey}-${hour}`,
|
|
993
|
+
"bg-zinc-50/70 dark:bg-zinc-950/45": hour < businessStartHour() || hour > businessEndHour(),
|
|
994
|
+
"cursor-pointer hover:bg-blue-500/5": Boolean(props.owner.onSlotClick || props.owner.onSlotDoubleClick),
|
|
995
|
+
}}
|
|
996
|
+
{...slotInteractionProps(props.owner, () => {
|
|
997
|
+
const start = zonedSlot(day, hour, dateConfig());
|
|
998
|
+
return { start, end: slotEnd(start), allDay: false };
|
|
999
|
+
})}
|
|
1000
|
+
{...timeDropProps(`${dayKey}-${hour}`, () => zonedSlot(day, hour, dateConfig()), false)}
|
|
1001
|
+
/>
|
|
1002
|
+
)}
|
|
1003
|
+
</For>
|
|
1004
|
+
<For each={previewEvents().filter((event) => event.dayKey === dayKey && !event.allDay)}>
|
|
1005
|
+
{(event) => {
|
|
1006
|
+
const layout = eventLayout(event);
|
|
1007
|
+
return (
|
|
1008
|
+
<div
|
|
1009
|
+
class="pointer-events-none absolute inset-x-1.5 z-30 rounded-lg border border-dashed border-blue-500 bg-blue-500/10"
|
|
1010
|
+
style={{ top: `${layout.top}rem`, height: `${layout.height}rem` }}
|
|
1011
|
+
>
|
|
1012
|
+
<div class="px-2 py-1 text-[10px] font-semibold text-blue-600">
|
|
1013
|
+
{formatTime(event.startDate, dateConfig())} - {formatTime(event.endDate, dateConfig())}
|
|
1014
|
+
</div>
|
|
1015
|
+
</div>
|
|
1016
|
+
);
|
|
1017
|
+
}}
|
|
1018
|
+
</For>
|
|
1019
|
+
<For each={layouts()}>
|
|
1020
|
+
{(layoutItem) => {
|
|
1021
|
+
if (!isDayView() && layoutItem.lane >= visibleLaneCount(layoutItem.lanes)) return null;
|
|
1022
|
+
const event = layoutItem.event;
|
|
1023
|
+
const layout = eventLayout(event);
|
|
1024
|
+
const position = laneStyle(layoutItem);
|
|
1025
|
+
const [resizePreview, setResizePreview] = createSignal<Date | null>(null);
|
|
1026
|
+
const resizeStart = (pointerEvent: PointerEvent) => {
|
|
1027
|
+
if (!props.owner.onEventResize) return;
|
|
1028
|
+
pointerEvent.preventDefault();
|
|
1029
|
+
pointerEvent.stopPropagation();
|
|
1030
|
+
const startY = pointerEvent.clientY;
|
|
1031
|
+
const initialEnd = event.sourceEndDate;
|
|
1032
|
+
let nextEnd = initialEnd;
|
|
1033
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
1034
|
+
const deltaMinutes = Math.round(((moveEvent.clientY - startY) / 64) * 60);
|
|
1035
|
+
const candidate = roundToMinutes(addMinutes(initialEnd, deltaMinutes), 15);
|
|
1036
|
+
if (candidate > event.sourceStartDate) {
|
|
1037
|
+
nextEnd = candidate;
|
|
1038
|
+
setResizePreview(candidate);
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
const onUp = () => {
|
|
1042
|
+
window.removeEventListener("pointermove", onMove);
|
|
1043
|
+
window.removeEventListener("pointerup", onUp);
|
|
1044
|
+
setResizePreview(null);
|
|
1045
|
+
if (nextEnd.getTime() !== initialEnd.getTime()) {
|
|
1046
|
+
props.owner.onEventResize?.(event, { start: event.sourceStartDate, end: nextEnd, allDay: event.allDay });
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
window.addEventListener("pointermove", onMove);
|
|
1050
|
+
window.addEventListener("pointerup", onUp, { once: true });
|
|
1051
|
+
};
|
|
1052
|
+
return (
|
|
1053
|
+
<div
|
|
1054
|
+
class="absolute"
|
|
1055
|
+
style={{
|
|
1056
|
+
top: `${layout.top}rem`,
|
|
1057
|
+
height: `${layout.height}rem`,
|
|
1058
|
+
left: position.left,
|
|
1059
|
+
width: position.width,
|
|
1060
|
+
"z-index": String(20 + layoutItem.lane),
|
|
1061
|
+
}}
|
|
1062
|
+
>
|
|
1063
|
+
<div class="group relative h-full">
|
|
1064
|
+
<EventChip event={event} owner={props.owner} href={eventHref(props.owner, event)} fill />
|
|
1065
|
+
<Show when={resizePreview()}>
|
|
1066
|
+
{(end) => (
|
|
1067
|
+
<>
|
|
1068
|
+
<div
|
|
1069
|
+
class="pointer-events-none absolute inset-x-0 top-0 z-10 rounded border border-dashed border-blue-500 bg-blue-500/10"
|
|
1070
|
+
style={{
|
|
1071
|
+
height: `${eventLayout({ ...event, endDate: end(), sourceEndDate: end() }).height}rem`,
|
|
1072
|
+
}}
|
|
1073
|
+
/>
|
|
1074
|
+
<div class="pointer-events-none absolute inset-x-0 top-full z-30 rounded-b border border-dashed border-blue-500 bg-white/90 px-1.5 py-0.5 text-[10px] font-semibold text-blue-600 dark:bg-zinc-950/90">
|
|
1075
|
+
until {formatTime(end(), dateConfig())}
|
|
1076
|
+
</div>
|
|
1077
|
+
</>
|
|
1078
|
+
)}
|
|
1079
|
+
</Show>
|
|
1080
|
+
<Show when={props.owner.onEventResize}>
|
|
1081
|
+
<button
|
|
1082
|
+
type="button"
|
|
1083
|
+
aria-label="Resize event"
|
|
1084
|
+
draggable={false}
|
|
1085
|
+
class="absolute inset-x-3 bottom-1 z-20 flex h-4 cursor-ns-resize items-center justify-center rounded-full bg-blue-100/90 text-blue-600 opacity-0 backdrop-blur transition-opacity group-hover:opacity-90 focus:opacity-100 hover:opacity-100 dark:bg-blue-500/20 dark:text-blue-200"
|
|
1086
|
+
onPointerDown={resizeStart}
|
|
1087
|
+
onDragStart={(event) => event.preventDefault()}
|
|
1088
|
+
>
|
|
1089
|
+
<i class="ti ti-grip-horizontal text-[12px]" />
|
|
1090
|
+
</button>
|
|
1091
|
+
</Show>
|
|
1092
|
+
</div>
|
|
1093
|
+
</div>
|
|
1094
|
+
);
|
|
1095
|
+
}}
|
|
1096
|
+
</For>
|
|
1097
|
+
<For each={overflowLayouts(layouts())}>
|
|
1098
|
+
{(overflow) => {
|
|
1099
|
+
const layout = timeRangeLayout(overflow.groupStartDate, overflow.groupEndDate);
|
|
1100
|
+
const key = `${dayKey}-${overflow.groupId}`;
|
|
1101
|
+
const hiddenTitle = () =>
|
|
1102
|
+
overflow.hiddenEvents.map((event) => `${formatTime(event.startDate, dateConfig())} ${event.title}`).join("\n");
|
|
1103
|
+
return (
|
|
1104
|
+
<>
|
|
1105
|
+
<button
|
|
1106
|
+
type="button"
|
|
1107
|
+
class="absolute right-1 z-50 flex min-h-9 w-7 items-center justify-center rounded-lg border border-blue-500/35 bg-blue-500/10 text-[10px] font-black text-blue-700 transition-colors hover:bg-blue-500/15 dark:border-blue-300/30 dark:bg-blue-400/10 dark:text-blue-200 dark:hover:bg-blue-400/15"
|
|
1108
|
+
style={{ top: `${layout.top}rem`, height: `${layout.height}rem` }}
|
|
1109
|
+
title={hiddenTitle()}
|
|
1110
|
+
aria-label={`${overflow.hiddenEvents.length} hidden overlapping events`}
|
|
1111
|
+
onClick={(event) => {
|
|
1112
|
+
event.stopPropagation();
|
|
1113
|
+
setExpandedOverflow(expandedOverflow() === key ? "" : key);
|
|
1114
|
+
}}
|
|
1115
|
+
>
|
|
1116
|
+
<span class="[writing-mode:vertical-rl]">+{overflow.hiddenEvents.length}</span>
|
|
1117
|
+
</button>
|
|
1118
|
+
<Show when={expandedOverflow() === key}>
|
|
1119
|
+
<div
|
|
1120
|
+
class="absolute right-9 z-[70] flex w-56 flex-col gap-1 rounded-xl border border-zinc-200 bg-white p-2 shadow-lg dark:border-zinc-700 dark:bg-zinc-950"
|
|
1121
|
+
style={{ top: `${layout.top}rem` }}
|
|
1122
|
+
>
|
|
1123
|
+
<div class="px-1 pb-1 text-[10px] font-semibold text-dimmed">
|
|
1124
|
+
{formatTime(overflow.groupStartDate, dateConfig())} - {formatTime(overflow.groupEndDate, dateConfig())}
|
|
1125
|
+
</div>
|
|
1126
|
+
<For each={overflow.hiddenEvents}>
|
|
1127
|
+
{(event) => <EventChip event={event} owner={props.owner} href={eventHref(props.owner, event)} compact />}
|
|
1128
|
+
</For>
|
|
1129
|
+
</div>
|
|
1130
|
+
</Show>
|
|
1131
|
+
</>
|
|
1132
|
+
);
|
|
1133
|
+
}}
|
|
1134
|
+
</For>
|
|
1135
|
+
</div>
|
|
1136
|
+
);
|
|
1137
|
+
}}
|
|
1138
|
+
</For>
|
|
1139
|
+
</div>
|
|
1140
|
+
</div>
|
|
1141
|
+
</div>
|
|
1142
|
+
);
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
const YearView = (props: { owner: CalendarProps; date: Date; events: NormalizedEvent[] }): JSX.Element => (
|
|
1146
|
+
<div class="grid grid-cols-1 divide-y divide-zinc-100 dark:divide-zinc-800/70 md:grid-cols-3 md:divide-x md:divide-y-0">
|
|
1147
|
+
<For
|
|
1148
|
+
each={Array.from({ length: 12 }, (_, month) =>
|
|
1149
|
+
zonedMonthDate(zonedYearMonth(props.date, ownerDateConfig(props.owner)).year, month, ownerDateConfig(props.owner)),
|
|
1150
|
+
)}
|
|
1151
|
+
>
|
|
1152
|
+
{(monthDate) => (
|
|
1153
|
+
<div class="p-3">
|
|
1154
|
+
<div class="mb-2 text-xs font-semibold text-primary">
|
|
1155
|
+
{monthDate.toLocaleDateString(ownerDateConfig(props.owner).locale ?? "en", {
|
|
1156
|
+
month: "long",
|
|
1157
|
+
timeZone: ownerDateConfig(props.owner).timeZone,
|
|
1158
|
+
})}
|
|
1159
|
+
</div>
|
|
1160
|
+
<div class="grid grid-cols-7 gap-1 text-center text-[10px]">
|
|
1161
|
+
<For
|
|
1162
|
+
each={calendar
|
|
1163
|
+
.getMonthGrid(
|
|
1164
|
+
zonedYearMonth(monthDate, ownerDateConfig(props.owner)).year,
|
|
1165
|
+
zonedYearMonth(monthDate, ownerDateConfig(props.owner)).month,
|
|
1166
|
+
ownerDateConfig(props.owner),
|
|
1167
|
+
)
|
|
1168
|
+
.flat()
|
|
1169
|
+
.slice(0, 35)}
|
|
1170
|
+
>
|
|
1171
|
+
{(day) => {
|
|
1172
|
+
const dateConfig = ownerDateConfig(props.owner);
|
|
1173
|
+
const events = props.events.filter((event) => event.dayKey === calendar.formatDateKey(day, dateConfig));
|
|
1174
|
+
return (
|
|
1175
|
+
<a
|
|
1176
|
+
href={props.owner.getDateHref?.(day, "day") ?? "#"}
|
|
1177
|
+
class={`relative flex aspect-square items-center justify-center rounded-md ${calendar.isToday(day, dateConfig) ? "bg-blue-500 text-white" : calendar.isSameMonth(day, monthDate, dateConfig) ? "text-primary hover:bg-zinc-100 dark:hover:bg-zinc-800" : "text-zinc-300 dark:text-zinc-700"}`}
|
|
1178
|
+
>
|
|
1179
|
+
{calendar.formatDayNumber(day, dateConfig)}
|
|
1180
|
+
<Show when={events.length > 0}>
|
|
1181
|
+
<span
|
|
1182
|
+
class={`absolute bottom-1 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full ${events[0]!.colorHex ? "" : yearIndicatorClass(day, events[0]!.color ?? "blue", dateConfig)}`}
|
|
1183
|
+
style={
|
|
1184
|
+
events[0]!.colorHex
|
|
1185
|
+
? { "background-color": calendar.isToday(day, dateConfig) ? "white" : events[0]!.colorHex }
|
|
1186
|
+
: undefined
|
|
1187
|
+
}
|
|
1188
|
+
/>
|
|
1189
|
+
</Show>
|
|
1190
|
+
</a>
|
|
1191
|
+
);
|
|
1192
|
+
}}
|
|
1193
|
+
</For>
|
|
1194
|
+
</div>
|
|
1195
|
+
</div>
|
|
1196
|
+
)}
|
|
1197
|
+
</For>
|
|
1198
|
+
</div>
|
|
1199
|
+
);
|
|
1200
|
+
|
|
1201
|
+
const MobileMonthView = (props: {
|
|
1202
|
+
owner: CalendarProps;
|
|
1203
|
+
date: Date;
|
|
1204
|
+
selectedDate: Date;
|
|
1205
|
+
events: NormalizedEvent[];
|
|
1206
|
+
labels: Required<CalendarLabels>;
|
|
1207
|
+
}): JSX.Element => {
|
|
1208
|
+
const dateConfig = () => ownerDateConfig(props.owner);
|
|
1209
|
+
const selectedEvents = () => props.events.filter((event) => event.dayKey === calendar.formatDateKey(props.selectedDate, dateConfig()));
|
|
1210
|
+
return (
|
|
1211
|
+
<div class="mx-auto max-w-md p-3">
|
|
1212
|
+
<MonthView owner={{ ...props.owner, withWeekNumbers: false }} date={props.date} events={props.events} labels={props.labels} />
|
|
1213
|
+
<div class="mt-4 border-t border-zinc-100 pt-3 dark:border-zinc-800/70">
|
|
1214
|
+
<div class="mb-2 text-sm font-semibold text-primary">
|
|
1215
|
+
{props.selectedDate.toLocaleDateString(dateConfig().locale ?? "en", {
|
|
1216
|
+
weekday: "long",
|
|
1217
|
+
month: "long",
|
|
1218
|
+
day: "numeric",
|
|
1219
|
+
timeZone: dateConfig().timeZone,
|
|
1220
|
+
})}
|
|
1221
|
+
</div>
|
|
1222
|
+
<Show when={selectedEvents().length > 0} fallback={<div class="py-6 text-center text-xs text-dimmed">{props.labels.noEvents}</div>}>
|
|
1223
|
+
<div class="flex flex-col gap-1">
|
|
1224
|
+
<For each={selectedEvents()}>
|
|
1225
|
+
{(event) => <EventChip event={event} owner={props.owner} href={eventHref(props.owner, event)} />}
|
|
1226
|
+
</For>
|
|
1227
|
+
</div>
|
|
1228
|
+
</Show>
|
|
1229
|
+
</div>
|
|
1230
|
+
</div>
|
|
1231
|
+
);
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
const CalendarBody = (props: { children: JSX.Element }): JSX.Element => (
|
|
1235
|
+
<div class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">{props.children}</div>
|
|
1236
|
+
);
|
|
1237
|
+
|
|
1238
|
+
const Calendar = (props: CalendarProps): JSX.Element => {
|
|
1239
|
+
const view = () => props.view ?? "month";
|
|
1240
|
+
const dateConfig = () => ownerDateConfig(props);
|
|
1241
|
+
const date = () => parseDate(props.date);
|
|
1242
|
+
const selectedDate = () => parseDate(props.selectedDate ?? props.date);
|
|
1243
|
+
const normalizedEvents = () => normalizeEvents(props.events, dateConfig());
|
|
1244
|
+
const mergedLabels = () => ({ ...labels, ...props.labels });
|
|
1245
|
+
const days = () => {
|
|
1246
|
+
if (view() === "day") return [date()];
|
|
1247
|
+
return calendar.getWeekDays(date(), dateConfig());
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
return (
|
|
1251
|
+
<section class={`paper flex min-h-0 flex-col overflow-hidden ${props.class ?? ""}`}>
|
|
1252
|
+
<CalendarHeader date={date()} view={view()} labels={mergedLabels()} owner={props} />
|
|
1253
|
+
<Show
|
|
1254
|
+
when={view() !== "month"}
|
|
1255
|
+
fallback={
|
|
1256
|
+
<CalendarBody>
|
|
1257
|
+
<MonthView owner={props} date={date()} events={normalizedEvents()} labels={mergedLabels()} />
|
|
1258
|
+
</CalendarBody>
|
|
1259
|
+
}
|
|
1260
|
+
>
|
|
1261
|
+
<Show
|
|
1262
|
+
when={view() !== "year"}
|
|
1263
|
+
fallback={
|
|
1264
|
+
<CalendarBody>
|
|
1265
|
+
<YearView owner={props} date={date()} events={normalizedEvents()} />
|
|
1266
|
+
</CalendarBody>
|
|
1267
|
+
}
|
|
1268
|
+
>
|
|
1269
|
+
<Show
|
|
1270
|
+
when={view() !== "mobile-month"}
|
|
1271
|
+
fallback={
|
|
1272
|
+
<CalendarBody>
|
|
1273
|
+
<MobileMonthView
|
|
1274
|
+
owner={props}
|
|
1275
|
+
date={date()}
|
|
1276
|
+
selectedDate={selectedDate()}
|
|
1277
|
+
events={normalizedEvents()}
|
|
1278
|
+
labels={mergedLabels()}
|
|
1279
|
+
/>
|
|
1280
|
+
</CalendarBody>
|
|
1281
|
+
}
|
|
1282
|
+
>
|
|
1283
|
+
<TimeGridView owner={props} date={date()} events={normalizedEvents()} labels={mergedLabels()} days={days()} />
|
|
1284
|
+
</Show>
|
|
1285
|
+
</Show>
|
|
1286
|
+
</Show>
|
|
1287
|
+
</section>
|
|
1288
|
+
);
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1291
|
+
export default Calendar;
|