A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Confirm bulk watch removals and improve profile feedback

- Add confirmation dialogs when removing multiple watch entries

- Show toast feedback for settings, avatar, and account actions

- Normalize TMDB IDs before saving tracked movies and episodes

+574 -91
+2
apps/web/package.json
··· 32 32 "clsx": "^2.1.1", 33 33 "cmdk": "^1.1.1", 34 34 "lucide-react": "^0.577.0", 35 + "next-themes": "^0.4.6", 35 36 "posthog-js": "^1.358.1", 36 37 "radix-ui": "^1.4.3", 37 38 "react": "^19.2.0", 38 39 "react-dom": "^19.2.0", 39 40 "slugify": "^1.6.9", 41 + "sonner": "^2.0.7", 40 42 "tailwind-merge": "^3.0.2", 41 43 "tailwindcss": "^4.2.4", 42 44 "tw-animate-css": "^1.3.6",
+69
apps/web/src/components/ConfirmRemoveDialog.tsx
··· 1 + import { AlertTriangle, Loader2 } from "lucide-react"; 2 + import { 3 + Dialog, 4 + DialogContent, 5 + DialogDescription, 6 + DialogHeader, 7 + DialogTitle, 8 + } from "#/components/ui/dialog"; 9 + 10 + interface ConfirmRemoveDialogProps { 11 + open: boolean; 12 + onOpenChange: (open: boolean) => void; 13 + title: string; 14 + entryCount: number; 15 + onConfirm: () => void; 16 + isPending: boolean; 17 + } 18 + 19 + export default function ConfirmRemoveDialog({ 20 + open, 21 + onOpenChange, 22 + title, 23 + entryCount, 24 + onConfirm, 25 + isPending, 26 + }: ConfirmRemoveDialogProps) { 27 + return ( 28 + <Dialog open={open} onOpenChange={onOpenChange}> 29 + <DialogContent> 30 + <DialogHeader> 31 + <DialogTitle className="flex items-center gap-2"> 32 + <AlertTriangle className="h-5 w-5 text-amber-500" /> 33 + Remove all plays? 34 + </DialogTitle> 35 + <DialogDescription> 36 + This will remove all <strong>{entryCount}</strong> watch entries for{" "} 37 + <strong>{title}</strong>. This action cannot be undone. 38 + </DialogDescription> 39 + </DialogHeader> 40 + <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> 41 + <button 42 + type="button" 43 + onClick={() => onOpenChange(false)} 44 + className="btn btn-secondary" 45 + > 46 + Cancel 47 + </button> 48 + <button 49 + type="button" 50 + onClick={() => { 51 + onConfirm(); 52 + }} 53 + disabled={isPending} 54 + className="btn bg-red-600 text-white hover:bg-red-700" 55 + > 56 + {isPending ? ( 57 + <> 58 + <Loader2 className="h-4 w-4 animate-spin" /> 59 + Removing... 60 + </> 61 + ) : ( 62 + "Remove all" 63 + )} 64 + </button> 65 + </div> 66 + </DialogContent> 67 + </Dialog> 68 + ); 69 + }
+71 -3
apps/web/src/components/DashboardMediaCard.tsx
··· 1 - import { useState } from "react"; 1 + import { useMemo, useState } from "react"; 2 + import ConfirmRemoveDialog from "#/components/ConfirmRemoveDialog"; 3 + import { useMediaWatchStatus } from "#/lib/hooks"; 2 4 import { useWatchActions } from "#/lib/hooks/useWatchActions"; 3 5 import ManageListsDialog from "./ManageListsDialog"; 4 6 import type { MediaCardProps } from "./MediaCard"; ··· 16 18 } 17 19 18 20 export default function DashboardMediaCard(props: DashboardMediaCardProps) { 19 - const { type, id, seasonNumber, episodeNumber, showId, isWatched, ...rest } = 20 - props; 21 + const { 22 + type, 23 + id, 24 + seasonNumber, 25 + episodeNumber, 26 + showId, 27 + isWatched, 28 + title, 29 + ...rest 30 + } = props; 21 31 22 32 const [listDialogOpen, setListDialogOpen] = useState(false); 33 + const [confirmRemoveOpen, setConfirmRemoveOpen] = useState(false); 23 34 24 35 const isMovie = type === "movie"; 25 36 const actualShowId = showId || (type === "show" ? String(id) : undefined); ··· 30 41 : { mediaType: "show", showId: actualShowId || "" }, 31 42 ); 32 43 44 + const watchStatusOptions = isMovie 45 + ? ({ mediaType: "movie", movieId: String(id) } as const) 46 + : ({ mediaType: "show", showId: actualShowId || "" } as const); 47 + 48 + const { movieWatchHistory, watchHistory } = 49 + useMediaWatchStatus(watchStatusOptions); 50 + 33 51 const rawMediaId = isMovie ? String(id) : actualShowId || String(id); 34 52 35 53 const handleMarkWatched = () => { ··· 44 62 } 45 63 }; 46 64 65 + const episodeWatchHistory = useMemo(() => { 66 + if (isMovie || !watchHistory || !Array.isArray(watchHistory)) return []; 67 + if (seasonNumber === undefined || episodeNumber === undefined) return []; 68 + return watchHistory.filter( 69 + (ep: { seasonNumber: number; episodeNumber: number }) => 70 + ep.seasonNumber === seasonNumber && ep.episodeNumber === episodeNumber, 71 + ); 72 + }, [isMovie, watchHistory, seasonNumber, episodeNumber]); 73 + 74 + const confirmEntryCount = isMovie 75 + ? movieWatchHistory?.length || 0 76 + : episodeWatchHistory.length; 77 + 78 + const confirmTitle = isMovie 79 + ? title || "" 80 + : title 81 + ? `${title} S${seasonNumber}E${episodeNumber}` 82 + : `S${seasonNumber}E${episodeNumber}`; 83 + 47 84 const handleUnmarkWatched = () => { 85 + if (confirmEntryCount > 1) { 86 + setConfirmRemoveOpen(true); 87 + return; 88 + } 89 + 48 90 if (isMovie) { 49 91 watchActions.unmarkMovieWatched(); 50 92 } else if ( ··· 56 98 } 57 99 }; 58 100 101 + const handleConfirmRemove = () => { 102 + if (isMovie) { 103 + watchActions.unmarkMovieWatched(); 104 + } else if ( 105 + actualShowId && 106 + seasonNumber !== undefined && 107 + episodeNumber !== undefined 108 + ) { 109 + watchActions.unmarkEpisodeWatched(seasonNumber, episodeNumber, "all"); 110 + } 111 + setConfirmRemoveOpen(false); 112 + }; 113 + 59 114 return ( 60 115 <> 61 116 <MediaCard 62 117 {...rest} 63 118 id={id} 119 + title={title} 64 120 type={type} 65 121 seasonNumber={seasonNumber} 66 122 episodeNumber={episodeNumber} ··· 86 142 episodeNumber={episodeNumber} 87 143 open={listDialogOpen} 88 144 onOpenChange={setListDialogOpen} 145 + /> 146 + <ConfirmRemoveDialog 147 + open={confirmRemoveOpen} 148 + onOpenChange={setConfirmRemoveOpen} 149 + title={confirmTitle} 150 + entryCount={confirmEntryCount} 151 + onConfirm={handleConfirmRemove} 152 + isPending={ 153 + isMovie 154 + ? watchActions.isUnmarkMoviePending 155 + : watchActions.isUnmarkEpisodePending 156 + } 89 157 /> 90 158 </> 91 159 );
+51 -5
apps/web/src/components/FeedItemActions.tsx
··· 1 1 import { Bookmark, BookmarkCheck, Library, Loader2 } from "lucide-react"; 2 - import { useState } from "react"; 2 + import { useMemo, useState } from "react"; 3 + import ConfirmRemoveDialog from "#/components/ConfirmRemoveDialog"; 3 4 import ManageListsDialog from "#/components/ManageListsDialog"; 4 5 import { 5 6 useListItemStatus, ··· 10 11 interface FeedItemActionsMovieProps { 11 12 type: "movie"; 12 13 mediaId: string; 14 + title?: string; 13 15 } 14 16 15 17 interface FeedItemActionsShowProps { ··· 17 19 mediaId: string; 18 20 seasonNumber: number; 19 21 episodeNumber: number; 22 + title?: string; 20 23 } 21 24 22 25 type FeedItemActionsProps = ··· 25 28 26 29 export default function FeedItemActions(props: FeedItemActionsProps) { 27 30 const [listDialogOpen, setListDialogOpen] = useState(false); 31 + const [confirmRemoveOpen, setConfirmRemoveOpen] = useState(false); 28 32 29 33 const isShow = props.type === "show"; 30 - const { mediaId } = props; 34 + const { mediaId, title } = props; 31 35 32 36 // For list operations, use episode-scoped mediaId so we add/remove 33 37 // the specific episode, not the entire show. ··· 40 44 ? ({ mediaType: "show", showId: mediaId } as const) 41 45 : ({ mediaType: "movie", movieId: mediaId } as const); 42 46 43 - const { isWatched, isEpisodeWatched } = 47 + const { isWatched, isEpisodeWatched, movieWatchHistory, watchHistory } = 44 48 useMediaWatchStatus(watchStatusOptions); 45 49 46 50 const watchActions = useWatchActions(watchStatusOptions); 47 51 52 + const episodeWatchHistory = useMemo(() => { 53 + if (!isShow || !watchHistory || !Array.isArray(watchHistory)) return []; 54 + const { seasonNumber, episodeNumber } = props as FeedItemActionsShowProps; 55 + return watchHistory.filter( 56 + (ep: { seasonNumber: number; episodeNumber: number }) => 57 + ep.seasonNumber === seasonNumber && ep.episodeNumber === episodeNumber, 58 + ); 59 + }, [isShow, watchHistory, props]); 60 + 48 61 const { otherLists, userLists, listsForItem } = useListItemStatus({ 49 62 mediaType: props.type, 50 63 mediaId: listMediaId, ··· 57 70 let isInShelf: boolean; 58 71 let isShelfPending: boolean; 59 72 let handleToggleShelf: () => void; 73 + let confirmEntryCount = 0; 74 + let confirmTitle = title || ""; 75 + let handleConfirmRemove: () => void; 60 76 61 77 if (isShow) { 62 78 const { seasonNumber, episodeNumber } = props; ··· 68 84 watchActions.isMarkEpisodePending || watchActions.isUnmarkEpisodePending; 69 85 handleToggleShelf = () => { 70 86 if (episodeWatched) { 71 - watchActions.unmarkEpisodeWatched(seasonNumber, episodeNumber); 87 + if (episodeWatchHistory.length > 1) { 88 + setConfirmRemoveOpen(true); 89 + } else { 90 + watchActions.unmarkEpisodeWatched(seasonNumber, episodeNumber); 91 + } 72 92 } else { 73 93 watchActions.markEpisodeWatched(seasonNumber, episodeNumber); 74 94 } 75 95 }; 96 + confirmEntryCount = episodeWatchHistory.length; 97 + confirmTitle = title 98 + ? `${title} S${seasonNumber}E${episodeNumber}` 99 + : `S${seasonNumber}E${episodeNumber}`; 100 + handleConfirmRemove = () => { 101 + watchActions.unmarkEpisodeWatched(seasonNumber, episodeNumber, "all"); 102 + setConfirmRemoveOpen(false); 103 + }; 76 104 } else { 77 105 isInShelf = !!isWatched; 78 106 isShelfPending = 79 107 watchActions.isMarkMoviePending || watchActions.isUnmarkMoviePending; 80 108 handleToggleShelf = () => { 81 109 if (isWatched) { 82 - watchActions.unmarkMovieWatched(); 110 + if (movieWatchHistory && movieWatchHistory.length > 1) { 111 + setConfirmRemoveOpen(true); 112 + } else { 113 + watchActions.unmarkMovieWatched(); 114 + } 83 115 } else { 84 116 watchActions.markMovieWatched(); 85 117 } 118 + }; 119 + confirmEntryCount = movieWatchHistory?.length || 0; 120 + handleConfirmRemove = () => { 121 + watchActions.unmarkMovieWatched(); 122 + setConfirmRemoveOpen(false); 86 123 }; 87 124 } 88 125 ··· 145 182 mediaId={listMediaId} 146 183 open={listDialogOpen} 147 184 onOpenChange={setListDialogOpen} 185 + /> 186 + 187 + <ConfirmRemoveDialog 188 + open={confirmRemoveOpen} 189 + onOpenChange={setConfirmRemoveOpen} 190 + title={confirmTitle} 191 + entryCount={confirmEntryCount} 192 + onConfirm={handleConfirmRemove} 193 + isPending={isShelfPending} 148 194 /> 149 195 </div> 150 196 );
+18 -14
apps/web/src/components/Pagination.tsx
··· 12 12 if (totalPages <= 1) return null; 13 13 14 14 const getPageNumbers = () => { 15 - const pages: (number | string)[] = []; 15 + type PageItem = 16 + | { type: "page"; value: number } 17 + | { type: "ellipsis"; id: string }; 18 + const pages: PageItem[] = []; 16 19 const maxVisible = 5; 17 20 18 21 if (totalPages <= maxVisible + 2) { 19 - for (let i = 1; i <= totalPages; i++) pages.push(i); 22 + for (let i = 1; i <= totalPages; i++) 23 + pages.push({ type: "page", value: i }); 20 24 } else { 21 - pages.push(1); 22 - if (page > 3) pages.push("..."); 25 + pages.push({ type: "page", value: 1 }); 26 + if (page > 3) pages.push({ type: "ellipsis", id: "left" }); 23 27 24 28 const start = Math.max(2, page - 1); 25 29 const end = Math.min(totalPages - 1, page + 1); 26 - for (let i = start; i <= end; i++) pages.push(i); 30 + for (let i = start; i <= end; i++) pages.push({ type: "page", value: i }); 27 31 28 - if (page < totalPages - 2) pages.push("..."); 29 - pages.push(totalPages); 32 + if (page < totalPages - 2) pages.push({ type: "ellipsis", id: "right" }); 33 + pages.push({ type: "page", value: totalPages }); 30 34 } 31 35 return pages; 32 36 }; ··· 42 46 ← Prev 43 47 </button> 44 48 45 - {getPageNumbers().map((p, i) => 46 - p === "..." ? ( 49 + {getPageNumbers().map((item) => 50 + item.type === "ellipsis" ? ( 47 51 <span 48 - key={`ellipsis-${i}`} 52 + key={item.id} 49 53 className="flex h-9 w-9 items-center justify-center text-(--foreground-muted) text-sm" 50 54 > 51 55 ... 52 56 </span> 53 57 ) : ( 54 58 <button 55 - key={p} 59 + key={item.value} 56 60 type="button" 57 - onClick={() => onPageChange(p as number)} 61 + onClick={() => onPageChange(item.value)} 58 62 className={`flex h-9 w-9 items-center justify-center rounded-md border font-medium text-sm transition-colors ${ 59 - page === p 63 + page === item.value 60 64 ? "border-(--accent) bg-(--accent) text-[#3f2e00]" 61 65 : "border-(--border) bg-(--background-elevated) hover:bg-(--background-subtle)" 62 66 }`} 63 67 > 64 - {p} 68 + {item.value} 65 69 </button> 66 70 ), 67 71 )}
+2
apps/web/src/components/following/ActivityCard.tsx
··· 168 168 <FeedItemActions 169 169 type="movie" 170 170 mediaId={String(activity.movieId)} 171 + title={activity.title || ""} 171 172 /> 172 173 ) : ( 173 174 <FeedItemActions ··· 175 176 mediaId={String(activity.showId)} 176 177 seasonNumber={Number(activity.seasonNumber || 0)} 177 178 episodeNumber={Number(activity.episodeNumber || 0)} 179 + title={activity.showTitle || ""} 178 180 /> 179 181 )} 180 182 </div>
+10 -1
apps/web/src/components/shows/EpisodeList.tsx
··· 10 10 nextEpisode?: { seasonNumber: number; episodeNumber: number } | null; 11 11 onMarkEpisode: (seasonNumber: number, episodeNumber: number) => void; 12 12 onUnmarkEpisode: (seasonNumber: number, episodeNumber: number) => void; 13 + onUnmarkEpisodeAll?: (seasonNumber: number, episodeNumber: number) => void; 13 14 processingEpisode: { seasonNumber: number; episodeNumber: number } | null; 14 15 unmarkingEpisode: { seasonNumber: number; episodeNumber: number } | null; 15 16 isLoading?: boolean; ··· 24 25 nextEpisode = null, 25 26 onMarkEpisode, 26 27 onUnmarkEpisode, 28 + onUnmarkEpisodeAll, 27 29 processingEpisode, 28 30 unmarkingEpisode, 29 31 isLoading = false, ··· 47 49 return ( 48 50 <div> 49 51 {episodes.map((episode, index) => { 50 - const isWatched = watchHistory.some( 52 + const episodeWatchHistory = watchHistory.filter( 51 53 (ep) => 52 54 ep.seasonNumber === seasonNumber && 53 55 ep.episodeNumber === episode.episode_number, 54 56 ); 57 + const isWatched = episodeWatchHistory.length > 0; 55 58 const isUpNext = 56 59 nextEpisode?.seasonNumber === seasonNumber && 57 60 nextEpisode?.episodeNumber === episode.episode_number; ··· 79 82 onUnmarkWatched={() => 80 83 onUnmarkEpisode(seasonNumber, episode.episode_number) 81 84 } 85 + onUnmarkAllWatched={ 86 + onUnmarkEpisodeAll 87 + ? () => onUnmarkEpisodeAll(seasonNumber, episode.episode_number) 88 + : undefined 89 + } 90 + watchHistoryCount={episodeWatchHistory.length} 82 91 isLast={index === episodes.length - 1} 83 92 /> 84 93 );
+37 -5
apps/web/src/components/shows/EpisodeRow.tsx
··· 1 1 import { Link } from "@tanstack/react-router"; 2 2 import { Check, Loader2 } from "lucide-react"; 3 + import { useState } from "react"; 4 + import ConfirmRemoveDialog from "#/components/ConfirmRemoveDialog"; 3 5 import { useAuth } from "#/lib/auth-context"; 4 6 import { withUserLocale } from "#/lib/date-utils"; 5 7 ··· 22 24 isUnmarking: boolean; 23 25 onMarkWatched: () => void; 24 26 onUnmarkWatched: () => void; 27 + onUnmarkAllWatched?: () => void; 28 + watchHistoryCount?: number; 25 29 isLast?: boolean; 26 30 } 27 31 ··· 59 63 isUnmarking, 60 64 onMarkWatched, 61 65 onUnmarkWatched, 66 + onUnmarkAllWatched, 67 + watchHistoryCount = 0, 62 68 isLast = false, 63 69 }: EpisodeRowProps) { 64 70 const { userSettings } = useAuth(); 65 71 const userTimezone = userSettings?.timezone; 72 + const [confirmRemoveOpen, setConfirmRemoveOpen] = useState(false); 73 + 74 + const dialogTitle = `${episode.name} S${seasonNumber}E${episode.episode_number}`; 75 + 76 + const handleUnmarkClick = (e: React.MouseEvent) => { 77 + e.preventDefault(); 78 + e.stopPropagation(); 79 + if (watchHistoryCount > 1) { 80 + setConfirmRemoveOpen(true); 81 + } else { 82 + onUnmarkWatched(); 83 + } 84 + }; 85 + 86 + const handleConfirmRemove = () => { 87 + if (onUnmarkAllWatched) { 88 + onUnmarkAllWatched(); 89 + } else { 90 + onUnmarkWatched(); 91 + } 92 + setConfirmRemoveOpen(false); 93 + }; 66 94 67 95 return ( 68 96 <Link ··· 98 126 {isWatched ? ( 99 127 <button 100 128 type="button" 101 - onClick={(e) => { 102 - e.preventDefault(); 103 - e.stopPropagation(); 104 - onUnmarkWatched(); 105 - }} 129 + onClick={handleUnmarkClick} 106 130 disabled={isUnmarking} 107 131 className="flex items-center gap-1.5 rounded-md bg-green-500/10 px-3 py-1.5 font-medium text-green-600 text-xs transition-colors hover:bg-green-500/20" 108 132 title="Remove from shelf" ··· 141 165 )} 142 166 </button> 143 167 )} 168 + <ConfirmRemoveDialog 169 + open={confirmRemoveOpen} 170 + onOpenChange={setConfirmRemoveOpen} 171 + title={dialogTitle} 172 + entryCount={watchHistoryCount} 173 + onConfirm={handleConfirmRemove} 174 + isPending={isUnmarking} 175 + /> 144 176 </Link> 145 177 ); 146 178 }
+59
apps/web/src/components/ui/sonner.tsx
··· 1 + import { 2 + CircleCheckIcon, 3 + InfoIcon, 4 + Loader2Icon, 5 + OctagonXIcon, 6 + TriangleAlertIcon, 7 + } from "lucide-react"; 8 + import { useEffect, useState } from "react"; 9 + import { Toaster as Sonner, type ToasterProps } from "sonner"; 10 + 11 + function getResolvedTheme(): "light" | "dark" { 12 + if (typeof document === "undefined") return "light"; 13 + const cls = document.documentElement.classList; 14 + if (cls.contains("dark")) return "dark"; 15 + if (cls.contains("light")) return "light"; 16 + return window.matchMedia("(prefers-color-scheme: dark)").matches 17 + ? "dark" 18 + : "light"; 19 + } 20 + 21 + const Toaster = ({ ...props }: ToasterProps) => { 22 + const [theme, setTheme] = useState<"light" | "dark">(getResolvedTheme); 23 + 24 + useEffect(() => { 25 + const observer = new MutationObserver(() => { 26 + setTheme(getResolvedTheme()); 27 + }); 28 + observer.observe(document.documentElement, { 29 + attributes: true, 30 + attributeFilter: ["class"], 31 + }); 32 + return () => observer.disconnect(); 33 + }, []); 34 + 35 + return ( 36 + <Sonner 37 + theme={theme} 38 + className="toaster group" 39 + icons={{ 40 + success: <CircleCheckIcon className="size-4" />, 41 + info: <InfoIcon className="size-4" />, 42 + warning: <TriangleAlertIcon className="size-4" />, 43 + error: <OctagonXIcon className="size-4" />, 44 + loading: <Loader2Icon className="size-4 animate-spin" />, 45 + }} 46 + style={ 47 + { 48 + "--normal-bg": "var(--popover)", 49 + "--normal-text": "var(--popover-foreground)", 50 + "--normal-border": "var(--border)", 51 + "--border-radius": "var(--radius)", 52 + } as React.CSSProperties 53 + } 54 + {...props} 55 + /> 56 + ); 57 + }; 58 + 59 + export { Toaster };
+8 -2
apps/web/src/lib/auth-context.tsx
··· 45 45 throwOnError: true, 46 46 }); 47 47 return data ?? null; 48 - } catch (error: any) { 49 - if (error.status === 401 || error.statusCode === 401) { 48 + } catch (error) { 49 + if ( 50 + typeof error === "object" && 51 + error !== null && 52 + ("status" in error || "statusCode" in error) && 53 + ((error as Record<string, unknown>).status === 401 || 54 + (error as Record<string, unknown>).statusCode === 401) 55 + ) { 50 56 return null; 51 57 } 52 58 throw error;
+15 -3
apps/web/src/lib/hooks/useMedia.ts
··· 156 156 157 157 return useMutation({ 158 158 mutationFn: async (variables: { 159 - path: { showId: string; seasonNumber: number; episodeNumber: number }; 159 + path: { showId: string }; 160 + query?: { 161 + mode?: "latest" | "all"; 162 + seasonNumber?: number; 163 + episodeNumber?: number; 164 + }; 160 165 }) => { 161 166 const result = await showsControllerUnmarkWatched(variables); 162 167 return result.data; ··· 226 231 ); 227 232 }; 228 233 229 - const handleUnmarkEpisode = (seasonNumber: number, episodeNumber: number) => { 234 + const handleUnmarkEpisode = ( 235 + seasonNumber: number, 236 + episodeNumber: number, 237 + mode: "latest" | "all" = "latest", 238 + ) => { 230 239 if (!isAuthenticated) return; 231 240 setUnmarkingEpisode({ seasonNumber, episodeNumber }); 232 241 unmarkEpisodeMutation.mutate( 233 - { path: { showId, seasonNumber, episodeNumber } }, 242 + { 243 + path: { showId }, 244 + query: { seasonNumber, episodeNumber, mode }, 245 + }, 234 246 { onSettled: () => setUnmarkingEpisode(null) }, 235 247 ); 236 248 };
+2
apps/web/src/routes/__root.tsx
··· 6 6 Scripts, 7 7 } from "@tanstack/react-router"; 8 8 import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; 9 + import { Toaster } from "#/components/ui/sonner"; 9 10 import { AuthProvider } from "#/lib/auth-context"; 10 11 import { 11 12 DefaultErrorComponent, ··· 58 59 <main className="flex-1">{children}</main> 59 60 <Footer /> 60 61 </div> 62 + <Toaster /> 61 63 </AuthProvider> 62 64 <TanStackDevtools 63 65 config={{ position: "bottom-right" }}
+8 -2
apps/web/src/routes/profile.$handle.tsx
··· 18 18 }), 19 19 ); 20 20 return { profile }; 21 - } catch (error: any) { 22 - if (error.status === 404 || error.statusCode === 404) { 21 + } catch (error) { 22 + if ( 23 + typeof error === "object" && 24 + error !== null && 25 + ("status" in error || "statusCode" in error) && 26 + ((error as Record<string, unknown>).status === 404 || 27 + (error as Record<string, unknown>).statusCode === 404) 28 + ) { 23 29 throw notFound(); 24 30 } 25 31 throw error;
+120 -38
apps/web/src/routes/settings.tsx
··· 4 4 getAccountDeletionProgress, 5 5 getAccountDeletionStatusMessage, 6 6 isActiveAccountDeletionStatus, 7 + type UserProfileDto, 7 8 usersControllerDeleteMyAccountMutation, 8 9 usersControllerDeleteMyAvatarMutation, 9 10 usersControllerGetMyAccountDeletionOptions, 10 11 usersControllerGetMySettingsOptions, 11 12 usersControllerUpdateMyProfileMutation, 12 13 usersControllerUpdateMySettingsMutation, 13 - usersControllerUploadMyAvatarMutation, 14 14 } from "@opnshelf/api"; 15 15 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 16 16 import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; ··· 24 24 User, 25 25 } from "lucide-react"; 26 26 import { useEffect, useRef, useState } from "react"; 27 + import { toast } from "sonner"; 27 28 import TimezoneSelector from "#/components/TimezoneSelector"; 28 29 import { Button } from "#/components/ui/button"; 29 30 import { ··· 34 35 DialogTitle, 35 36 } from "#/components/ui/dialog"; 36 37 import { Switch } from "#/components/ui/switch"; 38 + import { apiConfig } from "#/lib/api"; 37 39 import { useAuth } from "#/lib/auth-context"; 38 40 39 41 function isUnauthorizedError(error: unknown): boolean { ··· 89 91 queryClient.invalidateQueries({ 90 92 queryKey: usersControllerGetMySettingsOptions().queryKey, 91 93 }); 94 + }, 95 + onError: (error) => { 96 + toast.error( 97 + error instanceof Error ? error.message : "Failed to update settings", 98 + ); 92 99 }, 93 100 }); 94 101 ··· 99 106 queryClient.invalidateQueries({ 100 107 queryKey: authControllerMeOptions().queryKey, 101 108 }); 109 + toast.success("Display name updated"); 110 + }, 111 + onError: (error) => { 112 + toast.error( 113 + error instanceof Error ? error.message : "Failed to update profile", 114 + ); 102 115 }, 103 116 }); 104 117 118 + async function uploadAvatar(file: File): Promise<UserProfileDto> { 119 + const formData = new FormData(); 120 + formData.append("avatar", file); 121 + 122 + const response = await fetch( 123 + `${apiConfig.baseUrl}/users/me/profile/avatar`, 124 + { 125 + method: "POST", 126 + body: formData, 127 + credentials: "include", 128 + }, 129 + ); 130 + 131 + if (!response.ok) { 132 + const errorData = await response.json().catch(() => ({ 133 + message: "Failed to upload avatar", 134 + })); 135 + throw new Error(errorData.message || "Failed to upload avatar"); 136 + } 137 + 138 + return response.json(); 139 + } 140 + 105 141 const uploadAvatarMutation = useMutation({ 106 142 mutationKey: ["users", "me", "profile", "avatar", "upload"], 107 - ...usersControllerUploadMyAvatarMutation(), 143 + mutationFn: uploadAvatar, 108 144 onSuccess: () => { 109 145 queryClient.invalidateQueries({ 110 146 queryKey: authControllerMeOptions().queryKey, 111 147 }); 148 + toast.success("Profile photo updated"); 149 + }, 150 + onError: (error) => { 151 + toast.error( 152 + error instanceof Error 153 + ? error.message 154 + : "Failed to upload profile photo", 155 + ); 112 156 }, 113 157 }); 114 158 ··· 119 163 queryClient.invalidateQueries({ 120 164 queryKey: authControllerMeOptions().queryKey, 121 165 }); 166 + toast.success("Profile photo removed"); 167 + }, 168 + onError: (error) => { 169 + toast.error( 170 + error instanceof Error 171 + ? error.message 172 + : "Failed to remove profile photo", 173 + ); 122 174 }, 123 175 }); 124 176 125 177 const deleteAccountMutation = useMutation({ 126 178 mutationKey: ["users", "me", "account", "delete"], 127 179 ...usersControllerDeleteMyAccountMutation(), 180 + onError: (error) => { 181 + toast.error( 182 + error instanceof Error ? error.message : "Failed to delete account", 183 + ); 184 + }, 128 185 }); 129 186 130 187 // Display name state ··· 137 194 const fileInputRef = useRef<HTMLInputElement>(null); 138 195 139 196 const handleAvatarUpload = (file: File) => { 140 - uploadAvatarMutation.mutate({ 141 - body: { avatar: file }, 142 - }); 197 + uploadAvatarMutation.mutate(file); 143 198 }; 144 199 145 200 // Deletion dialog state ··· 381 436 type="text" 382 437 value={`@${user.handle}`} 383 438 disabled 384 - className="input bg-(--background-subtle)" 439 + className="input cursor-not-allowed bg-(--background-subtle)" 385 440 readOnly 386 441 /> 387 442 <p className="text-(--foreground-muted) text-xs"> ··· 401 456 </p> 402 457 403 458 {isDeleting && deletionJob ? ( 404 - <div className="space-y-3"> 405 - <div className="flex items-center gap-2 text-red-800 dark:text-red-200"> 406 - <Loader2 className="h-4 w-4 animate-spin" /> 407 - <span className="font-medium text-sm">{deletionMessage}</span> 408 - </div> 409 - {deletionProgress !== null && ( 410 - <div className="h-2 w-full overflow-hidden rounded-full bg-red-200 dark:bg-red-900"> 411 - <div 412 - className="h-full rounded-full bg-red-600 transition-all dark:bg-red-400" 413 - style={{ width: `${deletionProgress}%` }} 414 - /> 415 - </div> 416 - )} 417 - {deletionJob.status === "failed" && ( 418 - <div className="space-y-2"> 419 - <p className="text-red-700 text-sm dark:text-red-300"> 420 - {deletionJob.lastError} 421 - </p> 422 - <Button 423 - variant="outline" 424 - onClick={handleDeleteAccount} 425 - disabled={deleteAccountMutation.isPending} 426 - className="border-red-300 text-red-700 hover:bg-red-100 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-900" 427 - > 428 - {deleteAccountMutation.isPending ? ( 429 - <Loader2 className="mr-1 h-4 w-4 animate-spin" /> 430 - ) : null} 431 - Retry 432 - </Button> 433 - </div> 434 - )} 459 + <div className="flex items-center gap-2 text-red-800 dark:text-red-200"> 460 + <Loader2 className="h-4 w-4 animate-spin" /> 461 + <span className="font-medium text-sm"> 462 + Account deletion in progress… 463 + </span> 435 464 </div> 436 465 ) : ( 437 466 <button ··· 450 479 </section> 451 480 </div> 452 481 453 - {/* Delete Account Dialog */} 482 + {/* Delete Account Confirmation Dialog */} 454 483 <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> 455 484 <DialogContent className="sm:max-w-[425px]"> 456 485 <DialogHeader> ··· 528 557 )} 529 558 Permanently Delete Account 530 559 </Button> 560 + </div> 561 + </DialogContent> 562 + </Dialog> 563 + 564 + {/* Deletion Progress Dialog — non-dismissible */} 565 + <Dialog open={isDeleting && !!deletionJob}> 566 + <DialogContent 567 + showCloseButton={false} 568 + onInteractOutside={(e) => e.preventDefault()} 569 + onEscapeKeyDown={(e) => e.preventDefault()} 570 + className="sm:max-w-[425px]" 571 + > 572 + <DialogHeader> 573 + <DialogTitle className="flex items-center gap-2 text-red-700 dark:text-red-300"> 574 + <AlertTriangle className="h-5 w-5" /> 575 + Deleting your account 576 + </DialogTitle> 577 + <DialogDescription> 578 + Please do not close this page until deletion is complete. 579 + </DialogDescription> 580 + </DialogHeader> 581 + 582 + <div className="space-y-4 py-4"> 583 + <div className="flex items-center gap-2 text-red-800 dark:text-red-200"> 584 + <Loader2 className="h-4 w-4 animate-spin" /> 585 + <span className="font-medium text-sm">{deletionMessage}</span> 586 + </div> 587 + {deletionProgress !== null && ( 588 + <div className="h-2 w-full overflow-hidden rounded-full bg-red-200 dark:bg-red-900"> 589 + <div 590 + className="h-full rounded-full bg-red-600 transition-all dark:bg-red-400" 591 + style={{ width: `${deletionProgress}%` }} 592 + /> 593 + </div> 594 + )} 595 + {deletionJob?.status === "failed" && ( 596 + <div className="space-y-2"> 597 + <p className="text-red-700 text-sm dark:text-red-300"> 598 + {deletionJob.lastError} 599 + </p> 600 + <Button 601 + variant="outline" 602 + onClick={handleDeleteAccount} 603 + disabled={deleteAccountMutation.isPending} 604 + className="border-red-300 text-red-700 hover:bg-red-100 dark:border-red-800 dark:text-red-300 dark:hover:bg-red-900" 605 + > 606 + {deleteAccountMutation.isPending ? ( 607 + <Loader2 className="mr-1 h-4 w-4 animate-spin" /> 608 + ) : null} 609 + Retry 610 + </Button> 611 + </div> 612 + )} 531 613 </div> 532 614 </DialogContent> 533 615 </Dialog>
+3
apps/web/src/routes/shows/$showId/$showName/index.tsx
··· 367 367 nextEpisode={nextEpisode || null} 368 368 onMarkEpisode={handleMarkEpisode} 369 369 onUnmarkEpisode={handleUnmarkEpisode} 370 + onUnmarkEpisodeAll={(sn, en) => 371 + handleUnmarkEpisode(sn, en, "all") 372 + } 370 373 processingEpisode={processingEpisode} 371 374 unmarkingEpisode={unmarkingEpisode} 372 375 isLoading={
+3
apps/web/src/routes/shows/$showId/$showName/seasons.$seasonNumber/index.tsx
··· 378 378 nextEpisode={upNextEpisode || null} 379 379 onMarkEpisode={handleMarkEpisode} 380 380 onUnmarkEpisode={handleUnmarkEpisode} 381 + onUnmarkEpisodeAll={(sn, en) => 382 + handleUnmarkEpisode(sn, en, "all") 383 + } 381 384 processingEpisode={processingEpisode} 382 385 unmarkingEpisode={unmarkingEpisode} 383 386 isLoading={false}
+10 -1
backend/src/movies/movies.service.ts
··· 337 337 ) { 338 338 // Fetch movie details from TMDB and upsert in database 339 339 const movieData = await this.getMovieDetails(movieId); 340 + 341 + if (!movieData || !movieData.id) { 342 + throw new Error( 343 + `Failed to fetch movie details for movieId ${movieId}: invalid response from TMDB`, 344 + ); 345 + } 346 + 347 + const normalizedMovieId = movieData.id.toString(); 348 + 340 349 await this.upsertMovie(movieData); 341 350 342 351 // Create new TrackedMovie record (since rkey is unique, each watch is a new record) ··· 346 355 rkey, 347 356 cid, 348 357 userDid, 349 - movieId, 358 + movieId: normalizedMovieId, 350 359 watchedDate: new Date(watchedAt), 351 360 status: "watched", 352 361 },
+42 -9
backend/src/shows/shows.service.ts
··· 941 941 watchedAt: string, 942 942 ) { 943 943 const showData = await this.getShowDetails(showId); 944 + 945 + if (!showData || !showData.id) { 946 + throw new Error( 947 + `Failed to fetch show details for showId ${showId}: invalid response from TMDB`, 948 + ); 949 + } 950 + 951 + const normalizedShowId = showData.id.toString(); 952 + 953 + if (normalizedShowId !== showId) { 954 + this.logger.warn( 955 + `Show ID mismatch: requested ${showId}, TMDB returned ${normalizedShowId}. Using TMDB ID for tracked episode.`, 956 + ); 957 + } 958 + 944 959 await this.upsertShow(showData); 945 - await this.syncShowMetadata(showId).catch((err) => 960 + await this.syncShowMetadata(normalizedShowId).catch((err) => 946 961 this.logger.warn( 947 - `Failed to sync metadata for show ${showId}: ${err instanceof Error ? err.message : String(err)}`, 962 + `Failed to sync metadata for show ${normalizedShowId}: ${err instanceof Error ? err.message : String(err)}`, 948 963 ), 949 964 ); 950 965 ··· 954 969 rkey, 955 970 cid, 956 971 userDid, 957 - showId, 972 + showId: normalizedShowId, 958 973 seasonNumber, 959 974 episodeNumber, 960 975 watchedDate: new Date(watchedAt), ··· 1160 1175 } 1161 1176 1162 1177 const showData = await this.getShowDetails(showId); 1178 + 1179 + if (!showData || !showData.id) { 1180 + throw new Error( 1181 + `Failed to fetch show details for showId ${showId}: invalid response from TMDB`, 1182 + ); 1183 + } 1184 + 1185 + const normalizedShowId = showData.id.toString(); 1186 + 1163 1187 await this.upsertShow(showData); 1164 - await this.syncShowMetadata(showId).catch((err) => 1188 + await this.syncShowMetadata(normalizedShowId).catch((err) => 1165 1189 this.logger.warn( 1166 - `Failed to sync metadata for show ${showId}: ${err instanceof Error ? err.message : String(err)}`, 1190 + `Failed to sync metadata for show ${normalizedShowId}: ${err instanceof Error ? err.message : String(err)}`, 1167 1191 ), 1168 1192 ); 1169 1193 ··· 1201 1225 rkey: result.rkey, 1202 1226 cid: result.cid, 1203 1227 userDid, 1204 - showId, 1228 + showId: normalizedShowId, 1205 1229 seasonNumber: result.seasonNumber, 1206 1230 episodeNumber: result.episodeNumber, 1207 1231 watchedDate: new Date(watchedAt), ··· 1284 1308 } 1285 1309 1286 1310 const showData = await this.getShowDetails(showId); 1311 + 1312 + if (!showData || !showData.id) { 1313 + throw new Error( 1314 + `Failed to fetch show details for showId ${showId}: invalid response from TMDB`, 1315 + ); 1316 + } 1317 + 1318 + const normalizedShowId = showData.id.toString(); 1319 + 1287 1320 await this.upsertShow(showData); 1288 - await this.syncShowMetadata(showId).catch((err) => 1321 + await this.syncShowMetadata(normalizedShowId).catch((err) => 1289 1322 this.logger.warn( 1290 - `Failed to sync metadata for show ${showId}: ${err instanceof Error ? err.message : String(err)}`, 1323 + `Failed to sync metadata for show ${normalizedShowId}: ${err instanceof Error ? err.message : String(err)}`, 1291 1324 ), 1292 1325 ); 1293 1326 ··· 1325 1358 rkey: result.rkey, 1326 1359 cid: result.cid, 1327 1360 userDid, 1328 - showId, 1361 + showId: normalizedShowId, 1329 1362 seasonNumber: result.seasonNumber, 1330 1363 episodeNumber: result.episodeNumber, 1331 1364 watchedDate: new Date(watchedAt),
+15 -7
backend/src/users/profile.service.ts
··· 321 321 input.displayName !== undefined 322 322 ? input.displayName 323 323 : normalizeDisplayName(input.existingRecord?.displayName); 324 - const nextAvatar = normalizeProfileBlob( 325 - input.avatar !== undefined ? input.avatar : input.existingRecord?.avatar, 326 - ); 324 + const nextAvatar = 325 + input.avatar !== undefined ? input.avatar : input.existingRecord?.avatar; 327 326 328 - return profileSchema.build({ 327 + const record: ProfileRecord = { 328 + $type: "xyz.opnshelf.profile", 329 329 createdAt, 330 330 updatedAt, 331 - ...(nextDisplayName ? { displayName: nextDisplayName } : {}), 332 - ...(nextAvatar ? { avatar: nextAvatar } : {}), 333 - }); 331 + }; 332 + 333 + if (nextDisplayName) { 334 + record.displayName = nextDisplayName; 335 + } 336 + 337 + if (nextAvatar) { 338 + record.avatar = nextAvatar; 339 + } 340 + 341 + return record; 334 342 } 335 343 336 344 private async putProfileRecord(session: ATSession, record: ProfileRecord) {
+1 -1
package.json
··· 24 24 "husky": "^9.1.7", 25 25 "turbo": "^2.9.6" 26 26 }, 27 - "packageManager": "pnpm@10.30.2+sha512.36cdc707e7b7940a988c9c1ecf88d084f8514b5c3f085f53a2e244c2921d3b2545bc20dd4ebe1fc245feec463bb298aecea7a63ed1f7680b877dc6379d8d0cb4" 27 + "packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8" 28 28 }
+28
pnpm-lock.yaml
··· 65 65 lucide-react: 66 66 specifier: ^0.577.0 67 67 version: 0.577.0(react@19.2.5) 68 + next-themes: 69 + specifier: ^0.4.6 70 + version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) 68 71 posthog-js: 69 72 specifier: ^1.358.1 70 73 version: 1.369.3 ··· 80 83 slugify: 81 84 specifier: ^1.6.9 82 85 version: 1.6.9 86 + sonner: 87 + specifier: ^2.0.7 88 + version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) 83 89 tailwind-merge: 84 90 specifier: ^3.0.2 85 91 version: 3.5.0 ··· 5354 5360 neo-async@2.6.2: 5355 5361 resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} 5356 5362 5363 + next-themes@0.4.6: 5364 + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} 5365 + peerDependencies: 5366 + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc 5367 + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc 5368 + 5357 5369 nf3@0.3.16: 5358 5370 resolution: {integrity: sha512-Gs0xRPpUm2nDkqbi40NJ9g7qDIcjcJzgExiydnq6LAyqhI2jfno8wG3NKTL+IiJsx799UHOb1CnSd4Wg4SG4Pw==} 5359 5371 ··· 6066 6078 sonic-boom@3.8.1: 6067 6079 resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} 6068 6080 6081 + sonner@2.0.7: 6082 + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} 6083 + peerDependencies: 6084 + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc 6085 + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc 6086 + 6069 6087 source-map-js@1.2.1: 6070 6088 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 6071 6089 engines: {node: '>=0.10.0'} ··· 12555 12573 12556 12574 neo-async@2.6.2: {} 12557 12575 12576 + next-themes@0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5): 12577 + dependencies: 12578 + react: 19.2.5 12579 + react-dom: 19.2.5(react@19.2.5) 12580 + 12558 12581 nf3@0.3.16: {} 12559 12582 12560 12583 nitro@3.0.260415-beta(@electric-sql/pglite@0.4.1)(chokidar@5.0.0)(dotenv@17.4.2)(giget@2.0.0)(jiti@2.6.1)(lru-cache@11.3.5)(mysql2@3.15.3)(rollup@4.60.1)(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)): ··· 13396 13419 sonic-boom@3.8.1: 13397 13420 dependencies: 13398 13421 atomic-sleep: 1.0.0 13422 + 13423 + sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): 13424 + dependencies: 13425 + react: 19.2.5 13426 + react-dom: 19.2.5(react@19.2.5) 13399 13427 13400 13428 source-map-js@1.2.1: {} 13401 13429