feat: add SPA, serve at /app/, update Dockerfile and README
- React + TanStack Router + shadcn/ui SPA under spa/ - serve spa/dist at /app/ with index.html fallback for client routing - Dockerfile: node build stage for SPA, copy dist into runtime image - README: document SPA, CORS_ORIGINS env var, architecture entry - vite base set to /app/, manifest.json paths fixed
This commit is contained in:
141
spa/src/routes/_app/diary.tsx
Normal file
141
spa/src/routes/_app/diary.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
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">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold">{t("diary.title")}</h1>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user