Files
movies-diary/spa/src/routes/_app/diary.tsx

140 lines
4.8 KiB
TypeScript

import { createFileRoute } from "@tanstack/react-router"
import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import { BookOpen, ChevronLeft, ChevronRight } from "lucide-react"
import { format, startOfMonth, subMonths } from "date-fns"
import { MovieCard } from "@/components/movie-card"
import { EmptyState } from "@/components/empty-state"
import { SwipeToDelete } from "@/components/swipe-to-delete"
import { VirtualList } from "@/components/virtual-list"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { useInfiniteDiary, useDeleteReview } from "@/hooks/use-diary"
import type { DiaryEntryDto } from "@/lib/api/common"
export const Route = createFileRoute("/_app/diary")({
component: DiaryPage,
})
function groupByDate(items: DiaryEntryDto[]) {
const groups: Record<string, DiaryEntryDto[]> = {}
for (const entry of items) {
const date = entry.review.watched_at.slice(0, 10)
;(groups[date] ??= []).push(entry)
}
return Object.entries(groups).sort(([a], [b]) => b.localeCompare(a))
}
function DiaryPage() {
const { t } = useTranslation()
const [month, setMonth] = useState(() => startOfMonth(new Date()))
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteDiary({ sort_by: "desc" })
const deleteReview = useDeleteReview()
const monthLabel = format(month, "MMMM yyyy")
const monthStr = format(month, "yyyy-MM")
const allItems = data?.pages.flatMap((p) => p.items) ?? []
const filtered = allItems.filter((e) => e.review.watched_at.startsWith(monthStr))
const grouped = groupByDate(filtered)
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
type FlatItem =
| { type: "header"; date: string }
| { type: "entry"; entry: DiaryEntryDto }
const flatItems: FlatItem[] = grouped.flatMap(([date, entries]) => [
{ type: "header" as const, date },
...entries.map((entry) => ({ type: "entry" as const, entry })),
])
const activeMonths = [...new Set(allItems.map((e) => e.review.watched_at.slice(0, 7)))].sort()
const prevMonth = activeMonths.filter((m) => m < monthStr).at(-1)
const nextMonth = activeMonths.filter((m) => m > monthStr).find(() => true)
const canGoBack = hasNextPage || !!prevMonth
const canGoForward = !!nextMonth && startOfMonth(new Date(nextMonth + "-01")) <= startOfMonth(new Date())
function goBack() {
if (prevMonth) {
setMonth(startOfMonth(new Date(prevMonth + "-01")))
} else {
setMonth((m) => subMonths(m, 1))
}
}
function goForward() {
if (nextMonth) {
setMonth(startOfMonth(new Date(nextMonth + "-01")))
}
}
return (
<div className="space-y-4 p-4">
<h1 className="text-lg font-bold">{t("diary.title")}</h1>
<div className="flex items-center justify-between rounded-xl bg-card px-3 py-2">
<Button variant="ghost" size="icon" onClick={goBack} disabled={!canGoBack}>
<ChevronLeft className="size-5" />
</Button>
<span className="text-sm font-medium">{monthLabel}</span>
<Button variant="ghost" size="icon" onClick={goForward} disabled={!canGoForward}>
<ChevronRight className="size-5" />
</Button>
</div>
{isPending && <DiarySkeleton />}
{!isPending && grouped.length === 0 && (
<EmptyState icon={BookOpen} title={t("diary.noEntries")} description={t("diary.nothingLogged")} />
)}
{flatItems.length > 0 && (
<VirtualList
items={flatItems}
estimateSize={80}
hasMore={!!hasNextPage}
isFetching={isFetchingNextPage}
onLoadMore={loadMore}
renderItem={(item) =>
item.type === "header" ? (
<h2 className="pt-2 text-xs font-medium text-muted-foreground">{item.date}</h2>
) : (
<SwipeToDelete
onDelete={() => deleteReview.mutate(item.entry.review.id)}
confirmTitle={t("diary.deleteReview")}
confirmDescription={`${item.entry.movie.title}${item.entry.review.watched_at.slice(0, 10)}`}
>
<MovieCard
movie={item.entry.movie}
rating={item.entry.review.rating}
comment={item.entry.review.comment}
variant="full"
/>
</SwipeToDelete>
)
}
/>
)}
</div>
)
}
function DiarySkeleton() {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3 rounded-xl bg-card p-3">
<Skeleton className="h-[84px] w-14 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))}
</div>
)
}