Add Spoiler Flag with veil, AP content-warning encoding, and full-stack plumbing
Authors can flag a review's comment as containing spoilers; the comment is blurred behind a reveal control for every viewer except the author, omitted from RSS, and federates as containsSpoilers plus an AP summary content warning (ADR-0011) so Mastodon-class clients also collapse it. Flag follows the general-edit path (ADR-0001) across domain, both DB backends, REST, HTML templates, SPA (shadcn), TUI fixtures, and exports.
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Globe, Users } from "lucide-react"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { SpoilerVeil } from "@/components/spoiler-veil"
|
||||
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { timeAgo } from "@/lib/date"
|
||||
import type { SocialReviewDto } from "@/features/movies"
|
||||
|
||||
export function CommunityReviews({ reviews, onShowDetail }: { reviews: { items: SocialReviewDto[] }; onShowDetail?: (review: SocialReviewDto) => void }) {
|
||||
type CommunityReviewsProps = {
|
||||
reviews: { items: SocialReviewDto[] }
|
||||
/** Authenticated viewer id — own reviews are never veiled. */
|
||||
currentUserId?: string
|
||||
onShowDetail?: (review: SocialReviewDto) => void
|
||||
}
|
||||
|
||||
export function CommunityReviews({ reviews, currentUserId, onShowDetail }: CommunityReviewsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@@ -17,36 +25,43 @@ export function CommunityReviews({ reviews, onShowDetail }: { reviews: { items:
|
||||
<EmptyState icon={Users} title={t("movie.noReviews")} description={t("movie.beFirst")} />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{reviews.items.map((r, i) => (
|
||||
<Card key={i} size="sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-1.5 text-sm">
|
||||
{r.user_display}
|
||||
{r.is_federated && <Globe className="size-3 text-muted-foreground/60" />}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[10px]">{timeAgo(r.watched_at)}</CardDescription>
|
||||
{reviews.items.map((r, i) => {
|
||||
const veiled = !!r.spoilers && currentUserId !== r.user_id
|
||||
return (
|
||||
<Card key={i} size="sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-1.5 text-sm">
|
||||
{r.user_display}
|
||||
{r.is_federated && <Globe className="size-3 text-muted-foreground/60" />}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[10px]">{timeAgo(r.watched_at)}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StarDisplay rating={r.rating} size="xs" />
|
||||
{r.watch_medium && <WatchMediumBadge medium={r.watch_medium} />}
|
||||
</div>
|
||||
</div>
|
||||
<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 && (
|
||||
<CardContent>
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
role={onShowDetail ? "button" : undefined}
|
||||
tabIndex={onShowDetail ? 0 : undefined}
|
||||
onClick={onShowDetail ? () => onShowDetail(r) : undefined}
|
||||
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail(r) : undefined}
|
||||
>{r.comment}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</CardHeader>
|
||||
{r.comment && (
|
||||
<CardContent>
|
||||
{veiled ? (
|
||||
<SpoilerVeil content={r.comment} className="text-xs text-muted-foreground" />
|
||||
) : (
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
role={onShowDetail ? "button" : undefined}
|
||||
tabIndex={onShowDetail ? 0 : undefined}
|
||||
onClick={veiled ? undefined : onShowDetail ? () => onShowDetail(r) : undefined}
|
||||
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail(r) : undefined}
|
||||
>{r.comment}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -94,6 +94,7 @@ export function FeedTab() {
|
||||
onLoadMore={loadMore}
|
||||
renderItem={(entry) => {
|
||||
const isOwn = entry.user_id === auth?.user_id
|
||||
const veil = !isOwn && !!entry.review.spoilers
|
||||
const card = (
|
||||
<ReviewCard
|
||||
movie={entry.movie}
|
||||
@@ -102,6 +103,7 @@ export function FeedTab() {
|
||||
userId={entry.user_id}
|
||||
isFederated={entry.is_federated}
|
||||
actorUrl={entry.actor_url}
|
||||
veil={veil}
|
||||
onEdit={isOwn ? () => setEditingEntry(entry) : undefined}
|
||||
onShowDetail={entry.review.comment ? () => setDetailEntry(entry) : undefined}
|
||||
/>
|
||||
@@ -139,6 +141,7 @@ export function FeedTab() {
|
||||
movie={detailEntry.movie}
|
||||
review={detailEntry.review}
|
||||
userName={detailEntry.user_display_name}
|
||||
veil={detailEntry.user_id !== auth?.user_id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { SpoilerVeil } from "@/components/spoiler-veil"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { posterUrl } from "@/lib/api/client"
|
||||
import type { MovieDto } from "@/lib/api/common"
|
||||
@@ -11,10 +12,13 @@ type MovieCardProps = {
|
||||
subtitle?: React.ReactNode
|
||||
variant?: "compact" | "full"
|
||||
action?: React.ReactNode
|
||||
/** Blur the comment until revealed — set for every viewer except the author. */
|
||||
spoilers?: boolean
|
||||
onShowDetail?: () => void
|
||||
}
|
||||
|
||||
export function MovieCard({ movie, rating, comment, subtitle, variant = "full", action, onShowDetail }: MovieCardProps) {
|
||||
export function MovieCard({ movie, rating, comment, subtitle, variant = "full", action, spoilers, onShowDetail }: MovieCardProps) {
|
||||
const veiled = !!spoilers && !!comment
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<Link to="/movies/$id" params={{ id: movie.id }} className="glass flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors active:bg-muted/50">
|
||||
@@ -25,13 +29,17 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
|
||||
<p className="truncate text-sm font-semibold">{movie.title}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
{comment && (
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground/70"
|
||||
role={onShowDetail ? "button" : undefined}
|
||||
tabIndex={onShowDetail ? 0 : undefined}
|
||||
onClick={onShowDetail ? (e) => { e.preventDefault(); onShowDetail() } : undefined}
|
||||
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
|
||||
>{comment}</p>
|
||||
veiled ? (
|
||||
<SpoilerVeil content={comment} className="truncate text-xs text-muted-foreground/70" />
|
||||
) : (
|
||||
<p
|
||||
className="truncate text-xs text-muted-foreground/70"
|
||||
role={onShowDetail ? "button" : undefined}
|
||||
tabIndex={onShowDetail ? 0 : undefined}
|
||||
onClick={onShowDetail ? (e) => { e.preventDefault(); onShowDetail() } : undefined}
|
||||
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
|
||||
>{comment}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{rating != null && <StarDisplay rating={rating} size="xs" />}
|
||||
@@ -51,13 +59,17 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
|
||||
<p className="text-xs text-muted-foreground">{movie.release_year}{movie.director && ` · ${movie.director}`}</p>
|
||||
{rating != null && <div className="mt-1"><StarDisplay rating={rating} /></div>}
|
||||
{comment && (
|
||||
<p
|
||||
className="mt-1 line-clamp-2 text-xs text-muted-foreground"
|
||||
role={onShowDetail ? "button" : undefined}
|
||||
tabIndex={onShowDetail ? 0 : undefined}
|
||||
onClick={onShowDetail ? (e) => { e.preventDefault(); onShowDetail() } : undefined}
|
||||
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
|
||||
>{comment}</p>
|
||||
veiled ? (
|
||||
<SpoilerVeil content={comment} className="mt-1 line-clamp-2 text-xs text-muted-foreground" />
|
||||
) : (
|
||||
<p
|
||||
className="mt-1 line-clamp-2 text-xs text-muted-foreground"
|
||||
role={onShowDetail ? "button" : undefined}
|
||||
tabIndex={onShowDetail ? 0 : undefined}
|
||||
onClick={onShowDetail ? (e) => { e.preventDefault(); onShowDetail() } : undefined}
|
||||
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
|
||||
>{comment}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="flex items-center" onClick={(e) => e.preventDefault()}>{action}</div>}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { TimeAgo } from "@/components/time-ago"
|
||||
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
||||
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
|
||||
import { DiaryCalendar } from "@/components/diary-calendar"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import type { DiaryEntryDto } from "@/lib/api/common"
|
||||
import { tmdbProfileUrl } from "@/lib/api/client"
|
||||
import type { UserProfileResponse } from "@/features/users"
|
||||
@@ -150,6 +151,7 @@ function StatCell({ label, value }: { label: string; value: string | number }) {
|
||||
|
||||
function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string; search?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { auth } = useAuth()
|
||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteDiary({ sort_by: sortBy, user_id: userId })
|
||||
const items = data?.pages.flatMap((p) => p.items) ?? []
|
||||
@@ -179,6 +181,7 @@ function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string;
|
||||
comment={e.review.comment}
|
||||
subtitle={<><TimeAgo date={e.review.watched_at} /></>}
|
||||
variant="compact"
|
||||
spoilers={userId && userId === auth?.user_id ? undefined : e.review.spoilers}
|
||||
onShowDetail={e.review.comment ? () => setDetailEntry(e) : undefined}
|
||||
/>
|
||||
)}
|
||||
@@ -189,6 +192,7 @@ function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string;
|
||||
onOpenChange={(open) => !open && setDetailEntry(null)}
|
||||
movie={detailEntry.movie}
|
||||
review={detailEntry.review}
|
||||
veil={!!userId && userId !== auth?.user_id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TimeAgo } from "@/components/time-ago"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||
import { EditableContextMenu } from "@/components/editable-context-menu"
|
||||
import { SpoilerVeil } from "@/components/spoiler-veil"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { posterUrl } from "@/lib/api/client"
|
||||
@@ -16,11 +17,14 @@ type ReviewCardProps = {
|
||||
userId?: string
|
||||
isFederated?: boolean
|
||||
actorUrl?: string
|
||||
/** Blur the comment until revealed — set for every viewer except the author. */
|
||||
veil?: boolean
|
||||
onEdit?: () => void
|
||||
onShowDetail?: () => void
|
||||
}
|
||||
|
||||
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl, onEdit, onShowDetail }: ReviewCardProps) {
|
||||
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl, veil, onEdit, onShowDetail }: ReviewCardProps) {
|
||||
const showDetail = veil ? undefined : onShowDetail
|
||||
const card = (
|
||||
<Card size="sm">
|
||||
<CardContent className="flex gap-3">
|
||||
@@ -61,15 +65,19 @@ export function ReviewCard({ movie, review, userName, userId, isFederated, actor
|
||||
{review.watch_medium && <WatchMediumBadge medium={review.watch_medium} />}
|
||||
</div>
|
||||
{review.comment && (
|
||||
<p
|
||||
className="mt-1 line-clamp-2 text-xs text-muted-foreground"
|
||||
role={onShowDetail ? "button" : undefined}
|
||||
tabIndex={onShowDetail ? 0 : undefined}
|
||||
onClick={onShowDetail}
|
||||
onKeyDown={onShowDetail ? (e) => e.key === "Enter" && onShowDetail() : undefined}
|
||||
>
|
||||
{review.comment}
|
||||
</p>
|
||||
veil ? (
|
||||
<SpoilerVeil content={review.comment} className="mt-1 text-xs text-muted-foreground" />
|
||||
) : (
|
||||
<p
|
||||
className="mt-1 line-clamp-2 text-xs text-muted-foreground"
|
||||
role={showDetail ? "button" : undefined}
|
||||
tabIndex={showDetail ? 0 : undefined}
|
||||
onClick={showDetail}
|
||||
onKeyDown={showDetail ? (e) => e.key === "Enter" && showDetail() : undefined}
|
||||
>
|
||||
{review.comment}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { VisuallyHidden } from "radix-ui"
|
||||
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
|
||||
import { StarDisplay } from "@/components/star-display"
|
||||
import { SpoilerVeil } from "@/components/spoiler-veil"
|
||||
import { WatchMediumBadge } from "@/components/watch-medium-badge"
|
||||
import { shortDate } from "@/lib/date"
|
||||
import { posterUrl } from "@/lib/api/client"
|
||||
@@ -12,9 +13,11 @@ type ReviewDetailSheetProps = {
|
||||
movie: MovieDto
|
||||
review: ReviewDto
|
||||
userName?: string
|
||||
/** Blur the comment until revealed — set for every viewer except the author. */
|
||||
veil?: boolean
|
||||
}
|
||||
|
||||
export function ReviewDetailSheet({ open, onOpenChange, movie, review, userName }: ReviewDetailSheetProps) {
|
||||
export function ReviewDetailSheet({ open, onOpenChange, movie, review, userName, veil }: ReviewDetailSheetProps) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent className="mx-auto max-w-lg">
|
||||
@@ -40,9 +43,13 @@ export function ReviewDetailSheet({ open, onOpenChange, movie, review, userName
|
||||
</div>
|
||||
|
||||
{review.comment && (
|
||||
<p className="select-text whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{review.comment}
|
||||
</p>
|
||||
veil && review.spoilers ? (
|
||||
<SpoilerVeil content={review.comment} className="text-sm leading-relaxed" />
|
||||
) : (
|
||||
<p className="select-text whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{review.comment}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next"
|
||||
import { CalendarIcon } from "lucide-react"
|
||||
import { format } from "date-fns"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
@@ -19,6 +21,8 @@ type ReviewFormFieldsProps = {
|
||||
onWatchedAtChange: (v: Date) => void
|
||||
watchMedium?: string
|
||||
onWatchMediumChange: (v: string | undefined) => void
|
||||
spoilers: boolean
|
||||
onSpoilersChange: (v: boolean) => void
|
||||
}
|
||||
|
||||
export function ReviewFormFields({
|
||||
@@ -30,6 +34,8 @@ export function ReviewFormFields({
|
||||
onWatchedAtChange,
|
||||
watchMedium,
|
||||
onWatchMediumChange,
|
||||
spoilers,
|
||||
onSpoilersChange,
|
||||
}: ReviewFormFieldsProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -42,6 +48,17 @@ export function ReviewFormFields({
|
||||
|
||||
<AutoGrowTextarea value={comment} onChange={onCommentChange} placeholder={t("logReview.commentPlaceholder")} className="mb-5" />
|
||||
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="review-spoilers"
|
||||
checked={spoilers}
|
||||
onCheckedChange={(v) => onSpoilersChange(v === true)}
|
||||
/>
|
||||
<Label htmlFor="review-spoilers" className="text-xs font-normal text-muted-foreground">
|
||||
{t("review.containsSpoilers")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">{t("logReview.watchedAt")}</p>
|
||||
<Popover modal>
|
||||
|
||||
@@ -52,6 +52,7 @@ function LogMode({ open, onOpenChange }: { open: boolean; onOpenChange: (open: b
|
||||
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() {
|
||||
@@ -60,6 +61,7 @@ function LogMode({ open, onOpenChange }: { open: boolean; onOpenChange: (open: b
|
||||
setComment("")
|
||||
setWatchedAt(new Date())
|
||||
setWatchMedium(undefined)
|
||||
setSpoilers(false)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
@@ -79,6 +81,7 @@ function LogMode({ open, onOpenChange }: { open: boolean; onOpenChange: (open: b
|
||||
comment: comment || undefined,
|
||||
watched_at: formatWatchedAt(watchedAt),
|
||||
watch_medium: watchMedium,
|
||||
spoilers: spoilers || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -118,6 +121,8 @@ function LogMode({ open, onOpenChange }: { open: boolean; onOpenChange: (open: b
|
||||
onWatchedAtChange={setWatchedAt}
|
||||
watchMedium={watchMedium}
|
||||
onWatchMediumChange={setWatchMedium}
|
||||
spoilers={spoilers}
|
||||
onSpoilersChange={setSpoilers}
|
||||
/>
|
||||
|
||||
<Button onClick={handleSubmit} disabled={!rating || logMutation.isPending} className="w-full" size="lg">
|
||||
@@ -148,6 +153,7 @@ function EditMode({
|
||||
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()
|
||||
@@ -166,6 +172,7 @@ function EditMode({
|
||||
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"))
|
||||
@@ -206,6 +213,8 @@ function EditMode({
|
||||
onWatchedAtChange={handleDateChange}
|
||||
watchMedium={watchMedium}
|
||||
onWatchMediumChange={setWatchMedium}
|
||||
spoilers={spoilers}
|
||||
onSpoilersChange={setSpoilers}
|
||||
/>
|
||||
|
||||
<Button onClick={handleSubmit} disabled={!rating || editMutation.isPending} className="w-full" size="lg">
|
||||
|
||||
43
spa/src/components/spoiler-veil.tsx
Normal file
43
spa/src/components/spoiler-veil.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type SpoilerVeilProps = {
|
||||
content: string
|
||||
/** Layout classes for the veiled text (line clamps, sizing, color). */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides review text behind a blur with a reveal button — shown to everyone
|
||||
* except the review's author. Once revealed, the text stays visible.
|
||||
*/
|
||||
export function SpoilerVeil({ content, className }: SpoilerVeilProps) {
|
||||
const { t } = useTranslation()
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
|
||||
if (revealed) {
|
||||
return <span className={cn("block whitespace-pre-wrap", className)}>{content}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="relative block" aria-label={t("review.containsSpoilers")}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("pointer-events-none block select-none blur-[5px]", className)}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="absolute inset-y-0 left-0 h-6 self-center px-2 text-[10px]"
|
||||
onClick={() => setRevealed(true)}
|
||||
>
|
||||
{t("review.showSpoilers")}
|
||||
</Button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export const logReviewRequestSchema = z.object({
|
||||
comment: z.string().optional(),
|
||||
watched_at: z.string(),
|
||||
watch_medium: z.string().optional(),
|
||||
spoilers: z.boolean().optional(),
|
||||
})
|
||||
export type LogReviewRequest = z.infer<typeof logReviewRequestSchema>
|
||||
|
||||
@@ -40,6 +41,7 @@ export const editReviewRequestSchema = z.object({
|
||||
comment: z.string().nullable().optional(),
|
||||
watched_at: z.string().optional(),
|
||||
watch_medium: z.string().nullable().optional(),
|
||||
spoilers: z.boolean().optional(),
|
||||
})
|
||||
export type EditReviewRequest = z.infer<typeof editReviewRequestSchema>
|
||||
|
||||
|
||||
@@ -26,11 +26,13 @@ export type MovieStatsDto = z.infer<typeof movieStatsDtoSchema>
|
||||
|
||||
export const socialReviewDtoSchema = z.object({
|
||||
user_display: z.string(),
|
||||
user_id: z.string(),
|
||||
rating: z.number(),
|
||||
comment: z.string().optional(),
|
||||
watched_at: z.string(),
|
||||
is_federated: z.boolean(),
|
||||
watch_medium: z.string().optional(),
|
||||
spoilers: z.boolean().optional(),
|
||||
})
|
||||
export type SocialReviewDto = z.infer<typeof socialReviewDtoSchema>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export const reviewDtoSchema = z.object({
|
||||
comment: z.string().optional(),
|
||||
watched_at: z.string(),
|
||||
watch_medium: z.string().optional(),
|
||||
spoilers: z.boolean().optional(),
|
||||
})
|
||||
export type ReviewDto = z.infer<typeof reviewDtoSchema>
|
||||
|
||||
|
||||
@@ -353,6 +353,10 @@
|
||||
"saved": "{{title}} updated!",
|
||||
"noChanges": "No changes to save"
|
||||
},
|
||||
"review": {
|
||||
"containsSpoilers": "Contains spoilers",
|
||||
"showSpoilers": "Show"
|
||||
},
|
||||
"logReview": {
|
||||
"title": "Log Review",
|
||||
"yourRating": "Your Rating",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
|
||||
import { useAuth } from "@/components/auth-provider"
|
||||
import { useMovie, useMovieHistory, useMovieProfile } from "@/features/movies"
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ function MovieDetailPage() {
|
||||
const { t } = useTranslation()
|
||||
const { id } = Route.useParams()
|
||||
const { data, isPending } = useMovie(id)
|
||||
const { auth } = useAuth()
|
||||
const { data: profile } = useMovieProfile(id)
|
||||
const { data: history } = useMovieHistory(id)
|
||||
useDocumentTitle(data?.movie.title)
|
||||
@@ -105,7 +107,11 @@ function MovieDetailPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<CommunityReviews reviews={reviews} onShowDetail={setDetailReview} />
|
||||
<CommunityReviews
|
||||
reviews={reviews}
|
||||
currentUserId={auth?.user_id}
|
||||
onShowDetail={setDetailReview}
|
||||
/>
|
||||
|
||||
{history && <ViewingHistory history={history} />}
|
||||
|
||||
@@ -114,8 +120,9 @@ function MovieDetailPage() {
|
||||
open={!!detailReview}
|
||||
onOpenChange={(open) => !open && setDetailReview(null)}
|
||||
movie={movie}
|
||||
review={{ id: "", rating: detailReview.rating, comment: detailReview.comment, watched_at: detailReview.watched_at, watch_medium: detailReview.watch_medium }}
|
||||
review={{ id: "", rating: detailReview.rating, comment: detailReview.comment, watched_at: detailReview.watched_at, watch_medium: detailReview.watch_medium, spoilers: detailReview.spoilers }}
|
||||
userName={detailReview.user_display}
|
||||
veil={detailReview.user_id !== auth?.user_id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user