review detail sheet on diary/profile/community, wrapup period presets

This commit is contained in:
2026-07-10 21:51:42 +02:00
parent 1a7448fd3d
commit 07df6fe207
6 changed files with 116 additions and 22 deletions

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com
import { timeAgo } from "@/lib/date" import { timeAgo } from "@/lib/date"
import type { SocialReviewDto } from "@/features/movies" import type { SocialReviewDto } from "@/features/movies"
export function CommunityReviews({ reviews }: { reviews: { items: SocialReviewDto[] } }) { export function CommunityReviews({ reviews, onShowDetail }: { reviews: { items: SocialReviewDto[] }; onShowDetail?: (review: SocialReviewDto) => void }) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
@@ -36,7 +36,13 @@ export function CommunityReviews({ reviews }: { reviews: { items: SocialReviewDt
</CardHeader> </CardHeader>
{r.comment && ( {r.comment && (
<CardContent> <CardContent>
<p className="text-xs text-muted-foreground">{r.comment}</p> <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> </CardContent>
)} )}
</Card> </Card>

View File

@@ -11,9 +11,10 @@ type MovieCardProps = {
subtitle?: React.ReactNode subtitle?: React.ReactNode
variant?: "compact" | "full" variant?: "compact" | "full"
action?: React.ReactNode action?: React.ReactNode
onShowDetail?: () => void
} }
export function MovieCard({ movie, rating, comment, subtitle, variant = "full", action }: MovieCardProps) { export function MovieCard({ movie, rating, comment, subtitle, variant = "full", action, onShowDetail }: MovieCardProps) {
if (variant === "compact") { if (variant === "compact") {
return ( 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"> <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">
@@ -23,7 +24,15 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{movie.title}</p> <p className="truncate text-sm font-semibold">{movie.title}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>} {subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
{comment && <p className="truncate text-xs text-muted-foreground/70">{comment}</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>
)}
</div> </div>
{rating != null && <StarDisplay rating={rating} size="xs" />} {rating != null && <StarDisplay rating={rating} size="xs" />}
</Link> </Link>
@@ -41,7 +50,15 @@ export function MovieCard({ movie, rating, comment, subtitle, variant = "full",
<p className="font-semibold">{movie.title}</p> <p className="font-semibold">{movie.title}</p>
<p className="text-xs text-muted-foreground">{movie.release_year}{movie.director && ` · ${movie.director}`}</p> <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>} {rating != null && <div className="mt-1"><StarDisplay rating={rating} /></div>}
{comment && <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{comment}</p>} {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>
)}
</div> </div>
{action && <div className="flex items-center" onClick={(e) => e.preventDefault()}>{action}</div>} {action && <div className="flex items-center" onClick={(e) => e.preventDefault()}>{action}</div>}
</CardContent> </CardContent>

View File

@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { useCallback } from "react" import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Bar, BarChart, XAxis, YAxis } from "recharts" import { Bar, BarChart, XAxis, YAxis } from "recharts"
import { Globe, Search, User } from "lucide-react" import { Globe, Search, User } from "lucide-react"
@@ -16,6 +16,8 @@ import { VirtualList } from "@/components/virtual-list"
import { useInfiniteDiary } from "@/features/diary" import { useInfiniteDiary } from "@/features/diary"
import { TimeAgo } from "@/components/time-ago" 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 type { DiaryEntryDto } from "@/lib/api/common"
import type { UserProfileResponse } from "@/features/users" import type { UserProfileResponse } from "@/features/users"
type ProfileViewProps = { type ProfileViewProps = {
@@ -154,27 +156,39 @@ function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string;
) )
: items : items
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage]) const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
const [detailEntry, setDetailEntry] = useState<DiaryEntryDto | null>(null)
if (isPending) return <Skeleton className="h-40 w-full rounded-xl" /> if (isPending) return <Skeleton className="h-40 w-full rounded-xl" />
if (!filtered.length) return <EmptyState icon={User} title={t("profile.noEntries")} /> if (!filtered.length) return <EmptyState icon={User} title={t("profile.noEntries")} />
return ( return (
<VirtualList <>
items={filtered} <VirtualList
estimateSize={52} items={filtered}
hasMore={!!hasNextPage} estimateSize={52}
isFetching={isFetchingNextPage} hasMore={!!hasNextPage}
onLoadMore={loadMore} isFetching={isFetchingNextPage}
renderItem={(e) => ( onLoadMore={loadMore}
<MovieCard renderItem={(e) => (
movie={e.movie} <MovieCard
rating={e.review.rating} movie={e.movie}
comment={e.review.comment} rating={e.review.rating}
subtitle={<><TimeAgo date={e.review.watched_at} /></>} comment={e.review.comment}
variant="compact" subtitle={<><TimeAgo date={e.review.watched_at} /></>}
variant="compact"
onShowDetail={e.review.comment ? () => setDetailEntry(e) : undefined}
/>
)}
/>
{detailEntry && (
<ReviewDetailSheet
open={!!detailEntry}
onOpenChange={(open) => !open && setDetailEntry(null)}
movie={detailEntry.movie}
review={detailEntry.review}
/> />
)} )}
/> </>
) )
} }

View File

@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"
import { BookOpen, ChevronLeft, ChevronRight, Pencil } from "lucide-react" import { BookOpen, ChevronLeft, ChevronRight, Pencil } from "lucide-react"
import { format, startOfMonth, subMonths } from "date-fns" import { format, startOfMonth, subMonths } from "date-fns"
import { ReviewSheet } from "@/components/review-sheet" import { ReviewSheet } from "@/components/review-sheet"
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
import { EditableContextMenu } from "@/components/editable-context-menu" import { EditableContextMenu } from "@/components/editable-context-menu"
import { MovieCard } from "@/components/movie-card" import { MovieCard } from "@/components/movie-card"
import { EmptyState } from "@/components/empty-state" import { EmptyState } from "@/components/empty-state"
@@ -39,6 +40,7 @@ function DiaryPage() {
useInfiniteDiary({ sort_by: "desc", user_id: auth?.user_id }) useInfiniteDiary({ sort_by: "desc", user_id: auth?.user_id })
const deleteReview = useDeleteReview() const deleteReview = useDeleteReview()
const [editingEntry, setEditingEntry] = useState<DiaryEntryDto | null>(null) const [editingEntry, setEditingEntry] = useState<DiaryEntryDto | null>(null)
const [detailEntry, setDetailEntry] = useState<DiaryEntryDto | null>(null)
const monthLabel = format(month, "MMMM yyyy") const monthLabel = format(month, "MMMM yyyy")
const monthStr = format(month, "yyyy-MM") const monthStr = format(month, "yyyy-MM")
@@ -121,6 +123,7 @@ function DiaryPage() {
rating={item.entry.review.rating} rating={item.entry.review.rating}
comment={item.entry.review.comment} comment={item.entry.review.comment}
variant="full" variant="full"
onShowDetail={item.entry.review.comment ? () => setDetailEntry(item.entry) : undefined}
action={ action={
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{item.entry.review.watch_medium && ( {item.entry.review.watch_medium && (
@@ -154,6 +157,15 @@ function DiaryPage() {
review={editingEntry.review} review={editingEntry.review}
/> />
)} )}
{detailEntry && (
<ReviewDetailSheet
open={!!detailEntry}
onOpenChange={(open) => !open && setDetailEntry(null)}
movie={detailEntry.movie}
review={detailEntry.review}
/>
)}
</div> </div>
) )
} }

View File

@@ -1,8 +1,10 @@
import { createFileRoute, Link } from "@tanstack/react-router" import { createFileRoute, Link } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Bookmark, BookmarkCheck, Star, User } from "lucide-react" import { Bookmark, BookmarkCheck, Star, User } from "lucide-react"
import { BackButton } from "@/components/back-button" import { BackButton } from "@/components/back-button"
import { CommunityReviews } from "@/components/community-reviews" import { CommunityReviews } from "@/components/community-reviews"
import { ReviewDetailSheet } from "@/components/review-detail-sheet"
import { ViewingHistory } from "@/components/viewing-history" import { ViewingHistory } from "@/components/viewing-history"
import { RatingHistogram } from "@/components/rating-histogram" import { RatingHistogram } from "@/components/rating-histogram"
import { HorizontalStrip } from "@/components/horizontal-strip" import { HorizontalStrip } from "@/components/horizontal-strip"
@@ -17,7 +19,7 @@ import {
useAddToWatchlist, useAddToWatchlist,
useRemoveFromWatchlist, useRemoveFromWatchlist,
} from "@/features/watchlist" } from "@/features/watchlist"
import type { CastMemberDto, CrewMemberDto } from "@/features/movies" import type { CastMemberDto, CrewMemberDto, SocialReviewDto } from "@/features/movies"
export const Route = createFileRoute("/_app/movies/$id")({ export const Route = createFileRoute("/_app/movies/$id")({
component: MovieDetailPage, component: MovieDetailPage,
@@ -30,6 +32,7 @@ function MovieDetailPage() {
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)
const [detailReview, setDetailReview] = useState<SocialReviewDto | null>(null)
if (isPending) return <DetailSkeleton /> if (isPending) return <DetailSkeleton />
if (!data) return null if (!data) return null
@@ -102,9 +105,19 @@ function MovieDetailPage() {
</section> </section>
)} )}
<CommunityReviews reviews={reviews} /> <CommunityReviews reviews={reviews} onShowDetail={setDetailReview} />
{history && <ViewingHistory history={history} />} {history && <ViewingHistory history={history} />}
{detailReview && (
<ReviewDetailSheet
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 }}
userName={detailReview.user_display}
/>
)}
</div> </div>
) )
} }

