feature/spoiler-flag #25
@@ -45,9 +45,8 @@
|
|||||||
{% if let Some(wm) = entry.review().watch_medium() %}
|
{% if let Some(wm) = entry.review().watch_medium() %}
|
||||||
<span class="watch-medium">{{ wm.label() }}</span>
|
<span class="watch-medium">{{ wm.label() }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% let author = ctx.is_current_user(entry.review().user_id().value()) %}
|
|
||||||
{% if let Some(comment) = entry.review().comment() %}
|
{% if let Some(comment) = entry.review().comment() %}
|
||||||
{% if !author && entry.review().spoilers() %}
|
{% if entry.review().spoilers() %}
|
||||||
<details class="comment spoiler">
|
<details class="comment spoiler">
|
||||||
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
||||||
{{ comment.value() }}
|
{{ comment.value() }}
|
||||||
|
|||||||
@@ -129,9 +129,8 @@
|
|||||||
<span class="star {% if filled %}filled{% else %}empty{% endif %}">★</span>
|
<span class="star {% if filled %}filled{% else %}empty{% endif %}">★</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% let author = ctx.is_current_user(entry.review().user_id().value()) %}
|
|
||||||
{% if let Some(comment) = entry.review().comment() %}
|
{% if let Some(comment) = entry.review().comment() %}
|
||||||
{% if !author && entry.review().spoilers() %}
|
{% if entry.review().spoilers() %}
|
||||||
<details class="comment spoiler">
|
<details class="comment spoiler">
|
||||||
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
||||||
{{ comment.value() }}
|
{{ comment.value() }}
|
||||||
|
|||||||
@@ -219,9 +219,8 @@
|
|||||||
<span class="star {% if filled %}filled{% else %}empty{% endif %}">★</span>
|
<span class="star {% if filled %}filled{% else %}empty{% endif %}">★</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% let author = ctx.is_current_user(entry.review().user_id().value()) %}
|
|
||||||
{% if let Some(comment) = entry.review().comment() %}
|
{% if let Some(comment) = entry.review().comment() %}
|
||||||
{% if !author && entry.review().spoilers() %}
|
{% if entry.review().spoilers() %}
|
||||||
<details class="comment spoiler">
|
<details class="comment spoiler">
|
||||||
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
<summary style="cursor:pointer;opacity:.75">Contains spoilers</summary>
|
||||||
{{ comment.value() }}
|
{{ comment.value() }}
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ import type { SocialReviewDto } from "@/features/movies"
|
|||||||
|
|
||||||
type CommunityReviewsProps = {
|
type CommunityReviewsProps = {
|
||||||
reviews: { items: SocialReviewDto[] }
|
reviews: { items: SocialReviewDto[] }
|
||||||
/** Authenticated viewer id — own reviews are never veiled. */
|
|
||||||
currentUserId?: string
|
|
||||||
onShowDetail?: (review: SocialReviewDto) => void
|
onShowDetail?: (review: SocialReviewDto) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommunityReviews({ reviews, currentUserId, onShowDetail }: CommunityReviewsProps) {
|
export function CommunityReviews({ reviews, onShowDetail }: CommunityReviewsProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,7 +24,7 @@ export function CommunityReviews({ reviews, currentUserId, onShowDetail }: Commu
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{reviews.items.map((r, i) => {
|
{reviews.items.map((r, i) => {
|
||||||
const veiled = !!r.spoilers && currentUserId !== r.user_id
|
const veiled = !!r.spoilers
|
||||||
return (
|
return (
|
||||||
<Card key={i} size="sm">
|
<Card key={i} size="sm">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ export function FeedTab() {
|
|||||||
onLoadMore={loadMore}
|
onLoadMore={loadMore}
|
||||||
renderItem={(entry) => {
|
renderItem={(entry) => {
|
||||||
const isOwn = entry.user_id === auth?.user_id
|
const isOwn = entry.user_id === auth?.user_id
|
||||||
const veil = !isOwn && !!entry.review.spoilers
|
|
||||||
const card = (
|
const card = (
|
||||||
<ReviewCard
|
<ReviewCard
|
||||||
movie={entry.movie}
|
movie={entry.movie}
|
||||||
@@ -103,7 +102,6 @@ export function FeedTab() {
|
|||||||
userId={entry.user_id}
|
userId={entry.user_id}
|
||||||
isFederated={entry.is_federated}
|
isFederated={entry.is_federated}
|
||||||
actorUrl={entry.actor_url}
|
actorUrl={entry.actor_url}
|
||||||
veil={veil}
|
|
||||||
onEdit={isOwn ? () => setEditingEntry(entry) : undefined}
|
onEdit={isOwn ? () => setEditingEntry(entry) : undefined}
|
||||||
onShowDetail={entry.review.comment ? () => setDetailEntry(entry) : undefined}
|
onShowDetail={entry.review.comment ? () => setDetailEntry(entry) : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -141,7 +139,6 @@ export function FeedTab() {
|
|||||||
movie={detailEntry.movie}
|
movie={detailEntry.movie}
|
||||||
review={detailEntry.review}
|
review={detailEntry.review}
|
||||||
userName={detailEntry.user_display_name}
|
userName={detailEntry.user_display_name}
|
||||||
veil={detailEntry.user_id !== auth?.user_id}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { TimeAgo } from "@/components/time-ago"
|
|||||||
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
|
||||||
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
|
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
|
||||||
import { DiaryCalendar } from "@/components/diary-calendar"
|
import { DiaryCalendar } from "@/components/diary-calendar"
|
||||||
import { useAuth } from "@/components/auth-provider"
|
|
||||||
import type { DiaryEntryDto } from "@/lib/api/common"
|
import type { DiaryEntryDto } from "@/lib/api/common"
|
||||||
import { tmdbProfileUrl } from "@/lib/api/client"
|
import { tmdbProfileUrl } from "@/lib/api/client"
|
||||||
import type { UserProfileResponse } from "@/features/users"
|
import type { UserProfileResponse } from "@/features/users"
|
||||||
@@ -151,7 +150,6 @@ function StatCell({ label, value }: { label: string; value: string | number }) {
|
|||||||
|
|
||||||
function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string; search?: string }) {
|
function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string; search?: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { auth } = useAuth()
|
|
||||||
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||||
useInfiniteDiary({ sort_by: sortBy, user_id: userId })
|
useInfiniteDiary({ sort_by: sortBy, user_id: userId })
|
||||||
const items = data?.pages.flatMap((p) => p.items) ?? []
|
const items = data?.pages.flatMap((p) => p.items) ?? []
|
||||||
@@ -181,7 +179,7 @@ function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string;
|
|||||||
comment={e.review.comment}
|
comment={e.review.comment}
|
||||||
subtitle={<><TimeAgo date={e.review.watched_at} /></>}
|
subtitle={<><TimeAgo date={e.review.watched_at} /></>}
|
||||||
variant="compact"
|
variant="compact"
|
||||||
spoilers={userId && userId === auth?.user_id ? undefined : e.review.spoilers}
|
spoilers={e.review.spoilers}
|
||||||
onShowDetail={e.review.comment ? () => setDetailEntry(e) : undefined}
|
onShowDetail={e.review.comment ? () => setDetailEntry(e) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -192,7 +190,6 @@ function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string;
|
|||||||
onOpenChange={(open) => !open && setDetailEntry(null)}
|
onOpenChange={(open) => !open && setDetailEntry(null)}
|
||||||
movie={detailEntry.movie}
|
movie={detailEntry.movie}
|
||||||
review={detailEntry.review}
|
review={detailEntry.review}
|
||||||
veil={!!userId && userId !== auth?.user_id}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -17,13 +17,12 @@ type ReviewCardProps = {
|
|||||||
userId?: string
|
userId?: string
|
||||||
isFederated?: boolean
|
isFederated?: boolean
|
||||||
actorUrl?: string
|
actorUrl?: string
|
||||||
/** Blur the comment until revealed — set for every viewer except the author. */
|
|
||||||
veil?: boolean
|
|
||||||
onEdit?: () => void
|
onEdit?: () => void
|
||||||
onShowDetail?: () => void
|
onShowDetail?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl, veil, onEdit, onShowDetail }: ReviewCardProps) {
|
export function ReviewCard({ movie, review, userName, userId, isFederated, actorUrl, onEdit, onShowDetail }: ReviewCardProps) {
|
||||||
|
const veil = !!review.spoilers
|
||||||
const showDetail = veil ? undefined : onShowDetail
|
const showDetail = veil ? undefined : onShowDetail
|
||||||
const card = (
|
const card = (
|
||||||
<Card size="sm">
|
<Card size="sm">
|
||||||
|
|||||||
@@ -13,11 +13,9 @@ type ReviewDetailSheetProps = {
|
|||||||
movie: MovieDto
|
movie: MovieDto
|
||||||
review: ReviewDto
|
review: ReviewDto
|
||||||
userName?: string
|
userName?: string
|
||||||
/** Blur the comment until revealed — set for every viewer except the author. */
|
|
||||||
veil?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewDetailSheet({ open, onOpenChange, movie, review, userName, veil }: ReviewDetailSheetProps) {
|
export function ReviewDetailSheet({ open, onOpenChange, movie, review, userName }: ReviewDetailSheetProps) {
|
||||||
return (
|
return (
|
||||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
<DrawerContent className="mx-auto max-w-lg">
|
<DrawerContent className="mx-auto max-w-lg">
|
||||||
@@ -43,7 +41,7 @@ export function ReviewDetailSheet({ open, onOpenChange, movie, review, userName,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{review.comment && (
|
{review.comment && (
|
||||||
veil && review.spoilers ? (
|
review.spoilers ? (
|
||||||
<SpoilerVeil content={review.comment} className="text-sm leading-relaxed" />
|
<SpoilerVeil content={review.comment} className="text-sm leading-relaxed" />
|
||||||
) : (
|
) : (
|
||||||
<p className="select-text whitespace-pre-wrap text-sm leading-relaxed">
|
<p className="select-text whitespace-pre-wrap text-sm leading-relaxed">
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ type SpoilerVeilProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hides review text behind a blur with a reveal button — shown to everyone
|
* Hides review text behind a blur with a reveal button — shown to every
|
||||||
* except the review's author. Once revealed, the text stays visible.
|
* viewer, the author included. Once revealed, the text stays visible.
|
||||||
*/
|
*/
|
||||||
export function SpoilerVeil({ content, className }: SpoilerVeilProps) {
|
export function SpoilerVeil({ content, className }: SpoilerVeilProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { TrendingUp } from "lucide-react"
|
import { TrendingUp } from "lucide-react"
|
||||||
import { StarDisplay } from "@/components/star-display"
|
import { StarDisplay } from "@/components/star-display"
|
||||||
|
import { SpoilerVeil } from "@/components/spoiler-veil"
|
||||||
import { shortDate } from "@/lib/date"
|
import { shortDate } from "@/lib/date"
|
||||||
import type { ReviewHistoryResponse } from "@/features/movies"
|
import type { ReviewHistoryResponse } from "@/features/movies"
|
||||||
|
|
||||||
@@ -24,7 +25,11 @@ export function ViewingHistory({ history }: { history: ReviewHistoryResponse })
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{shortDate(v.watched_at)}</p>
|
<p className="text-sm font-medium">{shortDate(v.watched_at)}</p>
|
||||||
{v.comment && (
|
{v.comment && (
|
||||||
|
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>
|
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">{v.comment}</p>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<StarDisplay rating={v.rating} size="xs" />
|
<StarDisplay rating={v.rating} size="xs" />
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
|
import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
|
||||||
import { useAuth } from "@/components/auth-provider"
|
|
||||||
import { useMovie, useMovieHistory, useMovieProfile } from "@/features/movies"
|
import { useMovie, useMovieHistory, useMovieProfile } from "@/features/movies"
|
||||||
import { useDocumentTitle } from "@/hooks/use-document-title"
|
import { useDocumentTitle } from "@/hooks/use-document-title"
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +29,6 @@ function MovieDetailPage() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { id } = Route.useParams()
|
const { id } = Route.useParams()
|
||||||
const { data, isPending } = useMovie(id)
|
const { data, isPending } = useMovie(id)
|
||||||
const { auth } = useAuth()
|
|
||||||
const { data: profile } = useMovieProfile(id)
|
const { data: profile } = useMovieProfile(id)
|
||||||
const { data: history } = useMovieHistory(id)
|
const { data: history } = useMovieHistory(id)
|
||||||
useDocumentTitle(data?.movie.title)
|
useDocumentTitle(data?.movie.title)
|
||||||
@@ -109,7 +107,6 @@ function MovieDetailPage() {
|
|||||||
|
|
||||||
<CommunityReviews
|
<CommunityReviews
|
||||||
reviews={reviews}
|
reviews={reviews}
|
||||||
currentUserId={auth?.user_id}
|
|
||||||
onShowDetail={setDetailReview}
|
onShowDetail={setDetailReview}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -122,7 +119,6 @@ function MovieDetailPage() {
|
|||||||
movie={movie}
|
movie={movie}
|
||||||
review={{ id: "", rating: detailReview.rating, comment: detailReview.comment, watched_at: detailReview.watched_at, watch_medium: detailReview.watch_medium, spoilers: detailReview.spoilers }}
|
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}
|
userName={detailReview.user_display}
|
||||||
veil={detailReview.user_id !== auth?.user_id}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user