feat: add WatchMedium field, general review editing, configurable deploy
Some checks failed
CI / Check / Test (push) Failing after 27m0s

- WatchMedium enum (cinema/streaming/tv/physical_media/download/media_server/other)
- PATCH /api/v1/reviews/:id partial update (rating, comment, watched_at, watch_medium)
- edit_review use case w/ ownership + remote review guard, best-effort AP Update broadcast
- SPA: icon picker, edit sheet (long-press mobile / pencil desktop), watch medium badge
- shared ReviewFormFields, EditableContextMenu, parse_watched_at/format_watched_at
- deploy.sh parameterized (--features, --tag), CORS allows PATCH
- CONTEXT.md glossary, ADR-0001 general review editing
This commit is contained in:
2026-07-10 00:01:14 +02:00
parent 9794babe06
commit 29cc68b07c
72 changed files with 1298 additions and 124 deletions

View File

@@ -1,17 +1,20 @@
import { useRouter } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
export function BackButton() {
const { t } = useTranslation()
const router = useRouter()
return (
<button
<Button
variant="ghost"
size="sm"
onClick={() => router.history.back()}
className="inline-flex items-center gap-1 text-sm text-muted-foreground"
className="gap-1 text-muted-foreground"
>
<ArrowLeft className="size-4" /> {t("common.back")}
</button>
</Button>
)
}

View File

@@ -0,0 +1,112 @@
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 { useEditReview } from "@/hooks/use-diary"
import { toast } from "sonner"
import { posterUrl } from "@/lib/api/client"
import { hapticMedium } from "@/lib/haptics"
import type { EditReviewRequest } from "@/lib/api/diary"
import type { MovieDto, ReviewDto } from "@/lib/api/common"
type EditReviewSheetProps = {
open: boolean
onOpenChange: (open: boolean) => void
movie: MovieDto
review: ReviewDto
}
function parseLocalDate(s: string): Date {
const [datePart, timePart] = s.split("T")
if (!datePart) return new Date()
const [y, m, d] = datePart.split("-").map(Number)
if (timePart) {
const [h, min, sec] = timePart.split(":").map(Number)
return new Date(y!, m! - 1, d!, h, min, sec)
}
return new Date(y!, m! - 1, d!)
}
function formatLocalDateTime(d: Date): string {
const pad = (n: number) => n.toString().padStart(2, "0")
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
export function EditReviewSheet({ open, onOpenChange, movie, review }: EditReviewSheetProps) {
const { t } = useTranslation()
const [rating, setRating] = useState(review.rating)
const [comment, setComment] = useState(review.comment ?? "")
const [watchedAt, setWatchedAt] = useState<Date>(() => parseLocalDate(review.watched_at))
const [dateChanged, setDateChanged] = useState(false)
const [watchMedium, setWatchMedium] = useState<string | undefined>(review.watch_medium)
const editMutation = useEditReview()
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 = formatLocalDateTime(watchedAt)
if (watchMedium !== review.watch_medium) data.watch_medium = watchMedium ?? null
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">
<div className="mb-5 flex gap-3">
<div className="h-24 w-16 flex-shrink-0 overflow-hidden rounded-lg bg-muted">
{movie.poster_path && <img src={posterUrl(movie.poster_path)} alt="" className="size-full object-cover" />}
</div>
<div>
<p className="text-lg font-bold">{movie.title}</p>
<p className="text-sm text-muted-foreground">{movie.release_year}{movie.director && ` · ${movie.director}`}</p>
</div>
</div>
<ReviewFormFields
rating={rating}
onRatingChange={setRating}
comment={comment}
onCommentChange={setComment}
watchedAt={watchedAt}
onWatchedAtChange={handleDateChange}
watchMedium={watchMedium}
onWatchMediumChange={setWatchMedium}
/>
<Button onClick={handleSubmit} disabled={!rating || editMutation.isPending} className="w-full" size="lg">
{editMutation.isPending ? t("editReview.saving") : t("editReview.save")}
</Button>
</div>
</DrawerContent>
</Drawer>
)
}

View File

@@ -0,0 +1,31 @@
import { useTranslation } from "react-i18next"
import { Pencil } from "lucide-react"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
type EditableContextMenuProps = {
onEdit: () => void
children: React.ReactNode
}
export function EditableContextMenu({ onEdit, children }: EditableContextMenuProps) {
const { t } = useTranslation()
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div>{children}</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onEdit}>
<Pencil className="mr-2 size-4" />
{t("editReview.title")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}

