From 0ee2e04fe560c4b61671b63ede6b4f3b7ef1b495 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 10 Jul 2026 17:38:58 +0200 Subject: [PATCH] refactor(spa): collapse api/hooks telescope, merge review sheets, extract tab+list modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - merge lib/api/* + hooks/use-* → features/* domain modules - log-sheet + edit-review-sheet → review-sheet with mode discriminant - index.tsx → feed-tab, watchlist-tab, queue-tab - social actor-list pattern → ActorList component - movie detail inline sections → community-reviews, viewing-history --- spa/src/components/actor-list.tsx | 66 +++++ spa/src/components/community-reviews.tsx | 48 +++ spa/src/components/edit-review-sheet.tsx | 112 ------- spa/src/components/feed-tab.tsx | 146 +++++++++ spa/src/components/goal-card.tsx | 2 +- spa/src/components/goal-sheet.tsx | 2 +- spa/src/components/log-sheet.tsx | 106 ------- spa/src/components/profile-view.tsx | 4 +- spa/src/components/queue-tab.tsx | 73 +++++ spa/src/components/review-sheet.tsx | 241 +++++++++++++++ spa/src/components/search-overlay.tsx | 2 +- spa/src/components/viewing-history.tsx | 36 +++ spa/src/components/watchlist-tab.tsx | 74 +++++ spa/src/components/wrapup-fun-facts.tsx | 2 +- spa/src/components/wrapup-hero.tsx | 2 +- spa/src/components/wrapup-rank-card.tsx | 2 +- spa/src/components/wrapup-share-card.tsx | 2 +- spa/src/{lib/api => features}/auth.ts | 65 +++- spa/src/features/diary.ts | 177 +++++++++++ .../{hooks/use-goals.ts => features/goals.ts} | 73 ++++- spa/src/features/imports.ts | 188 ++++++++++++ spa/src/{lib/api => features}/movies.ts | 66 ++++- spa/src/{lib/api => features}/search.ts | 60 +++- .../use-social.ts => features/social.ts} | 132 +++++++-- spa/src/{lib/api => features}/users.ts | 87 ++++-- .../watchlist.ts} | 50 +++- spa/src/{lib/api => features}/webhooks.ts | 74 ++++- spa/src/{lib/api => features}/wrapup.ts | 87 ++++-- spa/src/hooks/use-auth.ts | 47 --- spa/src/hooks/use-diary.ts | 105 ------- spa/src/hooks/use-imports.ts | 103 ------- spa/src/hooks/use-movies.ts | 58 ---- spa/src/hooks/use-search.ts | 52 ---- spa/src/hooks/use-users.ts | 63 ---- spa/src/hooks/use-webhooks.ts | 73 ----- spa/src/hooks/use-wrapup.ts | 59 ---- spa/src/lib/api/diary.ts | 85 ------ spa/src/lib/api/goals.ts | 58 ---- spa/src/lib/api/imports.ts | 101 ------- spa/src/lib/api/index.ts | 12 - spa/src/lib/api/social.ts | 109 ------- spa/src/lib/api/watchlist.ts | 43 --- spa/src/lib/date.ts | 16 + spa/src/routes/_app.tsx | 4 +- spa/src/routes/_app/diary.tsx | 7 +- spa/src/routes/_app/index.tsx | 280 +----------------- spa/src/routes/_app/movies.$id.tsx | 73 +---- spa/src/routes/_app/people.$id.tsx | 2 +- spa/src/routes/_app/profile.tsx | 8 +- spa/src/routes/_app/search.tsx | 4 +- spa/src/routes/_app/settings/blocked.tsx | 2 +- spa/src/routes/_app/settings/edit-profile.tsx | 2 +- spa/src/routes/_app/settings/import.tsx | 4 +- spa/src/routes/_app/settings/index.tsx | 6 +- spa/src/routes/_app/settings/webhooks.tsx | 2 +- spa/src/routes/_app/settings/wrapup.tsx | 4 +- spa/src/routes/_app/social.tsx | 190 ++++-------- spa/src/routes/_app/users.$id.tsx | 4 +- spa/src/routes/_app/wrapup.$id.tsx | 4 +- spa/src/routes/login.tsx | 2 +- spa/src/routes/register.tsx | 2 +- 61 files changed, 1753 insertions(+), 1810 deletions(-) create mode 100644 spa/src/components/actor-list.tsx create mode 100644 spa/src/components/community-reviews.tsx delete mode 100644 spa/src/components/edit-review-sheet.tsx create mode 100644 spa/src/components/feed-tab.tsx delete mode 100644 spa/src/components/log-sheet.tsx create mode 100644 spa/src/components/queue-tab.tsx create mode 100644 spa/src/components/review-sheet.tsx create mode 100644 spa/src/components/viewing-history.tsx create mode 100644 spa/src/components/watchlist-tab.tsx rename spa/src/{lib/api => features}/auth.ts (50%) create mode 100644 spa/src/features/diary.ts rename spa/src/{hooks/use-goals.ts => features/goals.ts} (57%) create mode 100644 spa/src/features/imports.ts rename spa/src/{lib/api => features}/movies.ts (67%) rename spa/src/{lib/api => features}/search.ts (62%) rename spa/src/{hooks/use-social.ts => features/social.ts} (57%) rename spa/src/{lib/api => features}/users.ts (73%) rename spa/src/{hooks/use-watchlist.ts => features/watchlist.ts} (51%) rename spa/src/{lib/api => features}/webhooks.ts (55%) rename spa/src/{lib/api => features}/wrapup.ts (64%) delete mode 100644 spa/src/hooks/use-auth.ts delete mode 100644 spa/src/hooks/use-diary.ts delete mode 100644 spa/src/hooks/use-imports.ts delete mode 100644 spa/src/hooks/use-movies.ts delete mode 100644 spa/src/hooks/use-search.ts delete mode 100644 spa/src/hooks/use-users.ts delete mode 100644 spa/src/hooks/use-webhooks.ts delete mode 100644 spa/src/hooks/use-wrapup.ts delete mode 100644 spa/src/lib/api/diary.ts delete mode 100644 spa/src/lib/api/goals.ts delete mode 100644 spa/src/lib/api/imports.ts delete mode 100644 spa/src/lib/api/index.ts delete mode 100644 spa/src/lib/api/social.ts delete mode 100644 spa/src/lib/api/watchlist.ts 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" + /> +
+