Skip to content
File

Blob: src/client/pages/addressbooks.tsx

typescript300 lines
1import { type FormEvent, useId, useState } from "react";
2import { useQueryClient } from "@tanstack/react-query";
3import { BookUser, Pencil, Plus, Trash2 } from "lucide-react";
4 
5import { $api } from "@/client/lib/api";
6import { useMe } from "@/client/auth/use-me";
7import type { Addressbook } 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 ADDRESSBOOKS_KEY = ["get", "/api/v1/addressbooks"] as const;
23 
24function trimTrailingSlash(value: string): string {
25 return value.endsWith("/") ? value.slice(0, -1) : value;
26}
27 
28export function AddressbooksPage() {
29 const list = $api.useQuery("get", "/api/v1/addressbooks", {});
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<Addressbook | null>(null);
34 
35 return (
36 <div className="space-y-8">
37 <PageHeader
38 icon={<BookUser className="h-7 w-7" strokeWidth={2} aria-hidden="true" />}
39 title="Address books"
40 description="Contact collections exposed over CardDAV."
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 address book"
47 >
48 <span className="hidden sm:inline">New address book</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 address books" />
60 ) : !list.data || list.data.length === 0 ? (
61 <EmptyState
62 icon={<BookUser className="h-7 w-7" aria-hidden="true" />}
63 title="No address books yet"
64 description="Create your first address book. You can add contacts from any CardDAV 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 address book
72 </Button>
73 }
74 />
75 ) : (
76 <ul className="grid gap-3 sm:grid-cols-2">
77 {list.data.map((book) => (
78 <li key={book.id}>
79 <AddressbookRow book={book} davOrigin={davOrigin} onEdit={() => setEditing(book)} />
80 </li>
81 ))}
82 </ul>
83 )}
84
85 <AddressbookFormDialog mode="create" open={creating} onClose={() => setCreating(false)} existing={null} />
86 <AddressbookFormDialog mode="edit" open={editing != null} onClose={() => setEditing(null)} existing={editing} />
87 </div>
88 );
89}
90 
91function AddressbookRow({ book, davOrigin, onEdit }: { book: Addressbook; davOrigin: string; onEdit: () => void }) {
92 const queryClient = useQueryClient();
93 const [confirming, setConfirming] = useState(false);
94 const remove = $api.useMutation("delete", "/api/v1/addressbooks/{addressbook_id}", {
95 onSuccess: () => {
96 toast.success(`Deleted ${book.display_name}.`);
97 void queryClient.invalidateQueries({ queryKey: ADDRESSBOOKS_KEY });
98 setConfirming(false);
99 },
100 });
101 
102 return (
103 <Card className="!p-4">
104 <div className="flex items-start justify-between gap-3">
105 <div className="min-w-0 space-y-1">
106 <h3 className="truncate font-medium text-zinc-100">{book.display_name}</h3>
107 {book.description ? <p className="text-sm text-zinc-400">{book.description}</p> : null}
108 <p className="font-mono text-xs text-zinc-500">{book.name}</p>
109 <p className="text-xs text-zinc-500">Modified {formatDate(book.modified_at, { relative: true })}</p>
110 </div>
111 <div className="flex shrink-0 flex-col items-end gap-1">
112 <Button
113 size="sm"
114 variant="ghost"
115 leftIcon={<Pencil className="h-3 w-3" aria-hidden="true" />}
116 onClick={onEdit}
117 >
118 Edit
119 </Button>
120 <Button
121 size="sm"
122 variant="danger"
123 leftIcon={<Trash2 className="h-3 w-3" aria-hidden="true" />}
124 onClick={() => setConfirming(true)}
125 >
126 Delete
127 </Button>
128 </div>
129 </div>
130 <details className="mt-3 border-t border-zinc-800/60 pt-3 text-xs text-zinc-400">
131 <summary className="cursor-pointer text-zinc-300">DAV URL</summary>
132 <div className="mt-2">
133 <Copyable
134 value={book.href}
135 copyValue={davOrigin ? `${davOrigin}${book.href}` : book.href}
136 label="address book URL"
137 />
138 </div>
139 </details>
140 <ConfirmDialog
141 open={confirming}
142 onClose={() => setConfirming(false)}
143 onConfirm={() => remove.mutate({ params: { path: { addressbook_id: book.id } } })}
144 title={`Delete ${book.display_name}?`}
145 description="All contacts in this address book will be removed. This cannot be undone."
146 confirmLabel="Delete address book"
147 destructive
148 loading={remove.isPending}
149 >
150 <p className="text-xs text-zinc-500">
151 DAV URL: <span className="font-mono text-zinc-300">{book.href}</span>
152 </p>
153 </ConfirmDialog>
154 </Card>
155 );
156}
157 
158function AddressbookFormDialog({
159 mode,
160 open,
161 onClose,
162 existing,
163}: {
164 mode: "create" | "edit";
165 open: boolean;
166 onClose: () => void;
167 existing: Addressbook | null;
168}) {
169 const queryClient = useQueryClient();
170 const create = $api.useMutation("post", "/api/v1/addressbooks", {
171 onSuccess: () => {
172 void queryClient.invalidateQueries({ queryKey: ADDRESSBOOKS_KEY });
173 onClose();
174 },
175 });
176 const update = $api.useMutation("patch", "/api/v1/addressbooks/{addressbook_id}", {
177 onSuccess: () => {
178 void queryClient.invalidateQueries({ queryKey: ADDRESSBOOKS_KEY });
179 onClose();
180 },
181 });
182 
183 const pending = mode === "create" ? create.isPending : update.isPending;
184 const error = mode === "create" ? create.error : update.error;
185 const formId = useId();
186 
187 return (
188 <Dialog
189 open={open}
190 onClose={() => {
191 if (!pending) onClose();
192 }}
193 title={mode === "create" ? "New address book" : `Edit ${existing?.display_name ?? "address book"}`}
194 description={mode === "create" ? "Create a contact collection." : "Update this address book."}
195 footer={
196 <>
197 <Button variant="ghost" onClick={onClose} disabled={pending}>
198 Cancel
199 </Button>
200 <Button variant="primary" type="submit" form={formId} loading={pending}>
201 {mode === "create" ? "Create" : "Save"}
202 </Button>
203 </>
204 }
205 >
206 <AddressbookForm
207 key={existing?.id ?? "create"}
208 formId={formId}
209 mode={mode}
210 existing={existing}
211 onSubmit={(values) => {
212 if (mode === "create") {
213 create.mutate({
214 body: {
215 name: values.name?.trim() ?? "",
216 display_name: values.display_name,
217 description: values.description ?? undefined,
218 },
219 });
220 } else if (existing) {
221 update.mutate({
222 params: { path: { addressbook_id: existing.id } },
223 body: {
224 display_name: values.display_name,
225 description: values.description,
226 },
227 });
228 }
229 }}
230 error={error}
231 />
232 </Dialog>
233 );
234}
235 
236function AddressbookForm({
237 formId,
238 mode,
239 existing,
240 onSubmit,
241 error,
242}: {
243 formId: string;
244 mode: "create" | "edit";
245 existing: Addressbook | null;
246 onSubmit: (values: { name?: string; display_name?: string; description?: string | null }) => void;
247 error: unknown;
248}) {
249 const [name, setName] = useState(existing?.name ?? "");
250 const [displayName, setDisplayName] = useState(existing?.display_name ?? "");
251 const [description, setDescription] = useState(existing?.description ?? "");
252 
253 const submit = (event: FormEvent) => {
254 event.preventDefault();
255 if (mode === "create") {
256 onSubmit({
257 name: name.trim(),
258 display_name: displayName.trim() || undefined,
259 description: description.trim() || undefined,
260 });
261 } else {
262 onSubmit({
263 display_name: displayName.trim() || undefined,
264 description: description.trim() ? description.trim() : null,
265 });
266 }
267 };
268 
269 return (
270 <form id={formId} onSubmit={submit} className="space-y-4">
271 {mode === "create" ? (
272 <Label hint="The internal name (segment in the DAV URL).">
273 Name
274 <Input
275 value={name}
276 onChange={(e) => setName(e.target.value)}
277 placeholder="contacts"
278 required
279 maxLength={128}
280 />
281 </Label>
282 ) : null}
283 <Label>
284 Display name
285 <Input
286 value={displayName}
287 onChange={(e) => setDisplayName(e.target.value)}
288 placeholder="Contacts"
289 maxLength={256}
290 />
291 </Label>
292 <Label>
293 Description
294 <Textarea value={description} onChange={(e) => setDescription(e.target.value)} maxLength={4096} />
295 </Label>
296 {error ? <ErrorBanner error={error} /> : null}
297 </form>
298 );
299}