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.

Add shared search dialog and dedicated search page

- Share search dialog state across header and dashboard
- Expand command palette with navigation, theme, and sign-out actions
- Add `/search` route for full results and pagination

+924 -197
+7 -3
apps/web/src/components/Header.tsx
··· 18 18 DropdownMenuTrigger, 19 19 } from "#/components/ui/dropdown-menu"; 20 20 import { useAuth } from "#/lib/auth-context"; 21 + import { useSearchDialog } from "#/lib/search-dialog-context"; 21 22 import SearchCommand from "./SearchCommand"; 22 23 import ThemeToggle from "./ThemeToggle"; 23 24 ··· 33 34 const router = useRouterState(); 34 35 const currentPath = router.location.pathname; 35 36 const { user, isAuthenticated, isLoading, logout } = useAuth(); 37 + const { open: searchOpen, setOpen: setSearchOpen } = useSearchDialog(); 38 + 39 + const visibleNavigation = isAuthenticated ? navigation : []; 36 40 37 41 useEffect(() => { 38 42 const handleScroll = () => { ··· 91 95 92 96 {/* Desktop Navigation */} 93 97 <div className="hidden items-center gap-1 md:flex"> 94 - {navigation.map((item) => { 98 + {visibleNavigation.map((item) => { 95 99 const isActive = 96 100 currentPath === item.href || 97 101 (item.href !== "/" && currentPath.startsWith(item.href)); ··· 113 117 {/* Right side actions */} 114 118 <div className="flex items-center gap-2"> 115 119 {/* Search */} 116 - <SearchCommand /> 120 + <SearchCommand open={searchOpen} onOpenChange={setSearchOpen} /> 117 121 118 122 {/* Theme Toggle */} 119 123 <ThemeToggle /> ··· 220 224 <div className="fixed inset-x-0 top-16 z-40 h-[calc(100vh-4rem)] border-(--border) border-t bg-(--background) md:hidden"> 221 225 <div className="container-app h-full overflow-y-auto py-4"> 222 226 <div className="flex flex-col gap-1"> 223 - {navigation.map((item) => { 227 + {visibleNavigation.map((item) => { 224 228 const isActive = 225 229 currentPath === item.href || 226 230 (item.href !== "/" && currentPath.startsWith(item.href));
+355 -181
apps/web/src/components/SearchCommand.tsx
··· 7 7 type UnifiedSearchResultDto, 8 8 } from "@opnshelf/api"; 9 9 import { useQuery } from "@tanstack/react-query"; 10 - import { Link } from "@tanstack/react-router"; 10 + import { useNavigate } from "@tanstack/react-router"; 11 11 import { 12 12 Calendar, 13 13 Clock, 14 14 Film, 15 - Heart, 16 15 Home, 17 16 List, 18 17 Loader2, 18 + LogOut, 19 + Monitor, 20 + Moon, 19 21 Search, 20 22 Settings, 21 23 Star, 24 + Sun, 22 25 Tv, 23 26 User, 24 27 Users, 25 28 } from "lucide-react"; 26 - import { useEffect, useState } from "react"; 29 + import { useEffect, useRef, useState } from "react"; 27 30 import { 28 31 CommandDialog, 29 - CommandEmpty, 30 32 CommandGroup, 31 33 CommandInput, 32 34 CommandItem, ··· 42 44 onOpenChange?: (open: boolean) => void; 43 45 } 44 46 45 - // Debounce hook 46 47 function useDebounce<T>(value: T, delay: number): T { 47 48 const [debouncedValue, setDebouncedValue] = useState<T>(value); 48 49 ··· 58 59 59 60 return debouncedValue; 60 61 } 62 + 63 + const RESULTS_PER_SECTION = 8; 61 64 62 65 export function SearchCommand({ 63 66 open: controlledOpen, ··· 67 70 const [searchQuery, setSearchQuery] = useState(""); 68 71 const debouncedQuery = useDebounce(searchQuery, 400); 69 72 73 + const isControlled = controlledOpen !== undefined; 74 + const isOpen = isControlled ? controlledOpen : open; 75 + const handleOpenChange = (value: boolean) => { 76 + if (isControlled) { 77 + onOpenChange?.(value); 78 + } else { 79 + setOpen(value); 80 + } 81 + }; 82 + 83 + const isOpenRef = useRef(isOpen); 84 + isOpenRef.current = isOpen; 85 + const handleOpenChangeRef = useRef(handleOpenChange); 86 + handleOpenChangeRef.current = handleOpenChange; 87 + 70 88 useEffect(() => { 71 89 const down = (e: KeyboardEvent) => { 72 90 if (e.key === "k" && (e.metaKey || e.ctrlKey)) { 73 91 e.preventDefault(); 74 - setOpen((open) => !open); 92 + handleOpenChangeRef.current(!isOpenRef.current); 75 93 } 76 94 }; 77 95 document.addEventListener("keydown", down); 78 96 return () => document.removeEventListener("keydown", down); 79 97 }, []); 80 98 81 - // Reset search when dialog closes 82 99 useEffect(() => { 83 - if (!open) { 100 + if (!isOpen) { 84 101 setSearchQuery(""); 85 102 } 86 - }, [open]); 103 + }, [isOpen]); 87 104 88 - const isOpen = controlledOpen !== undefined ? controlledOpen : open; 89 - const handleOpenChange = onOpenChange || setOpen; 90 - const { user } = useAuth(); 105 + const { user, logout } = useAuth(); 91 106 const currentUserHandle = user?.handle; 107 + const navigate = useNavigate(); 108 + 109 + // Theme toggle state 110 + const [themeMode, setThemeMode] = useState<"light" | "dark" | "auto">("auto"); 111 + 112 + useEffect(() => { 113 + if (typeof window === "undefined") return; 114 + const stored = window.localStorage.getItem("theme"); 115 + if (stored === "light" || stored === "dark" || stored === "auto") { 116 + setThemeMode(stored); 117 + } 118 + }, []); 92 119 93 - // Search all API - only enabled when there's a search query 94 - const { data: searchData, isLoading: isSearching } = useQuery({ 120 + function cycleTheme() { 121 + if (typeof window === "undefined") return; 122 + const nextMode: "light" | "dark" | "auto" = 123 + themeMode === "light" ? "dark" : themeMode === "dark" ? "auto" : "light"; 124 + setThemeMode(nextMode); 125 + window.localStorage.setItem("theme", nextMode); 126 + 127 + const prefersDark = window.matchMedia( 128 + "(prefers-color-scheme: dark)", 129 + ).matches; 130 + const resolved = 131 + nextMode === "auto" ? (prefersDark ? "dark" : "light") : nextMode; 132 + 133 + document.documentElement.classList.remove("light", "dark"); 134 + document.documentElement.classList.add(resolved); 135 + 136 + if (nextMode === "auto") { 137 + document.documentElement.removeAttribute("data-theme"); 138 + } else { 139 + document.documentElement.setAttribute("data-theme", nextMode); 140 + } 141 + document.documentElement.style.colorScheme = resolved; 142 + } 143 + 144 + const themeIcons = { 145 + light: Sun, 146 + dark: Moon, 147 + auto: Monitor, 148 + }; 149 + 150 + const themeLabels = { 151 + light: "Light mode", 152 + dark: "Dark mode", 153 + auto: "System preference", 154 + }; 155 + 156 + const ThemeIcon = themeIcons[themeMode]; 157 + 158 + const { 159 + data: searchData, 160 + isLoading: isSearching, 161 + isError: isSearchError, 162 + } = useQuery({ 95 163 ...searchControllerSearchAllOptions({ 96 164 query: { query: debouncedQuery }, 97 165 }), 98 166 enabled: debouncedQuery.length > 0, 99 167 }); 100 168 101 - // User lists - always fetched 102 169 const { data: userLists } = useQuery({ 103 170 ...listsControllerGetUserListsOptions(), 104 171 }); 105 172 106 - // People search - only enabled when there's a search query 107 - const { data: peopleData, isLoading: isSearchingPeople } = useQuery({ 173 + const { 174 + data: peopleData, 175 + isLoading: isSearchingPeople, 176 + isError: isPeopleError, 177 + } = useQuery({ 108 178 ...socialControllerSearchPeopleOptions({ 109 179 query: { q: debouncedQuery }, 110 180 }), 111 181 enabled: debouncedQuery.length > 0, 112 182 }); 113 183 114 - // Filter results by type 115 184 const movies = 116 185 searchData?.results?.filter( 117 186 (item: UnifiedSearchResultDto) => item.media_type === "movie", ··· 123 192 ) || []; 124 193 125 194 const hasSearchQuery = debouncedQuery.length > 0; 126 - const hasResults = 195 + const hasSearchResults = 127 196 movies.length > 0 || 128 197 shows.length > 0 || 129 - (userLists && userLists.length > 0); 198 + (peopleData?.items && peopleData.items.length > 0); 130 199 const isLoading = isSearching || isSearchingPeople; 200 + const hasError = isSearchError || isPeopleError; 131 201 132 - // Get the display title for a search result 133 202 const getTitle = (item: UnifiedSearchResultDto): string => { 134 203 return item.title || item.name || "Unknown"; 135 204 }; 136 205 137 - // Get the release year for a search result 138 206 const getYear = (item: UnifiedSearchResultDto): string => { 139 207 const date = item.release_date || item.first_air_date; 140 208 if (date) { ··· 143 211 return ""; 144 212 }; 145 213 214 + const goTo = (to: string, params?: Record<string, string>) => { 215 + handleOpenChange(false); 216 + if (params) { 217 + navigate({ to, params }); 218 + } else { 219 + navigate({ to }); 220 + } 221 + }; 222 + 223 + const goToSearch = (type?: string) => { 224 + handleOpenChange(false); 225 + navigate({ 226 + to: "/search", 227 + search: { 228 + q: debouncedQuery, 229 + ...(type ? { type } : {}), 230 + }, 231 + }); 232 + }; 233 + 146 234 return ( 147 235 <> 148 - {/* Trigger button - can be placed anywhere */} 149 236 <button 150 237 type="button" 151 238 onClick={() => handleOpenChange(true)} ··· 159 246 </kbd> 160 247 </button> 161 248 162 - <CommandDialog open={isOpen} onOpenChange={handleOpenChange}> 249 + <CommandDialog 250 + open={isOpen} 251 + onOpenChange={handleOpenChange} 252 + commandProps={{ shouldFilter: false }} 253 + > 163 254 <CommandInput 164 255 placeholder="Search movies, shows, lists..." 165 256 value={searchQuery} 166 257 onValueChange={setSearchQuery} 167 258 /> 168 259 <CommandList> 169 - {/* Empty state */} 170 - {hasSearchQuery && !isLoading && !hasResults && ( 171 - <CommandEmpty> 172 - <div className="flex flex-col items-center gap-2 py-6 text-(--foreground-muted)"> 173 - <Search className="h-8 w-8 opacity-50" /> 174 - <p>No results found for &quot;{debouncedQuery}&quot;</p> 175 - <p className="text-sm"> 176 - Try searching for movies, TV shows, or people 177 - </p> 178 - </div> 179 - </CommandEmpty> 180 - )} 181 - 182 - {/* Loading state */} 183 - {isLoading && ( 260 + {/* Loading */} 261 + {hasSearchQuery && isLoading && ( 184 262 <div className="flex items-center justify-center py-8 text-(--foreground-muted)"> 185 263 <Loader2 className="mr-2 h-5 w-5 animate-spin" /> 186 264 <span>Searching...</span> 187 265 </div> 188 266 )} 189 267 190 - {/* Navigation - Always shown */} 191 - <CommandGroup heading="Navigation"> 192 - <CommandItem asChild> 193 - <Link to="/" className="flex items-center gap-2"> 268 + {/* Error */} 269 + {hasSearchQuery && !isLoading && hasError && ( 270 + <div className="flex flex-col items-center gap-2 py-6 text-(--foreground-muted)"> 271 + <p>Something went wrong.</p> 272 + <p className="text-sm">Try again in a moment.</p> 273 + </div> 274 + )} 275 + 276 + {/* Empty state */} 277 + {hasSearchQuery && !isLoading && !hasError && !hasSearchResults && ( 278 + <div className="flex flex-col items-center gap-2 py-6 text-(--foreground-muted)"> 279 + <Search className="h-8 w-8 opacity-50" /> 280 + <p>No results found for &quot;{debouncedQuery}&quot;</p> 281 + <p className="text-sm"> 282 + Try searching for movies, TV shows, or people 283 + </p> 284 + </div> 285 + )} 286 + 287 + {/* Search results */} 288 + {hasSearchQuery && !isLoading && !hasError && hasSearchResults && ( 289 + <> 290 + {/* Movies */} 291 + {movies.length > 0 && ( 292 + <CommandGroup heading="Movies"> 293 + {movies 294 + .slice(0, RESULTS_PER_SECTION) 295 + .map((movie: UnifiedSearchResultDto) => { 296 + const title = getTitle(movie); 297 + return ( 298 + <CommandItem 299 + key={`movie-${movie.id}`} 300 + value={`movie ${title} ${getYear(movie)}`} 301 + onSelect={() => goTo(buildMovieUrl(movie.id, title))} 302 + > 303 + <Film className="h-4 w-4 shrink-0" /> 304 + <span className="truncate">{title}</span> 305 + {getYear(movie) && ( 306 + <span className="shrink-0 text-(--foreground-muted)"> 307 + ({getYear(movie)}) 308 + </span> 309 + )} 310 + <CommandShortcut> 311 + <span className="flex items-center gap-1"> 312 + <Star className="h-3 w-3" /> 313 + {movie.vote_average?.toFixed(1) || "N/A"} 314 + </span> 315 + </CommandShortcut> 316 + </CommandItem> 317 + ); 318 + })} 319 + {movies.length > RESULTS_PER_SECTION && ( 320 + <CommandItem 321 + value="more movies" 322 + onSelect={() => goToSearch("movies")} 323 + > 324 + <Search className="h-4 w-4" /> 325 + <span>Show more results</span> 326 + </CommandItem> 327 + )} 328 + </CommandGroup> 329 + )} 330 + 331 + {/* TV Shows */} 332 + {shows.length > 0 && ( 333 + <CommandGroup heading="TV Shows"> 334 + {shows 335 + .slice(0, RESULTS_PER_SECTION) 336 + .map((show: UnifiedSearchResultDto) => { 337 + const title = getTitle(show); 338 + return ( 339 + <CommandItem 340 + key={`show-${show.id}`} 341 + value={`show ${title} ${getYear(show)}`} 342 + onSelect={() => goTo(buildShowUrl(show.id, title))} 343 + > 344 + <Tv className="h-4 w-4 shrink-0" /> 345 + <span className="truncate">{title}</span> 346 + {getYear(show) && ( 347 + <span className="shrink-0 text-(--foreground-muted)"> 348 + ({getYear(show)}) 349 + </span> 350 + )} 351 + <CommandShortcut> 352 + <span className="flex items-center gap-1"> 353 + <Star className="h-3 w-3" /> 354 + {show.vote_average?.toFixed(1) || "N/A"} 355 + </span> 356 + </CommandShortcut> 357 + </CommandItem> 358 + ); 359 + })} 360 + {shows.length > RESULTS_PER_SECTION && ( 361 + <CommandItem 362 + value="more shows" 363 + onSelect={() => goToSearch("shows")} 364 + > 365 + <Search className="h-4 w-4" /> 366 + <span>Show more results</span> 367 + </CommandItem> 368 + )} 369 + </CommandGroup> 370 + )} 371 + 372 + {/* People */} 373 + {peopleData?.items && peopleData.items.length > 0 && ( 374 + <CommandGroup 375 + heading={`People (${peopleData.items.length} result${peopleData.items.length === 1 ? "" : "s"})`} 376 + > 377 + {peopleData.items 378 + .slice(0, RESULTS_PER_SECTION) 379 + .map((person: SocialUserCardDto) => { 380 + const name = String( 381 + person.displayName || person.handle || "Unknown", 382 + ); 383 + return ( 384 + <CommandItem 385 + key={`person-${person.did}`} 386 + value={`person ${name} ${person.handle}`} 387 + onSelect={() => 388 + goTo("/profile/$handle", { 389 + handle: person.handle || person.did, 390 + }) 391 + } 392 + > 393 + <User className="h-4 w-4 shrink-0" /> 394 + <span className="truncate">{name}</span> 395 + {person.handle && ( 396 + <span className="shrink-0 text-(--foreground-muted)"> 397 + @{String(person.handle)} 398 + </span> 399 + )} 400 + </CommandItem> 401 + ); 402 + })} 403 + {peopleData.items.length > RESULTS_PER_SECTION && ( 404 + <CommandItem 405 + value="more people" 406 + onSelect={() => goToSearch("people")} 407 + > 408 + <Search className="h-4 w-4" /> 409 + <span>Show more results</span> 410 + </CommandItem> 411 + )} 412 + </CommandGroup> 413 + )} 414 + </> 415 + )} 416 + 417 + {/* Navigation — only when not searching */} 418 + {!hasSearchQuery && ( 419 + <CommandGroup heading="Navigation"> 420 + <CommandItem 421 + value="dashboard" 422 + onSelect={() => goTo("/dashboard")} 423 + > 194 424 <Home className="h-4 w-4" /> 195 425 <span>Dashboard</span> 196 - </Link> 197 - </CommandItem> 198 - <CommandItem asChild> 199 - <Link to="/calendar" className="flex items-center gap-2"> 426 + </CommandItem> 427 + <CommandItem value="calendar" onSelect={() => goTo("/calendar")}> 200 428 <Calendar className="h-4 w-4" /> 201 429 <span>Calendar</span> 202 - </Link> 203 - </CommandItem> 204 - <CommandItem asChild> 205 - <Link to="/following" className="flex items-center gap-2"> 430 + </CommandItem> 431 + <CommandItem 432 + value="following" 433 + onSelect={() => goTo("/following")} 434 + > 206 435 <Users className="h-4 w-4" /> 207 436 <span>Following</span> 208 - </Link> 209 - </CommandItem> 210 - {currentUserHandle && ( 211 - <CommandItem asChild> 212 - <Link 213 - to="/profile/$handle/lists" 214 - params={{ handle: currentUserHandle }} 215 - className="flex items-center gap-2" 437 + </CommandItem> 438 + {currentUserHandle && ( 439 + <CommandItem 440 + value="up next" 441 + onSelect={() => 442 + goTo("/profile/$handle/up-next", { 443 + handle: currentUserHandle, 444 + }) 445 + } 446 + > 447 + <Clock className="h-4 w-4" /> 448 + <span>Up Next</span> 449 + </CommandItem> 450 + )} 451 + {currentUserHandle && ( 452 + <CommandItem 453 + value="profile" 454 + onSelect={() => 455 + goTo("/profile/$handle", { 456 + handle: currentUserHandle, 457 + }) 458 + } 459 + > 460 + <User className="h-4 w-4" /> 461 + <span>Profile</span> 462 + </CommandItem> 463 + )} 464 + {currentUserHandle && ( 465 + <CommandItem 466 + value="lists" 467 + onSelect={() => 468 + goTo("/profile/$handle/lists", { 469 + handle: currentUserHandle, 470 + }) 471 + } 216 472 > 217 473 <List className="h-4 w-4" /> 218 474 <span>Lists</span> 219 - </Link> 220 - </CommandItem> 221 - )} 222 - </CommandGroup> 223 - 224 - {/* Movies Section */} 225 - {movies.length > 0 && ( 226 - <> 227 - <CommandSeparator /> 228 - <CommandGroup heading={`Movies (${movies.length})`}> 229 - {movies.slice(0, 5).map((movie: UnifiedSearchResultDto) => ( 230 - <CommandItem key={`movie-${movie.id}`} asChild> 231 - <Link 232 - to={buildMovieUrl(movie.id, getTitle(movie))} 233 - className="flex items-center gap-2" 234 - > 235 - <Film className="h-4 w-4" /> 236 - <span>{getTitle(movie)}</span> 237 - {getYear(movie) && ( 238 - <span className="text-(--foreground-muted)"> 239 - ({getYear(movie)}) 240 - </span> 241 - )} 242 - <CommandShortcut> 243 - <span className="flex items-center gap-1"> 244 - <Star className="h-3 w-3" /> 245 - {movie.vote_average?.toFixed(1) || "N/A"} 246 - </span> 247 - </CommandShortcut> 248 - </Link> 249 - </CommandItem> 250 - ))} 251 - </CommandGroup> 252 - </> 475 + </CommandItem> 476 + )} 477 + </CommandGroup> 253 478 )} 254 479 255 - {/* TV Shows Section */} 256 - {shows.length > 0 && ( 257 - <> 258 - <CommandSeparator /> 259 - <CommandGroup heading={`TV Shows (${shows.length})`}> 260 - {shows.slice(0, 5).map((show: UnifiedSearchResultDto) => ( 261 - <CommandItem key={`show-${show.id}`} asChild> 262 - <Link 263 - to={buildShowUrl(show.id, getTitle(show))} 264 - className="flex items-center gap-2" 265 - > 266 - <Tv className="h-4 w-4" /> 267 - <span>{getTitle(show)}</span> 268 - {getYear(show) && ( 269 - <span className="text-(--foreground-muted)"> 270 - ({getYear(show)}) 271 - </span> 272 - )} 273 - <CommandShortcut> 274 - <span className="flex items-center gap-1"> 275 - <Star className="h-3 w-3" /> 276 - {show.vote_average?.toFixed(1) || "N/A"} 277 - </span> 278 - </CommandShortcut> 279 - </Link> 280 - </CommandItem> 281 - ))} 282 - </CommandGroup> 283 - </> 284 - )} 285 - 286 - {/* Your Lists Section - Always shown when available */} 480 + {/* Your Lists */} 287 481 {userLists && userLists.length > 0 && currentUserHandle && ( 288 482 <> 289 483 <CommandSeparator /> 290 484 <CommandGroup heading="Your Lists"> 291 - {userLists.slice(0, 5).map((list: ListSummaryDto) => ( 292 - <CommandItem key={`list-${list.id}`} asChild> 293 - <Link 294 - to="/profile/$handle/lists/$listSlug" 295 - params={{ 296 - handle: currentUserHandle, 297 - listSlug: list.slug, 298 - }} 299 - className="flex items-center gap-2" 485 + {userLists 486 + .slice(0, RESULTS_PER_SECTION) 487 + .map((list: ListSummaryDto) => ( 488 + <CommandItem 489 + key={`list-${list.id}`} 490 + value={`list ${list.name}`} 491 + onSelect={() => 492 + goTo("/profile/$handle/lists/$listSlug", { 493 + handle: currentUserHandle, 494 + listSlug: list.slug, 495 + }) 496 + } 300 497 > 301 498 <List className="h-4 w-4" /> 302 499 <span>{list.name}</span> 303 500 <CommandShortcut>{list.itemCount} items</CommandShortcut> 304 - </Link> 305 - </CommandItem> 306 - ))} 307 - </CommandGroup> 308 - </> 309 - )} 310 - 311 - {/* People Section */} 312 - {peopleData?.items && peopleData.items.length > 0 && ( 313 - <> 314 - <CommandSeparator /> 315 - <CommandGroup heading={`People (${peopleData.items.length})`}> 316 - {peopleData.items 317 - .slice(0, 5) 318 - .map((person: SocialUserCardDto) => ( 319 - <CommandItem key={`person-${person.did}`} asChild> 320 - <Link 321 - to="/profile/$handle" 322 - params={{ handle: person.handle || person.did }} 323 - className="flex items-center gap-2" 324 - > 325 - <User className="h-4 w-4" /> 326 - <span> 327 - {String( 328 - person.displayName || person.handle || "Unknown", 329 - )} 330 - </span> 331 - {person.handle && ( 332 - <span className="text-(--foreground-muted)"> 333 - @{String(person.handle)} 334 - </span> 335 - )} 336 - </Link> 337 501 </CommandItem> 338 502 ))} 339 503 </CommandGroup> 340 504 </> 341 505 )} 342 506 343 - {/* Quick Actions Section */} 507 + {/* Quick Actions */} 344 508 <CommandSeparator /> 345 509 <CommandGroup heading="Quick Actions"> 346 - <CommandItem> 347 - <Clock className="h-4 w-4" /> 348 - <span>Continue Watching</span> 510 + <CommandItem value="settings" onSelect={() => goTo("/settings")}> 511 + <Settings className="h-4 w-4" /> 512 + <span>Settings</span> 349 513 </CommandItem> 350 - <CommandItem> 351 - <Heart className="h-4 w-4" /> 352 - <span>Favorites</span> 514 + <CommandItem 515 + value="theme" 516 + onSelect={() => { 517 + cycleTheme(); 518 + }} 519 + > 520 + <ThemeIcon className="h-4 w-4" /> 521 + <span>{themeLabels[themeMode]}</span> 353 522 </CommandItem> 354 - <CommandItem asChild> 355 - <Link to="/settings" className="flex items-center gap-2"> 356 - <Settings className="h-4 w-4" /> 357 - <span>Settings</span> 358 - <CommandShortcut>⌘S</CommandShortcut> 359 - </Link> 360 - </CommandItem> 523 + {currentUserHandle && ( 524 + <CommandItem 525 + value="sign out" 526 + onSelect={() => { 527 + handleOpenChange(false); 528 + logout(); 529 + }} 530 + > 531 + <LogOut className="h-4 w-4" /> 532 + <span>Sign Out</span> 533 + </CommandItem> 534 + )} 361 535 </CommandGroup> 362 536 </CommandList> 363 537 </CommandDialog>
+6 -1
apps/web/src/components/ui/command.tsx
··· 32 32 children, 33 33 className, 34 34 showCloseButton = true, 35 + commandProps, 35 36 ...props 36 37 }: React.ComponentProps<typeof Dialog> & { 37 38 title?: string; 38 39 description?: string; 39 40 className?: string; 40 41 showCloseButton?: boolean; 42 + commandProps?: React.ComponentProps<typeof CommandPrimitive>; 41 43 }) { 42 44 return ( 43 45 <Dialog {...props}> ··· 49 51 className={cn("overflow-hidden p-0", className)} 50 52 showCloseButton={showCloseButton} 51 53 > 52 - <Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3"> 54 + <Command 55 + className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3" 56 + {...commandProps} 57 + > 53 58 {children} 54 59 </Command> 55 60 </DialogContent>
+36
apps/web/src/lib/search-dialog-context.tsx
··· 1 + import { 2 + createContext, 3 + type ReactNode, 4 + useCallback, 5 + useContext, 6 + useState, 7 + } from "react"; 8 + 9 + interface SearchDialogContextValue { 10 + open: boolean; 11 + setOpen: (open: boolean) => void; 12 + toggle: () => void; 13 + } 14 + 15 + const SearchDialogContext = createContext<SearchDialogContextValue | null>( 16 + null, 17 + ); 18 + 19 + export function SearchDialogProvider({ children }: { children: ReactNode }) { 20 + const [open, setOpen] = useState(false); 21 + const toggle = useCallback(() => setOpen((prev) => !prev), []); 22 + 23 + return ( 24 + <SearchDialogContext.Provider value={{ open, setOpen, toggle }}> 25 + {children} 26 + </SearchDialogContext.Provider> 27 + ); 28 + } 29 + 30 + export function useSearchDialog() { 31 + const ctx = useContext(SearchDialogContext); 32 + if (!ctx) { 33 + throw new Error("useSearchDialog must be used within SearchDialogProvider"); 34 + } 35 + return ctx; 36 + }
+21
apps/web/src/routeTree.gen.ts
··· 10 10 11 11 import { Route as rootRouteImport } from './routes/__root' 12 12 import { Route as SettingsRouteImport } from './routes/settings' 13 + import { Route as SearchRouteImport } from './routes/search' 13 14 import { Route as LoginRouteImport } from './routes/login' 14 15 import { Route as FollowingRouteImport } from './routes/following' 15 16 import { Route as DashboardRouteImport } from './routes/dashboard' ··· 35 36 const SettingsRoute = SettingsRouteImport.update({ 36 37 id: '/settings', 37 38 path: '/settings', 39 + getParentRoute: () => rootRouteImport, 40 + } as any) 41 + const SearchRoute = SearchRouteImport.update({ 42 + id: '/search', 43 + path: '/search', 38 44 getParentRoute: () => rootRouteImport, 39 45 } as any) 40 46 const LoginRoute = LoginRouteImport.update({ ··· 158 164 '/dashboard': typeof DashboardRoute 159 165 '/following': typeof FollowingRoute 160 166 '/login': typeof LoginRoute 167 + '/search': typeof SearchRoute 161 168 '/settings': typeof SettingsRoute 162 169 '/auth/complete': typeof AuthCompleteRoute 163 170 '/profile/$handle': typeof ProfileHandleRouteWithChildren ··· 182 189 '/dashboard': typeof DashboardRoute 183 190 '/following': typeof FollowingRoute 184 191 '/login': typeof LoginRoute 192 + '/search': typeof SearchRoute 185 193 '/settings': typeof SettingsRoute 186 194 '/auth/complete': typeof AuthCompleteRoute 187 195 '/movies/$movieId/$movieName': typeof MoviesMovieIdMovieNameRoute ··· 203 211 '/dashboard': typeof DashboardRoute 204 212 '/following': typeof FollowingRoute 205 213 '/login': typeof LoginRoute 214 + '/search': typeof SearchRoute 206 215 '/settings': typeof SettingsRoute 207 216 '/auth/complete': typeof AuthCompleteRoute 208 217 '/profile/$handle': typeof ProfileHandleRouteWithChildren ··· 229 238 | '/dashboard' 230 239 | '/following' 231 240 | '/login' 241 + | '/search' 232 242 | '/settings' 233 243 | '/auth/complete' 234 244 | '/profile/$handle' ··· 253 263 | '/dashboard' 254 264 | '/following' 255 265 | '/login' 266 + | '/search' 256 267 | '/settings' 257 268 | '/auth/complete' 258 269 | '/movies/$movieId/$movieName' ··· 273 284 | '/dashboard' 274 285 | '/following' 275 286 | '/login' 287 + | '/search' 276 288 | '/settings' 277 289 | '/auth/complete' 278 290 | '/profile/$handle' ··· 298 310 DashboardRoute: typeof DashboardRoute 299 311 FollowingRoute: typeof FollowingRoute 300 312 LoginRoute: typeof LoginRoute 313 + SearchRoute: typeof SearchRoute 301 314 SettingsRoute: typeof SettingsRoute 302 315 AuthCompleteRoute: typeof AuthCompleteRoute 303 316 ProfileHandleRoute: typeof ProfileHandleRouteWithChildren ··· 312 325 path: '/settings' 313 326 fullPath: '/settings' 314 327 preLoaderRoute: typeof SettingsRouteImport 328 + parentRoute: typeof rootRouteImport 329 + } 330 + '/search': { 331 + id: '/search' 332 + path: '/search' 333 + fullPath: '/search' 334 + preLoaderRoute: typeof SearchRouteImport 315 335 parentRoute: typeof rootRouteImport 316 336 } 317 337 '/login': { ··· 536 556 DashboardRoute: DashboardRoute, 537 557 FollowingRoute: FollowingRoute, 538 558 LoginRoute: LoginRoute, 559 + SearchRoute: SearchRoute, 539 560 SettingsRoute: SettingsRoute, 540 561 AuthCompleteRoute: AuthCompleteRoute, 541 562 ProfileHandleRoute: ProfileHandleRouteWithChildren,
+9 -6
apps/web/src/routes/__root.tsx
··· 8 8 import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; 9 9 import { Toaster } from "#/components/ui/sonner"; 10 10 import { AuthProvider } from "#/lib/auth-context"; 11 + import { SearchDialogProvider } from "#/lib/search-dialog-context"; 11 12 import { 12 13 DefaultErrorComponent, 13 14 NotFoundComponent, ··· 54 55 <body className="min-h-screen antialiased"> 55 56 <PostHogProvider> 56 57 <AuthProvider> 57 - <div className="flex min-h-screen flex-col"> 58 - <Header /> 59 - <main className="flex-1">{children}</main> 60 - <Footer /> 61 - </div> 62 - <Toaster /> 58 + <SearchDialogProvider> 59 + <div className="flex min-h-screen flex-col"> 60 + <Header /> 61 + <main className="flex-1">{children}</main> 62 + <Footer /> 63 + </div> 64 + <Toaster /> 65 + </SearchDialogProvider> 63 66 </AuthProvider> 64 67 <TanStackDevtools 65 68 config={{ position: "bottom-right" }}
+4 -6
apps/web/src/routes/dashboard.tsx
··· 20 20 import { withUserLocale } from "#/lib/date-utils"; 21 21 import { useDashboardStats, useUserShelf } from "#/lib/hooks"; 22 22 import { useUserUpNext } from "#/lib/hooks/useMedia"; 23 + import { useSearchDialog } from "#/lib/search-dialog-context"; 23 24 import { buildEpisodeUrl, buildMovieUrl, buildShowUrl } from "#/lib/url-utils"; 24 25 import DashboardMediaCard from "../components/DashboardMediaCard"; 25 26 ··· 118 119 isLoading: authLoading, 119 120 } = useAuth(); 120 121 const navigate = useNavigate(); 122 + const { setOpen: setSearchOpen } = useSearchDialog(); 121 123 const userDid = user?.did; 122 124 const userTimezone = userSettings?.timezone; 123 125 const userTimeFormat = userSettings?.timeFormat; ··· 408 410 </p> 409 411 <button 410 412 type="button" 411 - onClick={() => { 412 - /* TODO: open search dialog */ 413 - }} 413 + onClick={() => setSearchOpen(true)} 414 414 className="btn btn-primary inline-flex gap-2" 415 415 > 416 416 <Tv className="h-4 w-4" /> ··· 486 486 </p> 487 487 <button 488 488 type="button" 489 - onClick={() => { 490 - /* TODO: open search dialog */ 491 - }} 489 + onClick={() => setSearchOpen(true)} 492 490 className="btn btn-primary inline-flex gap-2" 493 491 > 494 492 <Film className="h-4 w-4" />
+486
apps/web/src/routes/search.tsx
··· 1 + import { 2 + type SocialUserCardDto, 3 + searchControllerSearchAllOptions, 4 + socialControllerFollowMutation, 5 + socialControllerSearchPeopleOptions, 6 + socialControllerUnfollowMutation, 7 + type UnifiedSearchResultDto, 8 + } from "@opnshelf/api"; 9 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 10 + import { 11 + createFileRoute, 12 + Link, 13 + useNavigate, 14 + useSearch, 15 + } from "@tanstack/react-router"; 16 + import { 17 + Film, 18 + Loader2, 19 + Search, 20 + Tv, 21 + UserMinus, 22 + UserPlus, 23 + Users, 24 + } from "lucide-react"; 25 + import { useEffect, useMemo, useState } from "react"; 26 + import { z } from "zod"; 27 + import { UserAvatar } from "#/components/following/UserAvatar"; 28 + import MediaCard from "#/components/MediaCard"; 29 + import { Pagination } from "#/components/Pagination"; 30 + import { useDebounce } from "#/hooks/useDebounce"; 31 + import { setupApiClient } from "#/lib/api"; 32 + import { useAuth } from "#/lib/auth-context"; 33 + 34 + setupApiClient(); 35 + 36 + const searchSchema = z.object({ 37 + q: z.string().optional(), 38 + type: z.string().optional(), 39 + page: z.coerce.number().min(1).optional().default(1), 40 + }); 41 + 42 + export const Route = createFileRoute("/search")({ 43 + component: SearchPage, 44 + validateSearch: searchSchema, 45 + head: ({ match }) => { 46 + const q = match.search.q; 47 + return { 48 + meta: [ 49 + { 50 + title: q ? `${q} — Search | OpnShelf` : "Search | OpnShelf", 51 + }, 52 + { 53 + name: "description", 54 + content: "Search for movies, TV shows, and people on OpnShelf.", 55 + }, 56 + ], 57 + }; 58 + }, 59 + }); 60 + 61 + type Tab = "all" | "movies" | "shows" | "people"; 62 + 63 + function getTitle(item: UnifiedSearchResultDto): string { 64 + return item.title || item.name || "Unknown"; 65 + } 66 + 67 + function getYear(item: UnifiedSearchResultDto): number | undefined { 68 + const date = item.release_date || item.first_air_date; 69 + if (date) { 70 + return new Date(date).getFullYear(); 71 + } 72 + return undefined; 73 + } 74 + 75 + function getPosterUrl(item: UnifiedSearchResultDto): string { 76 + return item.poster_path 77 + ? `https://image.tmdb.org/t/p/w500${item.poster_path}` 78 + : ""; 79 + } 80 + 81 + function getBackdropUrl(item: UnifiedSearchResultDto): string | undefined { 82 + return item.backdrop_path 83 + ? `https://image.tmdb.org/t/p/original${item.backdrop_path}` 84 + : undefined; 85 + } 86 + 87 + const tabs: { key: Tab; label: string; icon: typeof Film }[] = [ 88 + { key: "all", label: "All", icon: Search }, 89 + { key: "movies", label: "Movies", icon: Film }, 90 + { key: "shows", label: "TV Shows", icon: Tv }, 91 + { key: "people", label: "People", icon: Users }, 92 + ]; 93 + 94 + function SearchPage() { 95 + const search = useSearch({ from: Route.id }); 96 + const navigate = useNavigate(); 97 + const { isAuthenticated } = useAuth(); 98 + const queryClient = useQueryClient(); 99 + 100 + const initialQuery = search.q || ""; 101 + const initialType = search.type || ""; 102 + const initialPage = search.page; 103 + 104 + const validType = tabs.find((t) => t.key === initialType)?.key || "all"; 105 + 106 + const [query, setQuery] = useState(initialQuery); 107 + const debouncedQuery = useDebounce(query, 400); 108 + const [activeTab, setActiveTab] = useState<Tab>(validType as Tab); 109 + const [page, setPage] = useState(initialPage); 110 + 111 + // Sync URL query to local state on back/forward 112 + useEffect(() => { 113 + setQuery(initialQuery); 114 + }, [initialQuery]); 115 + 116 + useEffect(() => { 117 + const urlType = tabs.find((t) => t.key === initialType)?.key || "all"; 118 + setActiveTab(urlType as Tab); 119 + }, [initialType]); 120 + 121 + useEffect(() => { 122 + setPage(search.page); 123 + }, [search.page]); 124 + 125 + // Update URL when debounced query, tab, or page changes 126 + useEffect(() => { 127 + const newSearch: { q?: string; type?: string; page?: number } = {}; 128 + if (debouncedQuery) newSearch.q = debouncedQuery; 129 + if (activeTab !== "all") newSearch.type = activeTab; 130 + if (page > 1) newSearch.page = page; 131 + 132 + const needsUpdate = 133 + debouncedQuery !== (search.q || "") || 134 + (activeTab !== "all" ? activeTab : undefined) !== 135 + (search.type || undefined) || 136 + (page > 1 ? page : undefined) !== 137 + (search.page > 1 ? search.page : undefined); 138 + 139 + if (needsUpdate) { 140 + navigate({ 141 + to: "/search", 142 + search: Object.keys(newSearch).length > 0 ? newSearch : undefined, 143 + replace: true, 144 + }); 145 + } 146 + }, [debouncedQuery, activeTab, page, navigate, search]); 147 + 148 + const { data: searchData, isLoading: isSearching } = useQuery({ 149 + ...searchControllerSearchAllOptions({ 150 + query: { query: debouncedQuery, page }, 151 + }), 152 + enabled: debouncedQuery.length > 0, 153 + }); 154 + 155 + const { data: peopleData, isLoading: isSearchingPeople } = useQuery({ 156 + ...socialControllerSearchPeopleOptions({ 157 + query: { q: debouncedQuery, pageSize: 20 }, 158 + }), 159 + enabled: debouncedQuery.length > 0 && isAuthenticated, 160 + }); 161 + 162 + const followMutation = useMutation({ 163 + mutationKey: ["social", "follow"], 164 + ...socialControllerFollowMutation(), 165 + onSuccess: () => { 166 + queryClient.invalidateQueries({ 167 + predicate: (q) => { 168 + const key = q.queryKey[0] as { _id?: string } | undefined; 169 + return key?._id === "socialControllerSearchPeople"; 170 + }, 171 + }); 172 + }, 173 + }); 174 + 175 + const unfollowMutation = useMutation({ 176 + mutationKey: ["social", "unfollow"], 177 + ...socialControllerUnfollowMutation(), 178 + onSuccess: () => { 179 + queryClient.invalidateQueries({ 180 + predicate: (q) => { 181 + const key = q.queryKey[0] as { _id?: string } | undefined; 182 + return key?._id === "socialControllerSearchPeople"; 183 + }, 184 + }); 185 + }, 186 + }); 187 + 188 + const movies = useMemo( 189 + () => 190 + searchData?.results?.filter( 191 + (r: UnifiedSearchResultDto) => r.media_type === "movie", 192 + ) || [], 193 + [searchData], 194 + ); 195 + const shows = useMemo( 196 + () => 197 + searchData?.results?.filter( 198 + (r: UnifiedSearchResultDto) => r.media_type === "tv", 199 + ) || [], 200 + [searchData], 201 + ); 202 + const people = peopleData?.items || []; 203 + 204 + const hasQuery = debouncedQuery.length > 0; 205 + const isLoading = isSearching || (isAuthenticated && isSearchingPeople); 206 + const hasResults = movies.length > 0 || shows.length > 0 || people.length > 0; 207 + 208 + const handlePageChange = (newPage: number) => { 209 + setPage(newPage); 210 + window.scrollTo({ top: 0, behavior: "smooth" }); 211 + }; 212 + 213 + const handleTabChange = (tab: Tab) => { 214 + setActiveTab(tab); 215 + setPage(1); 216 + }; 217 + 218 + return ( 219 + <div className="container-app py-8"> 220 + <div className="mx-auto mb-8 max-w-2xl"> 221 + <h1 className="mb-4 text-center text-display-2">Search</h1> 222 + <div className="relative"> 223 + <Search className="absolute top-1/2 left-4 h-5 w-5 -translate-y-1/2 text-(--foreground-muted)" /> 224 + <input 225 + type="text" 226 + placeholder="Search movies, shows, people..." 227 + className="input h-12 pl-12! text-lg" 228 + value={query} 229 + onChange={(e) => setQuery(e.target.value)} 230 + /> 231 + {isLoading && ( 232 + <Loader2 className="absolute top-1/2 right-4 h-5 w-5 -translate-y-1/2 animate-spin text-(--foreground-muted)" /> 233 + )} 234 + </div> 235 + </div> 236 + 237 + <div className="mb-6 border-(--border) border-b"> 238 + <nav className="flex gap-1 overflow-x-auto"> 239 + {tabs.map((tab) => { 240 + const Icon = tab.icon; 241 + const isActive = activeTab === tab.key; 242 + return ( 243 + <button 244 + key={tab.key} 245 + type="button" 246 + onClick={() => handleTabChange(tab.key)} 247 + className={`flex items-center gap-2 whitespace-nowrap border-b-2 px-4 py-3 font-medium text-sm transition-colors ${ 248 + isActive 249 + ? "border-(--accent) text-(--accent)" 250 + : "border-transparent text-(--foreground-muted) hover:border-(--border-strong) hover:text-(--foreground)" 251 + }`} 252 + > 253 + <Icon className="h-4 w-4" /> 254 + {tab.label} 255 + </button> 256 + ); 257 + })} 258 + </nav> 259 + </div> 260 + 261 + {hasQuery ? ( 262 + isLoading ? ( 263 + <div className="flex flex-col items-center justify-center py-20 text-(--foreground-muted)"> 264 + <Loader2 className="mb-4 h-10 w-10 animate-spin" /> 265 + <p>Searching...</p> 266 + </div> 267 + ) : !hasResults ? ( 268 + <div className="flex flex-col items-center justify-center py-20 text-(--foreground-muted)"> 269 + <Search className="mb-4 h-12 w-12 opacity-40" /> 270 + <p className="text-lg"> 271 + No results found for &quot;{debouncedQuery}&quot; 272 + </p> 273 + <p className="mt-1 text-sm">Try a different search term</p> 274 + </div> 275 + ) : ( 276 + <div className="space-y-6"> 277 + {/* Combined Movies + TV Shows in "all" tab */} 278 + {activeTab === "all" && 279 + searchData?.results && 280 + searchData.results.length > 0 && ( 281 + <section> 282 + <div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 283 + {searchData.results.map((item) => ( 284 + <MediaCard 285 + key={`media-${item.id}-${item.media_type}`} 286 + id={item.id} 287 + title={getTitle(item)} 288 + posterUrl={getPosterUrl(item)} 289 + backdropUrl={getBackdropUrl(item)} 290 + type={item.media_type === "movie" ? "movie" : "show"} 291 + year={getYear(item)} 292 + rating={item.vote_average || undefined} 293 + size="md" 294 + layout="poster" 295 + /> 296 + ))} 297 + </div> 298 + </section> 299 + )} 300 + 301 + {/* Movies tab only */} 302 + {activeTab === "movies" && movies.length > 0 && ( 303 + <section> 304 + <div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 305 + {movies.map((item) => ( 306 + <MediaCard 307 + key={`movie-${item.id}`} 308 + id={item.id} 309 + title={getTitle(item)} 310 + posterUrl={getPosterUrl(item)} 311 + backdropUrl={getBackdropUrl(item)} 312 + type="movie" 313 + year={getYear(item)} 314 + rating={item.vote_average || undefined} 315 + size="md" 316 + layout="poster" 317 + /> 318 + ))} 319 + </div> 320 + </section> 321 + )} 322 + 323 + {/* TV Shows tab only */} 324 + {activeTab === "shows" && shows.length > 0 && ( 325 + <section> 326 + <div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 327 + {shows.map((item) => ( 328 + <MediaCard 329 + key={`show-${item.id}`} 330 + id={item.id} 331 + title={getTitle(item)} 332 + posterUrl={getPosterUrl(item)} 333 + backdropUrl={getBackdropUrl(item)} 334 + type="show" 335 + year={getYear(item)} 336 + rating={item.vote_average || undefined} 337 + size="md" 338 + layout="poster" 339 + /> 340 + ))} 341 + </div> 342 + </section> 343 + )} 344 + 345 + {(activeTab === "all" || activeTab === "people") && ( 346 + <section> 347 + {activeTab === "all" && people.length > 0 && ( 348 + <div className="mb-4 flex items-center justify-between"> 349 + <h2 className="text-display-3">People</h2> 350 + <button 351 + type="button" 352 + onClick={() => handleTabChange("people")} 353 + className="font-medium text-(--accent) text-sm hover:text-(--accent-hover)" 354 + > 355 + View all 356 + </button> 357 + </div> 358 + )} 359 + 360 + {!isAuthenticated && activeTab === "people" ? ( 361 + <div className="card p-8 text-center"> 362 + <Users className="mx-auto mb-3 h-10 w-10 text-(--foreground-muted)" /> 363 + <p className="mb-2 text-(--foreground-muted)"> 364 + Sign in to search people 365 + </p> 366 + <Link 367 + to="/login" 368 + className="btn btn-primary inline-flex gap-2" 369 + > 370 + Sign In 371 + </Link> 372 + </div> 373 + ) : people.length > 0 ? ( 374 + <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> 375 + {people.map((person: SocialUserCardDto) => ( 376 + <div 377 + key={person.did} 378 + className="flex items-center gap-3 rounded-lg border border-(--border) bg-(--background-elevated) p-4 transition-colors hover:border-(--border-strong)" 379 + > 380 + <UserAvatar 381 + src={person.avatar} 382 + alt={String(person.displayName) || person.handle} 383 + size="md" 384 + /> 385 + <div className="min-w-0 flex-1"> 386 + <Link 387 + to="/profile/$handle" 388 + params={{ 389 + handle: person.handle || person.did, 390 + }} 391 + className="block truncate font-medium text-sm hover:text-(--accent)" 392 + > 393 + {String(person.displayName) || person.handle} 394 + </Link> 395 + <p className="text-(--foreground-muted) text-xs"> 396 + @{person.handle} 397 + </p> 398 + </div> 399 + {isAuthenticated && 400 + person.isFollowing !== undefined && ( 401 + <button 402 + type="button" 403 + className={`btn btn-sm h-8 px-3 text-xs ${ 404 + person.isFollowing 405 + ? "btn-secondary" 406 + : "btn-primary" 407 + }`} 408 + onClick={() => { 409 + if (person.isFollowing) { 410 + unfollowMutation.mutate({ 411 + path: { 412 + targetDid: person.did, 413 + }, 414 + }); 415 + } else { 416 + followMutation.mutate({ 417 + path: { 418 + targetDid: person.did, 419 + }, 420 + }); 421 + } 422 + }} 423 + disabled={ 424 + followMutation.variables?.path?.targetDid === 425 + person.did || 426 + unfollowMutation.variables?.path?.targetDid === 427 + person.did 428 + } 429 + > 430 + {followMutation.variables?.path?.targetDid === 431 + person.did ? ( 432 + <Loader2 className="h-3 w-3 animate-spin" /> 433 + ) : unfollowMutation.variables?.path 434 + ?.targetDid === person.did ? ( 435 + <Loader2 className="h-3 w-3 animate-spin" /> 436 + ) : person.isFollowing ? ( 437 + <> 438 + <UserMinus className="mr-1 h-3 w-3" /> 439 + Unfollow 440 + </> 441 + ) : ( 442 + <> 443 + <UserPlus className="mr-1 h-3 w-3" /> 444 + Follow 445 + </> 446 + )} 447 + </button> 448 + )} 449 + </div> 450 + ))} 451 + </div> 452 + ) : activeTab === "people" && hasQuery ? ( 453 + <div className="py-8 text-center text-(--foreground-muted)"> 454 + No people found for &quot;{debouncedQuery}&quot; 455 + </div> 456 + ) : null} 457 + </section> 458 + )} 459 + 460 + {(activeTab === "all" || 461 + activeTab === "movies" || 462 + activeTab === "shows") && 463 + (movies.length > 0 || shows.length > 0) && ( 464 + <div className="flex justify-center pt-4"> 465 + <Pagination 466 + page={page} 467 + totalPages={Math.max( 468 + 1, 469 + Math.ceil((searchData?.total_results || 0) / 20), 470 + )} 471 + onPageChange={handlePageChange} 472 + /> 473 + </div> 474 + )} 475 + </div> 476 + ) 477 + ) : ( 478 + <div className="flex flex-col items-center justify-center py-20 text-(--foreground-muted)"> 479 + <Search className="mb-4 h-12 w-12 opacity-40" /> 480 + <p className="text-lg">What are you looking for?</p> 481 + <p className="mt-1 text-sm">Search for movies, TV shows, or people</p> 482 + </div> 483 + )} 484 + </div> 485 + ); 486 + }