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:
14
spa/src/routes/__root.tsx
Normal file
14
spa/src/routes/__root.tsx
Normal 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
52
spa/src/routes/_app.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
252
spa/src/routes/_app/index.tsx
Normal file
252
spa/src/routes/_app/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
276
spa/src/routes/_app/movies.$id.tsx
Normal file
276
spa/src/routes/_app/movies.$id.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
spa/src/routes/_app/people.$id.tsx
Normal file
121
spa/src/routes/_app/people.$id.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
spa/src/routes/_app/profile.tsx
Normal file
70
spa/src/routes/_app/profile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
spa/src/routes/_app/search.tsx
Normal file
124
spa/src/routes/_app/search.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
162
spa/src/routes/_app/settings/blocked.tsx
Normal file
162
spa/src/routes/_app/settings/blocked.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
246
spa/src/routes/_app/settings/edit-profile.tsx
Normal file
246
spa/src/routes/_app/settings/edit-profile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
393
spa/src/routes/_app/settings/import.tsx
Normal file
393
spa/src/routes/_app/settings/import.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
spa/src/routes/_app/settings/index.tsx
Normal file
150
spa/src/routes/_app/settings/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
145
spa/src/routes/_app/settings/webhooks.tsx
Normal file
145
spa/src/routes/_app/settings/webhooks.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
spa/src/routes/_app/settings/wrapup.tsx
Normal file
148
spa/src/routes/_app/settings/wrapup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
289
spa/src/routes/_app/social.tsx
Normal file
289
spa/src/routes/_app/social.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
spa/src/routes/_app/users.$id.tsx
Normal file
65
spa/src/routes/_app/users.$id.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
234
spa/src/routes/_app/wrapup.$id.tsx
Normal file
234
spa/src/routes/_app/wrapup.$id.tsx
Normal 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
56
spa/src/routes/login.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
spa/src/routes/register.tsx
Normal file
61
spa/src/routes/register.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user