feat: calendar diary view on profile (#15)
This commit is contained in:
91
spa/src/components/diary-calendar.tsx
Normal file
91
spa/src/components/diary-calendar.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import { Calendar } from "@/components/ui/calendar"
|
||||||
|
import { posterUrl } from "@/lib/api/client"
|
||||||
|
import type { DiaryEntryDto } from "@/lib/api/common"
|
||||||
|
import type { DayButton } from "react-day-picker"
|
||||||
|
|
||||||
|
type DiaryCalendarProps = {
|
||||||
|
entries: DiaryEntryDto[]
|
||||||
|
onSelectDate?: (entries: DiaryEntryDto[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(dateStr: string): string {
|
||||||
|
return dateStr.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiaryCalendar({ entries, onSelectDate }: DiaryCalendarProps) {
|
||||||
|
const byDate = useMemo(() => {
|
||||||
|
const map = new Map<string, DiaryEntryDto[]>()
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = parseDate(e.review.watched_at)
|
||||||
|
const list = map.get(key)
|
||||||
|
if (list) list.push(e)
|
||||||
|
else map.set(key, [e])
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [entries])
|
||||||
|
|
||||||
|
const watchedDates = useMemo(
|
||||||
|
() => [...byDate.keys()].map((d) => new Date(d + "T00:00:00")),
|
||||||
|
[byDate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
classNames={{
|
||||||
|
month: "flex w-full flex-col gap-2",
|
||||||
|
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({
|
||||||
|
day,
|
||||||
|
byDate,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton> & {
|
||||||
|
byDate: Map<string, DiaryEntryDto[]>
|
||||||
|
}) {
|
||||||
|
const key = day.date.toISOString().slice(0, 10)
|
||||||
|
const dayEntries = byDate.get(key)
|
||||||
|
const poster = dayEntries?.[0]?.movie.poster_path
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="leading-tight">{day.date.getDate()}</span>
|
||||||
|
{poster && (
|
||||||
|
<img
|
||||||
|
src={posterUrl(poster)}
|
||||||
|
alt=""
|
||||||
|
className="mt-0.5 aspect-[2/3] w-full max-w-6 rounded-sm object-cover opacity-80"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{dayEntries && dayEntries.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">
|
||||||
|
{dayEntries.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{dayEntries && dayEntries.length === 1 && !poster && (
|
||||||
|
<span className="mt-1 size-1.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,45 @@ 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 [detailEntries, setDetailEntries] = useState<DiaryEntryDto[] | null>(null)
|
||||||
|
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}
|
||||||
|
onSelectDate={(entries) => {
|
||||||
|
if (entries.length === 1) setDetailEntry(entries[0]!)
|
||||||
|
else setDetailEntries(entries)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{detailEntry && (
|
||||||
|
<ReviewDetailSheet
|
||||||
|
open={!!detailEntry}
|
||||||
|
onOpenChange={(open) => !open && setDetailEntry(null)}
|
||||||
|
movie={detailEntry.movie}
|
||||||
|
review={detailEntry.review}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{detailEntries && (
|
||||||
|
<ReviewDetailSheet
|
||||||
|
open={!!detailEntries}
|
||||||
|
onOpenChange={(open) => !open && setDetailEntries(null)}
|
||||||
|
movie={detailEntries[0]!.movie}
|
||||||
|
review={detailEntries[0]!.review}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const trendChartConfig = {
|
const trendChartConfig = {
|
||||||
count: { label: "Movies", color: "var(--primary)" },
|
count: { label: "Movies", color: "var(--primary)" },
|
||||||
} satisfies ChartConfig
|
} satisfies ChartConfig
|
||||||
|
|||||||
Reference in New Issue
Block a user