Compare commits

...

2 Commits

Author SHA1 Message Date
41bed3583d feat: calendar diary view on profile (#15)
Some checks failed
CI / Check / Test (push) Failing after 8m29s
2026-07-10 22:32:47 +02:00
0a8b52514d feat: calendar diary view on profile (#15) 2026-07-10 22:22:13 +02:00
2 changed files with 246 additions and 0 deletions

View File

@@ -0,0 +1,216 @@
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { VisuallyHidden } from "radix-ui"
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
addMonths,
subMonths,
eachDayOfInterval,
isSameMonth,
isToday,
format,
} from "date-fns"
import { Button } from "@/components/ui/button"
import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
import { StarDisplay } from "@/components/star-display"
import { WATCH_MEDIUMS } from "@/lib/watch-mediums"
import { posterUrl } from "@/lib/api/client"
import { shortDate } from "@/lib/date"
import type { DiaryEntryDto } from "@/lib/api/common"
type DiaryCalendarProps = {
entries: DiaryEntryDto[]
onSelectEntry?: (entry: DiaryEntryDto) => void
}
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
export function DiaryCalendar({ entries, onSelectEntry }: DiaryCalendarProps) {
const [month, setMonth] = useState(() => startOfMonth(new Date()))
const [dayEntries, setDayEntries] = useState<DiaryEntryDto[] | null>(null)
const byDate = useMemo(() => {
const map = new Map<string, DiaryEntryDto[]>()
for (const e of entries) {
const key = e.review.watched_at.slice(0, 10)
const list = map.get(key)
if (list) list.push(e)
else map.set(key, [e])
}
return map
}, [entries])
const days = useMemo(() => {
const start = startOfWeek(startOfMonth(month))
const end = endOfWeek(endOfMonth(month))
return eachDayOfInterval({ start, end })
}, [month])
function handleDayClick(clicked: DiaryEntryDto[]) {
if (clicked.length === 1) {
onSelectEntry?.(clicked[0]!)
} else {
setDayEntries(clicked)
}
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="icon"
onClick={() => setMonth((m) => subMonths(m, 1))}
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-sm font-medium">
{format(month, "MMMM yyyy")}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => setMonth((m) => addMonths(m, 1))}
>
<ChevronRight className="size-4" />
</Button>
</div>
<div className="grid grid-cols-7 gap-px">
{WEEKDAYS.map((d) => (
<div
key={d}
className="py-1 text-center text-xs font-medium text-muted-foreground"
>
{d}
</div>
))}
{days.map((day) => {
const key = format(day, "yyyy-MM-dd")
const de = byDate.get(key)
const inMonth = isSameMonth(day, month)
const today = isToday(day)
const poster = de?.[0]?.movie.poster_path
return (
<Button
key={key}
variant="ghost"
disabled={!de?.length}
onClick={() => de?.length && handleDayClick(de)}
className={`relative flex h-auto min-h-12 flex-col items-center gap-0.5 rounded-lg p-1 text-xs ${
!inMonth ? "opacity-30" : ""
} ${today ? "ring-1 ring-primary/50" : ""}`}
>
<span
className={
de?.length
? "font-semibold text-primary"
: "text-muted-foreground"
}
>
{day.getDate()}
</span>
{poster && (
<img
src={posterUrl(poster)}
alt=""
className="aspect-2/3 w-7 rounded-sm object-cover"
/>
)}
{de && de.length > 1 && (
<span className="absolute top-0.5 right-0.5 flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-bold text-primary-foreground">
{de.length}
</span>
)}
{de && de.length === 1 && !poster && (
<span className="size-1.5 rounded-full bg-primary" />
)}
</Button>
)
})}
</div>
<DayDrawer
entries={dayEntries}
onClose={() => setDayEntries(null)}
onSelect={(e) => {
setDayEntries(null)
onSelectEntry?.(e)
}}
/>
</div>
)
}
function DayDrawer({
entries,
onClose,
onSelect,
}: {
entries: DiaryEntryDto[] | null
onClose: () => void
onSelect: (entry: DiaryEntryDto) => void
}) {
const { t } = useTranslation()
if (!entries) return null
const dateLabel = shortDate(entries[0]!.review.watched_at)
return (
<Drawer open onOpenChange={(open) => !open && onClose()}>
<DrawerContent className="mx-auto max-w-lg">
<VisuallyHidden.Root>
<DrawerTitle>{dateLabel}</DrawerTitle>
</VisuallyHidden.Root>
<div className="p-4 pb-8">
<p className="mb-3 text-sm font-semibold">
{dateLabel} &middot; {t("common.films", { count: entries.length })}
</p>
<div className="space-y-2">
{entries.map((e) => (
<Button
key={e.review.id}
variant="ghost"
onClick={() => onSelect(e)}
className="flex h-auto w-full items-center justify-start gap-3 rounded-xl p-2"
>
<div className="h-16 w-11 shrink-0 overflow-hidden rounded-lg bg-muted">
{e.movie.poster_path && (
<img
src={posterUrl(e.movie.poster_path)}
alt=""
className="size-full object-cover"
/>
)}
</div>
<div className="min-w-0 flex-1 text-left">
<p className="truncate text-sm font-semibold">
{e.movie.title}
</p>
<div className="mt-0.5 flex items-center gap-1.5">
<StarDisplay rating={e.review.rating} size="xs" />
{e.review.watch_medium && (() => {
const Icon = WATCH_MEDIUMS.find((d) => d.value === e.review.watch_medium)?.icon
return Icon ? <Icon className="size-3.5 text-muted-foreground" /> : null
})()}
</div>
{e.review.comment && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{e.review.comment}
</p>
)}
</div>
</Button>
))}
</div>
</div>
</DrawerContent>
</Drawer>
)
}

