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.

feat: redesign release calendar with week-based selection and improved day cells

- Display release titles in calendar day cells with colored dot indicators
- Change to week-based selection (Mon-Sun) with entire week highlighting
- Sidebar shows all releases for selected week sorted by date
- Start calendar week on Monday instead of Sunday
- Remove 'This Week' stats section
- Fix cell sizing to be uniform with consistent h-24 height

Also fix lint errors across codebase:
- Remove unused isInWatchlist parameter from MediaCard
- Fix unused context parameter in login beforeLoad
- Remove unused suppression comments
- Add intentional suppression for theme script dangerouslySetInnerHTML
- Wrap getErrorMessage in useCallback in auth/complete
- Replace placeholder anchor tags with spans in login page

+381 -360
+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="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> 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>
+137 -137
apps/web/src/components/Header.tsx
··· 111 111 })} 112 112 </div> 113 113 114 - {/* Right side actions */} 115 - <div className="flex items-center gap-2"> 116 - {/* Search */} 117 - <SearchCommand /> 114 + {/* Right side actions */} 115 + <div className="flex items-center gap-2"> 116 + {/* Search */} 117 + <SearchCommand /> 118 118 119 - {/* Theme Toggle */} 120 - <ThemeToggle /> 119 + {/* Theme Toggle */} 120 + <ThemeToggle /> 121 121 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)]"> 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 + > 147 133 {user.avatar ? ( 148 134 <img 149 135 src={user.avatar} 150 136 alt={user.displayName || user.handle} 151 - className="h-full w-full rounded-full object-cover" 137 + className="h-full w-full object-cover" 152 138 /> 153 139 ) : ( 154 - <User className="h-4 w-4 text-[var(--accent)]" /> 140 + <User className="h-4 w-4 text-[var(--foreground-muted)]" /> 155 141 )} 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> 164 - </div> 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" /> 202 - ) : ( 203 - <Menu className="h-5 w-5" /> 204 - )} 205 - </button> 206 - </div> 207 - </nav> 208 - 209 - {/* Mobile Navigation - Overlay */} 210 - {mobileMenuOpen && ( 211 - <div className="fixed inset-x-0 top-16 z-40 h-[calc(100vh-4rem)] border-t border-[var(--border)] bg-[var(--background)] md:hidden"> 212 - <div className="container-app h-full overflow-y-auto py-4"> 213 - <div className="flex flex-col gap-1"> 214 - {navigation.map((item) => { 215 - const isActive = 216 - currentPath === item.href || 217 - (item.href !== "/" && currentPath.startsWith(item.href)); 218 - const Icon = item.icon; 219 - return ( 220 - <Link 221 - key={item.name} 222 - to={item.href} 223 - className={`flex items-center gap-3 rounded-md px-3 py-3 text-sm font-medium transition-colors ${ 224 - isActive 225 - ? "bg-[var(--accent-subtle)] text-[var(--accent)]" 226 - : "text-[var(--foreground-muted)] hover:bg-[var(--background-subtle)] hover:text-[var(--foreground)]" 227 - }`} 228 - > 229 - <Icon className="h-5 w-5" /> 230 - {item.name} 231 - </Link> 232 - ); 233 - })} 234 - 235 - {/* Mobile user section */} 236 - {isAuthenticated && user && ( 237 - <> 238 - <div className="my-2 border-t border-[var(--border)]" /> 239 - <div className="flex items-center gap-3 px-3 py-3"> 142 + </button> 143 + </DropdownMenuTrigger> 144 + <DropdownMenuContent align="end" className="w-56"> 145 + <div className="flex items-center gap-2 p-2"> 240 146 <div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--accent-subtle)]"> 241 147 {user.avatar ? ( 242 148 <img ··· 257 163 </span> 258 164 </div> 259 165 </div> 260 - <button 261 - type="button" 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 262 175 onClick={logout} 263 - className="flex items-center gap-3 rounded-md px-3 py-3 text-sm font-medium text-red-600 transition-colors hover:bg-red-50" 176 + className="cursor-pointer text-red-600 focus:text-red-600" 264 177 > 265 - <LogOut className="h-5 w-5" /> 178 + <LogOut className="mr-2 h-4 w-4" /> 266 179 Sign Out 267 - </button> 268 - </> 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" /> 202 + ) : ( 203 + <Menu className="h-5 w-5" /> 269 204 )} 205 + </button> 206 + </div> 207 + </nav> 270 208 271 - {!isAuthenticated && ( 272 - <> 273 - <div className="my-2 border-t border-[var(--border)]" /> 274 - <Link 275 - to="/login" 276 - className="flex items-center gap-3 rounded-md bg-[var(--accent)] px-3 py-3 text-sm font-medium text-white" 277 - > 278 - Sign In 279 - </Link> 280 - </> 281 - )} 209 + {/* Mobile Navigation - Overlay */} 210 + {mobileMenuOpen && ( 211 + <div className="fixed inset-x-0 top-16 z-40 h-[calc(100vh-4rem)] border-t border-[var(--border)] bg-[var(--background)] md:hidden"> 212 + <div className="container-app h-full overflow-y-auto py-4"> 213 + <div className="flex flex-col gap-1"> 214 + {navigation.map((item) => { 215 + const isActive = 216 + currentPath === item.href || 217 + (item.href !== "/" && currentPath.startsWith(item.href)); 218 + const Icon = item.icon; 219 + return ( 220 + <Link 221 + key={item.name} 222 + to={item.href} 223 + className={`flex items-center gap-3 rounded-md px-3 py-3 text-sm font-medium transition-colors ${ 224 + isActive 225 + ? "bg-[var(--accent-subtle)] text-[var(--accent)]" 226 + : "text-[var(--foreground-muted)] hover:bg-[var(--background-subtle)] hover:text-[var(--foreground)]" 227 + }`} 228 + > 229 + <Icon className="h-5 w-5" /> 230 + {item.name} 231 + </Link> 232 + ); 233 + })} 234 + 235 + {/* Mobile user section */} 236 + {isAuthenticated && user && ( 237 + <> 238 + <div className="my-2 border-t border-[var(--border)]" /> 239 + <div className="flex items-center gap-3 px-3 py-3"> 240 + <div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--accent-subtle)]"> 241 + {user.avatar ? ( 242 + <img 243 + src={user.avatar} 244 + alt={user.displayName || user.handle} 245 + className="h-full w-full rounded-full object-cover" 246 + /> 247 + ) : ( 248 + <User className="h-4 w-4 text-[var(--accent)]" /> 249 + )} 250 + </div> 251 + <div className="flex flex-col"> 252 + <span className="text-sm font-medium"> 253 + {user.displayName || user.handle} 254 + </span> 255 + <span className="text-xs text-[var(--foreground-muted)]"> 256 + @{user.handle} 257 + </span> 258 + </div> 259 + </div> 260 + <button 261 + type="button" 262 + onClick={logout} 263 + className="flex items-center gap-3 rounded-md px-3 py-3 text-sm font-medium text-red-600 transition-colors hover:bg-red-50" 264 + > 265 + <LogOut className="h-5 w-5" /> 266 + Sign Out 267 + </button> 268 + </> 269 + )} 270 + 271 + {!isAuthenticated && ( 272 + <> 273 + <div className="my-2 border-t border-[var(--border)]" /> 274 + <Link 275 + to="/login" 276 + className="flex items-center gap-3 rounded-md bg-[var(--accent)] px-3 py-3 text-sm font-medium text-white" 277 + > 278 + Sign In 279 + </Link> 280 + </> 281 + )} 282 + </div> 282 283 </div> 283 284 </div> 284 - </div> 285 - )} 286 - </div> 285 + )} 286 + </div> 287 287 </header> 288 288 ); 289 289 }
-1
apps/web/src/components/MediaCard.tsx
··· 36 36 episodeInfo, 37 37 progress, 38 38 isWatched = false, 39 - isInWatchlist = false, 40 39 watchedDate, 41 40 size = "md", 42 41 layout = "poster",
+5 -1
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 { DefaultErrorComponent, NotFoundComponent } from "../components/ErrorBoundary"; 9 + import { 10 + DefaultErrorComponent, 11 + NotFoundComponent, 12 + } from "../components/ErrorBoundary"; 10 13 import Footer from "../components/Footer"; 11 14 import Header from "../components/Header"; 12 15 import PostHogProvider from "../integrations/posthog/provider"; ··· 43 46 return ( 44 47 <html lang="en" suppressHydrationWarning> 45 48 <head> 49 + {/* biome-ignore lint/security/noDangerouslySetInnerHtml: Theme script must be inline to prevent FOUC */} 46 50 <script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} /> 47 51 <HeadContent /> 48 52 </head>
+5 -5
apps/web/src/routes/auth/complete.tsx
··· 2 2 import { useQueryClient } from "@tanstack/react-query"; 3 3 import { createFileRoute, useSearch } from "@tanstack/react-router"; 4 4 import { CheckCircle, Loader2, XCircle } from "lucide-react"; 5 - import { useEffect, useState } from "react"; 5 + import { useCallback, useEffect, useState } from "react"; 6 6 7 7 export const Route = createFileRoute("/auth/complete")({ 8 8 component: AuthCompletePage, ··· 16 16 ); 17 17 const [errorMessage, setErrorMessage] = useState(""); 18 18 19 - // Helper function to get error message - defined before useEffect 20 - const getErrorMessage = (error: string): string => { 19 + // Helper function to get error message - memoized with useCallback 20 + const getErrorMessage = useCallback((error: string): string => { 21 21 switch (error) { 22 22 case "handle_required": 23 23 return "Please provide your handle to sign in."; ··· 28 28 default: 29 29 return "An unexpected error occurred. Please try again."; 30 30 } 31 - }; 31 + }, []); 32 32 33 33 useEffect(() => { 34 34 // Check for error in query params ··· 54 54 setStatus("error"); 55 55 setErrorMessage("Failed to complete authentication. Please try again."); 56 56 }); 57 - }, [queryClient, search]); 57 + }, [queryClient, search, getErrorMessage]); 58 58 59 59 return ( 60 60 <div className="container-app flex min-h-[calc(100vh-4rem)] items-center justify-center py-12">
+151 -142
apps/web/src/routes/calendar.tsx
··· 1 1 import type { ReleaseCalendarItemDto } from "@opnshelf/api"; 2 2 import { showsControllerGetUserReleaseCalendarOptions } from "@opnshelf/api"; 3 3 import { useQuery } from "@tanstack/react-query"; 4 - import { createFileRoute } from "@tanstack/react-router"; 4 + import { createFileRoute, Link } from "@tanstack/react-router"; 5 5 import { 6 6 Calendar as CalendarIcon, 7 7 ChevronLeft, ··· 39 39 const user = useUser(); 40 40 const [currentDate, setCurrentDate] = useState(new Date()); 41 41 const [viewMode, setViewMode] = useState<"month" | "week" | "list">("month"); 42 + const [selectedWeekStart, setSelectedWeekStart] = useState<Date | null>(null); 42 43 43 44 // Fetch release calendar data 44 45 const { data: calendarData, isLoading } = useQuery({ ··· 75 76 0, 76 77 ).getDate(); 77 78 78 - const firstDayOfMonth = new Date( 79 - currentDate.getFullYear(), 80 - currentDate.getMonth(), 81 - 1, 82 - ).getDay(); 79 + const firstDayOfMonth = 80 + (new Date(currentDate.getFullYear(), currentDate.getMonth(), 1).getDay() + 81 + 6) % 82 + 7; // Shift so Monday = 0, Sunday = 6 83 83 84 84 const prevMonth = () => { 85 85 setCurrentDate( 86 86 new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1), 87 87 ); 88 + setSelectedWeekStart(null); 88 89 }; 89 90 90 91 const nextMonth = () => { 91 92 setCurrentDate( 92 93 new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1), 93 94 ); 95 + setSelectedWeekStart(null); 94 96 }; 95 97 96 98 const formatDateKey = (day: number) => { ··· 100 102 return `${year}-${month}-${dayStr}`; 101 103 }; 102 104 103 - const getUpcomingReleases = (): Array< 104 - ReleaseCalendarItemDto & { date: string } 105 - > => { 106 - if (!calendarData?.items) return []; 105 + const getWeekStart = (date: Date): Date => { 106 + const d = new Date(date); 107 + const day = d.getDay(); 108 + // Adjust for Monday start (0 = Sunday, so Monday is 1) 109 + const diff = day === 0 ? 6 : day - 1; 110 + d.setDate(d.getDate() - diff); 111 + d.setHours(0, 0, 0, 0); 112 + return d; 113 + }; 107 114 108 - const today = new Date(); 109 - today.setHours(0, 0, 0, 0); 110 - const twoWeeksLater = new Date(today); 111 - twoWeeksLater.setDate(today.getDate() + 14); 115 + const isSameDay = (d1: Date, d2: Date): boolean => { 116 + return ( 117 + d1.getFullYear() === d2.getFullYear() && 118 + d1.getMonth() === d2.getMonth() && 119 + d1.getDate() === d2.getDate() 120 + ); 121 + }; 112 122 113 - return calendarData.items 114 - .filter((item) => { 115 - const releaseDate = new Date(item.releaseDate); 116 - return releaseDate >= today && releaseDate <= twoWeeksLater; 117 - }) 118 - .sort( 119 - (a, b) => 120 - new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime(), 121 - ) 122 - .slice(0, 10) 123 - .map((item) => ({ ...item, date: item.releaseDate.split("T")[0] })); 123 + const isInSelectedWeek = (day: number): boolean => { 124 + if (!selectedWeekStart) return false; 125 + const dayDate = new Date( 126 + currentDate.getFullYear(), 127 + currentDate.getMonth(), 128 + day, 129 + ); 130 + const dayWeekStart = getWeekStart(dayDate); 131 + return isSameDay(dayWeekStart, selectedWeekStart); 132 + }; 133 + 134 + const getDisplayTitle = (item: ReleaseCalendarItemDto): string => { 135 + // For TV episodes, show "SxE Title" format 136 + if ( 137 + item.mediaType === "show" && 138 + item.releaseKind === "episode" && 139 + item.seasonNumber !== undefined 140 + ) { 141 + if (item.episodeNumber !== undefined) { 142 + return `${item.seasonNumber}x${item.episodeNumber} ${item.title}`; 143 + } 144 + return `S${item.seasonNumber} ${item.title}`; 145 + } 146 + return item.title; 124 147 }; 125 148 126 149 const getReleaseType = (item: ReleaseCalendarItemDto): "movie" | "show" => { 127 150 return item.mediaType; 128 151 }; 129 152 130 - const getEpisodeInfo = (item: ReleaseCalendarItemDto): string | undefined => { 153 + const _getEpisodeInfo = ( 154 + item: ReleaseCalendarItemDto, 155 + ): string | undefined => { 131 156 if (item.releaseKind === "episode" && item.seasonNumber !== undefined) { 132 157 if (item.episodeNumber !== undefined) { 133 158 return `S${item.seasonNumber}E${item.episodeNumber}`; ··· 140 165 return undefined; 141 166 }; 142 167 143 - const upcomingReleases = getUpcomingReleases(); 168 + const getSelectedWeekReleases = (): Array< 169 + ReleaseCalendarItemDto & { date: string } 170 + > => { 171 + if (!selectedWeekStart) return []; 172 + 173 + const weekReleases: Array<ReleaseCalendarItemDto & { date: string }> = []; 144 174 145 - // Calculate summary stats for current week 146 - const getThisWeekStats = () => { 147 - if (!calendarData?.items) { 148 - return { movies: 0, episodes: 0, premieres: 0 }; 175 + for (let i = 0; i < 7; i++) { 176 + const date = new Date(selectedWeekStart); 177 + date.setDate(selectedWeekStart.getDate() + i); 178 + const dateKey = date.toISOString().split("T")[0]; 179 + const dayReleases = releases[dateKey] || []; 180 + for (const release of dayReleases) { 181 + weekReleases.push({ ...release, date: dateKey }); 182 + } 149 183 } 150 184 151 - const today = new Date(); 152 - const weekStart = new Date(today); 153 - weekStart.setDate(today.getDate() - today.getDay()); 154 - const weekEnd = new Date(weekStart); 155 - weekEnd.setDate(weekStart.getDate() + 7); 185 + // Sort by date 186 + return weekReleases.sort( 187 + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), 188 + ); 189 + }; 156 190 157 - let movies = 0; 158 - let episodes = 0; 159 - let premieres = 0; 191 + const selectedWeekReleases = getSelectedWeekReleases(); 160 192 161 - for (const item of calendarData.items) { 162 - const releaseDate = new Date(item.releaseDate); 163 - if (releaseDate >= weekStart && releaseDate < weekEnd) { 164 - if (item.mediaType === "movie") { 165 - movies++; 166 - } else if (item.releaseKind === "episode") { 167 - episodes++; 168 - } else if (item.releaseKind === "show") { 169 - premieres++; 170 - } 171 - } 193 + const getItemUrl = (item: ReleaseCalendarItemDto) => { 194 + if (item.mediaType === "movie" && item.movieId) { 195 + return `/movies/${item.movieId}`; 172 196 } 197 + if (item.mediaType === "show" && item.showId) { 198 + return `/shows/${item.showId}`; 199 + } 200 + return "#"; 201 + }; 173 202 174 - return { movies, episodes, premieres }; 203 + const formatWeekRange = (): string => { 204 + if (!selectedWeekStart) return ""; 205 + const weekEnd = new Date(selectedWeekStart); 206 + weekEnd.setDate(selectedWeekStart.getDate() + 6); 207 + return `${selectedWeekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`; 175 208 }; 176 - 177 - const thisWeekStats = getThisWeekStats(); 178 209 179 210 if (isLoading) { 180 211 return ( ··· 249 280 <div className="lg:col-span-2"> 250 281 {/* Weekday Headers */} 251 282 <div className="mb-2 grid grid-cols-7 gap-1"> 252 - {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((day) => ( 283 + {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((day) => ( 253 284 <div 254 285 key={day} 255 286 className="py-2 text-center text-sm font-medium text-[var(--foreground-muted)]" ··· 262 293 {/* Calendar Days */} 263 294 <div className="grid grid-cols-7 gap-1"> 264 295 {/* Empty cells for days before the first day of month */} 265 - {/* biome-ignore lint/suspicious/noArrayIndexKey: Empty calendar placeholder cells */} 266 296 {Array.from({ length: firstDayOfMonth }).map((_, index) => ( 267 297 <div 268 298 // biome-ignore lint/suspicious/noArrayIndexKey: Empty calendar placeholder cells 269 299 key={`calendar-empty-${index}`} 270 - className="aspect-square rounded-lg bg-[var(--background-subtle)]" 300 + className="h-24 rounded-lg bg-[var(--background-subtle)]" 271 301 aria-hidden="true" 272 302 /> 273 303 ))} ··· 284 314 currentDate.getMonth(), 285 315 day, 286 316 ).toDateString(); 317 + const inSelectedWeek = isInSelectedWeek(day); 287 318 288 319 return ( 289 - <div 320 + <button 290 321 key={day} 291 - className={`relative aspect-square rounded-lg border p-2 transition-colors ${ 292 - isToday 293 - ? "border-[var(--accent)] bg-[var(--accent-subtle)]" 294 - : "border-[var(--border)] bg-[var(--background-elevated)] hover:border-[var(--border-strong)]" 322 + type="button" 323 + onClick={() => { 324 + const clickedDate = new Date( 325 + currentDate.getFullYear(), 326 + currentDate.getMonth(), 327 + day, 328 + ); 329 + setSelectedWeekStart(getWeekStart(clickedDate)); 330 + }} 331 + className={`relative h-24 flex flex-col items-start rounded-lg border p-1.5 text-left transition-all ${ 332 + inSelectedWeek 333 + ? "border-[var(--accent)] bg-[var(--accent-subtle)] ring-1 ring-[var(--accent)]" 334 + : isToday 335 + ? "border-[var(--accent)] bg-[var(--accent-subtle)]" 336 + : "border-[var(--border)] bg-[var(--background-elevated)] hover:border-[var(--border-strong)]" 295 337 }`} 296 338 > 297 339 <span 298 340 className={`text-sm font-medium ${ 299 - isToday 341 + isToday || inSelectedWeek 300 342 ? "text-[var(--accent)]" 301 343 : "text-[var(--foreground)]" 302 344 }`} ··· 305 347 </span> 306 348 307 349 {dayReleases.length > 0 && ( 308 - <div className="absolute bottom-1 right-1 left-1 flex flex-wrap gap-1"> 309 - {dayReleases.slice(0, 3).map((release) => ( 350 + <div className="mt-1 flex flex-col gap-0.5 w-full"> 351 + {dayReleases.slice(0, 2).map((release) => ( 310 352 <div 311 353 key={`${release.showId || release.movieId || release.title}-${release.releaseDate}`} 312 - className={`h-1.5 w-1.5 rounded-full ${ 313 - getReleaseType(release) === "movie" 314 - ? "bg-blue-500" 315 - : "bg-purple-500" 316 - }`} 317 - title={release.title} 318 - /> 354 + className="flex items-center gap-1.5 overflow-hidden" 355 + > 356 + <div 357 + className={`h-1.5 w-1.5 rounded-full flex-shrink-0 ${ 358 + getReleaseType(release) === "movie" 359 + ? "bg-blue-500" 360 + : "bg-purple-500" 361 + }`} 362 + /> 363 + <span className="text-[10px] text-[var(--foreground)] truncate leading-tight"> 364 + {getDisplayTitle(release)} 365 + </span> 366 + </div> 319 367 ))} 320 - {dayReleases.length > 3 && ( 321 - <span className="text-[10px] text-[var(--foreground-muted)]"> 322 - +{dayReleases.length - 3} 368 + {dayReleases.length > 2 && ( 369 + <span className="text-[10px] text-[var(--foreground-muted)] leading-tight"> 370 + +{dayReleases.length - 2} more 323 371 </span> 324 372 )} 325 373 </div> 326 374 )} 327 - </div> 375 + </button> 328 376 ); 329 377 })} 330 378 </div> 331 - 332 - {/* Legend */} 333 - <div className="mt-4 flex items-center gap-4 text-sm"> 334 - <div className="flex items-center gap-2"> 335 - <div className="h-3 w-3 rounded-full bg-blue-500" /> 336 - <span className="text-[var(--foreground-muted)]">Movies</span> 337 - </div> 338 - <div className="flex items-center gap-2"> 339 - <div className="h-3 w-3 rounded-full bg-purple-500" /> 340 - <span className="text-[var(--foreground-muted)]">TV Shows</span> 341 - </div> 342 - <div className="flex items-center gap-2"> 343 - <div className="h-3 w-3 rounded-full border border-[var(--accent)] bg-[var(--accent-subtle)]" /> 344 - <span className="text-[var(--foreground-muted)]">Today</span> 345 - </div> 346 - </div> 347 379 </div> 348 380 349 381 {/* Sidebar */} 350 382 <div className="space-y-6"> 351 - {/* Upcoming Releases */} 383 + {/* Selected Week Releases */} 352 384 <section> 353 385 <h3 className="text-display-3 mb-4 flex items-center gap-2"> 354 386 <CalendarIcon className="h-5 w-5" /> 355 - Upcoming 387 + {selectedWeekStart ? formatWeekRange() : "Select a week"} 356 388 </h3> 357 - {upcomingReleases.length === 0 ? ( 389 + {selectedWeekReleases.length === 0 ? ( 358 390 <div className="card p-6 text-center"> 359 - <Clock className="mx-auto mb-3 h-8 w-8 text-[var(--foreground-muted)]" /> 360 - <p className="text-[var(--foreground-muted)]"> 361 - No upcoming releases 362 - </p> 363 - <p className="mt-1 text-sm text-[var(--foreground-muted)]"> 364 - Add shows and movies to your watchlist to see their release 365 - dates here. 366 - </p> 391 + {selectedWeekStart ? ( 392 + <> 393 + <Clock className="mx-auto mb-3 h-8 w-8 text-[var(--foreground-muted)]" /> 394 + <p className="text-[var(--foreground-muted)]"> 395 + No releases this week 396 + </p> 397 + </> 398 + ) : ( 399 + <> 400 + <CalendarIcon className="mx-auto mb-3 h-8 w-8 text-[var(--foreground-muted)]" /> 401 + <p className="text-[var(--foreground-muted)]"> 402 + Click any day to see the week&apos;s releases 403 + </p> 404 + </> 405 + )} 367 406 </div> 368 407 ) : ( 369 408 <div className="space-y-3"> 370 - {upcomingReleases.map((release) => ( 371 - <div 372 - key={`${release.showId || release.movieId}-${release.releaseDate}`} 409 + {selectedWeekReleases.map((release) => ( 410 + <Link 411 + key={`${release.showId || release.movieId}-${release.releaseDate}-${release.date}`} 412 + to={getItemUrl(release)} 373 413 className="card card-interactive flex items-center gap-3 p-3" 374 414 > 375 415 {release.posterPath ? ( ··· 389 429 )} 390 430 <div className="flex-1 min-w-0"> 391 431 <p className="font-medium text-sm truncate"> 392 - {release.title} 432 + {getDisplayTitle(release)} 393 433 </p> 394 434 <div className="mt-1 flex items-center gap-2 text-xs text-[var(--foreground-muted)]"> 395 435 <span className="flex items-center gap-1"> ··· 398 438 ) : ( 399 439 <Tv className="h-3 w-3" /> 400 440 )} 401 - {release.date} 441 + {new Date(release.date).toLocaleDateString("en-US", { 442 + month: "short", 443 + day: "numeric", 444 + })} 402 445 </span> 403 446 </div> 404 - {getEpisodeInfo(release) && ( 405 - <p className="mt-1 text-xs text-[var(--foreground-muted)] truncate"> 406 - {getEpisodeInfo(release)} 407 - </p> 408 - )} 409 447 </div> 410 - </div> 448 + </Link> 411 449 ))} 412 450 </div> 413 451 )} 414 - </section> 415 - 416 - {/* This Week Summary */} 417 - <section className="card p-4"> 418 - <h4 className="font-display font-semibold mb-3">This Week</h4> 419 - <div className="space-y-2 text-sm"> 420 - <div className="flex justify-between"> 421 - <span className="text-[var(--foreground-muted)]">Movies</span> 422 - <span className="font-medium"> 423 - {thisWeekStats.movies} releases 424 - </span> 425 - </div> 426 - <div className="flex justify-between"> 427 - <span className="text-[var(--foreground-muted)]"> 428 - TV Episodes 429 - </span> 430 - <span className="font-medium"> 431 - {thisWeekStats.episodes} episodes 432 - </span> 433 - </div> 434 - <div className="flex justify-between"> 435 - <span className="text-[var(--foreground-muted)]"> 436 - Season Premieres 437 - </span> 438 - <span className="font-medium"> 439 - {thisWeekStats.premieres} shows 440 - </span> 441 - </div> 442 - </div> 443 452 </section> 444 453 </div> 445 454 </div>
+47 -32
apps/web/src/routes/index.tsx
··· 1 - import type { FollowedActivityItemDto, ReleaseCalendarItemDto } from "@opnshelf/api"; 1 + import type { 2 + FollowedActivityItemDto, 3 + ReleaseCalendarItemDto, 4 + } from "@opnshelf/api"; 2 5 import { 3 6 showsControllerGetUserReleaseCalendarOptions, 4 7 socialControllerGetFeedOptions, ··· 290 293 return { 291 294 id: item.id, // Use the unique tracked episode ID 292 295 showId: item.showId, 293 - title: item.episodeTitle || `${item.showTitle} S${item.seasonNumber}E${item.episodeNumber}`, 296 + title: 297 + item.episodeTitle || 298 + `${item.showTitle} S${item.seasonNumber}E${item.episodeNumber}`, 294 299 type: "show" as const, 295 300 posterUrl: item.posterPath 296 301 ? `https://image.tmdb.org/t/p/w500${item.posterPath}` ··· 399 404 </div> 400 405 ) : displayContent.length > 0 ? ( 401 406 <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> 402 - {displayContent.map((item) => ( 403 - <MediaCard 404 - key={item.id} 405 - id={item.id} 406 - title={item.title} 407 - posterUrl={item.posterUrl} 408 - backdropUrl={item.backdropUrl} 409 - type={item.type} 410 - year={item.year} 411 - episodeInfo={item.episodeInfo} 412 - watchedDate={ 413 - item.watchedDate 414 - ? formatWatchedDate(item.watchedDate) 415 - : undefined 416 - } 417 - layout="backdrop" 418 - size="md" 419 - /> 420 - ))} 407 + {displayContent.map((item) => ( 408 + <MediaCard 409 + key={item.id} 410 + id={item.id} 411 + title={item.title} 412 + posterUrl={item.posterUrl} 413 + backdropUrl={item.backdropUrl} 414 + type={item.type} 415 + year={item.year} 416 + episodeInfo={item.episodeInfo} 417 + watchedDate={ 418 + item.watchedDate 419 + ? formatWatchedDate(item.watchedDate) 420 + : undefined 421 + } 422 + layout="backdrop" 423 + size="md" 424 + /> 425 + ))} 421 426 </div> 422 427 ) : ( 423 428 <div className="card p-8 text-center"> ··· 469 474 > 470 475 {/* User Avatar */} 471 476 <img 472 - src={item.actor.avatar || `https://i.pravatar.cc/150?u=${item.actor.did}`} 477 + src={ 478 + item.actor.avatar || 479 + `https://i.pravatar.cc/150?u=${item.actor.did}` 480 + } 473 481 alt={item.actor.displayName || item.actor.handle} 474 482 className="h-10 w-10 rounded-full object-cover" 475 483 /> ··· 483 491 {item.actor.displayName || item.actor.handle} 484 492 </Link>{" "} 485 493 {item.verb === "watch" && ( 486 - <span className="text-[var(--foreground-muted)]">watched</span> 494 + <span className="text-[var(--foreground-muted)]"> 495 + watched 496 + </span> 487 497 )} 488 498 {item.verb === "follow" && ( 489 - <span className="text-[var(--foreground-muted)]">followed</span> 499 + <span className="text-[var(--foreground-muted)]"> 500 + followed 501 + </span> 490 502 )} 491 503 {item.verb === "list_add" && ( 492 - <span className="text-[var(--foreground-muted)]">added to list</span> 504 + <span className="text-[var(--foreground-muted)]"> 505 + added to list 506 + </span> 493 507 )} 494 508 </p> 495 509 {/* Content Title */} ··· 499 513 className="hover:text-[var(--accent)]" 500 514 > 501 515 {item.content.title} 502 - {item.content.type === "episode" && item.content.episodeTitle && ( 503 - <span className="text-[var(--foreground-muted)]"> 504 - {" "} 505 - (S{item.content.seasonNumber}E 506 - {item.content.episodeNumber}) 507 - </span> 508 - )} 516 + {item.content.type === "episode" && 517 + item.content.episodeTitle && ( 518 + <span className="text-[var(--foreground-muted)]"> 519 + {" "} 520 + (S{item.content.seasonNumber}E 521 + {item.content.episodeNumber}) 522 + </span> 523 + )} 509 524 </Link> 510 525 </p> 511 526 {/* Timestamp & Actions */}
+3 -9
apps/web/src/routes/login.tsx
··· 5 5 6 6 export const Route = createFileRoute("/login")({ 7 7 component: LoginPage, 8 - beforeLoad: ({ context }) => { 8 + beforeLoad: () => { 9 9 // If user is already authenticated, redirect to home 10 10 // Note: This is a simple check, the actual check happens in the component 11 11 }, ··· 119 119 <div className="mt-6 text-center text-sm text-[var(--foreground-muted)]"> 120 120 <p> 121 121 By signing in, you agree to our{" "} 122 - <a href="#" className="text-[var(--accent)] hover:underline"> 123 - Terms of Service 124 - </a>{" "} 125 - and{" "} 126 - <a href="#" className="text-[var(--accent)] hover:underline"> 127 - Privacy Policy 128 - </a> 129 - . 122 + <span className="text-[var(--accent)]">Terms of Service</span> and{" "} 123 + <span className="text-[var(--accent)]">Privacy Policy</span>. 130 124 </p> 131 125 <p className="mt-4"> 132 126 Powered by{" "}
-1
apps/web/src/routes/movie/$id.tsx
··· 687 687 )} 688 688 {watchHistory[0]?.rating && ( 689 689 <div className="flex items-center gap-1 pt-2 border-t border-[var(--border-subtle)]"> 690 - {/* biome-ignore lint/suspicious/noArrayIndexKey: Rating stars use position as key */} 691 690 {Array.from({ length: 5 }).map((_, i) => ( 692 691 <Star 693 692 // biome-ignore lint/suspicious/noArrayIndexKey: Rating stars use position as key
+23 -22
apps/web/src/routes/show/$id.tsx
··· 1 1 import { 2 + listsControllerGetListsForItemOptions, 2 3 showsControllerGetSeasonDetailsOptions, 3 4 showsControllerGetShowWatchHistoryOptions, 4 - listsControllerGetListsForItemOptions, 5 5 } from "@opnshelf/api"; 6 6 import { useQuery } from "@tanstack/react-query"; 7 7 import { createFileRoute, Link } from "@tanstack/react-router"; ··· 237 237 if (!watchHistory || watchHistory.length === 0) return false; 238 238 239 239 return watchHistory.some( 240 - (ep) => 241 - ep.seasonNumber === seasonNum && ep.episodeNumber === episodeNum, 240 + (ep) => ep.seasonNumber === seasonNum && ep.episodeNumber === episodeNum, 242 241 ); 243 242 }; 244 243 ··· 685 684 <section className="card p-5"> 686 685 <h3 className="font-display font-semibold mb-4">In Your Lists</h3> 687 686 <div className="space-y-2"> 688 - {listsContainingShow.length > 0 ? ( 689 - listsContainingShow.map((list) => ( 690 - <Link 691 - key={list.listId} 692 - to={`/lists/${list.listSlug}`} 693 - className="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-[var(--background-subtle)]" 694 - > 695 - <span className="text-sm font-medium"> 696 - {list.listName} 697 - </span> 698 - <ChevronRight className="h-4 w-4 text-[var(--foreground-muted)]" /> 699 - </Link> 700 - )) 701 - ) : ( 702 - <p className="text-sm text-[var(--foreground-muted)]"> 703 - Not in any lists yet 704 - </p> 705 - )} 687 + {listsContainingShow.length > 0 ? ( 688 + listsContainingShow.map((list) => ( 689 + <Link 690 + key={list.listId} 691 + to={`/lists/${list.listSlug}`} 692 + className="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-[var(--background-subtle)]" 693 + > 694 + <span className="text-sm font-medium"> 695 + {list.listName} 696 + </span> 697 + <ChevronRight className="h-4 w-4 text-[var(--foreground-muted)]" /> 698 + </Link> 699 + )) 700 + ) : ( 701 + <p className="text-sm text-[var(--foreground-muted)]"> 702 + Not in any lists yet 703 + </p> 704 + )} 706 705 </div> 707 706 <button 708 707 type="button" 709 708 className="mt-3 w-full btn btn-secondary text-sm" 710 709 > 711 710 <Plus className="h-4 w-4" /> 712 - {listsContainingShow.length > 0 ? "Add to another list" : "Add to list"} 711 + {listsContainingShow.length > 0 712 + ? "Add to another list" 713 + : "Add to list"} 713 714 </button> 714 715 </section> 715 716 </div>