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:
2026-06-04 04:20:15 +02:00
parent 15dc0e526b
commit b9c0b10740
153 changed files with 24329 additions and 1 deletions

14
spa/src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,14 @@
import { createRootRoute, Outlet } from "@tanstack/react-router"
import { ThemeProvider } from "@/components/theme-provider"
export const Route = createRootRoute({
component: RootLayout,
})
function RootLayout() {
return (
<ThemeProvider>
<Outlet />
</ThemeProvider>
)
}

52
spa/src/routes/_app.tsx Normal file
View File

@@ -0,0 +1,52 @@
import {
createFileRoute,
Outlet,
redirect,
} from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { Toaster } from "@/components/ui/sonner"
import { BottomTabBar } from "@/components/bottom-tab-bar"
import { LogSheet } from "@/components/log-sheet"
import { getAuth } from "@/lib/auth"
export const Route = createFileRoute("/_app")({
beforeLoad: () => {
if (!getAuth()) throw redirect({ to: "/login" })
},
component: AppLayout,
errorComponent: ErrorFallback,
})
function ErrorFallback({ error, reset }: { error: unknown; reset: () => void }) {
const { t } = useTranslation()
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
<p className="text-lg font-semibold">{t("errors.somethingWrong")}</p>
<p className="text-sm text-muted-foreground">
{error instanceof Error ? error.message : t("errors.unknownError")}
</p>
<button
onClick={reset}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
>
{t("common.tryAgain")}
</button>
</div>
)
}
function AppLayout() {
const [logOpen, setLogOpen] = useState(false)
return (
<div className="mx-auto min-h-svh max-w-lg">
<main className="pb-20">
<Outlet />
</main>
<BottomTabBar onLogTap={() => setLogOpen(true)} />
<LogSheet open={logOpen} onOpenChange={setLogOpen} />
<Toaster position="top-center" />
</div>
)
}

View 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>
)
}

View File