View File

@@ -17,6 +17,7 @@ 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 { ReviewDetailSheet } from "@/components/review-detail-sheet"
import { DiaryCalendar } from "@/components/diary-calendar"
import type { DiaryEntryDto } from "@/lib/api/common" import type { DiaryEntryDto } from "@/lib/api/common"
import type { UserProfileResponse } from "@/features/users" import type { UserProfileResponse } from "@/features/users"
@@ -51,6 +52,7 @@ export function ProfileView({
const profileTabs = [ const profileTabs = [
{ value: "recent", label: t("profile.recent") }, { value: "recent", label: t("profile.recent") },
{ value: "top_rated", label: t("profile.topRated") }, { value: "top_rated", label: t("profile.topRated") },
{ value: "calendar", label: t("profile.calendar", { defaultValue: "Calendar" }) },
{ value: "trends", label: t("profile.trends") }, { value: "trends", label: t("profile.trends") },
] as const ] as const
@@ -128,6 +130,7 @@ export function ProfileView({
search={search} search={search}
/> />
)} )}
{tab === "calendar" && <CalendarTab userId={userId} />}
{tab === "trends" && <TrendsView data={data} />} {tab === "trends" && <TrendsView data={data} />}
</> </>
)} )}
@@ -192,6 +195,33 @@ function DiaryTab({ sortBy, userId, search }: { sortBy: string; userId?: string;
) )
} }
function CalendarTab({ userId }: { userId?: string }) {
const { data, isPending, hasNextPage, fetchNextPage } =
useInfiniteDiary({ sort_by: "date_desc", user_id: userId })
const items = data?.pages.flatMap((p) => p.items) ?? []
const [detailEntry, setDetailEntry] = useState<DiaryEntryDto | null>(null)
if (isPending) return <Skeleton className="h-80 w-full rounded-xl" />
if (hasNextPage) fetchNextPage()
return (
<>
<DiaryCalendar
entries={items}
onSelectEntry={setDetailEntry}
/>
{detailEntry && (
<ReviewDetailSheet
open={!!detailEntry}
onOpenChange={(open) => !open && setDetailEntry(null)}
movie={detailEntry.movie}
review={detailEntry.review}
/>
)}
</>
)
}
const trendChartConfig = { const trendChartConfig = {
count: { label: "Movies", color: "var(--primary)" }, count: { label: "Movies", color: "var(--primary)" },
} satisfies ChartConfig } satisfies ChartConfig