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 new user onboarding flow

Route new users through onboarding after auth and
let them import Trakt history and Bluesky follows.

Also prevent auth-state flashes and tighten mobile
grid layouts across key pages.

+732 -40
+1 -1
apps/web/src/components/Header.tsx
··· 36 36 const { user, isAuthenticated, isLoading, logout } = useAuth(); 37 37 const { open: searchOpen, setOpen: setSearchOpen } = useSearchDialog(); 38 38 39 - const visibleNavigation = isAuthenticated ? navigation : []; 39 + const visibleNavigation = isAuthenticated || isLoading ? navigation : []; 40 40 41 41 useEffect(() => { 42 42 const handleScroll = () => {
+1 -1
apps/web/src/components/PersonGrid.tsx
··· 45 45 {uniquePeople.length === 0 ? ( 46 46 <p className="text-(--foreground-muted) text-sm">{emptyMessage}</p> 47 47 ) : ( 48 - <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 48 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-3"> 49 49 {uniquePeople.map((person) => ( 50 50 <Link 51 51 key={person.id}
+1 -1
apps/web/src/components/SimilarMediaGrid.tsx
··· 23 23 return ( 24 24 <section> 25 25 <h2 className="mb-4 text-display-3">{title}</h2> 26 - <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> 26 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-4"> 27 27 {items.map((item) => ( 28 28 <MediaCard 29 29 key={item.id}
+21
apps/web/src/routeTree.gen.ts
··· 11 11 import { Route as rootRouteImport } from './routes/__root' 12 12 import { Route as SettingsRouteImport } from './routes/settings' 13 13 import { Route as SearchRouteImport } from './routes/search' 14 + import { Route as OnboardingRouteImport } from './routes/onboarding' 14 15 import { Route as LoginRouteImport } from './routes/login' 15 16 import { Route as FollowingRouteImport } from './routes/following' 16 17 import { Route as DashboardRouteImport } from './routes/dashboard' ··· 42 43 const SearchRoute = SearchRouteImport.update({ 43 44 id: '/search', 44 45 path: '/search', 46 + getParentRoute: () => rootRouteImport, 47 + } as any) 48 + const OnboardingRoute = OnboardingRouteImport.update({ 49 + id: '/onboarding', 50 + path: '/onboarding', 45 51 getParentRoute: () => rootRouteImport, 46 52 } as any) 47 53 const LoginRoute = LoginRouteImport.update({ ··· 171 177 '/dashboard': typeof DashboardRoute 172 178 '/following': typeof FollowingRoute 173 179 '/login': typeof LoginRoute 180 + '/onboarding': typeof OnboardingRoute 174 181 '/search': typeof SearchRoute 175 182 '/settings': typeof SettingsRoute 176 183 '/auth/complete': typeof AuthCompleteRoute ··· 197 204 '/dashboard': typeof DashboardRoute 198 205 '/following': typeof FollowingRoute 199 206 '/login': typeof LoginRoute 207 + '/onboarding': typeof OnboardingRoute 200 208 '/search': typeof SearchRoute 201 209 '/settings': typeof SettingsRoute 202 210 '/auth/complete': typeof AuthCompleteRoute ··· 220 228 '/dashboard': typeof DashboardRoute 221 229 '/following': typeof FollowingRoute 222 230 '/login': typeof LoginRoute 231 + '/onboarding': typeof OnboardingRoute 223 232 '/search': typeof SearchRoute 224 233 '/settings': typeof SettingsRoute 225 234 '/auth/complete': typeof AuthCompleteRoute ··· 248 257 | '/dashboard' 249 258 | '/following' 250 259 | '/login' 260 + | '/onboarding' 251 261 | '/search' 252 262 | '/settings' 253 263 | '/auth/complete' ··· 274 284 | '/dashboard' 275 285 | '/following' 276 286 | '/login' 287 + | '/onboarding' 277 288 | '/search' 278 289 | '/settings' 279 290 | '/auth/complete' ··· 296 307 | '/dashboard' 297 308 | '/following' 298 309 | '/login' 310 + | '/onboarding' 299 311 | '/search' 300 312 | '/settings' 301 313 | '/auth/complete' ··· 323 335 DashboardRoute: typeof DashboardRoute 324 336 FollowingRoute: typeof FollowingRoute 325 337 LoginRoute: typeof LoginRoute 338 + OnboardingRoute: typeof OnboardingRoute 326 339 SearchRoute: typeof SearchRoute 327 340 SettingsRoute: typeof SettingsRoute 328 341 AuthCompleteRoute: typeof AuthCompleteRoute ··· 346 359 path: '/search' 347 360 fullPath: '/search' 348 361 preLoaderRoute: typeof SearchRouteImport 362 + parentRoute: typeof rootRouteImport 363 + } 364 + '/onboarding': { 365 + id: '/onboarding' 366 + path: '/onboarding' 367 + fullPath: '/onboarding' 368 + preLoaderRoute: typeof OnboardingRouteImport 349 369 parentRoute: typeof rootRouteImport 350 370 } 351 371 '/login': { ··· 577 597 DashboardRoute: DashboardRoute, 578 598 FollowingRoute: FollowingRoute, 579 599 LoginRoute: LoginRoute, 600 + OnboardingRoute: OnboardingRoute, 580 601 SearchRoute: SearchRoute, 581 602 SettingsRoute: SettingsRoute, 582 603 AuthCompleteRoute: AuthCompleteRoute,
+4 -3
apps/web/src/routes/auth/complete.tsx
··· 42 42 // Refetch user data 43 43 queryClient 44 44 .fetchQuery(authControllerMeOptions()) 45 - .then(() => { 45 + .then((data) => { 46 46 setStatus("success"); 47 - // Redirect to home after a short delay 47 + // Redirect to onboarding for new users, otherwise home 48 + const redirectTo = data?.needsOnboarding ? "/onboarding" : "/dashboard"; 48 49 setTimeout(() => { 49 - window.location.href = "/"; 50 + window.location.href = redirectTo; 50 51 }, 1500); 51 52 }) 52 53 .catch((err) => {
+26 -20
apps/web/src/routes/dashboard.tsx
··· 124 124 const userTimezone = userSettings?.timezone; 125 125 const userTimeFormat = userSettings?.timeFormat; 126 126 127 - // Redirect to login if not authenticated 127 + // Redirect to login if not authenticated, or onboarding if needed 128 128 useEffect(() => { 129 - if (!authLoading && !isAuthenticated) { 130 - navigate({ to: "/login" }); 129 + if (!authLoading) { 130 + if (!isAuthenticated) { 131 + navigate({ to: "/login" }); 132 + } else if (user?.needsOnboarding) { 133 + navigate({ to: "/onboarding" }); 134 + } 131 135 } 132 - }, [authLoading, isAuthenticated, navigate]); 136 + }, [authLoading, isAuthenticated, user?.needsOnboarding, navigate]); 133 137 134 138 // Fetch user data from API 135 139 const { data: shelfData, isLoading: shelfLoading } = useUserShelf( ··· 308 312 </div> 309 313 310 314 {/* Stats Grid */} 311 - <div className="mb-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> 315 + <div className="mb-8 grid grid-cols-2 gap-4 lg:grid-cols-4"> 312 316 {isLoading 313 317 ? // Skeleton stats 314 318 [1, 2, 3, 4].map((i) => ( 315 - <div key={i} className="card animate-pulse p-5"> 316 - <div className="flex items-center justify-between"> 317 - <div className="space-y-2"> 318 - <div className="h-4 w-16 rounded bg-(--background-subtle)" /> 319 - <div className="h-8 w-12 rounded bg-(--background-subtle)" /> 320 - <div className="h-3 w-20 rounded bg-(--background-subtle)" /> 319 + <div key={i} className="card animate-pulse p-4 sm:p-5"> 320 + <div className="flex items-center gap-3 sm:items-center sm:justify-between"> 321 + <div className="order-1 h-10 w-10 rounded-lg bg-(--background-subtle) sm:order-2 sm:h-12 sm:w-12 sm:rounded-xl" /> 322 + <div className="order-2 space-y-1 sm:order-1 sm:space-y-2"> 323 + <div className="h-3 w-14 rounded bg-(--background-subtle) sm:h-4 sm:w-16" /> 324 + <div className="h-6 w-10 rounded bg-(--background-subtle) sm:h-8 sm:w-12" /> 325 + <div className="hidden h-3 w-20 rounded bg-(--background-subtle) sm:block" /> 321 326 </div> 322 - <div className="h-12 w-12 rounded-xl bg-(--background-subtle)" /> 323 327 </div> 324 328 </div> 325 329 )) ··· 328 332 return ( 329 333 <div 330 334 key={stat.label} 331 - className="card p-5" 335 + className="card p-4 sm:p-5" 332 336 style={{ animationDelay: `${index * 50}ms` }} 333 337 > 334 - <div className="flex items-center justify-between"> 335 - <div> 338 + <div className="flex items-center gap-3 sm:items-center sm:justify-between"> 339 + <div className="order-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-(--accent-subtle) text-(--accent) sm:order-2 sm:h-12 sm:w-12 sm:rounded-xl"> 340 + <Icon className="h-5 w-5 sm:h-6 sm:w-6" /> 341 + </div> 342 + <div className="order-2 sm:order-1"> 336 343 <p className="text-(--foreground-muted) text-sm"> 337 344 {stat.label} 338 345 </p> 339 - <p className="mt-1 text-display-3">{stat.value}</p> 340 - <p className="mt-1 text-(--accent) text-xs"> 346 + <p className="font-semibold text-lg sm:mt-1 sm:text-display-3"> 347 + {stat.value} 348 + </p> 349 + <p className="hidden text-(--accent) text-xs sm:mt-1 sm:block"> 341 350 {stat.change} 342 351 </p> 343 - </div> 344 - <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-(--accent-subtle) text-(--accent)"> 345 - <Icon className="h-6 w-6" /> 346 352 </div> 347 353 </div> 348 354 </div>
+16 -5
apps/web/src/routes/index.tsx
··· 10 10 Users, 11 11 } from "lucide-react"; 12 12 import { useEffect } from "react"; 13 + import LoadingState from "#/components/LoadingState"; 13 14 import { useAuth } from "#/lib/auth-context"; 14 15 15 16 export const Route = createFileRoute("/")({ ··· 17 18 }); 18 19 19 20 function LandingPage() { 20 - const { isAuthenticated, isLoading: authLoading } = useAuth(); 21 + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); 21 22 const navigate = useNavigate(); 22 23 23 - // Redirect to dashboard if already authenticated 24 + // Redirect to dashboard (or onboarding for new users) if already authenticated 24 25 useEffect(() => { 25 26 if (!authLoading && isAuthenticated) { 26 - navigate({ to: "/dashboard" }); 27 + if (user?.needsOnboarding) { 28 + navigate({ to: "/onboarding" }); 29 + } else { 30 + navigate({ to: "/dashboard" }); 31 + } 27 32 } 28 - }, [authLoading, isAuthenticated, navigate]); 33 + }, [authLoading, isAuthenticated, user?.needsOnboarding, navigate]); 34 + 35 + // Show loading while auth state is resolving to prevent 36 + // logged-out content from flashing for logged-in users 37 + if (authLoading) { 38 + return <LoadingState />; 39 + } 29 40 30 41 const features = [ 31 42 { ··· 126 137 </p> 127 138 </div> 128 139 129 - <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> 140 + <div className="grid grid-cols-2 gap-6 sm:grid-cols-2 lg:grid-cols-3"> 130 141 {features.map((feature) => { 131 142 const Icon = feature.icon; 132 143 return (
+19 -2
apps/web/src/routes/login.tsx
··· 3 3 useNavigate, 4 4 useSearch, 5 5 } from "@tanstack/react-router"; 6 - import { ArrowRight, Film, Loader2 } from "lucide-react"; 6 + import { AlertTriangle, ArrowRight, Film, Loader2 } from "lucide-react"; 7 7 import { useEffect, useState } from "react"; 8 + import LoadingState from "#/components/LoadingState"; 8 9 import { useAuth } from "#/lib/auth-context"; 9 10 10 11 export const Route = createFileRoute("/login")({ ··· 14 15 function LoginPage() { 15 16 const [handle, setHandle] = useState(""); 16 17 const [isLoading, setIsLoading] = useState(false); 17 - const { login, signup, isAuthenticated } = useAuth(); 18 + const { login, signup, isAuthenticated, isLoading: authLoading } = useAuth(); 18 19 const navigate = useNavigate(); 19 20 const search = useSearch({ from: "/login" }); 20 21 const message = (search as { message?: string }).message; ··· 25 26 navigate({ to: "/dashboard" }); 26 27 } 27 28 }, [isAuthenticated, navigate]); 29 + 30 + // Show loading while auth state is resolving to prevent 31 + // login form from flashing for logged-in users 32 + if (authLoading) { 33 + return <LoadingState />; 34 + } 28 35 29 36 const handleLogin = (e: React.FormEvent) => { 30 37 e.preventDefault(); ··· 51 58 <h1 className="text-display-2">Welcome to OpnShelf</h1> 52 59 <p className="mt-2 text-(--foreground-muted)"> 53 60 Track what you watch with your AT Protocol account 61 + </p> 62 + </div> 63 + 64 + {/* Development Warning */} 65 + <div className="mb-6 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-amber-800 text-sm dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-200"> 66 + <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" /> 67 + <p> 68 + <span className="font-semibold">Development Preview</span> — This 69 + app is still in development. Things will change and your data might 70 + be reset. 54 71 </p> 55 72 </div> 56 73
+636
apps/web/src/routes/onboarding.tsx
··· 1 + import { 2 + getTraktImportStatusMessage, 3 + getTraktImportStatusProgress, 4 + isActiveTraktImportStatus, 5 + isTerminalTraktImportStatus, 6 + type TraktImportStatusJob, 7 + usersControllerCompleteOnboarding, 8 + usersControllerFetchMyTraktPublicHistory, 9 + usersControllerGetMyCurrentTraktImportOptions, 10 + usersControllerImportMyBlueskyFollows, 11 + usersControllerStartMyTraktImport, 12 + } from "@opnshelf/api"; 13 + import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 14 + import { createFileRoute, useNavigate } from "@tanstack/react-router"; 15 + import { 16 + ArrowRight, 17 + CheckCircle, 18 + Film, 19 + Loader2, 20 + Tv, 21 + Upload, 22 + Users, 23 + X, 24 + } from "lucide-react"; 25 + import { useEffect, useRef, useState } from "react"; 26 + import { useAuth } from "#/lib/auth-context"; 27 + 28 + export const Route = createFileRoute("/onboarding")({ 29 + component: OnboardingPage, 30 + }); 31 + 32 + type OnboardingStep = "welcome" | "trakt" | "bluesky" | "done"; 33 + 34 + function TraktAvatar({ url, name }: { url?: string; name: string }) { 35 + const [error, setError] = useState(false); 36 + 37 + if (!url || error) { 38 + return ( 39 + <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-(--accent-subtle)"> 40 + <Tv className="size-5 text-(--accent)" /> 41 + </div> 42 + ); 43 + } 44 + 45 + return ( 46 + <img 47 + src={url} 48 + alt={name} 49 + className="h-10 w-10 shrink-0 rounded-full object-cover" 50 + referrerPolicy="no-referrer" 51 + onError={() => setError(true)} 52 + /> 53 + ); 54 + } 55 + 56 + function OnboardingPage() { 57 + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); 58 + const navigate = useNavigate(); 59 + const [step, setStep] = useState<OnboardingStep>("welcome"); 60 + const hasSetInitialStep = useRef(false); 61 + 62 + // Check for an existing Trakt import job on mount 63 + const { data: existingImport, isLoading: checkingImport } = useQuery({ 64 + ...usersControllerGetMyCurrentTraktImportOptions(), 65 + enabled: !authLoading && isAuthenticated && !!user?.needsOnboarding, 66 + }); 67 + 68 + useEffect(() => { 69 + if (hasSetInitialStep.current || authLoading || checkingImport) return; 70 + hasSetInitialStep.current = true; 71 + 72 + if (existingImport) { 73 + setStep("trakt"); 74 + } 75 + }, [authLoading, checkingImport, existingImport]); 76 + 77 + // Redirect unauthenticated users to login 78 + useEffect(() => { 79 + if (!authLoading && !isAuthenticated) { 80 + navigate({ to: "/login" }); 81 + } 82 + }, [authLoading, isAuthenticated, navigate]); 83 + 84 + // Redirect already onboarded users to dashboard 85 + useEffect(() => { 86 + if (!authLoading && isAuthenticated && !user?.needsOnboarding) { 87 + navigate({ to: "/dashboard" }); 88 + } 89 + }, [authLoading, isAuthenticated, user?.needsOnboarding, navigate]); 90 + 91 + if (authLoading || checkingImport) { 92 + return ( 93 + <div className="container-app flex min-h-[calc(100vh-4rem)] items-center justify-center"> 94 + <Loader2 className="size-8 animate-spin text-(--accent)" /> 95 + </div> 96 + ); 97 + } 98 + 99 + return ( 100 + <div className="container-app flex min-h-[calc(100vh-4rem)] items-center justify-center py-12"> 101 + <div className="w-full max-w-lg"> 102 + {step === "welcome" && <WelcomeStep onNext={() => setStep("trakt")} />} 103 + {step === "trakt" && ( 104 + <TraktStep 105 + onNext={() => setStep("bluesky")} 106 + onSkip={() => setStep("bluesky")} 107 + /> 108 + )} 109 + {step === "bluesky" && ( 110 + <BlueskyStep 111 + onNext={() => setStep("done")} 112 + onSkip={() => setStep("done")} 113 + /> 114 + )} 115 + {step === "done" && <DoneStep />} 116 + </div> 117 + </div> 118 + ); 119 + } 120 + 121 + /* ------------------------------------------------------------------ 122 + Step 1: Welcome 123 + ------------------------------------------------------------------ */ 124 + function WelcomeStep({ onNext }: { onNext: () => void }) { 125 + return ( 126 + <div className="card p-8 text-center"> 127 + <div className="mb-6 flex justify-center"> 128 + <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-(--accent) text-[#3f2e00]"> 129 + <Film className="size-8" /> 130 + </div> 131 + </div> 132 + <h1 className="mb-3 text-display-2">Welcome to OpnShelf</h1> 133 + <p className="mx-auto mb-8 max-w-sm text-(--foreground-muted)"> 134 + Let&apos;s get you set up in just a few steps. You can import your watch 135 + history and connect with friends already here. 136 + </p> 137 + <button type="button" onClick={onNext} className="btn btn-primary w-full"> 138 + Get Started 139 + <ArrowRight className="size-4" /> 140 + </button> 141 + </div> 142 + ); 143 + } 144 + 145 + /* ------------------------------------------------------------------ 146 + Step 2: Trakt.tv Import 147 + ------------------------------------------------------------------ */ 148 + function TraktStep({ 149 + onNext, 150 + onSkip, 151 + }: { 152 + onNext: () => void; 153 + onSkip: () => void; 154 + }) { 155 + const [username, setUsername] = useState(""); 156 + const [previewData, setPreviewData] = useState<{ 157 + profile: { username: string; name?: string; avatarUrl?: string }; 158 + importableCount: number; 159 + previewItems: Array<{ 160 + type: "movie" | "episode"; 161 + title: string; 162 + subtitle?: string; 163 + watchedAt: string; 164 + }>; 165 + } | null>(null); 166 + const [jobData, setJobData] = useState<TraktImportStatusJob | null>(null); 167 + const [fetchError, setFetchError] = useState(""); 168 + const queryClient = useQueryClient(); 169 + 170 + const fetchPreview = useMutation({ 171 + mutationFn: async (body: { username: string }) => { 172 + const { data } = await usersControllerFetchMyTraktPublicHistory({ 173 + body, 174 + throwOnError: true, 175 + }); 176 + return data; 177 + }, 178 + onSuccess: (data) => { 179 + setPreviewData(data); 180 + setFetchError(""); 181 + }, 182 + onError: (error: unknown) => { 183 + const message = 184 + typeof error === "object" && error !== null && "message" in error 185 + ? String((error as { message?: string }).message) 186 + : "Could not fetch Trakt history. Please check the username and try again."; 187 + setFetchError(message); 188 + }, 189 + }); 190 + 191 + const startImport = useMutation({ 192 + mutationFn: async (body: { username: string }) => { 193 + const { data } = await usersControllerStartMyTraktImport({ 194 + body, 195 + throwOnError: true, 196 + }); 197 + return data; 198 + }, 199 + onSuccess: (data) => { 200 + if (data.job) { 201 + setJobData({ 202 + status: data.job.status, 203 + currentPage: data.job.currentPage, 204 + totalPages: data.job.totalPages, 205 + importedCount: data.job.importedCount, 206 + skippedCount: data.job.skippedCount, 207 + failedCount: data.job.failedCount, 208 + lastError: data.job.lastError, 209 + }); 210 + } 211 + }, 212 + }); 213 + 214 + // Poll current import status when there's an active job 215 + const { data: currentImport } = useQuery({ 216 + ...usersControllerGetMyCurrentTraktImportOptions(), 217 + enabled: !!jobData && isActiveTraktImportStatus(jobData.status), 218 + refetchInterval: 3000, 219 + }); 220 + 221 + useEffect(() => { 222 + if (currentImport) { 223 + setJobData({ 224 + status: currentImport.status, 225 + currentPage: currentImport.currentPage, 226 + totalPages: currentImport.totalPages, 227 + importedCount: currentImport.importedCount, 228 + skippedCount: currentImport.skippedCount, 229 + failedCount: currentImport.failedCount, 230 + lastError: currentImport.lastError, 231 + }); 232 + } 233 + }, [currentImport]); 234 + 235 + const handleFetch = () => { 236 + if (!username.trim()) return; 237 + setPreviewData(null); 238 + setJobData(null); 239 + fetchPreview.mutate({ username: username.trim() }); 240 + }; 241 + 242 + const handleStartImport = () => { 243 + if (!username.trim()) return; 244 + startImport.mutate({ username: username.trim() }); 245 + }; 246 + 247 + const isImportDone = jobData && isTerminalTraktImportStatus(jobData.status); 248 + const progress = jobData ? getTraktImportStatusProgress(jobData) : null; 249 + const statusMessage = jobData ? getTraktImportStatusMessage(jobData) : null; 250 + 251 + return ( 252 + <div className="card p-6"> 253 + <div className="mb-6 flex items-center justify-between"> 254 + <div> 255 + <h2 className="text-display-3">Import from Trakt</h2> 256 + <p className="mt-1 text-(--foreground-muted) text-sm"> 257 + Import your public watch history from Trakt.tv 258 + </p> 259 + </div> 260 + {!jobData && ( 261 + <button 262 + type="button" 263 + onClick={onSkip} 264 + className="text-(--foreground-muted) text-sm hover:text-(--foreground)" 265 + > 266 + Skip 267 + </button> 268 + )} 269 + </div> 270 + 271 + {/* Username input */} 272 + {!previewData && !jobData && ( 273 + <div className="space-y-4"> 274 + <div> 275 + <label 276 + htmlFor="trakt-username" 277 + className="mb-1.5 block font-medium text-sm" 278 + > 279 + Trakt Username 280 + </label> 281 + <input 282 + id="trakt-username" 283 + type="text" 284 + placeholder="your-trakt-username" 285 + value={username} 286 + onChange={(e) => setUsername(e.target.value)} 287 + className="input" 288 + onKeyDown={(e) => { 289 + if (e.key === "Enter") handleFetch(); 290 + }} 291 + /> 292 + </div> 293 + {fetchError && <p className="text-red-500 text-sm">{fetchError}</p>} 294 + <button 295 + type="button" 296 + onClick={handleFetch} 297 + disabled={fetchPreview.isPending || !username.trim()} 298 + className="btn btn-primary w-full" 299 + > 300 + {fetchPreview.isPending ? ( 301 + <> 302 + <Loader2 className="size-4 animate-spin" /> 303 + Fetching preview... 304 + </> 305 + ) : ( 306 + <> 307 + <Upload className="size-4" /> 308 + Preview History 309 + </> 310 + )} 311 + </button> 312 + </div> 313 + )} 314 + 315 + {/* Preview results */} 316 + {previewData && !jobData && ( 317 + <div className="space-y-4"> 318 + <div className="flex items-center gap-3 rounded-lg bg-(--background-subtle) p-3"> 319 + <TraktAvatar 320 + url={previewData.profile.avatarUrl} 321 + name={previewData.profile.username} 322 + /> 323 + <div> 324 + <p className="font-medium text-sm"> 325 + {previewData.profile.name || previewData.profile.username} 326 + </p> 327 + <p className="text-(--foreground-muted) text-xs"> 328 + @{previewData.profile.username} 329 + </p> 330 + </div> 331 + </div> 332 + 333 + {previewData.previewItems.length > 0 && ( 334 + <div className="space-y-2"> 335 + <p className="font-medium text-sm">Recent items</p> 336 + {previewData.previewItems.map((item) => ( 337 + <div 338 + key={`${item.title}-${item.watchedAt}`} 339 + className="flex items-center gap-3 rounded-lg border border-(--border) bg-(--background-elevated) p-3" 340 + > 341 + <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-(--accent-subtle)"> 342 + {item.type === "movie" ? ( 343 + <Film className="size-4 text-(--accent)" /> 344 + ) : ( 345 + <Tv className="size-4 text-(--accent)" /> 346 + )} 347 + </div> 348 + <div className="min-w-0 flex-1"> 349 + <p className="truncate text-sm">{item.title}</p> 350 + {item.subtitle && ( 351 + <p className="truncate text-(--foreground-muted) text-xs"> 352 + {item.subtitle} 353 + </p> 354 + )} 355 + </div> 356 + </div> 357 + ))} 358 + </div> 359 + )} 360 + 361 + <div className="flex gap-3"> 362 + <button 363 + type="button" 364 + onClick={() => { 365 + setPreviewData(null); 366 + setUsername(""); 367 + }} 368 + className="btn btn-secondary flex-1" 369 + > 370 + <X className="size-4" /> 371 + Change 372 + </button> 373 + <button 374 + type="button" 375 + onClick={handleStartImport} 376 + disabled={startImport.isPending} 377 + className="btn btn-primary flex-1" 378 + > 379 + {startImport.isPending ? ( 380 + <Loader2 className="size-4 animate-spin" /> 381 + ) : ( 382 + <> 383 + <Upload className="size-4" /> 384 + Start Import 385 + </> 386 + )} 387 + </button> 388 + </div> 389 + </div> 390 + )} 391 + 392 + {/* Import progress */} 393 + {jobData && ( 394 + <div className="space-y-4"> 395 + <div className="flex items-center gap-3"> 396 + {isImportDone ? ( 397 + <CheckCircle className="size-5 text-green-500" /> 398 + ) : ( 399 + <Loader2 className="size-5 animate-spin text-(--accent)" /> 400 + )} 401 + <div> 402 + <p className="font-medium text-sm"> 403 + {isImportDone ? "Import finished" : "Importing..."} 404 + </p> 405 + {statusMessage && ( 406 + <p className="text-(--foreground-muted) text-xs"> 407 + {statusMessage} 408 + </p> 409 + )} 410 + </div> 411 + </div> 412 + 413 + {typeof progress === "number" && ( 414 + <div className="space-y-1"> 415 + <div className="h-2 w-full overflow-hidden rounded-full bg-(--background-subtle)"> 416 + <div 417 + className="h-full rounded-full bg-(--accent) transition-all duration-500" 418 + style={{ width: `${progress}%` }} 419 + /> 420 + </div> 421 + <p className="text-right text-(--foreground-muted) text-xs"> 422 + {progress}% 423 + </p> 424 + </div> 425 + )} 426 + 427 + {isImportDone && ( 428 + <button 429 + type="button" 430 + onClick={() => { 431 + setJobData(null); 432 + setPreviewData(null); 433 + setUsername(""); 434 + // Invalidate shelf so the user sees imported items 435 + queryClient.invalidateQueries({ queryKey: ["shelf"] }); 436 + onNext(); 437 + }} 438 + className="btn btn-primary w-full" 439 + > 440 + Continue 441 + <ArrowRight className="size-4" /> 442 + </button> 443 + )} 444 + </div> 445 + )} 446 + </div> 447 + ); 448 + } 449 + 450 + /* ------------------------------------------------------------------ 451 + Step 3: Bluesky Follows 452 + ------------------------------------------------------------------ */ 453 + function BlueskyStep({ 454 + onNext, 455 + onSkip, 456 + }: { 457 + onNext: () => void; 458 + onSkip: () => void; 459 + }) { 460 + const [result, setResult] = useState<{ 461 + scannedCount: number; 462 + matchedCount: number; 463 + createdCount: number; 464 + alreadyFollowingCount: number; 465 + } | null>(null); 466 + 467 + const importFollows = useMutation({ 468 + mutationFn: async () => { 469 + const { data } = await usersControllerImportMyBlueskyFollows({ 470 + throwOnError: true, 471 + }); 472 + return data; 473 + }, 474 + onSuccess: (data) => { 475 + setResult(data); 476 + }, 477 + }); 478 + 479 + return ( 480 + <div className="card p-6"> 481 + <div className="mb-6 flex items-center justify-between"> 482 + <div> 483 + <h2 className="text-display-3">Connect with Friends</h2> 484 + <p className="mt-1 text-(--foreground-muted) text-sm"> 485 + Import your Bluesky follows who are already on OpnShelf 486 + </p> 487 + </div> 488 + <button 489 + type="button" 490 + onClick={onSkip} 491 + className="text-(--foreground-muted) text-sm hover:text-(--foreground)" 492 + > 493 + Skip 494 + </button> 495 + </div> 496 + 497 + {!result && ( 498 + <div className="space-y-4"> 499 + <div className="flex items-center gap-3 rounded-lg bg-(--background-subtle) p-4"> 500 + <div className="flex h-10 w-10 items-center justify-center rounded-full bg-(--accent-subtle)"> 501 + <Users className="size-5 text-(--accent)" /> 502 + </div> 503 + <div> 504 + <p className="font-medium text-sm"> 505 + Find people you follow on Bluesky 506 + </p> 507 + <p className="text-(--foreground-muted) text-xs"> 508 + We&apos;ll scan your follows and auto-follow anyone already 509 + here. 510 + </p> 511 + </div> 512 + </div> 513 + 514 + <button 515 + type="button" 516 + onClick={() => importFollows.mutate()} 517 + disabled={importFollows.isPending} 518 + className="btn btn-primary w-full" 519 + > 520 + {importFollows.isPending ? ( 521 + <> 522 + <Loader2 className="size-4 animate-spin" /> 523 + Scanning follows... 524 + </> 525 + ) : ( 526 + <> 527 + <Users className="size-4" /> 528 + Import Bluesky Follows 529 + </> 530 + )} 531 + </button> 532 + </div> 533 + )} 534 + 535 + {result && ( 536 + <div className="space-y-4"> 537 + <div className="flex items-center gap-3"> 538 + <CheckCircle className="size-5 text-green-500" /> 539 + <p className="font-medium text-sm">Follows imported</p> 540 + </div> 541 + 542 + <div className="grid grid-cols-2 gap-3"> 543 + <div className="rounded-lg bg-(--background-subtle) p-3 text-center"> 544 + <p className="text-display-3">{result.scannedCount}</p> 545 + <p className="text-(--foreground-muted) text-xs">Scanned</p> 546 + </div> 547 + <div className="rounded-lg bg-(--background-subtle) p-3 text-center"> 548 + <p className="text-display-3">{result.matchedCount}</p> 549 + <p className="text-(--foreground-muted) text-xs">Matched</p> 550 + </div> 551 + <div className="rounded-lg bg-(--background-subtle) p-3 text-center"> 552 + <p className="text-display-3">{result.createdCount}</p> 553 + <p className="text-(--foreground-muted) text-xs">New follows</p> 554 + </div> 555 + <div className="rounded-lg bg-(--background-subtle) p-3 text-center"> 556 + <p className="text-display-3">{result.alreadyFollowingCount}</p> 557 + <p className="text-(--foreground-muted) text-xs"> 558 + Already following 559 + </p> 560 + </div> 561 + </div> 562 + 563 + <button 564 + type="button" 565 + onClick={onNext} 566 + className="btn btn-primary w-full" 567 + > 568 + Continue 569 + <ArrowRight className="size-4" /> 570 + </button> 571 + </div> 572 + )} 573 + </div> 574 + ); 575 + } 576 + 577 + /* ------------------------------------------------------------------ 578 + Step 4: Done 579 + ------------------------------------------------------------------ */ 580 + function DoneStep() { 581 + const navigate = useNavigate(); 582 + const queryClient = useQueryClient(); 583 + const { user } = useAuth(); 584 + 585 + const completeOnboarding = useMutation({ 586 + mutationFn: async () => { 587 + const { data } = await usersControllerCompleteOnboarding({ 588 + throwOnError: true, 589 + }); 590 + return data; 591 + }, 592 + onSuccess: () => { 593 + // Invalidate user data so needsOnboarding updates 594 + queryClient.invalidateQueries({ queryKey: ["authControllerMe"] }); 595 + // Give a moment then redirect 596 + setTimeout(() => { 597 + navigate({ to: "/dashboard" }); 598 + }, 800); 599 + }, 600 + }); 601 + 602 + useEffect(() => { 603 + // Auto-complete onboarding when this step mounts 604 + completeOnboarding.mutate(); 605 + }, [completeOnboarding.mutate]); 606 + 607 + return ( 608 + <div className="card p-8 text-center"> 609 + <div className="mb-6 flex justify-center"> 610 + <div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10"> 611 + <CheckCircle className="size-8 text-green-500" /> 612 + </div> 613 + </div> 614 + <h2 className="mb-2 text-display-2">You&apos;re all set!</h2> 615 + <p className="mx-auto mb-6 max-w-sm text-(--foreground-muted)"> 616 + Welcome to OpnShelf{user?.displayName ? `, ${user.displayName}` : ""}. 617 + Start tracking what you watch and discover what your friends are into. 618 + </p> 619 + {completeOnboarding.isPending ? ( 620 + <div className="flex items-center justify-center gap-2 text-(--foreground-muted) text-sm"> 621 + <Loader2 className="size-4 animate-spin" /> 622 + Finishing up... 623 + </div> 624 + ) : ( 625 + <button 626 + type="button" 627 + onClick={() => navigate({ to: "/dashboard" })} 628 + className="btn btn-primary inline-flex" 629 + > 630 + Go to Dashboard 631 + <ArrowRight className="size-4" /> 632 + </button> 633 + )} 634 + </div> 635 + ); 636 + }
+2 -2
apps/web/src/routes/people/$personId/$personName.tsx
··· 223 223 {knownForItems.length > 0 && ( 224 224 <section> 225 225 <h2 className="mb-4 text-display-3">Known For</h2> 226 - <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 226 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-3"> 227 227 {knownForItems.map((item) => ( 228 228 <ActionableMediaCard 229 229 key={`known-${item.id}-${item.media_type}`} ··· 257 257 </p> 258 258 ) : ( 259 259 <> 260 - <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> 260 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-4"> 261 261 {filmographyItems.map((item) => ( 262 262 <ActionableMediaCard 263 263 key={`film-${item.id}-${item.media_type}`}
+1 -1
apps/web/src/routes/profile.$handle/connections.tsx
··· 74 74 </p> 75 75 </div> 76 76 ) : ( 77 - <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 77 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-2 lg:grid-cols-3"> 78 78 {activeQuery.data.items.map((user) => ( 79 79 <Link 80 80 key={user.did}
+4 -4
apps/web/src/routes/search.tsx
··· 271 271 searchData?.results && 272 272 searchData.results.length > 0 && ( 273 273 <section> 274 - <div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 274 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 275 275 {searchData.results.map((item) => ( 276 276 <ActionableMediaCard 277 277 key={`media-${item.id}-${item.media_type}`} ··· 292 292 {/* Movies tab only */} 293 293 {activeTab === "movies" && movies.length > 0 && ( 294 294 <section> 295 - <div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 295 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 296 296 {movies.map((item) => ( 297 297 <ActionableMediaCard 298 298 key={`movie-${item.id}`} ··· 313 313 {/* TV Shows tab only */} 314 314 {activeTab === "shows" && shows.length > 0 && ( 315 315 <section> 316 - <div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 316 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"> 317 317 {shows.map((item) => ( 318 318 <ActionableMediaCard 319 319 key={`show-${item.id}`} ··· 360 360 </Link> 361 361 </div> 362 362 ) : people.length > 0 ? ( 363 - <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> 363 + <div className="grid grid-cols-2 gap-3 sm:grid-cols-2 lg:grid-cols-3"> 364 364 {people.map((person: SocialUserCardDto) => ( 365 365 <div 366 366 key={person.did}