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: separate landing page from dashboard and add auth protection

- Move dashboard from / to /dashboard route
- Create new landing page at / for logged-out users
- Add auth redirects: landing → dashboard (logged in), dashboard → login (logged out)
- Protect calendar, following, and lists pages (redirect to login if not authenticated)
- Update Header navigation to point to /dashboard
- Fix redirect crash by using useNavigate hook instead of throw redirect

+806 -646
+1 -1
apps/web/src/components/Header.tsx
··· 22 22 import ThemeToggle from "./ThemeToggle"; 23 23 24 24 const navigation = [ 25 - { name: "Dashboard", href: "/", icon: Film }, 25 + { name: "Dashboard", href: "/dashboard", icon: Film }, 26 26 { name: "Calendar", href: "/calendar", icon: Calendar }, 27 27 { name: "Following", href: "/following", icon: Users }, 28 28 { name: "Lists", href: "/lists", icon: List },
+21
apps/web/src/routeTree.gen.ts
··· 12 12 import { Route as LoginRouteImport } from './routes/login' 13 13 import { Route as ListsRouteImport } from './routes/lists' 14 14 import { Route as FollowingRouteImport } from './routes/following' 15 + import { Route as DashboardRouteImport } from './routes/dashboard' 15 16 import { Route as CalendarRouteImport } from './routes/calendar' 16 17 import { Route as AboutRouteImport } from './routes/about' 17 18 import { Route as IndexRouteImport } from './routes/index' ··· 32 33 const FollowingRoute = FollowingRouteImport.update({ 33 34 id: '/following', 34 35 path: '/following', 36 + getParentRoute: () => rootRouteImport, 37 + } as any) 38 + const DashboardRoute = DashboardRouteImport.update({ 39 + id: '/dashboard', 40 + path: '/dashboard', 35 41 getParentRoute: () => rootRouteImport, 36 42 } as any) 37 43 const CalendarRoute = CalendarRouteImport.update({ ··· 69 75 '/': typeof IndexRoute 70 76 '/about': typeof AboutRoute 71 77 '/calendar': typeof CalendarRoute 78 + '/dashboard': typeof DashboardRoute 72 79 '/following': typeof FollowingRoute 73 80 '/lists': typeof ListsRoute 74 81 '/login': typeof LoginRoute ··· 80 87 '/': typeof IndexRoute 81 88 '/about': typeof AboutRoute 82 89 '/calendar': typeof CalendarRoute 90 + '/dashboard': typeof DashboardRoute 83 91 '/following': typeof FollowingRoute 84 92 '/lists': typeof ListsRoute 85 93 '/login': typeof LoginRoute ··· 92 100 '/': typeof IndexRoute 93 101 '/about': typeof AboutRoute 94 102 '/calendar': typeof CalendarRoute 103 + '/dashboard': typeof DashboardRoute 95 104 '/following': typeof FollowingRoute 96 105 '/lists': typeof ListsRoute 97 106 '/login': typeof LoginRoute ··· 105 114 | '/' 106 115 | '/about' 107 116 | '/calendar' 117 + | '/dashboard' 108 118 | '/following' 109 119 | '/lists' 110 120 | '/login' ··· 116 126 | '/' 117 127 | '/about' 118 128 | '/calendar' 129 + | '/dashboard' 119 130 | '/following' 120 131 | '/lists' 121 132 | '/login' ··· 127 138 | '/' 128 139 | '/about' 129 140 | '/calendar' 141 + | '/dashboard' 130 142 | '/following' 131 143 | '/lists' 132 144 | '/login' ··· 139 151 IndexRoute: typeof IndexRoute 140 152 AboutRoute: typeof AboutRoute 141 153 CalendarRoute: typeof CalendarRoute 154 + DashboardRoute: typeof DashboardRoute 142 155 FollowingRoute: typeof FollowingRoute 143 156 ListsRoute: typeof ListsRoute 144 157 LoginRoute: typeof LoginRoute ··· 168 181 path: '/following' 169 182 fullPath: '/following' 170 183 preLoaderRoute: typeof FollowingRouteImport 184 + parentRoute: typeof rootRouteImport 185 + } 186 + '/dashboard': { 187 + id: '/dashboard' 188 + path: '/dashboard' 189 + fullPath: '/dashboard' 190 + preLoaderRoute: typeof DashboardRouteImport 171 191 parentRoute: typeof rootRouteImport 172 192 } 173 193 '/calendar': { ··· 219 239 IndexRoute: IndexRoute, 220 240 AboutRoute: AboutRoute, 221 241 CalendarRoute: CalendarRoute, 242 + DashboardRoute: DashboardRoute, 222 243 FollowingRoute: FollowingRoute, 223 244 ListsRoute: ListsRoute, 224 245 LoginRoute: LoginRoute,
+12 -3
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, Link } from "@tanstack/react-router"; 4 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 5 5 import { 6 6 Calendar as CalendarIcon, 7 7 ChevronLeft, ··· 12 12 Tv, 13 13 } from "lucide-react"; 14 14 import { useEffect, useMemo, useState } from "react"; 15 - import { useUser } from "../lib/auth-context"; 15 + import { useAuth } from "../lib/auth-context"; 16 16 17 17 export const Route = createFileRoute("/calendar")({ 18 18 component: CalendarPage, ··· 47 47 } 48 48 49 49 function CalendarPage() { 50 - const user = useUser(); 50 + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); 51 + const navigate = useNavigate(); 52 + 53 + // Redirect to login if not authenticated 54 + useEffect(() => { 55 + if (!authLoading && !isAuthenticated) { 56 + navigate({ to: "/login" }); 57 + } 58 + }, [authLoading, isAuthenticated, navigate]); 59 + 51 60 const [currentDate, setCurrentDate] = useState(new Date()); 52 61 const [selectedWeekStart, setSelectedWeekStart] = useState<Date | null>(null); 53 62
+611
apps/web/src/routes/dashboard.tsx
··· 1 + import type { 2 + FollowedActivityItemDto, 3 + ReleaseCalendarItemDto, 4 + } from "@opnshelf/api"; 5 + import { 6 + showsControllerGetUserReleaseCalendarOptions, 7 + socialControllerGetFeedOptions, 8 + } from "@opnshelf/api"; 9 + import { useQuery } from "@tanstack/react-query"; 10 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 11 + import { 12 + Calendar, 13 + ChevronRight, 14 + Clock, 15 + Film, 16 + Heart, 17 + MessageCircle, 18 + TrendingUp, 19 + Tv, 20 + Users, 21 + } from "lucide-react"; 22 + import { useEffect } from "react"; 23 + import { setupApiClient } from "#/lib/api"; 24 + import { useAuth } from "#/lib/auth-context"; 25 + import { useDashboardStats, useUserShelf } from "#/lib/hooks"; 26 + import MediaCard from "../components/MediaCard"; 27 + 28 + // Initialize API client 29 + setupApiClient(); 30 + 31 + export const Route = createFileRoute("/dashboard")({ 32 + component: Dashboard, 33 + }); 34 + 35 + // Helper function to format relative time 36 + function formatRelativeDate(dateStr: string): string { 37 + const releaseDate = new Date(dateStr); 38 + const today = new Date(); 39 + today.setHours(0, 0, 0, 0); 40 + releaseDate.setHours(0, 0, 0, 0); 41 + 42 + const diffTime = releaseDate.getTime() - today.getTime(); 43 + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); 44 + 45 + if (diffDays === 0) return "Today"; 46 + if (diffDays === 1) return "Tomorrow"; 47 + if (diffDays < 7) return `in ${diffDays} days`; 48 + if (diffDays < 30) return `in ${Math.ceil(diffDays / 7)} weeks`; 49 + return `in ${Math.ceil(diffDays / 30)} months`; 50 + } 51 + 52 + // Helper function to format date 53 + function formatDate(dateStr: string): string { 54 + const date = new Date(dateStr); 55 + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); 56 + } 57 + 58 + // Helper function to format relative time for social feed 59 + function formatRelativeTime(dateString: string): string { 60 + const date = new Date(dateString); 61 + const now = new Date(); 62 + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); 63 + 64 + if (diffInSeconds < 60) return "Just now"; 65 + if (diffInSeconds < 3600) 66 + return `${Math.floor(diffInSeconds / 60)} minutes ago`; 67 + if (diffInSeconds < 86400) 68 + return `${Math.floor(diffInSeconds / 3600)} hours ago`; 69 + if (diffInSeconds < 604800) 70 + return `${Math.floor(diffInSeconds / 86400)} days ago`; 71 + return date.toLocaleDateString(); 72 + } 73 + 74 + // Helper function to get episode info 75 + function getEpisodeInfo(item: ReleaseCalendarItemDto): string | undefined { 76 + if (item.releaseKind === "episode" && item.seasonNumber !== undefined) { 77 + if (item.episodeNumber !== undefined) { 78 + return `S${item.seasonNumber}E${item.episodeNumber}`; 79 + } 80 + return `Season ${item.seasonNumber}`; 81 + } 82 + if (item.releaseKind === "show") { 83 + return "Season Premiere"; 84 + } 85 + return undefined; 86 + } 87 + 88 + // Helper function to format watched time (e.g. "Apr 9 at 2:30 PM", "Jan 15, 2024 at 2:30 PM") 89 + function formatWatchedDate(dateStr: string): string { 90 + const date = new Date(dateStr); 91 + const now = new Date(); 92 + const isThisYear = date.getFullYear() === now.getFullYear(); 93 + const timeString = date.toLocaleTimeString("en-US", { 94 + hour: "numeric", 95 + minute: "2-digit", 96 + }); 97 + 98 + if (isThisYear) { 99 + const formattedDate = date.toLocaleDateString("en-US", { 100 + month: "short", 101 + day: "numeric", 102 + }); 103 + return `${formattedDate} at ${timeString}`; 104 + } 105 + const formattedDate = date.toLocaleDateString("en-US", { 106 + month: "short", 107 + day: "numeric", 108 + year: "numeric", 109 + }); 110 + return `${formattedDate} at ${timeString}`; 111 + } 112 + 113 + function Dashboard() { 114 + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); 115 + const navigate = useNavigate(); 116 + const userDid = user?.did; 117 + 118 + // Redirect to login if not authenticated 119 + useEffect(() => { 120 + if (!authLoading && !isAuthenticated) { 121 + navigate({ to: "/login" }); 122 + } 123 + }, [authLoading, isAuthenticated, navigate]); 124 + 125 + // Fetch user data from API 126 + const { data: shelfData, isLoading: shelfLoading } = useUserShelf( 127 + userDid || "", 128 + 12, 129 + ); 130 + const { data: statsData, isLoading: statsLoading } = useDashboardStats( 131 + userDid || "", 132 + ); 133 + 134 + // Fetch social activity feed 135 + const { data: feedData, isLoading: feedLoading } = useQuery({ 136 + ...socialControllerGetFeedOptions({ 137 + query: { pageSize: 10 }, 138 + }), 139 + }); 140 + 141 + // Fetch release calendar data 142 + const { data: calendarData, isLoading: calendarLoading } = useQuery({ 143 + ...showsControllerGetUserReleaseCalendarOptions({ 144 + path: { userDid: user?.did || "" }, 145 + }), 146 + }); 147 + 148 + // Get upcoming releases - filter to next two weeks, limit to 10 149 + const upcomingReleases = calendarData?.items 150 + ? calendarData.items 151 + .filter((item) => { 152 + const releaseDate = new Date(item.releaseDate); 153 + const today = new Date(); 154 + today.setHours(0, 0, 0, 0); 155 + const twoWeeksLater = new Date(today); 156 + twoWeeksLater.setDate(today.getDate() + 14); 157 + return releaseDate >= today && releaseDate <= twoWeeksLater; 158 + }) 159 + .sort( 160 + (a, b) => 161 + new Date(a.releaseDate).getTime() - 162 + new Date(b.releaseDate).getTime(), 163 + ) 164 + .slice(0, 10) 165 + : []; 166 + 167 + const isLoading = 168 + shelfLoading || 169 + statsLoading || 170 + feedLoading || 171 + authLoading || 172 + calendarLoading; 173 + 174 + // Calculate real stats from shelf data 175 + const movieCount = 176 + shelfData?.items?.filter((item) => item.type === "movie").length || 0; 177 + const showCount = 178 + shelfData?.items?.filter((item) => item.type === "episode").length || 0; 179 + 180 + const userStats = [ 181 + { 182 + label: "Movies", 183 + value: String(movieCount), 184 + icon: Film, 185 + change: statsData 186 + ? `+${statsData.recentMovies || 0} this month` 187 + : "Track your first movie", 188 + }, 189 + { 190 + label: "Shows", 191 + value: String(showCount), 192 + icon: Tv, 193 + change: statsData 194 + ? `+${statsData.recentShows || 0} this month` 195 + : "Track your first show", 196 + }, 197 + { 198 + label: "Hours", 199 + value: String(Math.round(statsData?.totalWatchTimeHours || 0)), 200 + icon: Clock, 201 + change: statsData?.weeklyWatchTimeHours 202 + ? `${statsData.weeklyWatchTimeHours}h this week` 203 + : "Start watching", 204 + }, 205 + { 206 + label: "Streak", 207 + value: String(statsData?.streakDays || 0), 208 + icon: TrendingUp, 209 + change: 210 + statsData?.streakDays && statsData.streakDays > 0 211 + ? "days" 212 + : "Start a streak", 213 + }, 214 + ]; 215 + 216 + // Transform user's tracked content for display from shelf data 217 + const userContent = 218 + shelfData?.items?.slice(0, 6).map((item) => { 219 + if (item.type === "movie") { 220 + return { 221 + id: item.movieId, 222 + title: item.title, 223 + type: "movie" as const, 224 + posterUrl: item.posterPath 225 + ? `https://image.tmdb.org/t/p/w500${item.posterPath}` 226 + : "", 227 + backdropUrl: item.backdropPath 228 + ? `https://image.tmdb.org/t/p/original${item.backdropPath}` 229 + : undefined, 230 + year: item.releaseYear, 231 + isWatched: !!item.watchedDate, 232 + watchedDate: item.watchedDate, 233 + }; 234 + } 235 + // Episode type 236 + return { 237 + id: item.id, // Use the unique tracked episode ID 238 + showId: item.showId, 239 + title: 240 + item.episodeTitle || 241 + `${item.showTitle} S${item.seasonNumber}E${item.episodeNumber}`, 242 + type: "show" as const, 243 + posterUrl: item.posterPath 244 + ? `https://image.tmdb.org/t/p/w500${item.posterPath}` 245 + : "", 246 + backdropUrl: item.backdropPath 247 + ? `https://image.tmdb.org/t/p/original${item.backdropPath}` 248 + : undefined, 249 + year: item.firstAirYear, 250 + episodeInfo: `${item.showTitle} • S${item.seasonNumber}E${item.episodeNumber}`, 251 + isWatched: !!item.watchedDate, 252 + watchedDate: item.watchedDate, 253 + }; 254 + }) || []; 255 + 256 + return ( 257 + <div className="container-app py-8"> 258 + {/* Welcome Section */} 259 + <div className="mb-8"> 260 + <h1 className="text-display-2 mb-2"> 261 + {`Welcome back, ${user?.displayName || user?.handle || ""}`} 262 + </h1> 263 + <p className="text-[var(--foreground-muted)]">@{user?.handle}</p> 264 + </div> 265 + 266 + {/* Stats Grid */} 267 + <div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> 268 + {isLoading 269 + ? // Skeleton stats 270 + [1, 2, 3, 4].map((i) => ( 271 + <div key={i} className="card p-5 animate-pulse"> 272 + <div className="flex items-center justify-between"> 273 + <div className="space-y-2"> 274 + <div className="h-4 w-16 rounded bg-[var(--background-subtle)]" /> 275 + <div className="h-8 w-12 rounded bg-[var(--background-subtle)]" /> 276 + <div className="h-3 w-20 rounded bg-[var(--background-subtle)]" /> 277 + </div> 278 + <div className="h-12 w-12 rounded-xl bg-[var(--background-subtle)]" /> 279 + </div> 280 + </div> 281 + )) 282 + : userStats.map((stat, index) => { 283 + const Icon = stat.icon; 284 + return ( 285 + <div 286 + key={stat.label} 287 + className="card p-5" 288 + style={{ animationDelay: `${index * 50}ms` }} 289 + > 290 + <div className="flex items-center justify-between"> 291 + <div> 292 + <p className="text-sm text-[var(--foreground-muted)]"> 293 + {stat.label} 294 + </p> 295 + <p className="text-display-3 mt-1">{stat.value}</p> 296 + <p className="mt-1 text-xs text-[var(--accent)]"> 297 + {stat.change} 298 + </p> 299 + </div> 300 + <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--accent-subtle)] text-[var(--accent)]"> 301 + <Icon className="h-6 w-6" /> 302 + </div> 303 + </div> 304 + </div> 305 + ); 306 + })} 307 + </div> 308 + 309 + <div className="grid gap-8 lg:grid-cols-3"> 310 + {/* Main Content - Continue Watching */} 311 + <div className="lg:col-span-2 space-y-8"> 312 + {/* Continue Watching */} 313 + <section> 314 + <div className="mb-4 flex items-center justify-between"> 315 + <h2 className="text-display-3">Your Shelf</h2> 316 + <Link 317 + to="/shelf" 318 + className="flex items-center gap-1 text-sm font-medium text-[var(--accent)] hover:text-[var(--accent-hover)]" 319 + > 320 + View all 321 + <ChevronRight className="h-4 w-4" /> 322 + </Link> 323 + </div> 324 + 325 + {isLoading ? ( 326 + <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> 327 + {[1, 2, 3, 4, 5, 6].map((i) => ( 328 + <div 329 + key={i} 330 + className="aspect-[16/9] animate-pulse rounded-lg bg-[var(--background-subtle)]" 331 + /> 332 + ))} 333 + </div> 334 + ) : userContent.length > 0 ? ( 335 + <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> 336 + {userContent.map((item) => ( 337 + <MediaCard 338 + key={item.id} 339 + id={item.id} 340 + title={item.title} 341 + posterUrl={item.posterUrl} 342 + backdropUrl={item.backdropUrl} 343 + type={item.type} 344 + year={item.year} 345 + episodeInfo={item.episodeInfo} 346 + watchedDate={ 347 + item.watchedDate 348 + ? formatWatchedDate(item.watchedDate) 349 + : undefined 350 + } 351 + layout="backdrop" 352 + size="md" 353 + /> 354 + ))} 355 + </div> 356 + ) : ( 357 + <div className="card p-8 text-center"> 358 + <p className="text-[var(--foreground-muted)] mb-2"> 359 + Your shelf is empty 360 + </p> 361 + <p className="text-sm text-[var(--foreground-muted)] mb-4"> 362 + Start tracking movies and shows to see them here! 363 + </p> 364 + <Link 365 + to="/search" 366 + className="btn btn-primary inline-flex gap-2" 367 + > 368 + <Film className="h-4 w-4" /> 369 + Discover Content 370 + </Link> 371 + </div> 372 + )} 373 + </section> 374 + 375 + {/* Social Feed - Activity from people you follow */} 376 + <section> 377 + <div className="mb-4 flex items-center justify-between"> 378 + <h2 className="text-display-3">Friend Activity</h2> 379 + <Link 380 + to="/following" 381 + className="flex items-center gap-1 text-sm font-medium text-[var(--accent)] hover:text-[var(--accent-hover)]" 382 + > 383 + <Users className="h-4 w-4" /> 384 + View all 385 + </Link> 386 + </div> 387 + {feedLoading ? ( 388 + <div className="card p-8"> 389 + <div className="space-y-3"> 390 + {[1, 2, 3].map((i) => ( 391 + <div 392 + key={i} 393 + className="flex items-center gap-3 animate-pulse" 394 + > 395 + <div className="h-10 w-10 rounded-full bg-[var(--background-subtle)]" /> 396 + <div className="flex-1 space-y-1"> 397 + <div className="h-4 w-1/2 rounded bg-[var(--background-subtle)]" /> 398 + <div className="h-3 w-1/3 rounded bg-[var(--background-subtle)]" /> 399 + </div> 400 + </div> 401 + ))} 402 + </div> 403 + </div> 404 + ) : feedData?.items && feedData.items.length > 0 ? ( 405 + <div className="card divide-y divide-[var(--border)]"> 406 + {feedData.items 407 + .filter( 408 + (item: FollowedActivityItemDto) => item.content != null, 409 + ) 410 + .map((item: FollowedActivityItemDto) => ( 411 + <div 412 + key={item.id} 413 + className="flex items-start gap-3 p-4 first:pt-5 last:pb-5" 414 + > 415 + {/* User Avatar */} 416 + <img 417 + src={ 418 + item.actor.avatar || 419 + `https://i.pravatar.cc/150?u=${item.actor.did}` 420 + } 421 + alt={item.actor.displayName || item.actor.handle} 422 + className="h-10 w-10 rounded-full object-cover" 423 + /> 424 + <div className="flex-1 min-w-0"> 425 + {/* Activity Header */} 426 + <p className="text-sm"> 427 + <Link 428 + to={`/profile/${item.actor.handle}`} 429 + className="font-medium hover:text-[var(--accent)]" 430 + > 431 + {item.actor.displayName || item.actor.handle} 432 + </Link>{" "} 433 + {item.verb === "watch" && ( 434 + <span className="text-[var(--foreground-muted)]"> 435 + watched 436 + </span> 437 + )} 438 + {item.verb === "follow" && ( 439 + <span className="text-[var(--foreground-muted)]"> 440 + followed 441 + </span> 442 + )} 443 + {item.verb === "list_add" && ( 444 + <span className="text-[var(--foreground-muted)]"> 445 + added to list 446 + </span> 447 + )} 448 + </p> 449 + {/* Content Title */} 450 + <p className="font-medium text-sm mt-0.5"> 451 + <Link 452 + to={`/${item.content.type}/${item.content.id}`} 453 + className="hover:text-[var(--accent)]" 454 + > 455 + {item.content.title} 456 + {item.content.type === "episode" && 457 + item.content.episodeTitle && ( 458 + <span className="text-[var(--foreground-muted)]"> 459 + {" "} 460 + (S{item.content.seasonNumber}E 461 + {item.content.episodeNumber}) 462 + </span> 463 + )} 464 + </Link> 465 + </p> 466 + {/* Timestamp & Actions */} 467 + <div className="flex items-center gap-3 mt-1.5 text-xs text-[var(--foreground-muted)]"> 468 + <span>{formatRelativeTime(item.createdAt)}</span> 469 + {item.verb === "watch" && ( 470 + <button 471 + type="button" 472 + className="flex items-center gap-1 hover:text-[var(--accent)]" 473 + > 474 + <Heart className="h-3 w-3" /> 475 + Like 476 + </button> 477 + )} 478 + </div> 479 + </div> 480 + {/* Content Type Badge */} 481 + <span 482 + className={`badge ${item.content.type === "movie" ? "badge-subtle" : "badge-accent"}`} 483 + > 484 + {item.content.type === "movie" ? "Movie" : "TV"} 485 + </span> 486 + </div> 487 + ))} 488 + </div> 489 + ) : ( 490 + <div className="card p-8 text-center"> 491 + <MessageCircle className="h-12 w-12 mx-auto mb-3 text-[var(--foreground-muted)]" /> 492 + <p className="text-[var(--foreground-muted)]"> 493 + Activity from people you follow will appear here. 494 + </p> 495 + <Link 496 + to="/following" 497 + className="btn btn-primary mt-4 inline-flex" 498 + > 499 + <Users className="h-4 w-4 mr-2" /> 500 + Find people to follow 501 + </Link> 502 + </div> 503 + )} 504 + </section> 505 + </div> 506 + 507 + {/* Sidebar */} 508 + <div className="space-y-8"> 509 + {/* Upcoming */} 510 + <section> 511 + <div className="mb-4 flex items-center justify-between"> 512 + <h2 className="text-display-3">Upcoming</h2> 513 + <Link 514 + to="/calendar" 515 + className="flex items-center gap-1 text-sm font-medium text-[var(--accent)] hover:text-[var(--accent-hover)]" 516 + > 517 + <Calendar className="h-4 w-4" /> 518 + Calendar 519 + </Link> 520 + </div> 521 + {isLoading ? ( 522 + <div className="card p-4"> 523 + <div className="space-y-3"> 524 + {[1, 2, 3].map((i) => ( 525 + <div 526 + key={i} 527 + className="flex items-center gap-3 animate-pulse" 528 + > 529 + <div className="h-12 w-9 rounded bg-[var(--background-subtle)]" /> 530 + <div className="flex-1 space-y-1"> 531 + <div className="h-4 w-3/4 rounded bg-[var(--background-subtle)]" /> 532 + <div className="h-3 w-1/2 rounded bg-[var(--background-subtle)]" /> 533 + </div> 534 + </div> 535 + ))} 536 + </div> 537 + </div> 538 + ) : upcomingReleases.length === 0 ? ( 539 + <div className="card p-6 text-center"> 540 + <Clock className="mx-auto mb-3 h-8 w-8 text-[var(--foreground-muted)]" /> 541 + <p className="text-sm text-[var(--foreground-muted)]"> 542 + No upcoming releases 543 + </p> 544 + <p className="mt-1 text-xs text-[var(--foreground-muted)]"> 545 + Track shows and movies to see their release dates here. 546 + </p> 547 + </div> 548 + ) : ( 549 + <div className="space-y-3"> 550 + {upcomingReleases.map((release) => ( 551 + <Link 552 + key={`${release.showId || release.movieId || release.title}-${release.releaseDate}`} 553 + to={ 554 + release.mediaType === "movie" && release.movieId 555 + ? `/movies/${release.movieId}` 556 + : release.showId 557 + ? `/shows/${release.showId}` 558 + : "#" 559 + } 560 + className="card card-interactive flex items-center gap-3 p-3" 561 + > 562 + {release.posterPath ? ( 563 + <img 564 + src={`https://image.tmdb.org/t/p/w200${release.posterPath}`} 565 + alt={release.title} 566 + className="h-12 w-9 rounded object-cover" 567 + /> 568 + ) : ( 569 + <div className="flex h-12 w-9 items-center justify-center rounded bg-[var(--background-subtle)]"> 570 + {release.mediaType === "movie" ? ( 571 + <Film className="h-5 w-5 text-[var(--foreground-muted)]" /> 572 + ) : ( 573 + <Tv className="h-5 w-5 text-[var(--foreground-muted)]" /> 574 + )} 575 + </div> 576 + )} 577 + <div className="flex-1 min-w-0"> 578 + <p className="font-medium text-sm truncate"> 579 + {release.title} 580 + </p> 581 + <div className="mt-0.5 flex items-center gap-2 text-xs text-[var(--foreground-muted)]"> 582 + <span>{formatDate(release.releaseDate)}</span> 583 + <span className="text-[var(--accent)]"> 584 + • {formatRelativeDate(release.releaseDate)} 585 + </span> 586 + </div> 587 + {getEpisodeInfo(release) && ( 588 + <p className="mt-0.5 text-xs text-[var(--foreground-muted)] truncate"> 589 + {getEpisodeInfo(release)} 590 + </p> 591 + )} 592 + </div> 593 + <span 594 + className={`badge ${ 595 + release.mediaType === "movie" 596 + ? "badge-subtle" 597 + : "badge-accent" 598 + }`} 599 + > 600 + {release.mediaType === "movie" ? "Movie" : "TV"} 601 + </span> 602 + </Link> 603 + ))} 604 + </div> 605 + )} 606 + </section> 607 + </div> 608 + </div> 609 + </div> 610 + ); 611 + }
+11 -3
apps/web/src/routes/following.tsx
··· 8 8 socialControllerUnfollowMutation, 9 9 } from "@opnshelf/api"; 10 10 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 11 - import { createFileRoute, Link } from "@tanstack/react-router"; 11 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 12 12 import { 13 13 Activity, 14 14 Clock, ··· 26 26 } from "lucide-react"; 27 27 import { useCallback, useEffect, useState } from "react"; 28 28 import { setupApiClient } from "#/lib/api"; 29 - import { useUser } from "#/lib/auth-context"; 29 + import { useAuth } from "#/lib/auth-context"; 30 30 31 31 export const Route = createFileRoute("/following")({ 32 32 component: FollowingPage, ··· 64 64 } 65 65 66 66 function FollowingPage() { 67 - const user = useUser(); 67 + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); 68 + const navigate = useNavigate(); 68 69 const queryClient = useQueryClient(); 69 70 const userHandle = user?.handle; 71 + 72 + // Redirect to login if not authenticated 73 + useEffect(() => { 74 + if (!authLoading && !isAuthenticated) { 75 + navigate({ to: "/login" }); 76 + } 77 + }, [authLoading, isAuthenticated, navigate]); 70 78 71 79 // Search state 72 80 const [searchQuery, setSearchQuery] = useState("");
+129 -626
apps/web/src/routes/index.tsx
··· 1 - import type { 2 - FollowedActivityItemDto, 3 - ReleaseCalendarItemDto, 4 - } from "@opnshelf/api"; 5 - import { 6 - showsControllerGetUserReleaseCalendarOptions, 7 - socialControllerGetFeedOptions, 8 - } from "@opnshelf/api"; 9 - import { useQuery } from "@tanstack/react-query"; 10 - import { createFileRoute, Link } from "@tanstack/react-router"; 1 + import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 11 2 import { 3 + ArrowRight, 12 4 Calendar, 13 - ChevronRight, 14 - Clock, 15 5 Film, 16 6 Heart, 17 - Loader2, 18 - MessageCircle, 19 - TrendingUp, 7 + List, 8 + Sparkles, 20 9 Tv, 21 10 Users, 22 11 } from "lucide-react"; 23 - import { setupApiClient } from "#/lib/api"; 12 + import { useEffect } from "react"; 24 13 import { useAuth } from "#/lib/auth-context"; 25 - import { 26 - useDashboardStats, 27 - useDiscoverMovies, 28 - useDiscoverShows, 29 - useUserShelf, 30 - } from "#/lib/hooks"; 31 - import MediaCard from "../components/MediaCard"; 32 14 33 15 export const Route = createFileRoute("/")({ 34 - component: Dashboard, 35 - beforeLoad: async () => { 36 - // Check if user is authenticated by fetching user data 37 - // If not authenticated, the API will return 401 and onUnauthorized handler will trigger 38 - // But we also want to redirect from client-side if we know user isn't logged in 39 - }, 16 + component: LandingPage, 40 17 }); 41 18 42 - // Initialize API client 43 - setupApiClient(); 19 + function LandingPage() { 20 + const { isAuthenticated, isLoading: authLoading } = useAuth(); 21 + const navigate = useNavigate(); 44 22 45 - // Demo stats - will be replaced with real user stats 46 - const demoStats = [ 47 - { label: "Movies", value: "0", icon: Film, change: "Track your first movie" }, 48 - { label: "Shows", value: "0", icon: Tv, change: "Track your first show" }, 49 - { label: "Hours", value: "0", icon: Clock, change: "Start watching" }, 50 - { label: "Streak", value: "0", icon: TrendingUp, change: "days" }, 51 - ]; 52 - 53 - // Helper function to format relative time 54 - function formatRelativeDate(dateStr: string): string { 55 - const releaseDate = new Date(dateStr); 56 - const today = new Date(); 57 - today.setHours(0, 0, 0, 0); 58 - releaseDate.setHours(0, 0, 0, 0); 59 - 60 - const diffTime = releaseDate.getTime() - today.getTime(); 61 - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); 62 - 63 - if (diffDays === 0) return "Today"; 64 - if (diffDays === 1) return "Tomorrow"; 65 - if (diffDays < 7) return `in ${diffDays} days`; 66 - if (diffDays < 30) return `in ${Math.ceil(diffDays / 7)} weeks`; 67 - return `in ${Math.ceil(diffDays / 30)} months`; 68 - } 69 - 70 - // Helper function to format date 71 - function formatDate(dateStr: string): string { 72 - const date = new Date(dateStr); 73 - return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); 74 - } 75 - 76 - // Helper function to format relative time for social feed 77 - function formatRelativeTime(dateString: string): string { 78 - const date = new Date(dateString); 79 - const now = new Date(); 80 - const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); 81 - 82 - if (diffInSeconds < 60) return "Just now"; 83 - if (diffInSeconds < 3600) 84 - return `${Math.floor(diffInSeconds / 60)} minutes ago`; 85 - if (diffInSeconds < 86400) 86 - return `${Math.floor(diffInSeconds / 3600)} hours ago`; 87 - if (diffInSeconds < 604800) 88 - return `${Math.floor(diffInSeconds / 86400)} days ago`; 89 - return date.toLocaleDateString(); 90 - } 91 - 92 - // Helper function to get episode info 93 - function getEpisodeInfo(item: ReleaseCalendarItemDto): string | undefined { 94 - if (item.releaseKind === "episode" && item.seasonNumber !== undefined) { 95 - if (item.episodeNumber !== undefined) { 96 - return `S${item.seasonNumber}E${item.episodeNumber}`; 23 + // Redirect to dashboard if already authenticated 24 + useEffect(() => { 25 + if (!authLoading && isAuthenticated) { 26 + navigate({ to: "/dashboard" }); 97 27 } 98 - return `Season ${item.seasonNumber}`; 99 - } 100 - if (item.releaseKind === "show") { 101 - return "Season Premiere"; 102 - } 103 - return undefined; 104 - } 105 - 106 - // Helper function to format watched time (e.g., "Apr 9 at 2:30 PM", "Jan 15, 2024 at 2:30 PM") 107 - function formatWatchedDate(dateStr: string): string { 108 - const date = new Date(dateStr); 109 - const now = new Date(); 110 - const isThisYear = date.getFullYear() === now.getFullYear(); 111 - const timeString = date.toLocaleTimeString("en-US", { 112 - hour: "numeric", 113 - minute: "2-digit", 114 - }); 115 - 116 - if (isThisYear) { 117 - const formattedDate = date.toLocaleDateString("en-US", { 118 - month: "short", 119 - day: "numeric", 120 - }); 121 - return `${formattedDate} at ${timeString}`; 122 - } 123 - const formattedDate = date.toLocaleDateString("en-US", { 124 - month: "short", 125 - day: "numeric", 126 - year: "numeric", 127 - }); 128 - return `${formattedDate} at ${timeString}`; 129 - } 130 - 131 - function Dashboard() { 132 - const { user, isAuthenticated, isLoading: authLoading } = useAuth(); 133 - const userDid = user?.did; 134 - 135 - // Fetch real user data from API 136 - const { 137 - data: moviesData, 138 - isLoading: moviesLoading, 139 - error: moviesError, 140 - } = useDiscoverMovies(1); 141 - const { 142 - data: showsData, 143 - isLoading: showsLoading, 144 - error: showsError, 145 - } = useDiscoverShows(1); 146 - const { data: shelfData, isLoading: shelfLoading } = useUserShelf( 147 - userDid || "", 148 - 12, 149 - ); 150 - const { data: statsData, isLoading: statsLoading } = useDashboardStats( 151 - userDid || "", 152 - ); 153 - 154 - // Fetch social activity feed 155 - const { data: feedData, isLoading: feedLoading } = useQuery({ 156 - ...socialControllerGetFeedOptions({ 157 - query: { pageSize: 10 }, 158 - }), 159 - enabled: !!userDid, 160 - }); 161 - 162 - // Fetch release calendar data 163 - const { data: calendarData, isLoading: calendarLoading } = useQuery({ 164 - ...showsControllerGetUserReleaseCalendarOptions({ 165 - path: { userDid: user?.did || "" }, 166 - }), 167 - enabled: !!user?.did, 168 - }); 169 - 170 - // Get upcoming releases - filter to next two weeks, limit to 10 171 - const upcomingReleases = calendarData?.items 172 - ? calendarData.items 173 - .filter((item) => { 174 - const releaseDate = new Date(item.releaseDate); 175 - const today = new Date(); 176 - today.setHours(0, 0, 0, 0); 177 - const twoWeeksLater = new Date(today); 178 - twoWeeksLater.setDate(today.getDate() + 14); 179 - return releaseDate >= today && releaseDate <= twoWeeksLater; 180 - }) 181 - .sort( 182 - (a, b) => 183 - new Date(a.releaseDate).getTime() - 184 - new Date(b.releaseDate).getTime(), 185 - ) 186 - .slice(0, 10) 187 - : []; 188 - 189 - const isLoading = 190 - moviesLoading || 191 - showsLoading || 192 - shelfLoading || 193 - statsLoading || 194 - feedLoading || 195 - authLoading || 196 - calendarLoading; 197 - const hasError = moviesError || showsError; 198 - 199 - // Calculate real stats from shelf data 200 - const movieCount = 201 - shelfData?.items?.filter((item) => item.type === "movie").length || 0; 202 - const showCount = 203 - shelfData?.items?.filter((item) => item.type === "episode").length || 0; 28 + }, [authLoading, isAuthenticated, navigate]); 204 29 205 - const userStats = [ 30 + const features = [ 206 31 { 207 - label: "Movies", 208 - value: String(movieCount), 209 32 icon: Film, 210 - change: statsData 211 - ? `+${statsData.recentMovies || 0} this month` 212 - : "Track your first movie", 33 + title: "Track Everything", 34 + description: 35 + "Log movies and TV shows you watch. Keep a complete history of your viewing journey.", 213 36 }, 214 37 { 215 - label: "Shows", 216 - value: String(showCount), 217 - icon: Tv, 218 - change: statsData 219 - ? `+${statsData.recentShows || 0} this month` 220 - : "Track your first show", 38 + icon: Heart, 39 + title: "Rate & Review", 40 + description: 41 + "Share your thoughts and ratings. Help others discover great content.", 221 42 }, 222 43 { 223 - label: "Hours", 224 - value: String(Math.round(statsData?.totalWatchTimeHours || 0)), 225 - icon: Clock, 226 - change: statsData?.weeklyWatchTimeHours 227 - ? `${statsData.weeklyWatchTimeHours}h this week` 228 - : "Start watching", 44 + icon: Calendar, 45 + title: "Release Calendar", 46 + description: 47 + "Never miss a premiere. Track upcoming releases for shows you follow.", 48 + }, 49 + { 50 + icon: Users, 51 + title: "Social Feed", 52 + description: 53 + "See what your friends are watching. Discover new favorites together.", 54 + }, 55 + { 56 + icon: List, 57 + title: "Curated Lists", 58 + description: 59 + "Create and share watchlists. Organize your must-watch content.", 229 60 }, 230 61 { 231 - label: "Streak", 232 - value: String(statsData?.streakDays || 0), 233 - icon: TrendingUp, 234 - change: 235 - statsData?.streakDays && statsData.streakDays > 0 236 - ? "days" 237 - : "Start a streak", 62 + icon: Tv, 63 + title: "Episode Tracking", 64 + description: 65 + "Track episodes and seasons. Pick up right where you left off.", 238 66 }, 239 67 ]; 240 68 241 - // Transform API data for MediaCard 242 - const featuredContent = [ 243 - ...(moviesData?.results?.slice(0, 4).map((movie) => ({ 244 - id: movie.id, 245 - title: movie.title, 246 - type: "movie" as const, 247 - posterUrl: movie.poster_path 248 - ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` 249 - : "", 250 - backdropUrl: movie.backdrop_path 251 - ? `https://image.tmdb.org/t/p/original${movie.backdrop_path}` 252 - : undefined, 253 - year: movie.release_date 254 - ? new Date(movie.release_date).getFullYear() 255 - : undefined, 256 - })) || []), 257 - ...(showsData?.results?.slice(0, 2).map((show) => ({ 258 - id: show.id, 259 - title: show.name, 260 - type: "show" as const, 261 - posterUrl: show.poster_path 262 - ? `https://image.tmdb.org/t/p/w500${show.poster_path}` 263 - : "", 264 - backdropUrl: show.backdrop_path 265 - ? `https://image.tmdb.org/t/p/original${show.backdrop_path}` 266 - : undefined, 267 - year: show.first_air_date 268 - ? new Date(show.first_air_date).getFullYear() 269 - : undefined, 270 - })) || []), 271 - ].slice(0, 6); 272 - 273 - // Transform user's tracked content for display from shelf data 274 - const userContent = 275 - shelfData?.items?.slice(0, 6).map((item) => { 276 - if (item.type === "movie") { 277 - return { 278 - id: item.movieId, 279 - title: item.title, 280 - type: "movie" as const, 281 - posterUrl: item.posterPath 282 - ? `https://image.tmdb.org/t/p/w500${item.posterPath}` 283 - : "", 284 - backdropUrl: item.backdropPath 285 - ? `https://image.tmdb.org/t/p/original${item.backdropPath}` 286 - : undefined, 287 - year: item.releaseYear, 288 - isWatched: !!item.watchedDate, 289 - watchedDate: item.watchedDate, 290 - }; 291 - } 292 - // Episode type 293 - return { 294 - id: item.id, // Use the unique tracked episode ID 295 - showId: item.showId, 296 - title: 297 - item.episodeTitle || 298 - `${item.showTitle} S${item.seasonNumber}E${item.episodeNumber}`, 299 - type: "show" as const, 300 - posterUrl: item.posterPath 301 - ? `https://image.tmdb.org/t/p/w500${item.posterPath}` 302 - : "", 303 - backdropUrl: item.backdropPath 304 - ? `https://image.tmdb.org/t/p/original${item.backdropPath}` 305 - : undefined, 306 - year: item.firstAirYear, 307 - episodeInfo: `${item.showTitle} • S${item.seasonNumber}E${item.episodeNumber}`, 308 - isWatched: !!item.watchedDate, 309 - watchedDate: item.watchedDate, 310 - }; 311 - }) || []; 312 - 313 - // Use user content if available, otherwise fall back to featured content 314 - const displayContent = userContent.length > 0 ? userContent : featuredContent; 315 - 316 69 return ( 317 - <div className="container-app py-8"> 318 - {/* Welcome Section */} 319 - <div className="mb-8"> 320 - <h1 className="text-display-2 mb-2"> 321 - {user?.displayName 322 - ? `Welcome back, ${user.displayName}` 323 - : "Welcome back"} 324 - </h1> 325 - <p className="text-[var(--foreground-muted)]"> 326 - {user?.handle 327 - ? `@${user.handle}` 328 - : "Here's what's happening with your media tracking."} 329 - </p> 330 - </div> 331 - 332 - {/* Stats Grid */} 333 - <div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> 334 - {(statsLoading ? demoStats : userStats).map((stat, index) => { 335 - const Icon = stat.icon; 336 - return ( 337 - <div 338 - key={stat.label} 339 - className="card p-5" 340 - style={{ animationDelay: `${index * 50}ms` }} 341 - > 342 - <div className="flex items-center justify-between"> 343 - <div> 344 - <p className="text-sm text-[var(--foreground-muted)]"> 345 - {stat.label} 346 - </p> 347 - <p className="text-display-3 mt-1">{stat.value}</p> 348 - <p className="mt-1 text-xs text-[var(--accent)]"> 349 - {stat.change} 350 - </p> 351 - </div> 352 - <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--accent-subtle)] text-[var(--accent)]"> 353 - <Icon className="h-6 w-6" /> 354 - </div> 355 - </div> 70 + <div className="min-h-screen"> 71 + {/* Hero Section */} 72 + <section className="relative overflow-hidden pt-16 pb-24 lg:pt-32 lg:pb-40"> 73 + <div className="container-app"> 74 + <div className="mx-auto max-w-3xl text-center"> 75 + {/* Badge */} 76 + <div className="mb-6 inline-flex items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--background-elevated)] px-4 py-1.5 text-sm"> 77 + <Sparkles className="h-4 w-4 text-[var(--accent)]" /> 78 + <span>Powered by AT Protocol</span> 356 79 </div> 357 - ); 358 - })} 359 - </div> 360 80 361 - {hasError && ( 362 - <div className="mb-6 rounded-lg border border-red-200 bg-red-50 p-4 text-red-800"> 363 - <p className="font-medium">Failed to load content</p> 364 - <p className="text-sm">Please check your connection and try again.</p> 365 - </div> 366 - )} 81 + {/* Headline */} 82 + <h1 className="text-display-1 mb-6"> 83 + Track what you watch. 84 + <br /> 85 + Share with friends. 86 + </h1> 367 87 368 - {/* Show sign in prompt if not authenticated */} 369 - {!authLoading && !isAuthenticated && ( 370 - <div className="mb-8 rounded-xl border border-[var(--border)] bg-[var(--background-elevated)] p-8 text-center"> 371 - <h2 className="text-display-3 mb-2">Welcome to OpnShelf</h2> 372 - <p className="mb-6 text-[var(--foreground-muted)]"> 373 - Sign in with your AT Protocol account to track your movies and 374 - shows. 375 - </p> 376 - <Link to="/login" className="btn btn-primary inline-flex gap-2"> 377 - Sign In 378 - <ChevronRight className="h-4 w-4" /> 379 - </Link> 380 - </div> 381 - )} 88 + {/* Subheadline */} 89 + <p className="mx-auto mb-10 max-w-xl text-lg text-[var(--foreground-muted)]"> 90 + OpnShelf is your personal media tracker. Log movies and shows, see 91 + what friends are watching, and discover your next favorite. 92 + </p> 382 93 383 - <div className="grid gap-8 lg:grid-cols-3"> 384 - {/* Main Content - Continue Watching */} 385 - <div className="lg:col-span-2 space-y-8"> 386 - {/* Continue Watching */} 387 - <section> 388 - <div className="mb-4 flex items-center justify-between"> 389 - <h2 className="text-display-3"> 390 - {userContent.length > 0 ? "Your Shelf" : "Featured For You"} 391 - </h2> 94 + {/* CTA Buttons */} 95 + <div className="flex flex-col items-center justify-center gap-4 sm:flex-row"> 392 96 <Link 393 - to="/shelf" 394 - className="flex items-center gap-1 text-sm font-medium text-[var(--accent)] hover:text-[var(--accent-hover)]" 97 + to="/login" 98 + className="btn btn-primary inline-flex items-center gap-2 px-8 py-3 text-lg" 395 99 > 396 - View all 397 - <ChevronRight className="h-4 w-4" /> 100 + Get Started 101 + <ArrowRight className="h-5 w-5" /> 398 102 </Link> 399 - </div> 400 - 401 - {isLoading ? ( 402 - <div className="flex h-64 items-center justify-center"> 403 - <Loader2 className="h-8 w-8 animate-spin text-[var(--accent)]" /> 404 - </div> 405 - ) : displayContent.length > 0 ? ( 406 - <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> 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 - ))} 426 - </div> 427 - ) : ( 428 - <div className="card p-8 text-center"> 429 - <p className="text-[var(--foreground-muted)]"> 430 - No content yet. Start tracking movies and shows! 431 - </p> 432 - <Link to="/search" className="btn btn-primary mt-4 inline-flex"> 433 - Discover Content 434 - </Link> 435 - </div> 436 - )} 437 - </section> 438 - 439 - {/* Social Feed - Activity from people you follow */} 440 - <section> 441 - <div className="mb-4 flex items-center justify-between"> 442 - <h2 className="text-display-3">Friend Activity</h2> 443 103 <Link 444 - to="/following" 445 - className="flex items-center gap-1 text-sm font-medium text-[var(--accent)] hover:text-[var(--accent-hover)]" 104 + to="/about" 105 + className="text-[var(--foreground-muted)] hover:text-[var(--foreground)] transition-colors" 446 106 > 447 - <Users className="h-4 w-4" /> 448 - View all 107 + Learn more 449 108 </Link> 450 109 </div> 451 - {feedLoading ? ( 452 - <div className="card p-8"> 453 - <div className="space-y-3"> 454 - {[1, 2, 3].map((i) => ( 455 - <div 456 - key={i} 457 - className="flex items-center gap-3 animate-pulse" 458 - > 459 - <div className="h-10 w-10 rounded-full bg-[var(--background-subtle)]" /> 460 - <div className="flex-1 space-y-1"> 461 - <div className="h-4 w-1/2 rounded bg-[var(--background-subtle)]" /> 462 - <div className="h-3 w-1/3 rounded bg-[var(--background-subtle)]" /> 463 - </div> 464 - </div> 465 - ))} 110 + </div> 111 + </div> 112 + 113 + {/* Background decoration */} 114 + <div className="absolute inset-0 -z-10 overflow-hidden"> 115 + <div className="absolute left-1/2 top-0 h-[600px] w-[800px] -translate-x-1/2 rounded-full bg-[var(--accent)] opacity-[0.03] blur-3xl" /> 116 + </div> 117 + </section> 118 + 119 + {/* Features Grid */} 120 + <section className="border-t border-[var(--border)] py-24"> 121 + <div className="container-app"> 122 + <div className="mb-16 text-center"> 123 + <h2 className="text-display-2 mb-4">Everything you need</h2> 124 + <p className="mx-auto max-w-xl text-[var(--foreground-muted)]"> 125 + A complete toolkit for media tracking and social discovery. 126 + </p> 127 + </div> 128 + 129 + <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> 130 + {features.map((feature) => { 131 + const Icon = feature.icon; 132 + return ( 133 + <div 134 + key={feature.title} 135 + className="card p-6 transition-colors hover:border-[var(--border-strong)]" 136 + > 137 + <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--accent-subtle)] text-[var(--accent)]"> 138 + <Icon className="h-6 w-6" /> 139 + </div> 140 + <h3 className="mb-2 text-lg font-semibold"> 141 + {feature.title} 142 + </h3> 143 + <p className="text-sm text-[var(--foreground-muted)]"> 144 + {feature.description} 145 + </p> 466 146 </div> 467 - </div> 468 - ) : feedData?.items && feedData.items.length > 0 ? ( 469 - <div className="card divide-y divide-[var(--border)]"> 470 - {feedData.items.map((item: FollowedActivityItemDto) => ( 471 - <div 472 - key={item.id} 473 - className="flex items-start gap-3 p-4 first:pt-5 last:pb-5" 474 - > 475 - {/* User Avatar */} 476 - <img 477 - src={ 478 - item.actor.avatar || 479 - `https://i.pravatar.cc/150?u=${item.actor.did}` 480 - } 481 - alt={item.actor.displayName || item.actor.handle} 482 - className="h-10 w-10 rounded-full object-cover" 483 - /> 484 - <div className="flex-1 min-w-0"> 485 - {/* Activity Header */} 486 - <p className="text-sm"> 487 - <Link 488 - to={`/profile/${item.actor.handle}`} 489 - className="font-medium hover:text-[var(--accent)]" 490 - > 491 - {item.actor.displayName || item.actor.handle} 492 - </Link>{" "} 493 - {item.verb === "watch" && ( 494 - <span className="text-[var(--foreground-muted)]"> 495 - watched 496 - </span> 497 - )} 498 - {item.verb === "follow" && ( 499 - <span className="text-[var(--foreground-muted)]"> 500 - followed 501 - </span> 502 - )} 503 - {item.verb === "list_add" && ( 504 - <span className="text-[var(--foreground-muted)]"> 505 - added to list 506 - </span> 507 - )} 508 - </p> 509 - {/* Content Title */} 510 - <p className="font-medium text-sm mt-0.5"> 511 - <Link 512 - to={`/${item.content.type}/${item.content.id}`} 513 - className="hover:text-[var(--accent)]" 514 - > 515 - {item.content.title} 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 - )} 524 - </Link> 525 - </p> 526 - {/* Timestamp & Actions */} 527 - <div className="flex items-center gap-3 mt-1.5 text-xs text-[var(--foreground-muted)]"> 528 - <span>{formatRelativeTime(item.createdAt)}</span> 529 - {item.verb === "watch" && ( 530 - <button 531 - type="button" 532 - className="flex items-center gap-1 hover:text-[var(--accent)]" 533 - > 534 - <Heart className="h-3 w-3" /> 535 - Like 536 - </button> 537 - )} 538 - </div> 539 - </div> 540 - {/* Content Type Badge */} 541 - <span 542 - className={`badge ${item.content.type === "movie" ? "badge-subtle" : "badge-accent"}`} 543 - > 544 - {item.content.type === "movie" ? "Movie" : "TV"} 545 - </span> 546 - </div> 547 - ))} 548 - </div> 549 - ) : ( 550 - <div className="card p-8 text-center"> 551 - <MessageCircle className="h-12 w-12 mx-auto mb-3 text-[var(--foreground-muted)]" /> 552 - <p className="text-[var(--foreground-muted)]"> 553 - Activity from people you follow will appear here. 554 - </p> 555 - <Link 556 - to="/following" 557 - className="btn btn-primary mt-4 inline-flex" 558 - > 559 - <Users className="h-4 w-4 mr-2" /> 560 - Find people to follow 561 - </Link> 562 - </div> 563 - )} 564 - </section> 147 + ); 148 + })} 149 + </div> 565 150 </div> 151 + </section> 566 152 567 - {/* Sidebar */} 568 - <div className="space-y-8"> 569 - {/* Upcoming */} 570 - <section> 571 - <div className="mb-4 flex items-center justify-between"> 572 - <h2 className="text-display-3">Upcoming</h2> 573 - <Link 574 - to="/calendar" 575 - className="flex items-center gap-1 text-sm font-medium text-[var(--accent)] hover:text-[var(--accent-hover)]" 576 - > 577 - <Calendar className="h-4 w-4" /> 578 - Calendar 579 - </Link> 580 - </div> 581 - {calendarLoading ? ( 582 - <div className="card p-4"> 583 - <div className="space-y-3"> 584 - {[1, 2, 3].map((i) => ( 585 - <div 586 - key={i} 587 - className="flex items-center gap-3 animate-pulse" 588 - > 589 - <div className="h-12 w-9 rounded bg-[var(--background-subtle)]" /> 590 - <div className="flex-1 space-y-1"> 591 - <div className="h-4 w-3/4 rounded bg-[var(--background-subtle)]" /> 592 - <div className="h-3 w-1/2 rounded bg-[var(--background-subtle)]" /> 593 - </div> 594 - </div> 595 - ))} 596 - </div> 597 - </div> 598 - ) : upcomingReleases.length === 0 ? ( 599 - <div className="card p-6 text-center"> 600 - <Clock className="mx-auto mb-3 h-8 w-8 text-[var(--foreground-muted)]" /> 601 - <p className="text-sm text-[var(--foreground-muted)]"> 602 - No upcoming releases 603 - </p> 604 - <p className="mt-1 text-xs text-[var(--foreground-muted)]"> 605 - Track shows and movies to see their release dates here. 606 - </p> 607 - </div> 608 - ) : ( 609 - <div className="space-y-3"> 610 - {upcomingReleases.map((release) => ( 611 - <Link 612 - key={`${release.showId || release.movieId || release.title}-${release.releaseDate}`} 613 - to={ 614 - release.mediaType === "movie" && release.movieId 615 - ? `/movies/${release.movieId}` 616 - : release.showId 617 - ? `/shows/${release.showId}` 618 - : "#" 619 - } 620 - className="card card-interactive flex items-center gap-3 p-3" 621 - > 622 - {release.posterPath ? ( 623 - <img 624 - src={`https://image.tmdb.org/t/p/w200${release.posterPath}`} 625 - alt={release.title} 626 - className="h-12 w-9 rounded object-cover" 627 - /> 628 - ) : ( 629 - <div className="flex h-12 w-9 items-center justify-center rounded bg-[var(--background-subtle)]"> 630 - {release.mediaType === "movie" ? ( 631 - <Film className="h-5 w-5 text-[var(--foreground-muted)]" /> 632 - ) : ( 633 - <Tv className="h-5 w-5 text-[var(--foreground-muted)]" /> 634 - )} 635 - </div> 636 - )} 637 - <div className="flex-1 min-w-0"> 638 - <p className="font-medium text-sm truncate"> 639 - {release.title} 640 - </p> 641 - <div className="mt-0.5 flex items-center gap-2 text-xs text-[var(--foreground-muted)]"> 642 - <span>{formatDate(release.releaseDate)}</span> 643 - <span className="text-[var(--accent)]"> 644 - • {formatRelativeDate(release.releaseDate)} 645 - </span> 646 - </div> 647 - {getEpisodeInfo(release) && ( 648 - <p className="mt-0.5 text-xs text-[var(--foreground-muted)] truncate"> 649 - {getEpisodeInfo(release)} 650 - </p> 651 - )} 652 - </div> 653 - <span 654 - className={`badge ${ 655 - release.mediaType === "movie" 656 - ? "badge-subtle" 657 - : "badge-accent" 658 - }`} 659 - > 660 - {release.mediaType === "movie" ? "Movie" : "TV"} 661 - </span> 662 - </Link> 663 - ))} 664 - </div> 665 - )} 666 - </section> 153 + {/* CTA Section */} 154 + <section className="border-t border-[var(--border)] py-24"> 155 + <div className="container-app"> 156 + <div className="mx-auto max-w-2xl text-center"> 157 + <h2 className="text-display-2 mb-4">Ready to start tracking?</h2> 158 + <p className="mb-8 text-[var(--foreground-muted)]"> 159 + Join thousands of users tracking their media journey with 160 + OpnShelf. Sign in with your AT Protocol account to get started. 161 + </p> 162 + <Link 163 + to="/login" 164 + className="btn btn-primary inline-flex items-center gap-2 px-8 py-3 text-lg" 165 + > 166 + Sign In 167 + <ArrowRight className="h-5 w-5" /> 168 + </Link> 169 + </div> 667 170 </div> 668 - </div> 171 + </section> 669 172 </div> 670 173 ); 671 174 }
+13 -4
apps/web/src/routes/lists.tsx
··· 1 1 import type { MediaInListDto } from "@opnshelf/api"; 2 - import { createFileRoute } from "@tanstack/react-router"; 2 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 3 import { 4 4 AlertCircle, 5 5 Clock, ··· 16 16 Star, 17 17 Tv, 18 18 } from "lucide-react"; 19 - import { useMemo, useState } from "react"; 19 + import { useEffect, useMemo, useState } from "react"; 20 20 import { Button } from "#/components/ui/button"; 21 21 import { 22 22 Dialog, ··· 25 25 DialogHeader, 26 26 DialogTitle, 27 27 } from "#/components/ui/dialog"; 28 - import { useUser } from "#/lib/auth-context"; 28 + import { useAuth } from "#/lib/auth-context"; 29 29 import { useCreateList, useList, useUserLists } from "#/lib/hooks"; 30 30 import MediaCard from "../components/MediaCard"; 31 31 ··· 129 129 } 130 130 131 131 function ListsPage() { 132 - const _user = useUser(); 132 + const { isAuthenticated, isLoading: authLoading } = useAuth(); 133 + const navigate = useNavigate(); 134 + 135 + // Redirect to login if not authenticated 136 + useEffect(() => { 137 + if (!authLoading && !isAuthenticated) { 138 + navigate({ to: "/login" }); 139 + } 140 + }, [authLoading, isAuthenticated, navigate]); 141 + 133 142 const [selectedListSlug, setSelectedListSlug] = useState<string | null>(null); 134 143 const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); 135 144 const [showCreateModal, setShowCreateModal] = useState(false);
+8 -9
apps/web/src/routes/login.tsx
··· 1 - import { createFileRoute, redirect } from "@tanstack/react-router"; 1 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 2 import { ArrowRight, Film, Loader2 } from "lucide-react"; 3 - import { useState } from "react"; 3 + import { useEffect, useState } from "react"; 4 4 import { useAuth } from "#/lib/auth-context"; 5 5 6 6 export const Route = createFileRoute("/login")({ 7 7 component: LoginPage, 8 - beforeLoad: () => { 9 - // If user is already authenticated, redirect to home 10 - // Note: This is a simple check, the actual check happens in the component 11 - }, 12 8 }); 13 9 14 10 function LoginPage() { 15 11 const [handle, setHandle] = useState(""); 16 12 const [isLoading, setIsLoading] = useState(false); 17 13 const { login, signup, isAuthenticated } = useAuth(); 14 + const navigate = useNavigate(); 18 15 19 16 // Redirect if already authenticated 20 - if (isAuthenticated) { 21 - throw redirect({ to: "/" }); 22 - } 17 + useEffect(() => { 18 + if (isAuthenticated) { 19 + navigate({ to: "/dashboard" }); 20 + } 21 + }, [isAuthenticated, navigate]); 23 22 24 23 const handleLogin = (e: React.FormEvent) => { 25 24 e.preventDefault();