@viliha/vui-ui 1.1.7 → 1.2.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.
Files changed (138) hide show
  1. package/AGENT.md +79 -9
  2. package/README.md +60 -0
  3. package/bin/vui.mjs +211 -0
  4. package/package.json +10 -2
  5. package/src/record-view.tsx +10 -5
  6. package/template/.env.example +20 -0
  7. package/template/app/(app)/branches/branches-table.tsx +69 -0
  8. package/template/app/(app)/branches/page.tsx +13 -0
  9. package/template/app/(app)/businesses/businesses-table.tsx +39 -0
  10. package/template/app/(app)/businesses/page.tsx +13 -0
  11. package/template/app/(app)/calendar/page.tsx +725 -0
  12. package/template/app/(app)/charts/charts-content.tsx +190 -0
  13. package/template/app/(app)/charts/layout.tsx +8 -0
  14. package/template/app/(app)/charts/page.tsx +44 -0
  15. package/template/app/(app)/chat/layout.tsx +14 -0
  16. package/template/app/(app)/chat/page.tsx +311 -0
  17. package/template/app/(app)/components/layout.tsx +8 -0
  18. package/template/app/(app)/components/page.tsx +464 -0
  19. package/template/app/(app)/crm/companies/companies-table.tsx +56 -0
  20. package/template/app/(app)/crm/companies/page.tsx +13 -0
  21. package/template/app/(app)/crm/opportunities/opportunities-board.tsx +484 -0
  22. package/template/app/(app)/crm/opportunities/page.tsx +13 -0
  23. package/template/app/(app)/crm/people/page.tsx +13 -0
  24. package/template/app/(app)/crm/people/people-table.tsx +50 -0
  25. package/template/app/(app)/dashboard/layout.tsx +8 -0
  26. package/template/app/(app)/dashboard/page.tsx +239 -0
  27. package/template/app/(app)/departments/departments-table.tsx +55 -0
  28. package/template/app/(app)/departments/page.tsx +13 -0
  29. package/template/app/(app)/employees/employees-table.tsx +64 -0
  30. package/template/app/(app)/employees/page.tsx +13 -0
  31. package/template/app/(app)/forms/layout.tsx +8 -0
  32. package/template/app/(app)/forms/page.tsx +388 -0
  33. package/template/app/(app)/layout.tsx +74 -0
  34. package/template/app/(app)/markets/markets-table.tsx +76 -0
  35. package/template/app/(app)/markets/page.tsx +13 -0
  36. package/template/app/(app)/organizations/edit/page.tsx +72 -0
  37. package/template/app/(app)/organizations/new/page.tsx +46 -0
  38. package/template/app/(app)/organizations/organizations-config.tsx +97 -0
  39. package/template/app/(app)/organizations/organizations-table.tsx +42 -0
  40. package/template/app/(app)/organizations/page.tsx +13 -0
  41. package/template/app/(app)/settings/layout.tsx +8 -0
  42. package/template/app/(app)/settings/page.tsx +255 -0
  43. package/template/app/(app)/steps/page.tsx +263 -0
  44. package/template/app/(app)/support/layout.tsx +14 -0
  45. package/template/app/(app)/support/page.tsx +345 -0
  46. package/template/app/(app)/system/cities/cities-table.tsx +34 -0
  47. package/template/app/(app)/system/cities/page.tsx +13 -0
  48. package/template/app/(app)/system/countries/countries-table.tsx +34 -0
  49. package/template/app/(app)/system/countries/page.tsx +13 -0
  50. package/template/app/(app)/system/currencies/currencies-table.tsx +34 -0
  51. package/template/app/(app)/system/currencies/page.tsx +13 -0
  52. package/template/app/(app)/system/languages/languages-table.tsx +32 -0
  53. package/template/app/(app)/system/languages/page.tsx +13 -0
  54. package/template/app/(app)/system/regions/page.tsx +13 -0
  55. package/template/app/(app)/system/regions/regions-table.tsx +32 -0
  56. package/template/app/(app)/users/page.tsx +13 -0
  57. package/template/app/(app)/users/users-table.tsx +97 -0
  58. package/template/app/_components/app-sidebar.tsx +565 -0
  59. package/template/app/_components/auth.tsx +184 -0
  60. package/template/app/_components/breadcrumbs.tsx +26 -0
  61. package/template/app/_components/global-search.tsx +209 -0
  62. package/template/app/_components/logo.tsx +49 -0
  63. package/template/app/_components/nav-config.ts +138 -0
  64. package/template/app/_components/open-tabs.tsx +530 -0
  65. package/template/app/_components/quick-actions.tsx +140 -0
  66. package/template/app/_components/route-meta.ts +137 -0
  67. package/template/app/_components/set-page-title.tsx +15 -0
  68. package/template/app/_components/showcase.tsx +29 -0
  69. package/template/app/_components/stat-card.tsx +56 -0
  70. package/template/app/_components/theme-toggle.tsx +46 -0
  71. package/template/app/_components/top-bar.tsx +109 -0
  72. package/template/app/_components/user-menu.tsx +109 -0
  73. package/template/app/_components/wordmark.tsx +45 -0
  74. package/template/app/apple-icon.png +0 -0
  75. package/template/app/auth/forgot-password/page.tsx +102 -0
  76. package/template/app/auth/layout.tsx +43 -0
  77. package/template/app/auth/page.tsx +13 -0
  78. package/template/app/auth/reset-password/page.tsx +108 -0
  79. package/template/app/auth/signin/page.tsx +238 -0
  80. package/template/app/auth/signup/page.tsx +159 -0
  81. package/template/app/auth/verify/page.tsx +86 -0
  82. package/template/app/error.tsx +56 -0
  83. package/template/app/globals.css +6 -0
  84. package/template/app/icon.png +0 -0
  85. package/template/app/icon.svg +4 -0
  86. package/template/app/layout.tsx +103 -0
  87. package/template/app/not-found.tsx +29 -0
  88. package/template/app/onboarding/layout.tsx +31 -0
  89. package/template/app/onboarding/page.tsx +399 -0
  90. package/template/app/page.tsx +13 -0
  91. package/template/app/register-business/layout.tsx +30 -0
  92. package/template/app/register-business/page.tsx +234 -0
  93. package/template/components/ui/accordion.tsx +66 -0
  94. package/template/components/ui/alert-dialog.tsx +196 -0
  95. package/template/components/ui/alert.tsx +66 -0
  96. package/template/components/ui/aspect-ratio.tsx +11 -0
  97. package/template/components/ui/avatar.tsx +109 -0
  98. package/template/components/ui/badge.tsx +48 -0
  99. package/template/components/ui/breadcrumb.tsx +109 -0
  100. package/template/components/ui/button.tsx +64 -0
  101. package/template/components/ui/calendar.tsx +220 -0
  102. package/template/components/ui/card.tsx +92 -0
  103. package/template/components/ui/checkbox.tsx +32 -0
  104. package/template/components/ui/collapsible.tsx +33 -0
  105. package/template/components/ui/command.tsx +184 -0
  106. package/template/components/ui/dialog.tsx +158 -0
  107. package/template/components/ui/dropdown-menu.tsx +257 -0
  108. package/template/components/ui/form.tsx +167 -0
  109. package/template/components/ui/hover-card.tsx +44 -0
  110. package/template/components/ui/input-otp.tsx +77 -0
  111. package/template/components/ui/input.tsx +21 -0
  112. package/template/components/ui/label.tsx +24 -0
  113. package/template/components/ui/popover.tsx +89 -0
  114. package/template/components/ui/progress.tsx +31 -0
  115. package/template/components/ui/radio-group.tsx +45 -0
  116. package/template/components/ui/scroll-area.tsx +58 -0
  117. package/template/components/ui/select.tsx +190 -0
  118. package/template/components/ui/separator.tsx +28 -0
  119. package/template/components/ui/sheet.tsx +143 -0
  120. package/template/components/ui/skeleton.tsx +13 -0
  121. package/template/components/ui/slider.tsx +63 -0
  122. package/template/components/ui/sonner.tsx +40 -0
  123. package/template/components/ui/switch.tsx +35 -0
  124. package/template/components/ui/table.tsx +116 -0
  125. package/template/components/ui/tabs.tsx +91 -0
  126. package/template/components/ui/textarea.tsx +18 -0
  127. package/template/components/ui/toggle-group.tsx +83 -0
  128. package/template/components/ui/toggle.tsx +47 -0
  129. package/template/components/ui/tooltip.tsx +57 -0
  130. package/template/lib/auth-demo.ts +94 -0
  131. package/template/lib/crm-data.ts +80 -0
  132. package/template/lib/demo-data.ts +267 -0
  133. package/template/lib/mock-data.ts +169 -0
  134. package/template/lib/org-store.ts +50 -0
  135. package/template/lib/seo.ts +188 -0
  136. package/template/lib/utils.ts +3 -0
  137. package/template/next.config.mjs +13 -0
  138. package/template/postcss.config.mjs +7 -0
