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:
2026-10-17 21:12:56 +02:00
parent 9dd9df1f73
commit bb2001b9cc
66 changed files with 513 additions and 167 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>}

View File

@@ -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}
/>
)}
</>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View 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>
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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",

View File

@@ -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>