View File

@@ -2,6 +2,7 @@ import { createFileRoute, Link } from "@tanstack/react-router"
import { useState } from "react" import { useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { ArrowLeft, ChevronRight, Sparkles, Trash2 } from "lucide-react" import { ArrowLeft, ChevronRight, Sparkles, Trash2 } from "lucide-react"
import { format, subMonths, startOfYear, endOfYear } from "date-fns"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
@@ -125,6 +126,7 @@ function WrapupPage() {
<DrawerTitle>{t("wrapup.generateWrapUp")}</DrawerTitle> <DrawerTitle>{t("wrapup.generateWrapUp")}</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="space-y-3 p-4 pb-8"> <div className="space-y-3 p-4 pb-8">
<PeriodPresets onSelect={(s, e) => { setStartDate(s); setEndDate(e) }} />
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>{t("wrapup.startDate")}</Label> <Label>{t("wrapup.startDate")}</Label>
<Input <Input
@@ -173,3 +175,33 @@ function WrapupPage() {
</div> </div>
) )
} }
function PeriodPresets({ onSelect }: { onSelect: (start: string, end: string) => void }) {
const { t } = useTranslation()
const now = new Date()
const fmt = (d: Date) => format(d, "yyyy-MM-dd")
const currentYear = now.getFullYear()
const presets = [
{ label: String(currentYear), start: fmt(startOfYear(now)), end: fmt(endOfYear(now)) },
{ label: String(currentYear - 1), start: fmt(startOfYear(new Date(currentYear - 1, 0))), end: fmt(endOfYear(new Date(currentYear - 1, 0))) },
{ label: t("wrapup.last12Months", { defaultValue: "Last 12 months" }), start: fmt(subMonths(now, 12)), end: fmt(now) },
{ label: t("wrapup.last6Months", { defaultValue: "Last 6 months" }), start: fmt(subMonths(now, 6)), end: fmt(now) },
]
return (
<div className="flex flex-wrap gap-1.5">
{presets.map((p) => (
<Button
key={p.label}
type="button"
variant="outline"
size="sm"
onClick={() => onSelect(p.start, p.end)}
>
{p.label}
</Button>
))}
</div>
)
}