1'use client';
2import { ExtendedUserEntry } from '@/app/(app)/dashboard/state';
3import SubmitButton from '@/components/submitButton';
4import { Button } from '@/components/ui/button';
5import { Slider } from '@/components/ui/slider';
6import { Textarea } from '@/components/ui/textarea';
7import { UserEntryStatus, UserEntryVisibility, UserList } from '@prisma/client';
8import {
9 Bookmark,
10 Check,
11 ChevronDown,
12 Eye,
13 ListPlus,
14 Pause,
15 Save,
16 Trash2,
17 UsersRound,
18 X,
19} from 'lucide-react';
20import { ReactNode, useEffect, useState } from 'react';
21import { toast } from 'sonner';
22import { Input } from './ui/input';
23import { cn } from '@/lib/utils';
24import { useMediaQuery } from 'usehooks-ts';
25import { Badge } from './ui/badge';
26import {
27 Dialog,
28 DialogContent,
29 DialogFooter,
30 DialogHeader,
31 DialogTitle,
32 DialogTrigger,
33} from './ui/dialog';
34import { Label } from './ui/label';
35import { DateTimePicker } from './ui/date-time-picker';
36import { api } from '@/trpc/react';
37import { getUserTitleFromEntry } from '@/server/api/routers/dashboard_';
38import { EntryRedirect } from '@/app/(app)/_components/EntryIslandContext';
39import {
40 DropdownMenu,
41 DropdownMenuContent,
42 DropdownMenuGroup,
43 DropdownMenuItem,
44 DropdownMenuTrigger,
45} from './ui/dropdown-menu';
46import { capitalizeFirst } from '@/lib/capitalizeFirst';
47import AddToList from './addToList';
48
49const ModifyUserEntry = ({
50 userEntry,
51 userLists,
52 userListsWithEntry,
53 refetchUserLists,
54 setOpen,
55 setUserEntry,
56 removeUserEntry: removeUserEntryClient,
57}: {
58 userEntry: ExtendedUserEntry;
59 userLists: UserList[];
60 userListsWithEntry: UserList[];
61 refetchUserLists: () => Promise<void>;
62 setOpen: (value: boolean) => void;
63 setUserEntry: (userEntry: ExtendedUserEntry) => void;
64 removeUserEntry: (userEntry: ExtendedUserEntry) => void;
65}) => {
66 const [rating, setRating] = useState(userEntry.rating);
67 const [notes, setNotes] = useState(userEntry.notes);
68 const [watchedAt, setWatchedAt] = useState<Date | null>(
69 userEntry.watchedAt ? userEntry.watchedAt : new Date()
70 );
71
72 const utils = api.useUtils();
73
74 const isDesktop = useMediaQuery('(min-width: 1024px)');
75
76 const [addListsOpen, setAddListsOpen] = useState(false);
77
78 const [removeUserEntryOpen, setRemoveUserEntryOpen] = useState(false);
79
80 useEffect(() => {
81 setNotes(userEntry.notes);
82 setRating(userEntry.rating);
83 }, [userEntry]);
84
85 const updateUserEntry = api.userEntry.update.useMutation({
86 onSuccess(data) {
87 toast.success(data.message);
88 setUserEntry({
89 ...data.userEntry,
90 entry: {
91 ...data.userEntry.entry,
92 releaseDate: new Date(data.userEntry.entry.releaseDate),
93 },
94 });
95 utils.entries.getEntryPage.invalidate();
96 },
97 onError(error) {
98 toast.error(error.message);
99 },
100 });
101
102 const removeUserEntry = api.userEntry.remove.useMutation({
103 onSuccess(data) {
104 toast.success(data.message);
105 utils.dashboard.invalidate();
106 utils.entries.getEntryPage.invalidate();
107 removeUserEntryClient(userEntry);
108 setOpen(false);
109 },
110 onError(error) {
111 toast.error(error.message);
112 },
113 });
114
115 const updateStatus = async (status: UserEntryStatus) => {
116 setUserEntry({
117 ...userEntry,
118 watchedAt: status === 'completed' ? new Date() : null,
119 status,
120 });
121 updateUserEntry.mutate({
122 userEntryId: userEntry.id,
123 status,
124 });
125 };
126
127 const updateProgress = async (progress: number) => {
128 if (progress === userEntry.progress) return;
129 setUserEntry({
130 ...userEntry,
131 watchedAt: progress >= userEntry.entry.length ? new Date() : null,
132 status:
133 progress >= userEntry.entry.length ? 'completed' : userEntry.status,
134 progress,
135 });
136 updateUserEntry.mutate({
137 userEntryId: userEntry.id,
138 progress,
139 });
140 };
141
142 const updateVisibility = async (visibility: UserEntryVisibility) => {
143 setUserEntry({
144 ...userEntry,
145 visibility,
146 });
147 updateUserEntry.mutate({
148 userEntryId: userEntry.id,
149 visibility,
150 });
151 };
152
153 const Header = () => (
154 <DialogHeader>
155 <div className="grid w-full grid-cols-[max-content,1fr] gap-4 pb-4 pt-4 lg:pt-0">
156 <img
157 src={userEntry.entry.posterPath}
158 className="aspect-[2/3] w-[100px] rounded-lg shadow-md"
159 />
160 <div className="flex flex-col gap-2">
161 <div className="flex items-end gap-2">
162 <EntryRedirect
163 entryId={userEntry.entry.id}
164 entrySlug={userEntry.entry.slug}
165 >
166 <DialogTitle>
167 <div className="text-lg font-semibold tracking-tight lg:pt-0">
168 {getUserTitleFromEntry(userEntry.entry)}
169 </div>
170 </DialogTitle>
171 </EntryRedirect>
172 <div className="pb-[2px] text-sm text-base-500">
173 {userEntry.entry.releaseDate.getFullYear()}
174 </div>
175 </div>
176 {userEntry.entry.tagline && (
177 <div className="text-sm font-normal italic text-base-500">
178 "{userEntry.entry.tagline}"
179 </div>
180 )}
181 <div className="break-all text-sm font-normal">
182 {userEntry.entry.overview.slice(0, isDesktop ? 190 : 150) +
183 (userEntry.entry.overview.length > (isDesktop ? 190 : 150)
184 ? '...'
185 : '')}
186 </div>
187 </div>
188 </div>
189 </DialogHeader>
190 );
191
192 const Footer = ({ children }: { children?: ReactNode }) => (
193 <div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
194 <Dialog open={removeUserEntryOpen} onOpenChange={setRemoveUserEntryOpen}>
195 <DialogTrigger asChild>
196 <Button variant="destructive" size="sm">
197 <Trash2 className="size-3 stroke-white" /> Remove
198 </Button>
199 </DialogTrigger>
200 <DialogContent>
201 <DialogHeader>
202 <DialogTitle>
203 Are you sure you want to remove this review?
204 </DialogTitle>
205 </DialogHeader>
206 <DialogFooter>
207 <Button
208 className="w-full"
209 variant={'outline'}
210 onClick={() => setRemoveUserEntryOpen(false)}
211 >
212 No
213 </Button>
214 <SubmitButton
215 size={'default'}
216 isPending={removeUserEntry.isPending}
217 onClick={() =>
218 removeUserEntry.mutate({
219 userEntryId: userEntry.id,
220 })
221 }
222 >
223 Yes
224 </SubmitButton>
225 </DialogFooter>
226 </DialogContent>
227 </Dialog>
228 <div className="flex flex-col gap-2 lg:flex-row">
229 <DropdownMenu>
230 <DropdownMenuTrigger asChild>
231 <Button
232 variant="outline"
233 className="w-full lg:w-fit"
234 size="sm"
235 role="combobox"
236 aria-expanded={addListsOpen}
237 >
238 {(() => {
239 switch (userEntry.visibility) {
240 case 'public':
241 return <Eye className="size-3" />;
242 case 'friends':
243 return <UsersRound className="size-3" />;
244 case 'private':
245 return <X className="size-3" />;
246 }
247 })()}{' '}
248 {capitalizeFirst(userEntry.visibility)}{' '}
249 <ChevronDown className="size-3 stroke-base-600" />
250 </Button>
251 </DropdownMenuTrigger>
252 <DropdownMenuContent>
253 <DropdownMenuGroup>
254 {Object.values(UserEntryVisibility).map(visiblity => (
255 <DropdownMenuItem onClick={() => updateVisibility(visiblity)}>
256 {(() => {
257 switch (visiblity) {
258 case 'public':
259 return <Eye className="size-3" />;
260 case 'friends':
261 return <UsersRound className="size-3" />;
262 case 'private':
263 return <X className="size-3" />;
264 }
265 })()}
266 {capitalizeFirst(visiblity)}
267 </DropdownMenuItem>
268 ))}
269 </DropdownMenuGroup>
270 </DropdownMenuContent>
271 </DropdownMenu>
272 <AddToList
273 onSuccess={() => refetchUserLists()}
274 entryId={userEntry.entryId}
275 userLists={userLists}
276 userListsWithEntry={userListsWithEntry}
277 >
278 <Button variant="outline" size="sm" role="combobox">
279 <ListPlus className="size-3" /> Add to list
280 </Button>
281 </AddToList>
282 {children}
283 </div>
284 </div>
285 );
286
287 if (userEntry.watchedAt !== null) {
288 return (
289 <div className="grid h-full grow grid-rows-[max-content,max-content,1fr,max-content]">
290 <Header />
291 <div className="flex flex-row items-center gap-3 border-b border-b-base-200 py-3 text-sm">
292 <div className="w-max text-base-500">Rating</div>
293 <Badge className="min-w-[50px] max-w-[50px] justify-center">
294 {rating / 20}
295 </Badge>
296 <div className="w-full">
297 <Slider
298 value={[rating]}
299 onValueChange={e => setRating(Number(e[0]))}
300 className="w-full"
301 name="rating"
302 step={1}
303 min={0}
304 max={100}
305 />
306 </div>
307 </div>
308 <div className="flex h-full flex-col items-center gap-4 py-3 text-sm">
309 <Textarea
310 className="h-full resize-none"
311 // border-none p-0 shadow-none focus-visible:ring-0
312 placeholder="Write your review"
313 value={notes}
314 onChange={e => setNotes(e.target.value)}
315 name="notes"
316 />
317 <div className="flex w-full flex-col justify-start gap-6 lg:flex-row">
318 <div className="flex items-center gap-2">
319 <Label>Watched</Label>
320 <DateTimePicker date={watchedAt} setDate={setWatchedAt} />
321 </div>
322 <div className="hidden items-center gap-2 lg:flex">
323 <Label>Created</Label>
324 {new Date(userEntry.createdAt.toString()).toDateString()}
325 </div>
326 <div className="hidden items-center gap-2 lg:flex">
327 <Label>Updated</Label>
328 {new Date(userEntry.updatedAt.toString()).toDateString()}
329 </div>
330 </div>
331 </div>
332 <Footer>
333 <SubmitButton
334 isPending={updateUserEntry.isPending}
335 onClick={() =>
336 updateUserEntry.mutate({
337 userEntryId: userEntry.id,
338 rating,
339 notes,
340 watchedAt: watchedAt ? watchedAt : undefined,
341 })
342 }
343 className="w-full px-6 lg:w-fit"
344 size={'sm'}
345 >
346 <Save className="size-3" />
347 Save
348 </SubmitButton>
349 </Footer>
350 </div>
351 );
352 } else {
353 return (
354 <div className="grid h-full w-full grow grid-rows-[max-content,1fr]">
355 <Header />
356 <div className="flex flex-col gap-2">
357 <Button
358 variant={userEntry.status === 'planning' ? 'default' : 'outline'}
359 onClick={() => updateStatus('planning')}
360 >
361 <Bookmark />{' '}
362 <div className="w-full">
363 Planning to{' '}
364 {userEntry.entry.category === 'Book' ? 'read' : 'watch'}
365 </div>
366 </Button>
367 <div
368 className={cn('w-full', {
369 'flex flex-col rounded-lg border bg-white shadow-sm':
370 userEntry.status === 'watching',
371 })}
372 >
373 <Button
374 className={cn('w-full', {
375 'shadow-sm': userEntry.status === 'watching',
376 })}
377 variant={userEntry.status === 'watching' ? 'default' : 'outline'}
378 onClick={() => updateStatus('watching')}
379 >
380 <Eye />{' '}
381 <div className="w-full">
382 {userEntry.entry.category === 'Book' ? 'Reading' : 'Watching'}
383 </div>
384 </Button>
385 <div
386 className={cn(
387 'flex h-0 items-center gap-2 overflow-hidden px-2 py-0 text-base-500 transition-all duration-500',
388 { 'h-[48px] py-2': userEntry.status === 'watching' }
389 )}
390 >
391 <Input
392 defaultValue={userEntry.progress}
393 className="text-base-900"
394 onBlur={e => {
395 if (Number(e.target.value) < 0) {
396 return;
397 }
398 updateProgress(Number(e.target.value));
399 }}
400 type="number"
401 />
402 <div>/</div>
403 <div>{userEntry.entry.length}</div>
404 </div>
405 </div>
406
407 <div
408 className={cn('w-full', {
409 'flex flex-col rounded-lg border bg-white shadow-sm':
410 userEntry.status === 'paused',
411 })}
412 >
413 <Button
414 className={cn('w-full', {
415 'shadow-sm': userEntry.status === 'paused',
416 })}
417 variant={userEntry.status === 'paused' ? 'default' : 'outline'}
418 onClick={() => updateStatus('paused')}
419 >
420 <Pause /> <div className="w-full">Paused</div>
421 </Button>
422 <div
423 className={cn(
424 'flex h-0 items-center justify-center gap-2 overflow-hidden px-2 py-0 transition-all duration-500',
425 { 'h-auto py-2': userEntry.status === 'paused' }
426 )}
427 >
428 <div>{userEntry.progress}</div>
429 <div>/</div>
430 <div>{userEntry.entry.length}</div>
431 </div>
432 </div>
433
434 <div
435 className={cn('w-full', {
436 'flex flex-col rounded-lg border bg-white shadow-sm':
437 userEntry.status === 'dnf',
438 })}
439 >
440 <Button
441 className={cn('w-full', {
442 'shadow-sm': userEntry.status === 'dnf',
443 })}
444 variant={userEntry.status === 'dnf' ? 'default' : 'outline'}
445 onClick={() => updateStatus('dnf')}
446 >
447 <X /> <div className="w-full">Did not finish</div>
448 </Button>
449 <div
450 className={cn(
451 'flex h-0 items-center justify-center gap-2 overflow-hidden px-2 py-0 transition-all duration-500',
452 { 'h-auto py-2': userEntry.status === 'dnf' }
453 )}
454 >
455 <div>{userEntry.progress}</div>
456 <div>/</div>
457 <div>{userEntry.entry.length}</div>
458 </div>
459 </div>
460
461 <Button
462 variant={userEntry.status === 'completed' ? 'default' : 'outline'}
463 onClick={() => updateStatus('completed')}
464 >
465 <Check /> <div className="w-full">Completed</div>
466 </Button>
467 </div>
468 <Footer />
469 </div>
470 );
471 }
472};
473
474export default ModifyUserEntry;