feat: calendar diary view on profile (#15)
Some checks failed
CI / Check / Test (push) Failing after 8m29s
Some checks failed
CI / Check / Test (push) Failing after 8m29s
This commit is contained in:
@@ -1,23 +1,42 @@
|
|||||||
import { useMemo } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Calendar } from "@/components/ui/calendar"
|
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 { posterUrl } from "@/lib/api/client"
|
||||||
|
import { shortDate } from "@/lib/date"
|
||||||
import type { DiaryEntryDto } from "@/lib/api/common"
|
import type { DiaryEntryDto } from "@/lib/api/common"
|
||||||
import type { DayButton } from "react-day-picker"
|
|
||||||
|
|
||||||
type DiaryCalendarProps = {
|
type DiaryCalendarProps = {
|
||||||
entries: DiaryEntryDto[]
|
entries: DiaryEntryDto[]
|
||||||
onSelectDate?: (entries: DiaryEntryDto[]) => void
|
onSelectEntry?: (entry: DiaryEntryDto) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDate(dateStr: string): string {
|
const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
|
||||||
return dateStr.slice(0, 10)
|
|
||||||
}
|
export function DiaryCalendar({ entries, onSelectEntry }: DiaryCalendarProps) {
|
||||||
|
const [month, setMonth] = useState(() => startOfMonth(new Date()))
|
||||||
|
const [dayEntries, setDayEntries] = useState<DiaryEntryDto[] | null>(null)
|
||||||
|
|
||||||
export function DiaryCalendar({ entries, onSelectDate }: DiaryCalendarProps) {
|
|
||||||
const byDate = useMemo(() => {
|
const byDate = useMemo(() => {
|
||||||
const map = new Map<string, DiaryEntryDto[]>()
|
const map = new Map<string, DiaryEntryDto[]>()
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
const key = parseDate(e.review.watched_at)
|
const key = e.review.watched_at.slice(0, 10)
|
||||||
const list = map.get(key)
|
const list = map.get(key)
|
||||||
if (list) list.push(e)
|
if (list) list.push(e)
|
||||||
else map.set(key, [e])
|
else map.set(key, [e])
|
||||||
@@ -25,67 +44,173 @@ export function DiaryCalendar({ entries, onSelectDate }: DiaryCalendarProps) {
|
|||||||
return map
|
return map
|
||||||
}, [entries])
|
}, [entries])
|
||||||
|
|
||||||
const watchedDates = useMemo(
|
const days = useMemo(() => {
|
||||||
() => [...byDate.keys()].map((d) => new Date(d + "T00:00:00")),
|
const start = startOfWeek(startOfMonth(month))
|
||||||
[byDate],
|
const end = endOfWeek(endOfMonth(month))
|
||||||
)
|
return eachDayOfInterval({ start, end })
|
||||||
|
}, [month])
|
||||||
|
|
||||||
return (
|
function handleDayClick(clicked: DiaryEntryDto[]) {
|
||||||
<Calendar
|
if (clicked.length === 1) {
|
||||||
mode="single"
|
onSelectEntry?.(clicked[0]!)
|
||||||
classNames={{
|
} else {
|
||||||
month: "flex w-full flex-col gap-2",
|
setDayEntries(clicked)
|
||||||
day: "group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none",
|
}
|
||||||
}}
|
|
||||||
modifiers={{ watched: watchedDates }}
|
|
||||||
modifiersClassNames={{ watched: "bg-primary/20 rounded-(--cell-radius)" }}
|
|
||||||
onDayClick={(day) => {
|
|
||||||
const key = day.toISOString().slice(0, 10)
|
|
||||||
const dayEntries = byDate.get(key)
|
|
||||||
if (dayEntries?.length && onSelectDate) onSelectDate(dayEntries)
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
DayButton: (props) => (
|
|
||||||
<CalendarDay {...props} byDate={byDate} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalendarDay({
|
return (
|
||||||
day,
|
<div className="space-y-2">
|
||||||
byDate,
|
<div className="flex items-center justify-between">
|
||||||
...props
|
<Button
|
||||||
}: React.ComponentProps<typeof DayButton> & {
|
variant="ghost"
|
||||||
byDate: Map<string, DiaryEntryDto[]>
|
size="icon"
|
||||||
}) {
|
onClick={() => setMonth((m) => subMonths(m, 1))}
|
||||||
const key = day.date.toISOString().slice(0, 10)
|
>
|
||||||
const dayEntries = byDate.get(key)
|
<ChevronLeft className="size-4" />
|
||||||
const poster = dayEntries?.[0]?.movie.poster_path
|
</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 (
|
return (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
key={key}
|
||||||
className="relative flex size-full min-h-10 flex-col items-center justify-start rounded-(--cell-radius) p-0.5 text-xs transition-colors hover:bg-muted/50"
|
variant="ghost"
|
||||||
{...props}
|
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="leading-tight">{day.date.getDate()}</span>
|
<span
|
||||||
|
className={
|
||||||
|
de?.length
|
||||||
|
? "font-semibold text-primary"
|
||||||
|
: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{day.getDate()}
|
||||||
|
</span>
|
||||||
{poster && (
|
{poster && (
|
||||||
<img
|
<img
|
||||||
src={posterUrl(poster)}
|
src={posterUrl(poster)}
|
||||||
alt=""
|
alt=""
|
||||||
className="mt-0.5 aspect-[2/3] w-full max-w-6 rounded-sm object-cover opacity-80"
|
className="aspect-2/3 w-7 rounded-sm object-cover"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{dayEntries && dayEntries.length > 1 && (
|
{de && de.length > 1 && (
|
||||||
<span className="absolute bottom-0.5 right-0.5 flex size-3.5 items-center justify-center rounded-full bg-primary text-[8px] font-bold text-primary-foreground">
|
<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">
|
||||||
{dayEntries.length}
|
{de.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{dayEntries && dayEntries.length === 1 && !poster && (
|
{de && de.length === 1 && !poster && (
|
||||||
<span className="mt-1 size-1.5 rounded-full bg-primary" />
|
<span className="size-1.5 rounded-full bg-primary" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</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} · {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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,7 +199,6 @@ function CalendarTab({ userId }: { userId?: string }) {
|
|||||||
const { data, isPending, hasNextPage, fetchNextPage } =
|
const { data, isPending, hasNextPage, fetchNextPage } =
|
||||||
useInfiniteDiary({ sort_by: "date_desc", user_id: userId })
|
useInfiniteDiary({ sort_by: "date_desc", user_id: userId })
|
||||||
const items = data?.pages.flatMap((p) => p.items) ?? []
|
const items = data?.pages.flatMap((p) => p.items) ?? []
|
||||||
const [detailEntries, setDetailEntries] = useState<DiaryEntryDto[] | null>(null)
|
|
||||||
const [detailEntry, setDetailEntry] = useState<DiaryEntryDto | null>(null)
|
const [detailEntry, setDetailEntry] = useState<DiaryEntryDto | null>(null)
|
||||||
|
|
||||||
if (isPending) return <Skeleton className="h-80 w-full rounded-xl" />
|
if (isPending) return <Skeleton className="h-80 w-full rounded-xl" />
|
||||||
@@ -209,10 +208,7 @@ function CalendarTab({ userId }: { userId?: string }) {
|
|||||||
<>
|
<>
|
||||||
<DiaryCalendar
|
<DiaryCalendar
|
||||||
entries={items}
|
entries={items}
|
||||||
onSelectDate={(entries) => {
|
onSelectEntry={setDetailEntry}
|
||||||
if (entries.length === 1) setDetailEntry(entries[0]!)
|
|
||||||
else setDetailEntries(entries)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{detailEntry && (
|
{detailEntry && (
|
||||||
<ReviewDetailSheet
|
<ReviewDetailSheet
|
||||||
@@ -222,14 +218,6 @@ function CalendarTab({ userId }: { userId?: string }) {
|
|||||||
review={detailEntry.review}
|
review={detailEntry.review}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{detailEntries && (
|
|
||||||
<ReviewDetailSheet
|
|
||||||
open={!!detailEntries}
|
|
||||||
onOpenChange={(open) => !open && setDetailEntries(null)}
|
|
||||||
movie={detailEntries[0]!.movie}
|
|
||||||
review={detailEntries[0]!.review}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user