A self hosted solution for privately rating and reviewing different sorts of media
at master 474 lines 15 kB view raw
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;