A personal media tracker built on the AT Protocol
opnshelf.xyz
1import {
2 listsControllerCreateListMutation,
3 listsControllerGetUserListsQueryKey,
4} from "@opnshelf/api";
5import { usePostHog } from "@posthog/react";
6import { useMutation, useQueryClient } from "@tanstack/react-query";
7import { ListPlus } from "lucide-react";
8import { useId, useState } from "react";
9import { useTheme } from "@/components/theme-provider";
10import {
11 Dialog,
12 DialogContent,
13 DialogDescription,
14 DialogHeader,
15 DialogTitle,
16 DialogTrigger,
17} from "@/components/ui/dialog";
18import { M3Button } from "@/components/ui/m3-button";
19import { M3TextField } from "@/components/ui/m3-text-field";
20import { cn } from "@/lib/utils";
21
22type CreateListDialogProps = {
23 triggerClassName?: string;
24};
25
26export function CreateListDialog({ triggerClassName }: CreateListDialogProps) {
27 const [open, setOpen] = useState(false);
28 const [name, setName] = useState("");
29 const [description, setDescription] = useState("");
30 const [isDescriptionFocused, setIsDescriptionFocused] = useState(false);
31 const queryClient = useQueryClient();
32 const id = useId();
33 const { seedColor } = useTheme();
34 const posthog = usePostHog();
35
36 const createListMutation = useMutation({
37 mutationKey: ["lists", "create"],
38 ...listsControllerCreateListMutation(),
39 onSuccess: (data) => {
40 queryClient.invalidateQueries({
41 queryKey: listsControllerGetUserListsQueryKey(),
42 });
43 posthog.capture("list_created", {
44 list_name: name.trim(),
45 has_description: !!description.trim(),
46 list_id: (data as { id?: string })?.id,
47 });
48 setOpen(false);
49 setName("");
50 setDescription("");
51 },
52 });
53
54 const handleSubmit = (e: React.FormEvent) => {
55 e.preventDefault();
56 if (!name.trim()) return;
57
58 createListMutation.mutate({
59 body: {
60 name: name.trim(),
61 description: description.trim() || undefined,
62 },
63 });
64 };
65
66 return (
67 <Dialog open={open} onOpenChange={setOpen}>
68 <DialogTrigger asChild>
69 <M3Button
70 variant="filled"
71 className={cn("gap-2 ml-auto", triggerClassName)}
72 >
73 <ListPlus className="size-4" />
74 Create List
75 </M3Button>
76 </DialogTrigger>
77 <DialogContent
78 style={{
79 backgroundColor: "var(--md-sys-color-surface-container)",
80 borderColor: "var(--md-sys-color-outline)",
81 color: "var(--md-sys-color-on-surface)",
82 }}
83 >
84 <DialogHeader>
85 <DialogTitle
86 className="md-headline-small"
87 style={{ color: "var(--md-sys-color-on-surface)" }}
88 >
89 Create New List
90 </DialogTitle>
91 <DialogDescription
92 className="md-body-medium"
93 style={{ color: "var(--md-sys-color-on-surface-variant)" }}
94 >
95 Create a custom list to organize your movies.
96 </DialogDescription>
97 </DialogHeader>
98 <form onSubmit={handleSubmit} className="space-y-4">
99 <div className="space-y-2">
100 <M3TextField
101 id={`${id}-name`}
102 label="Name"
103 placeholder="My Awesome List"
104 value={name}
105 onChange={(e) => setName(e.target.value)}
106 required
107 maxLength={100}
108 variant="outlined"
109 />
110 </div>
111 <div className="space-y-2">
112 <div
113 className="relative rounded-(--md-sys-shape-corner-extra-small) border bg-transparent transition-all duration-200"
114 style={{
115 borderColor: isDescriptionFocused
116 ? "var(--md-sys-color-primary)"
117 : "var(--md-sys-color-outline)",
118 borderWidth: isDescriptionFocused ? 2 : 1,
119 }}
120 >
121 <label
122 htmlFor={`${id}-description`}
123 className="absolute left-4 top-0 -translate-y-1/2 px-1 md-label-small pointer-events-none"
124 style={{
125 backgroundColor: "var(--md-sys-color-surface)",
126 color: isDescriptionFocused
127 ? "var(--md-sys-color-primary)"
128 : "var(--md-sys-color-on-surface-variant)",
129 }}
130 >
131 Description (optional)
132 </label>
133 <textarea
134 id={`${id}-description`}
135 placeholder="What's this list about?"
136 value={description}
137 onChange={(e) => setDescription(e.target.value)}
138 onFocus={() => setIsDescriptionFocused(true)}
139 onBlur={() => setIsDescriptionFocused(false)}
140 maxLength={500}
141 rows={3}
142 className="w-full resize-none bg-transparent py-4 px-4 text-(--md-sys-color-on-surface) placeholder:text-(--md-sys-color-on-surface-variant) outline-none md-body-large"
143 />
144 </div>
145 </div>
146 <div className="flex justify-end gap-2">
147 <M3Button
148 type="button"
149 variant="outlined"
150 onClick={() => setOpen(false)}
151 >
152 Cancel
153 </M3Button>
154 <M3Button
155 type="submit"
156 variant="filled"
157 disabled={!name.trim() || createListMutation.isPending}
158 style={{
159 backgroundColor: seedColor,
160 color: "var(--md-sys-color-on-primary)",
161 }}
162 >
163 {createListMutation.isPending ? "Creating..." : "Create"}
164 </M3Button>
165 </div>
166 </form>
167 </DialogContent>
168 </Dialog>
169 );
170}