@@ -0,0 +1,725 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import {
5
+ addDays,
6
+ addMonths,
7
+ endOfWeek,
8
+ format,
9
+ isSameDay,
10
+ isSameMonth,
11
+ isToday,
12
+ setHours,
13
+ startOfMonth,
14
+ startOfWeek,
15
+ subMonths,
16
+ } from "date-fns";
17
+ import {
18
+ CalendarIcon,
19
+ ChevronLeftIcon,
20
+ ChevronRightIcon,
21
+ ClockIcon,
22
+ Cross2Icon,
23
+ HomeIcon,
24
+ PersonIcon,
25
+ PlusIcon,
26
+ TextAlignLeftIcon,
27
+ VideoIcon,
28
+ } from "@radix-ui/react-icons";
29
+
30
+ import { cn } from "@viliha/vui-ui/utils";
31
+ import { Button } from "@viliha/vui-ui/button";
32
+ import { Input } from "@viliha/vui-ui/input";
33
+ import { Checkbox } from "@viliha/vui-ui/checkbox";
34
+ import { Select } from "@viliha/vui-ui/select";
35
+ import {
36
+ Dialog,
37
+ DialogBody,
38
+ DialogFooter,
39
+ DialogHeader,
40
+ DialogTitle,
41
+ } from "@viliha/vui-ui/dialog";
42
+ import { Breadcrumbs } from "@/app/_components/breadcrumbs";
43
+ import { SetPageTitle } from "@/app/_components/set-page-title";
44
+
45
+ type EvType = "event" | "task" | "appointment";
46
+ type Ev = {
47
+ id: number;
48
+ date: string; // yyyy-MM-dd
49
+ start: string; // HH:mm
50
+ end: string; // HH:mm
51
+ title: string;
52
+ color: string; // EVENT_COLORS key
53
+ type: EvType;
54
+ guests?: string;
55
+ meet?: boolean;
56
+ location?: string;
57
+ description?: string;
58
+ notify: string; // minutes before, as string
59
+ };
60
+ type Draft = Omit<Ev, "id">;
61
+ type Mode = "month" | "week" | "day";
62
+
63
+ const keyOf = (d: Date) => format(d, "yyyy-MM-dd");
64
+ const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
65
+ const HOURS = Array.from({ length: 24 }, (_, h) => h);
66
+ const HOUR_REM = 3.5; // matches h-14 hour rows
67
+
68
+ // time helpers ("HH:mm")
69
+ const toMin = (t: string) => Number(t.slice(0, 2)) * 60 + Number(t.slice(3, 5));
70
+ const minToTime = (m: number) => {
71
+ const c = Math.max(0, Math.min(24 * 60 - 1, m));
72
+ return `${String(Math.floor(c / 60)).padStart(2, "0")}:${String(c % 60).padStart(2, "0")}`;
73
+ };
74
+ const hourLabel = (h: number) => format(setHours(new Date(2000, 0, 1), h), "h a");
75
+ const timeLabel = (t: string) => format(new Date(`2000-01-01T${t}:00`), "h:mm a");
76
+
77
+ // Google-style color labels — static Tailwind classes (same convention as TAB_COLORS).
78
+ const EVENT_COLORS: { key: string; label: string; chip: string; dot: string }[] =
79
+ [
80
+ { key: "blueberry", label: "Blueberry", chip: "bg-blue-600 text-white", dot: "bg-blue-600" },
81
+ { key: "tomato", label: "Tomato", chip: "bg-red-600 text-white", dot: "bg-red-600" },
82
+ { key: "tangerine", label: "Tangerine", chip: "bg-orange-500 text-white", dot: "bg-orange-500" },
83
+ { key: "banana", label: "Banana", chip: "bg-yellow-400 text-black", dot: "bg-yellow-400" },
84
+ { key: "sage", label: "Sage", chip: "bg-green-500 text-white", dot: "bg-green-500" },
85
+ { key: "peacock", label: "Peacock", chip: "bg-cyan-600 text-white", dot: "bg-cyan-600" },
86
+ { key: "lavender", label: "Lavender", chip: "bg-violet-400 text-black", dot: "bg-violet-400" },
87
+ { key: "grape", label: "Grape", chip: "bg-purple-600 text-white", dot: "bg-purple-600" },
88
+ { key: "graphite", label: "Graphite", chip: "bg-gray-500 text-white", dot: "bg-gray-500" },
89
+ ];
90
+ const DEFAULT_COLOR = "blueberry";
91
+ const CHIP_FOR = new Map(EVENT_COLORS.map((c) => [c.key, c.chip]));
92
+ const chipFor = (key: string) =>
93
+ CHIP_FOR.get(key) ?? "bg-blue-600 text-white";
94
+
95
+ // 15-minute slots, labelled in AM/PM — replaces the native time picker.
96
+ const TIME_OPTIONS = Array.from({ length: 96 }, (_, i) => {
97
+ const t = minToTime(i * 15);
98
+ return { value: t, label: timeLabel(t) };
99
+ });
100
+
101
+ const NOTIFY_OPTIONS = [
102
+ { value: "0", label: "At time of event" },
103
+ { value: "5", label: "5 minutes before" },
104
+ { value: "10", label: "10 minutes before" },
105
+ { value: "15", label: "15 minutes before" },
106
+ { value: "30", label: "30 minutes before" },
107
+ { value: "60", label: "1 hour before" },
108
+ { value: "1440", label: "1 day before" },
109
+ ];
110
+ const TYPE_TABS: { key: EvType; label: string }[] = [
111
+ { key: "event", label: "Event" },
112
+ { key: "task", label: "Task" },
113
+ { key: "appointment", label: "Appointment schedule" },
114
+ ];
115
+
116
+ const today = new Date();
117
+ const rel = (days: number) => keyOf(addDays(today, days));
118
+ const SEED: Ev[] = [
119
+ { id: 1, date: rel(0), start: "09:30", end: "10:00", title: "Standup", color: "peacock", type: "event", notify: "10" },
120
+ { id: 6, date: rel(0), start: "09:45", end: "10:30", title: "Client sync", color: "tangerine", type: "event", notify: "10" },
121
+ { id: 2, date: rel(0), start: "14:00", end: "15:00", title: "Design review", color: "grape", type: "event", notify: "10", meet: true },
122
+ { id: 3, date: rel(2), start: "11:00", end: "12:00", title: "Onboarding call", color: "tangerine", type: "event", notify: "15" },
123
+ { id: 4, date: rel(5), start: "16:30", end: "17:00", title: "1:1 with Ava", color: "sage", type: "event", notify: "10" },
124
+ { id: 5, date: rel(-3), start: "10:00", end: "11:30", title: "Sprint planning", color: "tomato", type: "event", notify: "30" },
125
+ ];
126
+
127
+ export default function CalendarPage() {
128
+ const [mode, setMode] = React.useState<Mode>("month");
129
+ const [cursor, setCursor] = React.useState(today);
130
+ const [events, setEvents] = React.useState<Ev[]>(SEED);
131
+ const [draft, setDraft] = React.useState<Draft | null>(null);
132
+ const [more, setMore] = React.useState(false);
133
+
134
+ const byDate = React.useMemo(() => {
135
+ const map = new Map<string, Ev[]>();
136
+ for (const e of events) {
137
+ const list = map.get(e.date);
138
+ if (list) list.push(e);
139
+ else map.set(e.date, [e]);
140
+ }
141
+ for (const list of map.values())
142
+ list.sort((a, b) => a.start.localeCompare(b.start));
143
+ return map;
144
+ }, [events]);
145
+
146
+ const openAdd = (dateKey: string, hour?: number) => {
147
+ const start = hour === undefined ? "09:00" : `${String(hour).padStart(2, "0")}:00`;
148
+ setMore(false);
149
+ setDraft({
150
+ date: dateKey,
151
+ start,
152
+ end: minToTime(toMin(start) + 60),
153
+ title: "",
154
+ color: DEFAULT_COLOR,
155
+ type: "event",
156
+ guests: "",
157
+ meet: false,
158
+ location: "",
159
+ description: "",
160
+ notify: "10",
161
+ });
162
+ };
163
+ const setD = (patch: Partial<Draft>) =>
164
+ setDraft((d) => (d ? { ...d, ...patch } : d));
165
+ const save = () => {
166
+ if (!draft || !draft.title.trim()) return;
167
+ const end =
168
+ toMin(draft.end) > toMin(draft.start)
169
+ ? draft.end
170
+ : minToTime(toMin(draft.start) + 60);
171
+ setEvents((prev) => [
172
+ ...prev,
173
+ { ...draft, id: Date.now(), title: draft.title.trim(), end },
174
+ ]);
175
+ setDraft(null);
176
+ };
177
+ const remove = (id: number) =>
178
+ setEvents((prev) => prev.filter((e) => e.id !== id));
179
+
180
+ const step = (dir: 1 | -1) =>
181
+ setCursor((c) =>
182
+ mode === "month"
183
+ ? dir === 1
184
+ ? addMonths(c, 1)
185
+ : subMonths(c, 1)
186
+ : mode === "week"
187
+ ? addDays(c, dir * 7)
188
+ : addDays(c, dir),
189
+ );
190
+
191
+ const heading =
192
+ mode === "month"
193
+ ? format(cursor, "MMMM yyyy")
194
+ : mode === "week"
195
+ ? `${format(startOfWeek(cursor), "MMM d")} – ${format(endOfWeek(cursor), "MMM d, yyyy")}`
196
+ : format(cursor, "EEEE, MMM d, yyyy");
197
+
198
+ return (
199
+ <div className="flex h-full flex-col">
200
+ <SetPageTitle title="Calendar" icon={CalendarIcon} />
201
+ <div className="flex h-12 shrink-0 items-center border-b border-border px-4">
202
+ <Breadcrumbs />
203
+ </div>
204
+
205
+ <div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
206
+ {/* Toolbar */}
207
+ <div className="flex shrink-0 items-center justify-between gap-3">
208
+ <div className="flex items-center gap-2">
209
+ <Button onClick={() => setCursor(today)}>Today</Button>
210
+ <div className="flex items-center">
211
+ <Button size="icon" aria-label="Previous" onClick={() => step(-1)}>
212
+ <ChevronLeftIcon className="size-4" />
213
+ </Button>
214
+ <Button size="icon" aria-label="Next" onClick={() => step(1)}>
215
+ <ChevronRightIcon className="size-4" />
216
+ </Button>
217
+ </div>
218
+ <h2 className="text-lg font-semibold tracking-tight">{heading}</h2>
219
+ </div>
220
+ <div className="flex items-center gap-2">
221
+ <div
222
+ role="tablist"
223
+ aria-label="Calendar view"
224
+ className="flex items-center rounded-md border border-border p-0.5"
225
+ >
226
+ {(["month", "week", "day"] as Mode[]).map((m) => (
227
+ <button
228
+ key={m}
229
+ type="button"
230
+ role="tab"
231
+ aria-selected={mode === m}
232
+ onClick={() => setMode(m)}
233
+ className={cnTab(mode === m)}
234
+ >
235
+ {m.charAt(0).toUpperCase() + m.slice(1)}
236
+ </button>
237
+ ))}
238
+ </div>
239
+ <Button variant="primary" onClick={() => openAdd(keyOf(today))}>
240
+ <PlusIcon className="size-4" />
241
+ Add appointment
242
+ </Button>
243
+ </div>
244
+ </div>
245
+
246
+ {mode === "month" ? (
247
+ <MonthView cursor={cursor} byDate={byDate} openAdd={openAdd} remove={remove} />
248
+ ) : (
249
+ <TimeGridView
250
+ days={
251
+ mode === "day"
252
+ ? [cursor]
253
+ : Array.from({ length: 7 }, (_, i) => addDays(startOfWeek(cursor), i))
254
+ }
255
+ byDate={byDate}
256
+ openAdd={openAdd}
257
+ remove={remove}
258
+ />
259
+ )}
260
+ </div>
261
+
262
+ {/* Add appointment dialog — Google-style */}
263
+ <Dialog
264
+ open={draft !== null}
265
+ onClose={() => setDraft(null)}
266
+ label="Add appointment"
267
+ className="max-w-lg"
268
+ >
269
+ <DialogHeader>
270
+ <DialogTitle>New appointment</DialogTitle>
271
+ </DialogHeader>
272
+ {draft && (
273
+ <DialogBody>
274
+ <div className="flex flex-col gap-4">
275
+ {/* Title with live color accent */}
276
+ <div className="flex items-center gap-3">
277
+ <span
278
+ aria-hidden
279
+ className={cn(
280
+ "size-3 shrink-0 rounded-full",
281
+ EVENT_COLORS.find((c) => c.key === draft.color)?.dot,
282
+ )}
283
+ />
284
+ <input
285
+ value={draft.title}
286
+ onChange={(e) => setD({ title: e.target.value })}
287
+ onKeyDown={(e) => e.key === "Enter" && save()}
288
+ placeholder="Add title"
289
+ aria-label="Title"
290
+ className="w-full border-0 border-b border-input bg-transparent pb-1 text-lg font-medium placeholder:font-normal placeholder:text-muted-foreground focus-visible:border-[var(--button-primary)] focus-visible:outline-none"
291
+ />
292
+ </div>
293
+
294
+ {/* Type tabs */}
295
+ <div
296
+ role="tablist"
297
+ aria-label="Entry type"
298
+ className="flex items-center gap-1 rounded-md bg-muted/50 p-0.5"
299
+ >
300
+ {TYPE_TABS.map((t) => (
301
+ <button
302
+ key={t.key}
303
+ type="button"
304
+ role="tab"
305
+ aria-selected={draft.type === t.key}
306
+ onClick={() => setD({ type: t.key })}
307
+ className={cnTab(draft.type === t.key)}
308
+ >
309
+ {t.label}
310
+ </button>
311
+ ))}
312
+ </div>
313
+
314
+ {/* Date + start–end range */}
315
+ <Row icon={<ClockIcon className="size-4" />}>
316
+ <span className="text-sm font-medium">
317
+ {format(new Date(`${draft.date}T00:00:00`), "EEEE, MMM d")}
318
+ </span>
319
+ <Select
320
+ value={draft.start}
321
+ onValueChange={(v) =>
322
+ setD({
323
+ start: v,
324
+ end:
325
+ toMin(draft.end) <= toMin(v)
326
+ ? minToTime(toMin(v) + 60)
327
+ : draft.end,
328
+ })
329
+ }
330
+ options={TIME_OPTIONS}
331
+ ariaLabel="Start time"
332
+ className="w-28 shrink-0"
333
+ />
334
+ <span className="text-muted-foreground">–</span>
335
+ <Select
336
+ value={draft.end}
337
+ onValueChange={(v) => setD({ end: v })}
338
+ options={TIME_OPTIONS}
339
+ ariaLabel="End time"
340
+ className="w-28 shrink-0"
341
+ />
342
+ </Row>
343
+
344
+ {/* Color label — essential, always visible */}
345
+ <Row icon={<CalendarIcon className="size-4" />}>
346
+ <div className="flex flex-wrap items-center gap-1.5">
347
+ {EVENT_COLORS.map((c) => (
348
+ <button
349
+ key={c.key}
350
+ type="button"
351
+ aria-label={c.label}
352
+ aria-pressed={draft.color === c.key}
353
+ title={c.label}
354
+ onClick={() => setD({ color: c.key })}
355
+ className={cn(
356
+ "size-5 rounded-full ring-offset-2 ring-offset-background transition-transform hover:scale-110",
357
+ c.dot,
358
+ draft.color === c.key && "ring-2 ring-foreground",
359
+ )}
360
+ />
361
+ ))}
362
+ </div>
363
+ </Row>
364
+
365
+ {/* Progressive disclosure — keep the default add simple */}
366
+ {!more && (
367
+ <button
368
+ type="button"
369
+ onClick={() => setMore(true)}
370
+ className="self-start text-sm font-medium text-[var(--button-primary)] hover:underline"
371
+ >
372
+ More options
373
+ </button>
374
+ )}
375
+
376
+ {more && (
377
+ <div className="flex flex-col gap-4 border-t border-border pt-4">
378
+ {/* Guests */}
379
+ <Row icon={<PersonIcon className="size-4" />}>
380
+ <Input
381
+ value={draft.guests}
382
+ onChange={(e) => setD({ guests: e.target.value })}
383
+ placeholder="Add guests (comma-separated)"
384
+ aria-label="Guests"
385
+ />
386
+ </Row>
387
+
388
+ {/* Google Meet */}
389
+ <Row icon={<VideoIcon className="size-4" />}>
390
+ <label
391
+ htmlFor="cal-meet"
392
+ className="flex cursor-pointer items-center gap-2 text-sm"
393
+ >
394
+ <Checkbox
395
+ id="cal-meet"
396
+ checked={draft.meet}
397
+ onChange={(e) => setD({ meet: e.target.checked })}
398
+ />
399
+ Add Google Meet video conferencing
400
+ </label>
401
+ </Row>
402
+
403
+ {/* Location */}
404
+ <Row icon={<HomeIcon className="size-4" />}>
405
+ <Input
406
+ value={draft.location}
407
+ onChange={(e) => setD({ location: e.target.value })}
408
+ placeholder="Add location"
409
+ aria-label="Location"
410
+ />
411
+ </Row>
412
+
413
+ {/* Description */}
414
+ <Row icon={<TextAlignLeftIcon className="size-4" />}>
415
+ <textarea
416
+ value={draft.description}
417
+ onChange={(e) => setD({ description: e.target.value })}
418
+ placeholder="Add description"
419
+ aria-label="Description"
420
+ rows={2}
421
+ className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
422
+ />
423
+ </Row>
424
+
425
+ {/* Notify */}
426
+ <Row icon={<PersonIcon className="size-4" />}>
427
+ <div className="flex items-center gap-2 text-sm">
428
+ <span>Suman Bonakurthi · Busy ·</span>
429
+ <Select
430
+ value={draft.notify}
431
+ onValueChange={(v) => setD({ notify: v })}
432
+ options={NOTIFY_OPTIONS}
433
+ ariaLabel="Notification"
434
+ className="w-44"
435
+ />
436
+ </div>
437
+ </Row>
438
+ </div>
439
+ )}
440
+ </div>
441
+ </DialogBody>
442
+ )}
443
+ <DialogFooter>
444
+ <Button onClick={() => setDraft(null)}>
445
+ <Cross2Icon className="size-4" />
446
+ Cancel
447
+ </Button>
448
+ <Button
449
+ variant="primary"
450
+ onClick={save}
451
+ disabled={!draft?.title.trim()}
452
+ >
453
+ <PlusIcon className="size-4" />
454
+ Save
455
+ </Button>
456
+ </DialogFooter>
457
+ </Dialog>
458
+ </div>
459
+ );
460
+ }
461
+
462
+ function Row({
463
+ icon,
464
+ children,
465
+ }: {
466
+ icon: React.ReactNode;
467
+ children: React.ReactNode;
468
+ }) {
469
+ return (
470
+ <div className="flex items-center gap-3">
471
+ <span className="shrink-0 text-muted-foreground">{icon}</span>
472
+ <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
473
+ {children}
474
+ </div>
475
+ </div>
476
+ );
477
+ }
478
+
479
+ type ViewProps = {
480
+ byDate: Map<string, Ev[]>;
481
+ openAdd: (dateKey: string, hour?: number) => void;
482
+ remove: (id: number) => void;
483
+ };
484
+
485
+ function MonthView({
486
+ cursor,
487
+ byDate,
488
+ openAdd,
489
+ remove,
490
+ }: ViewProps & { cursor: Date }) {
491
+ const days = React.useMemo(() => {
492
+ const start = startOfWeek(startOfMonth(cursor), { weekStartsOn: 0 });
493
+ return Array.from({ length: 42 }, (_, i) => addDays(start, i));
494
+ }, [cursor]);
495
+
496
+ return (
497
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card">
498
+ <div className="grid shrink-0 grid-cols-7 border-b border-border bg-muted/40">
499
+ {WEEKDAYS.map((d) => (
500
+ <div
501
+ key={d}
502
+ className="px-2 py-1.5 text-center text-xs font-medium text-muted-foreground"
503
+ >
504
+ {d}
505
+ </div>
506
+ ))}
507
+ </div>
508
+ <div className="grid min-h-0 flex-1 grid-cols-7 grid-rows-6">
509
+ {days.map((day) => {
510
+ const dk = keyOf(day);
511
+ const list = byDate.get(dk) ?? [];
512
+ const inMonth = isSameMonth(day, cursor);
513
+ const shown = list.slice(0, 3);
514
+ const extra = list.length - shown.length;
515
+ return (
516
+ <div key={dk} className={cnCell(inMonth)}>
517
+ <div className="flex items-center justify-between">
518
+ <span
519
+ className={cnDate(inMonth, isToday(day))}
520
+ aria-current={isToday(day) ? "date" : undefined}
521
+ >
522
+ {format(day, "d")}
523
+ </span>
524
+ <button
525
+ type="button"
526
+ onClick={() => openAdd(dk)}
527
+ aria-label={`Add appointment on ${format(day, "d MMM")}`}
528
+ className="grid size-5 shrink-0 place-items-center rounded text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
529
+ >
530
+ <PlusIcon className="size-3.5" />
531
+ </button>
532
+ </div>
533
+ <div className="mt-0.5 flex flex-col gap-0.5 overflow-hidden">
534
+ {shown.map((e) => (
535
+ <button
536
+ key={e.id}
537
+ type="button"
538
+ onClick={() => remove(e.id)}
539
+ title={`${timeLabel(e.start)} ${e.title} — click to remove`}
540
+ className={cn(
541
+ "flex items-center gap-1 truncate rounded px-1 py-0.5 text-left text-[11px] transition-opacity hover:opacity-90",
542
+ chipFor(e.color),
543
+ )}
544
+ >
545
+ <span className="tabular-nums opacity-80">{e.start}</span>
546
+ <span className="truncate">{e.title}</span>
547
+ </button>
548
+ ))}
549
+ {extra > 0 && (
550
+ <span className="px-1 text-[11px] text-muted-foreground">
551
+ +{extra} more
552
+ </span>
553
+ )}
554
+ </div>
555
+ </div>
556
+ );
557
+ })}
558
+ </div>
559
+ </div>
560
+ );
561
+ }
562
+
563
+ function TimeGridView({
564
+ days,
565
+ byDate,
566
+ openAdd,
567
+ remove,
568
+ }: ViewProps & { days: Date[] }) {
569
+ const nowTopRem = ((today.getHours() * 60 + today.getMinutes()) / 60) * HOUR_REM;
570
+ const scrollRef = React.useRef<HTMLDivElement>(null);
571
+ const hasToday = days.some((d) => isSameDay(d, today));
572
+ // Scroll the current time into view (centered) whenever this grid is shown.
573
+ React.useEffect(() => {
574
+ const el = scrollRef.current;
575
+ if (!el || !hasToday) return;
576
+ el.scrollTop = Math.max(0, nowTopRem * 16 - el.clientHeight / 2);
577
+ }, [hasToday, nowTopRem]);
578
+ return (
579
+ <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border bg-card">
580
+ {/* Day header */}
581
+ <div className="flex shrink-0 border-b border-border bg-muted/40">
582
+ <div className="w-16 shrink-0" />
583
+ {days.map((day) => (
584
+ <div key={keyOf(day)} className="flex-1 px-2 py-1.5 text-center">
585
+ <div className="text-xs font-medium text-muted-foreground">
586
+ {format(day, "EEE")}
587
+ </div>
588
+ <div className={cnDate(true, isToday(day))}>{format(day, "d")}</div>
589
+ </div>
590
+ ))}
591
+ </div>
592
+ {/* Scrollable hour grid */}
593
+ <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
594
+ <div className="flex">
595
+ {/* Time gutter */}
596
+ <div className="w-16 shrink-0">
597
+ {HOURS.map((h) => (
598
+ <div
599
+ key={h}
600
+ className="h-14 border-b border-r border-border pr-1 pt-0.5 text-right text-[11px] tabular-nums text-muted-foreground"
601
+ >
602
+ {hourLabel(h)}
603
+ </div>
604
+ ))}
605
+ </div>
606
+ {/* Day columns */}
607
+ <div className="relative flex flex-1">
608
+ {days.some((d) => isSameDay(d, today)) && (
609
+ <div
610
+ className="pointer-events-none absolute inset-x-0 z-20 border-t-2 border-red-500"
611
+ style={{ top: `${nowTopRem}rem` }}
612
+ >
613
+ <span className="absolute -left-1 -top-1 size-2 rounded-full bg-red-500" />
614
+ </div>
615
+ )}
616
+ {days.map((day) => {
617
+ const dk = keyOf(day);
618
+ const list = byDate.get(dk) ?? [];
619
+ return (
620
+ <div key={dk} className="relative flex-1 border-l border-border">
621
+ {/* Background hour cells (click to add) */}
622
+ {HOURS.map((h) => (
623
+ <button
624
+ key={h}
625
+ type="button"
626
+ onClick={() => openAdd(dk, h)}
627
+ aria-label={`Add appointment on ${format(day, "d MMM")} at ${hourLabel(h)}`}
628
+ className="block h-14 w-full border-b border-border transition-colors hover:bg-accent/30"
629
+ />
630
+ ))}
631
+ {/* Event blocks, positioned by start–end; overlapping events
632
+ split into side-by-side columns (lane / colsInCluster). */}
633
+ {layoutDay(list).map(({ ev: e, lane, cols }) => {
634
+ const top = (toMin(e.start) / 60) * HOUR_REM;
635
+ const height = (Math.max(toMin(e.end) - toMin(e.start), 20) / 60) * HOUR_REM;
636
+ return (
637
+ <button
638
+ key={e.id}
639
+ type="button"
640
+ onClick={() => remove(e.id)}
641
+ title={`${timeLabel(e.start)} – ${timeLabel(e.end)} · ${e.title} — click to remove`}
642
+ style={{
643
+ top: `${top}rem`,
644
+ height: `${height}rem`,
645
+ left: `calc(${(lane / cols) * 100}% + 1px)`,
646
+ width: `calc(${(1 / cols) * 100}% - 2px)`,
647
+ }}
648
+ className={cn(
649
+ "absolute z-10 flex flex-col overflow-hidden rounded px-1 py-0.5 text-left text-[11px] leading-tight shadow-sm transition-opacity hover:opacity-90",
650
+ chipFor(e.color),
651
+ )}
652
+ >
653
+ <span className="truncate font-medium">{e.title}</span>
654
+ <span className="truncate tabular-nums opacity-80">
655
+ {timeLabel(e.start)} – {timeLabel(e.end)}
656
+ </span>
657
+ </button>
658
+ );
659
+ })}
660
+ </div>
661
+ );
662
+ })}
663
+ </div>
664
+ </div>
665
+ </div>
666
+ </div>
667
+ );
668
+ }
669
+
670
+ /** Assign overlapping events to side-by-side lanes. Events that overlap in time
671
+ * share a "cluster"; within it each gets a lane, and all report the cluster's
672
+ * column count so widths divide evenly. */
673
+ function layoutDay(events: Ev[]): { ev: Ev; lane: number; cols: number }[] {
674
+ const sorted = [...events].sort(
675
+ (a, b) => toMin(a.start) - toMin(b.start) || toMin(a.end) - toMin(b.end),
676
+ );
677
+ const out: { ev: Ev; lane: number; cols: number }[] = [];
678
+ let cluster: { ev: Ev; lane: number; end: number }[] = [];
679
+ let clusterEnd = -1;
680
+ const flush = () => {
681
+ const cols = Math.max(1, ...cluster.map((c) => c.lane + 1));
682
+ for (const c of cluster) out.push({ ev: c.ev, lane: c.lane, cols });
683
+ cluster = [];
684
+ clusterEnd = -1;
685
+ };
686
+ for (const ev of sorted) {
687
+ const s = toMin(ev.start);
688
+ const e = Math.max(toMin(ev.end), s + 20); // min height match
689
+ if (cluster.length && s >= clusterEnd) flush();
690
+ const used = new Set(cluster.filter((c) => c.end > s).map((c) => c.lane));
691
+ let lane = 0;
692
+ while (used.has(lane)) lane++;
693
+ cluster.push({ ev, lane, end: e });
694
+ clusterEnd = Math.max(clusterEnd, e);
695
+ }
696
+ flush();
697
+ return out;
698
+ }
699
+
700
+ function cnTab(active: boolean): string {
701
+ return cn(
702
+ "rounded px-2.5 py-1 text-xs font-medium transition-colors",
703
+ active
704
+ ? "bg-[var(--button-primary)] text-[var(--button-primary-foreground)]"
705
+ : "text-muted-foreground hover:text-foreground",
706
+ );
707
+ }
708
+
709
+ function cnCell(inMonth: boolean): string {
710
+ return cn(
711
+ "group flex min-h-0 flex-col items-stretch overflow-hidden border-b border-r border-border p-1 transition-colors hover:bg-accent/30",
712
+ inMonth ? "bg-card" : "bg-muted/30",
713
+ );
714
+ }
715
+
716
+ function cnDate(inMonth: boolean, today_: boolean): string {
717
+ return cn(
718
+ "inline-flex size-6 items-center justify-center self-start rounded-full text-xs",
719
+ today_
720
+ ? "bg-[var(--button-primary)] font-semibold text-[var(--button-primary-foreground)]"
721
+ : inMonth
722
+ ? "text-foreground"
723
+ : "text-muted-foreground",
724
+ );
725
+ }