@@ -0,0 +1,252 @@
import { createFileRoute } from "@tanstack/react-router"
import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import { Clapperboard, Film, Inbox, Plus } from "lucide-react"
import { ReviewCard } from "@/components/review-card"
import { MovieCard } from "@/components/movie-card"
import { EmptyState } from "@/components/empty-state"
import { SwipeTabs } from "@/components/swipe-tabs"
import { SwipeToDelete } from "@/components/swipe-to-delete"
import { VirtualList } from "@/components/virtual-list"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { StarRating } from "@/components/star-rating"
import { useAuth } from "@/components/auth-provider"
import { useInfiniteActivityFeed, useDeleteReview } from "@/hooks/use-diary"
import { SearchOverlay } from "@/components/search-overlay"
import type { MovieSelection } from "@/components/search-overlay"
import { useInfiniteWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from "@/hooks/use-watchlist"
import { useWatchQueue, useConfirmWatch, useDismissWatch } from "@/hooks/use-webhooks"
export const Route = createFileRoute("/_app/")({
component: HomePage,
})
function HomePage() {
const { t } = useTranslation()
const homeTabs = [
{ value: "feed", label: t("feed.tab") },
{ value: "watchlist", label: t("feed.watchlist") },
{ value: "queue", label: t("feed.queue") },
] as const
return (
<div className="p-4">
<div className="mb-3 flex items-center justify-between">
<h1 className="text-lg font-bold">{t("feed.title")}</h1>
</div>
<SwipeTabs tabs={homeTabs} defaultValue="feed" tabsListClassName="w-full">
{(tab) => (
<>
{tab === "feed" && <FeedTab />}
{tab === "watchlist" && <WatchlistTab />}
{tab === "queue" && <QueueTab />}
</>
)}
</SwipeTabs>
</div>
)
}
function FeedTab() {
const { t } = useTranslation()
const { auth } = useAuth()
const [sortBy, setSortBy] = useState("date")
const feedSortOptions = [
{ value: "date", label: t("feed.sortLatest") },
{ value: "date_asc", label: t("feed.sortOldest") },
{ value: "rating", label: t("feed.sortTopRated") },
{ value: "rating_asc", label: t("feed.sortLowestRated") },
] as const
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteActivityFeed({ sort_by: sortBy })
const deleteReview = useDeleteReview()
const items = data?.pages.flatMap((p) => p.items) ?? []
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
return (
<div className="space-y-2">
<div className="flex justify-end">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{feedSortOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isPending && <FeedSkeleton />}
{!isPending && !items.length && (
<EmptyState icon={Film} title={t("feed.noActivity")} description={t("feed.noActivityDesc")} />
)}
{items.length > 0 && (
<VirtualList
items={items}
estimateSize={120}
hasMore={!!hasNextPage}
isFetching={isFetchingNextPage}
onLoadMore={loadMore}
renderItem={(entry) => {
const card = (
<ReviewCard
movie={entry.movie}
review={entry.review}
userName={entry.user_display_name}
userId={entry.user_id}
/>
)
return entry.user_id === auth?.user_id ? (
<SwipeToDelete
onDelete={() => deleteReview.mutate(entry.review.id)}
confirmTitle={t("feed.deleteReview")}
confirmDescription={entry.movie.title}
>
{card}
</SwipeToDelete>
) : (
card
)
}}
/>
)}
</div>
)
}
function WatchlistTab() {
const { t } = useTranslation()
const { data, isPending, hasNextPage, isFetchingNextPage, fetchNextPage } =
useInfiniteWatchlist()
const items = data?.pages.flatMap((p) => p.items) ?? []
const addMutation = useAddToWatchlist()
const removeMutation = useRemoveFromWatchlist()
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
const [searchOpen, setSearchOpen] = useState(false)
function handleAdd(movie: MovieSelection) {
setSearchOpen(false)
addMutation.mutate(
movie.id
? { movie_id: movie.id }
: {
external_metadata_id: movie.external_metadata_id,
manual_title: movie.title,
manual_release_year: movie.release_year,
},
)
}
return (
<div className="space-y-2">
<Button variant="outline" size="sm" className="w-full" onClick={() => setSearchOpen(true)}>
<Plus className="mr-1 size-4" />
{t("feed.addToWatchlist")}
</Button>
{searchOpen && (
<SearchOverlay open onClose={() => setSearchOpen(false)} onSelect={handleAdd} />
)}
{isPending && <FeedSkeleton />}
{!isPending && !items.length && (
<EmptyState icon={Clapperboard} title={t("feed.watchlistEmpty")} description={t("feed.watchlistEmptyDesc")} />
)}
{items.length > 0 && (
<VirtualList
items={items}
estimateSize={110}
hasMore={!!hasNextPage}
isFetching={isFetchingNextPage}
onLoadMore={loadMore}
renderItem={(entry) => (
<SwipeToDelete
onDelete={() => removeMutation.mutate(entry.movie.id)}
confirmTitle={t("feed.removeFromWatchlist")}
confirmDescription={entry.movie.title}
>
<MovieCard movie={entry.movie} variant="full" />
</SwipeToDelete>
)}
/>
)}
</div>
)
}
function QueueTab() {
const { t } = useTranslation()
const { data, isPending } = useWatchQueue()
const confirmMutation = useConfirmWatch()
const dismissMutation = useDismissWatch()
const [ratings, setRatings] = useState<Record<string, number>>({})
if (isPending) return <FeedSkeleton />
if (!data?.length)
return <EmptyState icon={Inbox} title={t("feed.queueEmpty")} description={t("feed.queueEmptyDesc")} />
return (
<div className="space-y-3">
{data.map((entry) => (
<div key={entry.id} className="rounded-xl bg-card p-3">
<p className="font-semibold">{entry.title}</p>
<p className="text-xs text-muted-foreground">
{entry.year && `${entry.year} · `}{entry.source} · {entry.watched_at}
</p>
<div className="mt-2">
<StarRating
value={ratings[entry.id] ?? 0}
onChange={(v) => setRatings((p) => ({ ...p, [entry.id]: v }))}
size="sm"
/>
</div>
<div className="mt-2 flex gap-2">
<Button
size="sm"
disabled={!ratings[entry.id]}
onClick={() =>
confirmMutation.mutate({
confirmations: [{ watch_event_id: entry.id, rating: ratings[entry.id]! }],
})
}
>
{t("common.confirm")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => dismissMutation.mutate({ event_ids: [entry.id] })}
>
{t("common.dismiss")}
</Button>
</div>
</div>
))}
</div>
)
}
function FeedSkeleton() {
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-3 w-20" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,276 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { ArrowLeft, Bookmark, BookmarkCheck, Star, TrendingUp, User, Users } from "lucide-react"
import { StarDisplay } from "@/components/star-display"
import { RatingHistogram } from "@/components/rating-histogram"
import { EmptyState } from "@/components/empty-state"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
import { useMovie, useMovieHistory, useMovieProfile } from "@/hooks/use-movies"
import {
useWatchlistStatus,
useAddToWatchlist,
useRemoveFromWatchlist,
} from "@/hooks/use-watchlist"
import type { CastMemberDto, CrewMemberDto } from "@/lib/api/movies"
export const Route = createFileRoute("/_app/movies/$id")({
component: MovieDetailPage,
})
function MovieDetailPage() {
const { t } = useTranslation()
const { id } = Route.useParams()
const { data, isPending } = useMovie(id)
const { data: profile } = useMovieProfile(id)
const { data: history } = useMovieHistory(id)
if (isPending) return <DetailSkeleton />
if (!data) return null
const { movie, stats, reviews } = data
const hasStats = profile && (profile.budget_usd != null || profile.revenue_usd != null || profile.vote_average != null)
return (
<div className="space-y-5 p-4">
<Link to="/" className="inline-flex items-center gap-1 text-sm text-muted-foreground">
<ArrowLeft className="size-4" /> {t("common.back")}
</Link>
<HeroSection movie={movie} stats={stats} movieId={id} tagline={profile?.tagline} />
{(profile?.overview ?? movie.overview) && (
<p className="text-sm leading-relaxed text-muted-foreground">{profile?.overview ?? movie.overview}</p>
)}
{hasStats && (
<div className="flex gap-2">
{profile.budget_usd != null && (
<div className="flex-1 rounded-xl bg-card p-2.5 text-center">
<p className="text-sm font-semibold">${(profile.budget_usd / 1e6).toFixed(0)}M</p>
<p className="text-[10px] text-muted-foreground">{t("movie.budget")}</p>
</div>
)}
{profile.revenue_usd != null && (
<div className="flex-1 rounded-xl bg-card p-2.5 text-center">
<p className="text-sm font-semibold">${(profile.revenue_usd / 1e6).toFixed(0)}M</p>
<p className="text-[10px] text-muted-foreground">{t("movie.revenue")}</p>
</div>
)}
{profile.vote_average != null && (
<div className="flex-1 rounded-xl bg-card p-2.5 text-center">
<p className="text-sm font-semibold">{profile.vote_average.toFixed(1)}</p>
<p className="text-[10px] text-muted-foreground">{t("movie.tmdb")}</p>
</div>
)}
</div>
)}
{stats.rating_histogram.length > 0 && (
<div className="rounded-xl bg-card p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">{t("movie.ratingDistribution")}</p>
<RatingHistogram histogram={stats.rating_histogram} />
</div>
)}
{profile && profile.cast.length > 0 && (
<section className="overflow-hidden">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("movie.cast")}</h3>
<PersonStrip items={profile.cast} type="cast" />
</section>
)}
{profile && profile.crew.length > 0 && (
<section className="overflow-hidden">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("movie.crew")}</h3>
<PersonStrip items={profile.crew} type="crew" />
</section>
)}
{profile && profile.keywords.length > 0 && (
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("movie.keywords")}</h3>
<div className="flex flex-wrap gap-1.5">
{profile.keywords.map((k) => (
<Badge key={k.tmdb_id} variant="outline">{k.name}</Badge>
))}
</div>
</section>
)}
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("movie.community")}</h3>
{!reviews.items.length ? (
<EmptyState icon={Users} title={t("movie.noReviews")} description={t("movie.beFirst")} />
) : (
<div className="space-y-2">
{reviews.items.map((r, i) => (
<Card key={i} size="sm">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-sm">{r.user_display}</CardTitle>
<CardDescription className="text-[10px]">{r.watched_at.slice(0, 10)}</CardDescription>
</div>
<StarDisplay rating={r.rating} size="xs" />
</div>
</CardHeader>
{r.comment && (
<CardContent>
<p className="text-xs text-muted-foreground">{r.comment}</p>
</CardContent>
)}
</Card>
))}
</div>
)}
</section>
{history && history.viewings.length > 0 && (
<section>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("movie.yourHistory")}</h3>
<div className="space-y-2">
{history.trend && (
<div className="flex items-center gap-2 rounded-xl bg-card p-3 text-xs text-muted-foreground">
<TrendingUp className="size-3.5" />
{t("movie.trend", { trend: history.trend })}
</div>
)}
{history.viewings.map((v) => (
<div key={v.id} className="flex items-center justify-between rounded-xl bg-card p-3">
<div>
<p className="text-sm font-medium">{v.watched_at}</p>
{v.comment && (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1">{v.comment}</p>
)}
</div>
<StarDisplay rating={v.rating} size="xs" />
</div>
))}
</div>
</section>
)}
</div>
)
}
function HeroSection({
movie,
stats,
movieId,
tagline,
}: {
movie: { title: string; release_year: number; director?: string; poster_path?: string; genres: string[]; runtime_minutes?: number }
stats: { total_count: number; avg_rating?: number; federated_count: number }
movieId: string
tagline?: string
}) {
const { t } = useTranslation()
const { data: watchlistData } = useWatchlistStatus(movieId)
const addWatchlist = useAddToWatchlist()
const removeWatchlist = useRemoveFromWatchlist()
const onWatchlist = watchlistData?.on_watchlist ?? false
return (
<div className="flex gap-4">
<div className="h-[150px] w-[100px] flex-shrink-0 overflow-hidden rounded-xl bg-muted">
{movie.poster_path && (
<img src={posterUrl(movie.poster_path)} alt="" className="size-full object-cover" />
)}
</div>
<div className="min-w-0 flex-1 space-y-2">
<h1 className="text-xl font-bold leading-tight">{movie.title}</h1>
<p className="text-sm text-muted-foreground">
{movie.release_year}
{movie.director && ` · ${movie.director}`}
{movie.runtime_minutes && ` · ${movie.runtime_minutes}m`}
</p>
{tagline && <p className="text-xs italic text-muted-foreground">{tagline}</p>}
{movie.genres.length > 0 && (
<div className="flex flex-wrap gap-1">
{movie.genres.map((g) => (
<Badge key={g} variant="secondary" className="text-[10px]">{g}</Badge>
))}
</div>
)}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{stats.avg_rating != null && (
<span className="flex items-center gap-1">
<Star className="size-3 fill-amber-500 text-amber-500" />
{stats.avg_rating.toFixed(1)}
</span>
)}
<span>{t("common.reviews", { count: stats.total_count })}</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant={onWatchlist ? "secondary" : "outline"}
onClick={() =>
onWatchlist
? removeWatchlist.mutate(movieId)
: addWatchlist.mutate({ movie_id: movieId })
}
>
{onWatchlist ? <BookmarkCheck className="mr-1 size-3.5" /> : <Bookmark className="mr-1 size-3.5" />}
{onWatchlist ? t("movie.saved") : t("movie.watchlist")}
</Button>
</div>
</div>
</div>
)
}
function PersonStrip({ items, type }: { items: (CastMemberDto | CrewMemberDto)[]; type: "cast" | "crew" }) {
return (
<div className="-mx-4 flex gap-2.5 overflow-x-auto overscroll-x-contain px-4 pb-2" style={{ scrollbarWidth: "thin", scrollbarColor: "rgba(255,255,255,0.15) transparent" }}>
{items.map((person, i) => {
const subtitle = type === "cast"
? (person as CastMemberDto).character
: (person as CrewMemberDto).job
return (
<div key={`${person.tmdb_person_id}-${i}`} className="w-[72px] flex-shrink-0">
<div className="aspect-[2/3] overflow-hidden rounded-lg bg-muted">
{person.profile_path ? (
<img src={tmdbProfileUrl(person.profile_path)} alt="" className="size-full object-cover" loading="lazy" />
) : (
<div className="flex size-full items-center justify-center">
<User className="size-5 text-muted-foreground/40" />
</div>
)}
</div>
<p className="mt-1 truncate text-[11px] font-semibold leading-tight">{person.name}</p>
<p className="truncate text-[10px] italic text-muted-foreground">{subtitle}</p>
</div>
)
})}
</div>
)
}
function DetailSkeleton() {
return (
<div className="space-y-4 p-4">
<Skeleton className="h-5 w-16" />
<div className="flex gap-4">
<Skeleton className="h-[150px] w-[100px] rounded-xl" />
<div className="flex-1 space-y-2">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-20" />
</div>
</div>
<Skeleton className="h-12 w-full rounded-xl" />
<Skeleton className="h-24 w-full rounded-xl" />
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 rounded-xl" />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { ArrowLeft, Film, User } from "lucide-react"
import { MovieCard } from "@/components/movie-card"
import { EmptyState } from "@/components/empty-state"
import { Skeleton } from "@/components/ui/skeleton"
import { tmdbProfileUrl } from "@/lib/api/client"
import { usePersonCredits } from "@/hooks/use-search"
export const Route = createFileRoute("/_app/people/$id")({
component: PersonDetailPage,
})
function PersonDetailPage() {
const { t } = useTranslation()
const { id } = Route.useParams()
const { data, isPending } = usePersonCredits(id)
if (isPending) return <PersonSkeleton />
if (!data) return null
const { person, cast, crew } = data
return (
<div className="space-y-4 p-4">
<Link to="/" className="inline-flex items-center gap-1 text-sm text-muted-foreground">
<ArrowLeft className="size-4" /> {t("common.back")}
</Link>
<div className="flex items-center gap-4">
<div className="size-16 flex-shrink-0 overflow-hidden rounded-full bg-muted">
{person.profile_path ? (
<img src={tmdbProfileUrl(person.profile_path)} alt="" className="size-full object-cover" />
) : (
<div className="flex size-full items-center justify-center">
<User className="size-6 text-muted-foreground/40" />
</div>
)}
</div>
<div>
<h1 className="text-xl font-bold">{person.name}</h1>
{person.known_for_department && (
<p className="text-sm text-muted-foreground">{person.known_for_department}</p>
)}
</div>
</div>
{cast.length > 0 && (
<section>
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("movie.castCredits", { count: cast.length })}
</h2>
<div className="space-y-1">
{cast.map((c) => (
<MovieCard
key={`${c.movie_id}-${c.character}`}
movie={{
id: c.movie_id,
title: c.title,
release_year: c.release_year ?? 0,
poster_path: c.poster_path,
genres: [],
}}
subtitle={c.character}
variant="compact"
/>
))}
</div>
</section>
)}
{crew.length > 0 && (
<section>
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("movie.crewCredits", { count: crew.length })}
</h2>
<div className="space-y-1">
{crew.map((c) => (
<MovieCard
key={`${c.movie_id}-${c.job}`}
movie={{
id: c.movie_id,
title: c.title,
release_year: c.release_year ?? 0,
poster_path: c.poster_path,
genres: [],
}}
subtitle={`${c.job} (${c.department})`}
variant="compact"
/>
))}
</div>
</section>
)}
{cast.length === 0 && crew.length === 0 && (
<EmptyState icon={Film} title={t("movie.noCredits")} />
)}
</div>
)
}
function PersonSkeleton() {
return (
<div className="space-y-4 p-4">
<Skeleton className="h-5 w-16" />
<div className="flex items-center gap-4">
<Skeleton className="size-16 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-20" />
</div>
</div>
<div className="space-y-2">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-10 rounded-lg" />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { ChevronRight, Settings, Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
import { useAuth } from "@/components/auth-provider"
import { useWrapUps } from "@/hooks/use-wrapup"
import { useUserProfile } from "@/hooks/use-users"
export const Route = createFileRoute("/_app/profile")({
component: ProfilePage,
})
function ProfilePage() {
const { t } = useTranslation()
const { auth } = useAuth()
const { data, isPending } = useUserProfile(auth?.user_id ?? "", {
view: "trends",
})
if (!auth) return null
if (isPending) return <ProfileSkeleton />
if (!data) return null
return (
<div className="p-4">
<div className="mb-4 flex items-center justify-between">
<h1 className="text-lg font-bold">{t("profile.title")}</h1>
<Link to="/settings" className="text-muted-foreground">
<Settings className="size-5" />
</Link>
</div>
<ProfileView
data={data}
actions={
<>
<Link to="/social" className="block">
<Button variant="outline" size="sm" className="w-full justify-between">
<span>{t("profile.followingFollowers")}</span>
<ChevronRight className="size-4 text-muted-foreground" />
</Button>
</Link>
<WrapUpLink />
</>
}
/>
</div>
)
}
function WrapUpLink() {
const { t } = useTranslation()
const { data } = useWrapUps()
const latest = data?.items?.find((w) => w.status === "completed")
if (!latest) return null
return (
<Link to="/wrapup/$id" params={{ id: latest.id }}>
<Button variant="outline" className="w-full justify-between">
<span className="flex items-center gap-2">
<Sparkles className="size-4" />
{t("profile.yearInReview")}
</span>
<ChevronRight className="size-4 text-muted-foreground" />
</Button>
</Link>
)
}

View File

@@ -0,0 +1,124 @@
import { createFileRoute } from "@tanstack/react-router"
import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import { Search as SearchIcon, Film, Users } from "lucide-react"
import { Input } from "@/components/ui/input"
import { MovieCard } from "@/components/movie-card"
import { PersonRow } from "@/components/person-row"
import { EmptyState } from "@/components/empty-state"
import { InfiniteScroll } from "@/components/infinite-scroll"
import { Skeleton } from "@/components/ui/skeleton"
import { useInfiniteSearch } from "@/hooks/use-search"
import { useDebounce } from "@/hooks/use-debounce"
export const Route = createFileRoute("/_app/search")({
component: SearchPage,
})
function SearchPage() {
const { t } = useTranslation()
const [query, setQuery] = useState("")
const debouncedQuery = useDebounce(query, 300)
const {
data,
isPending,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useInfiniteSearch({ q: debouncedQuery || undefined })
const hasQuery = debouncedQuery.length > 0
const movies = data?.pages.flatMap((p) => p.movies.items) ?? []
const people = data?.pages[0]?.people.items ?? []
const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
return (
<div className="space-y-4 p-4">
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("search.placeholder")}
className="pl-9"
autoFocus
/>
</div>
{!hasQuery && <EmptyState icon={SearchIcon} title={t("search.searchPrompt")} />}
{hasQuery && isPending && <SearchSkeleton />}
{hasQuery && data && (
<div className="space-y-6">
{people.length > 0 && (
<section>
<h2 className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Users className="size-3.5" /> {t("search.people")}
</h2>
<div className="space-y-1">
{people.map((person) => (
<PersonRow
key={person.person_id}
id={person.person_id}
name={person.name}
subtitle={person.known_for_department}
imagePath={person.profile_path}
/>
))}
</div>
</section>
)}
{movies.length > 0 && (
<section>
<h2 className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<Film className="size-3.5" /> {t("search.movies")}
</h2>
<div className="space-y-2">
{movies.map((hit) => (
<MovieCard
key={hit.movie_id}
movie={{
id: hit.movie_id,
title: hit.title,
release_year: hit.release_year ?? 0,
director: hit.director,
poster_path: hit.poster_path,
genres: hit.genres,
}}
variant="full"
/>
))}
</div>
<InfiniteScroll
hasMore={!!hasNextPage}
isFetching={isFetchingNextPage}
onLoadMore={loadMore}
/>
</section>
)}
{movies.length === 0 && people.length === 0 && (
<EmptyState icon={SearchIcon} title={t("search.noResults")} description={t("search.noResultsDesc")} />
)}
</div>
)}
</div>
)
}
function SearchSkeleton() {
return (
<div className="space-y-2">
{[1, 2, 3, 4].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>
)
}

View File

@@ -0,0 +1,162 @@
import { useState } from "react"
import { createFileRoute, Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { ArrowLeft, ShieldBan } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { EmptyState } from "@/components/empty-state"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { Skeleton } from "@/components/ui/skeleton"
import { useIsAdmin } from "@/components/auth-provider"
import {
useBlockedActors,
useUnblockActor,
useBlockedDomains,
useAddBlockedDomain,
useRemoveBlockedDomain,
} from "@/hooks/use-social"
export const Route = createFileRoute("/_app/settings/blocked")({
component: BlockedPage,
})
function BlockedPage() {
const { t } = useTranslation()
const isAdmin = useIsAdmin()
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-3">
<Link to="/settings" className="text-muted-foreground">
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-lg font-bold">{t("blocked.title")}</h1>
</div>
{isAdmin ? (
<Tabs defaultValue="users">
<TabsList className="w-full">
<TabsTrigger value="users">{t("blocked.users")}</TabsTrigger>
<TabsTrigger value="domains">{t("blocked.domains")}</TabsTrigger>
</TabsList>
<TabsContent value="users"><UsersTab /></TabsContent>
<TabsContent value="domains"><DomainsTab /></TabsContent>
</Tabs>
) : (
<UsersTab />
)}
</div>
)
}
function UsersTab() {
const { t } = useTranslation()
const { data: actors, isPending } = useBlockedActors()
const unblock = useUnblockActor()
if (isPending) {
return (
<div className="space-y-2">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-12 rounded-xl" />
))}
</div>
)
}
if (!actors?.length) {
return <EmptyState icon={ShieldBan} title={t("blocked.noBlockedUsers")} />
}
return (
<div className="space-y-2">
{actors.map((a) => (
<div
key={a.url}
className="flex items-center justify-between rounded-xl bg-card p-3"
>
<div>
<p className="text-sm font-medium">
{a.display_name || a.handle}
</p>
{a.display_name && (
<p className="text-xs text-muted-foreground">{a.handle}</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => unblock.mutate({ actor_url: a.url })}
>
{t("common.unblock")}
</Button>
</div>
))}
</div>
)
}
function DomainsTab() {
const { t } = useTranslation()
const { data: domains, isPending } = useBlockedDomains()
const addDomain = useAddBlockedDomain()
const removeDomain = useRemoveBlockedDomain()
const [newDomain, setNewDomain] = useState("")
const handleAdd = () => {
if (!newDomain.trim()) return
addDomain.mutate(
{ domain: newDomain.trim() },
{ onSuccess: () => setNewDomain("") },
)
}
if (isPending) {
return (
<div className="space-y-2">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-12 rounded-xl" />
))}
</div>
)
}
return (
<div className="space-y-3">
<div className="flex gap-2">
<Input
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
placeholder={t("blocked.domainPlaceholder")}
className="flex-1"
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<Button onClick={handleAdd} disabled={addDomain.isPending} size="sm">
{t("common.block")}
</Button>
</div>
{!domains?.length ? (
<EmptyState icon={ShieldBan} title={t("blocked.noBlockedDomains")} />
) : (
<div className="space-y-2">
{domains.map((d) => (
<div
key={d.domain}
className="flex items-center justify-between rounded-xl bg-card p-3"
>
<p className="text-sm font-medium">{d.domain}</p>
<Button
variant="outline"
size="sm"
onClick={() => removeDomain.mutate(d.domain)}
>
{t("common.remove")}
</Button>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,246 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { ArrowLeft, Camera, Plus, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Textarea } from "@/components/ui/textarea"
import { Skeleton } from "@/components/ui/skeleton"
import { posterUrl } from "@/lib/api/client"
import { useProfile, useUpdateProfile, useUpdateProfileFields } from "@/hooks/use-users"
export const Route = createFileRoute("/_app/settings/edit-profile")({
component: EditProfilePage,
})
function EditProfilePage() {
const { t } = useTranslation()
const { data, isPending } = useProfile()
const update = useUpdateProfile()
const updateFields = useUpdateProfileFields()
const navigate = useNavigate()
const [displayName, setDisplayName] = useState("")
const [bio, setBio] = useState("")
const [alsoKnownAs, setAlsoKnownAs] = useState("")
const [fields, setFields] = useState<{ name: string; value: string }[]>([])
const [avatarFile, setAvatarFile] = useState<File | null>(null)
const [bannerFile, setBannerFile] = useState<File | null>(null)
const [avatarPreview, setAvatarPreview] = useState<string | null>(null)
const [bannerPreview, setBannerPreview] = useState<string | null>(null)
const avatarInputRef = useRef<HTMLInputElement>(null)
const bannerInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (data) {
setDisplayName(data.display_name ?? "")
setBio(data.bio ?? "")
setAlsoKnownAs(data.also_known_as ?? "")
setFields(data.fields?.length ? data.fields.map((f) => ({ ...f })) : [])
}
}, [data])
function handleFileSelect(
e: React.ChangeEvent<HTMLInputElement>,
setFile: (f: File | null) => void,
setPreview: (url: string | null) => void,
) {
const file = e.target.files?.[0]
if (!file) return
setFile(file)
setPreview(URL.createObjectURL(file))
}
function updateField(index: number, key: "name" | "value", val: string) {
setFields((prev) => prev.map((f, i) => (i === index ? { ...f, [key]: val } : f)))
}
function removeField(index: number) {
setFields((prev) => prev.filter((_, i) => i !== index))
}
function addField() {
setFields((prev) => [...prev, { name: "", value: "" }])
}
async function handleSave() {
await update.mutateAsync({
display_name: displayName,
bio,
also_known_as: alsoKnownAs,
avatar: avatarFile ?? undefined,
banner: bannerFile ?? undefined,
})
const validFields = fields.filter((f) => f.name.trim() && f.value.trim())
if (validFields.length > 0 || (data?.fields?.length ?? 0) > 0) {
await updateFields.mutateAsync({ fields: validFields })
}
navigate({ to: "/settings" })
}
if (isPending) {
return (
<div className="space-y-4 p-4">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-32 w-full rounded-xl" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
)
}
const currentAvatar = avatarPreview ?? posterUrl(data?.avatar_url)
const currentBanner = bannerPreview ?? posterUrl(data?.banner_url)
const saving = update.isPending || updateFields.isPending
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-3">
<Link to="/settings" className="text-muted-foreground">
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-lg font-bold">{t("editProfile.title")}</h1>
</div>
{/* Banner */}
<div className="space-y-1.5">
<Label>{t("editProfile.banner")}</Label>
<button
onClick={() => bannerInputRef.current?.click()}
className="relative w-full overflow-hidden rounded-xl bg-muted"
style={{ aspectRatio: "3/1" }}
>
{currentBanner ? (
<img src={currentBanner} alt="" className="size-full object-cover" />
) : (
<div className="flex size-full items-center justify-center text-muted-foreground">
<Camera className="size-6" />
</div>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 transition-opacity hover:opacity-100">
<Camera className="size-6 text-white" />
</div>
</button>
<input
ref={bannerInputRef}
type="file"
accept="image/*"
onChange={(e) => handleFileSelect(e, setBannerFile, setBannerPreview)}
className="hidden"
/>
</div>
{/* Avatar */}
<div className="space-y-1.5">
<Label>{t("editProfile.avatar")}</Label>
<button
onClick={() => avatarInputRef.current?.click()}
className="relative size-20 overflow-hidden rounded-full bg-muted"
>
{currentAvatar ? (
<img src={currentAvatar} alt="" className="size-full object-cover" />
) : (
<div className="flex size-full items-center justify-center text-2xl font-bold text-muted-foreground">
{displayName.charAt(0).toUpperCase() || data?.username?.charAt(0).toUpperCase() || "?"}
</div>
)}
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/30 opacity-0 transition-opacity hover:opacity-100">
<Camera className="size-5 text-white" />
</div>
</button>
<input
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={(e) => handleFileSelect(e, setAvatarFile, setAvatarPreview)}
className="hidden"
/>
</div>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="display-name">{t("editProfile.displayName")}</Label>
<Input
id="display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder={t("editProfile.displayNamePlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="bio">{t("editProfile.bio")}</Label>
<Textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder={t("editProfile.bioPlaceholder")}
rows={3}
/>
</div>
<Separator />
{/* Federation fields */}
<Card size="sm">
<CardHeader>
<CardTitle className="text-sm">{t("editProfile.federation")}</CardTitle>
<CardDescription>{t("editProfile.federationDesc")}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="also-known-as">{t("editProfile.alsoKnownAs")}</Label>
<Input
id="also-known-as"
value={alsoKnownAs}
onChange={(e) => setAlsoKnownAs(e.target.value)}
placeholder={t("editProfile.alsoKnownAsPlaceholder")}
/>
<p className="text-xs text-muted-foreground">{t("editProfile.alsoKnownAsHelp")}</p>
</div>
<Separator />
<div className="space-y-1.5">
<Label>{t("editProfile.profileFields")}</Label>
<p className="text-xs text-muted-foreground">{t("editProfile.profileFieldsHelp")}</p>
</div>
{fields.map((field, i) => (
<div key={i} className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<Input
value={field.name}
onChange={(e) => updateField(i, "name", e.target.value)}
placeholder={t("editProfile.label")}
/>
<Input
value={field.value}
onChange={(e) => updateField(i, "value", e.target.value)}
placeholder={t("editProfile.value")}
/>
</div>
<Button variant="ghost" size="icon" onClick={() => removeField(i)} className="mt-1 text-muted-foreground hover:text-destructive">
<X className="size-4" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addField} className="w-full">
<Plus className="mr-1 size-4" />
{t("editProfile.addField")}
</Button>
</CardContent>
</Card>
<Button onClick={handleSave} disabled={saving} className="w-full">
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,393 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { ArrowLeft, CheckCircle, Upload } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import {
useCreateImportSession,
useApplyMapping,
useConfirmImport,
useImportPreview,
} from "@/hooks/use-imports"
import type { SessionCreatedResponse } from "@/lib/api/imports"
export const Route = createFileRoute("/_app/settings/import")({
component: ImportPage,
})
function ImportPage() {
const { t } = useTranslation()
const DOMAIN_FIELDS = [
{ value: "title", label: t("import.fieldTitle") },
{ value: "release_year", label: t("import.fieldReleaseYear") },
{ value: "director", label: t("import.fieldDirector") },
{ value: "rating", label: t("import.fieldRating") },
{ value: "watched_at", label: t("import.fieldWatchedAt") },
{ value: "comment", label: t("import.fieldComment") },
{ value: "external_metadata_id", label: t("import.fieldExternalId") },
{ value: "skip", label: t("import.fieldSkip") },
]
const RATING_SCALES = [
{ value: "1", label: t("import.scale1to5") },
{ value: "0.5", label: t("import.scale1to10") },
{ value: "0.05", label: t("import.scale1to100") },
{ value: "1.25", label: t("import.scaleLetterboxd") },
]
const [step, setStep] = useState(0)
const [session, setSession] = useState<SessionCreatedResponse | null>(null)
const [mappings, setMappings] = useState<Record<string, string>>({})
const [ratingScale, setRatingScale] = useState("1")
const [dateFormat, setDateFormat] = useState("")
const fileRef = useRef<HTMLInputElement>(null)
const createSession = useCreateImportSession()
const applyMapping = useApplyMapping()
const confirmImport = useConfirmImport()
const handleFile = (file: File) => {
createSession.mutate(file, {
onSuccess: (data) => {
setSession(data)
const initial: Record<string, string> = {}
for (const col of data.columns) initial[col] = "skip"
setMappings(initial)
setStep(1)
},
})
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
const file = e.dataTransfer.files[0]
if (file) handleFile(file)
}
const handleApplyMapping = () => {
if (!session) return
const mapped = Object.entries(mappings)
.filter(([, v]) => v !== "skip")
.map(([source_column, domain_field]) => ({
source_column,
domain_field,
rating_scale: domain_field === "rating" && ratingScale !== "1"
? parseFloat(ratingScale)
: undefined,
date_format: domain_field === "watched_at" && dateFormat
? dateFormat
: undefined,
}))
applyMapping.mutate(
{ sessionId: session.session_id, data: { mappings: mapped } },
{ onSuccess: () => setStep(2) },
)
}
const handleConfirm = () => {
if (!session) return
const indices = Array.from({ length: session.sample_rows.length }, (_, i) => i)
confirmImport.mutate(
{ sessionId: session.session_id, data: { confirmed_indices: indices } },
{ onSuccess: () => setStep(3) },
)
}
const hasRatingMapping = Object.values(mappings).includes("rating")
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-3">
{step === 0 || step === 3 ? (
<Link to="/settings" className="text-muted-foreground">
<ArrowLeft className="size-5" />
</Link>
) : (
<Button variant="ghost" size="icon" onClick={() => setStep((s) => s - 1)}>
<ArrowLeft className="size-5" />
</Button>
)}
<h1 className="text-lg font-bold">{t("import.title")}</h1>
<span className="ml-auto text-xs text-muted-foreground">
{step < 3 && t("import.step", { current: step + 1, total: 3 })}
</span>
</div>
{/* Progress */}
<div className="flex gap-1">
{[0, 1, 2, 3].map((s) => (
<div
key={s}
className={`h-1 flex-1 rounded-full ${s <= step ? "bg-primary" : "bg-muted"}`}
/>
))}
</div>
{/* Step 0: Upload */}
{step === 0 && (
<Card>
<CardContent>
<div
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
onClick={() => fileRef.current?.click()}
className="flex cursor-pointer flex-col items-center gap-3 rounded-xl border-2 border-dashed border-muted-foreground/30 p-10 text-center"
>
<Upload className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{t("import.dropCsv")}
</p>
<input
ref={fileRef}
type="file"
accept=".csv"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
{createSession.isPending && (
<p className="text-xs text-muted-foreground">{t("import.uploading")}</p>
)}
</div>
</CardContent>
</Card>
)}
{/* Step 1: Mapping */}
{step === 1 && session && (
<div className="space-y-4">
{/* Preview */}
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("import.preview")}</CardTitle>
<CardDescription>
{t("import.rowsCols", { rows: session.sample_rows.length, cols: session.columns.length })}
</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{session.columns.map((col) => (
<TableHead key={col} className="whitespace-nowrap text-xs">
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{session.sample_rows.slice(0, 3).map((row, i) => (
<TableRow key={i}>
{row.map((cell, j) => (
<TableCell key={j} className="max-w-32 truncate whitespace-nowrap text-xs">
{cell}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Column mapping */}
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("import.columnMapping")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{session.columns.map((col) => (
<div key={col} className="flex items-center gap-2">
<span className="min-w-24 truncate text-sm font-medium">{col}</span>
<Select
value={mappings[col] ?? "skip"}
onValueChange={(v) =>
setMappings((prev) => ({ ...prev, [col]: v }))
}
>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DOMAIN_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</CardContent>
</Card>
{/* Rating scale */}
{hasRatingMapping && (
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("import.ratingScale")}</CardTitle>
<CardDescription>{t("import.ratingScaleDesc")}</CardDescription>
</CardHeader>
<CardContent>
<Select value={ratingScale} onValueChange={setRatingScale}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RATING_SCALES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
)}
{/* Date format */}
{Object.values(mappings).includes("watched_at") && (
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("import.dateFormat")}</CardTitle>
<CardDescription>{t("import.dateFormatDesc")}</CardDescription>
</CardHeader>
<CardContent>
<Input
value={dateFormat}
onChange={(e) => setDateFormat(e.target.value)}
placeholder={t("import.dateFormatPlaceholder")}
/>
</CardContent>
</Card>
)}
<Button
onClick={handleApplyMapping}
disabled={applyMapping.isPending}
className="w-full"
>
{applyMapping.isPending ? t("import.applying") : t("common.continue")}
</Button>
</div>
)}
{/* Step 2: Confirm */}
{step === 2 && session && (
<ConfirmStep
sessionId={session.session_id}
onConfirm={handleConfirm}
isPending={confirmImport.isPending}
/>
)}
{/* Step 3: Done */}
{step === 3 && (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
<CheckCircle className="size-12 text-green-500" />
<p className="font-medium">{t("import.importComplete")}</p>
<Link to="/diary" className="text-sm text-primary underline">
{t("import.viewDiary")}
</Link>
</CardContent>
</Card>
)}
</div>
)
}
function ConfirmStep({
sessionId,
onConfirm,
isPending,
}: {
sessionId: string
onConfirm: () => void
isPending: boolean
}) {
const { t } = useTranslation()
const { data, isPending: previewLoading } = useImportPreview(sessionId)
if (previewLoading) return <Skeleton className="h-40 w-full rounded-xl" />
const rows = data?.rows ?? []
const valid = rows.filter((r) => r.status === "valid")
const duplicates = rows.filter((r) => r.status === "duplicate")
const invalid = rows.filter((r) => r.status === "invalid")
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("import.importSummary")}</CardTitle>
<CardDescription>
{t("import.summaryDesc", { valid: valid.length, duplicates: duplicates.length, invalid: invalid.length })}
</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("import.previewRows", { count: rows.length })}</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs">{t("import.status")}</TableHead>
<TableHead className="text-xs">{t("import.fieldTitle")}</TableHead>
<TableHead className="text-xs">{t("import.year")}</TableHead>
<TableHead className="text-xs">{t("import.fieldDirector")}</TableHead>
<TableHead className="text-xs">{t("import.fieldRating")}</TableHead>
<TableHead className="text-xs">{t("import.watched")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.index} className={row.status === "invalid" ? "text-destructive" : row.status === "duplicate" ? "opacity-50" : ""}>
<TableCell>
<Badge variant={row.status === "valid" ? "default" : row.status === "duplicate" ? "secondary" : "destructive"} className="text-[10px]">
{row.status}
</Badge>
</TableCell>
<TableCell className="max-w-32 truncate text-xs">{row.title ?? row.errors?.join(", ")}</TableCell>
<TableCell className="text-xs">{row.release_year}</TableCell>
<TableCell className="max-w-24 truncate text-xs">{row.director}</TableCell>
<TableCell className="text-xs">{row.rating}</TableCell>
<TableCell className="text-xs">{row.watched_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Button onClick={onConfirm} disabled={isPending || valid.length === 0} className="w-full">
{isPending ? t("import.importing") : t("import.importRows", { count: valid.length })}
</Button>
</div>
)
}

View File

@@ -0,0 +1,150 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import {
ArrowLeft,
ChevronRight,
Download,
Globe,
Key,
LogOut,
ShieldBan,
Sparkles,
User,
} from "lucide-react"
import { useAuth, useIsAdmin } from "@/components/auth-provider"
export const Route = createFileRoute("/_app/settings/")({
component: SettingsPage,
})
type SettingsItem = {
label: string
description?: string
to: string
icon: React.ReactNode
}
function SettingsPage() {
const { t } = useTranslation()
const { logout } = useAuth()
const isAdmin = useIsAdmin()
const navigate = useNavigate()
const account: SettingsItem[] = [
{
label: t("settings.editProfile"),
description: t("settings.editProfileDesc"),
to: "/settings/edit-profile",
icon: <User className="size-4" />,
},
]
const data: SettingsItem[] = [
{
label: t("settings.import"),
description: t("settings.importDesc"),
to: "/settings/import",
icon: <Download className="size-4" />,
},
{
label: t("settings.yearWrapUp"),
description: t("settings.yearWrapUpDesc"),
to: "/settings/wrapup",
icon: <Sparkles className="size-4" />,
},
]
const integrations: SettingsItem[] = [
{
label: t("settings.webhookTokens"),
description: t("settings.webhookTokensDesc"),
to: "/settings/webhooks",
icon: <Key className="size-4" />,
},
]
const social: SettingsItem[] = [
{
label: t("settings.blockedUsers"),
description: isAdmin ? t("settings.blockedUsersDescAdmin") : t("settings.blockedUsersDesc"),
to: "/settings/blocked",
icon: <ShieldBan className="size-4" />,
},
]
if (isAdmin) {
social.push({
label: t("settings.blockedDomains"),
description: t("settings.blockedDomainsDesc"),
to: "/settings/blocked",
icon: <Globe className="size-4" />,
})
}
const handleLogout = () => {
logout()
navigate({ to: "/login" })
}
return (
<div className="space-y-6 p-4">
<div className="flex items-center gap-3">
<Link to="/profile" className="text-muted-foreground">
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-lg font-bold">{t("settings.title")}</h1>
</div>
<SettingsGroup label={t("settings.account")} items={account} />
<SettingsGroup label={t("settings.data")} items={data} />
<SettingsGroup label={t("settings.integrations")} items={integrations} />
<SettingsGroup label={t("settings.socialGroup")} items={social} />
<button
onClick={handleLogout}
className="w-full rounded-xl bg-card p-3 text-sm font-medium text-red-400"
>
<div className="flex items-center gap-3">
<LogOut className="size-4" />
{t("settings.logOut")}
</div>
</button>
</div>
)
}
function SettingsGroup({
label,
items,
}: {
label: string
items: SettingsItem[]
}) {
return (
<div>
<p className="mb-1.5 px-1 text-xs font-medium text-muted-foreground">
{label}
</p>
<div className="divide-y divide-border rounded-xl bg-card">
{items.map((item) => (
<Link
key={item.label}
to={item.to}
className="flex items-center gap-3 p-3"
>
<span className="text-muted-foreground">{item.icon}</span>
<div className="flex-1">
<p className="text-sm font-medium">{item.label}</p>
{item.description && (
<p className="text-xs text-muted-foreground">
{item.description}
</p>
)}
</div>
<ChevronRight className="size-4 text-muted-foreground" />
</Link>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,145 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { ArrowLeft, Key, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import { Skeleton } from "@/components/ui/skeleton"
import { EmptyState } from "@/components/empty-state"
import {
useWebhookTokens,
useGenerateToken,
useDeleteToken,
} from "@/hooks/use-webhooks"
export const Route = createFileRoute("/_app/settings/webhooks")({
component: WebhooksPage,
})
function WebhooksPage() {
const { t } = useTranslation()
const { data: tokens, isPending } = useWebhookTokens()
const generate = useGenerateToken()
const remove = useDeleteToken()
const [open, setOpen] = useState(false)
const [provider, setProvider] = useState("jellyfin")
const [label, setLabel] = useState("")
const handleGenerate = () => {
generate.mutate(
{ provider, label: label || undefined },
{
onSuccess: (data) => {
navigator.clipboard.writeText(data.webhook_url)
toast.success(t("webhooks.copied"))
setOpen(false)
setLabel("")
},
},
)
}
return (
<div className="space-y-4 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/settings" className="text-muted-foreground">
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-lg font-bold">{t("webhooks.title")}</h1>
</div>
<button onClick={() => setOpen(true)} className="text-primary">
<Plus className="size-5" />
</button>
</div>
{isPending ? (
<div className="space-y-2">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-14 rounded-xl" />
))}
</div>
) : !tokens?.length ? (
<EmptyState icon={Key} title={t("webhooks.noTokens")} description={t("webhooks.noTokensDesc")} />
) : (
<div className="space-y-2">
{tokens.map((t) => (
<div
key={t.id}
className="flex items-center justify-between rounded-xl bg-card p-3"
>
<div>
<p className="text-sm font-medium">
{t.provider}
{t.label && `${t.label}`}
</p>
<p className="text-xs text-muted-foreground">
{new Date(t.created_at).toLocaleDateString()}
</p>
</div>
<button
onClick={() => remove.mutate(t.id)}
className="text-destructive"
>
<Trash2 className="size-4" />
</button>
</div>
))}
</div>
)}
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{t("webhooks.generateToken")}</DrawerTitle>
</DrawerHeader>
<div className="space-y-3 p-4">
<div className="space-y-1.5">
<Label>{t("webhooks.provider")}</Label>
<Select value={provider} onValueChange={setProvider}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="jellyfin">{t("webhooks.jellyfin")}</SelectItem>
<SelectItem value="plex">{t("webhooks.plex")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("webhooks.labelOptional")}</Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t("webhooks.labelPlaceholder")}
/>
</div>
<Button
onClick={handleGenerate}
disabled={generate.isPending}
className="w-full"
>
{generate.isPending ? t("common.generating") : t("common.generate")}
</Button>
</div>
</DrawerContent>
</Drawer>
</div>
)
}

View File

@@ -0,0 +1,148 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { ArrowLeft, ChevronRight, Sparkles, Trash2 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { EmptyState } from "@/components/empty-state"
import { useIsAdmin } from "@/components/auth-provider"
import {
useWrapUps,
useGenerateWrapUp,
useDeleteWrapUp,
} from "@/hooks/use-wrapup"
export const Route = createFileRoute("/_app/settings/wrapup")({
component: WrapupPage,
})
function WrapupPage() {
const { t } = useTranslation()
const isAdmin = useIsAdmin()
const { data, isPending } = useWrapUps()
const generate = useGenerateWrapUp()
const remove = useDeleteWrapUp()
const [open, setOpen] = useState(false)
const [startDate, setStartDate] = useState("")
const [endDate, setEndDate] = useState("")
const handleGenerate = () => {
generate.mutate(
{ start_date: startDate, end_date: endDate },
{
onSuccess: () => {
setOpen(false)
setStartDate("")
setEndDate("")
},
},
)
}
const items = data?.items ?? []
return (
<div className="space-y-4 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/settings" className="text-muted-foreground">
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-lg font-bold">{t("wrapup.title")}</h1>
</div>
{isAdmin && (
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
<Sparkles className="size-5" />
</Button>
)}
</div>
{isPending ? (
<div className="space-y-2">
{[1, 2].map((i) => (
<Skeleton key={i} className="h-14 rounded-xl" />
))}
</div>
) : !items.length ? (
<EmptyState icon={Sparkles} title={t("wrapup.noWrapUps")} />
) : (
<div className="space-y-2">
{items.map((w) => (
<Card key={w.id} size="sm">
<CardContent className="flex items-center justify-between">
{w.status === "completed" ? (
<Link to="/wrapup/$id" params={{ id: w.id }} className="flex flex-1 items-center justify-between">
<div>
<p className="text-sm font-medium">{w.start_date} {w.end_date}</p>
<Badge className="mt-1 text-[10px]">{w.status}</Badge>
</div>
<ChevronRight className="size-4 text-muted-foreground" />
</Link>
) : (
<div>
<p className="text-sm font-medium">{w.start_date} {w.end_date}</p>
<Badge variant="secondary" className="mt-1 text-[10px]">{w.status}</Badge>
</div>
)}
{isAdmin && (
<Button
variant="ghost"
size="icon"
onClick={() => remove.mutate(w.id)}
className="ml-2 text-destructive hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
)}
</CardContent>
</Card>
))}
</div>
)}
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="mx-auto max-w-lg">
<DrawerHeader>
<DrawerTitle>{t("wrapup.generateWrapUp")}</DrawerTitle>
</DrawerHeader>
<div className="space-y-3 p-4 pb-8">
<div className="space-y-1.5">
<Label>{t("wrapup.startDate")}</Label>
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label>{t("wrapup.endDate")}</Label>
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
<Button
onClick={handleGenerate}
disabled={generate.isPending || !startDate || !endDate}
className="w-full"
>
{generate.isPending ? t("common.generating") : t("common.generate")}
</Button>
</div>
</DrawerContent>
</Drawer>
</div>
)
}

View File

@@ -0,0 +1,289 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { ArrowLeft, UserCheck, UserMinus, UserPlus, UserX, Users } from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { EmptyState } from "@/components/empty-state"
import { toast } from "sonner"
import { useAuth } from "@/components/auth-provider"
import {
useFollow,
useFollowing,
useFollowers,
usePendingFollowers,
useUnfollow,
useAcceptFollower,
useRejectFollower,
useRemoveFollower,
useUserFollowing,
useUserFollowers,
} from "@/hooks/use-social"
import type { RemoteActorDto } from "@/lib/api/social"
type SearchParams = { user?: string }
export const Route = createFileRoute("/_app/social")({
validateSearch: (search: Record<string, unknown>): SearchParams => ({
user: typeof search.user === "string" ? search.user : undefined,
}),
component: SocialPage,
})
function SocialPage() {
const { t } = useTranslation()
const { user: viewUserId } = Route.useSearch()
const { auth } = useAuth()
const isSelf = !viewUserId || viewUserId === auth?.user_id
return (
<div className="space-y-4 p-4">
<div className="flex items-center gap-3">
<Link to={isSelf ? "/profile" : "/users/$id"} params={{ id: viewUserId ?? "" }} className="text-muted-foreground">
<ArrowLeft className="size-5" />
</Link>
<h1 className="text-lg font-bold">{t("social.title")}</h1>
</div>
{isSelf && <FollowByHandle />}
{isSelf ? <OwnSocialTabs /> : <UserSocialTabs userId={viewUserId!} />}
</div>
)
}
function OwnSocialTabs() {
const { t } = useTranslation()
return (
<Tabs defaultValue="following">
<TabsList className="w-full">
<TabsTrigger value="following">{t("social.following")}</TabsTrigger>
<TabsTrigger value="followers">{t("social.followers")}</TabsTrigger>
<TabsTrigger value="pending">{t("social.pending")}</TabsTrigger>
</TabsList>
<TabsContent value="following"><OwnFollowingTab /></TabsContent>
<TabsContent value="followers"><OwnFollowersTab /></TabsContent>
<TabsContent value="pending"><PendingTab /></TabsContent>
</Tabs>
)
}
function UserSocialTabs({ userId }: { userId: string }) {
const { t } = useTranslation()
return (
<Tabs defaultValue="following">
<TabsList className="w-full">
<TabsTrigger value="following">{t("social.following")}</TabsTrigger>
<TabsTrigger value="followers">{t("social.followers")}</TabsTrigger>
</TabsList>
<TabsContent value="following"><UserFollowingTab userId={userId} /></TabsContent>
<TabsContent value="followers"><UserFollowersTab userId={userId} /></TabsContent>
</Tabs>
)
}
function OwnFollowingTab() {
const { t } = useTranslation()
const { data, isPending } = useFollowing()
const unfollowMutation = useUnfollow()
if (isPending) return <ListSkeleton />
if (!data?.actors.length)
return <EmptyState icon={Users} title={t("social.notFollowing")} description={t("social.notFollowingDesc")} />
return (
<div className="space-y-2">
{data.actors.map((actor) => (
<ActorCard
key={actor.url}
actor={actor}
action={
<Button
variant="outline"
size="sm"
onClick={() => unfollowMutation.mutate({ handle: actor.handle })}
disabled={unfollowMutation.isPending}
>
<UserMinus className="mr-1 size-3.5" />
{t("common.unfollow")}
</Button>
}
/>
))}
</div>
)
}
function OwnFollowersTab() {
const { t } = useTranslation()
const { data, isPending } = useFollowers()
const removeMutation = useRemoveFollower()
if (isPending) return <ListSkeleton />
if (!data?.actors.length)
return <EmptyState icon={Users} title={t("social.noFollowers")} />
return (
<div className="space-y-2">
{data.actors.map((actor) => (
<ActorCard
key={actor.url}
actor={actor}
action={
<Button
variant="ghost"
size="sm"
onClick={() => removeMutation.mutate({ actor_url: actor.url })}
disabled={removeMutation.isPending}
className="text-destructive hover:text-destructive"
>
<UserX className="mr-1 size-3.5" />
{t("common.remove")}
</Button>
}
/>
))}
</div>
)
}
function PendingTab() {
const { t } = useTranslation()
const { data, isPending } = usePendingFollowers()
const acceptMutation = useAcceptFollower()
const rejectMutation = useRejectFollower()
if (isPending) return <ListSkeleton />
if (!data?.actors.length)
return <EmptyState icon={UserCheck} title={t("social.noPending")} />
return (
<div className="space-y-2">
{data.actors.map((actor) => (
<ActorCard
key={actor.url}
actor={actor}
action={
<div className="flex gap-1">
<Button size="sm" onClick={() => acceptMutation.mutate({ actor_url: actor.url })} disabled={acceptMutation.isPending}>
{t("common.accept")}
</Button>
<Button variant="outline" size="sm" onClick={() => rejectMutation.mutate({ actor_url: actor.url })} disabled={rejectMutation.isPending}>
{t("common.reject")}
</Button>
</div>
}
/>
))}
</div>
)
}
function UserFollowingTab({ userId }: { userId: string }) {
const { t } = useTranslation()
const { data, isPending } = useUserFollowing(userId)
if (isPending) return <ListSkeleton />
if (!data?.actors.length)
return <EmptyState icon={Users} title={t("social.notFollowing")} />
return (
<div className="space-y-2">
{data.actors.map((actor) => (
<ActorCard key={actor.url} actor={actor} />
))}
</div>
)
}
function UserFollowersTab({ userId }: { userId: string }) {
const { t } = useTranslation()
const { data, isPending } = useUserFollowers(userId)
if (isPending) return <ListSkeleton />
if (!data?.actors.length)
return <EmptyState icon={Users} title={t("social.noFollowersOther")} />
return (
<div className="space-y-2">
{data.actors.map((actor) => (
<ActorCard key={actor.url} actor={actor} />
))}
</div>
)
}
function ActorCard({ actor, action }: { actor: RemoteActorDto; action?: React.ReactNode }) {
const initial = (actor.display_name || actor.handle)[0]?.toUpperCase() ?? "?"
return (
<Card size="sm">
<CardContent className="flex items-center gap-3">
<Avatar>
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{actor.display_name || actor.handle}</p>
<p className="truncate text-xs text-muted-foreground">{actor.handle}</p>
</div>
{action}
</CardContent>
</Card>
)
}
function FollowByHandle() {
const { t } = useTranslation()
const [handle, setHandle] = useState("")
const followMutation = useFollow()
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!handle.trim()) return
followMutation.mutate(
{ handle: handle.trim() },
{
onSuccess: () => {
toast.success(t("social.followSent", { handle }))
setHandle("")
},
onError: () => {
toast.error(t("social.followError"))
},
},
)
}
return (
<Card size="sm">
<CardContent>
<form onSubmit={handleSubmit} className="flex items-center gap-2">
<Input
value={handle}
onChange={(e) => setHandle(e.target.value)}
placeholder={t("social.handlePlaceholder")}
className="flex-1"
/>
<Button type="submit" size="sm" disabled={!handle.trim() || followMutation.isPending}>
<UserPlus className="mr-1 size-3.5" />
{t("common.follow")}
</Button>
</form>
</CardContent>
</Card>
)
}
function ListSkeleton() {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-xl" />
))}
</div>
)
}

View File

@@ -0,0 +1,65 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { ArrowLeft, UserCheck, UserPlus } from "lucide-react"
import { Button } from "@/components/ui/button"
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
import { useAuth } from "@/components/auth-provider"
import { useUserProfile } from "@/hooks/use-users"
import { useFollow, useUnfollow, useFollowing } from "@/hooks/use-social"
export const Route = createFileRoute("/_app/users/$id")({
component: UserProfilePage,
})
function UserProfilePage() {
const { t } = useTranslation()
const { id } = Route.useParams()
const { auth } = useAuth()
const { data, isPending } = useUserProfile(id, { view: "trends" })
const { data: followingData } = useFollowing()
const followMutation = useFollow()
const unfollowMutation = useUnfollow()
if (isPending) return <ProfileSkeleton />
if (!data) return null
const isSelf = auth?.user_id === id
const isFollowing = followingData?.actors.some((a) => a.handle === data.username) ?? false
return (
<div className="p-4">
<Link to="/" className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground">
<ArrowLeft className="size-4" /> {t("common.back")}
</Link>
<ProfileView
data={data}
userId={id}
headerRight={
!isSelf ? (
isFollowing ? (
<Button
size="sm"
variant="outline"
onClick={() => unfollowMutation.mutate({ handle: data.username })}
disabled={unfollowMutation.isPending}
>
<UserCheck className="mr-1 size-3.5" />
{t("common.following")}
</Button>
) : (
<Button
size="sm"
onClick={() => followMutation.mutate({ handle: data.username })}
disabled={followMutation.isPending}
>
<UserPlus className="mr-1 size-3.5" />
{t("common.follow")}
</Button>
)
) : undefined
}
/>
</div>
)
}

View File

@@ -0,0 +1,234 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
import { ArrowLeft, Star, Users } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { RatingHistogram } from "@/components/rating-histogram"
import { posterUrl } from "@/lib/api/client"
import { useWrapUpReport } from "@/hooks/use-wrapup"
import type { MovieRef, PersonStat } from "@/lib/api/wrapup"
export const Route = createFileRoute("/_app/wrapup/$id")({
component: WrapUpReportPage,
})
function WrapUpReportPage() {
const { t } = useTranslation()
const { id } = Route.useParams()
const { data: report, isPending } = useWrapUpReport(id)
if (isPending) return <ReportSkeleton />
if (!report) return null
const watchHours = Math.round(report.total_watch_time_minutes / 60)
return (
<div className="space-y-4 p-4">
<Link to="/profile" className="inline-flex items-center gap-1 text-sm text-muted-foreground">
<ArrowLeft className="size-4" /> {t("profile.title")}
</Link>
{/* Hero */}
<Card>
<CardContent className="py-8 text-center">
<p className="text-xs uppercase tracking-widest text-muted-foreground">{t("wrapup.heroSubtitle")}</p>
<p className="mt-2 text-5xl font-extrabold tracking-tight">{report.total_movies}</p>
<p className="text-sm text-muted-foreground">{t("wrapup.moviesWatched")}</p>
{watchHours > 0 && (
<p className="mt-1 text-xs text-muted-foreground">{t("wrapup.watchHours", { hours: watchHours })}</p>
)}
</CardContent>
</Card>
{/* Ratings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<Star className="size-4" /> {t("wrapup.ratings")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{report.avg_rating != null && (
<div className="text-center">
<p className="text-4xl font-bold text-amber-500">{report.avg_rating.toFixed(1)}</p>
<p className="text-xs text-muted-foreground">{t("wrapup.averageRating")}</p>
</div>
)}
<RatingHistogram histogram={report.rating_distribution} />
<div className="flex flex-wrap gap-2">
{report.busiest_month && (
<Badge variant="secondary">{t("wrapup.busiestMonth", { month: report.busiest_month })}</Badge>
)}
{report.busiest_day_of_week && (
<Badge variant="secondary">{t("wrapup.favoriteDay", { day: report.busiest_day_of_week })}</Badge>
)}
</div>
</CardContent>
</Card>
{/* Top Directors */}
{report.top_directors.length > 0 && (
<RankCard
title={t("wrapup.topDirectors")}
subtitle={t("wrapup.uniqueDirectors", { count: report.director_diversity })}
items={report.top_directors.slice(0, 5)}
/>
)}
{/* Top Actors */}
{report.top_actors.length > 0 && (
<RankCard
title={t("wrapup.topActors")}
subtitle={t("wrapup.uniqueActors", { count: report.actor_diversity })}
items={report.top_actors.slice(0, 5)}
/>
)}
{/* Genres */}
{report.top_genres.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("wrapup.genres")}</CardTitle>
<CardDescription>{t("wrapup.genresExplored", { count: report.genre_diversity })}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{report.top_genres.slice(0, 8).map((g) => {
const max = report.top_genres[0]?.count ?? 1
return (
<div key={g.genre} className="flex items-center gap-2 text-sm">
<span className="w-20 truncate">{g.genre}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary" style={{ width: `${(g.count / max) * 100}%` }} />
</div>
<span className="w-6 text-right text-xs text-muted-foreground">{g.count}</span>
</div>
)
})}
<div className="flex flex-wrap gap-2 pt-2">
{report.highest_rated_genre && (
<Badge variant="secondary">{t("wrapup.highestRated", { genre: report.highest_rated_genre })}</Badge>
)}
{report.lowest_rated_genre && (
<Badge variant="secondary">{t("wrapup.lowestRated", { genre: report.lowest_rated_genre })}</Badge>
)}
</div>
</CardContent>
</Card>
)}
{/* Highlights */}
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("wrapup.highlights")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<MovieHighlight label={t("wrapup.highlightHighest")} movie={report.highest_rated_movie} />
<MovieHighlight label={t("wrapup.highlightLowest")} movie={report.lowest_rated_movie} />
<MovieHighlight label={t("wrapup.highlightOldest")} movie={report.oldest_movie} />
<MovieHighlight label={t("wrapup.highlightNewest")} movie={report.newest_movie} />
<MovieHighlight label={t("wrapup.highlightLongest")} movie={report.longest_movie} showRuntime />
<MovieHighlight label={t("wrapup.highlightShortest")} movie={report.shortest_movie} showRuntime />
<MovieHighlight label={t("wrapup.highlightFirst")} movie={report.first_movie_of_period} />
<MovieHighlight label={t("wrapup.highlightLast")} movie={report.last_movie_of_period} />
</div>
</CardContent>
</Card>
{/* Rewatches */}
{report.total_rewatches > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("wrapup.rewatches")}</CardTitle>
</CardHeader>
<CardContent className="text-center">
<p className="text-3xl font-bold">{report.total_rewatches}</p>
<p className="text-xs text-muted-foreground">{t("wrapup.moviesRewatched")}</p>
{report.most_rewatched_movie && (
<p className="mt-2 text-sm text-muted-foreground">
{t("wrapup.mostRewatched")} <strong>{report.most_rewatched_movie.title}</strong> ({report.most_rewatched_movie.year})
</p>
)}
</CardContent>
</Card>
)}
{/* Poster Mosaic */}
{report.poster_paths.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm">{t("wrapup.posterMosaic")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-5 gap-1">
{report.poster_paths.map((path, i) => (
<div key={i} className="aspect-[2/3] overflow-hidden rounded-md bg-muted">
<img src={posterUrl(path)} alt="" className="size-full object-cover" loading="lazy" />
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}
function RankCard({ title, subtitle, items }: { title: string; subtitle: string; items: PersonStat[] }) {
const { t } = useTranslation()
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<Users className="size-4" /> {title}
</CardTitle>
<CardDescription>{subtitle}</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-2">
{items.map((item, i) => (
<li key={item.name} className="flex items-center gap-3">
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-xs font-bold">{i + 1}</span>
<div className="flex-1">
<p className="text-sm font-medium">{item.name}</p>
<p className="text-xs text-muted-foreground">{t("common.filmsAvg", { count: item.count, avg: item.avg_rating.toFixed(1) })}</p>
</div>
</li>
))}
</ol>
</CardContent>
</Card>
)
}
function MovieHighlight({ label, movie, showRuntime }: { label: string; movie?: MovieRef; showRuntime?: boolean }) {
if (!movie) return null
return (
<div className="overflow-hidden rounded-xl bg-muted">
{movie.poster_path && (
<div className="aspect-[2/3] w-full">
<img src={posterUrl(movie.poster_path)} alt={movie.title} className="size-full object-cover" />
</div>
)}
<div className="p-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="truncate text-xs font-medium">{movie.title}</p>
<p className="text-[10px] text-muted-foreground">
{showRuntime && movie.runtime_minutes ? `${movie.runtime_minutes} min` : movie.year}
</p>
</div>
</div>
)
}
function ReportSkeleton() {
return (
<div className="space-y-4 p-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-60 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
)
}

56
spa/src/routes/login.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useLogin } from "@/hooks/use-auth"
export const Route = createFileRoute("/login")({
component: LoginPage,
})
function LoginPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const loginMutation = useLogin()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
loginMutation.mutate(
{ email, password },
{ onSuccess: () => navigate({ to: "/" }) },
)
}
return (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<h1 className="text-2xl font-bold tracking-tight">{t("auth.title")}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t("auth.loginHeading")}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoComplete="email" />
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("auth.password")}</Label>
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required autoComplete="current-password" />
</div>
{loginMutation.error && <p className="text-sm text-destructive">{t("auth.loginError")}</p>}
<Button type="submit" className="w-full" disabled={loginMutation.isPending}>
{loginMutation.isPending ? t("auth.loggingIn") : t("auth.logIn")}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
{t("auth.noAccount")}{" "}
<Link to="/register" className="font-medium text-primary underline">{t("auth.createAccount")}</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,61 @@
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useRegister } from "@/hooks/use-auth"
export const Route = createFileRoute("/register")({
component: RegisterPage,
})
function RegisterPage() {
const { t } = useTranslation()
const navigate = useNavigate()
const registerMutation = useRegister()
const [email, setEmail] = useState("")
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
registerMutation.mutate(
{ email, username, password },
{ onSuccess: () => navigate({ to: "/login" }) },
)
}
return (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<h1 className="text-2xl font-bold tracking-tight">{t("auth.title")}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t("auth.registerHeading")}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">{t("auth.email")}</Label>
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoComplete="email" />
</div>
<div className="space-y-2">
<Label htmlFor="username">{t("auth.username")}</Label>
<Input id="username" value={username} onChange={(e) => setUsername(e.target.value)} required autoComplete="username" />
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("auth.password")}</Label>
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required autoComplete="new-password" />
</div>
{registerMutation.error && <p className="text-sm text-destructive">{t("auth.registerError")}</p>}
<Button type="submit" className="w-full" disabled={registerMutation.isPending}>
{registerMutation.isPending ? t("auth.creating") : t("auth.createAccount")}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
{t("auth.hasAccount")}{" "}
<Link to="/login" className="font-medium text-primary underline">{t("auth.logIn")}</Link>
</p>
</div>
</div>
)
}