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.

chore: format

+1050 -1025
-1
apps/web/biome.json
··· 33 33 } 34 34 } 35 35 } 36 -
+259 -242
apps/web/src/components/Header.tsx
··· 1 - import { Link, useNavigate } from '@tanstack/react-router' 2 - import { useState } from 'react' 3 - import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' 4 - import { Film, Home, Menu, Search, X, LogIn, LogOut, User, BookOpen } from 'lucide-react' 5 - import { authControllerMeOptions, authControllerLogoutMutation } from '@opnshelf/api' 1 + import { Link, useNavigate } from "@tanstack/react-router"; 2 + import { useState } from "react"; 3 + import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; 4 + import { 5 + Film, 6 + Home, 7 + Menu, 8 + Search, 9 + X, 10 + LogIn, 11 + LogOut, 12 + User, 13 + BookOpen, 14 + } from "lucide-react"; 15 + import { 16 + authControllerMeOptions, 17 + authControllerLogoutMutation, 18 + } from "@opnshelf/api"; 6 19 7 20 export default function Header() { 8 - const [isOpen, setIsOpen] = useState(false) 9 - const queryClient = useQueryClient() 10 - const navigate = useNavigate() 21 + const [isOpen, setIsOpen] = useState(false); 22 + const queryClient = useQueryClient(); 23 + const navigate = useNavigate(); 11 24 12 - // Fetch auth state using generated TanStack Query hook 13 - const { data: user, isLoading: isAuthLoading } = useQuery({ 14 - ...authControllerMeOptions(), 15 - staleTime: 5 * 60 * 1000, // 5 minutes 16 - retry: false, 17 - }) 25 + // Fetch auth state using generated TanStack Query hook 26 + const { data: user, isLoading: isAuthLoading } = useQuery({ 27 + ...authControllerMeOptions(), 28 + staleTime: 5 * 60 * 1000, // 5 minutes 29 + retry: false, 30 + }); 18 31 19 - // Logout mutation using generated TanStack Query hook 20 - const logoutMutation = useMutation({ 21 - ...authControllerLogoutMutation(), 22 - onSuccess: () => { 23 - queryClient.invalidateQueries({ queryKey: ['auth'] }) 24 - }, 25 - }) 32 + // Logout mutation using generated TanStack Query hook 33 + const logoutMutation = useMutation({ 34 + ...authControllerLogoutMutation(), 35 + onSuccess: () => { 36 + queryClient.invalidateQueries({ queryKey: ["auth"] }); 37 + }, 38 + }); 26 39 27 - const handleLogout = async () => { 28 - await logoutMutation.mutateAsync({}) 29 - } 40 + const handleLogout = async () => { 41 + await logoutMutation.mutateAsync({}); 42 + }; 30 43 31 - const handleLogin = () => { 32 - navigate({ to: '/login' }) 33 - } 44 + const handleLogin = () => { 45 + navigate({ to: "/login" }); 46 + }; 34 47 35 - return ( 36 - <> 37 - <header className="px-4 py-3 flex items-center justify-between bg-gray-900 text-white border-b border-gray-800"> 38 - <div className="flex items-center gap-3"> 39 - <button 40 - type="button" 41 - onClick={() => setIsOpen(true)} 42 - className="p-2 hover:bg-gray-800 rounded-lg transition-colors md:hidden" 43 - aria-label="Open menu" 44 - > 45 - <Menu size={24} /> 46 - </button> 47 - <Link to="/" className="flex items-center gap-2"> 48 - <Film className="w-8 h-8 text-purple-500" /> 49 - <span className="text-xl font-bold">OpnShelf</span> 50 - </Link> 51 - </div> 48 + return ( 49 + <> 50 + <header className="px-4 py-3 flex items-center justify-between bg-gray-900 text-white border-b border-gray-800"> 51 + <div className="flex items-center gap-3"> 52 + <button 53 + type="button" 54 + onClick={() => setIsOpen(true)} 55 + className="p-2 hover:bg-gray-800 rounded-lg transition-colors md:hidden" 56 + aria-label="Open menu" 57 + > 58 + <Menu size={24} /> 59 + </button> 60 + <Link to="/" className="flex items-center gap-2"> 61 + <Film className="w-8 h-8 text-purple-500" /> 62 + <span className="text-xl font-bold">OpnShelf</span> 63 + </Link> 64 + </div> 52 65 53 - {/* Desktop nav */} 54 - <nav className="hidden md:flex items-center gap-1"> 55 - <Link 56 - to="/" 57 - className="flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors text-gray-300 hover:text-white" 58 - activeProps={{ 59 - className: 60 - 'flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors text-white', 61 - }} 62 - activeOptions={{ exact: true }} 63 - > 64 - <Home size={18} /> 65 - <span className="font-medium">Home</span> 66 - </Link> 67 - {user && ( 68 - <Link 69 - to="/shelf" 70 - className="flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors text-gray-300 hover:text-white" 71 - activeProps={{ 72 - className: 73 - 'flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors text-white', 74 - }} 75 - > 76 - <BookOpen size={18} /> 77 - <span className="font-medium">My Shelf</span> 78 - </Link> 79 - )} 80 - <Link 81 - to="/search" 82 - search={{ q: '' }} 83 - className="flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors text-gray-300 hover:text-white" 84 - > 85 - <Search size={18} /> 86 - <span className="font-medium">Search</span> 87 - </Link> 66 + {/* Desktop nav */} 67 + <nav className="hidden md:flex items-center gap-1"> 68 + <Link 69 + to="/" 70 + className="flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors text-gray-300 hover:text-white" 71 + activeProps={{ 72 + className: 73 + "flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors text-white", 74 + }} 75 + activeOptions={{ exact: true }} 76 + > 77 + <Home size={18} /> 78 + <span className="font-medium">Home</span> 79 + </Link> 80 + {user && ( 81 + <Link 82 + to="/shelf" 83 + className="flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors text-gray-300 hover:text-white" 84 + activeProps={{ 85 + className: 86 + "flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors text-white", 87 + }} 88 + > 89 + <BookOpen size={18} /> 90 + <span className="font-medium">My Shelf</span> 91 + </Link> 92 + )} 93 + <Link 94 + to="/search" 95 + search={{ q: "" }} 96 + className="flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors text-gray-300 hover:text-white" 97 + > 98 + <Search size={18} /> 99 + <span className="font-medium">Search</span> 100 + </Link> 88 101 89 - {/* Auth section */} 90 - <div className="ml-4 pl-4 border-l border-gray-700"> 91 - {isAuthLoading ? ( 92 - <div className="w-8 h-8 rounded-full bg-gray-700 animate-pulse" /> 93 - ) : user ? ( 94 - <div className="flex items-center gap-3"> 95 - {user.avatar ? ( 96 - <img 97 - src={String(user.avatar)} 98 - alt={String(user.displayName || user.handle)} 99 - className="w-8 h-8 rounded-full" 100 - /> 101 - ) : ( 102 - <div className="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center"> 103 - <User size={16} /> 104 - </div> 105 - )} 106 - <span className="text-sm text-gray-300"> 107 - {user.displayName ? String(user.displayName) : `@${user.handle}`} 108 - </span> 109 - <button 110 - type="button" 111 - onClick={handleLogout} 112 - disabled={logoutMutation.isPending} 113 - className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-gray-800 transition-colors text-gray-300 hover:text-white text-sm" 114 - title="Sign out" 115 - > 116 - <LogOut size={16} /> 117 - </button> 118 - </div> 119 - ) : ( 120 - <button 121 - type="button" 122 - onClick={handleLogin} 123 - className="flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors text-white text-sm font-medium" 124 - > 125 - <LogIn size={16} /> 126 - <span>Sign in</span> 127 - </button> 128 - )} 129 - </div> 130 - </nav> 131 - </header> 102 + {/* Auth section */} 103 + <div className="ml-4 pl-4 border-l border-gray-700"> 104 + {isAuthLoading ? ( 105 + <div className="w-8 h-8 rounded-full bg-gray-700 animate-pulse" /> 106 + ) : user ? ( 107 + <div className="flex items-center gap-3"> 108 + {user.avatar ? ( 109 + <img 110 + src={String(user.avatar)} 111 + alt={String(user.displayName || user.handle)} 112 + className="w-8 h-8 rounded-full" 113 + /> 114 + ) : ( 115 + <div className="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center"> 116 + <User size={16} /> 117 + </div> 118 + )} 119 + <span className="text-sm text-gray-300"> 120 + {user.displayName 121 + ? String(user.displayName) 122 + : `@${user.handle}`} 123 + </span> 124 + <button 125 + type="button" 126 + onClick={handleLogout} 127 + disabled={logoutMutation.isPending} 128 + className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-gray-800 transition-colors text-gray-300 hover:text-white text-sm" 129 + title="Sign out" 130 + > 131 + <LogOut size={16} /> 132 + </button> 133 + </div> 134 + ) : ( 135 + <button 136 + type="button" 137 + onClick={handleLogin} 138 + className="flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors text-white text-sm font-medium" 139 + > 140 + <LogIn size={16} /> 141 + <span>Sign in</span> 142 + </button> 143 + )} 144 + </div> 145 + </nav> 146 + </header> 132 147 133 - {/* Mobile drawer overlay */} 134 - {isOpen && ( 135 - <button 136 - type="button" 137 - className="fixed inset-0 bg-black/50 z-40 md:hidden" 138 - onClick={() => setIsOpen(false)} 139 - aria-label="Close menu overlay" 140 - /> 141 - )} 148 + {/* Mobile drawer overlay */} 149 + {isOpen && ( 150 + <button 151 + type="button" 152 + className="fixed inset-0 bg-black/50 z-40 md:hidden" 153 + onClick={() => setIsOpen(false)} 154 + aria-label="Close menu overlay" 155 + /> 156 + )} 142 157 143 - {/* Mobile drawer */} 144 - <aside 145 - className={`fixed top-0 left-0 h-full w-72 bg-gray-900 text-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out flex flex-col md:hidden ${ 146 - isOpen ? 'translate-x-0' : '-translate-x-full' 147 - }`} 148 - > 149 - <div className="flex items-center justify-between p-4 border-b border-gray-800"> 150 - <div className="flex items-center gap-2"> 151 - <Film className="w-6 h-6 text-purple-500" /> 152 - <span className="text-lg font-bold">OpnShelf</span> 153 - </div> 154 - <button 155 - type="button" 156 - onClick={() => setIsOpen(false)} 157 - className="p-2 hover:bg-gray-800 rounded-lg transition-colors" 158 - aria-label="Close menu" 159 - > 160 - <X size={24} /> 161 - </button> 162 - </div> 158 + {/* Mobile drawer */} 159 + <aside 160 + className={`fixed top-0 left-0 h-full w-72 bg-gray-900 text-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out flex flex-col md:hidden ${ 161 + isOpen ? "translate-x-0" : "-translate-x-full" 162 + }`} 163 + > 164 + <div className="flex items-center justify-between p-4 border-b border-gray-800"> 165 + <div className="flex items-center gap-2"> 166 + <Film className="w-6 h-6 text-purple-500" /> 167 + <span className="text-lg font-bold">OpnShelf</span> 168 + </div> 169 + <button 170 + type="button" 171 + onClick={() => setIsOpen(false)} 172 + className="p-2 hover:bg-gray-800 rounded-lg transition-colors" 173 + aria-label="Close menu" 174 + > 175 + <X size={24} /> 176 + </button> 177 + </div> 163 178 164 - <nav className="flex-1 p-4 overflow-y-auto"> 165 - <Link 166 - to="/" 167 - onClick={() => setIsOpen(false)} 168 - className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2 text-gray-300 hover:text-white" 169 - activeProps={{ 170 - className: 171 - 'flex items-center gap-3 p-3 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors mb-2 text-white', 172 - }} 173 - activeOptions={{ exact: true }} 174 - > 175 - <Home size={20} /> 176 - <span className="font-medium">Home</span> 177 - </Link> 179 + <nav className="flex-1 p-4 overflow-y-auto"> 180 + <Link 181 + to="/" 182 + onClick={() => setIsOpen(false)} 183 + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2 text-gray-300 hover:text-white" 184 + activeProps={{ 185 + className: 186 + "flex items-center gap-3 p-3 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors mb-2 text-white", 187 + }} 188 + activeOptions={{ exact: true }} 189 + > 190 + <Home size={20} /> 191 + <span className="font-medium">Home</span> 192 + </Link> 178 193 179 - <Link 180 - to="/search" 181 - search={{ q: '' }} 182 - onClick={() => setIsOpen(false)} 183 - className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2 text-gray-300 hover:text-white" 184 - > 185 - <Search size={20} /> 186 - <span className="font-medium">Search</span> 187 - </Link> 194 + <Link 195 + to="/search" 196 + search={{ q: "" }} 197 + onClick={() => setIsOpen(false)} 198 + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2 text-gray-300 hover:text-white" 199 + > 200 + <Search size={20} /> 201 + <span className="font-medium">Search</span> 202 + </Link> 188 203 189 - {user && ( 190 - <Link 191 - to="/shelf" 192 - onClick={() => setIsOpen(false)} 193 - className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2 text-gray-300 hover:text-white" 194 - activeProps={{ 195 - className: 196 - 'flex items-center gap-3 p-3 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors mb-2 text-white', 197 - }} 198 - > 199 - <BookOpen size={20} /> 200 - <span className="font-medium">My Shelf</span> 201 - </Link> 202 - )} 203 - </nav> 204 + {user && ( 205 + <Link 206 + to="/shelf" 207 + onClick={() => setIsOpen(false)} 208 + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2 text-gray-300 hover:text-white" 209 + activeProps={{ 210 + className: 211 + "flex items-center gap-3 p-3 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors mb-2 text-white", 212 + }} 213 + > 214 + <BookOpen size={20} /> 215 + <span className="font-medium">My Shelf</span> 216 + </Link> 217 + )} 218 + </nav> 204 219 205 - {/* Mobile auth section */} 206 - <div className="p-4 border-t border-gray-800"> 207 - {isAuthLoading ? ( 208 - <div className="h-12 bg-gray-700 rounded-lg animate-pulse" /> 209 - ) : user ? ( 210 - <div className="space-y-3"> 211 - <div className="flex items-center gap-3"> 212 - {user.avatar ? ( 213 - <img 214 - src={String(user.avatar)} 215 - alt={String(user.displayName || user.handle)} 216 - className="w-10 h-10 rounded-full" 217 - /> 218 - ) : ( 219 - <div className="w-10 h-10 rounded-full bg-purple-600 flex items-center justify-center"> 220 - <User size={20} /> 221 - </div> 222 - )} 223 - <div> 224 - <div className="font-medium">{user.displayName ? String(user.displayName) : user.handle}</div> 225 - <div className="text-sm text-gray-400">@{user.handle}</div> 226 - </div> 227 - </div> 228 - <button 229 - type="button" 230 - onClick={() => { 231 - handleLogout() 232 - setIsOpen(false) 233 - }} 234 - disabled={logoutMutation.isPending} 235 - className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors text-gray-300" 236 - > 237 - <LogOut size={18} /> 238 - <span>Sign out</span> 239 - </button> 240 - </div> 241 - ) : ( 242 - <button 243 - type="button" 244 - onClick={() => { 245 - handleLogin() 246 - setIsOpen(false) 247 - }} 248 - className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors text-white font-medium" 249 - > 250 - <LogIn size={18} /> 251 - <span>Sign in with Bluesky</span> 252 - </button> 253 - )} 254 - </div> 255 - </aside> 256 - </> 257 - ) 220 + {/* Mobile auth section */} 221 + <div className="p-4 border-t border-gray-800"> 222 + {isAuthLoading ? ( 223 + <div className="h-12 bg-gray-700 rounded-lg animate-pulse" /> 224 + ) : user ? ( 225 + <div className="space-y-3"> 226 + <div className="flex items-center gap-3"> 227 + {user.avatar ? ( 228 + <img 229 + src={String(user.avatar)} 230 + alt={String(user.displayName || user.handle)} 231 + className="w-10 h-10 rounded-full" 232 + /> 233 + ) : ( 234 + <div className="w-10 h-10 rounded-full bg-purple-600 flex items-center justify-center"> 235 + <User size={20} /> 236 + </div> 237 + )} 238 + <div> 239 + <div className="font-medium"> 240 + {user.displayName ? String(user.displayName) : user.handle} 241 + </div> 242 + <div className="text-sm text-gray-400">@{user.handle}</div> 243 + </div> 244 + </div> 245 + <button 246 + type="button" 247 + onClick={() => { 248 + handleLogout(); 249 + setIsOpen(false); 250 + }} 251 + disabled={logoutMutation.isPending} 252 + className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors text-gray-300" 253 + > 254 + <LogOut size={18} /> 255 + <span>Sign out</span> 256 + </button> 257 + </div> 258 + ) : ( 259 + <button 260 + type="button" 261 + onClick={() => { 262 + handleLogin(); 263 + setIsOpen(false); 264 + }} 265 + className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors text-white font-medium" 266 + > 267 + <LogIn size={18} /> 268 + <span>Sign in with Bluesky</span> 269 + </button> 270 + )} 271 + </div> 272 + </aside> 273 + </> 274 + ); 258 275 }
+51 -51
apps/web/src/components/ui/alert.tsx
··· 1 - import type * as React from "react" 2 - import { cva, type VariantProps } from "class-variance-authority" 1 + import type * as React from "react"; 2 + import { cva, type VariantProps } from "class-variance-authority"; 3 3 4 - import { cn } from "@/lib/utils" 4 + import { cn } from "@/lib/utils"; 5 5 6 6 const alertVariants = cva( 7 - "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", 8 - { 9 - variants: { 10 - variant: { 11 - default: "bg-card text-card-foreground", 12 - destructive: 13 - "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", 14 - }, 15 - }, 16 - defaultVariants: { 17 - variant: "default", 18 - }, 19 - } 20 - ) 7 + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", 8 + { 9 + variants: { 10 + variant: { 11 + default: "bg-card text-card-foreground", 12 + destructive: 13 + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", 14 + }, 15 + }, 16 + defaultVariants: { 17 + variant: "default", 18 + }, 19 + }, 20 + ); 21 21 22 22 function Alert({ 23 - className, 24 - variant, 25 - ...props 23 + className, 24 + variant, 25 + ...props 26 26 }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) { 27 - return ( 28 - <div 29 - data-slot="alert" 30 - role="alert" 31 - className={cn(alertVariants({ variant }), className)} 32 - {...props} 33 - /> 34 - ) 27 + return ( 28 + <div 29 + data-slot="alert" 30 + role="alert" 31 + className={cn(alertVariants({ variant }), className)} 32 + {...props} 33 + /> 34 + ); 35 35 } 36 36 37 37 function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { 38 - return ( 39 - <div 40 - data-slot="alert-title" 41 - className={cn( 42 - "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", 43 - className 44 - )} 45 - {...props} 46 - /> 47 - ) 38 + return ( 39 + <div 40 + data-slot="alert-title" 41 + className={cn( 42 + "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", 43 + className, 44 + )} 45 + {...props} 46 + /> 47 + ); 48 48 } 49 49 50 50 function AlertDescription({ 51 - className, 52 - ...props 51 + className, 52 + ...props 53 53 }: React.ComponentProps<"div">) { 54 - return ( 55 - <div 56 - data-slot="alert-description" 57 - className={cn( 58 - "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", 59 - className 60 - )} 61 - {...props} 62 - /> 63 - ) 54 + return ( 55 + <div 56 + data-slot="alert-description" 57 + className={cn( 58 + "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", 59 + className, 60 + )} 61 + {...props} 62 + /> 63 + ); 64 64 } 65 65 66 - export { Alert, AlertTitle, AlertDescription } 66 + export { Alert, AlertTitle, AlertDescription };
+10 -10
apps/web/src/data/demo.punk-songs.ts
··· 1 - import { createServerFn } from '@tanstack/react-start' 1 + import { createServerFn } from "@tanstack/react-start"; 2 2 3 3 export const getPunkSongs = createServerFn({ 4 - method: 'GET', 4 + method: "GET", 5 5 }).handler(async () => [ 6 - { id: 1, name: 'Teenage Dirtbag', artist: 'Wheatus' }, 7 - { id: 2, name: 'Smells Like Teen Spirit', artist: 'Nirvana' }, 8 - { id: 3, name: 'The Middle', artist: 'Jimmy Eat World' }, 9 - { id: 4, name: 'My Own Worst Enemy', artist: 'Lit' }, 10 - { id: 5, name: 'Fat Lip', artist: 'Sum 41' }, 11 - { id: 6, name: 'All the Small Things', artist: 'blink-182' }, 12 - { id: 7, name: 'Beverly Hills', artist: 'Weezer' }, 13 - ]) 6 + { id: 1, name: "Teenage Dirtbag", artist: "Wheatus" }, 7 + { id: 2, name: "Smells Like Teen Spirit", artist: "Nirvana" }, 8 + { id: 3, name: "The Middle", artist: "Jimmy Eat World" }, 9 + { id: 4, name: "My Own Worst Enemy", artist: "Lit" }, 10 + { id: 5, name: "Fat Lip", artist: "Sum 41" }, 11 + { id: 6, name: "All the Small Things", artist: "blink-182" }, 12 + { id: 7, name: "Beverly Hills", artist: "Weezer" }, 13 + ]);
+34 -34
apps/web/src/env.ts
··· 1 - import { createEnv } from '@t3-oss/env-core' 2 - import { z } from 'zod' 1 + import { createEnv } from "@t3-oss/env-core"; 2 + import { z } from "zod"; 3 3 4 4 export const env = createEnv({ 5 - server: { 6 - SERVER_URL: z.string().url().optional(), 7 - }, 5 + server: { 6 + SERVER_URL: z.string().url().optional(), 7 + }, 8 8 9 - /** 10 - * The prefix that client-side variables must have. This is enforced both at 11 - * a type-level and at runtime. 12 - */ 13 - clientPrefix: 'VITE_', 9 + /** 10 + * The prefix that client-side variables must have. This is enforced both at 11 + * a type-level and at runtime. 12 + */ 13 + clientPrefix: "VITE_", 14 14 15 - client: { 16 - VITE_APP_TITLE: z.string().min(1).optional(), 17 - VITE_API_URL: z.string().url().default('http://127.0.0.1:3001'), 18 - }, 15 + client: { 16 + VITE_APP_TITLE: z.string().min(1).optional(), 17 + VITE_API_URL: z.string().url().default("http://127.0.0.1:3001"), 18 + }, 19 19 20 - /** 21 - * What object holds the environment variables at runtime. This is usually 22 - * `process.env` or `import.meta.env`. 23 - */ 24 - runtimeEnv: import.meta.env, 20 + /** 21 + * What object holds the environment variables at runtime. This is usually 22 + * `process.env` or `import.meta.env`. 23 + */ 24 + runtimeEnv: import.meta.env, 25 25 26 - /** 27 - * By default, this library will feed the environment variables directly to 28 - * the Zod validator. 29 - * 30 - * This means that if you have an empty string for a value that is supposed 31 - * to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag 32 - * it as a type mismatch violation. Additionally, if you have an empty string 33 - * for a value that is supposed to be a string with a default value (e.g. 34 - * `DOMAIN=` in an ".env" file), the default value will never be applied. 35 - * 36 - * In order to solve these issues, we recommend that all new projects 37 - * explicitly specify this option as true. 38 - */ 39 - emptyStringAsUndefined: true, 40 - }) 26 + /** 27 + * By default, this library will feed the environment variables directly to 28 + * the Zod validator. 29 + * 30 + * This means that if you have an empty string for a value that is supposed 31 + * to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag 32 + * it as a type mismatch violation. Additionally, if you have an empty string 33 + * for a value that is supposed to be a string with a default value (e.g. 34 + * `DOMAIN=` in an ".env" file), the default value will never be applied. 35 + * 36 + * In order to solve these issues, we recommend that all new projects 37 + * explicitly specify this option as true. 38 + */ 39 + emptyStringAsUndefined: true, 40 + });
+4 -4
apps/web/src/integrations/tanstack-query/devtools.tsx
··· 1 - import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools' 1 + import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; 2 2 3 3 export default { 4 - name: 'Tanstack Query', 5 - render: <ReactQueryDevtoolsPanel />, 6 - } 4 + name: "Tanstack Query", 5 + render: <ReactQueryDevtoolsPanel />, 6 + };
+12 -12
apps/web/src/integrations/tanstack-query/root-provider.tsx
··· 1 - import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 1 + import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 2 2 3 3 export function getContext() { 4 - const queryClient = new QueryClient() 5 - return { 6 - queryClient, 7 - } 4 + const queryClient = new QueryClient(); 5 + return { 6 + queryClient, 7 + }; 8 8 } 9 9 10 10 export function Provider({ 11 - children, 12 - queryClient, 11 + children, 12 + queryClient, 13 13 }: { 14 - children: React.ReactNode 15 - queryClient: QueryClient 14 + children: React.ReactNode; 15 + queryClient: QueryClient; 16 16 }) { 17 - return ( 18 - <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> 19 - ) 17 + return ( 18 + <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> 19 + ); 20 20 }
+3 -3
apps/web/src/lib/utils.ts
··· 1 - import { clsx, type ClassValue } from 'clsx' 2 - import { twMerge } from 'tailwind-merge' 1 + import { clsx, type ClassValue } from "clsx"; 2 + import { twMerge } from "tailwind-merge"; 3 3 4 4 export function cn(...inputs: ClassValue[]) { 5 - return twMerge(clsx(inputs)) 5 + return twMerge(clsx(inputs)); 6 6 }
+18 -18
apps/web/src/router.tsx
··· 1 - import { QueryClient } from '@tanstack/react-query'; 2 - import { createRouter as createTanStackRouter } from '@tanstack/react-router'; 1 + import { QueryClient } from "@tanstack/react-query"; 2 + import { createRouter as createTanStackRouter } from "@tanstack/react-router"; 3 3 4 - import { routeTree } from './routeTree.gen'; 4 + import { routeTree } from "./routeTree.gen"; 5 5 6 6 const queryClient = new QueryClient({ 7 - defaultOptions: { 8 - queries: { 9 - staleTime: 60 * 1000, // 1 minute 10 - }, 11 - }, 7 + defaultOptions: { 8 + queries: { 9 + staleTime: 60 * 1000, // 1 minute 10 + }, 11 + }, 12 12 }); 13 13 14 14 export function getRouter() { 15 - return createTanStackRouter({ 16 - routeTree, 17 - context: { queryClient }, 18 - defaultPreload: 'intent', 19 - }); 15 + return createTanStackRouter({ 16 + routeTree, 17 + context: { queryClient }, 18 + defaultPreload: "intent", 19 + }); 20 20 } 21 21 22 - declare module '@tanstack/react-router' { 23 - interface Register { 24 - router: ReturnType<typeof getRouter>; 25 - } 26 - } 22 + declare module "@tanstack/react-router" { 23 + interface Register { 24 + router: ReturnType<typeof getRouter>; 25 + } 26 + }
+85 -87
apps/web/src/routes/__root.tsx
··· 1 1 import { 2 - HeadContent, 3 - Scripts, 4 - createRootRouteWithContext, 5 - Outlet, 6 - useNavigate, 7 - } from '@tanstack/react-router' 8 - import { useEffect } from 'react' 9 - import { QueryClientProvider, type QueryClient } from '@tanstack/react-query' 10 - import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' 11 - import { TanStackDevtools } from '@tanstack/react-devtools' 2 + HeadContent, 3 + Scripts, 4 + createRootRouteWithContext, 5 + Outlet, 6 + useNavigate, 7 + } from "@tanstack/react-router"; 8 + import { useEffect } from "react"; 9 + import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; 10 + import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; 11 + import { TanStackDevtools } from "@tanstack/react-devtools"; 12 12 13 - import Header from '../components/Header' 13 + import Header from "../components/Header"; 14 14 15 - import TanStackQueryDevtools from '../integrations/tanstack-query/devtools' 15 + import TanStackQueryDevtools from "../integrations/tanstack-query/devtools"; 16 16 17 - import appCss from '../styles.css?url' 18 - import { configureApiClient, setOnUnauthorized } from '@opnshelf/api' 19 - import { env } from '@/env' 17 + import appCss from "../styles.css?url"; 18 + import { configureApiClient, setOnUnauthorized } from "@opnshelf/api"; 19 + import { env } from "@/env"; 20 20 21 21 interface MyRouterContext { 22 - queryClient: QueryClient 22 + queryClient: QueryClient; 23 23 } 24 24 25 25 export const Route = createRootRouteWithContext<MyRouterContext>()({ 26 - head: () => ({ 27 - meta: [ 28 - { 29 - charSet: 'utf-8', 30 - }, 31 - { 32 - name: 'viewport', 33 - content: 'width=device-width, initial-scale=1', 34 - }, 35 - { 36 - title: 'OpnShelf', 37 - }, 38 - ], 39 - links: [ 40 - { 41 - rel: 'stylesheet', 42 - href: appCss, 43 - }, 44 - ], 45 - }), 26 + head: () => ({ 27 + meta: [ 28 + { 29 + charSet: "utf-8", 30 + }, 31 + { 32 + name: "viewport", 33 + content: "width=device-width, initial-scale=1", 34 + }, 35 + { 36 + title: "OpnShelf", 37 + }, 38 + ], 39 + links: [ 40 + { 41 + rel: "stylesheet", 42 + href: appCss, 43 + }, 44 + ], 45 + }), 46 46 47 - component: RootComponent, 48 - shellComponent: RootDocument, 49 - }) 50 - 47 + component: RootComponent, 48 + shellComponent: RootDocument, 49 + }); 51 50 52 51 configureApiClient(env.VITE_API_URL); 53 52 54 53 function RootComponent() { 55 - const { queryClient } = Route.useRouteContext() 56 - const navigate = useNavigate() 54 + const { queryClient } = Route.useRouteContext(); 55 + const navigate = useNavigate(); 57 56 58 - useEffect(() => { 59 - setOnUnauthorized(() => { 60 - queryClient.invalidateQueries({ queryKey: ['auth'] }) 61 - navigate({ 62 - to: '/login', 63 - search: { reason: 'session_expired' }, 64 - replace: true, 65 - }) 66 - }) 67 - return () => setOnUnauthorized(null) 68 - }, [queryClient, navigate]) 57 + useEffect(() => { 58 + setOnUnauthorized(() => { 59 + queryClient.invalidateQueries({ queryKey: ["auth"] }); 60 + navigate({ 61 + to: "/login", 62 + search: { reason: "session_expired" }, 63 + replace: true, 64 + }); 65 + }); 66 + return () => setOnUnauthorized(null); 67 + }, [queryClient, navigate]); 69 68 70 - return ( 71 - <QueryClientProvider client={queryClient}> 72 - <div className="min-h-screen flex flex-col"> 73 - <Header /> 74 - <main className="flex-1 flex flex-col min-h-0"> 75 - <Outlet /> 76 - </main> 77 - </div> 78 - <TanStackDevtools 79 - config={{ 80 - position: 'bottom-right', 81 - }} 82 - plugins={[ 83 - { 84 - name: 'Tanstack Router', 85 - render: <TanStackRouterDevtoolsPanel />, 86 - }, 87 - TanStackQueryDevtools, 88 - ]} 89 - /> 90 - </QueryClientProvider> 91 - ) 69 + return ( 70 + <QueryClientProvider client={queryClient}> 71 + <div className="min-h-screen flex flex-col"> 72 + <Header /> 73 + <main className="flex-1 flex flex-col min-h-0"> 74 + <Outlet /> 75 + </main> 76 + </div> 77 + <TanStackDevtools 78 + config={{ 79 + position: "bottom-right", 80 + }} 81 + plugins={[ 82 + { 83 + name: "Tanstack Router", 84 + render: <TanStackRouterDevtoolsPanel />, 85 + }, 86 + TanStackQueryDevtools, 87 + ]} 88 + /> 89 + </QueryClientProvider> 90 + ); 92 91 } 93 92 94 - 95 93 function RootDocument({ children }: { children: React.ReactNode }) { 96 - return ( 97 - <html lang="en"> 98 - <head> 99 - <HeadContent /> 100 - </head> 101 - <body> 102 - {children} 103 - <Scripts /> 104 - </body> 105 - </html> 106 - ) 94 + return ( 95 + <html lang="en"> 96 + <head> 97 + <HeadContent /> 98 + </head> 99 + <body> 100 + {children} 101 + <Scripts /> 102 + </body> 103 + </html> 104 + ); 107 105 }
+38 -38
apps/web/src/routes/auth/complete.tsx
··· 1 - import { createFileRoute, useNavigate } from '@tanstack/react-router'; 2 - import { useEffect } from 'react'; 3 - import { useQueryClient } from '@tanstack/react-query'; 4 - import { Film } from 'lucide-react'; 1 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 + import { useEffect } from "react"; 3 + import { useQueryClient } from "@tanstack/react-query"; 4 + import { Film } from "lucide-react"; 5 5 6 - export const Route = createFileRoute('/auth/complete')({ 7 - component: AuthCompletePage, 6 + export const Route = createFileRoute("/auth/complete")({ 7 + component: AuthCompletePage, 8 8 }); 9 9 10 10 /** ··· 12 12 * Rejects absolute URLs, protocol-relative URLs, and other attack vectors. 13 13 */ 14 14 function isValidRedirectPath(path: string): boolean { 15 - if (!path || typeof path !== 'string') return false; 16 - // Must start with / 17 - if (!path.startsWith('/')) return false; 18 - // Must not contain // (protocol-relative or double slash) 19 - if (path.includes('//')) return false; 20 - // Must not start with /\ (Windows path injection) 21 - if (path.startsWith('/\\')) return false; 22 - return true; 15 + if (!path || typeof path !== "string") return false; 16 + // Must start with / 17 + if (!path.startsWith("/")) return false; 18 + // Must not contain // (protocol-relative or double slash) 19 + if (path.includes("//")) return false; 20 + // Must not start with /\ (Windows path injection) 21 + if (path.startsWith("/\\")) return false; 22 + return true; 23 23 } 24 24 25 25 function AuthCompletePage() { 26 - const navigate = useNavigate(); 27 - const queryClient = useQueryClient(); 26 + const navigate = useNavigate(); 27 + const queryClient = useQueryClient(); 28 28 29 - useEffect(() => { 30 - // Invalidate auth query so app picks up the new session 31 - queryClient.invalidateQueries({ queryKey: ['auth'] }); 29 + useEffect(() => { 30 + // Invalidate auth query so app picks up the new session 31 + queryClient.invalidateQueries({ queryKey: ["auth"] }); 32 32 33 - // Read stored redirect path 34 - const storedRedirect = sessionStorage.getItem('auth_redirect'); 35 - sessionStorage.removeItem('auth_redirect'); 33 + // Read stored redirect path 34 + const storedRedirect = sessionStorage.getItem("auth_redirect"); 35 + sessionStorage.removeItem("auth_redirect"); 36 36 37 - // Validate and redirect 38 - if (storedRedirect && isValidRedirectPath(storedRedirect)) { 39 - navigate({ to: storedRedirect }); 40 - } else { 41 - navigate({ to: '/' }); 42 - } 43 - }, [navigate, queryClient]); 37 + // Validate and redirect 38 + if (storedRedirect && isValidRedirectPath(storedRedirect)) { 39 + navigate({ to: storedRedirect }); 40 + } else { 41 + navigate({ to: "/" }); 42 + } 43 + }, [navigate, queryClient]); 44 44 45 - return ( 46 - <div className="flex-1 bg-gray-950 text-gray-50 flex flex-col min-h-0"> 47 - <div className="flex-1 flex flex-col items-center justify-center p-4"> 48 - <Film className="w-12 h-12 text-purple-500 mb-4" /> 49 - <div className="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mb-4" /> 50 - <p className="text-gray-400">Completing sign-in...</p> 51 - </div> 52 - </div> 53 - ); 45 + return ( 46 + <div className="flex-1 bg-gray-950 text-gray-50 flex flex-col min-h-0"> 47 + <div className="flex-1 flex flex-col items-center justify-center p-4"> 48 + <Film className="w-12 h-12 text-purple-500 mb-4" /> 49 + <div className="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mb-4" /> 50 + <p className="text-gray-400">Completing sign-in...</p> 51 + </div> 52 + </div> 53 + ); 54 54 }
+48 -48
apps/web/src/routes/index.tsx
··· 1 - import { createFileRoute, Link } from '@tanstack/react-router'; 2 - import { Search, Film } from 'lucide-react'; 1 + import { createFileRoute, Link } from "@tanstack/react-router"; 2 + import { Search, Film } from "lucide-react"; 3 3 4 - export const Route = createFileRoute('/')({ 5 - component: HomePage, 4 + export const Route = createFileRoute("/")({ 5 + component: HomePage, 6 6 }); 7 7 8 8 function HomePage() { 9 - return ( 10 - <div className="min-h-screen bg-gray-950 text-gray-50"> 11 - <div className="container mx-auto px-4 py-16 max-w-4xl"> 12 - <div className="text-center mb-12"> 13 - <div className="flex justify-center mb-6"> 14 - <Film className="w-16 h-16 text-purple-500" /> 15 - </div> 16 - <h1 className="text-5xl font-bold mb-4">OpnShelf</h1> 17 - <p className="text-xl text-gray-400 mb-8"> 18 - Your personal media tracker powered by AT Protocol 19 - </p> 20 - <Link 21 - to="/search" 22 - search={{ q: '' }} 23 - className="inline-flex items-center gap-2 px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition-colors" 24 - > 25 - <Search className="w-5 h-5" /> 26 - Search Movies 27 - </Link> 28 - </div> 9 + return ( 10 + <div className="min-h-screen bg-gray-950 text-gray-50"> 11 + <div className="container mx-auto px-4 py-16 max-w-4xl"> 12 + <div className="text-center mb-12"> 13 + <div className="flex justify-center mb-6"> 14 + <Film className="w-16 h-16 text-purple-500" /> 15 + </div> 16 + <h1 className="text-5xl font-bold mb-4">OpnShelf</h1> 17 + <p className="text-xl text-gray-400 mb-8"> 18 + Your personal media tracker powered by AT Protocol 19 + </p> 20 + <Link 21 + to="/search" 22 + search={{ q: "" }} 23 + className="inline-flex items-center gap-2 px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition-colors" 24 + > 25 + <Search className="w-5 h-5" /> 26 + Search Movies 27 + </Link> 28 + </div> 29 29 30 - <div className="grid md:grid-cols-3 gap-6 mt-16"> 31 - <div className="bg-gray-900 p-6 rounded-lg border border-gray-800"> 32 - <h3 className="text-lg font-semibold mb-2">Track Your Media</h3> 33 - <p className="text-gray-400"> 34 - Keep track of movies, shows, and games you've watched and played 35 - </p> 36 - </div> 37 - <div className="bg-gray-900 p-6 rounded-lg border border-gray-800"> 38 - <h3 className="text-lg font-semibold mb-2">Own Your Data</h3> 39 - <p className="text-gray-400"> 40 - Built on AT Protocol - your data belongs to you 41 - </p> 42 - </div> 43 - <div className="bg-gray-900 p-6 rounded-lg border border-gray-800"> 44 - <h3 className="text-lg font-semibold mb-2">Discover & Share</h3> 45 - <p className="text-gray-400"> 46 - See what others are watching and share your favorites 47 - </p> 48 - </div> 49 - </div> 50 - </div> 51 - </div> 52 - ); 53 - } 30 + <div className="grid md:grid-cols-3 gap-6 mt-16"> 31 + <div className="bg-gray-900 p-6 rounded-lg border border-gray-800"> 32 + <h3 className="text-lg font-semibold mb-2">Track Your Media</h3> 33 + <p className="text-gray-400"> 34 + Keep track of movies, shows, and games you've watched and played 35 + </p> 36 + </div> 37 + <div className="bg-gray-900 p-6 rounded-lg border border-gray-800"> 38 + <h3 className="text-lg font-semibold mb-2">Own Your Data</h3> 39 + <p className="text-gray-400"> 40 + Built on AT Protocol - your data belongs to you 41 + </p> 42 + </div> 43 + <div className="bg-gray-900 p-6 rounded-lg border border-gray-800"> 44 + <h3 className="text-lg font-semibold mb-2">Discover & Share</h3> 45 + <p className="text-gray-400"> 46 + See what others are watching and share your favorites 47 + </p> 48 + </div> 49 + </div> 50 + </div> 51 + </div> 52 + ); 53 + }
+134 -136
apps/web/src/routes/login.tsx
··· 1 - import { createFileRoute, useNavigate } from '@tanstack/react-router'; 2 - import { useState, useEffect, useId } from 'react'; 3 - import { useQuery } from '@tanstack/react-query'; 4 - import { Film, LogIn, AlertCircle } from 'lucide-react'; 5 - import { authControllerMeOptions, getLoginUrl } from '@opnshelf/api'; 6 - import { z } from 'zod'; 7 - import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; 1 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 + import { useState, useEffect, useId } from "react"; 3 + import { useQuery } from "@tanstack/react-query"; 4 + import { Film, LogIn, AlertCircle } from "lucide-react"; 5 + import { authControllerMeOptions, getLoginUrl } from "@opnshelf/api"; 6 + import { z } from "zod"; 7 + import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 8 8 9 9 const loginSearchSchema = z.object({ 10 - error: z.enum(['auth_failed', 'callback_failed']).optional(), 11 - redirect: z.string().optional(), 12 - reason: z.enum(['session_expired']).optional(), 10 + error: z.enum(["auth_failed", "callback_failed"]).optional(), 11 + redirect: z.string().optional(), 12 + reason: z.enum(["session_expired"]).optional(), 13 13 }); 14 14 15 - export const Route = createFileRoute('/login')({ 16 - validateSearch: loginSearchSchema, 17 - component: LoginPage, 15 + export const Route = createFileRoute("/login")({ 16 + validateSearch: loginSearchSchema, 17 + component: LoginPage, 18 18 }); 19 19 20 20 function LoginPage() { 21 - const [handle, setHandle] = useState(''); 22 - const [isSubmitting, setIsSubmitting] = useState(false); 23 - const navigate = useNavigate(); 24 - const { error, redirect, reason } = Route.useSearch(); 25 - const handleId = useId(); 21 + const [handle, setHandle] = useState(""); 22 + const [isSubmitting, setIsSubmitting] = useState(false); 23 + const navigate = useNavigate(); 24 + const { error, redirect, reason } = Route.useSearch(); 25 + const handleId = useId(); 26 26 27 - // Check if user is already logged in using generated TanStack Query hook 28 - const { data: user, isLoading: isAuthLoading } = useQuery({ 29 - ...authControllerMeOptions(), 30 - staleTime: 5 * 60 * 1000, 31 - retry: false, 32 - }); 27 + // Check if user is already logged in using generated TanStack Query hook 28 + const { data: user, isLoading: isAuthLoading } = useQuery({ 29 + ...authControllerMeOptions(), 30 + staleTime: 5 * 60 * 1000, 31 + retry: false, 32 + }); 33 33 34 - // Redirect if already logged in 35 - useEffect(() => { 36 - if (user && !isAuthLoading) { 37 - navigate({ to: redirect || '/' }); 38 - } 39 - }, [user, isAuthLoading, navigate, redirect]); 34 + // Redirect if already logged in 35 + useEffect(() => { 36 + if (user && !isAuthLoading) { 37 + navigate({ to: redirect || "/" }); 38 + } 39 + }, [user, isAuthLoading, navigate, redirect]); 40 40 41 - const handleSubmit = (e: React.FormEvent) => { 42 - e.preventDefault(); 43 - setIsSubmitting(true); 41 + const handleSubmit = (e: React.FormEvent) => { 42 + e.preventDefault(); 43 + setIsSubmitting(true); 44 44 45 - // Store redirect URL in sessionStorage so we can use it after callback 46 - if (redirect) { 47 - sessionStorage.setItem('auth_redirect', redirect); 48 - } 45 + // Store redirect URL in sessionStorage so we can use it after callback 46 + if (redirect) { 47 + sessionStorage.setItem("auth_redirect", redirect); 48 + } 49 49 50 - // Redirect to backend login with optional handle 51 - const loginUrl = getLoginUrl(handle || undefined); 52 - window.location.href = loginUrl; 53 - }; 50 + // Redirect to backend login with optional handle 51 + const loginUrl = getLoginUrl(handle || undefined); 52 + window.location.href = loginUrl; 53 + }; 54 54 55 - const errorMessages: Record<string, string> = { 56 - auth_failed: 'Authentication failed. Please try again.', 57 - callback_failed: 'Something went wrong during sign in. Please try again.', 58 - }; 55 + const errorMessages: Record<string, string> = { 56 + auth_failed: "Authentication failed. Please try again.", 57 + callback_failed: "Something went wrong during sign in. Please try again.", 58 + }; 59 59 60 - if (isAuthLoading) { 61 - return ( 62 - <div className="flex-1 bg-gray-950 flex items-center justify-center min-h-0"> 63 - <div className="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" /> 64 - </div> 65 - ); 66 - } 60 + if (isAuthLoading) { 61 + return ( 62 + <div className="flex-1 bg-gray-950 flex items-center justify-center min-h-0"> 63 + <div className="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" /> 64 + </div> 65 + ); 66 + } 67 67 68 - return ( 69 - <div className="flex-1 bg-gray-950 text-gray-50 flex flex-col min-h-0"> 70 - <div className="flex-1 flex items-center justify-center p-4"> 71 - <div className="w-full max-w-md"> 72 - {/* Logo and title */} 73 - <div className="text-center mb-8"> 74 - <div className="flex justify-center mb-4"> 75 - <Film className="w-12 h-12 text-purple-500" /> 76 - </div> 77 - <h1 className="text-3xl font-bold mb-2">Sign in to OpnShelf</h1> 78 - <p className="text-gray-400"> 79 - Use your Bluesky account to sign in 80 - </p> 81 - </div> 68 + return ( 69 + <div className="flex-1 bg-gray-950 text-gray-50 flex flex-col min-h-0"> 70 + <div className="flex-1 flex items-center justify-center p-4"> 71 + <div className="w-full max-w-md"> 72 + {/* Logo and title */} 73 + <div className="text-center mb-8"> 74 + <div className="flex justify-center mb-4"> 75 + <Film className="w-12 h-12 text-purple-500" /> 76 + </div> 77 + <h1 className="text-3xl font-bold mb-2">Sign in to OpnShelf</h1> 78 + <p className="text-gray-400">Use your Bluesky account to sign in</p> 79 + </div> 82 80 83 - {/* Logged out message (session expired / 401 redirect) */} 84 - {reason === 'session_expired' && ( 85 - <Alert className="mb-6 border-amber-800 bg-amber-950/50 text-amber-200 [&>svg]:text-amber-500"> 86 - <AlertTitle>You have been logged out</AlertTitle> 87 - <AlertDescription> 88 - Your session has expired. Please sign in again to continue. 89 - </AlertDescription> 90 - </Alert> 91 - )} 81 + {/* Logged out message (session expired / 401 redirect) */} 82 + {reason === "session_expired" && ( 83 + <Alert className="mb-6 border-amber-800 bg-amber-950/50 text-amber-200 [&>svg]:text-amber-500"> 84 + <AlertTitle>You have been logged out</AlertTitle> 85 + <AlertDescription> 86 + Your session has expired. Please sign in again to continue. 87 + </AlertDescription> 88 + </Alert> 89 + )} 92 90 93 - {/* Error message */} 94 - {error && ( 95 - <div className="mb-6 p-4 bg-red-900/30 border border-red-800 rounded-lg flex items-start gap-3"> 96 - <AlertCircle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" /> 97 - <div className="text-red-200 text-sm"> 98 - {errorMessages[error] || 'An error occurred. Please try again.'} 99 - </div> 100 - </div> 101 - )} 91 + {/* Error message */} 92 + {error && ( 93 + <div className="mb-6 p-4 bg-red-900/30 border border-red-800 rounded-lg flex items-start gap-3"> 94 + <AlertCircle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" /> 95 + <div className="text-red-200 text-sm"> 96 + {errorMessages[error] || "An error occurred. Please try again."} 97 + </div> 98 + </div> 99 + )} 102 100 103 - {/* Login form */} 104 - <form onSubmit={handleSubmit} className="space-y-6"> 105 - <div> 106 - <label 107 - htmlFor={handleId} 108 - className="block text-sm font-medium text-gray-300 mb-2" 109 - > 110 - Bluesky Handle 111 - </label> 112 - <input 113 - id={handleId} 114 - type="text" 115 - value={handle} 116 - onChange={(e) => setHandle(e.target.value)} 117 - placeholder="username.bsky.social" 118 - className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors" 119 - disabled={isSubmitting} 120 - /> 121 - </div> 101 + {/* Login form */} 102 + <form onSubmit={handleSubmit} className="space-y-6"> 103 + <div> 104 + <label 105 + htmlFor={handleId} 106 + className="block text-sm font-medium text-gray-300 mb-2" 107 + > 108 + Bluesky Handle 109 + </label> 110 + <input 111 + id={handleId} 112 + type="text" 113 + value={handle} 114 + onChange={(e) => setHandle(e.target.value)} 115 + placeholder="username.bsky.social" 116 + className="w-full px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors" 117 + disabled={isSubmitting} 118 + /> 119 + </div> 122 120 123 - <button 124 - type="submit" 125 - disabled={isSubmitting} 126 - className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors" 127 - > 128 - {isSubmitting ? ( 129 - <> 130 - <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" /> 131 - <span>Redirecting...</span> 132 - </> 133 - ) : ( 134 - <> 135 - <LogIn size={20} /> 136 - <span>Sign in with Bluesky</span> 137 - </> 138 - )} 139 - </button> 121 + <button 122 + type="submit" 123 + disabled={isSubmitting} 124 + className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors" 125 + > 126 + {isSubmitting ? ( 127 + <> 128 + <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" /> 129 + <span>Redirecting...</span> 130 + </> 131 + ) : ( 132 + <> 133 + <LogIn size={20} /> 134 + <span>Sign in with Bluesky</span> 135 + </> 136 + )} 137 + </button> 140 138 141 - <p className="text-center text-sm text-gray-400"> 142 - Don&apos;t have an account?{' '} 143 - <a 144 - href="https://bsky.app/" 145 - target="_blank" 146 - rel="noopener noreferrer" 147 - className="text-purple-400 hover:text-purple-300 underline underline-offset-2 transition-colors" 148 - > 149 - Sign up on Bluesky 150 - </a> 151 - </p> 152 - </form> 153 - </div> 154 - </div> 155 - </div> 156 - ); 139 + <p className="text-center text-sm text-gray-400"> 140 + Don&apos;t have an account?{" "} 141 + <a 142 + href="https://bsky.app/" 143 + target="_blank" 144 + rel="noopener noreferrer" 145 + className="text-purple-400 hover:text-purple-300 underline underline-offset-2 transition-colors" 146 + > 147 + Sign up on Bluesky 148 + </a> 149 + </p> 150 + </form> 151 + </div> 152 + </div> 153 + </div> 154 + ); 157 155 }
+184 -177
apps/web/src/routes/search.tsx
··· 1 - import { createFileRoute, useNavigate } from '@tanstack/react-router'; 2 - import { useState, useEffect, useRef, useMemo } from 'react'; 3 - import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 4 - import { authControllerMeOptions, moviesControllerGetUserMoviesOptions, moviesControllerSearchMoviesOptions, moviesControllerMarkWatchedMutation, moviesControllerUnmarkWatchedMutation } from '@opnshelf/api'; 5 - import { Search, Check, Plus } from 'lucide-react'; 1 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 + import { useState, useEffect, useRef, useMemo } from "react"; 3 + import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 4 + import { 5 + authControllerMeOptions, 6 + moviesControllerGetUserMoviesOptions, 7 + moviesControllerSearchMoviesOptions, 8 + moviesControllerMarkWatchedMutation, 9 + moviesControllerUnmarkWatchedMutation, 10 + } from "@opnshelf/api"; 11 + import { Search, Check, Plus } from "lucide-react"; 6 12 7 - export const Route = createFileRoute('/search')({ 8 - component: SearchPage, 9 - validateSearch: (search: Record<string, unknown>) => ({ 10 - q: (search.q as string) || '', 11 - }), 13 + export const Route = createFileRoute("/search")({ 14 + component: SearchPage, 15 + validateSearch: (search: Record<string, unknown>) => ({ 16 + q: (search.q as string) || "", 17 + }), 12 18 }); 13 19 14 20 const DEBOUNCE_MS = 300; 15 21 16 22 function SearchPage() { 17 - const { q: searchQuery } = Route.useSearch(); 18 - const navigate = useNavigate({ from: Route.fullPath }); 19 - const queryClient = useQueryClient(); 20 - const [query, setQuery] = useState(searchQuery); 21 - const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 23 + const { q: searchQuery } = Route.useSearch(); 24 + const navigate = useNavigate({ from: Route.fullPath }); 25 + const queryClient = useQueryClient(); 26 + const [query, setQuery] = useState(searchQuery); 27 + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 28 + 29 + // Fetch auth state using generated TanStack Query hook 30 + const { data: user } = useQuery({ 31 + ...authControllerMeOptions(), 32 + staleTime: 5 * 60 * 1000, 33 + retry: false, 34 + }); 22 35 23 - // Fetch auth state using generated TanStack Query hook 24 - const { data: user } = useQuery({ 25 - ...authControllerMeOptions(), 26 - staleTime: 5 * 60 * 1000, 27 - retry: false, 28 - }); 36 + // Fetch user's tracked movies when logged in using generated TanStack Query hook 37 + const { data: trackedMovies } = useQuery({ 38 + ...moviesControllerGetUserMoviesOptions({ 39 + path: { userDid: user?.did || "" }, 40 + }), 41 + enabled: !!user?.did, 42 + }); 29 43 30 - // Fetch user's tracked movies when logged in using generated TanStack Query hook 31 - const { data: trackedMovies } = useQuery({ 32 - ...moviesControllerGetUserMoviesOptions({ 33 - path: { userDid: user?.did || '' }, 34 - }), 35 - enabled: !!user?.did, 36 - }); 44 + // Build a set of watched movie IDs for fast lookup 45 + const watchedMovieIds = useMemo(() => { 46 + if (!trackedMovies) return new Set<string>(); 47 + return new Set(trackedMovies.map((m: { movieId: string }) => m.movieId)); 48 + }, [trackedMovies]); 37 49 38 - // Build a set of watched movie IDs for fast lookup 39 - const watchedMovieIds = useMemo(() => { 40 - if (!trackedMovies) return new Set<string>(); 41 - return new Set(trackedMovies.map((m: { movieId: string }) => m.movieId)); 42 - }, [trackedMovies]); 50 + // Mutation for marking as watched using generated TanStack Query hook 51 + const markMutation = useMutation({ 52 + ...moviesControllerMarkWatchedMutation(), 53 + onSuccess: () => { 54 + queryClient.invalidateQueries({ queryKey: ["shelf"] }); 55 + }, 56 + }); 43 57 44 - // Mutation for marking as watched using generated TanStack Query hook 45 - const markMutation = useMutation({ 46 - ...moviesControllerMarkWatchedMutation(), 47 - onSuccess: () => { 48 - queryClient.invalidateQueries({ queryKey: ['shelf'] }); 49 - }, 50 - }); 58 + // Mutation for unmarking as watched using generated TanStack Query hook 59 + const unmarkMutation = useMutation({ 60 + ...moviesControllerUnmarkWatchedMutation(), 61 + onSuccess: () => { 62 + queryClient.invalidateQueries({ queryKey: ["shelf"] }); 63 + }, 64 + }); 51 65 52 - // Mutation for unmarking as watched using generated TanStack Query hook 53 - const unmarkMutation = useMutation({ 54 - ...moviesControllerUnmarkWatchedMutation(), 55 - onSuccess: () => { 56 - queryClient.invalidateQueries({ queryKey: ['shelf'] }); 57 - }, 58 - }); 66 + // Sync input with URL when navigating back/forward 67 + useEffect(() => { 68 + setQuery(searchQuery); 69 + }, [searchQuery]); 59 70 60 - // Sync input with URL when navigating back/forward 61 - useEffect(() => { 62 - setQuery(searchQuery); 63 - }, [searchQuery]); 71 + // Debounced navigation when query changes 72 + useEffect(() => { 73 + if (debounceRef.current) { 74 + clearTimeout(debounceRef.current); 75 + } 64 76 65 - // Debounced navigation when query changes 66 - useEffect(() => { 67 - if (debounceRef.current) { 68 - clearTimeout(debounceRef.current); 69 - } 77 + const trimmed = query.trim(); 78 + if (trimmed !== searchQuery) { 79 + debounceRef.current = setTimeout(() => { 80 + navigate({ search: { q: trimmed } }); 81 + }, DEBOUNCE_MS); 82 + } 70 83 71 - const trimmed = query.trim(); 72 - if (trimmed !== searchQuery) { 73 - debounceRef.current = setTimeout(() => { 74 - navigate({ search: { q: trimmed } }); 75 - }, DEBOUNCE_MS); 76 - } 84 + return () => { 85 + if (debounceRef.current) { 86 + clearTimeout(debounceRef.current); 87 + } 88 + }; 89 + }, [query, searchQuery, navigate]); 77 90 78 - return () => { 79 - if (debounceRef.current) { 80 - clearTimeout(debounceRef.current); 81 - } 82 - }; 83 - }, [query, searchQuery, navigate]); 91 + // Search movies using generated TanStack Query hook 92 + const { data, isLoading, error } = useQuery({ 93 + ...moviesControllerSearchMoviesOptions({ 94 + query: { query: searchQuery }, 95 + }), 96 + enabled: searchQuery.length > 0, 97 + }); 84 98 85 - // Search movies using generated TanStack Query hook 86 - const { data, isLoading, error } = useQuery({ 87 - ...moviesControllerSearchMoviesOptions({ 88 - query: { query: searchQuery }, 89 - }), 90 - enabled: searchQuery.length > 0, 91 - }); 99 + const isPending = markMutation.isPending || unmarkMutation.isPending; 92 100 93 - const isPending = markMutation.isPending || unmarkMutation.isPending; 101 + return ( 102 + <div className="min-h-screen bg-gray-950 text-gray-50"> 103 + <div className="container mx-auto px-4 py-8 max-w-7xl"> 104 + <h1 className="text-4xl font-bold mb-8">Search Movies</h1> 94 105 95 - return ( 96 - <div className="min-h-screen bg-gray-950 text-gray-50"> 97 - <div className="container mx-auto px-4 py-8 max-w-7xl"> 98 - <h1 className="text-4xl font-bold mb-8">Search Movies</h1> 99 - 100 - <div className="mb-8"> 101 - <div className="relative max-w-2xl"> 102 - <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> 103 - <input 104 - type="text" 105 - value={query} 106 - onChange={(e) => setQuery(e.target.value)} 107 - placeholder="Search for a movie..." 108 - className="w-full pl-12 pr-4 py-3 bg-gray-900 border border-gray-800 rounded-lg text-gray-50 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent" 109 - /> 110 - </div> 111 - </div> 106 + <div className="mb-8"> 107 + <div className="relative max-w-2xl"> 108 + <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> 109 + <input 110 + type="text" 111 + value={query} 112 + onChange={(e) => setQuery(e.target.value)} 113 + placeholder="Search for a movie..." 114 + className="w-full pl-12 pr-4 py-3 bg-gray-900 border border-gray-800 rounded-lg text-gray-50 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent" 115 + /> 116 + </div> 117 + </div> 112 118 113 - {isLoading && ( 114 - <div className="flex justify-center py-12"> 115 - <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-500"></div> 116 - </div> 117 - )} 119 + {isLoading && ( 120 + <div className="flex justify-center py-12"> 121 + <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-500"></div> 122 + </div> 123 + )} 118 124 119 - {error && ( 120 - <div className="bg-red-900/20 border border-red-900 text-red-400 px-4 py-3 rounded-lg"> 121 - Error: {error.message} 122 - </div> 123 - )} 125 + {error && ( 126 + <div className="bg-red-900/20 border border-red-900 text-red-400 px-4 py-3 rounded-lg"> 127 + Error: {error.message} 128 + </div> 129 + )} 124 130 125 - {data && data.results.length > 0 && ( 126 - <div> 127 - <p className="text-gray-400 mb-6"> 128 - Found {data.total_results.toLocaleString()} results 129 - </p> 130 - <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> 131 - {data.results.map((movie) => { 132 - const movieId = movie.id.toString(); 133 - const isWatched = watchedMovieIds.has(movieId); 131 + {data && data.results.length > 0 && ( 132 + <div> 133 + <p className="text-gray-400 mb-6"> 134 + Found {data.total_results.toLocaleString()} results 135 + </p> 136 + <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> 137 + {data.results.map((movie) => { 138 + const movieId = movie.id.toString(); 139 + const isWatched = watchedMovieIds.has(movieId); 134 140 135 - return ( 136 - <div 137 - key={movie.id} 138 - className="group" 139 - > 140 - <div className="relative aspect-2/3 bg-gray-900 rounded-lg overflow-hidden mb-2"> 141 - {movie.poster_path ? ( 142 - <img 143 - src={`https://image.tmdb.org/t/p/w342${movie.poster_path}`} 144 - alt={movie.title} 145 - className="w-full h-full object-cover" 146 - /> 147 - ) : ( 148 - <div className="w-full h-full flex items-center justify-center text-gray-600"> 149 - No poster 150 - </div> 151 - )} 152 - {user && ( 153 - <button 154 - type="button" 155 - onClick={() => { 156 - if (isWatched) { 157 - unmarkMutation.mutate({ path: { movieId } }); 158 - } else { 159 - markMutation.mutate({ body: { movieId } }); 160 - } 161 - }} 162 - disabled={isPending} 163 - className={`absolute top-2 right-2 p-2 rounded-full transition-opacity disabled:opacity-50 ${ 164 - isWatched 165 - ? 'bg-green-600 hover:bg-red-600 opacity-100' 166 - : 'bg-purple-600 hover:bg-purple-700 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100' 167 - }`} 168 - title={isWatched ? 'Remove from shelf' : 'Mark as watched'} 169 - > 170 - {isWatched ? ( 171 - <Check className="w-4 h-4" /> 172 - ) : ( 173 - <Plus className="w-4 h-4" /> 174 - )} 175 - </button> 176 - )} 177 - </div> 178 - <h3 className="font-semibold text-sm line-clamp-2 mb-1"> 179 - {movie.title} 180 - </h3> 181 - {movie.release_date && ( 182 - <p className="text-gray-500 text-sm"> 183 - {movie.release_date.split('-')[0]} 184 - </p> 185 - )} 186 - </div> 187 - ); 188 - })} 189 - </div> 190 - </div> 191 - )} 141 + return ( 142 + <div key={movie.id} className="group"> 143 + <div className="relative aspect-2/3 bg-gray-900 rounded-lg overflow-hidden mb-2"> 144 + {movie.poster_path ? ( 145 + <img 146 + src={`https://image.tmdb.org/t/p/w342${movie.poster_path}`} 147 + alt={movie.title} 148 + className="w-full h-full object-cover" 149 + /> 150 + ) : ( 151 + <div className="w-full h-full flex items-center justify-center text-gray-600"> 152 + No poster 153 + </div> 154 + )} 155 + {user && ( 156 + <button 157 + type="button" 158 + onClick={() => { 159 + if (isWatched) { 160 + unmarkMutation.mutate({ path: { movieId } }); 161 + } else { 162 + markMutation.mutate({ body: { movieId } }); 163 + } 164 + }} 165 + disabled={isPending} 166 + className={`absolute top-2 right-2 p-2 rounded-full transition-opacity disabled:opacity-50 ${ 167 + isWatched 168 + ? "bg-green-600 hover:bg-red-600 opacity-100" 169 + : "bg-purple-600 hover:bg-purple-700 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100" 170 + }`} 171 + title={ 172 + isWatched ? "Remove from shelf" : "Mark as watched" 173 + } 174 + > 175 + {isWatched ? ( 176 + <Check className="w-4 h-4" /> 177 + ) : ( 178 + <Plus className="w-4 h-4" /> 179 + )} 180 + </button> 181 + )} 182 + </div> 183 + <h3 className="font-semibold text-sm line-clamp-2 mb-1"> 184 + {movie.title} 185 + </h3> 186 + {movie.release_date && ( 187 + <p className="text-gray-500 text-sm"> 188 + {movie.release_date.split("-")[0]} 189 + </p> 190 + )} 191 + </div> 192 + ); 193 + })} 194 + </div> 195 + </div> 196 + )} 192 197 193 - {data && data.results.length === 0 && searchQuery && ( 194 - <div className="text-center py-12"> 195 - <p className="text-gray-400 text-lg">No results found for "{searchQuery}"</p> 196 - </div> 197 - )} 198 - </div> 199 - </div> 200 - ); 198 + {data && data.results.length === 0 && searchQuery && ( 199 + <div className="text-center py-12"> 200 + <p className="text-gray-400 text-lg"> 201 + No results found for "{searchQuery}" 202 + </p> 203 + </div> 204 + )} 205 + </div> 206 + </div> 207 + ); 201 208 }
+140 -134
apps/web/src/routes/shelf.tsx
··· 1 - import { createFileRoute, Link } from '@tanstack/react-router'; 2 - import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 3 - import { authControllerMeOptions, moviesControllerGetUserMoviesOptions, moviesControllerUnmarkWatchedMutation } from '@opnshelf/api'; 4 - import { BookOpen, Trash2, LogIn } from 'lucide-react'; 1 + import { createFileRoute, Link } from "@tanstack/react-router"; 2 + import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 3 + import { 4 + authControllerMeOptions, 5 + moviesControllerGetUserMoviesOptions, 6 + moviesControllerUnmarkWatchedMutation, 7 + } from "@opnshelf/api"; 8 + import { BookOpen, Trash2, LogIn } from "lucide-react"; 5 9 6 - export const Route = createFileRoute('/shelf')({ 7 - component: ShelfPage, 10 + export const Route = createFileRoute("/shelf")({ 11 + component: ShelfPage, 8 12 }); 9 13 10 14 function ShelfPage() { 11 - const queryClient = useQueryClient(); 15 + const queryClient = useQueryClient(); 12 16 13 - // Fetch auth state using generated TanStack Query hook 14 - const { data: user, isLoading: isAuthLoading } = useQuery({ 15 - ...authControllerMeOptions(), 16 - staleTime: 5 * 60 * 1000, 17 - retry: false, 18 - }); 17 + // Fetch auth state using generated TanStack Query hook 18 + const { data: user, isLoading: isAuthLoading } = useQuery({ 19 + ...authControllerMeOptions(), 20 + staleTime: 5 * 60 * 1000, 21 + retry: false, 22 + }); 19 23 20 - // Fetch user's tracked movies using generated TanStack Query hook 21 - const { data: trackedMovies, isLoading: isMoviesLoading } = useQuery({ 22 - ...moviesControllerGetUserMoviesOptions({ 23 - path: { userDid: user?.did || '' }, 24 - }), 25 - enabled: !!user?.did, 26 - }); 24 + // Fetch user's tracked movies using generated TanStack Query hook 25 + const { data: trackedMovies, isLoading: isMoviesLoading } = useQuery({ 26 + ...moviesControllerGetUserMoviesOptions({ 27 + path: { userDid: user?.did || "" }, 28 + }), 29 + enabled: !!user?.did, 30 + }); 27 31 28 - // Mutation for removing from shelf using generated TanStack Query hook 29 - const unmarkMutation = useMutation({ 30 - ...moviesControllerUnmarkWatchedMutation(), 31 - onSuccess: () => { 32 - queryClient.invalidateQueries({ queryKey: ['shelf'] }); 33 - }, 34 - }); 32 + // Mutation for removing from shelf using generated TanStack Query hook 33 + const unmarkMutation = useMutation({ 34 + ...moviesControllerUnmarkWatchedMutation(), 35 + onSuccess: () => { 36 + queryClient.invalidateQueries({ queryKey: ["shelf"] }); 37 + }, 38 + }); 35 39 36 - if (isAuthLoading) { 37 - return ( 38 - <div className="min-h-screen bg-gray-950 text-gray-50"> 39 - <div className="container mx-auto px-4 py-8 max-w-7xl"> 40 - <div className="flex justify-center py-12"> 41 - <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-500"></div> 42 - </div> 43 - </div> 44 - </div> 45 - ); 46 - } 40 + if (isAuthLoading) { 41 + return ( 42 + <div className="min-h-screen bg-gray-950 text-gray-50"> 43 + <div className="container mx-auto px-4 py-8 max-w-7xl"> 44 + <div className="flex justify-center py-12"> 45 + <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-500"></div> 46 + </div> 47 + </div> 48 + </div> 49 + ); 50 + } 47 51 48 - if (!user) { 49 - return ( 50 - <div className="min-h-screen bg-gray-950 text-gray-50"> 51 - <div className="container mx-auto px-4 py-16 max-w-4xl"> 52 - <div className="text-center"> 53 - <BookOpen className="w-16 h-16 text-purple-500 mx-auto mb-6" /> 54 - <h1 className="text-4xl font-bold mb-4">My Shelf</h1> 55 - <p className="text-xl text-gray-400 mb-8"> 56 - Sign in to track movies you've watched 57 - </p> 58 - <Link 59 - to="/login" 60 - className="inline-flex items-center gap-2 px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition-colors" 61 - > 62 - <LogIn className="w-5 h-5" /> 63 - Sign in with Bluesky 64 - </Link> 65 - </div> 66 - </div> 67 - </div> 68 - ); 69 - } 52 + if (!user) { 53 + return ( 54 + <div className="min-h-screen bg-gray-950 text-gray-50"> 55 + <div className="container mx-auto px-4 py-16 max-w-4xl"> 56 + <div className="text-center"> 57 + <BookOpen className="w-16 h-16 text-purple-500 mx-auto mb-6" /> 58 + <h1 className="text-4xl font-bold mb-4">My Shelf</h1> 59 + <p className="text-xl text-gray-400 mb-8"> 60 + Sign in to track movies you've watched 61 + </p> 62 + <Link 63 + to="/login" 64 + className="inline-flex items-center gap-2 px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition-colors" 65 + > 66 + <LogIn className="w-5 h-5" /> 67 + Sign in with Bluesky 68 + </Link> 69 + </div> 70 + </div> 71 + </div> 72 + ); 73 + } 70 74 71 - return ( 72 - <div className="min-h-screen bg-gray-950 text-gray-50"> 73 - <div className="container mx-auto px-4 py-8 max-w-7xl"> 74 - <div className="flex items-center gap-3 mb-8"> 75 - <BookOpen className="w-8 h-8 text-purple-500" /> 76 - <h1 className="text-4xl font-bold">My Shelf</h1> 77 - </div> 75 + return ( 76 + <div className="min-h-screen bg-gray-950 text-gray-50"> 77 + <div className="container mx-auto px-4 py-8 max-w-7xl"> 78 + <div className="flex items-center gap-3 mb-8"> 79 + <BookOpen className="w-8 h-8 text-purple-500" /> 80 + <h1 className="text-4xl font-bold">My Shelf</h1> 81 + </div> 78 82 79 - {isMoviesLoading && ( 80 - <div className="flex justify-center py-12"> 81 - <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-500"></div> 82 - </div> 83 - )} 83 + {isMoviesLoading && ( 84 + <div className="flex justify-center py-12"> 85 + <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-500"></div> 86 + </div> 87 + )} 84 88 85 - {trackedMovies && trackedMovies.length > 0 && ( 86 - <div> 87 - <p className="text-gray-400 mb-6"> 88 - {trackedMovies.length} movie{trackedMovies.length !== 1 ? 's' : ''} watched 89 - </p> 90 - <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> 91 - {trackedMovies.map((tracked) => ( 92 - <div 93 - key={tracked.id} 94 - className="group relative" 95 - > 96 - <div className="relative aspect-2/3 bg-gray-900 rounded-lg overflow-hidden mb-2"> 97 - {tracked.movie.posterPath ? ( 98 - <img 99 - src={`https://image.tmdb.org/t/p/w342${tracked.movie.posterPath}`} 100 - alt={tracked.movie.title} 101 - className="w-full h-full object-cover" 102 - /> 103 - ) : ( 104 - <div className="w-full h-full flex items-center justify-center text-gray-600"> 105 - No poster 106 - </div> 107 - )} 108 - <button 109 - type="button" 110 - onClick={() => unmarkMutation.mutate({ path: { movieId: tracked.movieId } })} 111 - disabled={unmarkMutation.isPending} 112 - className="absolute top-2 right-2 p-2 bg-red-600 hover:bg-red-700 rounded-full [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity disabled:opacity-50" 113 - title="Remove from shelf" 114 - > 115 - <Trash2 className="w-4 h-4" /> 116 - </button> 117 - </div> 118 - <h3 className="font-semibold text-sm line-clamp-2 mb-1"> 119 - {tracked.movie.title} 120 - </h3> 121 - {tracked.movie.releaseYear && ( 122 - <p className="text-gray-500 text-sm"> 123 - {tracked.movie.releaseYear} 124 - </p> 125 - )} 126 - </div> 127 - ))} 128 - </div> 129 - </div> 130 - )} 89 + {trackedMovies && trackedMovies.length > 0 && ( 90 + <div> 91 + <p className="text-gray-400 mb-6"> 92 + {trackedMovies.length} movie 93 + {trackedMovies.length !== 1 ? "s" : ""} watched 94 + </p> 95 + <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> 96 + {trackedMovies.map((tracked) => ( 97 + <div key={tracked.id} className="group relative"> 98 + <div className="relative aspect-2/3 bg-gray-900 rounded-lg overflow-hidden mb-2"> 99 + {tracked.movie.posterPath ? ( 100 + <img 101 + src={`https://image.tmdb.org/t/p/w342${tracked.movie.posterPath}`} 102 + alt={tracked.movie.title} 103 + className="w-full h-full object-cover" 104 + /> 105 + ) : ( 106 + <div className="w-full h-full flex items-center justify-center text-gray-600"> 107 + No poster 108 + </div> 109 + )} 110 + <button 111 + type="button" 112 + onClick={() => 113 + unmarkMutation.mutate({ 114 + path: { movieId: tracked.movieId }, 115 + }) 116 + } 117 + disabled={unmarkMutation.isPending} 118 + className="absolute top-2 right-2 p-2 bg-red-600 hover:bg-red-700 rounded-full [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity disabled:opacity-50" 119 + title="Remove from shelf" 120 + > 121 + <Trash2 className="w-4 h-4" /> 122 + </button> 123 + </div> 124 + <h3 className="font-semibold text-sm line-clamp-2 mb-1"> 125 + {tracked.movie.title} 126 + </h3> 127 + {tracked.movie.releaseYear && ( 128 + <p className="text-gray-500 text-sm"> 129 + {tracked.movie.releaseYear} 130 + </p> 131 + )} 132 + </div> 133 + ))} 134 + </div> 135 + </div> 136 + )} 131 137 132 - {trackedMovies && trackedMovies.length === 0 && ( 133 - <div className="text-center py-12"> 134 - <BookOpen className="w-16 h-16 text-gray-700 mx-auto mb-4" /> 135 - <p className="text-gray-400 text-lg mb-4">Your shelf is empty</p> 136 - <Link 137 - to="/search" 138 - search={{ q: '' }} 139 - className="inline-flex items-center gap-2 px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition-colors" 140 - > 141 - Search for movies 142 - </Link> 143 - </div> 144 - )} 145 - </div> 146 - </div> 147 - ); 138 + {trackedMovies && trackedMovies.length === 0 && ( 139 + <div className="text-center py-12"> 140 + <BookOpen className="w-16 h-16 text-gray-700 mx-auto mb-4" /> 141 + <p className="text-gray-400 text-lg mb-4">Your shelf is empty</p> 142 + <Link 143 + to="/search" 144 + search={{ q: "" }} 145 + className="inline-flex items-center gap-2 px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition-colors" 146 + > 147 + Search for movies 148 + </Link> 149 + </div> 150 + )} 151 + </div> 152 + </div> 153 + ); 148 154 }
+30 -30
apps/web/vite.config.ts
··· 1 - import { defineConfig } from 'vite' 2 - import { devtools } from '@tanstack/devtools-vite' 3 - import { tanstackStart } from '@tanstack/react-start/plugin/vite' 4 - import viteReact from '@vitejs/plugin-react' 5 - import viteTsConfigPaths from 'vite-tsconfig-paths' 6 - import { fileURLToPath, URL } from 'node:url' 1 + import { defineConfig } from "vite"; 2 + import { devtools } from "@tanstack/devtools-vite"; 3 + import { tanstackStart } from "@tanstack/react-start/plugin/vite"; 4 + import viteReact from "@vitejs/plugin-react"; 5 + import viteTsConfigPaths from "vite-tsconfig-paths"; 6 + import { fileURLToPath, URL } from "node:url"; 7 7 8 - import tailwindcss from '@tailwindcss/vite' 9 - import { nitro } from 'nitro/vite' 8 + import tailwindcss from "@tailwindcss/vite"; 9 + import { nitro } from "nitro/vite"; 10 10 11 11 const config = defineConfig({ 12 - server: { 13 - port: 3000, 14 - host: true, // listen on 0.0.0.0 so both localhost and 127.0.0.1 work 15 - }, 16 - resolve: { 17 - alias: { 18 - '@': fileURLToPath(new URL('./src', import.meta.url)), 19 - }, 20 - }, 21 - plugins: [ 22 - devtools(), 23 - nitro(), 24 - // this is the plugin that enables path aliases 25 - viteTsConfigPaths({ 26 - projects: ['./tsconfig.json'], 27 - }), 28 - tailwindcss(), 29 - tanstackStart(), 30 - viteReact(), 31 - ], 32 - }) 12 + server: { 13 + port: 3000, 14 + host: true, // listen on 0.0.0.0 so both localhost and 127.0.0.1 work 15 + }, 16 + resolve: { 17 + alias: { 18 + "@": fileURLToPath(new URL("./src", import.meta.url)), 19 + }, 20 + }, 21 + plugins: [ 22 + devtools(), 23 + nitro(), 24 + // this is the plugin that enables path aliases 25 + viteTsConfigPaths({ 26 + projects: ["./tsconfig.json"], 27 + }), 28 + tailwindcss(), 29 + tanstackStart(), 30 + viteReact(), 31 + ], 32 + }); 33 33 34 - export default config 34 + export default config;