Some checks failed
CI / Check / Test (push) Has been cancelled
Reviewed-on: #25
281 lines
8.8 KiB
TypeScript
281 lines
8.8 KiB
TypeScript
import { useState } from "react"
|
|
import { useTranslation } from "react-i18next"
|
|
import { VisuallyHidden } from "radix-ui"
|
|
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
|
|
import { Button } from "@/components/ui/button"
|
|
import { ReviewFormFields } from "@/components/review-form-fields"
|
|
import { SearchOverlay } from "@/components/search-overlay"
|
|
import type { MovieSelection } from "@/components/search-overlay"
|
|
import { useLogReview, useEditReview, useDeleteReview } from "@/features/diary"
|
|
import { ConfirmDialog } from "@/components/confirm-dialog"
|
|
import { Trash2 } from "lucide-react"
|
|
import { toast } from "sonner"
|
|
import { posterUrl } from "@/lib/api/client"
|
|
import { hapticMedium } from "@/lib/haptics"
|
|
import { formatWatchedAt, isDateOnly, parseWatchedAt } from "@/lib/date"
|
|
import type { EditReviewRequest } from "@/features/diary"
|
|
import type { MovieDto, ReviewDto } from "@/lib/api/common"
|
|
|
|
type LogMode = {
|
|
mode: "log"
|
|
}
|
|
|
|
type EditMode = {
|
|
mode: "edit"
|
|
movie: MovieDto
|
|
review: ReviewDto
|
|
}
|
|
|
|
type ReviewSheetProps = {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
} & (LogMode | EditMode)
|
|
|
|
export function ReviewSheet(props: ReviewSheetProps) {
|
|
if (props.mode === "log") {
|
|
return <LogMode open={props.open} onOpenChange={props.onOpenChange} />
|
|
}
|
|
return (
|
|
<EditMode
|
|
open={props.open}
|
|
onOpenChange={props.onOpenChange}
|
|
movie={props.movie}
|
|
review={props.review}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function LogMode({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
|
const { t } = useTranslation()
|
|
const [movie, setMovie] = useState<MovieSelection | null>(null)
|
|
const [rating, setRating] = useState(0)
|
|
const [comment, setComment] = useState("")
|
|
const [watchedAt, setWatchedAt] = useState<Date>(new Date())
|
|
const [watchMedium, setWatchMedium] = useState<string | undefined>()
|
|
const [spoilers, setSpoilers] = useState(false)
|
|
const logMutation = useLogReview()
|
|
|
|
function reset() {
|
|
setMovie(null)
|
|
setRating(0)
|
|
setComment("")
|
|
setWatchedAt(new Date())
|
|
setWatchMedium(undefined)
|
|
setSpoilers(false)
|
|
}
|
|
|
|
function handleClose() {
|
|
onOpenChange(false)
|
|
reset()
|
|
}
|
|
|
|
function handleSubmit() {
|
|
if (!movie || !rating) return
|
|
logMutation.mutate(
|
|
{
|
|
external_metadata_id: movie.external_metadata_id,
|
|
manual_title: movie.title,
|
|
manual_release_year: movie.release_year,
|
|
manual_director: movie.director,
|
|
rating,
|
|
comment: comment || undefined,
|
|
watched_at: formatWatchedAt(watchedAt),
|
|
watch_medium: watchMedium,
|
|
spoilers: spoilers || undefined,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
hapticMedium()
|
|
toast.success(t("logReview.logged", { title: movie.title }))
|
|
handleClose()
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
if (open && !movie) {
|
|
return <SearchOverlay open onClose={handleClose} onSelect={(m) => setMovie(m)} />
|
|
}
|
|
|
|
return (
|
|
<Drawer open={open && !!movie} onOpenChange={(o) => !o && handleClose()}>
|
|
<DrawerContent className="mx-auto max-w-lg">
|
|
<VisuallyHidden.Root><DrawerTitle>{t("logReview.title")}</DrawerTitle></VisuallyHidden.Root>
|
|
<div className="p-5 pb-8">
|
|
{movie && (
|
|
<>
|
|
<MovieHeader
|
|
title={movie.title}
|
|
releaseYear={movie.release_year}
|
|
director={movie.director}
|
|
posterPath={movie.poster_path}
|
|
genres={movie.genres}
|
|
/>
|
|
|
|
<ReviewFormFields
|
|
rating={rating}
|
|
onRatingChange={setRating}
|
|
comment={comment}
|
|
onCommentChange={setComment}
|
|
watchedAt={watchedAt}
|
|
onWatchedAtChange={setWatchedAt}
|
|
watchMedium={watchMedium}
|
|
onWatchMediumChange={setWatchMedium}
|
|
spoilers={spoilers}
|
|
onSpoilersChange={setSpoilers}
|
|
/>
|
|
|
|
<Button onClick={handleSubmit} disabled={!rating || logMutation.isPending} className="w-full" size="lg">
|
|
{logMutation.isPending ? t("logReview.logging") : t("logReview.logReview")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
)
|
|
}
|
|
|
|
function EditMode({
|
|
open,
|
|
onOpenChange,
|
|
movie,
|
|
review,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
movie: MovieDto
|
|
review: ReviewDto
|
|
}) {
|
|
const { t } = useTranslation()
|
|
const [rating, setRating] = useState(review.rating)
|
|
const [comment, setComment] = useState(review.comment ?? "")
|
|
const [watchedAt, setWatchedAt] = useState<Date>(() => parseWatchedAt(review.watched_at))
|
|
const [dateChanged, setDateChanged] = useState(false)
|
|
const [watchMedium, setWatchMedium] = useState<string | undefined>(review.watch_medium)
|
|
const [spoilers, setSpoilers] = useState(review.spoilers ?? false)
|
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
|
const editMutation = useEditReview()
|
|
const deleteMutation = useDeleteReview()
|
|
|
|
function handleDateChange(d: Date) {
|
|
setWatchedAt(d)
|
|
setDateChanged(true)
|
|
}
|
|
|
|
function handleSubmit() {
|
|
if (!rating) return
|
|
|
|
const data: Partial<EditReviewRequest> = {}
|
|
if (rating !== review.rating) data.rating = rating
|
|
const newComment = comment || null
|
|
if (newComment !== (review.comment ?? null)) data.comment = newComment
|
|
if (dateChanged) data.watched_at = formatWatchedAt(watchedAt, isDateOnly(review.watched_at))
|
|
if (watchMedium !== review.watch_medium) data.watch_medium = watchMedium ?? null
|
|
if (spoilers !== (review.spoilers ?? false)) data.spoilers = spoilers
|
|
|
|
if (Object.keys(data).length === 0) {
|
|
toast.info(t("editReview.noChanges"))
|
|
onOpenChange(false)
|
|
return
|
|
}
|
|
|
|
editMutation.mutate(
|
|
{ id: review.id, data },
|
|
{
|
|
onSuccess: () => {
|
|
hapticMedium()
|
|
toast.success(t("editReview.saved", { title: movie.title }))
|
|
onOpenChange(false)
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
|
<DrawerContent className="mx-auto max-w-lg">
|
|
<VisuallyHidden.Root><DrawerTitle>{t("editReview.title")}</DrawerTitle></VisuallyHidden.Root>
|
|
<div className="p-5 pb-8">
|
|
<MovieHeader
|
|
title={movie.title}
|
|
releaseYear={movie.release_year}
|
|
director={movie.director}
|
|
posterPath={movie.poster_path}
|
|
/>
|
|
|
|
<ReviewFormFields
|
|
rating={rating}
|
|
onRatingChange={setRating}
|
|
comment={comment}
|
|
onCommentChange={setComment}
|
|
watchedAt={watchedAt}
|
|
onWatchedAtChange={handleDateChange}
|
|
watchMedium={watchMedium}
|
|
onWatchMediumChange={setWatchMedium}
|
|
spoilers={spoilers}
|
|
onSpoilersChange={setSpoilers}
|
|
/>
|
|
|
|
<Button onClick={handleSubmit} disabled={!rating || editMutation.isPending} className="w-full" size="lg">
|
|
{editMutation.isPending ? t("editReview.saving") : t("editReview.save")}
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
className="mt-2 w-full text-destructive hover:text-destructive"
|
|
onClick={() => setConfirmDelete(true)}
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
<Trash2 className="mr-1.5 size-4" />
|
|
{t("editReview.delete", { defaultValue: "Delete review" })}
|
|
</Button>
|
|
|
|
<ConfirmDialog
|
|
open={confirmDelete}
|
|
onOpenChange={setConfirmDelete}
|
|
title={t("diary.deleteReview", { defaultValue: "Delete review?" })}
|
|
description={`${movie.title} — ${review.watched_at.slice(0, 10)}`}
|
|
onConfirm={() =>
|
|
deleteMutation.mutate(review.id, {
|
|
onSuccess: () => {
|
|
hapticMedium()
|
|
toast.success(t("editReview.deleted", { defaultValue: "Review deleted", title: movie.title }))
|
|
onOpenChange(false)
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
)
|
|
}
|
|
|
|
function MovieHeader({
|
|
title,
|
|
releaseYear,
|
|
director,
|
|
posterPath,
|
|
genres,
|
|
}: {
|
|
title: string
|
|
releaseYear?: number
|
|
director?: string | null
|
|
posterPath?: string | null
|
|
genres?: string[]
|
|
}) {
|
|
return (
|
|
<div className="mb-5 flex gap-3">
|
|
<div className="h-24 w-16 flex-shrink-0 overflow-hidden rounded-lg bg-muted">
|
|
{posterPath && <img src={posterUrl(posterPath)} alt="" className="size-full object-cover" />}
|
|
</div>
|
|
<div>
|
|
<p className="text-lg font-bold">{title}</p>
|
|
<p className="text-sm text-muted-foreground">{releaseYear}{director && ` · ${director}`}</p>
|
|
{genres && genres.length > 0 && <p className="mt-1 text-xs text-muted-foreground">{genres.join(", ")}</p>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|