feat: add WatchMedium field, general review editing, configurable deploy
Some checks failed
CI / Check / Test (push) Failing after 27m0s
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:
@@ -301,6 +301,28 @@ body > #root {
|
||||
fill: rgba(255, 255, 255, 0.85) !important;
|
||||
}
|
||||
|
||||
/* Tooltip — default shadcn uses bg-foreground which is white in this theme */
|
||||
[data-slot="tooltip-content"] {
|
||||
background: var(--popover);
|
||||
color: var(--popover-foreground);
|
||||
backdrop-filter: blur(var(--aero-blur));
|
||||
-webkit-backdrop-filter: blur(var(--aero-blur));
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
[data-slot="tooltip-content"] .lucide,
|
||||
[data-slot="tooltip-content"] [data-slot="tooltip-arrow"] {
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
|
||||
/* Context menu */
|
||||
[data-slot="context-menu-content"] {
|
||||
background: var(--popover);
|
||||
backdrop-filter: blur(var(--aero-blur));
|
||||
-webkit-backdrop-filter: blur(var(--aero-blur));
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Star glow for filled amber stars */
|
||||
.aero-star-filled {
|
||||
filter: drop-shadow(0 0 4px var(--aero-primary-glow)) drop-shadow(0 0 1px var(--aero-primary));
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
112
spa/src/components/edit-review-sheet.tsx
Normal file
112
spa/src/components/edit-review-sheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
spa/src/components/editable-context-menu.tsx
Normal file
31
spa/src/components/editable-context-menu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
70
spa/src/components/review-form-fields.tsx
Normal file
70
spa/src/components/review-form-fields.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
36
spa/src/components/watch-medium-badge.tsx
Normal file
36
spa/src/components/watch-medium-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
spa/src/components/watch-medium-picker.tsx
Normal file
55
spa/src/components/watch-medium-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@tanstack/react-query"
|
||||
import {
|
||||
deleteReview,
|
||||
editReview,
|
||||
getActivityFeed,
|
||||
getDiary,
|
||||
logReview,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import type {
|
||||
ActivityFeedQueryParams,
|
||||
DiaryQueryParams,
|
||||
EditReviewRequest,
|
||||
LogReviewRequest,
|
||||
} from "@/lib/api/diary"
|
||||
|
||||
@@ -79,6 +81,18 @@ export function useLogReview() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useEditReview() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: EditReviewRequest }) =>
|
||||
editReview(id, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: diaryKeys.all })
|
||||
qc.invalidateQueries({ queryKey: ["activity-feed"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteReview() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -157,6 +157,17 @@ export async function putForm<T = void>(
|
||||
})
|
||||
}
|
||||
|
||||
export async function patch<T = void>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
return request<T>(buildUrl(path), {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export async function del<T = void>(path: string): Promise<T> {
|
||||
return request<T>(buildUrl(path), { method: "DELETE" })
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const reviewDtoSchema = z.object({
|
||||
rating: z.number(),
|
||||
comment: z.string().optional(),
|
||||
watched_at: z.string(),
|
||||
watch_medium: z.string().optional(),
|
||||
})
|
||||
export type ReviewDto = z.infer<typeof reviewDtoSchema>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod"
|
||||
import type { DiaryEntryDto, Paginated } from "./common"
|
||||
import { diaryEntryDtoSchema, movieDtoSchema, paginatedSchema, reviewDtoSchema } from "./common"
|
||||
import { del, get, post } from "./client"
|
||||
import { del, get, patch, post } from "./client"
|
||||
|
||||
export const diaryQueryParamsSchema = z.object({
|
||||
limit: z.number().optional(),
|
||||
@@ -22,9 +22,18 @@ export const logReviewRequestSchema = z.object({
|
||||
rating: z.number(),
|
||||
comment: z.string().optional(),
|
||||
watched_at: z.string(),
|
||||
watch_medium: z.string().optional(),
|
||||
})
|
||||
export type LogReviewRequest = z.infer<typeof logReviewRequestSchema>
|
||||
|
||||
export const editReviewRequestSchema = z.object({
|
||||
rating: z.number().optional(),
|
||||
comment: z.string().nullable().optional(),
|
||||
watched_at: z.string().optional(),
|
||||
watch_medium: z.string().nullable().optional(),
|
||||
})
|
||||
export type EditReviewRequest = z.infer<typeof editReviewRequestSchema>
|
||||
|
||||
export const feedEntryDtoSchema = z.object({
|
||||
movie: movieDtoSchema,
|
||||
review: reviewDtoSchema,
|
||||
@@ -58,6 +67,10 @@ export function logReview(data: LogReviewRequest) {
|
||||
return post("/reviews", data)
|
||||
}
|
||||
|
||||
export function editReview(id: string, data: EditReviewRequest) {
|
||||
return patch(`/reviews/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteReview(id: string) {
|
||||
return del(`/reviews/${id}`)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export const socialReviewDtoSchema = z.object({
|
||||
comment: z.string().optional(),
|
||||
watched_at: z.string(),
|
||||
is_federated: z.boolean(),
|
||||
watch_medium: z.string().optional(),
|
||||
})
|
||||
export type SocialReviewDto = z.infer<typeof socialReviewDtoSchema>
|
||||
|
||||
|
||||
26
spa/src/lib/watch-mediums.ts
Normal file
26
spa/src/lib/watch-mediums.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import {
|
||||
Clapperboard,
|
||||
Tv,
|
||||
Radio,
|
||||
Disc3,
|
||||
Download,
|
||||
Server,
|
||||
Ellipsis,
|
||||
} from "lucide-react"
|
||||
|
||||
export type WatchMediumDef = {
|
||||
value: string
|
||||
icon: LucideIcon
|
||||
labelKey: string
|
||||
}
|
||||
|
||||
export const WATCH_MEDIUMS: WatchMediumDef[] = [
|
||||
{ value: "cinema", icon: Clapperboard, labelKey: "watchMedium.cinema" },
|
||||
{ value: "streaming", icon: Tv, labelKey: "watchMedium.streaming" },
|
||||
{ value: "tv", icon: Radio, labelKey: "watchMedium.tv" },
|
||||
{ value: "physical_media", icon: Disc3, labelKey: "watchMedium.physicalMedia" },
|
||||
{ value: "download", icon: Download, labelKey: "watchMedium.download" },
|
||||
{ value: "media_server", icon: Server, labelKey: "watchMedium.mediaServer" },
|
||||
{ value: "other", icon: Ellipsis, labelKey: "watchMedium.other" },
|
||||
]
|
||||
@@ -307,6 +307,23 @@
|
||||
"funActors": "You saw {{count}} different actors",
|
||||
"allMovies": "All Movies ({{count}})"
|
||||
},
|
||||
"watchMedium": {
|
||||
"label": "Watched via",
|
||||
"cinema": "Cinema",
|
||||
"streaming": "Streaming",
|
||||
"tv": "TV",
|
||||
"physicalMedia": "Physical Media",
|
||||
"download": "Download",
|
||||
"mediaServer": "Media Server",
|
||||
"other": "Other"
|
||||
},
|
||||
"editReview": {
|
||||
"title": "Edit Review",
|
||||
"save": "Save Changes",
|
||||
"saving": "Saving...",
|
||||
"saved": "{{title}} updated!",
|
||||
"noChanges": "No changes to save"
|
||||
},
|
||||
"logReview": {
|
||||
"title": "Log Review",
|
||||
"yourRating": "Your Rating",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from "@tanstack/react-router"
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { BottomTabBar } from "@/components/bottom-tab-bar"
|
||||
import { LogSheet } from "@/components/log-sheet"
|
||||
@@ -26,12 +27,9 @@ function ErrorFallback({ error, reset }: { error: unknown; reset: () => void })
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error instanceof Error ? error.message : t("errors.unknownError")}
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
||||
>
|
||||
<Button onClick={reset}>
|
||||
{t("common.tryAgain")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useCallback, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { BookOpen, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { BookOpen, ChevronLeft, ChevronRight, Pencil } from "lucide-react"
|
||||
import { format, startOfMonth, subMonths } from "date-fns"
|
||||
import { EditReviewSheet } from "@/components/edit-review-sheet"
|
||||
import { EditableContextMenu } from "@/components/editable-context-menu"
|
||||
import { MovieCard } from "@/components/movie-card"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { SwipeToDelete } from "@/components/swipe-to-delete"
|
||||
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||
import { VirtualList } from "@/components/virtual-list"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
@@ -33,6 +36,7 @@ function DiaryPage() {
|
||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteDiary({ sort_by: "desc" })
|
||||
const deleteReview = useDeleteReview()
|
||||
const [editingEntry, setEditingEntry] = useState<DiaryEntryDto | null>(null)
|
||||
|
||||
const monthLabel = format(month, "MMMM yyyy")
|
||||
const monthStr = format(month, "yyyy-MM")
|
||||
@@ -109,17 +113,44 @@ function DiaryPage() {
|
||||
confirmTitle={t("diary.deleteReview")}
|
||||
confirmDescription={`${item.entry.movie.title} — ${item.entry.review.watched_at.slice(0, 10)}`}
|
||||
>
|
||||
<MovieCard
|
||||
movie={item.entry.movie}
|
||||
rating={item.entry.review.rating}
|
||||
comment={item.entry.review.comment}
|
||||
variant="full"
|
||||
/>
|
||||
<EditableContextMenu onEdit={() => setEditingEntry(item.entry)}>
|
||||
<MovieCard
|
||||
movie={item.entry.movie}
|
||||
rating={item.entry.review.rating}
|
||||
comment={item.entry.review.comment}
|
||||
variant="full"
|
||||
action={
|
||||
<div className="flex items-center gap-1">
|
||||
{item.entry.review.watch_medium && (
|
||||
<WatchMediumBadge medium={item.entry.review.watch_medium} />
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hidden size-7 md:inline-flex"
|
||||
onClick={(e) => { e.preventDefault(); setEditingEntry(item.entry) }}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</EditableContextMenu>
|
||||
</SwipeToDelete>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingEntry && (
|
||||
<EditReviewSheet
|
||||
key={editingEntry.review.id}
|
||||
open={!!editingEntry}
|
||||
onOpenChange={(open) => !open && setEditingEntry(null)}
|
||||
movie={editingEntry.movie}
|
||||
review={editingEntry.review}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { StarRating } from "@/components/star-rating"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { EditReviewSheet } from "@/components/edit-review-sheet"
|
||||
import { useInfiniteActivityFeed, useDeleteReview } from "@/hooks/use-diary"
|
||||
import type { FeedEntryDto } from "@/lib/api/diary"
|
||||
import { SearchOverlay } from "@/components/search-overlay"
|
||||
import type { MovieSelection } from "@/components/search-overlay"
|
||||
import { useInfiniteWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from "@/hooks/use-watchlist"
|
||||
@@ -66,6 +68,7 @@ function FeedTab() {
|
||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteActivityFeed({ sort_by: sortBy })
|
||||
const deleteReview = useDeleteReview()
|
||||
const [editingEntry, setEditingEntry] = useState<FeedEntryDto | null>(null)
|
||||
const items = data?.pages.flatMap((p) => p.items) ?? []
|
||||
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
|
||||
|
||||
@@ -110,6 +113,7 @@ function FeedTab() {
|
||||
isFetching={isFetchingNextPage}
|
||||
onLoadMore={loadMore}
|
||||
renderItem={(entry) => {
|
||||
const isOwn = entry.user_id === auth?.user_id
|
||||
const card = (
|
||||
<ReviewCard
|
||||
movie={entry.movie}
|
||||
@@ -118,9 +122,10 @@ function FeedTab() {
|
||||
userId={entry.user_id}
|
||||
isFederated={entry.is_federated}
|
||||
actorUrl={entry.actor_url}
|
||||
onEdit={isOwn ? () => setEditingEntry(entry) : undefined}
|
||||
/>
|
||||
)
|
||||
return entry.user_id === auth?.user_id ? (
|
||||
return isOwn ? (
|
||||
<SwipeToDelete
|
||||
onDelete={() => deleteReview.mutate(entry.review.id)}
|
||||
confirmTitle={t("feed.deleteReview")}
|
||||
@@ -134,6 +139,16 @@ function FeedTab() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingEntry && (
|
||||
<EditReviewSheet
|
||||
key={editingEntry.review.id}
|
||||
open={!!editingEntry}
|
||||
onOpenChange={(open) => !open && setEditingEntry(null)}
|
||||
movie={editingEntry.movie}
|
||||
review={editingEntry.review}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"
|
||||
import { Bookmark, BookmarkCheck, Globe, Star, TrendingUp, User, Users } from "lucide-react"
|
||||
import { BackButton } from "@/components/back-button"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||
import { RatingHistogram } from "@/components/rating-histogram"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { HorizontalStrip } from "@/components/horizontal-strip"
|
||||
@@ -121,7 +122,10 @@ function MovieDetailPage() {
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[10px]">{timeAgo(r.watched_at)}</CardDescription>
|
||||
</div>
|
||||
<StarDisplay rating={r.rating} size="xs" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StarDisplay rating={r.rating} size="xs" />
|
||||
{r.watch_medium && <WatchMediumBadge medium={r.watch_medium} />}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{r.comment && (
|
||||
|
||||
@@ -113,15 +113,14 @@ function SettingsPage() {
|
||||
|
||||
{isAdmin && <AdminActions />}
|
||||
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleLogout}
|
||||
className="w-full rounded-xl bg-card p-3 text-sm font-medium text-red-400"
|
||||
className="w-full justify-start gap-3 rounded-xl bg-card p-3 text-red-400 hover:text-red-300"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<LogOut className="size-4" />
|
||||
{t("settings.logOut")}
|
||||
</div>
|
||||
</button>
|
||||
<LogOut className="size-4" />
|
||||
{t("settings.logOut")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,9 +68,9 @@ function WebhooksPage() {
|
||||
</Link>
|
||||
<h1 className="text-lg font-bold">{t("webhooks.title")}</h1>
|
||||
</div>
|
||||
<button onClick={() => setOpen(true)} className="text-primary">
|
||||
<Button variant="ghost" size="icon" onClick={() => setOpen(true)} className="text-primary">
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
@@ -97,12 +97,14 @@ function WebhooksPage() {
|
||||
{new Date(t.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove.mutate(t.id)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user