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.

fix: replace broken .btn classes with explicit Tailwind styling across header, footer, and theme toggle; update Your Library to use /users/{userDid}/shelf endpoint

+156 -163
+10 -10
apps/web/src/components/Footer.tsx
··· 48 48 {footerLinks.social.map((item) => { 49 49 const Icon = item.icon; 50 50 return ( 51 - <a 52 - key={item.name} 53 - href={item.href} 54 - target="_blank" 55 - rel="noopener noreferrer" 56 - className="btn btn-ghost h-9 w-9 p-0 text-[var(--foreground-muted)] hover:text-[var(--foreground)]" 57 - aria-label={item.name} 58 - > 59 - <Icon className="h-4 w-4" /> 60 - </a> 51 + <a 52 + key={item.name} 53 + href={item.href} 54 + target="_blank" 55 + rel="noopener noreferrer" 56 + className="flex h-9 w-9 items-center justify-center rounded-md text-[var(--foreground-muted)] transition-colors hover:bg-[var(--background-subtle)] hover:text-[var(--foreground)]" 57 + aria-label={item.name} 58 + > 59 + <Icon className="h-4 w-4" /> 60 + </a> 61 61 ); 62 62 })} 63 63 </div>
+81 -100
apps/web/src/components/Header.tsx
··· 1 1 import { Link, useRouterState } from "@tanstack/react-router"; 2 2 import { 3 - Bell, 4 3 Calendar, 5 4 Film, 6 5 List, ··· 112 111 })} 113 112 </div> 114 113 115 - {/* Right side actions */} 116 - <div className="flex items-center gap-2"> 117 - {/* Search */} 118 - <SearchCommand /> 114 + {/* Right side actions */} 115 + <div className="flex items-center gap-2"> 116 + {/* Search */} 117 + <SearchCommand /> 119 118 120 - {/* Notifications - only show when authenticated */} 121 - {isAuthenticated && ( 122 - <button 123 - type="button" 124 - className="btn btn-ghost hidden h-9 w-9 p-0 sm:flex" 125 - aria-label="Notifications" 126 - > 127 - <Bell className="h-4 w-4" /> 128 - </button> 129 - )} 119 + {/* Theme Toggle */} 120 + <ThemeToggle /> 130 121 131 - {/* Theme toggle */} 132 - <div className="hidden sm:block"> 133 - <ThemeToggle /> 134 - </div> 135 - 136 - {/* User Menu or Login Button */} 137 - {isLoading ? ( 138 - <div className="h-9 w-9 animate-pulse rounded-full bg-[var(--background-subtle)]" /> 139 - ) : isAuthenticated && user ? ( 140 - <DropdownMenu> 141 - <DropdownMenuTrigger asChild> 142 - <button 143 - type="button" 144 - className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full border border-[var(--border)] bg-[var(--background-elevated)] hover:border-[var(--border-strong)]" 145 - aria-label="User menu" 146 - > 122 + {/* User Menu or Login Button */} 123 + {isLoading ? ( 124 + <div className="h-9 w-9 animate-pulse rounded-full bg-[var(--background-subtle)]" /> 125 + ) : isAuthenticated && user ? ( 126 + <DropdownMenu> 127 + <DropdownMenuTrigger asChild> 128 + <button 129 + type="button" 130 + className="hidden h-9 w-9 items-center justify-center overflow-hidden rounded-full border border-[var(--border)] bg-[var(--background-elevated)] hover:border-[var(--border-strong)] transition-colors sm:flex" 131 + aria-label="User menu" 132 + > 133 + {user.avatar ? ( 134 + <img 135 + src={user.avatar} 136 + alt={user.displayName || user.handle} 137 + className="h-full w-full object-cover" 138 + /> 139 + ) : ( 140 + <User className="h-4 w-4 text-[var(--foreground-muted)]" /> 141 + )} 142 + </button> 143 + </DropdownMenuTrigger> 144 + <DropdownMenuContent align="end" className="w-56"> 145 + <div className="flex items-center gap-2 p-2"> 146 + <div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--accent-subtle)]"> 147 147 {user.avatar ? ( 148 148 <img 149 149 src={user.avatar} 150 150 alt={user.displayName || user.handle} 151 - className="h-full w-full object-cover" 151 + className="h-full w-full rounded-full object-cover" 152 152 /> 153 153 ) : ( 154 - <User className="h-4 w-4 text-[var(--foreground-muted)]" /> 154 + <User className="h-4 w-4 text-[var(--accent)]" /> 155 155 )} 156 - </button> 157 - </DropdownMenuTrigger> 158 - <DropdownMenuContent align="end" className="w-56"> 159 - <div className="flex items-center gap-2 p-2"> 160 - <div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--accent-subtle)]"> 161 - {user.avatar ? ( 162 - <img 163 - src={user.avatar} 164 - alt={user.displayName || user.handle} 165 - className="h-full w-full rounded-full object-cover" 166 - /> 167 - ) : ( 168 - <User className="h-4 w-4 text-[var(--accent)]" /> 169 - )} 170 - </div> 171 - <div className="flex flex-col"> 172 - <span className="text-sm font-medium"> 173 - {user.displayName || user.handle} 174 - </span> 175 - <span className="text-xs text-[var(--foreground-muted)]"> 176 - @{user.handle} 177 - </span> 178 - </div> 156 + </div> 157 + <div className="flex flex-col"> 158 + <span className="text-sm font-medium"> 159 + {user.displayName || user.handle} 160 + </span> 161 + <span className="text-xs text-[var(--foreground-muted)]"> 162 + @{user.handle} 163 + </span> 179 164 </div> 180 - <DropdownMenuSeparator /> 181 - <DropdownMenuItem asChild> 182 - <Link to="/settings" className="cursor-pointer"> 183 - <User className="mr-2 h-4 w-4" /> 184 - Profile & Settings 185 - </Link> 186 - </DropdownMenuItem> 187 - <DropdownMenuSeparator /> 188 - <DropdownMenuItem 189 - onClick={logout} 190 - className="cursor-pointer text-red-600 focus:text-red-600" 191 - > 192 - <LogOut className="mr-2 h-4 w-4" /> 193 - Sign Out 194 - </DropdownMenuItem> 195 - </DropdownMenuContent> 196 - </DropdownMenu> 165 + </div> 166 + <DropdownMenuSeparator /> 167 + <DropdownMenuItem asChild> 168 + <Link to="/settings" className="cursor-pointer"> 169 + <User className="mr-2 h-4 w-4" /> 170 + Profile & Settings 171 + </Link> 172 + </DropdownMenuItem> 173 + <DropdownMenuSeparator /> 174 + <DropdownMenuItem 175 + onClick={logout} 176 + className="cursor-pointer text-red-600 focus:text-red-600" 177 + > 178 + <LogOut className="mr-2 h-4 w-4" /> 179 + Sign Out 180 + </DropdownMenuItem> 181 + </DropdownMenuContent> 182 + </DropdownMenu> 183 + ) : ( 184 + <Link 185 + to="/login" 186 + className="hidden items-center justify-center rounded-md bg-[var(--accent)] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[var(--accent-hover)] sm:flex" 187 + > 188 + Sign In 189 + </Link> 190 + )} 191 + 192 + {/* Mobile menu button - only visible on small screens */} 193 + <button 194 + type="button" 195 + className="flex h-9 w-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--background-elevated)] text-[var(--foreground-muted)] transition-colors hover:bg-[var(--background-subtle)] hover:text-[var(--foreground)] md:hidden" 196 + onClick={() => setMobileMenuOpen(!mobileMenuOpen)} 197 + aria-label={mobileMenuOpen ? "Close menu" : "Open menu"} 198 + aria-expanded={mobileMenuOpen} 199 + > 200 + {mobileMenuOpen ? ( 201 + <X className="h-5 w-5" /> 197 202 ) : ( 198 - <Link to="/login" className="btn btn-primary hidden sm:flex"> 199 - Sign In 200 - </Link> 203 + <Menu className="h-5 w-5" /> 201 204 )} 202 - 203 - {/* Mobile menu button */} 204 - <button 205 - type="button" 206 - className="btn btn-ghost h-9 w-9 p-0 md:hidden" 207 - onClick={() => setMobileMenuOpen(!mobileMenuOpen)} 208 - aria-label={mobileMenuOpen ? "Close menu" : "Open menu"} 209 - aria-expanded={mobileMenuOpen} 210 - > 211 - {mobileMenuOpen ? ( 212 - <X className="h-5 w-5" /> 213 - ) : ( 214 - <Menu className="h-5 w-5" /> 215 - )} 216 - </button> 217 - </div> 205 + </button> 206 + </div> 218 207 </nav> 219 208 220 209 {/* Mobile Navigation */} ··· 289 278 </Link> 290 279 </> 291 280 )} 292 - 293 - <div className="my-2 border-t border-[var(--border)]" /> 294 - <div className="flex items-center justify-between px-3 py-2"> 295 - <span className="text-sm text-[var(--foreground-muted)]"> 296 - Theme 297 - </span> 298 - <ThemeToggle /> 299 - </div> 300 281 </div> 301 282 </div> 302 283 )}
+4 -3
apps/web/src/components/SearchCommand.tsx
··· 144 144 <button 145 145 type="button" 146 146 onClick={() => handleOpenChange(true)} 147 - className="relative h-9 w-9 rounded-md border border-[var(--border)] bg-[var(--background-elevated)] p-2 text-[var(--foreground-muted)] transition-colors hover:bg-[var(--background-subtle)] hover:text-[var(--foreground)]" 147 + className="group flex h-9 items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--background-elevated)] px-3 text-[var(--foreground-muted)] transition-colors hover:border-[var(--border-strong)] hover:bg-[var(--background-subtle)] hover:text-[var(--foreground)]" 148 148 aria-label="Search" 149 149 > 150 150 <Search className="h-4 w-4" /> 151 - <kbd className="pointer-events-none absolute right-1 top-1 hidden h-4 select-none items-center gap-1 rounded border bg-muted px-1 font-mono text-[10px] font-medium sm:flex"> 152 - <span className="text-xs">⌘</span>K 151 + <span className="hidden text-sm sm:inline">Search</span> 152 + <kbd className="ml-1 hidden h-5 select-none items-center gap-0.5 rounded border border-[var(--border-strong)] bg-[var(--background-subtle)] px-1.5 font-mono text-[10px] font-medium text-[var(--foreground-muted)] sm:flex"> 153 + <span>⌘</span>K 153 154 </kbd> 154 155 </button> 155 156
+2 -2
apps/web/src/components/ThemeToggle.tsx
··· 51 51 return ( 52 52 <button 53 53 type="button" 54 - className="btn btn-secondary h-9 w-9 p-0" 54 + className="flex h-9 w-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--background-elevated)] text-[var(--foreground)] transition-colors hover:bg-[var(--background-subtle)] hover:border-[var(--border-strong)]" 55 55 aria-label="Loading theme" 56 56 > 57 57 <Monitor className="h-4 w-4" /> ··· 85 85 <button 86 86 type="button" 87 87 onClick={cycleMode} 88 - className="btn btn-secondary h-9 w-9 p-0" 88 + className="flex h-9 w-9 items-center justify-center rounded-md border border-[var(--border)] bg-[var(--background-elevated)] text-[var(--foreground)] transition-colors hover:bg-[var(--background-subtle)] hover:border-[var(--border-strong)]" 89 89 aria-label={`Current theme: ${labels[mode]}. Click to change.`} 90 90 title={`Theme: ${labels[mode]}`} 91 91 >
+11
apps/web/src/lib/hooks/useDashboard.ts
··· 75 75 }); 76 76 } 77 77 78 + // User's shelf (library) - combines movies and episodes 79 + export function useUserShelf(userDid: string, pageSize = 10) { 80 + return useQuery({ 81 + ...shelfControllerGetUserShelfOptions({ 82 + path: { userDid }, 83 + query: { page: 1, pageSize }, 84 + }), 85 + enabled: !!userDid, 86 + }); 87 + } 88 + 78 89 // User's recent activity from shelf 79 90 export function useUserShelfActivity( 80 91 userDid: string,
+48 -48
apps/web/src/routes/index.tsx
··· 21 21 useDashboardStats, 22 22 useDiscoverMovies, 23 23 useDiscoverShows, 24 - useUserMovies, 24 + useUserShelf, 25 25 useUserShelfActivity, 26 - useUserShows, 27 - useUserUpNext, 28 26 } from "#/lib/hooks"; 29 27 import MediaCard from "../components/MediaCard"; 30 28 ··· 125 123 isLoading: showsLoading, 126 124 error: showsError, 127 125 } = useDiscoverShows(1); 128 - const { data: userMovies, isLoading: userMoviesLoading } = useUserMovies( 126 + const { data: shelfData, isLoading: shelfLoading } = useUserShelf( 129 127 userDid || "", 130 - ); 131 - const { data: userShows, isLoading: userShowsLoading } = useUserShows( 132 - userDid || "", 133 - ); 134 - const { data: upNextData, isLoading: upNextLoading } = useUserUpNext( 135 - userDid || "", 128 + 12, 136 129 ); 137 130 const { data: statsData, isLoading: statsLoading } = useDashboardStats( 138 131 userDid || "", ··· 177 170 const isLoading = 178 171 moviesLoading || 179 172 showsLoading || 180 - userMoviesLoading || 181 - userShowsLoading || 182 - upNextLoading || 173 + shelfLoading || 183 174 statsLoading || 184 175 activityLoading || 185 176 authLoading || 186 177 calendarLoading; 187 178 const hasError = moviesError || showsError; 188 179 189 - // Calculate real stats 180 + // Calculate real stats from shelf data 181 + const movieCount = 182 + shelfData?.items?.filter((item) => item.type === "movie").length || 0; 183 + const showCount = 184 + shelfData?.items?.filter((item) => item.type === "episode").length || 0; 185 + 190 186 const userStats = [ 191 187 { 192 188 label: "Movies", 193 - value: String(userMovies?.length || 0), 189 + value: String(movieCount), 194 190 icon: Film, 195 191 change: statsData 196 192 ? `+${statsData.recentMovies || 0} this month` ··· 198 194 }, 199 195 { 200 196 label: "Shows", 201 - value: String(userShows?.length || 0), 197 + value: String(showCount), 202 198 icon: Tv, 203 199 change: statsData 204 200 ? `+${statsData.recentShows || 0} this month` ··· 255 251 })) || []), 256 252 ].slice(0, 6); 257 253 258 - // Transform user's tracked content for display 259 - const userContent = [ 260 - ...(userMovies?.slice(0, 3).map((item) => ({ 261 - id: item.movieId, 262 - title: item.movie.title, 263 - type: "movie" as const, 264 - posterUrl: item.movie.posterPath 265 - ? `https://image.tmdb.org/t/p/w500${item.movie.posterPath}` 266 - : "", 267 - backdropUrl: item.movie.backdropPath 268 - ? `https://image.tmdb.org/t/p/original${item.movie.backdropPath}` 269 - : undefined, 270 - year: item.movie.releaseYear, 271 - isWatched: !!item.watchedDate, 272 - watchedDate: item.watchedDate, 273 - })) || []), 274 - ...(upNextData?.items?.slice(0, 3).map((item) => ({ 275 - id: item.showId, 276 - title: `${item.show.title} S${item.lastWatched.seasonNumber}E${item.lastWatched.episodeNumber}`, 277 - type: "show" as const, 278 - posterUrl: item.show.posterPath 279 - ? `https://image.tmdb.org/t/p/w500${item.show.posterPath}` 280 - : "", 281 - backdropUrl: item.show.backdropPath 282 - ? `https://image.tmdb.org/t/p/original${item.show.backdropPath}` 283 - : undefined, 284 - year: item.show.firstAirYear, 285 - episodeInfo: item.lastWatched.name, // Episode title 286 - isWatched: true, 287 - watchedDate: item.latestWatchedDate, 288 - })) || []), 289 - ].slice(0, 6); 254 + // Transform user's tracked content for display from shelf data 255 + const userContent = 256 + shelfData?.items?.slice(0, 6).map((item) => { 257 + if (item.type === "movie") { 258 + return { 259 + id: item.movieId, 260 + title: item.title, 261 + type: "movie" as const, 262 + posterUrl: item.posterPath 263 + ? `https://image.tmdb.org/t/p/w500${item.posterPath}` 264 + : "", 265 + backdropUrl: item.backdropPath 266 + ? `https://image.tmdb.org/t/p/original${item.backdropPath}` 267 + : undefined, 268 + year: item.releaseYear, 269 + isWatched: !!item.watchedDate, 270 + watchedDate: item.watchedDate, 271 + }; 272 + } 273 + // Episode type 274 + return { 275 + id: item.showId, 276 + title: item.showTitle, 277 + type: "show" as const, 278 + posterUrl: item.posterPath 279 + ? `https://image.tmdb.org/t/p/w500${item.posterPath}` 280 + : "", 281 + backdropUrl: item.backdropPath 282 + ? `https://image.tmdb.org/t/p/original${item.backdropPath}` 283 + : undefined, 284 + year: item.firstAirYear, 285 + episodeInfo: `S${item.seasonNumber}E${item.episodeNumber}`, 286 + isWatched: !!item.watchedDate, 287 + watchedDate: item.watchedDate, 288 + }; 289 + }) || []; 290 290 291 291 // Use user content if available, otherwise fall back to featured content 292 292 const displayContent = userContent.length > 0 ? userContent : featuredContent;