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: add mobile list view to calendar with week-based navigation

+206 -15
+206 -15
apps/web/src/routes/calendar.tsx
··· 11 11 Loader2, 12 12 Tv, 13 13 } from "lucide-react"; 14 - import { useMemo, useState } from "react"; 14 + import { useEffect, useMemo, useState } from "react"; 15 15 import { useUser } from "../lib/auth-context"; 16 16 17 17 export const Route = createFileRoute("/calendar")({ ··· 35 35 return releasesByDate; 36 36 } 37 37 38 + // Get the start of the week (Monday) for a given date 39 + function getWeekStart(date: Date): Date { 40 + const d = new Date(date); 41 + const day = d.getDay(); 42 + // Adjust for Monday start (0 = Sunday, so Monday is 1) 43 + const diff = day === 0 ? 6 : day - 1; 44 + d.setDate(d.getDate() - diff); 45 + d.setHours(0, 0, 0, 0); 46 + return d; 47 + } 48 + 38 49 function CalendarPage() { 39 50 const user = useUser(); 40 51 const [currentDate, setCurrentDate] = useState(new Date()); 41 52 const [selectedWeekStart, setSelectedWeekStart] = useState<Date | null>(null); 53 + 54 + // Initialize selected week to current week on mount 55 + useEffect(() => { 56 + setSelectedWeekStart((current) => { 57 + if (!current) { 58 + return getWeekStart(new Date()); 59 + } 60 + return current; 61 + }); 62 + }, []); 42 63 43 64 // Calculate date range for 3 months (prev, current, next) 44 65 const dateRange = useMemo(() => { ··· 127 148 return `${year}-${month}-${dayStr}`; 128 149 }; 129 150 130 - const getWeekStart = (date: Date): Date => { 131 - const d = new Date(date); 132 - const day = d.getDay(); 133 - // Adjust for Monday start (0 = Sunday, so Monday is 1) 134 - const diff = day === 0 ? 6 : day - 1; 135 - d.setDate(d.getDate() - diff); 136 - d.setHours(0, 0, 0, 0); 137 - return d; 138 - }; 139 - 140 151 const isSameDay = (d1: Date, d2: Date): boolean => { 141 152 return ( 142 153 d1.getFullYear() === d2.getFullYear() && ··· 236 247 return `${selectedWeekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`; 237 248 }; 238 249 250 + // Mobile week navigation functions 251 + const goToPrevWeek = () => { 252 + if (!selectedWeekStart) return; 253 + const newWeekStart = new Date(selectedWeekStart); 254 + newWeekStart.setDate(selectedWeekStart.getDate() - 7); 255 + setSelectedWeekStart(newWeekStart); 256 + setCurrentDate(newWeekStart); 257 + }; 258 + 259 + const goToNextWeek = () => { 260 + if (!selectedWeekStart) return; 261 + const newWeekStart = new Date(selectedWeekStart); 262 + newWeekStart.setDate(selectedWeekStart.getDate() + 7); 263 + setSelectedWeekStart(newWeekStart); 264 + setCurrentDate(newWeekStart); 265 + }; 266 + 267 + // Group releases by day for mobile list view 268 + const getMobileWeekReleases = useMemo(() => { 269 + if (!selectedWeekStart) return []; 270 + 271 + const days: Array<{ 272 + date: Date; 273 + dateKey: string; 274 + releases: Array<ReleaseCalendarItemDto>; 275 + isToday: boolean; 276 + }> = []; 277 + 278 + for (let i = 0; i < 7; i++) { 279 + const date = new Date(selectedWeekStart); 280 + date.setDate(selectedWeekStart.getDate() + i); 281 + const year = date.getFullYear(); 282 + const month = String(date.getMonth() + 1).padStart(2, "0"); 283 + const day = String(date.getDate()).padStart(2, "0"); 284 + const dateKey = `${year}-${month}-${day}`; 285 + const dayReleases = releases[dateKey] || []; 286 + 287 + const today = new Date(); 288 + const isToday = 289 + date.getFullYear() === today.getFullYear() && 290 + date.getMonth() === today.getMonth() && 291 + date.getDate() === today.getDate(); 292 + 293 + days.push({ 294 + date, 295 + dateKey, 296 + releases: dayReleases, 297 + isToday, 298 + }); 299 + } 300 + 301 + return days; 302 + }, [selectedWeekStart, releases]); 303 + 304 + // Format date for mobile view (e.g., "Mon, Jan 15") 305 + const formatMobileDate = (date: Date): string => { 306 + const today = new Date(); 307 + const isToday = 308 + date.getFullYear() === today.getFullYear() && 309 + date.getMonth() === today.getMonth() && 310 + date.getDate() === today.getDate(); 311 + 312 + if (isToday) return "Today"; 313 + 314 + return date.toLocaleDateString("en-US", { 315 + weekday: "short", 316 + month: "short", 317 + day: "numeric", 318 + }); 319 + }; 320 + 239 321 if (isLoading) { 240 322 return ( 241 323 <div className="container-app py-8"> ··· 265 347 </p> 266 348 </div> 267 349 268 - {/* Calendar Navigation */} 269 - <div className="mb-6 flex items-center justify-between"> 350 + {/* Desktop: Calendar Navigation */} 351 + <div className="mb-6 hidden items-center justify-between lg:flex"> 270 352 <button type="button" onClick={prevMonth} className="btn btn-secondary"> 271 353 <ChevronLeft className="h-4 w-4" /> 272 354 Previous ··· 279 361 <button 280 362 type="button" 281 363 onClick={goToToday} 282 - className="text-sm text-[var(--foreground-muted)] hover:text-[var(--foreground)] transition-colors mt-1" 364 + className="mt-1 text-sm text-[var(--foreground-muted)] transition-colors hover:text-[var(--foreground)]" 283 365 > 284 366 Go to today 285 367 </button> ··· 291 373 </button> 292 374 </div> 293 375 294 - <div className="grid gap-8 lg:grid-cols-3"> 376 + {/* Mobile: Week Navigation */} 377 + <div className="mb-6 flex items-center justify-between lg:hidden"> 378 + <button 379 + type="button" 380 + onClick={goToPrevWeek} 381 + className="btn btn-secondary h-12 w-12 p-0" 382 + aria-label="Previous week" 383 + > 384 + <ChevronLeft className="h-6 w-6" /> 385 + </button> 386 + 387 + <div className="flex flex-col items-center px-4"> 388 + <h2 className="text-display-3 text-center"> 389 + {selectedWeekStart ? formatWeekRange() : "Select a week"} 390 + </h2> 391 + <button 392 + type="button" 393 + onClick={goToToday} 394 + className="mt-1 text-sm text-[var(--foreground-muted)] transition-colors hover:text-[var(--foreground)]" 395 + > 396 + Go to today 397 + </button> 398 + </div> 399 + 400 + <button 401 + type="button" 402 + onClick={goToNextWeek} 403 + className="btn btn-secondary h-12 w-12 p-0" 404 + aria-label="Next week" 405 + > 406 + <ChevronRight className="h-6 w-6" /> 407 + </button> 408 + </div> 409 + 410 + {/* Mobile: Week List View */} 411 + <div className="space-y-6 lg:hidden"> 412 + {getMobileWeekReleases.map((day, _index) => ( 413 + <section key={day.dateKey} className={day.isToday ? "relative" : ""}> 414 + {day.isToday && ( 415 + <div className="absolute -left-3 top-0 bottom-0 w-1 rounded-full bg-[var(--accent)]" /> 416 + )} 417 + <h3 418 + className={`mb-3 text-display-3 ${ 419 + day.isToday ? "text-[var(--accent)]" : "" 420 + }`} 421 + > 422 + {formatMobileDate(day.date)} 423 + </h3> 424 + 425 + {day.releases.length === 0 ? ( 426 + <div className="card p-4"> 427 + <p className="text-sm text-[var(--foreground-muted)]"> 428 + No releases 429 + </p> 430 + </div> 431 + ) : ( 432 + <div className="space-y-3"> 433 + {day.releases.map((release) => ( 434 + <Link 435 + key={`${release.showId || release.movieId}-${day.dateKey}-${release.seasonNumber}-${release.episodeNumber}`} 436 + to={getItemUrl(release)} 437 + className="card card-interactive flex items-center gap-3 p-3" 438 + > 439 + {release.posterPath ? ( 440 + <img 441 + src={`https://image.tmdb.org/t/p/w200${release.posterPath}`} 442 + alt={release.title} 443 + className="h-24 w-16 rounded object-cover" 444 + loading="lazy" 445 + /> 446 + ) : ( 447 + <div className="flex h-24 w-16 shrink-0 items-center justify-center rounded bg-[var(--background-subtle)]"> 448 + {getReleaseType(release) === "movie" ? ( 449 + <Film className="h-10 w-10 text-[var(--foreground-muted)]" /> 450 + ) : ( 451 + <Tv className="h-10 w-10 text-[var(--foreground-muted)]" /> 452 + )} 453 + </div> 454 + )} 455 + <div className="min-w-0 flex-1"> 456 + <p className="truncate font-medium"> 457 + {getDisplayTitle(release)} 458 + </p> 459 + <div className="mt-1 flex items-center gap-2 text-sm text-[var(--foreground-muted)]"> 460 + <span className="flex items-center gap-1"> 461 + {getReleaseType(release) === "movie" ? ( 462 + <> 463 + <Film className="h-4 w-4" /> 464 + Movie 465 + </> 466 + ) : ( 467 + <> 468 + <Tv className="h-4 w-4" /> 469 + TV 470 + </> 471 + )} 472 + </span> 473 + </div> 474 + </div> 475 + <ChevronRight className="h-6 w-6 shrink-0 text-[var(--foreground-muted)]" /> 476 + </Link> 477 + ))} 478 + </div> 479 + )} 480 + </section> 481 + ))} 482 + </div> 483 + 484 + {/* Desktop View */} 485 + <div className="hidden gap-8 lg:grid lg:grid-cols-3"> 295 486 {/* Calendar Grid */} 296 487 <div className="lg:col-span-2"> 297 488 {/* Weekday Headers */}