feature/spoiler-flag (#25)
Some checks failed
CI / Check / Test (push) Has been cancelled

Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
2026-10-25 18:52:11 +00:00
parent 9dd9df1f73
commit 4f3d99ec8e
66 changed files with 502 additions and 168 deletions

View File

@@ -1,13 +1,19 @@
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[] }
onShowDetail?: (review: SocialReviewDto) => void
}
export function CommunityReviews({ reviews, onShowDetail }: CommunityReviewsProps) {
const { t } = useTranslation()
return (
@@ -17,36 +23,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
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

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

@@ -179,6 +179,7 @@ function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string;
comment={e.review.comment}
subtitle={<><TimeAgo date={e.review.watched_at} /></>}
variant="compact"
spoilers={e.review.spoilers}
onShowDetail={e.review.comment ? () => setDetailEntry(e) : undefined}
/>
)}

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"
@@ -21,6 +22,8 @@ type ReviewCardProps = {
}
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl, onEdit, onShowDetail }: ReviewCardProps) {
const veil = !!review.spoilers
const showDetail = veil ? undefined : onShowDetail
const card = (
<Card size="sm">
<CardContent className="flex gap-3">
@@ -61,15 +64,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"
@@ -40,9 +41,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>
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 every
* viewer, the author included. 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

@@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next"
import { TrendingUp } from "lucide-react"
import { StarDisplay } from "@/components/star-display"
import { SpoilerVeil } from "@/components/spoiler-veil"
import { shortDate } from "@/lib/date"
import type { ReviewHistoryResponse } from "@/features/movies"
@@ -24,7 +25,11 @@ export function ViewingHistory({ history }: { history: ReviewHistoryResponse })
<div>
<p className="text-sm font-medium">{shortDate(v.watched_at)}</p>
{v.comment && (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">{v.comment}</p>
v.spoilers ? (
<SpoilerVeil content={v.comment} className="mt-0.5 text-xs text-muted-foreground line-clamp-1" />
) : (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">{v.comment}</p>
)
)}
</div>
<StarDisplay rating={v.rating} size="xs" />

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

@@ -105,7 +105,10 @@ function MovieDetailPage() {
</section>
)}
<CommunityReviews reviews={reviews} onShowDetail={setDetailReview} />
<CommunityReviews
reviews={reviews}
onShowDetail={setDetailReview}
/>
{history && <ViewingHistory history={history} />}
@@ -114,7 +117,7 @@ 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}
/>
)}