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

Configure Feed

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

feat: rework onboarding

+871 -594
+539
apps/web/src/components/onboarding/onboarding-content.tsx
··· 1 + import { 2 + Check, 3 + CloudDownload, 4 + FileSpreadsheet, 5 + Sparkles, 6 + UserCircle2, 7 + WandSparkles, 8 + } from "lucide-react"; 9 + import { M3Button } from "@/components/ui/m3-button"; 10 + import { TIMEZONE_GROUPS } from "@/lib/timezones"; 11 + import type { 12 + ImportProgressState, 13 + OnboardingImportResult, 14 + TabValue, 15 + } from "./types"; 16 + 17 + const ONBOARDING_STEP_DETAILS = [ 18 + { 19 + title: "Briefing", 20 + description: "See how your shelf gets calibrated.", 21 + }, 22 + { 23 + title: "Identity", 24 + description: "Tune your profile card and local time.", 25 + }, 26 + { 27 + title: "Import", 28 + description: "Bring your watch history from Trakt or CSV.", 29 + }, 30 + { 31 + title: "Launch", 32 + description: "Review import status and open your shelf.", 33 + }, 34 + ] as const; 35 + 36 + const STEP_ICONS = [ 37 + Sparkles, 38 + UserCircle2, 39 + CloudDownload, 40 + WandSparkles, 41 + ] as const; 42 + const INPUT_CLASS = 43 + "w-full rounded-(--md-sys-shape-corner-medium) border border-[var(--md-sys-color-outline)] bg-[var(--md-sys-color-surface)] px-3 py-2 text-[var(--md-sys-color-on-surface)] outline-none transition placeholder:text-[var(--md-sys-color-on-surface-variant)] focus:border-[var(--md-sys-color-primary)] focus:ring-2 focus:ring-[var(--md-sys-color-primary)]/30"; 44 + 45 + export const ONBOARDING_STEPS = ONBOARDING_STEP_DETAILS.length; 46 + 47 + type OnboardingContentProps = { 48 + step: number; 49 + progress: number; 50 + activeTab: TabValue; 51 + traktUsername: string; 52 + displayName: string; 53 + timezone: string; 54 + timeFormat: "12h" | "24h"; 55 + displayNameId: string; 56 + timezoneId: string; 57 + fileInputId: string; 58 + userAvatarUrl: string; 59 + importProgress: ImportProgressState; 60 + importPercent: number; 61 + importResult: OnboardingImportResult; 62 + isCompleting: boolean; 63 + isSavingProfile: boolean; 64 + isImportBusy: boolean; 65 + onStepChange: (step: number) => void; 66 + onActiveTabChange: (tab: TabValue) => void; 67 + onTraktUsernameChange: (value: string) => void; 68 + onDisplayNameChange: (value: string) => void; 69 + onTimezoneChange: (value: string) => void; 70 + onTimeFormatChange: (value: "12h" | "24h") => void; 71 + onSkip: () => void; 72 + onSaveProfileAndContinue: () => void; 73 + onTraktImport: () => void; 74 + onCsvUpload: (file: File) => void; 75 + onComplete: () => void; 76 + }; 77 + 78 + export function OnboardingContent({ 79 + step, 80 + progress, 81 + activeTab, 82 + traktUsername, 83 + displayName, 84 + timezone, 85 + timeFormat, 86 + displayNameId, 87 + timezoneId, 88 + fileInputId, 89 + userAvatarUrl, 90 + importProgress, 91 + importPercent, 92 + importResult, 93 + isCompleting, 94 + isSavingProfile, 95 + isImportBusy, 96 + onStepChange, 97 + onActiveTabChange, 98 + onTraktUsernameChange, 99 + onDisplayNameChange, 100 + onTimezoneChange, 101 + onTimeFormatChange, 102 + onSkip, 103 + onSaveProfileAndContinue, 104 + onTraktImport, 105 + onCsvUpload, 106 + onComplete, 107 + }: OnboardingContentProps) { 108 + const currentStepDetail = 109 + ONBOARDING_STEP_DETAILS[step - 1] ?? ONBOARDING_STEP_DETAILS[0]; 110 + 111 + return ( 112 + <div className="flex flex-1 justify-center bg-[var(--md-sys-color-surface)] p-4 md:p-6"> 113 + <div className="grid w-full max-w-6xl gap-4 lg:grid-cols-[280px_minmax(0,1fr)]"> 114 + <aside className="flex flex-col gap-5 rounded-(--md-sys-shape-corner-extra-large) border border-[var(--md-sys-color-outline-variant)] bg-[var(--md-sys-color-surface-container-low)] p-5"> 115 + <p className="md-label-small m-0 uppercase tracking-[0.14em] text-[var(--md-sys-color-primary)]"> 116 + Onboarding 117 + </p> 118 + <h1 className="md-headline-medium m-0">Welcome to OpnShelf</h1> 119 + <p className="md-body-medium m-0 text-[var(--md-sys-color-on-surface-variant)]"> 120 + This setup turns a blank profile into a ready-to-track shelf with 121 + your preferred timezone and imported watch history. 122 + </p> 123 + 124 + <ol 125 + className="m-0 grid list-none gap-2 p-0" 126 + aria-label="Onboarding steps" 127 + > 128 + {ONBOARDING_STEP_DETAILS.map((item, index) => { 129 + const StepIcon = STEP_ICONS[index]; 130 + const stepNumber = index + 1; 131 + const isComplete = step > stepNumber; 132 + const isActive = step === stepNumber; 133 + 134 + return ( 135 + <li 136 + key={item.title} 137 + className={`flex items-start gap-3 rounded-(--md-sys-shape-corner-large) border p-3 ${ 138 + isActive 139 + ? "border-[var(--md-sys-color-outline)] bg-[var(--md-sys-color-surface-container)]" 140 + : "border-transparent" 141 + }`} 142 + > 143 + <span 144 + className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${ 145 + isComplete 146 + ? "bg-[var(--md-sys-color-primary)] text-[var(--md-sys-color-on-primary)]" 147 + : "bg-[var(--md-sys-color-secondary-container)] text-[var(--md-sys-color-on-secondary-container)]" 148 + }`} 149 + > 150 + {isComplete ? <Check size={16} /> : <StepIcon size={16} />} 151 + </span> 152 + <span className="grid gap-0.5"> 153 + <strong className="md-label-large text-[var(--md-sys-color-on-surface)]"> 154 + {item.title} 155 + </strong> 156 + <small className="md-body-small text-[var(--md-sys-color-on-surface-variant)]"> 157 + {item.description} 158 + </small> 159 + </span> 160 + </li> 161 + ); 162 + })} 163 + </ol> 164 + </aside> 165 + 166 + <section className="flex flex-col gap-4 rounded-(--md-sys-shape-corner-extra-large) border border-[var(--md-sys-color-outline-variant)] bg-[var(--md-sys-color-surface-container-low)] p-4 md:p-6"> 167 + <header className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"> 168 + <div> 169 + <p className="md-label-small m-0 uppercase text-[var(--md-sys-color-primary)]"> 170 + Step {step} of {ONBOARDING_STEPS} 171 + </p> 172 + <h2 className="md-title-large m-0 mt-1"> 173 + {currentStepDetail.title} 174 + </h2> 175 + <p className="md-body-medium m-0 text-[var(--md-sys-color-on-surface-variant)]"> 176 + {currentStepDetail.description} 177 + </p> 178 + </div> 179 + <p className="md-title-medium m-0 text-[var(--md-sys-color-primary)]"> 180 + {progress}% 181 + </p> 182 + </header> 183 + 184 + <div className="h-2 overflow-hidden rounded-full bg-[var(--md-sys-color-surface-container-high)]"> 185 + <div 186 + className="h-full rounded-full bg-[var(--md-sys-color-primary)] transition-[width] duration-300" 187 + style={{ width: `${progress}%` }} 188 + /> 189 + </div> 190 + 191 + {step === 1 && ( 192 + <div className="animate-in fade-in slide-in-from-bottom-2 grid gap-4 rounded-(--md-sys-shape-corner-large) border border-[var(--md-sys-color-outline-variant)] bg-[var(--md-sys-color-surface-container)] p-4 duration-300"> 193 + <p className="md-body-medium m-0"> 194 + You can finish in under two minutes. We will save your display 195 + name, timezone, and optionally import your viewing history. 196 + </p> 197 + <ul className="md-body-medium m-0 grid list-disc gap-2 pl-5 text-[var(--md-sys-color-on-surface-variant)]"> 198 + <li>Profile and timezone come first.</li> 199 + <li>Import from Trakt username or CSV export.</li> 200 + <li>You can skip import and start tracking instantly.</li> 201 + </ul> 202 + <div className="flex flex-wrap gap-2"> 203 + <M3Button variant="filled" onClick={() => onStepChange(2)}> 204 + Begin setup 205 + </M3Button> 206 + <M3Button 207 + variant="text" 208 + onClick={onSkip} 209 + disabled={isCompleting} 210 + > 211 + {isCompleting ? "Finishing..." : "Skip to shelf"} 212 + </M3Button> 213 + </div> 214 + </div> 215 + )} 216 + 217 + {step === 2 && ( 218 + <div className="animate-in fade-in slide-in-from-bottom-2 grid gap-4 rounded-(--md-sys-shape-corner-large) border border-[var(--md-sys-color-outline-variant)] bg-[var(--md-sys-color-surface-container)] p-4 duration-300"> 219 + <div className="grid grid-cols-[auto_1fr] items-center gap-4 rounded-(--md-sys-shape-corner-medium) border border-[var(--md-sys-color-outline-variant)] bg-[var(--md-sys-color-surface-container-high)] p-3"> 220 + <div className="h-13 w-13 overflow-hidden rounded-full border border-[var(--md-sys-color-outline)] bg-[var(--md-sys-color-surface-container-highest)]"> 221 + {userAvatarUrl ? ( 222 + <img 223 + src={userAvatarUrl} 224 + alt="BlueSky avatar" 225 + className="h-full w-full object-cover" 226 + /> 227 + ) : ( 228 + <div className="md-body-small grid h-full w-full place-items-center text-[var(--md-sys-color-on-surface-variant)]"> 229 + No avatar 230 + </div> 231 + )} 232 + </div> 233 + <div> 234 + <p className="md-title-small m-0">BlueSky profile linked</p> 235 + <p className="md-body-small m-0 mt-1 text-[var(--md-sys-color-on-surface-variant)]"> 236 + Avatar sync is active. Manual uploads will be added soon. 237 + </p> 238 + </div> 239 + </div> 240 + 241 + <div className="grid gap-3 md:grid-cols-2"> 242 + <label className="grid gap-1.5" htmlFor={displayNameId}> 243 + <span className="md-label-small uppercase text-[var(--md-sys-color-on-surface-variant)]"> 244 + Display name 245 + </span> 246 + <input 247 + id={displayNameId} 248 + type="text" 249 + value={displayName} 250 + onChange={(event) => 251 + onDisplayNameChange(event.target.value) 252 + } 253 + placeholder="How your name appears" 254 + className={INPUT_CLASS} 255 + /> 256 + </label> 257 + 258 + <label className="grid gap-1.5" htmlFor={timezoneId}> 259 + <span className="md-label-small uppercase text-[var(--md-sys-color-on-surface-variant)]"> 260 + Timezone 261 + </span> 262 + <select 263 + id={timezoneId} 264 + value={timezone} 265 + onChange={(event) => onTimezoneChange(event.target.value)} 266 + className={INPUT_CLASS} 267 + > 268 + {TIMEZONE_GROUPS.map((group) => ( 269 + <optgroup key={group.region} label={group.region}> 270 + {group.zones.map((zone) => ( 271 + <option key={zone} value={zone}> 272 + {zone} 273 + </option> 274 + ))} 275 + </optgroup> 276 + ))} 277 + </select> 278 + </label> 279 + </div> 280 + 281 + <div className="grid gap-1.5"> 282 + <p className="md-label-small m-0 uppercase text-[var(--md-sys-color-on-surface-variant)]"> 283 + Clock style 284 + </p> 285 + <div className="inline-flex w-fit gap-1 rounded-(--md-sys-shape-corner-large) border border-[var(--md-sys-color-outline-variant)] bg-[var(--md-sys-color-surface-container-high)] p-1"> 286 + <button 287 + type="button" 288 + className={`rounded-(--md-sys-shape-corner-medium) px-3.5 py-2 md-label-large ${ 289 + timeFormat === "12h" 290 + ? "bg-[var(--md-sys-color-secondary-container)] text-[var(--md-sys-color-on-secondary-container)]" 291 + : "text-[var(--md-sys-color-on-surface-variant)]" 292 + }`} 293 + onClick={() => onTimeFormatChange("12h")} 294 + > 295 + 12-hour 296 + </button> 297 + <button 298 + type="button" 299 + className={`rounded-(--md-sys-shape-corner-medium) px-3.5 py-2 md-label-large ${ 300 + timeFormat === "24h" 301 + ? "bg-[var(--md-sys-color-secondary-container)] text-[var(--md-sys-color-on-secondary-container)]" 302 + : "text-[var(--md-sys-color-on-surface-variant)]" 303 + }`} 304 + onClick={() => onTimeFormatChange("24h")} 305 + > 306 + 24-hour 307 + </button> 308 + </div> 309 + </div> 310 + 311 + <div className="flex flex-wrap gap-2"> 312 + <M3Button 313 + variant="text" 314 + onClick={() => onStepChange(1)} 315 + disabled={isSavingProfile} 316 + > 317 + Back 318 + </M3Button> 319 + <M3Button 320 + variant="filled" 321 + onClick={onSaveProfileAndContinue} 322 + disabled={isSavingProfile} 323 + > 324 + {isSavingProfile ? "Saving..." : "Save and continue"} 325 + </M3Button> 326 + </div> 327 + </div> 328 + )} 329 + 330 + {step === 3 && ( 331 + <div className="animate-in fade-in slide-in-from-bottom-2 grid gap-4 rounded-(--md-sys-shape-corner-large) border border-[var(--md-sys-color-outline-variant)] bg-[var(--md-sys-color-surface-container)] p-4 duration-300"> 332 + {importProgress.phase !== "idle" && ( 333 + <div className="grid gap-2 rounded-(--md-sys-shape-corner-medium) border border-[var(--md-sys-color-outline-variant)] bg-[var(--md-sys-color-surface-container-high)] p-3"> 334 + <p className="md-label-large m-0">{importProgress.message}</p> 335 + {importProgress.phase === "importing" ? ( 336 + <> 337 + <div className="h-2 overflow-hidden rounded-full bg-[var(--md-sys-color-surface-container-highest)]"> 338 + <div 339 + className="h-full rounded-full bg-[var(--md-sys-color-primary)] transition-[width] duration-300" 340 + style={{ width: `${importPercent}%` }} 341 + /> 342 + </div> 343 + <p className="md-body-small m-0 text-[var(--md-sys-color-on-surface-variant)]"> 344 + {importProgress.processedItems} /{" "} 345 + {importProgress.totalItems} items ({importPercent}%) 346 + </p> 347 + <p className="md-body-small m-0 text-[var(--md-sys-color-on-surface-variant)]"> 348 + Batch {importProgress.currentBatch} of{" "} 349 + {importProgress.totalBatches}. Imported{" "} 350 + {importProgress.imported}, skipped{" "} 351 + {importProgress.skipped}, failed {importProgress.failed} 352 + . 353 + </p> 354 + </> 355 + ) : ( 356 + <p className="md-body-small m-0 text-[var(--md-sys-color-on-surface-variant)]"> 357 + Preparing data for import... 358 + </p> 359 + )} 360 + </div> 361 + )} 362 + 363 + <div 364 + className="inline-flex w-fit gap-1 rounded-(--md-sys-shape-corner-large) border border-[var(--md-sys-color-outline-variant)] bg-[var(--md-sys-color-surface-container-high)] p-1" 365 + role="tablist" 366 + aria-label="Import source" 367 + > 368 + <button 369 + type="button" 370 + role="tab" 371 + aria-selected={activeTab === "trakt"} 372 + className={`rounded-(--md-sys-shape-corner-medium) px-3 py-2 md-label-large ${ 373 + activeTab === "trakt" 374 + ? "bg-[var(--md-sys-color-secondary-container)] text-[var(--md-sys-color-on-secondary-container)]" 375 + : "text-[var(--md-sys-color-on-surface-variant)]" 376 + }`} 377 + onClick={() => onActiveTabChange("trakt")} 378 + > 379 + Trakt username 380 + </button> 381 + <button 382 + type="button" 383 + role="tab" 384 + aria-selected={activeTab === "csv"} 385 + className={`rounded-(--md-sys-shape-corner-medium) px-3 py-2 md-label-large ${ 386 + activeTab === "csv" 387 + ? "bg-[var(--md-sys-color-secondary-container)] text-[var(--md-sys-color-on-secondary-container)]" 388 + : "text-[var(--md-sys-color-on-surface-variant)]" 389 + }`} 390 + onClick={() => onActiveTabChange("csv")} 391 + > 392 + CSV upload 393 + </button> 394 + </div> 395 + 396 + {activeTab === "trakt" ? ( 397 + <div className="grid gap-3" role="tabpanel"> 398 + <label className="grid gap-1.5"> 399 + <span className="md-label-small uppercase text-[var(--md-sys-color-on-surface-variant)]"> 400 + Trakt username 401 + </span> 402 + <input 403 + type="text" 404 + value={traktUsername} 405 + onChange={(event) => 406 + onTraktUsernameChange(event.target.value) 407 + } 408 + placeholder="your-trakt-handle" 409 + className={INPUT_CLASS} 410 + /> 411 + </label> 412 + <M3Button 413 + variant="filled" 414 + onClick={onTraktImport} 415 + disabled={isImportBusy} 416 + > 417 + Fetch and import 418 + </M3Button> 419 + </div> 420 + ) : ( 421 + <div className="grid gap-3" role="tabpanel"> 422 + <label 423 + htmlFor={fileInputId} 424 + className={`inline-flex w-fit items-center gap-2 rounded-(--md-sys-shape-corner-medium) border border-dashed border-[var(--md-sys-color-outline)] bg-[var(--md-sys-color-surface)] px-3 py-2 md-label-large ${ 425 + isImportBusy 426 + ? "pointer-events-none opacity-55" 427 + : "hover:bg-[var(--md-sys-color-surface-container-high)]" 428 + }`} 429 + > 430 + <FileSpreadsheet size={18} /> 431 + <span> 432 + {isImportBusy 433 + ? "Import in progress" 434 + : "Select Trakt CSV file"} 435 + </span> 436 + </label> 437 + <input 438 + id={fileInputId} 439 + type="file" 440 + className="sr-only" 441 + accept=".csv,text/csv" 442 + onChange={(event) => { 443 + const file = event.target.files?.[0]; 444 + if (file) { 445 + onCsvUpload(file); 446 + event.currentTarget.value = ""; 447 + } 448 + }} 449 + disabled={isImportBusy} 450 + /> 451 + <p className="md-body-small m-0 text-[var(--md-sys-color-on-surface-variant)]"> 452 + Use the standard Trakt export columns: watched_at, action, 453 + type, tmdb_id, season_number, episode_number. 454 + </p> 455 + </div> 456 + )} 457 + 458 + <div className="flex flex-wrap gap-2"> 459 + <M3Button 460 + variant="text" 461 + onClick={() => onStepChange(2)} 462 + disabled={isImportBusy} 463 + > 464 + Back 465 + </M3Button> 466 + <M3Button 467 + variant="text" 468 + onClick={onSkip} 469 + disabled={isCompleting || isImportBusy} 470 + > 471 + {isCompleting ? "Finishing..." : "Skip import"} 472 + </M3Button> 473 + </div> 474 + </div> 475 + )} 476 + 477 + {step === 4 && ( 478 + <div className="animate-in fade-in slide-in-from-bottom-2 grid gap-4 rounded-(--md-sys-shape-corner-large) border border-[var(--md-sys-color-outline-variant)] bg-[var(--md-sys-color-surface-container)] p-4 duration-300"> 479 + <h3 className="md-title-large m-0">You are all set.</h3> 480 + <p className="md-body-medium m-0"> 481 + Your profile is ready and your shelf can start collecting watch 482 + history. 483 + </p> 484 + <div className="grid gap-3 sm:grid-cols-3"> 485 + <div className="rounded-(--md-sys-shape-corner-medium) border border-[var(--md-sys-color-outline-variant)] bg-[var(--md-sys-color-surface-container-high)] p-3"> 486 + <p className="md-label-small m-0 uppercase text-[var(--md-sys-color-on-surface-variant)]"> 487 + Imported 488 + </p> 489 + <strong className="md-headline-small mt-1 block text-[var(--md-sys-color-primary)]"> 490 + {importResult.imported} 491 + </strong> 492 + </div> 493 + <div className="rounded-(--md-sys-shape-corner-medium) border border-[var(--md-sys-color-outline-variant)] bg-[var(--md-sys-color-surface-container-high)] p-3"> 494 + <p className="md-label-small m-0 uppercase text-[var(--md-sys-color-on-surface-variant)]"> 495 + Skipped 496 + </p> 497 + <strong className="md-headline-small mt-1 block text-[var(--md-sys-color-primary)]"> 498 + {importResult.skipped} 499 + </strong> 500 + </div> 501 + <div className="rounded-(--md-sys-shape-corner-medium) border border-[var(--md-sys-color-outline-variant)] bg-[var(--md-sys-color-surface-container-high)] p-3"> 502 + <p className="md-label-small m-0 uppercase text-[var(--md-sys-color-on-surface-variant)]"> 503 + Failed 504 + </p> 505 + <strong className="md-headline-small mt-1 block text-[var(--md-sys-color-primary)]"> 506 + {importResult.failed} 507 + </strong> 508 + </div> 509 + </div> 510 + 511 + {importResult.errors.length > 0 && ( 512 + <div className="max-h-[220px] overflow-auto rounded-(--md-sys-shape-corner-medium) border border-[var(--md-sys-color-error)] bg-[var(--md-sys-color-error-container)]/20 p-3"> 513 + <p className="md-label-large m-0 mb-2 text-[var(--md-sys-color-error)]"> 514 + Import errors 515 + </p> 516 + <ul className="md-body-small m-0 grid list-disc gap-1 pl-4 text-[var(--md-sys-color-error)]"> 517 + {importResult.errors.map((error) => ( 518 + <li key={error}>{error}</li> 519 + ))} 520 + </ul> 521 + </div> 522 + )} 523 + 524 + <div className="flex flex-wrap gap-2"> 525 + <M3Button 526 + variant="filled" 527 + onClick={onComplete} 528 + disabled={isCompleting} 529 + > 530 + {isCompleting ? "Finishing..." : "Open my shelf"} 531 + </M3Button> 532 + </div> 533 + </div> 534 + )} 535 + </section> 536 + </div> 537 + </div> 538 + ); 539 + }
+29
apps/web/src/components/onboarding/types.ts
··· 1 + export type TabValue = "trakt" | "csv"; 2 + 3 + export type ImportPhase = 4 + | "idle" 5 + | "fetching_trakt" 6 + | "parsing_csv" 7 + | "importing" 8 + | "done" 9 + | "error"; 10 + 11 + export type ImportProgressState = { 12 + phase: ImportPhase; 13 + totalItems: number; 14 + processedItems: number; 15 + currentBatch: number; 16 + totalBatches: number; 17 + imported: number; 18 + skipped: number; 19 + failed: number; 20 + startedAt: number | null; 21 + message: string; 22 + }; 23 + 24 + export type OnboardingImportResult = { 25 + imported: number; 26 + skipped: number; 27 + failed: number; 28 + errors: string[]; 29 + };
+250
apps/web/src/lib/onboarding-import.ts
··· 1 + import type { NormalizedImportItemDto } from "@opnshelf/api"; 2 + import Papa from "papaparse"; 3 + 4 + export type CsvParseError = { row: number; message: string }; 5 + 6 + export type ImportProgressUpdate = { 7 + totalItems: number; 8 + processedItems: number; 9 + currentBatch: number; 10 + totalBatches: number; 11 + imported: number; 12 + skipped: number; 13 + failed: number; 14 + }; 15 + 16 + const MAX_BATCH_SIZE = 25; 17 + const CSV_HEADERS = [ 18 + "watched_at", 19 + "action", 20 + "type", 21 + "tmdb_id", 22 + "season_number", 23 + "episode_number", 24 + ] as const; 25 + 26 + export async function runImportInChunks( 27 + items: NormalizedImportItemDto[], 28 + importMutate: (payload: { 29 + body: { items: NormalizedImportItemDto[] }; 30 + }) => Promise<{ 31 + imported: number; 32 + skipped: number; 33 + failed: number; 34 + errors: Array<{ message: string }>; 35 + }>, 36 + onProgress?: (update: ImportProgressUpdate) => void, 37 + ) { 38 + let imported = 0; 39 + let skipped = 0; 40 + let failed = 0; 41 + const errors: string[] = []; 42 + const totalItems = items.length; 43 + const totalBatches = Math.ceil(totalItems / MAX_BATCH_SIZE); 44 + 45 + onProgress?.({ 46 + totalItems, 47 + processedItems: 0, 48 + currentBatch: 0, 49 + totalBatches, 50 + imported, 51 + skipped, 52 + failed, 53 + }); 54 + 55 + for (let start = 0; start < totalItems; start += MAX_BATCH_SIZE) { 56 + const currentBatch = Math.floor(start / MAX_BATCH_SIZE) + 1; 57 + const chunk = items.slice(start, start + MAX_BATCH_SIZE); 58 + 59 + onProgress?.({ 60 + totalItems, 61 + processedItems: start, 62 + currentBatch, 63 + totalBatches, 64 + imported, 65 + skipped, 66 + failed, 67 + }); 68 + 69 + const result = await importMutate({ body: { items: chunk } }); 70 + imported += result.imported; 71 + skipped += result.skipped; 72 + failed += result.failed; 73 + errors.push(...result.errors.map((error) => error.message)); 74 + 75 + onProgress?.({ 76 + totalItems, 77 + processedItems: Math.min(start + chunk.length, totalItems), 78 + currentBatch, 79 + totalBatches, 80 + imported, 81 + skipped, 82 + failed, 83 + }); 84 + } 85 + 86 + return { 87 + imported, 88 + skipped, 89 + failed, 90 + errors, 91 + }; 92 + } 93 + 94 + export async function parseCsvFile(file: File): Promise<{ 95 + items: NormalizedImportItemDto[]; 96 + errors: CsvParseError[]; 97 + }> { 98 + return new Promise((resolve, reject) => { 99 + Papa.parse<Record<string, string>>(file, { 100 + header: true, 101 + skipEmptyLines: true, 102 + complete: (results) => { 103 + const items: NormalizedImportItemDto[] = []; 104 + const errors: CsvParseError[] = []; 105 + const headers = (results.meta.fields ?? []).map((header) => 106 + header.trim(), 107 + ); 108 + 109 + for (const expectedHeader of CSV_HEADERS) { 110 + if (!headers.includes(expectedHeader)) { 111 + errors.push({ 112 + row: 1, 113 + message: `Missing required header: ${expectedHeader}`, 114 + }); 115 + } 116 + } 117 + 118 + if (errors.length > 0) { 119 + resolve({ items, errors }); 120 + return; 121 + } 122 + 123 + for (let rowIndex = 0; rowIndex < results.data.length; rowIndex++) { 124 + const row = results.data[rowIndex] ?? {}; 125 + const normalized = normalizeCsvRow(row, rowIndex + 2); 126 + if (normalized.item) { 127 + items.push(normalized.item); 128 + } else if (normalized.error) { 129 + errors.push(normalized.error); 130 + } 131 + } 132 + 133 + resolve({ items, errors }); 134 + }, 135 + error: (error) => { 136 + reject(error); 137 + }, 138 + }); 139 + }); 140 + } 141 + 142 + function normalizeCsvRow( 143 + row: Record<string, string>, 144 + rowNumber: number, 145 + ): { item?: NormalizedImportItemDto; error?: CsvParseError } { 146 + const type = getCsvValue(row, "type").toLowerCase(); 147 + const watchedAtRaw = getCsvValue(row, "watched_at"); 148 + const watchedAt = Number.isNaN(Date.parse(watchedAtRaw)) 149 + ? "" 150 + : new Date(watchedAtRaw).toISOString(); 151 + const actionRaw = getCsvValue(row, "action").toLowerCase(); 152 + const action = actionRaw || "watch"; 153 + 154 + if (!["watch", "scrobble", "checkin"].includes(action)) { 155 + return { 156 + error: { 157 + row: rowNumber, 158 + message: `Row ${rowNumber}: unsupported action "${actionRaw || "unknown"}"`, 159 + }, 160 + }; 161 + } 162 + 163 + if (!watchedAt) { 164 + return { 165 + error: { 166 + row: rowNumber, 167 + message: `Row ${rowNumber}: invalid watched_at`, 168 + }, 169 + }; 170 + } 171 + 172 + if (type === "movie") { 173 + const movieTmdbId = Number.parseInt(getCsvValue(row, "tmdb_id"), 10); 174 + if (!Number.isInteger(movieTmdbId) || movieTmdbId < 1) { 175 + return { 176 + error: { 177 + row: rowNumber, 178 + message: `Row ${rowNumber}: missing movie TMDB id`, 179 + }, 180 + }; 181 + } 182 + 183 + return { 184 + item: { 185 + type: "movie", 186 + movieTmdbId, 187 + action: action as "watch" | "scrobble" | "checkin", 188 + watchedAt, 189 + }, 190 + }; 191 + } 192 + 193 + if (type === "episode") { 194 + const showTmdbId = Number.parseInt(getCsvValue(row, "tmdb_id"), 10); 195 + const seasonNumber = Number.parseInt(getCsvValue(row, "season_number"), 10); 196 + const episodeNumber = Number.parseInt( 197 + getCsvValue(row, "episode_number"), 198 + 10, 199 + ); 200 + 201 + if (!Number.isInteger(showTmdbId) || showTmdbId < 1) { 202 + return { 203 + error: { 204 + row: rowNumber, 205 + message: `Row ${rowNumber}: missing show TMDB id`, 206 + }, 207 + }; 208 + } 209 + 210 + if ( 211 + !Number.isInteger(seasonNumber) || 212 + seasonNumber < 0 || 213 + !Number.isInteger(episodeNumber) || 214 + episodeNumber < 1 215 + ) { 216 + return { 217 + error: { 218 + row: rowNumber, 219 + message: `Row ${rowNumber}: invalid season/episode values`, 220 + }, 221 + }; 222 + } 223 + 224 + return { 225 + item: { 226 + type: "episode", 227 + showTmdbId, 228 + seasonNumber, 229 + episodeNumber, 230 + action: action as "watch" | "scrobble" | "checkin", 231 + watchedAt, 232 + }, 233 + }; 234 + } 235 + 236 + return { 237 + error: { 238 + row: rowNumber, 239 + message: `Row ${rowNumber}: unsupported type "${type || "unknown"}"`, 240 + }, 241 + }; 242 + } 243 + 244 + function getCsvValue(row: Record<string, string>, key: string): string { 245 + const value = row[key]; 246 + if (typeof value === "string" && value.trim()) { 247 + return value.trim(); 248 + } 249 + return ""; 250 + }
+53 -594
apps/web/src/routes/onboarding.tsx
··· 1 1 import { 2 2 authControllerMeOptions, 3 - type NormalizedImportItemDto, 4 3 usersControllerCompleteOnboardingMutation, 5 4 usersControllerFetchMyTraktPublicHistoryMutation, 6 5 usersControllerGetMySettingsOptions, ··· 10 9 } from "@opnshelf/api"; 11 10 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 12 11 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 13 - import Papa from "papaparse"; 14 12 import { useEffect, useId, useMemo, useState } from "react"; 15 13 import { toast } from "sonner"; 16 - import { M3Button } from "@/components/ui/m3-button"; 17 - import { TIMEZONE_GROUPS } from "@/lib/timezones"; 18 - 19 - type TabValue = "trakt" | "csv"; 20 - type CsvParseError = { row: number; message: string }; 21 - type ImportPhase = 22 - | "idle" 23 - | "fetching_trakt" 24 - | "parsing_csv" 25 - | "importing" 26 - | "done" 27 - | "error"; 28 - 29 - type ImportProgressState = { 30 - phase: ImportPhase; 31 - totalItems: number; 32 - processedItems: number; 33 - currentBatch: number; 34 - totalBatches: number; 35 - imported: number; 36 - skipped: number; 37 - failed: number; 38 - startedAt: number | null; 39 - message: string; 40 - }; 41 - 42 - type ImportProgressUpdate = { 43 - totalItems: number; 44 - processedItems: number; 45 - currentBatch: number; 46 - totalBatches: number; 47 - imported: number; 48 - skipped: number; 49 - failed: number; 50 - }; 51 - 52 - const MAX_BATCH_SIZE = 25; 53 - const ONBOARDING_STEPS = 4; 54 - const CSV_HEADERS = [ 55 - "watched_at", 56 - "action", 57 - "type", 58 - "tmdb_id", 59 - "season_number", 60 - "episode_number", 61 - ] as const; 14 + import { 15 + ONBOARDING_STEPS, 16 + OnboardingContent, 17 + } from "@/components/onboarding/onboarding-content"; 18 + import type { 19 + ImportProgressState, 20 + OnboardingImportResult, 21 + TabValue, 22 + } from "@/components/onboarding/types"; 23 + import { parseCsvFile, runImportInChunks } from "@/lib/onboarding-import"; 62 24 63 25 export const Route = createFileRoute("/onboarding")({ 64 26 head: () => ({ ··· 78 40 const [timeFormat, setTimeFormat] = useState<"12h" | "24h">("24h"); 79 41 const displayNameId = useId(); 80 42 const timezoneId = useId(); 81 - const [importResult, setImportResult] = useState({ 43 + const fileInputId = useId(); 44 + const [importResult, setImportResult] = useState<OnboardingImportResult>({ 82 45 imported: 0, 83 46 skipped: 0, 84 47 failed: 0, 85 - errors: [] as string[], 48 + errors: [], 86 49 }); 87 50 const [importProgress, setImportProgress] = useState<ImportProgressState>({ 88 51 phase: "idle", ··· 195 158 if (needsAuthRedirect || needsShelfRedirect || !user) { 196 159 return null; 197 160 } 198 - 199 - const handleSkip = async () => { 200 - await completeOnboardingAndRedirect(); 201 - }; 202 161 203 162 const handleSaveProfileAndContinue = async () => { 204 163 await updateProfileMutation.mutateAsync({ ··· 388 347 }; 389 348 390 349 return ( 391 - <div className="flex-1 flex items-center justify-center p-4"> 392 - <div className="w-full max-w-3xl rounded-(--md-sys-shape-corner-large) border p-6 md:p-8 bg-(--md-sys-color-surface)"> 393 - <h1 className="md-headline-large mb-2">Welcome to OpnShelf</h1> 394 - <p className="md-body-large text-(--md-sys-color-on-surface-variant)"> 395 - Bring your watch history over, or skip and start tracking now. 396 - </p> 397 - 398 - <div className="h-2 rounded-full mt-6 mb-8 bg-(--md-sys-color-surface-container)"> 399 - <div 400 - className="h-2 rounded-full transition-all" 401 - style={{ 402 - width: `${progress}%`, 403 - backgroundColor: "var(--md-sys-color-primary)", 404 - }} 405 - /> 406 - </div> 407 - 408 - {step === 1 && ( 409 - <div className="space-y-5"> 410 - <p className="md-body-large text-(--md-sys-color-on-surface-variant)"> 411 - Step 1 of 4: Set your profile and time preferences, then import 412 - watch history from Trakt or CSV. 413 - </p> 414 - <div className="flex gap-3"> 415 - <M3Button variant="filled" onClick={() => setStep(2)}> 416 - Set up profile 417 - </M3Button> 418 - <M3Button 419 - variant="text" 420 - onClick={handleSkip} 421 - disabled={isCompleting} 422 - > 423 - {isCompleting ? "Finishing..." : "Skip for now"} 424 - </M3Button> 425 - </div> 426 - </div> 427 - )} 428 - 429 - {step === 2 && ( 430 - <div className="space-y-6"> 431 - <p className="md-body-large text-(--md-sys-color-on-surface-variant)"> 432 - Step 2 of 4: personalize your profile and how times are shown. 433 - </p> 434 - 435 - <div className="rounded-(--md-sys-shape-corner-large) border p-4 md:p-5 bg-(--md-sys-color-surface-container-low)"> 436 - <div className="flex items-center gap-4"> 437 - <div className="w-16 h-16 rounded-full overflow-hidden border bg-(--md-sys-color-surface-container-high)"> 438 - {userAvatarUrl ? ( 439 - <img 440 - src={userAvatarUrl} 441 - alt="BlueSky avatar" 442 - className="w-full h-full object-cover" 443 - /> 444 - ) : ( 445 - <div className="w-full h-full flex items-center justify-center text-sm text-(--md-sys-color-on-surface-variant)"> 446 - No avatar 447 - </div> 448 - )} 449 - </div> 450 - <div className="flex-1 min-w-0"> 451 - <p className="md-title-medium">BlueSky avatar</p> 452 - <p className="md-body-small text-(--md-sys-color-on-surface-variant)"> 453 - Imported from BlueSky. Avatar upload coming soon. 454 - </p> 455 - </div> 456 - <M3Button variant="outlined" disabled> 457 - Upload coming soon 458 - </M3Button> 459 - </div> 460 - </div> 461 - 462 - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> 463 - <div className="space-y-2"> 464 - <label className="md-label-large" htmlFor={displayNameId}> 465 - Display name 466 - </label> 467 - <input 468 - id={displayNameId} 469 - type="text" 470 - value={displayName} 471 - onChange={(event) => setDisplayName(event.target.value)} 472 - placeholder="How your name appears" 473 - className="w-full rounded-(--md-sys-shape-corner-medium) border px-3 py-2" 474 - /> 475 - </div> 476 - 477 - <div className="space-y-2"> 478 - <label className="md-label-large" htmlFor={timezoneId}> 479 - Timezone 480 - </label> 481 - <select 482 - id={timezoneId} 483 - value={timezone} 484 - onChange={(event) => setTimezone(event.target.value)} 485 - className="w-full rounded-(--md-sys-shape-corner-medium) border px-3 py-2 bg-(--md-sys-color-surface)" 486 - > 487 - {TIMEZONE_GROUPS.map((group) => ( 488 - <optgroup key={group.region} label={group.region}> 489 - {group.zones.map((zone) => ( 490 - <option key={zone} value={zone}> 491 - {zone} 492 - </option> 493 - ))} 494 - </optgroup> 495 - ))} 496 - </select> 497 - </div> 498 - </div> 499 - 500 - <div className="space-y-2"> 501 - <p className="md-label-large">Time format</p> 502 - <div className="inline-flex rounded-(--md-sys-shape-corner-large) border overflow-hidden"> 503 - <button 504 - type="button" 505 - onClick={() => setTimeFormat("12h")} 506 - className="px-4 py-2" 507 - style={{ 508 - backgroundColor: 509 - timeFormat === "12h" 510 - ? "var(--md-sys-color-secondary-container)" 511 - : "transparent", 512 - }} 513 - > 514 - 12h 515 - </button> 516 - <button 517 - type="button" 518 - onClick={() => setTimeFormat("24h")} 519 - className="px-4 py-2" 520 - style={{ 521 - backgroundColor: 522 - timeFormat === "24h" 523 - ? "var(--md-sys-color-secondary-container)" 524 - : "transparent", 525 - }} 526 - > 527 - 24h 528 - </button> 529 - </div> 530 - </div> 531 - 532 - <div className="flex gap-3"> 533 - <M3Button 534 - variant="text" 535 - onClick={() => setStep(1)} 536 - disabled={isSavingProfile} 537 - > 538 - Back 539 - </M3Button> 540 - <M3Button 541 - variant="filled" 542 - onClick={() => { 543 - void handleSaveProfileAndContinue(); 544 - }} 545 - disabled={isSavingProfile} 546 - > 547 - {isSavingProfile ? "Saving..." : "Save and continue"} 548 - </M3Button> 549 - </div> 550 - </div> 551 - )} 552 - 553 - {step === 3 && ( 554 - <div className="space-y-4"> 555 - {importProgress.phase !== "idle" && ( 556 - <div className="rounded-(--md-sys-shape-corner-medium) border p-3 space-y-2 bg-(--md-sys-color-surface-container-low)"> 557 - <p className="md-label-large">{importProgress.message}</p> 558 - {importProgress.phase === "importing" ? ( 559 - <> 560 - <div className="h-2 rounded-full bg-(--md-sys-color-surface-container)"> 561 - <div 562 - className="h-2 rounded-full transition-all" 563 - style={{ 564 - width: `${importPercent}%`, 565 - backgroundColor: "var(--md-sys-color-primary)", 566 - }} 567 - /> 568 - </div> 569 - <p className="md-body-small text-(--md-sys-color-on-surface-variant)"> 570 - {importProgress.processedItems} /{" "} 571 - {importProgress.totalItems} items ({importPercent}%) 572 - </p> 573 - <p className="md-body-small text-(--md-sys-color-on-surface-variant)"> 574 - Batch {importProgress.currentBatch} /{" "} 575 - {importProgress.totalBatches}· Imported{" "} 576 - {importProgress.imported} · Skipped{" "} 577 - {importProgress.skipped} · Failed {importProgress.failed} 578 - </p> 579 - </> 580 - ) : ( 581 - <p className="md-body-small text-(--md-sys-color-on-surface-variant)"> 582 - Preparing import... 583 - </p> 584 - )} 585 - </div> 586 - )} 587 - 588 - <div className="flex gap-2"> 589 - <button 590 - type="button" 591 - onClick={() => setActiveTab("trakt")} 592 - className="px-3 py-2 rounded-(--md-sys-shape-corner-medium)" 593 - style={{ 594 - backgroundColor: 595 - activeTab === "trakt" 596 - ? "var(--md-sys-color-secondary-container)" 597 - : "var(--md-sys-color-surface-container)", 598 - }} 599 - > 600 - Trakt username 601 - </button> 602 - <button 603 - type="button" 604 - onClick={() => setActiveTab("csv")} 605 - className="px-3 py-2 rounded-(--md-sys-shape-corner-medium)" 606 - style={{ 607 - backgroundColor: 608 - activeTab === "csv" 609 - ? "var(--md-sys-color-secondary-container)" 610 - : "var(--md-sys-color-surface-container)", 611 - }} 612 - > 613 - CSV upload 614 - </button> 615 - </div> 616 - 617 - {activeTab === "trakt" ? ( 618 - <div className="space-y-3"> 619 - <input 620 - type="text" 621 - value={traktUsername} 622 - onChange={(event) => setTraktUsername(event.target.value)} 623 - placeholder="Trakt username" 624 - className="w-full rounded-(--md-sys-shape-corner-medium) border px-3 py-2" 625 - /> 626 - <M3Button 627 - variant="filled" 628 - onClick={handleTraktImport} 629 - disabled={isImportBusy} 630 - > 631 - Fetch and import 632 - </M3Button> 633 - </div> 634 - ) : ( 635 - <div className="space-y-3"> 636 - <input 637 - type="file" 638 - accept=".csv,text/csv" 639 - onChange={(event) => { 640 - const file = event.target.files?.[0]; 641 - if (file) { 642 - void handleCsvUpload(file); 643 - } 644 - }} 645 - disabled={isImportBusy} 646 - /> 647 - <p className="md-body-small text-(--md-sys-color-on-surface-variant)"> 648 - Upload a Trakt history CSV export using the standard Trakt 649 - columns. 650 - </p> 651 - </div> 652 - )} 653 - 654 - <div className="flex gap-3"> 655 - <M3Button 656 - variant="text" 657 - onClick={() => setStep(2)} 658 - disabled={isImportBusy} 659 - > 660 - Back 661 - </M3Button> 662 - <M3Button 663 - variant="text" 664 - onClick={handleSkip} 665 - disabled={isCompleting || isImportBusy} 666 - > 667 - {isCompleting ? "Finishing..." : "Skip for now"} 668 - </M3Button> 669 - </div> 670 - </div> 671 - )} 672 - 673 - {step === 4 && ( 674 - <div className="space-y-4"> 675 - <h2 className="md-title-large">You&apos;re all set</h2> 676 - <p className="md-body-medium text-(--md-sys-color-on-surface-variant)"> 677 - Imported: {importResult.imported} | Skipped:{" "} 678 - {importResult.skipped} | Failed: {importResult.failed} 679 - </p> 680 - {importResult.errors.length > 0 && ( 681 - <div className="rounded-(--md-sys-shape-corner-medium) border p-3 max-h-56 overflow-auto"> 682 - <p className="md-label-large mb-2">Errors</p> 683 - <ul className="space-y-1"> 684 - {importResult.errors.map((error) => ( 685 - <li key={error} className="md-body-small"> 686 - {error} 687 - </li> 688 - ))} 689 - </ul> 690 - </div> 691 - )} 692 - <M3Button 693 - variant="filled" 694 - onClick={() => { 695 - void completeOnboardingAndRedirect(); 696 - }} 697 - disabled={isCompleting} 698 - > 699 - {isCompleting ? "Finishing..." : "Finish"} 700 - </M3Button> 701 - </div> 702 - )} 703 - </div> 704 - </div> 350 + <OnboardingContent 351 + step={step} 352 + progress={progress} 353 + activeTab={activeTab} 354 + traktUsername={traktUsername} 355 + displayName={displayName} 356 + timezone={timezone} 357 + timeFormat={timeFormat} 358 + displayNameId={displayNameId} 359 + timezoneId={timezoneId} 360 + fileInputId={fileInputId} 361 + userAvatarUrl={userAvatarUrl} 362 + importProgress={importProgress} 363 + importPercent={importPercent} 364 + importResult={importResult} 365 + isCompleting={isCompleting} 366 + isSavingProfile={isSavingProfile} 367 + isImportBusy={isImportBusy} 368 + onStepChange={setStep} 369 + onActiveTabChange={setActiveTab} 370 + onTraktUsernameChange={setTraktUsername} 371 + onDisplayNameChange={setDisplayName} 372 + onTimezoneChange={setTimezone} 373 + onTimeFormatChange={setTimeFormat} 374 + onSkip={() => { 375 + void completeOnboardingAndRedirect(); 376 + }} 377 + onSaveProfileAndContinue={() => { 378 + void handleSaveProfileAndContinue(); 379 + }} 380 + onTraktImport={() => { 381 + void handleTraktImport(); 382 + }} 383 + onCsvUpload={(file) => { 384 + void handleCsvUpload(file); 385 + }} 386 + onComplete={() => { 387 + void completeOnboardingAndRedirect(); 388 + }} 389 + /> 705 390 ); 706 391 } 707 - 708 - async function runImportInChunks( 709 - items: NormalizedImportItemDto[], 710 - importMutate: (payload: { 711 - body: { items: NormalizedImportItemDto[] }; 712 - }) => Promise<{ 713 - imported: number; 714 - skipped: number; 715 - failed: number; 716 - errors: Array<{ message: string }>; 717 - }>, 718 - onProgress?: (update: ImportProgressUpdate) => void, 719 - ) { 720 - let imported = 0; 721 - let skipped = 0; 722 - let failed = 0; 723 - const errors: string[] = []; 724 - const totalItems = items.length; 725 - const totalBatches = Math.ceil(totalItems / MAX_BATCH_SIZE); 726 - 727 - onProgress?.({ 728 - totalItems, 729 - processedItems: 0, 730 - currentBatch: 0, 731 - totalBatches, 732 - imported, 733 - skipped, 734 - failed, 735 - }); 736 - 737 - for (let start = 0; start < totalItems; start += MAX_BATCH_SIZE) { 738 - const currentBatch = Math.floor(start / MAX_BATCH_SIZE) + 1; 739 - const chunk = items.slice(start, start + MAX_BATCH_SIZE); 740 - 741 - onProgress?.({ 742 - totalItems, 743 - processedItems: start, 744 - currentBatch, 745 - totalBatches, 746 - imported, 747 - skipped, 748 - failed, 749 - }); 750 - 751 - const result = await importMutate({ body: { items: chunk } }); 752 - imported += result.imported; 753 - skipped += result.skipped; 754 - failed += result.failed; 755 - errors.push(...result.errors.map((error) => error.message)); 756 - 757 - onProgress?.({ 758 - totalItems, 759 - processedItems: Math.min(start + chunk.length, totalItems), 760 - currentBatch, 761 - totalBatches, 762 - imported, 763 - skipped, 764 - failed, 765 - }); 766 - } 767 - 768 - return { 769 - imported, 770 - skipped, 771 - failed, 772 - errors, 773 - }; 774 - } 775 - 776 - async function parseCsvFile(file: File): Promise<{ 777 - items: NormalizedImportItemDto[]; 778 - errors: CsvParseError[]; 779 - }> { 780 - return new Promise((resolve, reject) => { 781 - Papa.parse<Record<string, string>>(file, { 782 - header: true, 783 - skipEmptyLines: true, 784 - complete: (results) => { 785 - const items: NormalizedImportItemDto[] = []; 786 - const errors: CsvParseError[] = []; 787 - const headers = (results.meta.fields ?? []).map((header) => 788 - header.trim(), 789 - ); 790 - 791 - for (const expectedHeader of CSV_HEADERS) { 792 - if (!headers.includes(expectedHeader)) { 793 - errors.push({ 794 - row: 1, 795 - message: `Missing required header: ${expectedHeader}`, 796 - }); 797 - } 798 - } 799 - 800 - if (errors.length > 0) { 801 - resolve({ items, errors }); 802 - return; 803 - } 804 - 805 - for (let rowIndex = 0; rowIndex < results.data.length; rowIndex++) { 806 - const row = results.data[rowIndex] ?? {}; 807 - const normalized = normalizeCsvRow(row, rowIndex + 2); 808 - if (normalized.item) { 809 - items.push(normalized.item); 810 - } else if (normalized.error) { 811 - errors.push(normalized.error); 812 - } 813 - } 814 - 815 - resolve({ items, errors }); 816 - }, 817 - error: (error) => { 818 - reject(error); 819 - }, 820 - }); 821 - }); 822 - } 823 - 824 - function normalizeCsvRow( 825 - row: Record<string, string>, 826 - rowNumber: number, 827 - ): { item?: NormalizedImportItemDto; error?: CsvParseError } { 828 - const type = getCsvValue(row, "type").toLowerCase(); 829 - const watchedAtRaw = getCsvValue(row, "watched_at"); 830 - const watchedAt = Number.isNaN(Date.parse(watchedAtRaw)) 831 - ? "" 832 - : new Date(watchedAtRaw).toISOString(); 833 - const actionRaw = getCsvValue(row, "action").toLowerCase(); 834 - const action = actionRaw || "watch"; 835 - 836 - if (!["watch", "scrobble", "checkin"].includes(action)) { 837 - return { 838 - error: { 839 - row: rowNumber, 840 - message: `Row ${rowNumber}: unsupported action "${actionRaw || "unknown"}"`, 841 - }, 842 - }; 843 - } 844 - 845 - if (!watchedAt) { 846 - return { 847 - error: { 848 - row: rowNumber, 849 - message: `Row ${rowNumber}: invalid watched_at`, 850 - }, 851 - }; 852 - } 853 - 854 - if (type === "movie") { 855 - const movieTmdbId = Number.parseInt(getCsvValue(row, "tmdb_id"), 10); 856 - if (!Number.isInteger(movieTmdbId) || movieTmdbId < 1) { 857 - return { 858 - error: { 859 - row: rowNumber, 860 - message: `Row ${rowNumber}: missing movie TMDB id`, 861 - }, 862 - }; 863 - } 864 - 865 - return { 866 - item: { 867 - type: "movie", 868 - movieTmdbId, 869 - action: action as "watch" | "scrobble" | "checkin", 870 - watchedAt, 871 - }, 872 - }; 873 - } 874 - 875 - if (type === "episode") { 876 - const showTmdbId = Number.parseInt(getCsvValue(row, "tmdb_id"), 10); 877 - const seasonNumber = Number.parseInt(getCsvValue(row, "season_number"), 10); 878 - const episodeNumber = Number.parseInt( 879 - getCsvValue(row, "episode_number"), 880 - 10, 881 - ); 882 - 883 - if (!Number.isInteger(showTmdbId) || showTmdbId < 1) { 884 - return { 885 - error: { 886 - row: rowNumber, 887 - message: `Row ${rowNumber}: missing show TMDB id`, 888 - }, 889 - }; 890 - } 891 - 892 - if ( 893 - !Number.isInteger(seasonNumber) || 894 - seasonNumber < 0 || 895 - !Number.isInteger(episodeNumber) || 896 - episodeNumber < 1 897 - ) { 898 - return { 899 - error: { 900 - row: rowNumber, 901 - message: `Row ${rowNumber}: invalid season/episode values`, 902 - }, 903 - }; 904 - } 905 - 906 - return { 907 - item: { 908 - type: "episode", 909 - showTmdbId, 910 - seasonNumber, 911 - episodeNumber, 912 - action: action as "watch" | "scrobble" | "checkin", 913 - watchedAt, 914 - }, 915 - }; 916 - } 917 - 918 - return { 919 - error: { 920 - row: rowNumber, 921 - message: `Row ${rowNumber}: unsupported type "${type || "unknown"}"`, 922 - }, 923 - }; 924 - } 925 - 926 - function getCsvValue(row: Record<string, string>, key: string): string { 927 - const value = row[key]; 928 - if (typeof value === "string" && value.trim()) { 929 - return value.trim(); 930 - } 931 - return ""; 932 - }