Skip to content
File

Blob: src/client/pages/calendars.tsx

typescript328 lines
1import { type FormEvent, useId, useState } from "react";
2import { useQueryClient } from "@tanstack/react-query";
3import { Calendar, Pencil, Plus, Trash2 } from "lucide-react";
4 
5import { $api } from "@/client/lib/api";
6import { useMe } from "@/client/auth/use-me";
7import type { Calendar as CalendarRecord } from "@/client/lib/types";
8import { Button } from "@/client/components/ui/button";
9import { Card } from "@/client/components/ui/card";
10import { ConfirmDialog } from "@/client/components/ui/confirm-dialog";
11import { Copyable } from "@/client/components/ui/copyable";
12import { Dialog } from "@/client/components/ui/dialog";
13import { EmptyState } from "@/client/components/empty-state";
14import { ErrorBanner } from "@/client/components/error-banner";
15import { Input, Textarea } from "@/client/components/ui/input";
16import { Label } from "@/client/components/ui/label";
17import { PageHeader } from "@/client/components/ui/page-header";
18import { Skeleton } from "@/client/components/loading-panel";
19import { formatDate } from "@/client/lib/format";
20import { toast } from "@/client/lib/toast";
21 
22const CALENDARS_KEY = ["get", "/api/v1/calendars"] as const;
23 
24function trimTrailingSlash(value: string): string {
25 return value.endsWith("/") ? value.slice(0, -1) : value;
26}
27 
28export function CalendarsPage() {
29 const list = $api.useQuery("get", "/api/v1/calendars", {});
30 const me = useMe();
31 const davOrigin = me.data ? trimTrailingSlash(me.data.urls.root) : "";
32 const [creating, setCreating] = useState(false);
33 const [editing, setEditing] = useState<CalendarRecord | null>(null);
34 
35 return (
36 <div className="space-y-8">
37 <PageHeader
38 icon={<Calendar className="h-7 w-7" strokeWidth={2} aria-hidden="true" />}
39 title="Calendars"
40 description="Calendar collections exposed over CalDAV."
41 actions={
42 <Button
43 variant="primary"
44 leftIcon={<Plus className="h-4 w-4" aria-hidden="true" />}
45 onClick={() => setCreating(true)}
46 aria-label="New calendar"
47 >
48 <span className="hidden sm:inline">New calendar</span>
49 </Button>
50 }
51 />
52
53 {list.isPending ? (
54 <div className="grid gap-3 sm:grid-cols-2">
55 <Skeleton className="h-24" />
56 <Skeleton className="h-24" />
57 </div>
58 ) : list.error ? (
59 <ErrorBanner error={list.error} title="Could not load calendars" />
60 ) : !list.data || list.data.length === 0 ? (
61 <EmptyState
62 icon={<Calendar className="h-7 w-7" aria-hidden="true" />}
63 title="No calendars yet"
64 description="Create your first calendar collection. You can add events from any CalDAV client afterwards."
65 action={
66 <Button
67 variant="primary"
68 leftIcon={<Plus className="h-4 w-4" aria-hidden="true" />}
69 onClick={() => setCreating(true)}
70 >
71 Create calendar
72 </Button>
73 }
74 />
75 ) : (
76 <ul className="grid gap-3 sm:grid-cols-2">
77 {list.data.map((cal) => (
78 <li key={cal.id}>
79 <CalendarRow calendar={cal} davOrigin={davOrigin} onEdit={() => setEditing(cal)} />
80 </li>
81 ))}
82 </ul>
83 )}
84
85 <CalendarFormDialog mode="create" open={creating} onClose={() => setCreating(false)} existing={null} />
86 <CalendarFormDialog mode="edit" open={editing != null} onClose={() => setEditing(null)} existing={editing} />
87 </div>
88 );
89}
90 
91function CalendarRow({
92 calendar,
93 davOrigin,
94 onEdit,
95}: {
96 calendar: CalendarRecord;
97 davOrigin: string;
98 onEdit: () => void;
99}) {
100 const queryClient = useQueryClient();
101 const [confirming, setConfirming] = useState(false);
102 const remove = $api.useMutation("delete", "/api/v1/calendars/{calendar_id}", {
103 onSuccess: () => {
104 toast.success(`Deleted ${calendar.display_name}.`);
105 void queryClient.invalidateQueries({ queryKey: CALENDARS_KEY });
106 setConfirming(false);
107 },
108 });
109 
110 return (
111 <Card className="!p-4">
112 <div className="flex items-start justify-between gap-3">
113 <div className="min-w-0 space-y-1">
114 <div className="flex items-center gap-2">
115 <span
116 aria-hidden="true"
117 className="inline-block h-3 w-3 rounded-full border border-zinc-700/60"
118 style={{ backgroundColor: calendar.color ?? "transparent" }}
119 />
120 <h3 className="truncate font-medium text-zinc-100">{calendar.display_name}</h3>
121 </div>
122 {calendar.description ? <p className="text-sm text-zinc-400">{calendar.description}</p> : null}
123 <p className="font-mono text-xs text-zinc-500">{calendar.name}</p>
124 <p className="text-xs text-zinc-500">Modified {formatDate(calendar.modified_at, { relative: true })}</p>
125 </div>
126 <div className="flex shrink-0 flex-col items-end gap-1">
127 <Button
128 size="sm"
129 variant="ghost"
130 leftIcon={<Pencil className="h-3 w-3" aria-hidden="true" />}
131 onClick={onEdit}
132 >
133 Edit
134 </Button>
135 <Button
136 size="sm"
137 variant="danger"
138 leftIcon={<Trash2 className="h-3 w-3" aria-hidden="true" />}
139 onClick={() => setConfirming(true)}
140 >
141 Delete
142 </Button>
143 </div>
144 </div>
145 <details className="mt-3 border-t border-zinc-800/60 pt-3 text-xs text-zinc-400">
146 <summary className="cursor-pointer text-zinc-300">DAV URL</summary>
147 <div className="mt-2">
148 <Copyable
149 value={calendar.href}
150 copyValue={davOrigin ? `${davOrigin}${calendar.href}` : calendar.href}
151 label="calendar URL"
152 />
153 </div>
154 </details>
155 <ConfirmDialog
156 open={confirming}
157 onClose={() => setConfirming(false)}
158 onConfirm={() => remove.mutate({ params: { path: { calendar_id: calendar.id } } })}
159 title={`Delete ${calendar.display_name}?`}
160 description="All events in this calendar will be removed. This cannot be undone."
161 confirmLabel="Delete calendar"
162 destructive
163 loading={remove.isPending}
164 >
165 <p className="text-xs text-zinc-500">
166 DAV URL: <span className="font-mono text-zinc-300">{calendar.href}</span>
167 </p>
168 </ConfirmDialog>
169 </Card>
170 );
171}
172 
173function CalendarFormDialog({
174 mode,
175 open,
176 onClose,
177 existing,
178}: {
179 mode: "create" | "edit";
180 open: boolean;
181 onClose: () => void;
182 existing: CalendarRecord | null;
183}) {
184 const queryClient = useQueryClient();
185 const create = $api.useMutation("post", "/api/v1/calendars", {
186 onSuccess: () => {
187 void queryClient.invalidateQueries({ queryKey: CALENDARS_KEY });
188 onClose();
189 },
190 });
191 const update = $api.useMutation("patch", "/api/v1/calendars/{calendar_id}", {
192 onSuccess: () => {
193 void queryClient.invalidateQueries({ queryKey: CALENDARS_KEY });
194 onClose();
195 },
196 });
197 
198 const pending = mode === "create" ? create.isPending : update.isPending;
199 const error = mode === "create" ? create.error : update.error;
200 const formId = useId();
201 
202 return (
203 <Dialog
204 open={open}
205 onClose={() => {
206 if (!pending) onClose();
207 }}
208 title={mode === "create" ? "New calendar" : `Edit ${existing?.display_name ?? "calendar"}`}
209 description={mode === "create" ? "Create a calendar collection." : "Update this calendar's metadata."}
210 footer={
211 <>
212 <Button variant="ghost" onClick={onClose} disabled={pending}>
213 Cancel
214 </Button>
215 <Button variant="primary" type="submit" form={formId} loading={pending}>
216 {mode === "create" ? "Create" : "Save"}
217 </Button>
218 </>
219 }
220 >
221 <CalendarForm
222 key={existing?.id ?? "create"}
223 formId={formId}
224 mode={mode}
225 existing={existing}
226 onSubmit={(values) => {
227 if (mode === "create") {
228 create.mutate({ body: values as Parameters<typeof create.mutate>[0]["body"] });
229 } else if (existing) {
230 update.mutate({
231 params: { path: { calendar_id: existing.id } },
232 body: values,
233 });
234 }
235 }}
236 error={error}
237 />
238 </Dialog>
239 );
240}
241 
242function CalendarForm({
243 formId,
244 mode,
245 existing,
246 onSubmit,
247 error,
248}: {
249 formId: string;
250 mode: "create" | "edit";
251 existing: CalendarRecord | null;
252 onSubmit: (values: {
253 name?: string;
254 display_name?: string;
255 description?: string | null;
256 color?: string | null;
257 order_index?: number;
258 }) => void;
259 error: unknown;
260}) {
261 const [name, setName] = useState(existing?.name ?? "");
262 const [displayName, setDisplayName] = useState(existing?.display_name ?? "");
263 const [description, setDescription] = useState(existing?.description ?? "");
264 const [color, setColor] = useState(existing?.color ?? "#747178");
265 
266 const submit = (event: FormEvent) => {
267 event.preventDefault();
268 if (mode === "create") {
269 onSubmit({
270 name: name.trim(),
271 display_name: displayName.trim() || undefined,
272 description: description.trim() || undefined,
273 color: color || undefined,
274 });
275 } else {
276 onSubmit({
277 display_name: displayName.trim() || undefined,
278 description: description.trim() ? description.trim() : null,
279 color: color || null,
280 });
281 }
282 };
283 
284 return (
285 <form id={formId} onSubmit={submit} className="space-y-4">
286 {mode === "create" ? (
287 <Label hint="The internal name (segment in the DAV URL). Letters, digits, dashes; min 1, max 128.">
288 Name
289 <Input
290 value={name}
291 onChange={(e) => setName(e.target.value)}
292 placeholder="personal"
293 required
294 maxLength={128}
295 />
296 </Label>
297 ) : null}
298 <Label>
299 Display name
300 <Input
301 value={displayName}
302 onChange={(e) => setDisplayName(e.target.value)}
303 placeholder="Personal calendar"
304 maxLength={256}
305 />
306 </Label>
307 <Label>
308 Description
309 <Textarea value={description} onChange={(e) => setDescription(e.target.value)} maxLength={4096} />
310 </Label>
311 <Label hint="Used as a hint by CalDAV clients that support color metadata.">
312 Color
313 <div className="flex items-center gap-3">
314 <input
315 type="color"
316 value={color || "#747178"}
317 onChange={(e) => setColor(e.target.value)}
318 className="h-10 w-14 cursor-pointer rounded-xl border border-zinc-700/60 bg-zinc-800/80"
319 aria-label="Calendar color"
320 />
321 <Input value={color} onChange={(e) => setColor(e.target.value)} maxLength={64} className="font-mono" />
322 </div>
323 </Label>
324 {error ? <ErrorBanner error={error} /> : null}
325 </form>
326 );
327}