diff --git a/spa/src/components/actor-list.tsx b/spa/src/components/actor-list.tsx
new file mode 100644
index 0000000..1b1cc7c
--- /dev/null
+++ b/spa/src/components/actor-list.tsx
@@ -0,0 +1,66 @@
+import type { LucideIcon } from "lucide-react"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import { Card, CardContent } from "@/components/ui/card"
+import { Skeleton } from "@/components/ui/skeleton"
+import { EmptyState } from "@/components/empty-state"
+import type { ActorListResponse, RemoteActorDto } from "@/features/social"
+
+type ActorListProps = {
+ data: ActorListResponse | undefined
+ isPending: boolean
+ emptyIcon: LucideIcon
+ emptyTitle: string
+ emptyDescription?: string
+ renderAction?: (actor: RemoteActorDto) => React.ReactNode
+}
+
+export function ActorList({ data, isPending, emptyIcon, emptyTitle, emptyDescription, renderAction }: ActorListProps) {
+ if (isPending) return
+ if (!data?.actors.length) return
+
+ return (
+
+ {data.actors.map((actor) => (
+
+ ))}
+
+ )
+}
+
+function actorHandle(actor: RemoteActorDto): string {
+ try {
+ const host = new URL(actor.url).host
+ return `@${actor.handle}@${host}`
+ } catch {
+ return `@${actor.handle}`
+ }
+}
+
+function ActorCard({ actor, action }: { actor: RemoteActorDto; action?: React.ReactNode }) {
+ const initial = (actor.display_name || actor.handle)[0]?.toUpperCase() ?? "?"
+
+ return (
+
+
+
+ {initial}
+
+
+
{actor.display_name || actor.handle}
+
{actorHandle(actor)}
+
+ {action}
+
+
+ )
+}
+
+function ListSkeleton() {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ )
+}
diff --git a/spa/src/components/community-reviews.tsx b/spa/src/components/community-reviews.tsx
new file mode 100644
index 0000000..54eb4d3
--- /dev/null
+++ b/spa/src/components/community-reviews.tsx
@@ -0,0 +1,48 @@
+import { useTranslation } from "react-i18next"
+import { Globe, Users } from "lucide-react"
+import { StarDisplay } from "@/components/star-display"
+import { WatchMediumBadge } from "@/components/watch-medium-badge"
+import { EmptyState } from "@/components/empty-state"
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
+import { timeAgo } from "@/lib/date"
+import type { SocialReviewDto } from "@/features/movies"
+
+export function CommunityReviews({ reviews }: { reviews: { items: SocialReviewDto[] } }) {
+ const { t } = useTranslation()
+
+ return (
+
+ {t("movie.community")}
+ {!reviews.items.length ? (
+
+ ) : (
+
+ {reviews.items.map((r, i) => (
+
+
+
+
+
+ {r.user_display}
+ {r.is_federated && }
+
+ {timeAgo(r.watched_at)}
+
+
+
+ {r.watch_medium && }
+
+
+
+ {r.comment && (
+
+ {r.comment}
+
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/spa/src/components/edit-review-sheet.tsx b/spa/src/components/edit-review-sheet.tsx
deleted file mode 100644
index 04384fe..0000000
--- a/spa/src/components/edit-review-sheet.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { useState } from "react"
-import { useTranslation } from "react-i18next"
-import { VisuallyHidden } from "radix-ui"
-import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
-import { Button } from "@/components/ui/button"
-import { ReviewFormFields } from "@/components/review-form-fields"
-import { useEditReview } from "@/hooks/use-diary"
-import { toast } from "sonner"
-import { posterUrl } from "@/lib/api/client"
-import { hapticMedium } from "@/lib/haptics"
-import type { EditReviewRequest } from "@/lib/api/diary"
-import type { MovieDto, ReviewDto } from "@/lib/api/common"
-
-type EditReviewSheetProps = {
- open: boolean
- onOpenChange: (open: boolean) => void
- movie: MovieDto
- review: ReviewDto
-}
-
-function parseLocalDate(s: string): Date {
- const [datePart, timePart] = s.split("T")
- if (!datePart) return new Date()
- const [y, m, d] = datePart.split("-").map(Number)
- if (timePart) {
- const [h, min, sec] = timePart.split(":").map(Number)
- return new Date(y!, m! - 1, d!, h, min, sec)
- }
- return new Date(y!, m! - 1, d!)
-}
-
-function formatLocalDateTime(d: Date): string {
- const pad = (n: number) => n.toString().padStart(2, "0")
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
-}
-
-export function EditReviewSheet({ open, onOpenChange, movie, review }: EditReviewSheetProps) {
- const { t } = useTranslation()
- const [rating, setRating] = useState(review.rating)
- const [comment, setComment] = useState(review.comment ?? "")
- const [watchedAt, setWatchedAt] = useState(() => parseLocalDate(review.watched_at))
- const [dateChanged, setDateChanged] = useState(false)
- const [watchMedium, setWatchMedium] = useState(review.watch_medium)
- const editMutation = useEditReview()
-
- function handleDateChange(d: Date) {
- setWatchedAt(d)
- setDateChanged(true)
- }
-
- function handleSubmit() {
- if (!rating) return
-
- const data: Partial = {}
- if (rating !== review.rating) data.rating = rating
- const newComment = comment || null
- if (newComment !== (review.comment ?? null)) data.comment = newComment
- if (dateChanged) data.watched_at = formatLocalDateTime(watchedAt)
- if (watchMedium !== review.watch_medium) data.watch_medium = watchMedium ?? null
-
- if (Object.keys(data).length === 0) {
- toast.info(t("editReview.noChanges"))
- onOpenChange(false)
- return
- }
-
- editMutation.mutate(
- { id: review.id, data },
- {
- onSuccess: () => {
- hapticMedium()
- toast.success(t("editReview.saved", { title: movie.title }))
- onOpenChange(false)
- },
- },
- )
- }
-
- return (
-
-
- {t("editReview.title")}
-
-
-
- {movie.poster_path &&
})
}
-
-
-
{movie.title}
-
{movie.release_year}{movie.director && ` · ${movie.director}`}
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/spa/src/components/feed-tab.tsx b/spa/src/components/feed-tab.tsx
new file mode 100644
index 0000000..ee0bdc9
--- /dev/null
+++ b/spa/src/components/feed-tab.tsx
@@ -0,0 +1,146 @@
+import { useCallback, useState } from "react"
+import { useTranslation } from "react-i18next"
+import { Film, RefreshCw } from "lucide-react"
+import { ReviewCard } from "@/components/review-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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Skeleton } from "@/components/ui/skeleton"
+import { useAuth } from "@/components/auth-provider"
+import { useQueryClient } from "@tanstack/react-query"
+import { ReviewSheet } from "@/components/review-sheet"
+import { ReviewDetailSheet } from "@/components/review-detail-sheet"
+import { useInfiniteActivityFeed, useDeleteReview } from "@/features/diary"
+import type { FeedEntryDto } from "@/features/diary"
+
+export function FeedSkeleton() {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ )
+}
+
+export function FeedTab() {
+ const { t } = useTranslation()
+ const { auth } = useAuth()
+ const qc = useQueryClient()
+ const [refreshing, setRefreshing] = useState(false)
+ 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 [editingEntry, setEditingEntry] = useState(null)
+ const [detailEntry, setDetailEntry] = useState(null)
+ const items = data?.pages.flatMap((p) => p.items) ?? []
+ const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
+
+ return (
+
+
+
+
+
+
+ {isPending &&
}
+
+ {!isPending && !items.length && (
+
+ )}
+
+ {items.length > 0 && (
+
{
+ const isOwn = entry.user_id === auth?.user_id
+ const card = (
+ setEditingEntry(entry) : undefined}
+ onShowDetail={entry.review.comment ? () => setDetailEntry(entry) : undefined}
+ />
+ )
+ return isOwn ? (
+ deleteReview.mutate(entry.review.id)}
+ confirmTitle={t("feed.deleteReview")}
+ confirmDescription={entry.movie.title}
+ >
+ {card}
+
+ ) : (
+ card
+ )
+ }}
+ />
+ )}
+
+ {editingEntry && (
+ !open && setEditingEntry(null)}
+ movie={editingEntry.movie}
+ review={editingEntry.review}
+ />
+ )}
+
+ {detailEntry && (
+ !open && setDetailEntry(null)}
+ movie={detailEntry.movie}
+ review={detailEntry.review}
+ userName={detailEntry.user_display_name}
+ />
+ )}
+
+ )
+}
diff --git a/spa/src/components/goal-card.tsx b/spa/src/components/goal-card.tsx
index 1594b89..bd2658e 100644
--- a/spa/src/components/goal-card.tsx
+++ b/spa/src/components/goal-card.tsx
@@ -9,7 +9,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
-import type { GoalDto } from "@/lib/api/users"
+import type { GoalDto } from "@/features/users"
type GoalCardProps = {
goal: GoalDto
diff --git a/spa/src/components/goal-sheet.tsx b/spa/src/components/goal-sheet.tsx
index 385deba..e90c9d8 100644
--- a/spa/src/components/goal-sheet.tsx
+++ b/spa/src/components/goal-sheet.tsx
@@ -5,7 +5,7 @@ import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
-import { useCreateGoal, useUpdateGoal } from "@/hooks/use-goals"
+import { useCreateGoal, useUpdateGoal } from "@/features/goals"
import { toast } from "sonner"
type GoalSheetProps = {
diff --git a/spa/src/components/log-sheet.tsx b/spa/src/components/log-sheet.tsx
deleted file mode 100644
index 677d105..0000000
--- a/spa/src/components/log-sheet.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import { useState } from "react"
-import { useTranslation } from "react-i18next"
-import { VisuallyHidden } from "radix-ui"
-import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
-import { Button } from "@/components/ui/button"
-import { ReviewFormFields } from "@/components/review-form-fields"
-import { SearchOverlay } from "@/components/search-overlay"
-import type { MovieSelection } from "@/components/search-overlay"
-import { useLogReview } from "@/hooks/use-diary"
-import { toast } from "sonner"
-import { posterUrl } from "@/lib/api/client"
-import { hapticMedium } from "@/lib/haptics"
-
-type LogSheetProps = {
- open: boolean
- onOpenChange: (open: boolean) => void
-}
-
-export function LogSheet({ open, onOpenChange }: LogSheetProps) {
- const { t } = useTranslation()
- const [movie, setMovie] = useState(null)
- const [rating, setRating] = useState(0)
- const [comment, setComment] = useState("")
- const [watchedAt, setWatchedAt] = useState(new Date())
- const [watchMedium, setWatchMedium] = useState()
- const logMutation = useLogReview()
-
- function reset() {
- setMovie(null)
- setRating(0)
- setComment("")
- setWatchedAt(new Date())
- setWatchMedium(undefined)
- }
-
- function handleClose() {
- onOpenChange(false)
- reset()
- }
-
- function handleSubmit() {
- if (!movie || !rating) return
- logMutation.mutate(
- {
- external_metadata_id: movie.external_metadata_id,
- manual_title: movie.title,
- manual_release_year: movie.release_year,
- manual_director: movie.director,
- rating,
- comment: comment || undefined,
- watched_at: watchedAt.toISOString().replace("Z", "").split(".")[0]!,
- watch_medium: watchMedium,
- },
- {
- onSuccess: () => {
- hapticMedium()
- toast.success(t("logReview.logged", { title: movie.title }))
- handleClose()
- },
- },
- )
- }
-
- if (open && !movie) {
- return setMovie(m)} />
- }
-
- return (
- !o && handleClose()}>
-
- {t("logReview.title")}
-
- {movie && (
- <>
-
-
- {movie.poster_path &&
})
}
-
-
-
{movie.title}
-
{movie.release_year}{movie.director && ` · ${movie.director}`}
- {movie.genres.length > 0 &&
{movie.genres.join(", ")}
}
-
-
-
-
-
-
- >
- )}
-
-
-
- )
-}
diff --git a/spa/src/components/profile-view.tsx b/spa/src/components/profile-view.tsx
index 5f2be06..4165a5c 100644
--- a/spa/src/components/profile-view.tsx
+++ b/spa/src/components/profile-view.tsx
@@ -12,9 +12,9 @@ import { MovieCard } from "@/components/movie-card"
import { EmptyState } from "@/components/empty-state"
import { SwipeTabs } from "@/components/swipe-tabs"
import { VirtualList } from "@/components/virtual-list"
-import { useInfiniteDiary } from "@/hooks/use-diary"
+import { useInfiniteDiary } from "@/features/diary"
import { TimeAgo } from "@/components/time-ago"
-import type { UserProfileResponse } from "@/lib/api/users"
+import type { UserProfileResponse } from "@/features/users"
type ProfileViewProps = {
data: UserProfileResponse
diff --git a/spa/src/components/queue-tab.tsx b/spa/src/components/queue-tab.tsx
new file mode 100644
index 0000000..b244c3b
--- /dev/null
+++ b/spa/src/components/queue-tab.tsx
@@ -0,0 +1,73 @@
+import { useState } from "react"
+import { useTranslation } from "react-i18next"
+import { Inbox } from "lucide-react"
+import { EmptyState } from "@/components/empty-state"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { StarRating } from "@/components/star-rating"
+import { useWatchQueue, useConfirmWatch, useDismissWatch } from "@/features/webhooks"
+import { FeedSkeleton } from "@/components/feed-tab"
+
+export function QueueTab() {
+ const { t } = useTranslation()
+ const { data, isPending } = useWatchQueue()
+ const confirmMutation = useConfirmWatch()
+ const dismissMutation = useDismissWatch()
+ const [ratings, setRatings] = useState>({})
+ const [comments, setComments] = useState>({})
+
+ if (isPending) return
+ if (!data?.length)
+ return
+
+ return (
+
+ {data.map((entry) => (
+
+
{entry.title}
+
+ {entry.year && `${entry.year} · `}{entry.source} · {entry.watched_at}
+
+
+ setRatings((p) => ({ ...p, [entry.id]: v }))}
+ size="sm"
+ />
+
+
+ ))}
+
+ )
+}
diff --git a/spa/src/components/review-sheet.tsx b/spa/src/components/review-sheet.tsx
new file mode 100644
index 0000000..2d13412
--- /dev/null
+++ b/spa/src/components/review-sheet.tsx
@@ -0,0 +1,241 @@
+import { useState } from "react"
+import { useTranslation } from "react-i18next"
+import { VisuallyHidden } from "radix-ui"
+import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"
+import { Button } from "@/components/ui/button"
+import { ReviewFormFields } from "@/components/review-form-fields"
+import { SearchOverlay } from "@/components/search-overlay"
+import type { MovieSelection } from "@/components/search-overlay"
+import { useLogReview, useEditReview } from "@/features/diary"
+import { toast } from "sonner"
+import { posterUrl } from "@/lib/api/client"
+import { hapticMedium } from "@/lib/haptics"
+import { parseLocalDate, formatLocalDateTime } from "@/lib/date"
+import type { EditReviewRequest } from "@/features/diary"
+import type { MovieDto, ReviewDto } from "@/lib/api/common"
+
+type LogMode = {
+ mode: "log"
+}
+
+type EditMode = {
+ mode: "edit"
+ movie: MovieDto
+ review: ReviewDto
+}
+
+type ReviewSheetProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+} & (LogMode | EditMode)
+
+export function ReviewSheet(props: ReviewSheetProps) {
+ if (props.mode === "log") {
+ return
+ }
+ return (
+
+ )
+}
+
+function LogMode({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
+ const { t } = useTranslation()
+ const [movie, setMovie] = useState(null)
+ const [rating, setRating] = useState(0)
+ const [comment, setComment] = useState("")
+ const [watchedAt, setWatchedAt] = useState(new Date())
+ const [watchMedium, setWatchMedium] = useState()
+ const logMutation = useLogReview()
+
+ function reset() {
+ setMovie(null)
+ setRating(0)
+ setComment("")
+ setWatchedAt(new Date())
+ setWatchMedium(undefined)
+ }
+
+ function handleClose() {
+ onOpenChange(false)
+ reset()
+ }
+
+ function handleSubmit() {
+ if (!movie || !rating) return
+ logMutation.mutate(
+ {
+ external_metadata_id: movie.external_metadata_id,
+ manual_title: movie.title,
+ manual_release_year: movie.release_year,
+ manual_director: movie.director,
+ rating,
+ comment: comment || undefined,
+ watched_at: watchedAt.toISOString().replace("Z", "").split(".")[0]!,
+ watch_medium: watchMedium,
+ },
+ {
+ onSuccess: () => {
+ hapticMedium()
+ toast.success(t("logReview.logged", { title: movie.title }))
+ handleClose()
+ },
+ },
+ )
+ }
+
+ if (open && !movie) {
+ return setMovie(m)} />
+ }
+
+ return (
+ !o && handleClose()}>
+
+ {t("logReview.title")}
+
+ {movie && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+
+
+ )
+}
+
+function EditMode({
+ open,
+ onOpenChange,
+ movie,
+ review,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ movie: MovieDto
+ review: ReviewDto
+}) {
+ const { t } = useTranslation()
+ const [rating, setRating] = useState(review.rating)
+ const [comment, setComment] = useState(review.comment ?? "")
+ const [watchedAt, setWatchedAt] = useState(() => parseLocalDate(review.watched_at))
+ const [dateChanged, setDateChanged] = useState(false)
+ const [watchMedium, setWatchMedium] = useState(review.watch_medium)
+ const editMutation = useEditReview()
+
+ function handleDateChange(d: Date) {
+ setWatchedAt(d)
+ setDateChanged(true)
+ }
+
+ function handleSubmit() {
+ if (!rating) return
+
+ const data: Partial = {}
+ if (rating !== review.rating) data.rating = rating
+ const newComment = comment || null
+ if (newComment !== (review.comment ?? null)) data.comment = newComment
+ if (dateChanged) data.watched_at = formatLocalDateTime(watchedAt)
+ if (watchMedium !== review.watch_medium) data.watch_medium = watchMedium ?? null
+
+ if (Object.keys(data).length === 0) {
+ toast.info(t("editReview.noChanges"))
+ onOpenChange(false)
+ return
+ }
+
+ editMutation.mutate(
+ { id: review.id, data },
+ {
+ onSuccess: () => {
+ hapticMedium()
+ toast.success(t("editReview.saved", { title: movie.title }))
+ onOpenChange(false)
+ },
+ },
+ )
+ }
+
+ return (
+
+
+ {t("editReview.title")}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function MovieHeader({
+ title,
+ releaseYear,
+ director,
+ posterPath,
+ genres,
+}: {
+ title: string
+ releaseYear?: number
+ director?: string | null
+ posterPath?: string | null
+ genres?: string[]
+}) {
+ return (
+
+
+ {posterPath &&
})
}
+
+
+
{title}
+
{releaseYear}{director && ` · ${director}`}
+ {genres && genres.length > 0 &&
{genres.join(", ")}
}
+
+
+ )
+}
diff --git a/spa/src/components/search-overlay.tsx b/spa/src/components/search-overlay.tsx
index 35c4c48..4301f4a 100644
--- a/spa/src/components/search-overlay.tsx
+++ b/spa/src/components/search-overlay.tsx
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
-import { useSearch } from "@/hooks/use-search"
+import { useSearch } from "@/features/search"
import { useDebounce } from "@/hooks/use-debounce"
import { posterUrl } from "@/lib/api/client"
diff --git a/spa/src/components/viewing-history.tsx b/spa/src/components/viewing-history.tsx
new file mode 100644
index 0000000..0fbeecd
--- /dev/null
+++ b/spa/src/components/viewing-history.tsx
@@ -0,0 +1,36 @@
+import { useTranslation } from "react-i18next"
+import { TrendingUp } from "lucide-react"
+import { StarDisplay } from "@/components/star-display"
+import { shortDate } from "@/lib/date"
+import type { ReviewHistoryResponse } from "@/features/movies"
+
+export function ViewingHistory({ history }: { history: ReviewHistoryResponse }) {
+ const { t } = useTranslation()
+
+ if (history.viewings.length === 0) return null
+
+ return (
+
+ {t("movie.yourHistory")}
+
+ {history.trend && (
+
+
+ {t("movie.trend", { trend: history.trend })}
+
+ )}
+ {history.viewings.map((v) => (
+
+
+
{shortDate(v.watched_at)}
+ {v.comment && (
+
{v.comment}
+ )}
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/spa/src/components/watchlist-tab.tsx b/spa/src/components/watchlist-tab.tsx
new file mode 100644
index 0000000..7d52673
--- /dev/null
+++ b/spa/src/components/watchlist-tab.tsx
@@ -0,0 +1,74 @@
+import { useCallback, useState } from "react"
+import { useTranslation } from "react-i18next"
+import { Clapperboard, Plus } from "lucide-react"
+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 { SearchOverlay } from "@/components/search-overlay"
+import type { MovieSelection } from "@/components/search-overlay"
+import { useInfiniteWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from "@/features/watchlist"
+import { FeedSkeleton } from "@/components/feed-tab"
+
+export 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 (
+
+
+
+ {searchOpen && (
+
setSearchOpen(false)} onSelect={handleAdd} />
+ )}
+
+ {isPending && }
+
+ {!isPending && !items.length && (
+
+ )}
+
+ {items.length > 0 && (
+ (
+ removeMutation.mutate(entry.movie.id)}
+ confirmTitle={t("feed.removeFromWatchlist")}
+ confirmDescription={entry.movie.title}
+ >
+
+
+ )}
+ />
+ )}
+
+ )
+}
diff --git a/spa/src/components/wrapup-fun-facts.tsx b/spa/src/components/wrapup-fun-facts.tsx
index a776a48..1a8daec 100644
--- a/spa/src/components/wrapup-fun-facts.tsx
+++ b/spa/src/components/wrapup-fun-facts.tsx
@@ -3,7 +3,7 @@ import { Lightbulb } from "lucide-react"
import { fmtUsd } from "@/lib/format"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { RevealCard } from "@/components/reveal-card"
-import type { WrapUpReport } from "@/lib/api/wrapup"
+import type { WrapUpReport } from "@/features/wrapup"
export function FunFacts({ report, watchHours }: { report: WrapUpReport; watchHours: number }) {
const { t } = useTranslation()
diff --git a/spa/src/components/wrapup-hero.tsx b/spa/src/components/wrapup-hero.tsx
index 552572b..e6cd8f8 100644
--- a/spa/src/components/wrapup-hero.tsx
+++ b/spa/src/components/wrapup-hero.tsx
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"
import { Card, CardContent } from "@/components/ui/card"
import { RevealCard } from "@/components/reveal-card"
import { useCountUp } from "@/hooks/use-animate"
-import type { WrapUpReport } from "@/lib/api/wrapup"
+import type { WrapUpReport } from "@/features/wrapup"
export function HeroCard({ report, watchHours }: { report: WrapUpReport; watchHours: number }) {
const { t } = useTranslation()
diff --git a/spa/src/components/wrapup-rank-card.tsx b/spa/src/components/wrapup-rank-card.tsx
index b168eab..0ea7411 100644
--- a/spa/src/components/wrapup-rank-card.tsx
+++ b/spa/src/components/wrapup-rank-card.tsx
@@ -4,7 +4,7 @@ import { Users } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { tmdbProfileUrl } from "@/lib/api/client"
-import type { PersonStat } from "@/lib/api/wrapup"
+import type { PersonStat } from "@/features/wrapup"
export function RankCard({ title, subtitle, items, profilePaths }: { title: string; subtitle: string; items: PersonStat[]; profilePaths?: string[] }) {
const { t } = useTranslation()
diff --git a/spa/src/components/wrapup-share-card.tsx b/spa/src/components/wrapup-share-card.tsx
index 3b37e3f..dfa3523 100644
--- a/spa/src/components/wrapup-share-card.tsx
+++ b/spa/src/components/wrapup-share-card.tsx
@@ -4,7 +4,7 @@ import { Download, Share2, X } from "lucide-react"
import html2canvas from "html2canvas-pro"
import { Button } from "@/components/ui/button"
import { posterUrl } from "@/lib/api/client"
-import type { WrapUpReport } from "@/lib/api/wrapup"
+import type { WrapUpReport } from "@/features/wrapup"
const logoSrc = `${import.meta.env.BASE_URL}logo.webp`
const bgSrc = `${import.meta.env.BASE_URL}shareable_bg.jpg`
diff --git a/spa/src/lib/api/auth.ts b/spa/src/features/auth.ts
similarity index 50%
rename from spa/src/lib/api/auth.ts
rename to spa/src/features/auth.ts
index ec32020..4ad8349 100644
--- a/spa/src/lib/api/auth.ts
+++ b/spa/src/features/auth.ts
@@ -1,5 +1,8 @@
import { z } from "zod"
-import { API_URL, post } from "./client"
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { useAuth } from "@/components/auth-provider"
+import { API_URL, post } from "@/lib/api/client"
+import { getRefreshToken } from "@/lib/auth"
export const loginRequestSchema = z.object({
email: z.string(),
@@ -24,20 +27,20 @@ export const registerRequestSchema = z.object({
})
export type RegisterRequest = z.infer
-export function login(data: LoginRequest) {
- return post("/auth/login", data)
-}
-
-export function register(data: RegisterRequest) {
- return post("/auth/register", data)
-}
-
export type RefreshResponse = {
token: string
refresh_token: string
expires_at: string
}
+function login(data: LoginRequest) {
+ return post("/auth/login", data)
+}
+
+function register(data: RegisterRequest) {
+ return post("/auth/register", data)
+}
+
export async function refreshToken(
refresh_token: string,
): Promise {
@@ -50,6 +53,48 @@ export async function refreshToken(
return res.json()
}
-export function apiLogout(refresh_token: string) {
+function apiLogout(refresh_token: string) {
return post("/auth/logout", { refresh_token })
}
+
+export function useLogin() {
+ const { login: setAuth } = useAuth()
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: LoginRequest) => login(data),
+ onSuccess: (res) => {
+ setAuth({
+ token: res.token,
+ refresh_token: res.refresh_token,
+ user_id: res.user_id,
+ email: res.email,
+ role: res.role,
+ expires_at: res.expires_at,
+ })
+ qc.clear()
+ },
+ })
+}
+
+export function useRegister() {
+ return useMutation({
+ mutationFn: (data: RegisterRequest) => register(data),
+ })
+}
+
+export function useLogout() {
+ const { logout } = useAuth()
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: async () => {
+ const rt = getRefreshToken()
+ if (rt) {
+ try {
+ await apiLogout(rt)
+ } catch {}
+ }
+ logout()
+ qc.clear()
+ },
+ })
+}
diff --git a/spa/src/features/diary.ts b/spa/src/features/diary.ts
new file mode 100644
index 0000000..6a12beb
--- /dev/null
+++ b/spa/src/features/diary.ts
@@ -0,0 +1,177 @@
+import { z } from "zod"
+import {
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query"
+import type { DiaryEntryDto, Paginated } from "@/lib/api/common"
+import { diaryEntryDtoSchema, movieDtoSchema, paginatedSchema, reviewDtoSchema } from "@/lib/api/common"
+import { del, get, patch, post } from "@/lib/api/client"
+
+const PAGE_SIZE = 20
+
+export const diaryQueryParamsSchema = z.object({
+ limit: z.number().optional(),
+ offset: z.number().optional(),
+ sort_by: z.string().optional(),
+ movie_id: z.string().uuid().optional(),
+ user_id: z.string().uuid().optional(),
+})
+export type DiaryQueryParams = z.infer
+
+export const diaryResponseSchema = paginatedSchema(diaryEntryDtoSchema)
+export type DiaryResponse = Paginated
+
+export const logReviewRequestSchema = z.object({
+ external_metadata_id: z.string().optional(),
+ manual_title: z.string().optional(),
+ manual_release_year: z.number().optional(),
+ manual_director: z.string().optional(),
+ rating: z.number(),
+ comment: z.string().optional(),
+ watched_at: z.string(),
+ watch_medium: z.string().optional(),
+})
+export type LogReviewRequest = z.infer
+
+export const editReviewRequestSchema = z.object({
+ rating: z.number().optional(),
+ comment: z.string().nullable().optional(),
+ watched_at: z.string().optional(),
+ watch_medium: z.string().nullable().optional(),
+})
+export type EditReviewRequest = z.infer
+
+export const feedEntryDtoSchema = z.object({
+ movie: movieDtoSchema,
+ review: reviewDtoSchema,
+ user_id: z.string().uuid(),
+ user_display_name: z.string(),
+ is_federated: z.boolean(),
+ actor_url: z.string().optional(),
+})
+export type FeedEntryDto = z.infer
+
+export const activityFeedQueryParamsSchema = z.object({
+ limit: z.number().optional(),
+ offset: z.number().optional(),
+ sort_by: z.string().optional(),
+})
+export type ActivityFeedQueryParams = z.infer
+
+export const activityFeedResponseSchema = paginatedSchema(feedEntryDtoSchema)
+export type ActivityFeedResponse = Paginated
+
+export const exportQueryParamsSchema = z.object({
+ format: z.string().optional(),
+})
+export type ExportQueryParams = z.infer
+
+function getDiary(params?: DiaryQueryParams) {
+ return get("/diary", params)
+}
+
+export function logReview(data: LogReviewRequest) {
+ return post("/reviews", data)
+}
+
+export function editReview(id: string, data: EditReviewRequest) {
+ return patch(`/reviews/${id}`, data)
+}
+
+function deleteReview(id: string) {
+ return del(`/reviews/${id}`)
+}
+
+function getActivityFeed(params?: ActivityFeedQueryParams) {
+ return get("/activity-feed", params)
+}
+
+export function exportDiary(params?: ExportQueryParams) {
+ return get("/diary/export", params)
+}
+
+export const diaryKeys = {
+ all: ["diary"] as const,
+ list: (params?: Partial) => [...diaryKeys.all, "list", params] as const,
+ infinite: (params?: Partial) => [...diaryKeys.all, "infinite", params] as const,
+ feed: (params?: ActivityFeedQueryParams) =>
+ ["activity-feed", params] as const,
+}
+
+export function useDiary(params?: DiaryQueryParams) {
+ return useQuery({
+ queryKey: diaryKeys.list(params),
+ queryFn: () => getDiary(params),
+ })
+}
+
+export function useInfiniteDiary(params?: Omit) {
+ return useInfiniteQuery({
+ queryKey: diaryKeys.infinite(params),
+ queryFn: ({ pageParam = 0 }) =>
+ getDiary({ ...params, limit: PAGE_SIZE, offset: pageParam }),
+ initialPageParam: 0,
+ getNextPageParam: (last) => {
+ const next = last.offset + last.limit
+ return next < last.total_count ? next : undefined
+ },
+ })
+}
+
+export function useActivityFeed(params?: ActivityFeedQueryParams) {
+ return useQuery({
+ queryKey: diaryKeys.feed(params),
+ queryFn: () => getActivityFeed(params),
+ })
+}
+
+export function useInfiniteActivityFeed(
+ params?: Omit,
+) {
+ return useInfiniteQuery({
+ queryKey: diaryKeys.feed(params),
+ queryFn: ({ pageParam = 0 }) =>
+ getActivityFeed({ ...params, limit: PAGE_SIZE, offset: pageParam }),
+ initialPageParam: 0,
+ getNextPageParam: (last) => {
+ const next = last.offset + last.limit
+ return next < last.total_count ? next : undefined
+ },
+ })
+}
+
+export function useLogReview() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: LogReviewRequest) => logReview(data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: diaryKeys.all })
+ qc.invalidateQueries({ queryKey: ["activity-feed"] })
+ },
+ })
+}
+
+export function useEditReview() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: ({ id, data }: { id: string; data: EditReviewRequest }) =>
+ editReview(id, data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: diaryKeys.all })
+ qc.invalidateQueries({ queryKey: ["activity-feed"] })
+ },
+ })
+}
+
+export function useDeleteReview() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (id: string) => deleteReview(id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: diaryKeys.all })
+ qc.invalidateQueries({ queryKey: ["activity-feed"] })
+ },
+ })
+}
diff --git a/spa/src/hooks/use-goals.ts b/spa/src/features/goals.ts
similarity index 57%
rename from spa/src/hooks/use-goals.ts
rename to spa/src/features/goals.ts
index 79afa51..8b166fe 100644
--- a/spa/src/hooks/use-goals.ts
+++ b/spa/src/features/goals.ts
@@ -1,19 +1,62 @@
+import { z } from "zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
-import {
- getGoals,
- getUserGoals,
- createGoal,
- updateGoal,
- deleteGoal,
- getSettings,
- updateSettings,
-} from "@/lib/api/goals"
-import type {
- CreateGoalRequest,
- UpdateGoalRequest,
- UpdateUserSettingsRequest,
-} from "@/lib/api/goals"
-import { userKeys } from "@/hooks/use-users"
+import { get, post, put, del } from "@/lib/api/client"
+import { goalDtoSchema, userKeys } from "@/features/users"
+
+export const goalsResponseSchema = z.object({
+ goals: z.array(goalDtoSchema),
+})
+export type GoalsResponse = z.infer
+
+export type CreateGoalRequest = {
+ year: number
+ target_count: number
+}
+
+export type UpdateGoalRequest = {
+ target_count: number
+}
+
+export const userSettingsDtoSchema = z.object({
+ federate_goals: z.boolean(),
+ federate_reviews: z.boolean(),
+ federate_watchlist: z.boolean(),
+})
+export type UserSettingsDto = z.infer
+
+export type UpdateUserSettingsRequest = {
+ federate_goals: boolean
+ federate_reviews: boolean
+ federate_watchlist: boolean
+}
+
+function getGoals() {
+ return get("/goals")
+}
+
+function getUserGoals(userId: string) {
+ return get(`/users/${userId}/goals`)
+}
+
+function createGoal(data: CreateGoalRequest) {
+ return post>("/goals", data)
+}
+
+function updateGoal(year: number, data: UpdateGoalRequest) {
+ return put>(`/goals/${year}`, data)
+}
+
+function deleteGoal(year: number) {
+ return del(`/goals/${year}`)
+}
+
+function getSettings() {
+ return get("/settings")
+}
+
+function updateSettings(data: UpdateUserSettingsRequest) {
+ return put("/settings", data)
+}
export const goalKeys = {
all: ["goals"] as const,
diff --git a/spa/src/features/imports.ts b/spa/src/features/imports.ts
new file mode 100644
index 0000000..49553da
--- /dev/null
+++ b/spa/src/features/imports.ts
@@ -0,0 +1,188 @@
+import { z } from "zod"
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { del, get, post, put, uploadWithFields } from "@/lib/api/client"
+
+export const sessionCreatedResponseSchema = z.object({
+ session_id: z.string(),
+ columns: z.array(z.string()),
+ sample_rows: z.array(z.array(z.string())),
+})
+export type SessionCreatedResponse = z.infer
+
+export const sessionStateResponseSchema = z.object({
+ session_id: z.string(),
+ columns: z.array(z.string()),
+ has_mappings: z.boolean(),
+ row_count: z.number(),
+})
+export type SessionStateResponse = z.infer
+
+export const apiFieldMappingSchema = z.object({
+ source_column: z.string(),
+ domain_field: z.string(),
+ rating_scale: z.number().optional(),
+ date_format: z.string().optional(),
+})
+export type ApiFieldMapping = z.infer
+
+export const applyMappingRequestSchema = z.object({
+ mappings: z.array(apiFieldMappingSchema),
+})
+export type ApplyMappingRequest = z.infer
+
+export const confirmRequestSchema = z.object({
+ confirmed_indices: z.array(z.number()),
+})
+export type ConfirmRequest = z.infer
+
+export const saveProfileRequestSchema = z.object({
+ session_id: z.string(),
+ name: z.string(),
+})
+export type SaveProfileRequest = z.infer
+
+export type PreviewRow = {
+ index: number
+ status: string
+ title?: string
+ release_year?: string
+ director?: string
+ rating?: string
+ watched_at?: string
+ comment?: string
+ errors?: string[]
+}
+
+export type PreviewResponse = {
+ rows: PreviewRow[]
+}
+
+export type ImportProfile = {
+ id: string
+ name: string
+ created_at: string
+}
+
+function createImportSession(file: File) {
+ const ext = file.name.split(".").pop()?.toLowerCase()
+ const format = ext === "json" ? "json" : "csv"
+ return uploadWithFields("/import/sessions", file, { format })
+}
+
+function getImportSession(id: string) {
+ return get(`/import/sessions/${id}`)
+}
+
+function getImportPreview(id: string) {
+ return get(`/import/sessions/${id}/preview`)
+}
+
+function applyMapping(sessionId: string, data: ApplyMappingRequest) {
+ return put(`/import/sessions/${sessionId}/mapping`, data)
+}
+
+function confirmImport(sessionId: string, data: ConfirmRequest) {
+ return post(`/import/sessions/${sessionId}/confirm`, data)
+}
+
+function getImportProfiles() {
+ return get("/import/profiles")
+}
+
+function saveImportProfile(data: SaveProfileRequest) {
+ return post<{ id: string }>("/import/profiles", data)
+}
+
+function deleteImportProfile(id: string) {
+ return del(`/import/profiles/${id}`)
+}
+
+function applyImportProfile(sessionId: string, profileId: string) {
+ return put<{ row_count: number }>(`/import/sessions/${sessionId}/profile/${profileId}`)
+}
+
+export const importKeys = {
+ session: (id: string) => ["import-session", id] as const,
+ preview: (id: string) => ["import-preview", id] as const,
+ profiles: ["import-profiles"] as const,
+}
+
+export function useImportPreview(id: string) {
+ return useQuery({
+ queryKey: importKeys.preview(id),
+ queryFn: () => getImportPreview(id),
+ enabled: !!id,
+ })
+}
+
+export function useCreateImportSession() {
+ return useMutation({
+ mutationFn: (file: File) => createImportSession(file),
+ })
+}
+
+export function useImportSession(id: string) {
+ return useQuery({
+ queryKey: importKeys.session(id),
+ queryFn: () => getImportSession(id),
+ enabled: !!id,
+ })
+}
+
+export function useApplyMapping() {
+ return useMutation({
+ mutationFn: ({
+ sessionId,
+ data,
+ }: {
+ sessionId: string
+ data: ApplyMappingRequest
+ }) => applyMapping(sessionId, data),
+ })
+}
+
+export function useConfirmImport() {
+ return useMutation({
+ mutationFn: ({
+ sessionId,
+ data,
+ }: {
+ sessionId: string
+ data: ConfirmRequest
+ }) => confirmImport(sessionId, data),
+ })
+}
+
+export function useImportProfiles() {
+ return useQuery({
+ queryKey: importKeys.profiles,
+ queryFn: getImportProfiles,
+ })
+}
+
+export function useSaveImportProfile() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: SaveProfileRequest) => saveImportProfile(data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: importKeys.profiles })
+ },
+ })
+}
+
+export function useDeleteImportProfile() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (id: string) => deleteImportProfile(id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: importKeys.profiles })
+ },
+ })
+}
+
+export function useApplyImportProfile() {
+ return useMutation({
+ mutationFn: ({ sessionId, profileId }: { sessionId: string; profileId: string }) =>
+ applyImportProfile(sessionId, profileId),
+ })
+}
diff --git a/spa/src/lib/api/movies.ts b/spa/src/features/movies.ts
similarity index 67%
rename from spa/src/lib/api/movies.ts
rename to spa/src/features/movies.ts
index a23b15d..b4e21ab 100644
--- a/spa/src/lib/api/movies.ts
+++ b/spa/src/features/movies.ts
@@ -1,7 +1,8 @@
import { z } from "zod"
-import type { Paginated } from "./common"
-import { movieDtoSchema, paginatedSchema, reviewDtoSchema } from "./common"
-import { get, post } from "./client"
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import type { Paginated } from "@/lib/api/common"
+import { movieDtoSchema, paginatedSchema, reviewDtoSchema } from "@/lib/api/common"
+import { get, post } from "@/lib/api/client"
export const moviesQueryParamsSchema = z.object({
limit: z.number().optional(),
@@ -100,22 +101,71 @@ export const movieProfileResponseSchema = z.object({
})
export type MovieProfileResponse = z.infer
-export function getMovies(params?: MoviesQueryParams) {
+function getMovies(params?: MoviesQueryParams) {
return get("/movies", params)
}
-export function getMovie(id: string) {
+function getMovie(id: string) {
return get(`/movies/${id}`)
}
-export function getMovieHistory(id: string) {
+function getMovieHistory(id: string) {
return get(`/movies/${id}/history`)
}
-export function getMovieProfile(id: string) {
+function getMovieProfile(id: string) {
return get(`/movies/${id}/profile`)
}
-export function syncPoster(id: string) {
+function syncPoster(id: string) {
return post(`/movies/${id}/sync-poster`)
}
+
+export const movieKeys = {
+ all: ["movies"] as const,
+ list: (params?: MoviesQueryParams) => [...movieKeys.all, params] as const,
+ detail: (id: string) => [...movieKeys.all, id] as const,
+ history: (id: string) => [...movieKeys.all, id, "history"] as const,
+ profile: (id: string) => [...movieKeys.all, id, "profile"] as const,
+}
+
+export function useMovies(params?: MoviesQueryParams) {
+ return useQuery({
+ queryKey: movieKeys.list(params),
+ queryFn: () => getMovies(params),
+ })
+}
+
+export function useMovie(id: string) {
+ return useQuery({
+ queryKey: movieKeys.detail(id),
+ queryFn: () => getMovie(id),
+ enabled: !!id,
+ })
+}
+
+export function useMovieHistory(id: string) {
+ return useQuery({
+ queryKey: movieKeys.history(id),
+ queryFn: () => getMovieHistory(id),
+ enabled: !!id,
+ })
+}
+
+export function useMovieProfile(id: string) {
+ return useQuery({
+ queryKey: movieKeys.profile(id),
+ queryFn: () => getMovieProfile(id),
+ enabled: !!id,
+ })
+}
+
+export function useSyncPoster() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (id: string) => syncPoster(id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: movieKeys.all })
+ },
+ })
+}
diff --git a/spa/src/lib/api/search.ts b/spa/src/features/search.ts
similarity index 62%
rename from spa/src/lib/api/search.ts
rename to spa/src/features/search.ts
index 4e72988..c2dd0e9 100644
--- a/spa/src/lib/api/search.ts
+++ b/spa/src/features/search.ts
@@ -1,6 +1,9 @@
import { z } from "zod"
-import { paginatedSchema } from "./common"
-import { get } from "./client"
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
+import { paginatedSchema } from "@/lib/api/common"
+import { get } from "@/lib/api/client"
+
+const PAGE_SIZE = 20
export const searchQueryParamsSchema = z.object({
q: z.string().optional(),
@@ -82,14 +85,61 @@ export const personCreditsDtoSchema = z.object({
})
export type PersonCreditsDto = z.infer
-export function search(params: SearchQueryParams) {
+function search(params: SearchQueryParams) {
return get("/search", params)
}
-export function getPerson(id: string) {
+function getPerson(id: string) {
return get(`/people/${id}`)
}
-export function getPersonCredits(id: string) {
+function getPersonCredits(id: string) {
return get(`/people/${id}/credits`)
}
+
+export const searchKeys = {
+ all: ["search"] as const,
+ query: (params: SearchQueryParams) => [...searchKeys.all, params] as const,
+ person: (id: string) => ["people", id] as const,
+ personCredits: (id: string) => ["people", id, "credits"] as const,
+}
+
+export function useSearch(params: SearchQueryParams) {
+ return useQuery({
+ queryKey: searchKeys.query(params),
+ queryFn: () => search(params),
+ enabled: !!params.q || !!params.genre || !!params.person_id,
+ })
+}
+
+export function useInfiniteSearch(
+ params: Omit,
+) {
+ return useInfiniteQuery({
+ queryKey: searchKeys.query(params),
+ queryFn: ({ pageParam = 0 }) =>
+ search({ ...params, limit: PAGE_SIZE, offset: pageParam }),
+ initialPageParam: 0,
+ getNextPageParam: (last) => {
+ const next = last.movies.offset + last.movies.limit
+ return next < last.movies.total_count ? next : undefined
+ },
+ enabled: !!params.q || !!params.genre || !!params.person_id,
+ })
+}
+
+export function usePerson(id: string) {
+ return useQuery({
+ queryKey: searchKeys.person(id),
+ queryFn: () => getPerson(id),
+ enabled: !!id,
+ })
+}
+
+export function usePersonCredits(id: string) {
+ return useQuery({
+ queryKey: searchKeys.personCredits(id),
+ queryFn: () => getPersonCredits(id),
+ enabled: !!id,
+ })
+}
diff --git a/spa/src/hooks/use-social.ts b/spa/src/features/social.ts
similarity index 57%
rename from spa/src/hooks/use-social.ts
rename to spa/src/features/social.ts
index 8fcaabf..c0dc8e9 100644
--- a/spa/src/hooks/use-social.ts
+++ b/spa/src/features/social.ts
@@ -1,27 +1,113 @@
+import { z } from "zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
-import {
- acceptFollower,
- addBlockedDomain,
- blockActor,
- follow,
- getBlockedActors,
- getBlockedDomains,
- getFollowers,
- getFollowing,
- getPendingFollowers,
- getUserFollowers,
- getUserFollowing,
- rejectFollower,
- removeBlockedDomain,
- removeFollower,
- unblockActor,
- unfollow,
-} from "@/lib/api/social"
-import type {
- ActorUrlRequest,
- AddBlockedDomainRequest,
- FollowRequest,
-} from "@/lib/api/social"
+import { del, get, post } from "@/lib/api/client"
+
+export const remoteActorDtoSchema = z.object({
+ handle: z.string(),
+ display_name: z.string().optional(),
+ url: z.string(),
+})
+export type RemoteActorDto = z.infer
+
+export const actorListResponseSchema = z.object({
+ actors: z.array(remoteActorDtoSchema),
+})
+export type ActorListResponse = z.infer
+
+export const followRequestSchema = z.object({
+ handle: z.string(),
+})
+export type FollowRequest = z.infer
+
+export const actorUrlRequestSchema = z.object({
+ actor_url: z.string(),
+})
+export type ActorUrlRequest = z.infer
+
+export const blockedDomainResponseSchema = z.object({
+ domain: z.string(),
+ reason: z.string().optional(),
+ blocked_at: z.string(),
+})
+export type BlockedDomainResponse = z.infer
+
+export const addBlockedDomainRequestSchema = z.object({
+ domain: z.string(),
+ reason: z.string().optional(),
+})
+export type AddBlockedDomainRequest = z.infer
+
+export const blockedActorResponseSchema = z.object({
+ url: z.string(),
+ handle: z.string(),
+ display_name: z.string().optional(),
+ avatar_url: z.string().optional(),
+})
+export type BlockedActorResponse = z.infer
+
+function getFollowing() {
+ return get("/social/following")
+}
+
+function getFollowers() {
+ return get("/social/followers")
+}
+
+function getUserFollowing(userId: string) {
+ return get(`/users/${userId}/following`)
+}
+
+function getUserFollowers(userId: string) {
+ return get(`/users/${userId}/followers`)
+}
+
+function getPendingFollowers() {
+ return get("/social/followers/pending")
+}
+
+function follow(data: FollowRequest) {
+ return post("/social/follow", data)
+}
+
+function unfollow(data: ActorUrlRequest) {
+ return post("/social/unfollow", data)
+}
+
+function acceptFollower(data: ActorUrlRequest) {
+ return post("/social/followers/accept", data)
+}
+
+function rejectFollower(data: ActorUrlRequest) {
+ return post("/social/followers/reject", data)
+}
+
+function removeFollower(data: ActorUrlRequest) {
+ return post("/social/followers/remove", data)
+}
+
+function getBlockedDomains() {
+ return get("/admin/blocked-domains")
+}
+
+function addBlockedDomain(data: AddBlockedDomainRequest) {
+ return post("/admin/blocked-domains", data)
+}
+
+function removeBlockedDomain(domain: string) {
+ return del(`/admin/blocked-domains/${domain}`)
+}
+
+function blockActor(data: ActorUrlRequest) {
+ return post("/social/block", data)
+}
+
+function unblockActor(data: ActorUrlRequest) {
+ return post("/social/unblock", data)
+}
+
+function getBlockedActors() {
+ return get("/social/blocked")
+}
export const socialKeys = {
following: ["following"] as const,
diff --git a/spa/src/lib/api/users.ts b/spa/src/features/users.ts
similarity index 73%
rename from spa/src/lib/api/users.ts
rename to spa/src/features/users.ts
index a3b4ef3..aa35453 100644
--- a/spa/src/lib/api/users.ts
+++ b/spa/src/features/users.ts
@@ -1,6 +1,7 @@
import { z } from "zod"
-import { diaryEntryDtoSchema, paginatedSchema } from "./common"
-import { get, post, put, putForm } from "./client"
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { diaryEntryDtoSchema, paginatedSchema } from "@/lib/api/common"
+import { get, post, put, putForm } from "@/lib/api/client"
export const userSummaryDtoSchema = z.object({
id: z.string().uuid(),
@@ -62,8 +63,6 @@ export const monthActivityDtoSchema = z.object({
})
export type MonthActivityDto = z.infer
-const userDiaryResponseSchema = paginatedSchema(diaryEntryDtoSchema)
-
export const goalDtoSchema = z.object({
year: z.number(),
target_count: z.number(),
@@ -74,6 +73,8 @@ export const goalDtoSchema = z.object({
})
export type GoalDto = z.infer
+const userDiaryResponseSchema = paginatedSchema(diaryEntryDtoSchema)
+
export const userProfileResponseSchema = z.object({
user_id: z.string().uuid(),
username: z.string(),
@@ -116,18 +117,6 @@ export const updateProfileFieldsRequestSchema = z.object({
})
export type UpdateProfileFieldsRequest = z.infer
-export function getUsers() {
- return get("/users")
-}
-
-export function getUserProfile(id: string, params?: UserProfileQueryParams) {
- return get(`/users/${id}`, params)
-}
-
-export function getProfile() {
- return get("/profile")
-}
-
export type UpdateProfileData = {
display_name?: string
bio?: string
@@ -136,7 +125,19 @@ export type UpdateProfileData = {
banner?: File
}
-export function updateProfile(data: UpdateProfileData) {
+function getUsers() {
+ return get("/users")
+}
+
+function getUserProfile(id: string, params?: UserProfileQueryParams) {
+ return get(`/users/${id}`, params)
+}
+
+function getProfile() {
+ return get("/profile")
+}
+
+function updateProfile(data: UpdateProfileData) {
const form = new FormData()
if (data.display_name != null) form.append("display_name", data.display_name)
if (data.also_known_as != null) form.append("also_known_as", data.also_known_as)
@@ -146,10 +147,60 @@ export function updateProfile(data: UpdateProfileData) {
return putForm("/profile", form)
}
-export function updateProfileFields(data: UpdateProfileFieldsRequest) {
+function updateProfileFields(data: UpdateProfileFieldsRequest) {
return put("/profile/fields", data)
}
export function reindexSearch() {
return post("/admin/reindex-search")
}
+
+export const userKeys = {
+ all: ["users"] as const,
+ list: () => [...userKeys.all, "list"] as const,
+ profile: (id: string, params?: UserProfileQueryParams) =>
+ [...userKeys.all, id, params] as const,
+ me: ["profile"] as const,
+}
+
+export function useUsers() {
+ return useQuery({
+ queryKey: userKeys.list(),
+ queryFn: getUsers,
+ })
+}
+
+export function useUserProfile(id: string, params?: UserProfileQueryParams) {
+ return useQuery({
+ queryKey: userKeys.profile(id, params),
+ queryFn: () => getUserProfile(id, params),
+ enabled: !!id,
+ })
+}
+
+export function useProfile() {
+ return useQuery({
+ queryKey: userKeys.me,
+ queryFn: getProfile,
+ })
+}
+
+export function useUpdateProfile() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: UpdateProfileData) => updateProfile(data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: userKeys.me })
+ },
+ })
+}
+
+export function useUpdateProfileFields() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: UpdateProfileFieldsRequest) => updateProfileFields(data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: userKeys.me })
+ },
+ })
+}
diff --git a/spa/src/hooks/use-watchlist.ts b/spa/src/features/watchlist.ts
similarity index 51%
rename from spa/src/hooks/use-watchlist.ts
rename to spa/src/features/watchlist.ts
index ecb4b5f..103443e 100644
--- a/spa/src/hooks/use-watchlist.ts
+++ b/spa/src/features/watchlist.ts
@@ -1,19 +1,55 @@
+import { z } from "zod"
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
-import {
- addToWatchlist,
- getWatchlist,
- getWatchlistStatus,
- removeFromWatchlist,
-} from "@/lib/api/watchlist"
-import type { AddToWatchlistRequest } from "@/lib/api/watchlist"
+import type { Paginated } from "@/lib/api/common"
+import { movieDtoSchema, paginatedSchema } from "@/lib/api/common"
+import { del, get, post } from "@/lib/api/client"
const PAGE_SIZE = 20
+export const watchlistEntryDtoSchema = z.object({
+ id: z.string().uuid(),
+ movie: movieDtoSchema,
+ added_at: z.string(),
+})
+export type WatchlistEntryDto = z.infer
+
+export const watchlistResponseSchema = paginatedSchema(watchlistEntryDtoSchema)
+export type WatchlistResponse = Paginated
+
+export const addToWatchlistRequestSchema = z.object({
+ movie_id: z.string().uuid().optional(),
+ external_metadata_id: z.string().optional(),
+ manual_title: z.string().optional(),
+ manual_release_year: z.number().optional(),
+})
+export type AddToWatchlistRequest = z.infer
+
+export const watchlistStatusResponseSchema = z.object({
+ on_watchlist: z.boolean(),
+})
+export type WatchlistStatusResponse = z.infer
+
+function getWatchlist(params?: { limit?: number; offset?: number }) {
+ return get("/watchlist", params)
+}
+
+function getWatchlistStatus(movieId: string) {
+ return get(`/watchlist/${movieId}`)
+}
+
+function addToWatchlist(data: AddToWatchlistRequest) {
+ return post("/watchlist", data)
+}
+
+function removeFromWatchlist(movieId: string) {
+ return del(`/watchlist/${movieId}`)
+}
+
export const watchlistKeys = {
all: ["watchlist"] as const,
list: () => [...watchlistKeys.all, "list"] as const,
diff --git a/spa/src/lib/api/webhooks.ts b/spa/src/features/webhooks.ts
similarity index 55%
rename from spa/src/lib/api/webhooks.ts
rename to spa/src/features/webhooks.ts
index fff03ea..2c9b1f4 100644
--- a/spa/src/lib/api/webhooks.ts
+++ b/spa/src/features/webhooks.ts
@@ -1,5 +1,6 @@
import { z } from "zod"
-import { del, get, post } from "./client"
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { del, get, post } from "@/lib/api/client"
export const webhookTokenDtoSchema = z.object({
id: z.string(),
@@ -59,26 +60,85 @@ export const dismissWatchResponseSchema = z.object({
})
export type DismissWatchResponse = z.infer
-export function getWebhookTokens() {
+function getWebhookTokens() {
return get("/settings/webhook-tokens")
}
-export function generateToken(data: GenerateTokenRequest) {
+function generateToken(data: GenerateTokenRequest) {
return post("/settings/webhook-tokens", data)
}
-export function deleteToken(id: string) {
+function deleteToken(id: string) {
return del(`/settings/webhook-tokens/${id}`)
}
-export function getWatchQueue() {
+function getWatchQueue() {
return get("/watch-queue")
}
-export function confirmWatch(data: ConfirmWatchRequest) {
+function confirmWatch(data: ConfirmWatchRequest) {
return post("/watch-queue/confirm", data)
}
-export function dismissWatch(data: DismissWatchRequest) {
+function dismissWatch(data: DismissWatchRequest) {
return post("/watch-queue/dismiss", data)
}
+
+export const webhookKeys = {
+ tokens: ["webhook-tokens"] as const,
+ queue: ["watch-queue"] as const,
+}
+
+export function useWebhookTokens() {
+ return useQuery({
+ queryKey: webhookKeys.tokens,
+ queryFn: getWebhookTokens,
+ })
+}
+
+export function useGenerateToken() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: GenerateTokenRequest) => generateToken(data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: webhookKeys.tokens })
+ },
+ })
+}
+
+export function useDeleteToken() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (id: string) => deleteToken(id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: webhookKeys.tokens })
+ },
+ })
+}
+
+export function useWatchQueue() {
+ return useQuery({
+ queryKey: webhookKeys.queue,
+ queryFn: getWatchQueue,
+ })
+}
+
+export function useConfirmWatch() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: ConfirmWatchRequest) => confirmWatch(data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: webhookKeys.queue })
+ },
+ })
+}
+
+export function useDismissWatch() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: DismissWatchRequest) => dismissWatch(data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: webhookKeys.queue })
+ },
+ })
+}
diff --git a/spa/src/lib/api/wrapup.ts b/spa/src/features/wrapup.ts
similarity index 64%
rename from spa/src/lib/api/wrapup.ts
rename to spa/src/features/wrapup.ts
index cb6eda2..f43744d 100644
--- a/spa/src/lib/api/wrapup.ts
+++ b/spa/src/features/wrapup.ts
@@ -1,5 +1,6 @@
import { z } from "zod"
-import { del, get, post } from "./client"
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { del, get, post } from "@/lib/api/client"
export const generateWrapUpRequestSchema = z.object({
start_date: z.string(),
@@ -30,22 +31,6 @@ export const wrapUpListResponseSchema = z.object({
})
export type WrapUpListResponse = z.infer
-export function generateWrapUp(data: GenerateWrapUpRequest) {
- return post("/wrapups/generate", data)
-}
-
-export function getWrapUps() {
- return get("/wrapups")
-}
-
-export function getWrapUp(id: string) {
- return get(`/wrapups/${id}`)
-}
-
-export function deleteWrapUp(id: string) {
- return del(`/wrapups/${id}`)
-}
-
export type MovieRef = {
movie_id?: string
title: string
@@ -118,6 +103,72 @@ export type WrapUpReport = {
top_cast_profile_paths: string[]
}
-export function getWrapUpReport(id: string) {
+function generateWrapUp(data: GenerateWrapUpRequest) {
+ return post("/wrapups/generate", data)
+}
+
+function getWrapUps() {
+ return get("/wrapups")
+}
+
+function getWrapUp(id: string) {
+ return get(`/wrapups/${id}`)
+}
+
+function deleteWrapUp(id: string) {
+ return del(`/wrapups/${id}`)
+}
+
+function getWrapUpReport(id: string) {
return get(`/wrapups/${id}/report`)
}
+
+export const wrapupKeys = {
+ all: ["wrapups"] as const,
+ list: () => [...wrapupKeys.all, "list"] as const,
+ detail: (id: string) => [...wrapupKeys.all, id] as const,
+ report: (id: string) => [...wrapupKeys.all, id, "report"] as const,
+}
+
+export function useWrapUpReport(id: string) {
+ return useQuery({
+ queryKey: wrapupKeys.report(id),
+ queryFn: () => getWrapUpReport(id),
+ enabled: !!id,
+ })
+}
+
+export function useWrapUps() {
+ return useQuery({
+ queryKey: wrapupKeys.list(),
+ queryFn: getWrapUps,
+ })
+}
+
+export function useWrapUp(id: string) {
+ return useQuery({
+ queryKey: wrapupKeys.detail(id),
+ queryFn: () => getWrapUp(id),
+ enabled: !!id,
+ })
+}
+
+export function useGenerateWrapUp() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (data: GenerateWrapUpRequest) => generateWrapUp(data),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: wrapupKeys.all })
+ },
+ })
+}
+
+export function useDeleteWrapUp() {
+ const qc = useQueryClient()
+ return useMutation({
+ mutationFn: (id: string) => deleteWrapUp(id),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: wrapupKeys.all })
+ },
+ })
+}
diff --git a/spa/src/hooks/use-auth.ts b/spa/src/hooks/use-auth.ts
deleted file mode 100644
index 5470750..0000000
--- a/spa/src/hooks/use-auth.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { useMutation, useQueryClient } from "@tanstack/react-query"
-import { useAuth } from "@/components/auth-provider"
-import { apiLogout, login, register } from "@/lib/api/auth"
-import type { LoginRequest, RegisterRequest } from "@/lib/api/auth"
-import { getRefreshToken } from "@/lib/auth"
-
-export function useLogin() {
- const { login: setAuth } = useAuth()
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (data: LoginRequest) => login(data),
- onSuccess: (res) => {
- setAuth({
- token: res.token,
- refresh_token: res.refresh_token,
- user_id: res.user_id,
- email: res.email,
- role: res.role,
- expires_at: res.expires_at,
- })
- qc.clear()
- },
- })
-}
-
-export function useRegister() {
- return useMutation({
- mutationFn: (data: RegisterRequest) => register(data),
- })
-}
-
-export function useLogout() {
- const { logout } = useAuth()
- const qc = useQueryClient()
- return useMutation({
- mutationFn: async () => {
- const rt = getRefreshToken()
- if (rt) {
- try {
- await apiLogout(rt)
- } catch {}
- }
- logout()
- qc.clear()
- },
- })
-}
diff --git a/spa/src/hooks/use-diary.ts b/spa/src/hooks/use-diary.ts
deleted file mode 100644
index 738a492..0000000
--- a/spa/src/hooks/use-diary.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import {
- useInfiniteQuery,
- useMutation,
- useQuery,
- useQueryClient,
-} from "@tanstack/react-query"
-import {
- deleteReview,
- editReview,
- getActivityFeed,
- getDiary,
- logReview,
-} from "@/lib/api/diary"
-import type {
- ActivityFeedQueryParams,
- DiaryQueryParams,
- EditReviewRequest,
- LogReviewRequest,
-} from "@/lib/api/diary"
-
-const PAGE_SIZE = 20
-
-export const diaryKeys = {
- all: ["diary"] as const,
- list: (params?: Partial) => [...diaryKeys.all, "list", params] as const,
- infinite: (params?: Partial) => [...diaryKeys.all, "infinite", params] as const,
- feed: (params?: ActivityFeedQueryParams) =>
- ["activity-feed", params] as const,
-}
-
-export function useDiary(params?: DiaryQueryParams) {
- return useQuery({
- queryKey: diaryKeys.list(params),
- queryFn: () => getDiary(params),
- })
-}
-
-export function useInfiniteDiary(params?: Omit) {
- return useInfiniteQuery({
- queryKey: diaryKeys.infinite(params),
- queryFn: ({ pageParam = 0 }) =>
- getDiary({ ...params, limit: PAGE_SIZE, offset: pageParam }),
- initialPageParam: 0,
- getNextPageParam: (last) => {
- const next = last.offset + last.limit
- return next < last.total_count ? next : undefined
- },
- })
-}
-
-export function useActivityFeed(params?: ActivityFeedQueryParams) {
- return useQuery({
- queryKey: diaryKeys.feed(params),
- queryFn: () => getActivityFeed(params),
- })
-}
-
-export function useInfiniteActivityFeed(
- params?: Omit,
-) {
- return useInfiniteQuery({
- queryKey: diaryKeys.feed(params),
- queryFn: ({ pageParam = 0 }) =>
- getActivityFeed({ ...params, limit: PAGE_SIZE, offset: pageParam }),
- initialPageParam: 0,
- getNextPageParam: (last) => {
- const next = last.offset + last.limit
- return next < last.total_count ? next : undefined
- },
- })
-}
-
-export function useLogReview() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (data: LogReviewRequest) => logReview(data),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: diaryKeys.all })
- qc.invalidateQueries({ queryKey: ["activity-feed"] })
- },
- })
-}
-
-export function useEditReview() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: ({ id, data }: { id: string; data: EditReviewRequest }) =>
- editReview(id, data),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: diaryKeys.all })
- qc.invalidateQueries({ queryKey: ["activity-feed"] })
- },
- })
-}
-
-export function useDeleteReview() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (id: string) => deleteReview(id),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: diaryKeys.all })
- qc.invalidateQueries({ queryKey: ["activity-feed"] })
- },
- })
-}
diff --git a/spa/src/hooks/use-imports.ts b/spa/src/hooks/use-imports.ts
deleted file mode 100644
index 492a36c..0000000
--- a/spa/src/hooks/use-imports.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
-import {
- applyImportProfile,
- applyMapping,
- confirmImport,
- createImportSession,
- deleteImportProfile,
- getImportPreview,
- getImportProfiles,
- getImportSession,
- saveImportProfile,
-} from "@/lib/api/imports"
-import type {
- ApplyMappingRequest,
- ConfirmRequest,
- SaveProfileRequest,
-} from "@/lib/api/imports"
-
-export const importKeys = {
- session: (id: string) => ["import-session", id] as const,
- preview: (id: string) => ["import-preview", id] as const,
- profiles: ["import-profiles"] as const,
-}
-
-export function useImportPreview(id: string) {
- return useQuery({
- queryKey: importKeys.preview(id),
- queryFn: () => getImportPreview(id),
- enabled: !!id,
- })
-}
-
-export function useCreateImportSession() {
- return useMutation({
- mutationFn: (file: File) => createImportSession(file),
- })
-}
-
-export function useImportSession(id: string) {
- return useQuery({
- queryKey: importKeys.session(id),
- queryFn: () => getImportSession(id),
- enabled: !!id,
- })
-}
-
-export function useApplyMapping() {
- return useMutation({
- mutationFn: ({
- sessionId,
- data,
- }: {
- sessionId: string
- data: ApplyMappingRequest
- }) => applyMapping(sessionId, data),
- })
-}
-
-export function useConfirmImport() {
- return useMutation({
- mutationFn: ({
- sessionId,
- data,
- }: {
- sessionId: string
- data: ConfirmRequest
- }) => confirmImport(sessionId, data),
- })
-}
-
-export function useImportProfiles() {
- return useQuery({
- queryKey: importKeys.profiles,
- queryFn: getImportProfiles,
- })
-}
-
-export function useSaveImportProfile() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (data: SaveProfileRequest) => saveImportProfile(data),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: importKeys.profiles })
- },
- })
-}
-
-export function useDeleteImportProfile() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (id: string) => deleteImportProfile(id),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: importKeys.profiles })
- },
- })
-}
-
-export function useApplyImportProfile() {
- return useMutation({
- mutationFn: ({ sessionId, profileId }: { sessionId: string; profileId: string }) =>
- applyImportProfile(sessionId, profileId),
- })
-}
diff --git a/spa/src/hooks/use-movies.ts b/spa/src/hooks/use-movies.ts
deleted file mode 100644
index 0f4647f..0000000
--- a/spa/src/hooks/use-movies.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
-import {
- getMovie,
- getMovieHistory,
- getMovieProfile,
- getMovies,
- syncPoster,
-} from "@/lib/api/movies"
-import type { MoviesQueryParams } from "@/lib/api/movies"
-
-export const movieKeys = {
- all: ["movies"] as const,
- list: (params?: MoviesQueryParams) => [...movieKeys.all, params] as const,
- detail: (id: string) => [...movieKeys.all, id] as const,
- history: (id: string) => [...movieKeys.all, id, "history"] as const,
- profile: (id: string) => [...movieKeys.all, id, "profile"] as const,
-}
-
-export function useMovies(params?: MoviesQueryParams) {
- return useQuery({
- queryKey: movieKeys.list(params),
- queryFn: () => getMovies(params),
- })
-}
-
-export function useMovie(id: string) {
- return useQuery({
- queryKey: movieKeys.detail(id),
- queryFn: () => getMovie(id),
- enabled: !!id,
- })
-}
-
-export function useMovieHistory(id: string) {
- return useQuery({
- queryKey: movieKeys.history(id),
- queryFn: () => getMovieHistory(id),
- enabled: !!id,
- })
-}
-
-export function useMovieProfile(id: string) {
- return useQuery({
- queryKey: movieKeys.profile(id),
- queryFn: () => getMovieProfile(id),
- enabled: !!id,
- })
-}
-
-export function useSyncPoster() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (id: string) => syncPoster(id),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: movieKeys.all })
- },
- })
-}
diff --git a/spa/src/hooks/use-search.ts b/spa/src/hooks/use-search.ts
deleted file mode 100644
index 770c671..0000000
--- a/spa/src/hooks/use-search.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
-import { getPerson, getPersonCredits, search } from "@/lib/api/search"
-import type { SearchQueryParams } from "@/lib/api/search"
-
-const PAGE_SIZE = 20
-
-export const searchKeys = {
- all: ["search"] as const,
- query: (params: SearchQueryParams) => [...searchKeys.all, params] as const,
- person: (id: string) => ["people", id] as const,
- personCredits: (id: string) => ["people", id, "credits"] as const,
-}
-
-export function useSearch(params: SearchQueryParams) {
- return useQuery({
- queryKey: searchKeys.query(params),
- queryFn: () => search(params),
- enabled: !!params.q || !!params.genre || !!params.person_id,
- })
-}
-
-export function useInfiniteSearch(
- params: Omit,
-) {
- return useInfiniteQuery({
- queryKey: searchKeys.query(params),
- queryFn: ({ pageParam = 0 }) =>
- search({ ...params, limit: PAGE_SIZE, offset: pageParam }),
- initialPageParam: 0,
- getNextPageParam: (last) => {
- const next = last.movies.offset + last.movies.limit
- return next < last.movies.total_count ? next : undefined
- },
- enabled: !!params.q || !!params.genre || !!params.person_id,
- })
-}
-
-export function usePerson(id: string) {
- return useQuery({
- queryKey: searchKeys.person(id),
- queryFn: () => getPerson(id),
- enabled: !!id,
- })
-}
-
-export function usePersonCredits(id: string) {
- return useQuery({
- queryKey: searchKeys.personCredits(id),
- queryFn: () => getPersonCredits(id),
- enabled: !!id,
- })
-}
diff --git a/spa/src/hooks/use-users.ts b/spa/src/hooks/use-users.ts
deleted file mode 100644
index 46dee8b..0000000
--- a/spa/src/hooks/use-users.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
-import {
- getProfile,
- getUserProfile,
- getUsers,
- updateProfile,
- updateProfileFields,
-} from "@/lib/api/users"
-import type {
- UpdateProfileData,
- UpdateProfileFieldsRequest,
- UserProfileQueryParams,
-} from "@/lib/api/users"
-
-export const userKeys = {
- all: ["users"] as const,
- list: () => [...userKeys.all, "list"] as const,
- profile: (id: string, params?: UserProfileQueryParams) =>
- [...userKeys.all, id, params] as const,
- me: ["profile"] as const,
-}
-
-export function useUsers() {
- return useQuery({
- queryKey: userKeys.list(),
- queryFn: getUsers,
- })
-}
-
-export function useUserProfile(id: string, params?: UserProfileQueryParams) {
- return useQuery({
- queryKey: userKeys.profile(id, params),
- queryFn: () => getUserProfile(id, params),
- enabled: !!id,
- })
-}
-
-export function useProfile() {
- return useQuery({
- queryKey: userKeys.me,
- queryFn: getProfile,
- })
-}
-
-export function useUpdateProfile() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (data: UpdateProfileData) => updateProfile(data),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: userKeys.me })
- },
- })
-}
-
-export function useUpdateProfileFields() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (data: UpdateProfileFieldsRequest) => updateProfileFields(data),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: userKeys.me })
- },
- })
-}
diff --git a/spa/src/hooks/use-webhooks.ts b/spa/src/hooks/use-webhooks.ts
deleted file mode 100644
index 5486a63..0000000
--- a/spa/src/hooks/use-webhooks.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
-import {
- confirmWatch,
- deleteToken,
- dismissWatch,
- generateToken,
- getWatchQueue,
- getWebhookTokens,
-} from "@/lib/api/webhooks"
-import type {
- ConfirmWatchRequest,
- DismissWatchRequest,
- GenerateTokenRequest,
-} from "@/lib/api/webhooks"
-
-export const webhookKeys = {
- tokens: ["webhook-tokens"] as const,
- queue: ["watch-queue"] as const,
-}
-
-export function useWebhookTokens() {
- return useQuery({
- queryKey: webhookKeys.tokens,
- queryFn: getWebhookTokens,
- })
-}
-
-export function useGenerateToken() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (data: GenerateTokenRequest) => generateToken(data),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: webhookKeys.tokens })
- },
- })
-}
-
-export function useDeleteToken() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (id: string) => deleteToken(id),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: webhookKeys.tokens })
- },
- })
-}
-
-export function useWatchQueue() {
- return useQuery({
- queryKey: webhookKeys.queue,
- queryFn: getWatchQueue,
- })
-}
-
-export function useConfirmWatch() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (data: ConfirmWatchRequest) => confirmWatch(data),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: webhookKeys.queue })
- },
- })
-}
-
-export function useDismissWatch() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (data: DismissWatchRequest) => dismissWatch(data),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: webhookKeys.queue })
- },
- })
-}
diff --git a/spa/src/hooks/use-wrapup.ts b/spa/src/hooks/use-wrapup.ts
deleted file mode 100644
index e5f4017..0000000
--- a/spa/src/hooks/use-wrapup.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
-import {
- deleteWrapUp,
- generateWrapUp,
- getWrapUp,
- getWrapUpReport,
- getWrapUps,
-} from "@/lib/api/wrapup"
-import type { GenerateWrapUpRequest } from "@/lib/api/wrapup"
-
-export const wrapupKeys = {
- all: ["wrapups"] as const,
- list: () => [...wrapupKeys.all, "list"] as const,
- detail: (id: string) => [...wrapupKeys.all, id] as const,
- report: (id: string) => [...wrapupKeys.all, id, "report"] as const,
-}
-
-export function useWrapUpReport(id: string) {
- return useQuery({
- queryKey: wrapupKeys.report(id),
- queryFn: () => getWrapUpReport(id),
- enabled: !!id,
- })
-}
-
-export function useWrapUps() {
- return useQuery({
- queryKey: wrapupKeys.list(),
- queryFn: getWrapUps,
- })
-}
-
-export function useWrapUp(id: string) {
- return useQuery({
- queryKey: wrapupKeys.detail(id),
- queryFn: () => getWrapUp(id),
- enabled: !!id,
- })
-}
-
-export function useGenerateWrapUp() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (data: GenerateWrapUpRequest) => generateWrapUp(data),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: wrapupKeys.all })
- },
- })
-}
-
-export function useDeleteWrapUp() {
- const qc = useQueryClient()
- return useMutation({
- mutationFn: (id: string) => deleteWrapUp(id),
- onSuccess: () => {
- qc.invalidateQueries({ queryKey: wrapupKeys.all })
- },
- })
-}
diff --git a/spa/src/lib/api/diary.ts b/spa/src/lib/api/diary.ts
deleted file mode 100644
index 8c67960..0000000
--- a/spa/src/lib/api/diary.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { z } from "zod"
-import type { DiaryEntryDto, Paginated } from "./common"
-import { diaryEntryDtoSchema, movieDtoSchema, paginatedSchema, reviewDtoSchema } from "./common"
-import { del, get, patch, post } from "./client"
-
-export const diaryQueryParamsSchema = z.object({
- limit: z.number().optional(),
- offset: z.number().optional(),
- sort_by: z.string().optional(),
- movie_id: z.string().uuid().optional(),
- user_id: z.string().uuid().optional(),
-})
-export type DiaryQueryParams = z.infer
-
-export const diaryResponseSchema = paginatedSchema(diaryEntryDtoSchema)
-export type DiaryResponse = Paginated
-
-export const logReviewRequestSchema = z.object({
- external_metadata_id: z.string().optional(),
- manual_title: z.string().optional(),
- manual_release_year: z.number().optional(),
- manual_director: z.string().optional(),
- rating: z.number(),
- comment: z.string().optional(),
- watched_at: z.string(),
- watch_medium: z.string().optional(),
-})
-export type LogReviewRequest = z.infer
-
-export const editReviewRequestSchema = z.object({
- rating: z.number().optional(),
- comment: z.string().nullable().optional(),
- watched_at: z.string().optional(),
- watch_medium: z.string().nullable().optional(),
-})
-export type EditReviewRequest = z.infer
-
-export const feedEntryDtoSchema = z.object({
- movie: movieDtoSchema,
- review: reviewDtoSchema,
- user_id: z.string().uuid(),
- user_display_name: z.string(),
- is_federated: z.boolean(),
- actor_url: z.string().optional(),
-})
-export type FeedEntryDto = z.infer
-
-export const activityFeedQueryParamsSchema = z.object({
- limit: z.number().optional(),
- offset: z.number().optional(),
- sort_by: z.string().optional(),
-})
-export type ActivityFeedQueryParams = z.infer
-
-export const activityFeedResponseSchema = paginatedSchema(feedEntryDtoSchema)
-export type ActivityFeedResponse = Paginated
-
-export const exportQueryParamsSchema = z.object({
- format: z.string().optional(),
-})
-export type ExportQueryParams = z.infer
-
-export function getDiary(params?: DiaryQueryParams) {
- return get("/diary", params)
-}
-
-export function logReview(data: LogReviewRequest) {
- return post("/reviews", data)
-}
-
-export function editReview(id: string, data: EditReviewRequest) {
- return patch(`/reviews/${id}`, data)
-}
-
-export function deleteReview(id: string) {
- return del(`/reviews/${id}`)
-}
-
-export function getActivityFeed(params?: ActivityFeedQueryParams) {
- return get("/activity-feed", params)
-}
-
-export function exportDiary(params?: ExportQueryParams) {
- return get("/diary/export", params)
-}
diff --git a/spa/src/lib/api/goals.ts b/spa/src/lib/api/goals.ts
deleted file mode 100644
index 3b50b5a..0000000
--- a/spa/src/lib/api/goals.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { z } from "zod"
-import { get, post, put, del } from "./client"
-import { goalDtoSchema } from "./users"
-
-export const goalsResponseSchema = z.object({
- goals: z.array(goalDtoSchema),
-})
-export type GoalsResponse = z.infer
-
-export type CreateGoalRequest = {
- year: number
- target_count: number
-}
-
-export type UpdateGoalRequest = {
- target_count: number
-}
-
-export const userSettingsDtoSchema = z.object({
- federate_goals: z.boolean(),
- federate_reviews: z.boolean(),
- federate_watchlist: z.boolean(),
-})
-export type UserSettingsDto = z.infer
-
-export type UpdateUserSettingsRequest = {
- federate_goals: boolean
- federate_reviews: boolean
- federate_watchlist: boolean
-}
-
-export function getGoals() {
- return get("/goals")
-}
-
-export function getUserGoals(userId: string) {
- return get(`/users/${userId}/goals`)
-}
-
-export function createGoal(data: CreateGoalRequest) {
- return post>("/goals", data)
-}
-
-export function updateGoal(year: number, data: UpdateGoalRequest) {
- return put>(`/goals/${year}`, data)
-}
-
-export function deleteGoal(year: number) {
- return del(`/goals/${year}`)
-}
-
-export function getSettings() {
- return get("/settings")
-}
-
-export function updateSettings(data: UpdateUserSettingsRequest) {
- return put("/settings", data)
-}
diff --git a/spa/src/lib/api/imports.ts b/spa/src/lib/api/imports.ts
deleted file mode 100644
index adef1aa..0000000
--- a/spa/src/lib/api/imports.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { z } from "zod"
-import { del, get, post, put, uploadWithFields } from "./client"
-
-export const sessionCreatedResponseSchema = z.object({
- session_id: z.string(),
- columns: z.array(z.string()),
- sample_rows: z.array(z.array(z.string())),
-})
-export type SessionCreatedResponse = z.infer
-
-export const sessionStateResponseSchema = z.object({
- session_id: z.string(),
- columns: z.array(z.string()),
- has_mappings: z.boolean(),
- row_count: z.number(),
-})
-export type SessionStateResponse = z.infer
-
-export const apiFieldMappingSchema = z.object({
- source_column: z.string(),
- domain_field: z.string(),
- rating_scale: z.number().optional(),
- date_format: z.string().optional(),
-})
-export type ApiFieldMapping = z.infer
-
-export const applyMappingRequestSchema = z.object({
- mappings: z.array(apiFieldMappingSchema),
-})
-export type ApplyMappingRequest = z.infer
-
-export const confirmRequestSchema = z.object({
- confirmed_indices: z.array(z.number()),
-})
-export type ConfirmRequest = z.infer
-
-export const saveProfileRequestSchema = z.object({
- session_id: z.string(),
- name: z.string(),
-})
-export type SaveProfileRequest = z.infer
-
-export function createImportSession(file: File) {
- const ext = file.name.split(".").pop()?.toLowerCase()
- const format = ext === "json" ? "json" : "csv"
- return uploadWithFields("/import/sessions", file, { format })
-}
-
-export function getImportSession(id: string) {
- return get(`/import/sessions/${id}`)
-}
-
-export type PreviewRow = {
- index: number
- status: string
- title?: string
- release_year?: string
- director?: string
- rating?: string
- watched_at?: string
- comment?: string
- errors?: string[]
-}
-
-export type PreviewResponse = {
- rows: PreviewRow[]
-}
-
-export function getImportPreview(id: string) {
- return get(`/import/sessions/${id}/preview`)
-}
-
-export function applyMapping(sessionId: string, data: ApplyMappingRequest) {
- return put(`/import/sessions/${sessionId}/mapping`, data)
-}
-
-export function confirmImport(sessionId: string, data: ConfirmRequest) {
- return post(`/import/sessions/${sessionId}/confirm`, data)
-}
-
-export type ImportProfile = {
- id: string
- name: string
- created_at: string
-}
-
-export function getImportProfiles() {
- return get("/import/profiles")
-}
-
-export function saveImportProfile(data: SaveProfileRequest) {
- return post<{ id: string }>("/import/profiles", data)
-}
-
-export function deleteImportProfile(id: string) {
- return del(`/import/profiles/${id}`)
-}
-
-export function applyImportProfile(sessionId: string, profileId: string) {
- return put<{ row_count: number }>(`/import/sessions/${sessionId}/profile/${profileId}`)
-}
diff --git a/spa/src/lib/api/index.ts b/spa/src/lib/api/index.ts
deleted file mode 100644
index 28dae29..0000000
--- a/spa/src/lib/api/index.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-export { ApiError } from "./client"
-export * from "./common"
-export * from "./auth"
-export * from "./diary"
-export * from "./movies"
-export * from "./users"
-export * from "./search"
-export * from "./watchlist"
-export * from "./webhooks"
-export * from "./imports"
-export * from "./wrapup"
-export * from "./social"
diff --git a/spa/src/lib/api/social.ts b/spa/src/lib/api/social.ts
deleted file mode 100644
index 85ad8f8..0000000
--- a/spa/src/lib/api/social.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { z } from "zod"
-import { del, get, post } from "./client"
-
-export const remoteActorDtoSchema = z.object({
- handle: z.string(),
- display_name: z.string().optional(),
- url: z.string(),
-})
-export type RemoteActorDto = z.infer
-
-export const actorListResponseSchema = z.object({
- actors: z.array(remoteActorDtoSchema),
-})
-export type ActorListResponse = z.infer
-
-export const followRequestSchema = z.object({
- handle: z.string(),
-})
-export type FollowRequest = z.infer
-
-export const actorUrlRequestSchema = z.object({
- actor_url: z.string(),
-})
-export type ActorUrlRequest = z.infer
-
-export const blockedDomainResponseSchema = z.object({
- domain: z.string(),
- reason: z.string().optional(),
- blocked_at: z.string(),
-})
-export type BlockedDomainResponse = z.infer
-
-export const addBlockedDomainRequestSchema = z.object({
- domain: z.string(),
- reason: z.string().optional(),
-})
-export type AddBlockedDomainRequest = z.infer
-
-export const blockedActorResponseSchema = z.object({
- url: z.string(),
- handle: z.string(),
- display_name: z.string().optional(),
- avatar_url: z.string().optional(),
-})
-export type BlockedActorResponse = z.infer
-
-export function getFollowing() {
- return get("/social/following")
-}
-
-export function getFollowers() {
- return get("/social/followers")
-}
-
-export function getUserFollowing(userId: string) {
- return get(`/users/${userId}/following`)
-}
-
-export function getUserFollowers(userId: string) {
- return get(`/users/${userId}/followers`)
-}
-
-export function getPendingFollowers() {
- return get("/social/followers/pending")
-}
-
-export function follow(data: FollowRequest) {
- return post("/social/follow", data)
-}
-
-export function unfollow(data: ActorUrlRequest) {
- return post("/social/unfollow", data)
-}
-
-export function acceptFollower(data: ActorUrlRequest) {
- return post("/social/followers/accept", data)
-}
-
-export function rejectFollower(data: ActorUrlRequest) {
- return post("/social/followers/reject", data)
-}
-
-export function removeFollower(data: ActorUrlRequest) {
- return post("/social/followers/remove", data)
-}
-
-export function getBlockedDomains() {
- return get("/admin/blocked-domains")
-}
-
-export function addBlockedDomain(data: AddBlockedDomainRequest) {
- return post("/admin/blocked-domains", data)
-}
-
-export function removeBlockedDomain(domain: string) {
- return del(`/admin/blocked-domains/${domain}`)
-}
-
-export function blockActor(data: ActorUrlRequest) {
- return post("/social/block", data)
-}
-
-export function unblockActor(data: ActorUrlRequest) {
- return post("/social/unblock", data)
-}
-
-export function getBlockedActors() {
- return get("/social/blocked")
-}
diff --git a/spa/src/lib/api/watchlist.ts b/spa/src/lib/api/watchlist.ts
deleted file mode 100644
index 16226b7..0000000
--- a/spa/src/lib/api/watchlist.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { z } from "zod"
-import type { Paginated } from "./common"
-import { movieDtoSchema, paginatedSchema } from "./common"
-import { del, get, post } from "./client"
-
-export const watchlistEntryDtoSchema = z.object({
- id: z.string().uuid(),
- movie: movieDtoSchema,
- added_at: z.string(),
-})
-export type WatchlistEntryDto = z.infer
-
-export const watchlistResponseSchema = paginatedSchema(watchlistEntryDtoSchema)
-export type WatchlistResponse = Paginated
-
-export const addToWatchlistRequestSchema = z.object({
- movie_id: z.string().uuid().optional(),
- external_metadata_id: z.string().optional(),
- manual_title: z.string().optional(),
- manual_release_year: z.number().optional(),
-})
-export type AddToWatchlistRequest = z.infer
-
-export const watchlistStatusResponseSchema = z.object({
- on_watchlist: z.boolean(),
-})
-export type WatchlistStatusResponse = z.infer
-
-export function getWatchlist(params?: { limit?: number; offset?: number }) {
- return get("/watchlist", params)
-}
-
-export function getWatchlistStatus(movieId: string) {
- return get(`/watchlist/${movieId}`)
-}
-
-export function addToWatchlist(data: AddToWatchlistRequest) {
- return post("/watchlist", data)
-}
-
-export function removeFromWatchlist(movieId: string) {
- return del(`/watchlist/${movieId}`)
-}
diff --git a/spa/src/lib/date.ts b/spa/src/lib/date.ts
index ce6abfa..f239e20 100644
--- a/spa/src/lib/date.ts
+++ b/spa/src/lib/date.ts
@@ -17,3 +17,19 @@ export function shortDate(dateStr: string): string {
return dateStr.slice(0, 10)
}
}
+
+export function parseLocalDate(s: string): Date {
+ const [datePart, timePart] = s.split("T")
+ if (!datePart) return new Date()
+ const [y, m, d] = datePart.split("-").map(Number)
+ if (timePart) {
+ const [h, min, sec] = timePart.split(":").map(Number)
+ return new Date(y!, m! - 1, d!, h, min, sec)
+ }
+ return new Date(y!, m! - 1, d!)
+}
+
+export function formatLocalDateTime(d: Date): string {
+ const pad = (n: number) => n.toString().padStart(2, "0")
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
diff --git a/spa/src/routes/_app.tsx b/spa/src/routes/_app.tsx
index 9323ec1..e300450 100644
--- a/spa/src/routes/_app.tsx
+++ b/spa/src/routes/_app.tsx
@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import { Toaster } from "@/components/ui/sonner"
import { BottomTabBar } from "@/components/bottom-tab-bar"
-import { LogSheet } from "@/components/log-sheet"
+import { ReviewSheet } from "@/components/review-sheet"
import { getAuth } from "@/lib/auth"
export const Route = createFileRoute("/_app")({
@@ -43,7 +43,7 @@ function AppLayout() {
setLogOpen(true)} />
-
+
)
diff --git a/spa/src/routes/_app/diary.tsx b/spa/src/routes/_app/diary.tsx
index a29e273..b2033f4 100644
--- a/spa/src/routes/_app/diary.tsx
+++ b/spa/src/routes/_app/diary.tsx
@@ -3,7 +3,7 @@ import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import { BookOpen, ChevronLeft, ChevronRight, Pencil } from "lucide-react"
import { format, startOfMonth, subMonths } from "date-fns"
-import { EditReviewSheet } from "@/components/edit-review-sheet"
+import { ReviewSheet } from "@/components/review-sheet"
import { EditableContextMenu } from "@/components/editable-context-menu"
import { MovieCard } from "@/components/movie-card"
import { EmptyState } from "@/components/empty-state"
@@ -13,7 +13,7 @@ import { VirtualList } from "@/components/virtual-list"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { useAuth } from "@/components/auth-provider"
-import { useInfiniteDiary, useDeleteReview } from "@/hooks/use-diary"
+import { useInfiniteDiary, useDeleteReview } from "@/features/diary"
import { useDocumentTitle } from "@/hooks/use-document-title"
import type { DiaryEntryDto } from "@/lib/api/common"
@@ -145,8 +145,9 @@ function DiaryPage() {
)}
{editingEntry && (
- !open && setEditingEntry(null)}
movie={editingEntry.movie}
diff --git a/spa/src/routes/_app/index.tsx b/spa/src/routes/_app/index.tsx
index 1520e34..d22a1b0 100644
--- a/spa/src/routes/_app/index.tsx
+++ b/spa/src/routes/_app/index.tsx
@@ -1,28 +1,9 @@
import { createFileRoute } from "@tanstack/react-router"
-import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
-import { Clapperboard, Film, Inbox, Plus, RefreshCw } 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 { Textarea } from "@/components/ui/textarea"
-import { StarRating } from "@/components/star-rating"
-import { useAuth } from "@/components/auth-provider"
-import { useQueryClient } from "@tanstack/react-query"
-import { EditReviewSheet } from "@/components/edit-review-sheet"
-import { ReviewDetailSheet } from "@/components/review-detail-sheet"
-import { useInfiniteActivityFeed, useDeleteReview } from "@/hooks/use-diary"
-import type { FeedEntryDto } from "@/lib/api/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"
+import { FeedTab } from "@/components/feed-tab"
+import { WatchlistTab } from "@/components/watchlist-tab"
+import { QueueTab } from "@/components/queue-tab"
export const Route = createFileRoute("/_app/")({
component: HomePage,
@@ -53,258 +34,3 @@ function HomePage() {
)
}
-
-function FeedTab() {
- const { t } = useTranslation()
- const { auth } = useAuth()
- const qc = useQueryClient()
- const [refreshing, setRefreshing] = useState(false)
- 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 [editingEntry, setEditingEntry] = useState(null)
- const [detailEntry, setDetailEntry] = useState(null)
- const items = data?.pages.flatMap((p) => p.items) ?? []
- const loadMore = useCallback(() => fetchNextPage(), [fetchNextPage])
-
- return (
-
-
-
-
-
-
- {isPending &&
}
-
- {!isPending && !items.length && (
-
- )}
-
- {items.length > 0 && (
-
{
- const isOwn = entry.user_id === auth?.user_id
- const card = (
- setEditingEntry(entry) : undefined}
- onShowDetail={entry.review.comment ? () => setDetailEntry(entry) : undefined}
- />
- )
- return isOwn ? (
- deleteReview.mutate(entry.review.id)}
- confirmTitle={t("feed.deleteReview")}
- confirmDescription={entry.movie.title}
- >
- {card}
-
- ) : (
- card
- )
- }}
- />
- )}
-
- {editingEntry && (
- !open && setEditingEntry(null)}
- movie={editingEntry.movie}
- review={editingEntry.review}
- />
- )}
-
- {detailEntry && (
- !open && setDetailEntry(null)}
- movie={detailEntry.movie}
- review={detailEntry.review}
- userName={detailEntry.user_display_name}
- />
- )}
-
- )
-}
-
-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 (
-
-
-
- {searchOpen && (
-
setSearchOpen(false)} onSelect={handleAdd} />
- )}
-
- {isPending && }
-
- {!isPending && !items.length && (
-
- )}
-
- {items.length > 0 && (
- (
- removeMutation.mutate(entry.movie.id)}
- confirmTitle={t("feed.removeFromWatchlist")}
- confirmDescription={entry.movie.title}
- >
-
-
- )}
- />
- )}
-
- )
-}
-
-function QueueTab() {
- const { t } = useTranslation()
- const { data, isPending } = useWatchQueue()
- const confirmMutation = useConfirmWatch()
- const dismissMutation = useDismissWatch()
- const [ratings, setRatings] = useState>({})
- const [comments, setComments] = useState>({})
-
- if (isPending) return
- if (!data?.length)
- return
-
- return (
-
- {data.map((entry) => (
-
-
{entry.title}
-
- {entry.year && `${entry.year} · `}{entry.source} · {entry.watched_at}
-
-
- setRatings((p) => ({ ...p, [entry.id]: v }))}
- size="sm"
- />
-
-
- ))}
-
- )
-}
-
-function FeedSkeleton() {
- return (
-
- {[1, 2, 3].map((i) => (
-
- ))}
-
- )
-}
diff --git a/spa/src/routes/_app/movies.$id.tsx b/spa/src/routes/_app/movies.$id.tsx
index 7ead8b2..612b283 100644
--- a/spa/src/routes/_app/movies.$id.tsx
+++ b/spa/src/routes/_app/movies.$id.tsx
@@ -1,26 +1,24 @@
import { createFileRoute, Link } from "@tanstack/react-router"
import { useTranslation } from "react-i18next"
-import { Bookmark, BookmarkCheck, Globe, Star, TrendingUp, User, Users } from "lucide-react"
+import { Bookmark, BookmarkCheck, Star, User } from "lucide-react"
import { BackButton } from "@/components/back-button"
+import { CommunityReviews } from "@/components/community-reviews"
+import { ViewingHistory } from "@/components/viewing-history"
import { StarDisplay } from "@/components/star-display"
-import { WatchMediumBadge } from "@/components/watch-medium-badge"
import { RatingHistogram } from "@/components/rating-histogram"
-import { EmptyState } from "@/components/empty-state"
import { HorizontalStrip } from "@/components/horizontal-strip"
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 { timeAgo, shortDate } from "@/lib/date"
-import { useMovie, useMovieHistory, useMovieProfile } from "@/hooks/use-movies"
+import { useMovie, useMovieHistory, useMovieProfile } from "@/features/movies"
import { useDocumentTitle } from "@/hooks/use-document-title"
import {
useWatchlistStatus,
useAddToWatchlist,
useRemoveFromWatchlist,
-} from "@/hooks/use-watchlist"
-import type { CastMemberDto, CrewMemberDto } from "@/lib/api/movies"
+} from "@/features/watchlist"
+import type { CastMemberDto, CrewMemberDto } from "@/features/movies"
export const Route = createFileRoute("/_app/movies/$id")({
component: MovieDetailPage,
@@ -105,64 +103,9 @@ function MovieDetailPage() {
)}
-
- {t("movie.community")}
- {!reviews.items.length ? (
-
- ) : (
-
- {reviews.items.map((r, i) => (
-
-
-
-
-
- {r.user_display}
- {r.is_federated && }
-
- {timeAgo(r.watched_at)}
-
-
-
- {r.watch_medium && }
-
-
-
- {r.comment && (
-
- {r.comment}
-
- )}
-
- ))}
-
- )}
-
+
- {history && history.viewings.length > 0 && (
-
- {t("movie.yourHistory")}
-
- {history.trend && (
-
-
- {t("movie.trend", { trend: history.trend })}
-
- )}
- {history.viewings.map((v) => (
-
-
-
{shortDate(v.watched_at)}
- {v.comment && (
-
{v.comment}
- )}
-
-
-
- ))}
-
-
- )}
+ {history && }
)
}
diff --git a/spa/src/routes/_app/people.$id.tsx b/spa/src/routes/_app/people.$id.tsx
index 3bd59ae..804bc9b 100644
--- a/spa/src/routes/_app/people.$id.tsx
+++ b/spa/src/routes/_app/people.$id.tsx
@@ -13,7 +13,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import { posterUrl, tmdbProfileUrl } from "@/lib/api/client"
-import { usePersonCredits } from "@/hooks/use-search"
+import { usePersonCredits } from "@/features/search"
import { useDocumentTitle } from "@/hooks/use-document-title"
import { shortDate } from "@/lib/date"
import { differenceInYears, parseISO } from "date-fns"
diff --git a/spa/src/routes/_app/profile.tsx b/spa/src/routes/_app/profile.tsx
index 8679d3f..96416fe 100644
--- a/spa/src/routes/_app/profile.tsx
+++ b/spa/src/routes/_app/profile.tsx
@@ -5,14 +5,14 @@ import { ChevronDown, ChevronRight, Plus, Settings, Sparkles } from "lucide-reac
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"
-import { useDeleteGoal } from "@/hooks/use-goals"
+import { useWrapUps } from "@/features/wrapup"
+import { useUserProfile } from "@/features/users"
+import { useDeleteGoal } from "@/features/goals"
import { GoalCard } from "@/components/goal-card"
import { GoalSheet } from "@/components/goal-sheet"
import { toast } from "sonner"
import { useDocumentTitle } from "@/hooks/use-document-title"
-import type { GoalDto } from "@/lib/api/users"
+import type { GoalDto } from "@/features/users"
export const Route = createFileRoute("/_app/profile")({
component: ProfilePage,
diff --git a/spa/src/routes/_app/search.tsx b/spa/src/routes/_app/search.tsx
index e36e8fc..b75f311 100644
--- a/spa/src/routes/_app/search.tsx
+++ b/spa/src/routes/_app/search.tsx
@@ -9,10 +9,10 @@ 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 { useInfiniteSearch } from "@/features/search"
import { useDebounce } from "@/hooks/use-debounce"
import { useDocumentTitle } from "@/hooks/use-document-title"
-import { useAddToWatchlist } from "@/hooks/use-watchlist"
+import { useAddToWatchlist } from "@/features/watchlist"
import { toast } from "sonner"
export const Route = createFileRoute("/_app/search")({
diff --git a/spa/src/routes/_app/settings/blocked.tsx b/spa/src/routes/_app/settings/blocked.tsx
index 02039c3..edb13e5 100644
--- a/spa/src/routes/_app/settings/blocked.tsx
+++ b/spa/src/routes/_app/settings/blocked.tsx
@@ -14,7 +14,7 @@ import {
useBlockedDomains,
useAddBlockedDomain,
useRemoveBlockedDomain,
-} from "@/hooks/use-social"
+} from "@/features/social"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/blocked")({
diff --git a/spa/src/routes/_app/settings/edit-profile.tsx b/spa/src/routes/_app/settings/edit-profile.tsx
index de81005..b9cc762 100644
--- a/spa/src/routes/_app/settings/edit-profile.tsx
+++ b/spa/src/routes/_app/settings/edit-profile.tsx
@@ -9,7 +9,7 @@ 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 { useProfile, useUpdateProfile, useUpdateProfileFields } from "@/hooks/use-users"
+import { useProfile, useUpdateProfile, useUpdateProfileFields } from "@/features/users"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/edit-profile")({
diff --git a/spa/src/routes/_app/settings/import.tsx b/spa/src/routes/_app/settings/import.tsx
index 65e1df2..51d9574 100644
--- a/spa/src/routes/_app/settings/import.tsx
+++ b/spa/src/routes/_app/settings/import.tsx
@@ -32,9 +32,9 @@ import {
useImportProfiles,
useSaveImportProfile,
useDeleteImportProfile,
-} from "@/hooks/use-imports"
+} from "@/features/imports"
import { useDocumentTitle } from "@/hooks/use-document-title"
-import type { SessionCreatedResponse } from "@/lib/api/imports"
+import type { SessionCreatedResponse } from "@/features/imports"
export const Route = createFileRoute("/_app/settings/import")({
component: ImportPage,
diff --git a/spa/src/routes/_app/settings/index.tsx b/spa/src/routes/_app/settings/index.tsx
index 24accb3..d0281c6 100644
--- a/spa/src/routes/_app/settings/index.tsx
+++ b/spa/src/routes/_app/settings/index.tsx
@@ -19,9 +19,9 @@ import {
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { useAuth, useIsAdmin } from "@/components/auth-provider"
-import { reindexSearch } from "@/lib/api/users"
-import { useSettings, useUpdateSettings } from "@/hooks/use-goals"
-import type { UpdateUserSettingsRequest } from "@/lib/api/goals"
+import { reindexSearch } from "@/features/users"
+import { useSettings, useUpdateSettings } from "@/features/goals"
+import type { UpdateUserSettingsRequest } from "@/features/goals"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/")({
diff --git a/spa/src/routes/_app/settings/webhooks.tsx b/spa/src/routes/_app/settings/webhooks.tsx
index efd6515..7706dbc 100644
--- a/spa/src/routes/_app/settings/webhooks.tsx
+++ b/spa/src/routes/_app/settings/webhooks.tsx
@@ -26,7 +26,7 @@ import {
useWebhookTokens,
useGenerateToken,
useDeleteToken,
-} from "@/hooks/use-webhooks"
+} from "@/features/webhooks"
import { API_URL } from "@/lib/api/client"
import { useDocumentTitle } from "@/hooks/use-document-title"
diff --git a/spa/src/routes/_app/settings/wrapup.tsx b/spa/src/routes/_app/settings/wrapup.tsx
index 3b0f467..e70a2ce 100644
--- a/spa/src/routes/_app/settings/wrapup.tsx
+++ b/spa/src/routes/_app/settings/wrapup.tsx
@@ -21,8 +21,8 @@ import {
useWrapUps,
useGenerateWrapUp,
useDeleteWrapUp,
-} from "@/hooks/use-wrapup"
-import { useUsers } from "@/hooks/use-users"
+} from "@/features/wrapup"
+import { useUsers } from "@/features/users"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/wrapup")({
diff --git a/spa/src/routes/_app/social.tsx b/spa/src/routes/_app/social.tsx
index b5fbc82..e5c923f 100644
--- a/spa/src/routes/_app/social.tsx
+++ b/spa/src/routes/_app/social.tsx
@@ -2,15 +2,13 @@ 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 { ActorList } from "@/components/actor-list"
import {
useFollow,
useFollowing,
@@ -22,9 +20,8 @@ import {
useRemoveFollower,
useUserFollowing,
useUserFollowers,
-} from "@/hooks/use-social"
+} from "@/features/social"
import { useDocumentTitle } from "@/hooks/use-document-title"
-import type { RemoteActorDto } from "@/lib/api/social"
type SearchParams = { user?: string }
@@ -93,30 +90,25 @@ function OwnFollowingTab() {
const { data, isPending } = useFollowing()
const unfollowMutation = useUnfollow()
- if (isPending) return
- if (!data?.actors.length)
- return
-
return (
-
- {data.actors.map((actor) => (
-
unfollowMutation.mutate({ actor_url: actor.url })}
- disabled={unfollowMutation.isPending}
- >
-
- {t("common.unfollow")}
-
- }
- />
- ))}
-
+ (
+
+ )}
+ />
)
}
@@ -125,31 +117,25 @@ function OwnFollowersTab() {
const { data, isPending } = useFollowers()
const removeMutation = useRemoveFollower()
- if (isPending) return
- if (!data?.actors.length)
- return
-
return (
-
- {data.actors.map((actor) => (
-
removeMutation.mutate({ actor_url: actor.url })}
- disabled={removeMutation.isPending}
- className="text-destructive hover:text-destructive"
- >
-
- {t("common.remove")}
-
- }
- />
- ))}
-
+ (
+
+ )}
+ />
)
}
@@ -159,29 +145,23 @@ function PendingTab() {
const acceptMutation = useAcceptFollower()
const rejectMutation = useRejectFollower()
- if (isPending) return
- if (!data?.actors.length)
- return
-
return (
-
- {data.actors.map((actor) => (
-
-
-
-
- }
- />
- ))}
-
+ (
+
+
+
+
+ )}
+ />
)
}
@@ -189,62 +169,14 @@ function UserFollowingTab({ userId }: { userId: string }) {
const { t } = useTranslation()
const { data, isPending } = useUserFollowing(userId)
- if (isPending) return
- if (!data?.actors.length)
- return
-
- return (
-
- {data.actors.map((actor) => (
-
- ))}
-
- )
+ return
}
function UserFollowersTab({ userId }: { userId: string }) {
const { t } = useTranslation()
const { data, isPending } = useUserFollowers(userId)
- if (isPending) return
- if (!data?.actors.length)
- return
-
- return (
-
- {data.actors.map((actor) => (
-
- ))}
-
- )
-}
-
-function actorHandle(actor: RemoteActorDto): string {
- try {
- const host = new URL(actor.url).host
- return `@${actor.handle}@${host}`
- } catch {
- return `@${actor.handle}`
- }
-}
-
-function ActorCard({ actor, action }: { actor: RemoteActorDto; action?: React.ReactNode }) {
- const initial = (actor.display_name || actor.handle)[0]?.toUpperCase() ?? "?"
-
- return (
-
-
-
- {initial}
-
-
-
{actor.display_name || actor.handle}
-
{actorHandle(actor)}
-
- {action}
-
-
- )
+ return
}
function FollowByHandle() {
@@ -288,13 +220,3 @@ function FollowByHandle() {
)
}
-
-function ListSkeleton() {
- return (
-
- {[1, 2, 3].map((i) => (
-
- ))}
-
- )
-}
diff --git a/spa/src/routes/_app/users.$id.tsx b/spa/src/routes/_app/users.$id.tsx
index 2c4594d..1cf1919 100644
--- a/spa/src/routes/_app/users.$id.tsx
+++ b/spa/src/routes/_app/users.$id.tsx
@@ -7,8 +7,8 @@ import { Button } from "@/components/ui/button"
import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
import { GoalCard } from "@/components/goal-card"
import { useAuth } from "@/components/auth-provider"
-import { useUserProfile } from "@/hooks/use-users"
-import { useFollow, useUnfollow, useFollowing } from "@/hooks/use-social"
+import { useUserProfile } from "@/features/users"
+import { useFollow, useUnfollow, useFollowing } from "@/features/social"
import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/users/$id")({
diff --git a/spa/src/routes/_app/wrapup.$id.tsx b/spa/src/routes/_app/wrapup.$id.tsx
index c41b75a..2046cc8 100644
--- a/spa/src/routes/_app/wrapup.$id.tsx
+++ b/spa/src/routes/_app/wrapup.$id.tsx
@@ -16,9 +16,9 @@ import { FunFacts } from "@/components/wrapup-fun-facts"
import { RankCard } from "@/components/wrapup-rank-card"
import { posterUrl } from "@/lib/api/client"
import { fmtUsd } from "@/lib/format"
-import { useWrapUpReport } from "@/hooks/use-wrapup"
+import { useWrapUpReport } from "@/features/wrapup"
import { useDocumentTitle } from "@/hooks/use-document-title"
-import type { MovieRef } from "@/lib/api/wrapup"
+import type { MovieRef } from "@/features/wrapup"
const WrapUpShareCard = lazy(() => import("@/components/wrapup-share-card").then((m) => ({ default: m.WrapUpShareCard })))
diff --git a/spa/src/routes/login.tsx b/spa/src/routes/login.tsx
index a5d274d..08f56b1 100644
--- a/spa/src/routes/login.tsx
+++ b/spa/src/routes/login.tsx
@@ -4,7 +4,7 @@ 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"
+import { useLogin } from "@/features/auth"
export const Route = createFileRoute("/login")({
component: LoginPage,
diff --git a/spa/src/routes/register.tsx b/spa/src/routes/register.tsx
index fc5203c..98b4ece 100644
--- a/spa/src/routes/register.tsx
+++ b/spa/src/routes/register.tsx
@@ -4,7 +4,7 @@ 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"
+import { useRegister } from "@/features/auth"
export const Route = createFileRoute("/register")({
component: RegisterPage,