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" + /> +
+