View File

@@ -1,14 +1,9 @@
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { VisuallyHidden } from "radix-ui"
import { CalendarIcon } from "lucide-react"
import { format } from "date-fns"
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Calendar } from "@/components/ui/calendar"
import { StarRating } from "@/components/star-rating"
import { ReviewFormFields } from "@/components/review-form-fields"
import { SearchOverlay } from "@/components/search-overlay"
import type { MovieSelection } from "@/components/search-overlay"
import { useLogReview } from "@/hooks/use-diary"
@@ -27,6 +22,7 @@ export function LogSheet({ open, onOpenChange }: LogSheetProps) {
const [rating, setRating] = useState(0)
const [comment, setComment] = useState("")
const [watchedAt, setWatchedAt] = useState<Date>(new Date())
const [watchMedium, setWatchMedium] = useState<string | undefined>()
const logMutation = useLogReview()
function reset() {
@@ -34,6 +30,7 @@ export function LogSheet({ open, onOpenChange }: LogSheetProps) {
setRating(0)
setComment("")
setWatchedAt(new Date())
setWatchMedium(undefined)
}
function handleClose() {
@@ -52,6 +49,7 @@ export function LogSheet({ open, onOpenChange }: LogSheetProps) {
rating,
comment: comment || undefined,
watched_at: watchedAt.toISOString().replace("Z", "").split(".")[0]!,
watch_medium: watchMedium,
},
{
onSuccess: () => {
@@ -85,34 +83,16 @@ export function LogSheet({ open, onOpenChange }: LogSheetProps) {
</div>
</div>
<div className="mb-5 text-center">
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.yourRating")}</p>
<div className="flex justify-center"><StarRating value={rating} onChange={setRating} /></div>
</div>
<Textarea value={comment} onChange={(e) => setComment(e.target.value)} placeholder={t("logReview.commentPlaceholder")} className="mb-5" rows={3} />
<div className="mb-5">
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.watchedAt")}</p>
<Popover modal>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left font-normal">
<CalendarIcon className="mr-2 size-4" />
{format(watchedAt, "PPP")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
fixedWeeks
selected={watchedAt}
onSelect={(d) => d && setWatchedAt(d)}
disabled={(d) => d > new Date()}
autoFocus
/>
</PopoverContent>
</Popover>
</div>
<ReviewFormFields
rating={rating}
onRatingChange={setRating}
comment={comment}
onCommentChange={setComment}
watchedAt={watchedAt}
onWatchedAtChange={setWatchedAt}
watchMedium={watchMedium}
onWatchMediumChange={setWatchMedium}
/>
<Button onClick={handleSubmit} disabled={!rating || logMutation.isPending} className="w-full" size="lg">
{logMutation.isPending ? t("logReview.logging") : t("logReview.logReview")}

View File

@@ -1,7 +1,10 @@
import { Link } from "@tanstack/react-router"
import { Globe } from "lucide-react"
import { Globe, Pencil } from "lucide-react"
import { timeAgo } from "@/lib/date"
import { StarDisplay } from "@/components/star-display"
import { WatchMediumBadge } from "@/components/watch-medium-badge"
import { EditableContextMenu } from "@/components/editable-context-menu"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { posterUrl } from "@/lib/api/client"
import type { MovieDto, ReviewDto } from "@/lib/api/common"
@@ -13,10 +16,11 @@ type ReviewCardProps = {
userId?: string
isFederated?: boolean
actorUrl?: string
onEdit?: () => void
}
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl }: ReviewCardProps) {
return (
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl, onEdit }: ReviewCardProps) {
const card = (
<Card size="sm">
<CardContent className="flex gap-3">
<Link to="/movies/$id" params={{ id: movie.id }} className="h-[84px] w-14 flex-shrink-0 overflow-hidden rounded-lg bg-muted">
@@ -41,13 +45,27 @@ export function ReviewCard({ movie, review, userName, userId, isFederated, actor
<span>{timeAgo(review.watched_at)}</span>
</div>
)}
<Link to="/movies/$id" params={{ id: movie.id }} className="font-semibold hover:underline">
{movie.title}
</Link>
<StarDisplay rating={review.rating} />
<div className="flex items-center justify-between">
<Link to="/movies/$id" params={{ id: movie.id }} className="font-semibold hover:underline">
{movie.title}
</Link>
{onEdit && (
<Button variant="ghost" size="icon" className="hidden size-7 md:inline-flex" onClick={onEdit}>
<Pencil className="size-3.5" />
</Button>
)}
</div>
<div className="flex items-center gap-1.5">
<StarDisplay rating={review.rating} />
{review.watch_medium && <WatchMediumBadge medium={review.watch_medium} />}
</div>
{review.comment && <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{review.comment}</p>}
</div>
</CardContent>
</Card>
)
if (!onEdit) return card
return <EditableContextMenu onEdit={onEdit}>{card}</EditableContextMenu>
}

View File

@@ -0,0 +1,70 @@
import { useTranslation } from "react-i18next"
import { CalendarIcon } from "lucide-react"
import { format } from "date-fns"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Calendar } from "@/components/ui/calendar"
import { StarRating } from "@/components/star-rating"
import { WatchMediumPicker } from "@/components/watch-medium-picker"
type ReviewFormFieldsProps = {
rating: number
onRatingChange: (v: number) => void
comment: string
onCommentChange: (v: string) => void
watchedAt: Date
onWatchedAtChange: (v: Date) => void
watchMedium?: string
onWatchMediumChange: (v: string | undefined) => void
}
export function ReviewFormFields({
rating,
onRatingChange,
comment,
onCommentChange,
watchedAt,
onWatchedAtChange,
watchMedium,
onWatchMediumChange,
}: ReviewFormFieldsProps) {
const { t } = useTranslation()
return (
<>
<div className="mb-5 text-center">
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.yourRating")}</p>
<div className="flex justify-center"><StarRating value={rating} onChange={onRatingChange} /></div>
</div>
<Textarea value={comment} onChange={(e) => onCommentChange(e.target.value)} placeholder={t("logReview.commentPlaceholder")} className="mb-5" rows={3} />
<div className="mb-5">
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.watchedAt")}</p>
<Popover modal>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left font-normal">
<CalendarIcon className="mr-2 size-4" />
{format(watchedAt, "PPP")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
fixedWeeks
selected={watchedAt}
onSelect={(d) => d && onWatchedAtChange(d)}
disabled={(d) => d > new Date()}
autoFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="mb-5">
<WatchMediumPicker value={watchMedium} onChange={onWatchMediumChange} />
</div>
</>
)
}

View File

@@ -113,9 +113,9 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("searchOverlay.searchPlaceholder")} className="pl-9" autoFocus />
{query && (
<button onClick={() => setQuery("")} className="absolute right-3 top-1/2 -translate-y-1/2">
<Button variant="ghost" size="icon" onClick={() => setQuery("")} className="absolute right-3 top-1/2 size-6 -translate-y-1/2">
<X className="size-4 text-muted-foreground" />
</button>
</Button>
)}
</div>
<Button variant="ghost" size="sm" onClick={onClose}>{t("common.cancel")}</Button>

View File

@@ -48,7 +48,6 @@ function TooltipContent({
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)

View File

@@ -0,0 +1,36 @@
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
type WatchMediumBadgeProps = {
medium: string
className?: string
}
export function WatchMediumBadge({ medium, className }: WatchMediumBadgeProps) {
const { t } = useTranslation()
const entry = WATCH_MEDIUMS.find((m) => m.value === medium)
if (!entry) return null
const Icon = entry.icon
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className={cn("size-6", className)} aria-label={t(entry.labelKey)}>
<Icon className="size-3.5 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>{t(entry.labelKey)}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,55 @@
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
type WatchMediumPickerProps = {
value?: string
onChange: (value: string | undefined) => void
}
export function WatchMediumPicker({ value, onChange }: WatchMediumPickerProps) {
const { t } = useTranslation()
return (
<div>
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">
{t("watchMedium.label")}
</p>
<TooltipProvider>
<div className="flex flex-wrap gap-1.5">
{WATCH_MEDIUMS.map(({ value: val, icon: Icon, labelKey }) => {
const selected = value === val
return (
<Tooltip key={val}>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"size-8",
selected && "border-[var(--aero-primary)] bg-[var(--aero-primary)] text-white shadow-[0_0_8px_var(--aero-primary-glow)]",
)}
aria-label={t(labelKey)}
aria-pressed={selected}
onClick={() => onChange(selected ? undefined : val)}
>
<Icon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent sideOffset={4}>{t(labelKey)}</TooltipContent>
</Tooltip>
)
})}
</div>
</TooltipProvider>
</div>
)
}