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

Configure Feed

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

fix: mobile improvements (closes #70)

- Add Profile/Settings links to mobile dropdown + fix auto-close
- Add datetime picker for Your activity section
- Show Manage list + ListChecks icon when item already in a list
- Change Mark watched → Add to shelf on up-next page
- Replace x months ago with exact dates for older items
- Show 2 grid items on mobile for profile Recent sections
- Fix following/connections cards to extend full width
- Show per-week stats on mobile dashboard with shorter labels
- Fix media header title clipping on small screens
- Remove footer link columns, keep brand + social only
- Allow two-line titles in media cards
- Scroll to top on pagination page changes
- Remove list view from shelf page

+273 -374
+16 -2
apps/web/src/components/ActionableMediaCard.tsx
··· 4 4 import MediaCard from "#/components/MediaCard"; 5 5 import { useAuth } from "#/lib/auth-context"; 6 6 import { formatDateTime } from "#/lib/date-utils"; 7 - import { useMediaWatchStatus } from "#/lib/hooks"; 7 + import { useListItemStatus, useMediaWatchStatus } from "#/lib/hooks"; 8 8 import { useWatchActions } from "#/lib/hooks/useWatchActions"; 9 9 10 10 interface ActionableMediaCardProps { ··· 69 69 !isMovie && 70 70 typeof seasonNumber === "number" && 71 71 typeof episodeNumber === "number"; 72 + 73 + const { isInWatchlist, isInFavorites, otherLists } = useListItemStatus({ 74 + mediaType: type, 75 + mediaId: String(id), 76 + seasonNumber, 77 + episodeNumber, 78 + }); 79 + 80 + const isInAnyList = isInWatchlist || isInFavorites || otherLists.length > 0; 72 81 73 82 const watchActions = useWatchActions( 74 83 isMovie ··· 201 210 ? watchActions.isUnmarkEpisodePending 202 211 : watchActions.isUnmarkShowPending 203 212 } 213 + isInAnyList={isInAnyList} 204 214 /> 205 215 {interactive && ( 206 216 <ManageListsDialog ··· 210 220 episodeNumber={episodeNumber} 211 221 open={listDialogOpen} 212 222 onOpenChange={setListDialogOpen} 213 - title={`Add "${title}" to lists`} 223 + title={ 224 + isInAnyList 225 + ? `Manage lists for "${title}"` 226 + : `Add "${title}" to lists` 227 + } 214 228 /> 215 229 )} 216 230 {interactive && (
+16 -1
apps/web/src/components/DashboardMediaCard.tsx
··· 1 1 import { useMemo, useState } from "react"; 2 2 import ConfirmRemoveDialog from "#/components/ConfirmRemoveDialog"; 3 - import { useMediaWatchStatus } from "#/lib/hooks"; 3 + import { useListItemStatus, useMediaWatchStatus } from "#/lib/hooks"; 4 4 import { useWatchActions } from "#/lib/hooks/useWatchActions"; 5 5 import ManageListsDialog from "./ManageListsDialog"; 6 6 import type { MediaCardProps } from "./MediaCard"; ··· 49 49 useMediaWatchStatus(watchStatusOptions); 50 50 51 51 const rawMediaId = isMovie ? String(id) : actualShowId || String(id); 52 + 53 + const { isInWatchlist, isInFavorites, otherLists } = useListItemStatus({ 54 + mediaType: type, 55 + mediaId: String(id), 56 + seasonNumber, 57 + episodeNumber, 58 + }); 59 + 60 + const isInAnyList = isInWatchlist || isInFavorites || otherLists.length > 0; 52 61 53 62 const handleMarkWatched = () => { 54 63 if (isMovie) { ··· 134 143 ? watchActions.isUnmarkMoviePending 135 144 : watchActions.isUnmarkEpisodePending 136 145 } 146 + isInAnyList={isInAnyList} 137 147 /> 138 148 <ManageListsDialog 139 149 mediaType={type} ··· 142 152 episodeNumber={episodeNumber} 143 153 open={listDialogOpen} 144 154 onOpenChange={setListDialogOpen} 155 + title={ 156 + isInAnyList 157 + ? `Manage lists for "${title}"` 158 + : `Add "${title}" to lists` 159 + } 145 160 /> 146 161 <ConfirmRemoveDialog 147 162 open={confirmRemoveOpen}
+9 -1
apps/web/src/components/FeedItemActions.tsx
··· 1 - import { Bookmark, BookmarkCheck, Library, Loader2 } from "lucide-react"; 1 + import { 2 + Bookmark, 3 + BookmarkCheck, 4 + Library, 5 + ListChecks, 6 + Loader2, 7 + } from "lucide-react"; 2 8 import { useMemo, useState } from "react"; 3 9 import ConfirmRemoveDialog from "#/components/ConfirmRemoveDialog"; 4 10 import ManageListsDialog from "#/components/ManageListsDialog"; ··· 171 177 > 172 178 {isListsLoading ? ( 173 179 <Loader2 className="size-3.5 animate-spin" /> 180 + ) : otherLists.length > 0 ? ( 181 + <ListChecks className="size-3.5" /> 174 182 ) : ( 175 183 <Library className="size-3.5" /> 176 184 )}
+5 -70
apps/web/src/components/Footer.tsx
··· 28 28 ); 29 29 30 30 const footerLinks = { 31 - product: [ 32 - { name: "Features", href: "#" }, 33 - { name: "Calendar", href: "/calendar" }, 34 - { name: "Import", href: "/import" }, 35 - ], 36 - company: [ 37 - { name: "About", href: "/about" }, 38 - { name: "Blog", href: "#" }, 39 - { name: "Careers", href: "#" }, 40 - { name: "Contact", href: "#" }, 41 - ], 42 - legal: [ 43 - { name: "Privacy", href: "#" }, 44 - { name: "Terms", href: "#" }, 45 - { name: "Cookie Policy", href: "#" }, 46 - ], 47 31 social: [ 48 32 { 49 33 name: "Bluesky", ··· 64 48 return ( 65 49 <footer className="border-(--border) border-t bg-(--background)"> 66 50 <div className="container-app py-12"> 67 - <div className="grid gap-8 md:grid-cols-2 lg:grid-cols-5"> 51 + <div className="flex flex-col items-center gap-8"> 68 52 {/* Brand */} 69 - <div className="lg:col-span-2"> 70 - <Link to="/" className="flex items-center gap-2"> 53 + <div className="text-center"> 54 + <Link to="/" className="inline-flex items-center gap-2"> 71 55 <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-(--accent) text-[#3f2e00]"> 72 56 <Film className="size-4" /> 73 57 </div> 74 58 <span className="font-bold font-display text-lg">OpnShelf</span> 75 59 </Link> 76 - <p className="mt-4 max-w-sm text-(--foreground-muted) text-sm"> 60 + <p className="mx-auto mt-4 max-w-sm text-(--foreground-muted) text-sm"> 77 61 Track what you watch and discover what others are watching. A 78 62 personal media tracker built on the AT Protocol. 79 63 </p> 80 - <div className="mt-6 flex items-center gap-4"> 64 + <div className="mt-6 flex items-center justify-center gap-4"> 81 65 {footerLinks.social.map((item) => { 82 66 const Icon = item.icon; 83 67 return ( ··· 98 82 ); 99 83 })} 100 84 </div> 101 - </div> 102 - 103 - {/* Links */} 104 - <div> 105 - <h3 className="font-display font-semibold text-sm">Product</h3> 106 - <ul className="mt-4 space-y-3"> 107 - {footerLinks.product.map((item) => ( 108 - <li key={item.name}> 109 - <Link 110 - to={item.href} 111 - className="text-(--foreground-muted) text-sm transition-colors hover:text-(--foreground)" 112 - > 113 - {item.name} 114 - </Link> 115 - </li> 116 - ))} 117 - </ul> 118 - </div> 119 - 120 - <div> 121 - <h3 className="font-display font-semibold text-sm">Company</h3> 122 - <ul className="mt-4 space-y-3"> 123 - {footerLinks.company.map((item) => ( 124 - <li key={item.name}> 125 - <Link 126 - to={item.href} 127 - className="text-(--foreground-muted) text-sm transition-colors hover:text-(--foreground)" 128 - > 129 - {item.name} 130 - </Link> 131 - </li> 132 - ))} 133 - </ul> 134 - </div> 135 - 136 - <div> 137 - <h3 className="font-display font-semibold text-sm">Legal</h3> 138 - <ul className="mt-4 space-y-3"> 139 - {footerLinks.legal.map((item) => ( 140 - <li key={item.name}> 141 - <a 142 - href={item.href} 143 - className="text-(--foreground-muted) text-sm transition-colors hover:text-(--foreground)" 144 - > 145 - {item.name} 146 - </a> 147 - </li> 148 - ))} 149 - </ul> 150 85 </div> 151 86 </div> 152 87
+26 -1
apps/web/src/components/Header.tsx
··· 249 249 {/* Mobile user section */} 250 250 {isAuthenticated && user && ( 251 251 <> 252 - <div className="my-2 border-(--border) border-t" /> 252 + <Link 253 + to="/profile/$handle" 254 + params={{ handle: user.handle }} 255 + onClick={() => setMobileMenuOpen(false)} 256 + className={`flex items-center gap-3 rounded-md px-3 py-3 font-medium text-sm transition-colors ${ 257 + currentPath === `/profile/${user.handle}` 258 + ? "bg-(--accent-subtle) text-(--accent)" 259 + : "text-(--foreground-muted) hover:bg-(--background-subtle) hover:text-(--foreground)" 260 + }`} 261 + > 262 + <User className="h-5 w-5" /> 263 + Profile 264 + </Link> 265 + <Link 266 + to="/settings" 267 + onClick={() => setMobileMenuOpen(false)} 268 + className={`flex items-center gap-3 rounded-md px-3 py-3 font-medium text-sm transition-colors ${ 269 + currentPath === "/settings" 270 + ? "bg-(--accent-subtle) text-(--accent)" 271 + : "text-(--foreground-muted) hover:bg-(--background-subtle) hover:text-(--foreground)" 272 + }`} 273 + > 274 + <Settings className="h-5 w-5" /> 275 + Settings 276 + </Link> 277 + <div className="my-2 h-px bg-(--border)" /> 253 278 <div className="flex items-center gap-3 px-3 py-3"> 254 279 <div className="flex h-8 w-8 items-center justify-center rounded-full bg-(--accent-subtle)"> 255 280 {user.avatar ? (
+13 -6
apps/web/src/components/MediaCard.tsx
··· 4 4 Check, 5 5 Clock, 6 6 Library, 7 + ListChecks, 7 8 Loader2, 8 9 Play, 9 10 Star, ··· 31 32 progress?: number; 32 33 isWatched?: boolean; 33 34 isInWatchlist?: boolean; 35 + isInAnyList?: boolean; 34 36 watchedDate?: string; 35 37 role?: string; 36 38 year?: string | number; ··· 75 77 isRemoving = false, 76 78 isMarkWatchedPending = false, 77 79 isUnmarkWatchedPending = false, 80 + isInAnyList = false, 78 81 }: MediaCardProps) { 79 82 const [imageLoaded, setImageLoaded] = useState(false); 80 83 const [imageError, setImageError] = useState(false); ··· 219 222 onManageLists(); 220 223 }} 221 224 className="flex h-7 w-7 items-center justify-center rounded-full bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/40" 222 - aria-label="Manage lists" 223 - title="Add to list" 225 + aria-label={isInAnyList ? "Manage lists" : "Add to list"} 226 + title={isInAnyList ? "Manage list" : "Add to list"} 224 227 > 225 - <Library className="size-3.5" /> 228 + {isInAnyList ? ( 229 + <ListChecks className="size-3.5" /> 230 + ) : ( 231 + <Library className="size-3.5" /> 232 + )} 226 233 </button> 227 234 )} 228 235 {onRemove && ( ··· 283 290 {/* Backdrop layout content */} 284 291 {layout === "backdrop" && ( 285 292 <div className="absolute right-0 bottom-0 left-0 p-4"> 286 - <h3 className="line-clamp-1 font-semibold text-white"> 293 + <h3 className="line-clamp-2 font-semibold text-white"> 287 294 {displayName} 288 295 </h3> 289 296 {episodeInfo && ( ··· 304 311 <div className="mt-2 space-y-1"> 305 312 {episodeInfo ? ( 306 313 <> 307 - <h3 className="line-clamp-1 font-medium text-(--foreground) text-sm"> 314 + <h3 className="line-clamp-2 font-medium text-(--foreground) text-sm"> 308 315 {episodeInfo} 309 316 </h3> 310 317 <p className="line-clamp-1 text-(--foreground-muted) text-xs"> ··· 312 319 </p> 313 320 </> 314 321 ) : ( 315 - <h3 className="line-clamp-1 font-medium text-(--foreground) text-sm"> 322 + <h3 className="line-clamp-2 font-medium text-(--foreground) text-sm"> 316 323 {displayName} 317 324 </h3> 318 325 )}
+3 -3
apps/web/src/components/MediaHero.tsx
··· 111 111 <div className="h-full w-full bg-linear-to-br from-gray-700 to-gray-800" /> 112 112 )} 113 113 </div> 114 - <div className="flex flex-col justify-center"> 115 - <h1 className="text-display-2">{title}</h1> 114 + <div className="flex min-w-0 flex-col justify-center overflow-hidden"> 115 + <h1 className="break-words text-display-2">{title}</h1> 116 116 </div> 117 117 </div> 118 118 119 119 {/* Desktop Title */} 120 120 <div className="hidden lg:block"> 121 - <h1 className="text-display-2">{title}</h1> 121 + <h1 className="break-words text-display-2">{title}</h1> 122 122 </div> 123 123 124 124 {/* Meta Info */}
+12 -3
apps/web/src/components/Pagination.tsx
··· 39 39 <div className="flex items-center justify-center gap-1"> 40 40 <button 41 41 type="button" 42 - onClick={() => onPageChange(page - 1)} 42 + onClick={() => { 43 + onPageChange(page - 1); 44 + window.scrollTo({ top: 0, behavior: "smooth" }); 45 + }} 43 46 disabled={page <= 1} 44 47 className="flex h-9 items-center rounded-md border border-(--border) bg-(--background-elevated) px-3 text-sm transition-colors hover:bg-(--background-subtle) disabled:opacity-40 disabled:hover:bg-(--background-elevated)" 45 48 > ··· 58 61 <button 59 62 key={item.value} 60 63 type="button" 61 - onClick={() => onPageChange(item.value)} 64 + onClick={() => { 65 + onPageChange(item.value); 66 + window.scrollTo({ top: 0, behavior: "smooth" }); 67 + }} 62 68 className={`flex h-9 w-9 items-center justify-center rounded-md border font-medium text-sm transition-colors ${ 63 69 page === item.value 64 70 ? "border-(--accent) bg-(--accent) text-[#3f2e00]" ··· 72 78 73 79 <button 74 80 type="button" 75 - onClick={() => onPageChange(page + 1)} 81 + onClick={() => { 82 + onPageChange(page + 1); 83 + window.scrollTo({ top: 0, behavior: "smooth" }); 84 + }} 76 85 disabled={page >= totalPages} 77 86 className="flex h-9 items-center rounded-md border border-(--border) bg-(--background-elevated) px-3 text-sm transition-colors hover:bg-(--background-subtle) disabled:opacity-40 disabled:hover:bg-(--background-elevated)" 78 87 >
+118 -37
apps/web/src/components/YourActivity.tsx
··· 1 1 import { Loader2, Plus, X } from "lucide-react"; 2 + import { useState } from "react"; 3 + import { 4 + Popover, 5 + PopoverContent, 6 + PopoverTrigger, 7 + } from "#/components/ui/popover"; 2 8 import { useAuth } from "#/lib/auth-context"; 3 9 import { formatDateTime } from "#/lib/date-utils"; 4 10 ··· 9 15 10 16 interface YourActivityProps { 11 17 watchHistory: WatchHistoryEntry[]; 12 - onAddToShelf: () => void; 18 + onAddToShelf: (watchedAt?: string) => void; 13 19 onDeleteEntry: (id: string) => void; 14 20 isAddPending?: boolean; 15 21 isDeletePending?: boolean; 16 22 } 17 23 24 + function getCurrentDatetimeLocal(timezone?: string): string { 25 + const now = new Date(); 26 + const options: Intl.DateTimeFormatOptions = { 27 + year: "numeric", 28 + month: "2-digit", 29 + day: "2-digit", 30 + hour: "2-digit", 31 + minute: "2-digit", 32 + hour12: false, 33 + timeZone: timezone, 34 + }; 35 + const parts = new Intl.DateTimeFormat("en-US", options).formatToParts(now); 36 + const getPart = (type: string) => 37 + parts.find((p) => p.type === type)?.value ?? "00"; 38 + return `${getPart("year")}-${getPart("month")}-${getPart("day")}T${getPart("hour")}:${getPart("minute")}`; 39 + } 40 + 41 + function AddToShelfButton({ 42 + isPending, 43 + onConfirm, 44 + className, 45 + }: { 46 + isPending: boolean; 47 + onConfirm: (watchedAt: string) => void; 48 + className?: string; 49 + }) { 50 + const { userSettings } = useAuth(); 51 + const userTimezone = userSettings?.timezone; 52 + const [open, setOpen] = useState(false); 53 + const [watchedAt, setWatchedAt] = useState(() => 54 + getCurrentDatetimeLocal(userTimezone), 55 + ); 56 + 57 + const handleOpenChange = (isOpen: boolean) => { 58 + if (isOpen) { 59 + setWatchedAt(getCurrentDatetimeLocal(userTimezone)); 60 + } 61 + setOpen(isOpen); 62 + }; 63 + 64 + const handleConfirm = () => { 65 + onConfirm(watchedAt); 66 + setOpen(false); 67 + }; 68 + 69 + return ( 70 + <Popover open={open} onOpenChange={handleOpenChange}> 71 + <PopoverTrigger asChild> 72 + <button 73 + type="button" 74 + disabled={isPending} 75 + className={`btn btn-secondary gap-2 ${className ?? ""}`} 76 + > 77 + {isPending ? ( 78 + <> 79 + <Loader2 className="size-4 animate-spin" /> 80 + Loading 81 + </> 82 + ) : ( 83 + <> 84 + <Plus className="size-4" /> 85 + Add to shelf 86 + </> 87 + )} 88 + </button> 89 + </PopoverTrigger> 90 + <PopoverContent className="w-80 space-y-3"> 91 + <div className="space-y-2"> 92 + <label htmlFor="watched-at" className="block font-medium text-sm"> 93 + When did you watch this? 94 + </label> 95 + <input 96 + id="watched-at" 97 + type="datetime-local" 98 + value={watchedAt} 99 + onChange={(e) => setWatchedAt(e.target.value)} 100 + className="w-full rounded-md border bg-(--background) px-3 py-2 text-sm outline-hidden focus:ring-(--accent) focus:ring-2" 101 + /> 102 + </div> 103 + <div className="flex gap-2"> 104 + <button 105 + type="button" 106 + onClick={() => setOpen(false)} 107 + className="btn btn-secondary flex-1" 108 + > 109 + Cancel 110 + </button> 111 + <button 112 + type="button" 113 + onClick={handleConfirm} 114 + disabled={isPending} 115 + className="btn btn-primary flex-1" 116 + > 117 + Confirm 118 + </button> 119 + </div> 120 + </PopoverContent> 121 + </Popover> 122 + ); 123 + } 124 + 18 125 export function YourActivity({ 19 126 watchHistory, 20 127 onAddToShelf, ··· 56 163 </button> 57 164 </div> 58 165 ))} 59 - <button 60 - type="button" 61 - onClick={onAddToShelf} 62 - disabled={isAddPending} 63 - className="btn btn-secondary mt-3 w-full gap-2" 64 - > 65 - {isAddPending ? ( 66 - <> 67 - <Loader2 className="size-4 animate-spin" /> 68 - Loading 69 - </> 70 - ) : ( 71 - <> 72 - <Plus className="size-4" /> 73 - Add to shelf 74 - </> 75 - )} 76 - </button> 166 + <AddToShelfButton 167 + isPending={isAddPending} 168 + onConfirm={onAddToShelf} 169 + className="mt-3 w-full" 170 + /> 77 171 </div> 78 172 ) : ( 79 173 <div className="space-y-3"> 80 174 <p className="text-(--foreground-muted) text-sm"> 81 175 You haven&apos;t watched this yet 82 176 </p> 83 - <button 84 - type="button" 85 - onClick={onAddToShelf} 86 - disabled={isAddPending} 87 - className="btn btn-secondary w-full gap-2 text-sm" 88 - > 89 - {isAddPending ? ( 90 - <> 91 - <Loader2 className="size-4 animate-spin" /> 92 - Loading 93 - </> 94 - ) : ( 95 - <> 96 - <Plus className="size-4" /> 97 - Add to shelf 98 - </> 99 - )} 100 - </button> 177 + <AddToShelfButton 178 + isPending={isAddPending} 179 + onConfirm={onAddToShelf} 180 + className="w-full text-sm" 181 + /> 101 182 </div> 102 183 )} 103 184 </section>
+1 -1
apps/web/src/components/following/ActivityCard.tsx
··· 17 17 userTimeFormat, 18 18 }: ActivityCardProps) { 19 19 return ( 20 - <article className="card p-5 transition-shadow hover:shadow-md"> 20 + <article className="card w-full p-5 transition-shadow hover:shadow-md"> 21 21 <div className="flex gap-4"> 22 22 {/* Poster on the left */} 23 23 {(activity.posterPath || activity.backdropPath) && (
+1 -1
apps/web/src/components/following/FollowingList.tsx
··· 18 18 pendingUnfollowDid, 19 19 }: FollowingListProps) { 20 20 return ( 21 - <section className="card p-5"> 21 + <section className="card w-full p-5"> 22 22 <h3 className="mb-4 font-display font-semibold"> 23 23 Following ({following.length}) 24 24 </h3>
+1 -1
apps/web/src/components/following/NetworkStats.tsx
··· 6 6 7 7 export function NetworkStats({ following }: NetworkStatsProps) { 8 8 return ( 9 - <section className="card p-5"> 9 + <section className="card w-full p-5"> 10 10 <h3 className="mb-4 font-display font-semibold">Your Network</h3> 11 11 <div className="space-y-3 text-sm"> 12 12 <div className="flex justify-between">
+2 -3
apps/web/src/lib/hooks/useDashboard.ts
··· 9 9 showsControllerDiscoverShowsOptions, 10 10 } from "@opnshelf/api"; 11 11 import { useQuery } from "@tanstack/react-query"; 12 + import { formatDate } from "#/lib/date-utils"; 12 13 13 14 // Utility function to format relative time 14 15 function getRelativeTime(dateString: string): string { ··· 26 27 if (diffHours < 24) 27 28 return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`; 28 29 if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; 29 - if (diffDays < 365) 30 - return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) === 1 ? "" : "s"} ago`; 31 - return `${Math.floor(diffDays / 365)} year${Math.floor(diffDays / 365) === 1 ? "" : "s"} ago`; 30 + return formatDate(dateString); 32 31 } 33 32 34 33 // Activity item type
+11 -7
apps/web/src/lib/hooks/useWatchActions.ts
··· 255 255 }, 256 256 }); 257 257 258 - const handleMarkMovieWatched = () => { 258 + const handleMarkMovieWatched = (watchedAt?: string) => { 259 259 if (!isAuthenticated || options.mediaType !== "movie") return; 260 - markMovieWatched.mutate({ body: { movieId: options.movieId } }); 260 + markMovieWatched.mutate({ body: { movieId: options.movieId, watchedAt } }); 261 261 }; 262 262 263 263 const handleUnmarkMovieWatched = () => { ··· 278 278 const handleMarkEpisodeWatched = ( 279 279 seasonNumber: number, 280 280 episodeNumber: number, 281 + watchedAt?: string, 281 282 ) => { 282 283 if (!isAuthenticated || options.mediaType !== "show") return; 283 284 markEpisodeWatched.mutate({ 284 - body: { showId: options.showId, seasonNumber, episodeNumber }, 285 + body: { showId: options.showId, seasonNumber, episodeNumber, watchedAt }, 285 286 }); 286 287 }; 287 288 ··· 297 298 }); 298 299 }; 299 300 300 - const handleMarkShowWatched = () => { 301 + const handleMarkShowWatched = (watchedAt?: string) => { 301 302 if (!isAuthenticated || options.mediaType !== "show") return; 302 - markShowWatched.mutate({ body: { showId: options.showId } }); 303 + markShowWatched.mutate({ body: { showId: options.showId, watchedAt } }); 303 304 }; 304 305 305 306 const handleUnmarkShowWatched = () => { ··· 310 311 }); 311 312 }; 312 313 313 - const handleMarkSeasonWatched = (seasonNumber: number) => { 314 + const handleMarkSeasonWatched = ( 315 + seasonNumber: number, 316 + watchedAt?: string, 317 + ) => { 314 318 if (!isAuthenticated || options.mediaType !== "show") return; 315 319 markSeasonWatched.mutate({ 316 - body: { showId: options.showId, seasonNumber }, 320 + body: { showId: options.showId, seasonNumber, watchedAt }, 317 321 }); 318 322 }; 319 323
+8 -1
apps/web/src/routes/dashboard.tsx
··· 202 202 change: statsData 203 203 ? `${statsData.watchedLast30Days || 0} watched this month` 204 204 : "Track your first movie", 205 + changeMobile: `${statsData?.watchedLast30Days || 0} this month`, 205 206 }, 206 207 { 207 208 label: "Shows", ··· 210 211 change: statsData 211 212 ? `${statsData.watchedLast7Days || 0} watched this week` 212 213 : "Track your first show", 214 + changeMobile: `${statsData?.watchedLast7Days || 0} this week`, 213 215 }, 214 216 { 215 217 label: "Activity", ··· 218 220 change: statsData?.dailyActivity?.length 219 221 ? `${statsData.dailyActivity.length} active days` 220 222 : "Start watching", 223 + changeMobile: `${statsData?.watchedLast7Days || 0} this week`, 221 224 }, 222 225 { 223 226 label: "This Month", ··· 227 230 statsData?.watchedLast30Days && statsData.watchedLast30Days > 0 228 231 ? "total watched" 229 232 : "Start tracking", 233 + changeMobile: `${statsData?.watchedLast30Days || 0} this month`, 230 234 }, 231 235 ]; 232 236 ··· 330 334 <div className="order-2 space-y-1 sm:order-1 sm:space-y-2"> 331 335 <div className="h-3 w-14 rounded bg-(--background-subtle) sm:h-4 sm:w-16" /> 332 336 <div className="h-6 w-10 rounded bg-(--background-subtle) sm:h-8 sm:w-12" /> 333 - <div className="hidden h-3 w-20 rounded bg-(--background-subtle) sm:block" /> 337 + <div className="mt-1 h-3 w-20 rounded bg-(--background-subtle)" /> 334 338 </div> 335 339 </div> 336 340 </div> ··· 353 357 </p> 354 358 <p className="font-semibold text-lg sm:mt-1 sm:text-display-3"> 355 359 {stat.value} 360 + </p> 361 + <p className="mt-0.5 text-(--accent) text-xs sm:mt-1 sm:hidden"> 362 + {stat.changeMobile} 356 363 </p> 357 364 <p className="hidden text-(--accent) text-xs sm:mt-1 sm:block"> 358 365 {stat.change}
+1 -1
apps/web/src/routes/following.tsx
··· 159 159 <div className="container-app py-8"> 160 160 <FollowingHeader /> 161 161 162 - <div className="grid gap-8 lg:grid-cols-3"> 162 + <div className="grid grid-cols-1 gap-8 lg:grid-cols-3"> 163 163 {/* Sidebar - shown above feed on mobile, right side on desktop */} 164 164 <div className="order-1 space-y-6 lg:order-2"> 165 165 <FollowingList
+2 -2
apps/web/src/routes/movies/$movieId/$movieName.tsx
··· 215 215 ) : ( 216 216 <button 217 217 type="button" 218 - onClick={markMovieWatched} 218 + onClick={() => markMovieWatched()} 219 219 disabled={isMarkMoviePending} 220 220 className="btn btn-primary gap-2" 221 221 > ··· 291 291 {/* Your Activity */} 292 292 <YourActivity 293 293 watchHistory={movieWatchHistory || []} 294 - onAddToShelf={markMovieWatched} 294 + onAddToShelf={(watchedAt) => markMovieWatched(watchedAt)} 295 295 onDeleteEntry={deleteMovieWatchHistoryEntry} 296 296 isAddPending={isMarkMoviePending} 297 297 isDeletePending={isDeleteMovieHistoryPending}
+1 -1
apps/web/src/routes/profile.$handle/connections.tsx
··· 80 80 key={user.did} 81 81 to="/profile/$handle" 82 82 params={{ handle: user.handle }} 83 - className="card card-interactive flex items-center gap-4 p-4" 83 + className="card card-interactive flex w-full items-center gap-4 p-4" 84 84 > 85 85 <div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full border border-(--border) bg-(--background-elevated)"> 86 86 {typeof user.avatar === "string" ? (
+4 -4
apps/web/src/routes/profile.$handle/index.tsx
··· 118 118 </div> 119 119 120 120 {moviesLoading ? ( 121 - <div className="grid grid-cols-4 gap-4"> 121 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> 122 122 {[1, 2, 3, 4].map((i) => ( 123 123 <div 124 124 key={i} ··· 127 127 ))} 128 128 </div> 129 129 ) : movies.length > 0 ? ( 130 - <div className="grid grid-cols-4 gap-4"> 130 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> 131 131 {movies.map((item) => ( 132 132 <div key={item.id} className="[&_article]:!w-full"> 133 133 <ActionableMediaCard ··· 170 170 </div> 171 171 172 172 {episodesLoading ? ( 173 - <div className="grid grid-cols-4 gap-4"> 173 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> 174 174 {[1, 2, 3, 4].map((i) => ( 175 175 <div 176 176 key={i} ··· 179 179 ))} 180 180 </div> 181 181 ) : episodes.length > 0 ? ( 182 - <div className="grid grid-cols-4 gap-4"> 182 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-4"> 183 183 {episodes.map((item) => ( 184 184 <div key={item.id} className="[&_article]:!w-full"> 185 185 <ActionableMediaCard
+4 -212
apps/web/src/routes/profile.$handle/shelf.tsx
··· 1 1 import { 2 - moviesControllerUnmarkWatchedMutation, 3 2 shelfControllerGetUserShelfOptions, 4 - shelfControllerGetUserShelfQueryKey, 5 - showsControllerUnmarkWatchedMutation, 6 3 usersControllerGetPublicProfileOptions, 7 4 } from "@opnshelf/api"; 8 - import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 9 - import { createFileRoute, Link } from "@tanstack/react-router"; 10 - import { 11 - BookmarkX, 12 - Film, 13 - Grid3X3, 14 - List as ListIcon, 15 - Loader2, 16 - Search, 17 - Tv, 18 - } from "lucide-react"; 5 + import { useQuery } from "@tanstack/react-query"; 6 + import { createFileRoute } from "@tanstack/react-router"; 7 + import { Film, Search, Tv } from "lucide-react"; 19 8 import { useState } from "react"; 20 9 import ActionableMediaCard from "#/components/ActionableMediaCard"; 21 10 import { Pagination } from "#/components/Pagination"; 22 11 import { setupApiClient } from "#/lib/api"; 23 12 import { useAuth } from "#/lib/auth-context"; 24 - import { toSlug } from "#/lib/slug"; 25 13 26 14 setupApiClient(); 27 15 ··· 30 18 }); 31 19 32 20 type FilterType = "all" | "movie" | "episode"; 33 - type ViewMode = "grid" | "list"; 34 - 35 - function formatWatchedDate(dateStr?: string): string { 36 - if (!dateStr) return ""; 37 - const date = new Date(dateStr); 38 - return date.toLocaleDateString("en-US", { 39 - month: "short", 40 - day: "numeric", 41 - year: 42 - date.getFullYear() === new Date().getFullYear() ? undefined : "numeric", 43 - }); 44 - } 45 21 46 22 function ProfileShelfPage() { 47 23 const { handle } = Route.useParams(); 48 24 const { user } = useAuth(); 49 - const queryClient = useQueryClient(); 50 25 51 26 const { data: profile } = useQuery({ 52 27 ...usersControllerGetPublicProfileOptions({ path: { handle } }), ··· 57 32 58 33 const [filter, setFilter] = useState<FilterType>("all"); 59 34 const [searchQuery, setSearchQuery] = useState(""); 60 - const [viewMode, setViewMode] = useState<ViewMode>("grid"); 61 35 const [page, setPage] = useState(1); 62 36 63 37 // Server-side pagination with filtering ··· 84 58 setPage(1); 85 59 }; 86 60 87 - // Mutations for removing from shelf 88 - const removeMovieMutation = useMutation({ 89 - ...moviesControllerUnmarkWatchedMutation(), 90 - onSuccess: () => { 91 - queryClient.invalidateQueries({ 92 - queryKey: shelfControllerGetUserShelfQueryKey({ path: { userDid } }), 93 - }); 94 - }, 95 - }); 96 - const removeEpisodeMutation = useMutation({ 97 - ...showsControllerUnmarkWatchedMutation(), 98 - onSuccess: () => { 99 - queryClient.invalidateQueries({ 100 - queryKey: shelfControllerGetUserShelfQueryKey({ path: { userDid } }), 101 - }); 102 - }, 103 - }); 104 - 105 61 const items = data?.items ?? []; 106 62 107 63 return ( ··· 122 78 onChange={(e) => handleSearchChange(e.target.value)} 123 79 /> 124 80 </div> 125 - 126 - {/* View Toggle */} 127 - <div className="flex rounded-lg border border-(--border) bg-(--background-elevated) p-0.5"> 128 - <button 129 - type="button" 130 - onClick={() => setViewMode("grid")} 131 - className={`rounded-md p-1.5 transition-colors ${ 132 - viewMode === "grid" 133 - ? "bg-(--accent) text-[#3f2e00]" 134 - : "text-(--foreground-muted) hover:text-(--foreground)" 135 - }`} 136 - aria-label="Grid view" 137 - > 138 - <Grid3X3 className="h-4 w-4" /> 139 - </button> 140 - <button 141 - type="button" 142 - onClick={() => setViewMode("list")} 143 - className={`rounded-md p-1.5 transition-colors ${ 144 - viewMode === "list" 145 - ? "bg-(--accent) text-[#3f2e00]" 146 - : "text-(--foreground-muted) hover:text-(--foreground)" 147 - }`} 148 - aria-label="List view" 149 - > 150 - <ListIcon className="h-4 w-4" /> 151 - </button> 152 - </div> 153 81 </div> 154 82 </div> 155 83 ··· 199 127 : `${displayName}'s shelf is empty.`} 200 128 </p> 201 129 </div> 202 - ) : viewMode === "grid" ? ( 130 + ) : ( 203 131 <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"> 204 132 {items.map((item) => { 205 133 const isMovie = item.type === "movie"; ··· 232 160 ); 233 161 })} 234 162 </div> 235 - ) : ( 236 - <div className="space-y-2"> 237 - {items.map((item) => ( 238 - <ShelfListRow 239 - key={item.id} 240 - item={item} 241 - isOwner={isOwner} 242 - onRemoveMovie={(movieId) => 243 - removeMovieMutation.mutate({ 244 - path: { movieId }, 245 - query: { mode: "all" }, 246 - }) 247 - } 248 - onRemoveEpisode={(showId, seasonNumber, episodeNumber) => 249 - removeEpisodeMutation.mutate({ 250 - path: { showId }, 251 - query: { 252 - seasonNumber, 253 - episodeNumber, 254 - mode: "all", 255 - }, 256 - }) 257 - } 258 - isRemoving={ 259 - removeMovieMutation.isPending || removeEpisodeMutation.isPending 260 - } 261 - /> 262 - ))} 263 - </div> 264 163 )} 265 164 266 165 {/* Pagination */} ··· 276 175 </div> 277 176 ); 278 177 } 279 - 280 - function ShelfListRow({ 281 - item, 282 - isOwner, 283 - onRemoveMovie, 284 - onRemoveEpisode, 285 - isRemoving, 286 - }: { 287 - item: { 288 - id: string; 289 - type: "movie" | "episode"; 290 - posterPath?: string; 291 - watchedDate?: string; 292 - } & Record<string, unknown>; 293 - isOwner: boolean; 294 - onRemoveMovie: (movieId: string) => void; 295 - onRemoveEpisode: ( 296 - showId: string, 297 - seasonNumber: number, 298 - episodeNumber: number, 299 - ) => void; 300 - isRemoving: boolean; 301 - }) { 302 - const isMovie = item.type === "movie"; 303 - const title = isMovie ? (item.title as string) : (item.showTitle as string); 304 - const id = isMovie ? (item.movieId as string) : (item.showId as string); 305 - 306 - const episodeInfo = !isMovie 307 - ? `S${item.seasonNumber}E${item.episodeNumber}${item.episodeTitle ? ` — ${item.episodeTitle}` : ""}` 308 - : undefined; 309 - 310 - const handleRemove = () => { 311 - if (isMovie) { 312 - onRemoveMovie(id); 313 - } else { 314 - onRemoveEpisode( 315 - id, 316 - item.seasonNumber as number, 317 - item.episodeNumber as number, 318 - ); 319 - } 320 - }; 321 - 322 - return ( 323 - <Link 324 - to={isMovie ? "/movies/$movieId/$movieName" : "/shows/$showId/$showName"} 325 - params={ 326 - isMovie 327 - ? { movieId: id, movieName: toSlug(title) } 328 - : { showId: id, showName: toSlug(title) } 329 - } 330 - className="card card-interactive flex items-center gap-4 p-3" 331 - > 332 - <div className="h-16 w-11 shrink-0 overflow-hidden rounded-md bg-(--background-subtle)"> 333 - {item.posterPath ? ( 334 - <img 335 - src={`https://image.tmdb.org/t/p/w200${item.posterPath}`} 336 - alt={title} 337 - className="h-full w-full object-cover" 338 - loading="lazy" 339 - /> 340 - ) : ( 341 - <div className="flex h-full w-full items-center justify-center"> 342 - {isMovie ? ( 343 - <Film className="size-4 text-(--foreground-muted)" /> 344 - ) : ( 345 - <Tv className="size-4 text-(--foreground-muted)" /> 346 - )} 347 - </div> 348 - )} 349 - </div> 350 - <div className="min-w-0 flex-1"> 351 - <p className="font-medium text-sm">{title}</p> 352 - <div className="flex flex-col gap-0.5 text-(--foreground-muted) text-xs"> 353 - {episodeInfo && <span>{episodeInfo}</span>} 354 - {item.watchedDate && ( 355 - <span>{formatWatchedDate(item.watchedDate)}</span> 356 - )} 357 - </div> 358 - </div> 359 - <span 360 - className={`badge ${isMovie ? "badge-subtle" : "badge-accent"} text-xs`} 361 - > 362 - {isMovie ? "Movie" : "TV"} 363 - </span> 364 - {isOwner && ( 365 - <button 366 - type="button" 367 - onClick={(e) => { 368 - e.preventDefault(); 369 - e.stopPropagation(); 370 - handleRemove(); 371 - }} 372 - disabled={isRemoving} 373 - className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-(--border) bg-(--background-elevated) text-(--foreground-muted) transition-colors hover:border-red-300 hover:bg-red-500/10 hover:text-red-500 disabled:opacity-50" 374 - aria-label="Remove from shelf" 375 - > 376 - {isRemoving ? ( 377 - <Loader2 className="size-4 animate-spin" /> 378 - ) : ( 379 - <BookmarkX className="size-4" /> 380 - )} 381 - </button> 382 - )} 383 - </Link> 384 - ); 385 - }
+16 -15
apps/web/src/routes/profile.$handle/up-next.tsx
··· 4 4 } from "@opnshelf/api"; 5 5 import { useQuery } from "@tanstack/react-query"; 6 6 import { createFileRoute, Link } from "@tanstack/react-router"; 7 - import { Calendar, Check, Loader2, Tv } from "lucide-react"; 7 + import { Calendar, Loader2, Plus, Tv } from "lucide-react"; 8 8 import { useState } from "react"; 9 9 import { Pagination } from "#/components/Pagination"; 10 10 import { setupApiClient } from "#/lib/api"; 11 11 import { useAuth } from "#/lib/auth-context"; 12 + import { formatDate } from "#/lib/date-utils"; 12 13 import { useMarkEpisodeWatched } from "#/lib/hooks"; 13 14 import { toSlug } from "#/lib/slug"; 14 15 ··· 18 19 component: ProfileUpNextPage, 19 20 }); 20 21 21 - function formatDate(dateStr: string): string { 22 - return new Date(dateStr).toLocaleDateString("en-US", { 23 - month: "short", 24 - day: "numeric", 25 - }); 26 - } 27 - 28 22 function formatRelativeDate(dateStr: string): string { 29 23 const releaseDate = new Date(dateStr); 30 24 const today = new Date(); ··· 39 33 if (diffDays > 0 && diffDays < 7) return `in ${diffDays} days`; 40 34 if (diffDays > 0 && diffDays < 30) 41 35 return `in ${Math.ceil(diffDays / 7)} weeks`; 42 - if (diffDays > 0) return `in ${Math.ceil(diffDays / 30)} months`; 36 + if (diffDays > 0) return formatDate(dateStr); 43 37 if (diffDays === -1) return "Yesterday"; 44 38 if (diffDays > -7) return `${Math.abs(diffDays)} days ago`; 45 39 if (diffDays > -30) return `${Math.ceil(Math.abs(diffDays) / 7)} weeks ago`; 46 - return `${Math.ceil(Math.abs(diffDays) / 30)} months ago`; 40 + return formatDate(dateStr); 47 41 } 48 42 49 43 function ProfileUpNextPage() { ··· 167 161 <div className="mt-2 flex items-center gap-2 text-(--foreground-muted) text-sm"> 168 162 <Calendar className="size-4" /> 169 163 <span>{formatDate(nextEp.airDate)}</span> 170 - <span className="text-(--accent)"> 171 - • {formatRelativeDate(nextEp.airDate)} 172 - </span> 164 + {new Date(nextEp.airDate) >= 165 + new Date(new Date().setHours(0, 0, 0, 0)) ? ( 166 + <span className="text-(--accent)"> 167 + • {formatRelativeDate(nextEp.airDate)} 168 + </span> 169 + ) : item.latestWatchedDate ? ( 170 + <span className="text-xs"> 171 + • Last watched: {formatDate(item.latestWatchedDate)} 172 + </span> 173 + ) : null} 173 174 </div> 174 175 )} 175 176 ··· 214 215 {markEpisodeMutation.isPending ? ( 215 216 <Loader2 className="size-4 animate-spin" /> 216 217 ) : ( 217 - <Check className="size-4" /> 218 + <Plus className="size-4" /> 218 219 )} 219 - Mark watched 220 + Add to shelf 220 221 </button> 221 222 )} 222 223 </div>
+3 -1
apps/web/src/routes/shows/$showId/$showName/seasons.$seasonNumber.episodes.$episodeNumber.tsx
··· 388 388 {/* Your Activity */} 389 389 <YourActivity 390 390 watchHistory={episodeWatchHistory} 391 - onAddToShelf={() => markEpisodeWatched(seasonNum, episodeNum)} 391 + onAddToShelf={(watchedAt) => 392 + markEpisodeWatched(seasonNum, episodeNum, watchedAt) 393 + } 392 394 onDeleteEntry={deleteEpisodeWatchHistoryEntry} 393 395 isAddPending={isMarkEpisodePending} 394 396 isDeletePending={isDeleteEpisodeHistoryPending}