├── .all-contributorsrc ├── .env ├── .env.development ├── .eslintignore ├── .github └── workflows │ └── validate.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.kcd.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── INSTRUCTIONS.md ├── LICENSE.md ├── README.md ├── cypress.config.js ├── cypress ├── .eslintrc.js ├── e2e │ └── smoke.js ├── fixtures │ └── example.json ├── jsconfig.json └── support │ ├── e2e.js │ └── generate.js ├── docker-compose.yml ├── go.js ├── jest.config.js ├── jsconfig.json ├── netlify.toml ├── package-lock.json ├── package.json ├── public ├── _redirects ├── android-icon-144x144.png ├── android-icon-192x192.png ├── android-icon-36x36.png ├── android-icon-48x48.png ├── android-icon-72x72.png ├── android-icon-96x96.png ├── apple-icon-114x114.png ├── apple-icon-120x120.png ├── apple-icon-144x144.png ├── apple-icon-152x152.png ├── apple-icon-180x180.png ├── apple-icon-57x57.png ├── apple-icon-60x60.png ├── apple-icon-72x72.png ├── apple-icon-76x76.png ├── apple-icon-precomposed.png ├── apple-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── favicon.ico ├── index.html ├── manifest.json ├── mockServiceWorker.js ├── ms-icon-144x144.png ├── ms-icon-150x150.png ├── ms-icon-310x310.png ├── ms-icon-70x70.png └── serve.json ├── sandbox.config.json ├── scripts ├── build-variants.js ├── build.js ├── git-diffs.js ├── pre-commit.js ├── pre-push.js ├── setup.js ├── swap.js ├── update-branch.js ├── update-deps ├── update-exercises.js ├── update-links.js ├── utils.js └── validate-exercises.js ├── setup.js └── src ├── __tests__ └── book-screen.js ├── app.js ├── assets └── book-placeholder.svg ├── auth-provider.js ├── authenticated-app.js ├── bootstrap.js ├── components ├── __mocks__ │ └── profiler.js ├── __tests__ │ ├── modal.js │ └── rating.js ├── book-row.js ├── lib.js ├── list-item-list.js ├── logo.js ├── modal.js ├── profiler.js ├── rating.js └── status-buttons.js ├── context ├── auth-context.js └── index.js ├── dev-tools ├── dev-tools.js └── load.js ├── index.js ├── screens ├── book.js ├── discover.js ├── finished.js ├── not-found.js └── reading-list.js ├── setupProxy.js ├── setupTests.js ├── styles ├── colors.js ├── global.css └── media-queries.js ├── test ├── app-test-utils.js ├── data │ ├── books-data.json │ ├── books.js │ ├── list-items.js │ └── users.js ├── generate.js └── server │ ├── dev-server.js │ ├── index.js │ ├── server-handlers.js │ └── test-server.js ├── unauthenticated-app.js └── utils ├── __tests__ ├── api-client.js ├── misc.js └── use-async.js ├── api-client.js ├── books.js ├── hooks.js ├── list-items.js └── misc.js /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "kentcdodds", 10 | "name": "Kent C. Dodds", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/1500684?v=3", 12 | "profile": "https://kentcdodds.com", 13 | "contributions": [ 14 | "code", 15 | "doc", 16 | "infra", 17 | "test" 18 | ] 19 | }, 20 | { 21 | "login": "vojtaholik", 22 | "name": "Vojta Holik", 23 | "avatar_url": "https://avatars2.githubusercontent.com/u/25487857?v=4", 24 | "profile": "http://vojta.io", 25 | "contributions": [ 26 | "design", 27 | "code" 28 | ] 29 | }, 30 | { 31 | "login": "Sparragus", 32 | "name": "Richard B. Kaufman-López", 33 | "avatar_url": "https://avatars0.githubusercontent.com/u/80982?v=4", 34 | "profile": "https://richardkaufman.dev", 35 | "contributions": [ 36 | "code" 37 | ] 38 | }, 39 | { 40 | "login": "SekibOmazic", 41 | "name": "Sekib Omazic", 42 | "avatar_url": "https://avatars1.githubusercontent.com/u/3735902?v=4", 43 | "profile": "https://github.com/SekibOmazic", 44 | "contributions": [ 45 | "doc" 46 | ] 47 | }, 48 | { 49 | "login": "jdorfman", 50 | "name": "Justin Dorfman", 51 | "avatar_url": "https://avatars1.githubusercontent.com/u/398230?v=4", 52 | "profile": "https://stackshare.io/jdorfman/decisions", 53 | "contributions": [ 54 | "fundingFinding" 55 | ] 56 | }, 57 | { 58 | "login": "nkabbara", 59 | "name": "Nash Kabbara", 60 | "avatar_url": "https://avatars3.githubusercontent.com/u/31865?v=4", 61 | "profile": "http://nashkabbara.com", 62 | "contributions": [ 63 | "doc", 64 | "code", 65 | "bug" 66 | ] 67 | }, 68 | { 69 | "login": "umr55766", 70 | "name": "UMAIR MOHAMMAD", 71 | "avatar_url": "https://avatars0.githubusercontent.com/u/16179313?v=4", 72 | "profile": "https://in.linkedin.com/in/umr55766", 73 | "contributions": [ 74 | "code" 75 | ] 76 | }, 77 | { 78 | "login": "onemen", 79 | "name": "onemen", 80 | "avatar_url": "https://avatars0.githubusercontent.com/u/3650909?v=4", 81 | "profile": "https://github.com/onemen", 82 | "contributions": [ 83 | "code" 84 | ] 85 | }, 86 | { 87 | "login": "kettanaito", 88 | "name": "Artem Zakharchenko", 89 | "avatar_url": "https://avatars3.githubusercontent.com/u/14984911?v=4", 90 | "profile": "https://www.redd.one", 91 | "contributions": [ 92 | "code" 93 | ] 94 | }, 95 | { 96 | "login": "leonardoelias", 97 | "name": "Leonardo Elias", 98 | "avatar_url": "https://avatars2.githubusercontent.com/u/1995213?v=4", 99 | "profile": "http://htttp://www.leonardoelias.me", 100 | "contributions": [ 101 | "code" 102 | ] 103 | }, 104 | { 105 | "login": "motdde", 106 | "name": "Oluwaseun Oyebade", 107 | "avatar_url": "https://avatars1.githubusercontent.com/u/12215060?v=4", 108 | "profile": "http://motdde.com", 109 | "contributions": [ 110 | "bug" 111 | ] 112 | }, 113 | { 114 | "login": "wesbos", 115 | "name": "Wes Bos", 116 | "avatar_url": "https://avatars2.githubusercontent.com/u/176013?v=4", 117 | "profile": "http://www.wesbos.com", 118 | "contributions": [ 119 | "ideas" 120 | ] 121 | }, 122 | { 123 | "login": "awareness481", 124 | "name": "Jesse Jafa", 125 | "avatar_url": "https://avatars3.githubusercontent.com/u/12380586?v=4", 126 | "profile": "https://github.com/awareness481", 127 | "contributions": [ 128 | "ideas" 129 | ] 130 | }, 131 | { 132 | "login": "hd4ng", 133 | "name": "Huy Dang", 134 | "avatar_url": "https://avatars1.githubusercontent.com/u/29898753?v=4", 135 | "profile": "https://github.com/hd4ng", 136 | "contributions": [ 137 | "bug" 138 | ] 139 | }, 140 | { 141 | "login": "Buuntu", 142 | "name": "Gabriel Abud", 143 | "avatar_url": "https://avatars3.githubusercontent.com/u/7684770?v=4", 144 | "profile": "https://gabrielabud.com", 145 | "contributions": [ 146 | "doc" 147 | ] 148 | }, 149 | { 150 | "login": "kodyclemens", 151 | "name": "Kody Clemens", 152 | "avatar_url": "https://avatars0.githubusercontent.com/u/43357615?v=4", 153 | "profile": "https://kodyclemens.com", 154 | "contributions": [ 155 | "doc" 156 | ] 157 | }, 158 | { 159 | "login": "calec", 160 | "name": "calec", 161 | "avatar_url": "https://avatars3.githubusercontent.com/u/12165290?v=4", 162 | "profile": "http://cale.xyz", 163 | "contributions": [ 164 | "doc" 165 | ] 166 | }, 167 | { 168 | "login": "emzoumpo", 169 | "name": "Emmanouil Zoumpoulakis", 170 | "avatar_url": "https://avatars2.githubusercontent.com/u/2103443?v=4", 171 | "profile": "https://github.com/emzoumpo", 172 | "contributions": [ 173 | "code" 174 | ] 175 | }, 176 | { 177 | "login": "milamer", 178 | "name": "Christian Schurr", 179 | "avatar_url": "https://avatars2.githubusercontent.com/u/12884134?v=4", 180 | "profile": "https://github.com/milamer", 181 | "contributions": [ 182 | "code", 183 | "bug" 184 | ] 185 | }, 186 | { 187 | "login": "b2m9", 188 | "name": "Bob Massarczyk", 189 | "avatar_url": "https://avatars1.githubusercontent.com/u/8492232?v=4", 190 | "profile": "http://www.b2m9.com", 191 | "contributions": [ 192 | "doc" 193 | ] 194 | }, 195 | { 196 | "login": "deepak-chandani", 197 | "name": "Deepak Chandani", 198 | "avatar_url": "https://avatars0.githubusercontent.com/u/15975603?v=4", 199 | "profile": "https://radiant-sands-51546.herokuapp.com/profile/deepak.chandani", 200 | "contributions": [ 201 | "code" 202 | ] 203 | }, 204 | { 205 | "login": "frontendwizard", 206 | "name": "Juliano Farias", 207 | "avatar_url": "https://avatars1.githubusercontent.com/u/1124448?v=4", 208 | "profile": "http://frontendwizard.dev", 209 | "contributions": [ 210 | "test" 211 | ] 212 | }, 213 | { 214 | "login": "RobbertWolfs", 215 | "name": "Robbert Wolfs", 216 | "avatar_url": "https://avatars2.githubusercontent.com/u/12511178?v=4", 217 | "profile": "https://github.com/RobbertWolfs", 218 | "contributions": [ 219 | "doc", 220 | "code" 221 | ] 222 | }, 223 | { 224 | "login": "komisz", 225 | "name": "komisz", 226 | "avatar_url": "https://avatars3.githubusercontent.com/u/45998348?v=4", 227 | "profile": "https://github.com/komisz", 228 | "contributions": [ 229 | "bug" 230 | ] 231 | }, 232 | { 233 | "login": "MichaelDeBoey", 234 | "name": "Michaël De Boey", 235 | "avatar_url": "https://avatars3.githubusercontent.com/u/6643991?v=4", 236 | "profile": "https://michaeldeboey.be", 237 | "contributions": [ 238 | "projectManagement", 239 | "code" 240 | ] 241 | }, 242 | { 243 | "login": "marcosvega91", 244 | "name": "Marco Moretti", 245 | "avatar_url": "https://avatars2.githubusercontent.com/u/5365582?v=4", 246 | "profile": "https://github.com/marcosvega91", 247 | "contributions": [ 248 | "code" 249 | ] 250 | }, 251 | { 252 | "login": "vasilii-kovalev", 253 | "name": "Vasilii Kovalev", 254 | "avatar_url": "https://avatars0.githubusercontent.com/u/10310491?v=4", 255 | "profile": "https://vk.com/vasilii_kovalev", 256 | "contributions": [ 257 | "code", 258 | "doc" 259 | ] 260 | }, 261 | { 262 | "login": "p10ns11y", 263 | "name": "Peramanathan Sathyamoorthy", 264 | "avatar_url": "https://avatars2.githubusercontent.com/u/9104920?v=4", 265 | "profile": "http://twitter.com/peramanathan", 266 | "contributions": [ 267 | "code" 268 | ] 269 | }, 270 | { 271 | "login": "wbeuil", 272 | "name": "William BEUIL", 273 | "avatar_url": "https://avatars1.githubusercontent.com/u/8110579?v=4", 274 | "profile": "http://wbeuil.com", 275 | "contributions": [ 276 | "code" 277 | ] 278 | }, 279 | { 280 | "login": "andrewli-ca", 281 | "name": "Andrew Li", 282 | "avatar_url": "https://avatars2.githubusercontent.com/u/9060674?v=4", 283 | "profile": "http://andrewli.ca", 284 | "contributions": [ 285 | "doc" 286 | ] 287 | }, 288 | { 289 | "login": "tonykhaov", 290 | "name": "Tony Khaov", 291 | "avatar_url": "https://avatars1.githubusercontent.com/u/53958746?v=4", 292 | "profile": "https://github.com/tonykhaov", 293 | "contributions": [ 294 | "doc" 295 | ] 296 | }, 297 | { 298 | "login": "degeens", 299 | "name": "Stijn Geens", 300 | "avatar_url": "https://avatars2.githubusercontent.com/u/33414262?v=4", 301 | "profile": "https://github.com/degeens", 302 | "contributions": [ 303 | "doc" 304 | ] 305 | }, 306 | { 307 | "login": "andresgallego", 308 | "name": "Andrés Gallego", 309 | "avatar_url": "https://avatars2.githubusercontent.com/u/1014950?v=4", 310 | "profile": "https://github.com/andresgallego", 311 | "contributions": [ 312 | "ideas" 313 | ] 314 | }, 315 | { 316 | "login": "michaljuris", 317 | "name": "Michal Juriš", 318 | "avatar_url": "https://avatars1.githubusercontent.com/u/391000?v=4", 319 | "profile": "https://github.com/michaljuris", 320 | "contributions": [ 321 | "bug" 322 | ] 323 | }, 324 | { 325 | "login": "jkmuka", 326 | "name": "jkmuka", 327 | "avatar_url": "https://avatars2.githubusercontent.com/u/6767449?v=4", 328 | "profile": "https://github.com/jkmuka", 329 | "contributions": [ 330 | "bug" 331 | ] 332 | }, 333 | { 334 | "login": "raqib-rasheed", 335 | "name": "raqib-rasheed", 336 | "avatar_url": "https://avatars0.githubusercontent.com/u/71254614?v=4", 337 | "profile": "https://github.com/raqib-rasheed", 338 | "contributions": [ 339 | "bug" 340 | ] 341 | }, 342 | { 343 | "login": "Luke-kb", 344 | "name": "Luke-kb", 345 | "avatar_url": "https://avatars2.githubusercontent.com/u/7802494?v=4", 346 | "profile": "https://github.com/Luke-kb", 347 | "contributions": [ 348 | "doc" 349 | ] 350 | }, 351 | { 352 | "login": "Aprillion", 353 | "name": "Peter Hozák", 354 | "avatar_url": "https://avatars0.githubusercontent.com/u/1087670?v=4", 355 | "profile": "http://peter.hozak.info/", 356 | "contributions": [ 357 | "code" 358 | ] 359 | }, 360 | { 361 | "login": "cxc421", 362 | "name": "Chris Chuang", 363 | "avatar_url": "https://avatars3.githubusercontent.com/u/18439296?v=4", 364 | "profile": "https://github.com/cxc421", 365 | "contributions": [ 366 | "code", 367 | "bug" 368 | ] 369 | }, 370 | { 371 | "login": "ValentinH", 372 | "name": "Valentin Hervieu", 373 | "avatar_url": "https://avatars2.githubusercontent.com/u/2678610?v=4", 374 | "profile": "https://valentin-hervieu.fr", 375 | "contributions": [ 376 | "doc" 377 | ] 378 | }, 379 | { 380 | "login": "SamiTriki", 381 | "name": "~Sami Triki", 382 | "avatar_url": "https://avatars.githubusercontent.com/u/6273120?v=4", 383 | "profile": "http://triki.io", 384 | "contributions": [ 385 | "doc" 386 | ] 387 | }, 388 | { 389 | "login": "falldowngoboone", 390 | "name": "Ryan Boone", 391 | "avatar_url": "https://avatars.githubusercontent.com/u/3603771?v=4", 392 | "profile": "https://github.com/falldowngoboone", 393 | "contributions": [ 394 | "doc" 395 | ] 396 | }, 397 | { 398 | "login": "juanlatorre", 399 | "name": "Juan Latorre", 400 | "avatar_url": "https://avatars.githubusercontent.com/u/4494526?v=4", 401 | "profile": "https://juanlatorre.cl/", 402 | "contributions": [ 403 | "bug" 404 | ] 405 | }, 406 | { 407 | "login": "Groszczu", 408 | "name": "Roch Goszczyński", 409 | "avatar_url": "https://avatars.githubusercontent.com/u/45833713?v=4", 410 | "profile": "https://github.com/Groszczu", 411 | "contributions": [ 412 | "code", 413 | "bug" 414 | ] 415 | }, 416 | { 417 | "login": "hmttrp", 418 | "name": "Hendrik Mittrop", 419 | "avatar_url": "https://avatars.githubusercontent.com/u/4592406?v=4", 420 | "profile": "https://github.com/hmttrp", 421 | "contributions": [ 422 | "code" 423 | ] 424 | }, 425 | { 426 | "login": "payapula", 427 | "name": "payapula", 428 | "avatar_url": "https://avatars.githubusercontent.com/u/7134153?v=4", 429 | "profile": "https://github.com/payapula", 430 | "contributions": [ 431 | "doc" 432 | ] 433 | }, 434 | { 435 | "login": "jeltehomminga", 436 | "name": "Jelte Homminga", 437 | "avatar_url": "https://avatars.githubusercontent.com/u/35220102?v=4", 438 | "profile": "https://jelte.tech", 439 | "contributions": [ 440 | "doc" 441 | ] 442 | }, 443 | { 444 | "login": "daganomri", 445 | "name": "Omri Dagan", 446 | "avatar_url": "https://avatars.githubusercontent.com/u/23617146?v=4", 447 | "profile": "http://omridagan.dev", 448 | "contributions": [ 449 | "doc" 450 | ] 451 | }, 452 | { 453 | "login": "justindomingue", 454 | "name": "Justin Domingue", 455 | "avatar_url": "https://avatars.githubusercontent.com/u/1284993?v=4", 456 | "profile": "https://github.com/justindomingue", 457 | "contributions": [ 458 | "doc" 459 | ] 460 | }, 461 | { 462 | "login": "maferland", 463 | "name": "Marc-Antoine Ferland", 464 | "avatar_url": "https://avatars.githubusercontent.com/u/5889721?v=4", 465 | "profile": "https://www.maferland.com", 466 | "contributions": [ 467 | "doc" 468 | ] 469 | }, 470 | { 471 | "login": "marioleed", 472 | "name": "Mario Sannum", 473 | "avatar_url": "https://avatars.githubusercontent.com/u/1763448?v=4", 474 | "profile": "https://github.com/marioleed", 475 | "contributions": [ 476 | "code" 477 | ] 478 | }, 479 | { 480 | "login": "jansabbe", 481 | "name": "jansabbe", 482 | "avatar_url": "https://avatars.githubusercontent.com/u/648689?v=4", 483 | "profile": "http://www.atrenko.com", 484 | "contributions": [ 485 | "doc" 486 | ] 487 | }, 488 | { 489 | "login": "aswinckr", 490 | "name": "Aswin", 491 | "avatar_url": "https://avatars.githubusercontent.com/u/5960217?v=4", 492 | "profile": "https://aswin.design/", 493 | "contributions": [ 494 | "doc" 495 | ] 496 | }, 497 | { 498 | "login": "iacopo87", 499 | "name": "Iacopo Pazzaglia", 500 | "avatar_url": "https://avatars.githubusercontent.com/u/8019803?v=4", 501 | "profile": "https://github.com/iacopo87", 502 | "contributions": [ 503 | "doc" 504 | ] 505 | }, 506 | { 507 | "login": "lucianoayres", 508 | "name": "Luciano Ayres", 509 | "avatar_url": "https://avatars.githubusercontent.com/u/20209393?v=4", 510 | "profile": "http://www.lucianoayres.com.br", 511 | "contributions": [ 512 | "doc" 513 | ] 514 | }, 515 | { 516 | "login": "sadikaya", 517 | "name": "Sadi Kaya", 518 | "avatar_url": "https://avatars.githubusercontent.com/u/2705775?v=4", 519 | "profile": "https://github.com/sadikaya", 520 | "contributions": [ 521 | "doc" 522 | ] 523 | }, 524 | { 525 | "login": "rowinbot", 526 | "name": "Rowin Hernández", 527 | "avatar_url": "https://avatars.githubusercontent.com/u/18468260?v=4", 528 | "profile": "https://github.com/rowinbot", 529 | "contributions": [ 530 | "doc" 531 | ] 532 | }, 533 | { 534 | "login": "arturopie", 535 | "name": "Arturo Pie", 536 | "avatar_url": "https://avatars.githubusercontent.com/u/762752?v=4", 537 | "profile": "https://github.com/arturopie", 538 | "contributions": [ 539 | "code" 540 | ] 541 | }, 542 | { 543 | "login": "jasikpark", 544 | "name": "Caleb Jasik", 545 | "avatar_url": "https://avatars.githubusercontent.com/u/10626596?v=4", 546 | "profile": "http://jasik.xyz", 547 | "contributions": [ 548 | "doc" 549 | ] 550 | }, 551 | { 552 | "login": "red17electro", 553 | "name": "Server Khalilov", 554 | "avatar_url": "https://avatars.githubusercontent.com/u/16454623?v=4", 555 | "profile": "http://serverkhalilov.com", 556 | "contributions": [ 557 | "doc" 558 | ] 559 | }, 560 | { 561 | "login": "AngadSethi", 562 | "name": "Angad Sethi", 563 | "avatar_url": "https://avatars.githubusercontent.com/u/58678541?v=4", 564 | "profile": "https://github.com/AngadSethi", 565 | "contributions": [ 566 | "doc" 567 | ] 568 | }, 569 | { 570 | "login": "marydavis", 571 | "name": "Mary", 572 | "avatar_url": "https://avatars.githubusercontent.com/u/176437?v=4", 573 | "profile": "https://github.com/marydavis", 574 | "contributions": [ 575 | "doc" 576 | ] 577 | }, 578 | { 579 | "login": "DiegoCardoso", 580 | "name": "Diego Cardoso", 581 | "avatar_url": "https://avatars.githubusercontent.com/u/262432?v=4", 582 | "profile": "https://github.com/DiegoCardoso", 583 | "contributions": [ 584 | "doc" 585 | ] 586 | }, 587 | { 588 | "login": "kmccoan", 589 | "name": "kmccoan", 590 | "avatar_url": "https://avatars.githubusercontent.com/u/4242047?v=4", 591 | "profile": "https://github.com/kmccoan", 592 | "contributions": [ 593 | "doc" 594 | ] 595 | }, 596 | { 597 | "login": "tatasadi", 598 | "name": "Ehsan Tatasadi", 599 | "avatar_url": "https://avatars.githubusercontent.com/u/18641021?v=4", 600 | "profile": "http://ehsan.tatasadi.com", 601 | "contributions": [ 602 | "doc" 603 | ] 604 | }, 605 | { 606 | "login": "PM6", 607 | "name": "PM6", 608 | "avatar_url": "https://avatars.githubusercontent.com/u/12296209?v=4", 609 | "profile": "https://github.com/PM6", 610 | "contributions": [ 611 | "code" 612 | ] 613 | }, 614 | { 615 | "login": "benjaminmatthews", 616 | "name": "benjaminmatthews", 617 | "avatar_url": "https://avatars.githubusercontent.com/u/6886936?v=4", 618 | "profile": "https://github.com/benjaminmatthews", 619 | "contributions": [ 620 | "doc" 621 | ] 622 | }, 623 | { 624 | "login": "junagao", 625 | "name": "juliane nagao", 626 | "avatar_url": "https://avatars.githubusercontent.com/u/615616?v=4", 627 | "profile": "http://junagao.com", 628 | "contributions": [ 629 | "doc" 630 | ] 631 | }, 632 | { 633 | "login": "Creeland", 634 | "name": "Creeland A. Provinsal ", 635 | "avatar_url": "https://avatars.githubusercontent.com/u/518406?v=4", 636 | "profile": "https://github.com/Creeland", 637 | "contributions": [ 638 | "doc" 639 | ] 640 | }, 641 | { 642 | "login": "McCambley", 643 | "name": "Jake McCambley", 644 | "avatar_url": "https://avatars.githubusercontent.com/u/74033573?v=4", 645 | "profile": "http://jakemccambley.com", 646 | "contributions": [ 647 | "doc" 648 | ] 649 | } 650 | ], 651 | "contributorsPerLine": 7, 652 | "projectName": "bookshelf", 653 | "projectOwner": "kentcdodds", 654 | "repoType": "github", 655 | "repoHost": "https://github.com", 656 | "skipCi": true, 657 | "commitConvention": "angular", 658 | "commitType": "docs" 659 | } 660 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=https://bookshelf.jk/api 2 | REACT_APP_AUTH_URL=https://auth-provider.jk/auth 3 | 4 | # Because we have a custom jest setup for the workshop we need to skip the warnings 5 | SKIP_PREFLIGHT_CHECK=true 6 | 7 | # To support custom jsx pragma (for emotion) 8 | DISABLE_NEW_JSX_TRANSFORM=true 9 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=http://localhost:8989/api -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | public 5 | .docz 6 | scripts/workshop-setup.js 7 | 8 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: validate 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | pull_request: 7 | branches: 8 | - 'main' 9 | jobs: 10 | setup: 11 | # ignore all-contributors PRs 12 | if: ${{ !contains(github.head_ref, 'all-contributors') }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, windows-latest, macos-latest] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - name: ⬇️ Checkout repo 19 | uses: actions/checkout@v3 20 | 21 | - name: ⎔ Setup node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: 18 25 | 26 | - name: npm 8 27 | run: npm install --global npm@8 28 | 29 | - name: ▶️ Run setup script 30 | run: npm run setup 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # testing 5 | /coverage 6 | /cypress/videos 7 | /cypress/screenshots 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | dev-tools.local.js 24 | 25 | public/exercise 26 | public/final 27 | public/extra-* 28 | 29 | # Allow people to make ignored files anywhere 30 | *.ignored.* 31 | ignored/ 32 | .eslintcache 33 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | package-lock=true 3 | yes=true 4 | legacy-peer-deps=true 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | public 5 | .docz 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": false, 4 | "endOfLine": "lf", 5 | "htmlWhitespaceSensitivity": "css", 6 | "insertPragma": false, 7 | "jsxBracketSameLine": false, 8 | "jsxSingleQuote": false, 9 | "printWidth": 80, 10 | "proseWrap": "always", 11 | "quoteProps": "as-needed", 12 | "requirePragma": false, 13 | "semi": false, 14 | "singleQuote": true, 15 | "tabWidth": 2, 16 | "trailingComma": "all", 17 | "useTabs": false 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "formulahendry.auto-rename-tag", 6 | "VisualStudioExptTeam.vscodeintellicode", 7 | "shd101wyy.markdown-preview-enhanced" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.kcd.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.detectIndentation": true, 5 | "editor.fontFamily": "'Dank Mono', Menlo, Monaco, 'Courier New', monospace", 6 | "editor.fontLigatures": false, 7 | "editor.rulers": [80], 8 | "editor.snippetSuggestions": "top", 9 | "editor.wordBasedSuggestions": false, 10 | "editor.suggest.localityBonus": true, 11 | "editor.acceptSuggestionOnCommitCharacter": false, 12 | "[javascript]": { 13 | "editor.defaultFormatter": "esbenp.prettier-vscode", 14 | "editor.suggestSelection": "recentlyUsed", 15 | "editor.suggest.showKeywords": false 16 | }, 17 | "editor.renderWhitespace": "boundary", 18 | "files.defaultLanguage": "{activeEditorLanguage}", 19 | "javascript.validate.enable": false, 20 | "search.exclude": { 21 | "**/node_modules": true, 22 | "**/bower_components": true, 23 | "**/coverage": true, 24 | "**/dist": true, 25 | "**/build": true, 26 | "**/.build": true, 27 | "**/.gh-pages": true 28 | }, 29 | "editor.codeActionsOnSave": { 30 | "source.fixAll.eslint": false 31 | }, 32 | "eslint.validate": [ 33 | "javascript", 34 | "javascriptreact", 35 | "typescript", 36 | "typescriptreact" 37 | ], 38 | "eslint.options": { 39 | "env": { 40 | "browser": true, 41 | "jest/globals": true, 42 | "es6": true 43 | }, 44 | "parserOptions": { 45 | "ecmaVersion": 2019, 46 | "sourceType": "module", 47 | "ecmaFeatures": { 48 | "jsx": true 49 | } 50 | }, 51 | "rules": { 52 | "no-debugger": "off" 53 | } 54 | }, 55 | "workbench.colorTheme": "Night Owl", 56 | "workbench.iconTheme": "material-icon-theme", 57 | "breadcrumbs.enabled": true, 58 | "grunt.autoDetect": "off", 59 | "gulp.autoDetect": "off", 60 | "npm.runSilent": true, 61 | "explorer.confirmDragAndDrop": false, 62 | "editor.formatOnPaste": false, 63 | "editor.cursorSmoothCaretAnimation": true, 64 | "editor.smoothScrolling": true, 65 | "php.suggest.basic": false 66 | } 67 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of 9 | experience, nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or reject 41 | comments, commits, code, wiki edits, issues, and other contributions that are 42 | not aligned to this Code of Conduct, or to ban temporarily or permanently any 43 | contributor for other behaviors that they deem inappropriate, threatening, 44 | offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at kent+coc@doddsfamily.us. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an 62 | incident. Further details of specific enforcement policies may be posted 63 | separately. 64 | 65 | Project maintainers who do not follow or enforce the Code of Conduct in good 66 | faith may face temporary or permanent repercussions as determined by other 67 | members of the project's leadership. 68 | 69 | ## Attribution 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 72 | version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 73 | 74 | [homepage]: http://contributor-covenant.org 75 | [version]: http://contributor-covenant.org/version/1/4/ 76 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | **Working on your first Pull Request?** You can learn how from this _free_ 6 | series [How to Contribute to an Open Source Project on GitHub][egghead] 7 | 8 | ## READ THIS PLEASE 9 | 10 | Due to the way this project works, each of the exercises is self-contained in a 11 | branch that starts with `exercises/` which is _only one commit_ off of master. 12 | This is critically important for the tooling that we have to make it easy to 13 | manage the exercises over time. 14 | 15 | Unfortunately, because of this requirement, it's impossible to merge PRs that 16 | are made to exercise branches (because it's impossible to maintain the 17 | one-commit requirement). 18 | 19 | So, if you want to make a change to one of the `exercises/` branches, you're 20 | welcome to open a pull request, but I will have to apply any needed changes 21 | myself and will close your PR (though I will still add you to the contributors 22 | table). 23 | 24 | If your changes are to the `main` branch, then the pull request workflow is 25 | normal. 26 | 27 | ## Project setup 28 | 29 | 1. Fork and clone the repo 30 | 2. Run `npm run setup -s` to install dependencies and run validation 31 | 3. Create a branch for your PR with `git checkout -b pr/your-branch-name` 32 | 33 | > Tip: Keep your `main` branch pointing at the original repository and make pull 34 | > requests from branches on your fork. To do this, run: 35 | > 36 | > ``` 37 | > git remote add upstream https://github.com/kentcdodds/bookshelf.git 38 | > git fetch upstream 39 | > git branch --set-upstream-to=upstream/main main 40 | > ``` 41 | > 42 | > This will add the original repository as a "remote" called "upstream," Then 43 | > fetch the git information from that remote, then set your local `main` branch 44 | > to use the upstream main branch whenever you run `git pull`. Then you can make 45 | > all of your pull request branches based on this `main` branch. Whenever you 46 | > want to update your version of `main`, do a regular `git pull`. 47 | 48 | ## Help needed 49 | 50 | Please checkout the [the open issues][issues] 51 | 52 | Also, please watch the repo and respond to questions/bug reports/feature 53 | requests! Thanks! 54 | 55 | [egghead]: 56 | https://app.egghead.io/playlists/how-to-contribute-to-an-open-source-project-on-github 57 | [issues]: https://github.com/kentcdodds/bookshelf/issues 58 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16 2 | 3 | WORKDIR /app 4 | COPY . . 5 | RUN NO_EMAIL_AUTOFILL=true node setup 6 | 7 | CMD ["npm", "start"] 8 | -------------------------------------------------------------------------------- /INSTRUCTIONS.md: -------------------------------------------------------------------------------- 1 | # Bookshelf Instructions 2 | 3 | ## 📝 Your Notes 4 | 5 | Elaborate on your learnings here in `INSTRUCTIONS.md` 6 | 7 | ## Background 8 | 9 | Each exercise will have some background information to help orient you around 10 | the new concepts we'll be learning. 11 | 12 | ## Exercise 13 | 14 | Here's where the exercise description will go. 👨‍💼 Peter will be giving you 15 | project requirements here. 16 | 17 | ### Files 18 | 19 | A list of the files you need to open to complete the exercise will be here. For 20 | each file, there will be another file next to it with the suffix `.final` which 21 | you can use as a reference if you get totally stuck. 22 | 23 | ## Extra Credit 24 | 25 | ### 💯 Example 26 | 27 | Some of the exercises will come with extra credit you can do. 28 | 29 | ## 🦉 Elaboration and Feedback 30 | 31 | After the instruction, if you want to remember what you've just learned, then 32 | fill out the elaboration and feedback form: 33 | 34 | https://ws.kcd.im/?ws=Build%20React%20Apps&e=&em= 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | This material is available for private, non-commercial use under the 2 | [GPL version 3](http://www.gnu.org/licenses/gpl-3.0-standalone.html). If you 3 | would like to use this material to conduct your own workshop, please contact me 4 | at me@kentcdodds.com 5 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const isCI = require('is-ci') 2 | 3 | module.exports = { 4 | e2e: { 5 | specPattern: 'cypress/e2e', 6 | excludeSpecPattern: '**/*.+(exercise|final|extra-)*.js', 7 | setupNodeEvents(on, config) { 8 | const isDev = config.watchForFileChanges 9 | if (!isCI) { 10 | config.baseUrl = isDev 11 | ? 'http://localhost:3000' 12 | : 'http://localhost:8811' 13 | } 14 | return config 15 | }, 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /cypress/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | plugins: ['eslint-plugin-cypress'], 4 | extends: ['react-app', 'plugin:cypress/recommended'], 5 | env: {'cypress/globals': true}, 6 | } 7 | -------------------------------------------------------------------------------- /cypress/e2e/smoke.js: -------------------------------------------------------------------------------- 1 | import {buildUser} from '../support/generate' 2 | 3 | describe('smoke', () => { 4 | it('should allow a typical user flow', () => { 5 | const user = buildUser() 6 | cy.visit('/') 7 | 8 | cy.findByRole('button', {name: /register/i}).click() 9 | 10 | cy.findByRole('dialog').within(() => { 11 | cy.findByRole('textbox', {name: /username/i}).type(user.username) 12 | cy.findByLabelText(/password/i).type(user.password) 13 | cy.findByRole('button', {name: /register/i}).click() 14 | }) 15 | 16 | cy.findByRole('navigation').within(() => { 17 | cy.findByRole('link', {name: /discover/i}).click() 18 | }) 19 | 20 | cy.findByRole('main').within(() => { 21 | cy.findByRole('searchbox', {name: /search/i}).type('Voice of war{enter}') 22 | cy.findByRole('listitem', {name: /voice of war/i}).within(() => { 23 | cy.findByRole('button', {name: /add to list/i}).click() 24 | }) 25 | }) 26 | 27 | cy.findByRole('navigation').within(() => { 28 | cy.findByRole('link', {name: /reading list/i}).click() 29 | }) 30 | 31 | cy.findByRole('main').within(() => { 32 | cy.findAllByRole('listitem').should('have.length', 1) 33 | cy.findByRole('link', {name: /voice of war/i}).click() 34 | }) 35 | 36 | cy.findByRole('textbox', {name: /notes/i}).type('This is an awesome book') 37 | cy.findByLabelText(/loading/i).should('exist') 38 | cy.findByLabelText(/loading/i).should('not.exist') 39 | 40 | cy.findByRole('button', {name: /mark as read/i}).click() 41 | 42 | // the radio buttons are fancy and the inputs themselves are visually hidden 43 | // in favor of nice looking stars, so we have to force the click. 44 | cy.findByRole('radio', {name: /5 stars/i}).click({force: true}) 45 | 46 | cy.findByRole('navigation').within(() => { 47 | cy.findByRole('link', {name: /finished books/i}).click() 48 | }) 49 | 50 | cy.findByRole('main').within(() => { 51 | cy.findAllByRole('listitem').should('have.length', 1) 52 | cy.findByRole('radio', {name: /5 stars/i}).should('be.checked') 53 | cy.findByRole('link', {name: /voice of war/i}).click() 54 | }) 55 | 56 | cy.findByRole('button', {name: /remove from list/i}).click() 57 | cy.findByRole('textbox', {name: /notes/i}).should('not.exist') 58 | cy.findByRole('radio', {name: /5 stars/i}).should('not.exist') 59 | 60 | cy.findByRole('navigation').within(() => { 61 | cy.findByRole('link', {name: /finished books/i}).click() 62 | }) 63 | 64 | cy.findByRole('main').within(() => { 65 | cy.findAllByRole('listitem').should('have.length', 0) 66 | }) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../jsconfig.json", 3 | "compilerOptions": { 4 | // be explicit about types included 5 | // to avoid clashing with Jest types 6 | "types": ["cypress"] 7 | }, 8 | "include": ["../node_modules/cypress", "./**/*.js"] 9 | } 10 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | import 'cypress-hmr-restarter' 2 | import '@testing-library/cypress/add-commands' 3 | -------------------------------------------------------------------------------- /cypress/support/generate.js: -------------------------------------------------------------------------------- 1 | export * from '../../src/test/generate' 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | node: 5 | build: . 6 | volumes: 7 | - ./src:/app/src 8 | ports: 9 | - '3000:3000' 10 | -------------------------------------------------------------------------------- /go.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs') 4 | const { 5 | spawnSync, 6 | getExerciseBranches, 7 | getVariants, 8 | getExtraCreditTitles, 9 | } = require('./scripts/utils') 10 | 11 | const actions = { 12 | changeExercise, 13 | startExtraCredit, 14 | } 15 | 16 | const currentBranch = spawnSync('git rev-parse --abbrev-ref HEAD') 17 | 18 | async function go() { 19 | if (currentBranch === 'main') { 20 | // if we're on main then you can't do anything else 21 | await changeExercise() 22 | return 23 | } 24 | 25 | const {action} = await ( 26 | await import('inquirer') 27 | ).default.prompt([ 28 | { 29 | name: 'action', 30 | message: `What do you want to do?`, 31 | type: 'list', 32 | choices: [ 33 | {name: 'Change Exercise', value: 'changeExercise'}, 34 | {name: 'Start Extra Credit', value: 'startExtraCredit'}, 35 | ], 36 | }, 37 | ]) 38 | await actions[action]() 39 | } 40 | 41 | function getDisplayName(exerciseBranch) { 42 | const match = exerciseBranch.match( 43 | /exercises\/(?\d\d)-(?.*?)$/, 44 | ) 45 | const title = match.groups.title.split('-').join(' ') 46 | const capitalizedTitle = title.slice(0, 1).toUpperCase() + title.slice(1) 47 | return `${match.groups.number}. ${capitalizedTitle}` 48 | } 49 | 50 | async function changeExercise() { 51 | const {branch} = await ( 52 | await import('inquirer') 53 | ).default.prompt([ 54 | { 55 | name: 'branch', 56 | message: `Which exercise do you want to start working on?`, 57 | type: 'list', 58 | default: currentBranch, 59 | choices: [ 60 | {name: 'Return to main', value: 'main'}, 61 | ...getExerciseBranches().map(b => ({ 62 | name: getDisplayName(b), 63 | value: b, 64 | })), 65 | ], 66 | }, 67 | ]) 68 | spawnSync('git add -A') 69 | spawnSync('git reset --hard HEAD') 70 | spawnSync(`git checkout ${branch}`) 71 | if (branch.startsWith('exercises/')) { 72 | spawnSync('node ./scripts/swap exercise') 73 | } 74 | console.log(`✅ Ready to start work in ${branch}`) 75 | } 76 | 77 | async function startExtraCredit() { 78 | const variants = getVariants() 79 | const maxExtra = Math.max( 80 | ...Object.values(variants) 81 | .reduce((acc, v) => [...acc, ...v.extras], []) 82 | .map(e => e.number), 83 | ) 84 | 85 | const extraCreditTitles = getExtraCreditTitles() 86 | 87 | function getVariantDisplayName(variant) { 88 | if (variant === 'final') return 'Final' 89 | return `Extra Credit ${variant}: ${extraCreditTitles[variant - 1]}` 90 | } 91 | 92 | const {variant} = await ( 93 | await import('inquirer') 94 | ).default.prompt([ 95 | { 96 | name: 'variant', 97 | message: `Which part do you want to work on?`, 98 | type: 'list', 99 | choices: [ 100 | {name: 'Final', value: 'final'}, 101 | ...Array.from({length: maxExtra}, (v, i) => ({ 102 | name: getVariantDisplayName(i + 1), 103 | value: i + 1, 104 | })), 105 | ], 106 | }, 107 | ]) 108 | 109 | for (const {extras, exercise, final} of Object.values(variants)) { 110 | const availableECs = extras.map(e => e.number).filter(n => n < variant) 111 | const maxEC = Math.max(...availableECs) 112 | const maxExtra = extras.find(e => e.number === maxEC) 113 | 114 | if (variant === 'final' || (!maxExtra && !final)) { 115 | // reset the exercise to the original state 116 | spawnSync(`git checkout -- ${exercise.file}`) 117 | } else { 118 | const newExerciseContents = fs.readFileSync((maxExtra || final).file, { 119 | encoding: 'utf-8', 120 | }) 121 | fs.writeFileSync(exercise.file, newExerciseContents) 122 | } 123 | } 124 | console.log(`✅ Ready to start working on ${getVariantDisplayName(variant)}`) 125 | } 126 | 127 | go() 128 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const resolve = require('resolve') 4 | 5 | process.env.BABEL_ENV = 'test' 6 | process.env.NODE_ENV = 'test' 7 | process.env.PUBLIC_URL = '' 8 | 9 | require('react-scripts/config/env') 10 | 11 | module.exports = { 12 | roots: ['<rootDir>/src'], 13 | testMatch: ['**/__tests__/**/*.js'], 14 | testEnvironment: resolve.sync('jest-environment-jsdom', { 15 | basedir: require.resolve('jest'), 16 | }), 17 | 18 | // this testPathIgnorePatterns config just makes things work with the way we 19 | // have to do things for this workshop to work. You shouldn't need this in 20 | // your own jest config. NOTE: This is the *entire* reason we need a custom 21 | // jest config. Otherwise we'd be able to use regular react-scripts 22 | // so in your apps, react-scripts should work just fine. 23 | testPathIgnorePatterns: [ 24 | '/node_modules/', 25 | 'exercise\\.js$', 26 | 'final\\.js$', 27 | 'extra-\\d+\\.js$', 28 | ], 29 | setupFiles: [require.resolve('whatwg-fetch')], 30 | // some of the exercise branches don't have setupTests.js 31 | setupFilesAfterEnv: fs.existsSync('src/setupTests.js') 32 | ? ['<rootDir>/src/setupTests.js'] 33 | : [], 34 | moduleDirectories: ['node_modules', path.join(__dirname, './src')], 35 | transform: { 36 | '^.+\\.(js|jsx|mjs|cjs|ts|tsx)$': require.resolve( 37 | 'react-scripts/config/jest/babelTransform', 38 | ), 39 | '^.+\\.css$': require.resolve('react-scripts/config/jest/cssTransform.js'), 40 | '^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)': require.resolve( 41 | 'react-scripts/config/jest/fileTransform.js', 42 | ), 43 | }, 44 | transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(js|jsx)$'], 45 | resetMocks: true, 46 | collectCoverageFrom: [ 47 | 'src/**/*.js', 48 | '!<rootDir>/node_modules/**/*', 49 | '!<rootDir>/src/test/**/*', 50 | '!<rootDir>/src/setupProxy*', 51 | '!<rootDir>/src/setupTests*', 52 | '!<rootDir>/src/dev-tools/**/*', 53 | ], 54 | watchPlugins: [ 55 | 'jest-watch-typeahead/filename', 56 | 'jest-watch-typeahead/testname', 57 | ], 58 | } 59 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src" 4 | }, 5 | "include": ["src"] 6 | } 7 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run validate" 3 | publish = "build" 4 | [[plugins]] 5 | package = "netlify-plugin-cypress" 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bookshelf", 3 | "version": "1.0.0", 4 | "private": true, 5 | "author": "Kent C. Dodds <me@kentcdodds.com> (https://kentcdodds.com/)", 6 | "license": "GPL-3.0-only", 7 | "homepage": "https://bookshelf.lol/", 8 | "engines": { 9 | "node": ">=16", 10 | "npm": ">=8.16.0" 11 | }, 12 | "dependencies": { 13 | "@emotion/core": "^10.0.35", 14 | "@emotion/styled": "^10.0.27", 15 | "@reach/dialog": "^0.17.0", 16 | "@reach/menu-button": "^0.17.0", 17 | "@reach/tabs": "^0.17.0", 18 | "@reach/tooltip": "^0.17.0", 19 | "@reach/visually-hidden": "^0.17.0", 20 | "bootstrap": "^5.1.3", 21 | "codegen.macro": "^4.1.0", 22 | "debounce-fn": "^4.0.0", 23 | "faker": "^5.5.3", 24 | "history": "^5.3.0", 25 | "match-sorter": "^6.3.1", 26 | "msw": "^0.42.1", 27 | "prop-types": "^15.8.1", 28 | "react": "^18.2.0", 29 | "react-dom": "^18.2.0", 30 | "react-error-boundary": "^3.1.4", 31 | "react-icons": "^4.4.0", 32 | "react-query": "2.1.1", 33 | "react-query-devtools": "2.3.3", 34 | "react-router": "^6.3.0", 35 | "react-router-dom": "^6.3.0", 36 | "react-scripts": "^5.0.1", 37 | "stop-runaway-react-effects": "^2.0.0" 38 | }, 39 | "devDependencies": { 40 | "@babel/preset-react": "^7.17.12", 41 | "@testing-library/cypress": "^8.0.3", 42 | "@testing-library/jest-dom": "^5.16.4", 43 | "@testing-library/react": "^13.3.0", 44 | "@testing-library/user-event": "^14.2.1", 45 | "@types/react": "^18.0.14", 46 | "@types/react-dom": "^18.0.5", 47 | "cross-env": "^7.0.3", 48 | "cypress": "^10.1.0", 49 | "cypress-hmr-restarter": "^2.0.3", 50 | "eslint-plugin-cypress": "^2.12.1", 51 | "husky": "4.3.8", 52 | "inquirer": "^9.0.0", 53 | "is-ci": "^3.0.1", 54 | "is-ci-cli": "^2.2.0", 55 | "jest": "^27.4.3", 56 | "jest-watch-typeahead": "^0.6.4", 57 | "node-match-path": "^0.6.3", 58 | "npm-run-all": "^4.1.5", 59 | "prettier": "^2.7.1", 60 | "react-test-renderer": "^18.2.0", 61 | "resolve": "^1.22.1", 62 | "serve": "^13.0.2", 63 | "start-server-and-test": "^1.14.0", 64 | "whatwg-fetch": "^3.6.2" 65 | }, 66 | "scripts": { 67 | "start": "react-scripts start", 68 | "start:cli": "cross-env BROWSER=none react-scripts start", 69 | "build": "react-scripts build --profile", 70 | "test": "is-ci-cli \"test:coverage\" \"test:watch\"", 71 | "test:watch": "jest --watch", 72 | "test:coverage": "jest --watch=false --coverage", 73 | "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --watch --runInBand", 74 | "cy:install": "cypress install", 75 | "cy:run": "cypress run", 76 | "cy:open": "cypress open", 77 | "test:e2e": "start-server-and-test start:cli http://localhost:3000/list cy:open", 78 | "test:e2e:run": "start-server-and-test serve http://localhost:8811/list cy:run", 79 | "serve": "serve --no-clipboard --single --listen 8811 build", 80 | "lint": "eslint . --cache-location node_modules/.cache/eslint", 81 | "format": "prettier --write \"**/*.+(js|json|css|md|mdx|html)\"", 82 | "setup": "node setup", 83 | "validate-exercises": "node ./scripts/validate-exercises", 84 | "validate": "npm run validate-exercises && npm-run-all --parallel lint test:coverage build" 85 | }, 86 | "babel": { 87 | "presets": [ 88 | "@babel/preset-react" 89 | ] 90 | }, 91 | "eslintConfig": { 92 | "extends": "react-app" 93 | }, 94 | "husky": { 95 | "hooks": { 96 | "pre-push": "node ./scripts/pre-push" 97 | } 98 | }, 99 | "browserslist": { 100 | "development": [ 101 | "last 2 chrome versions", 102 | "last 2 firefox versions", 103 | "last 2 edge versions" 104 | ], 105 | "production": [ 106 | ">1%", 107 | "last 4 versions", 108 | "Firefox ESR", 109 | "not ie < 11" 110 | ] 111 | }, 112 | "description": "<div> <h1 align=\"center\"><a href=\"https://epicreact.dev\">Build an Epic React App 🚀 EpicReact.Dev</a></h1> <strong> Building a full React application </strong> <p> The React and JavaScript ecosystem is full of tools and libraries to help you build your applications. In this (huge) workshop we’ll build an application from scratch using widely supported and proven tools and techniques. We’ll cover everything about building frontend React applications, from the absolute basics to the tricky parts you'll run into building real world React apps and how to create great abstractions. </p>", 113 | "main": "go.js", 114 | "repository": { 115 | "type": "git", 116 | "url": "git+https://github.com/kentcdodds/bookshelf.git" 117 | }, 118 | "keywords": [], 119 | "bugs": { 120 | "url": "https://github.com/kentcdodds/bookshelf/issues" 121 | }, 122 | "msw": { 123 | "workerDirectory": "public" 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | / /list 302! 2 | /* /index.html 200 -------------------------------------------------------------------------------- /public/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/android-icon-144x144.png -------------------------------------------------------------------------------- /public/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/android-icon-192x192.png -------------------------------------------------------------------------------- /public/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/android-icon-36x36.png -------------------------------------------------------------------------------- /public/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/android-icon-48x48.png -------------------------------------------------------------------------------- /public/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/android-icon-72x72.png -------------------------------------------------------------------------------- /public/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/android-icon-96x96.png -------------------------------------------------------------------------------- /public/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/apple-icon-114x114.png -------------------------------------------------------------------------------- /public/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/apple-icon-120x120.png -------------------------------------------------------------------------------- /public/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/apple-icon-144x144.png -------------------------------------------------------------------------------- /public/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/apple-icon-152x152.png -------------------------------------------------------------------------------- /public/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/apple-icon-180x180.png -------------------------------------------------------------------------------- /public/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/apple-icon-57x57.png -------------------------------------------------------------------------------- /public/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/apple-icon-60x60.png -------------------------------------------------------------------------------- /public/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/apple-icon-72x72.png -------------------------------------------------------------------------------- /public/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/apple-icon-76x76.png -------------------------------------------------------------------------------- /public/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/apple-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/apple-icon.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="utf-8"?> 2 | <browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig> -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/favicon-96x96.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="utf-8"> 5 | <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"> 6 | <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 7 | <meta name="theme-color" content="#000000"> 8 | <!-- 9 | manifest.json provides metadata used when your web app is added to the 10 | homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/ 11 | --> 12 | <link rel="manifest" href="%PUBLIC_URL%/manifest.json"> 13 | <!-- 14 | Notice the use of %PUBLIC_URL% in the tags above. 15 | It will be replaced with the URL of the `public` folder during the build. 16 | Only files inside the `public` folder can be referenced from the HTML. 17 | 18 | Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will 19 | work correctly both with client-side routing and a non-root public URL. 20 | Learn how to configure a non-root public URL by running `npm run build`. 21 | --> 22 | <title>The React Bookshelf App 23 | 24 | 25 |
26 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "The React Bookshelf", 3 | "name": "The React Bookshelf 📚", 4 | "start_url": ".", 5 | "display": "standalone", 6 | "theme_color": "#000000", 7 | "background_color": "#ffffff", 8 | "icons": [ 9 | { 10 | "src": "\/android-icon-36x36.png", 11 | "sizes": "36x36", 12 | "type": "image\/png", 13 | "density": "0.75" 14 | }, 15 | { 16 | "src": "\/android-icon-48x48.png", 17 | "sizes": "48x48", 18 | "type": "image\/png", 19 | "density": "1.0" 20 | }, 21 | { 22 | "src": "\/android-icon-72x72.png", 23 | "sizes": "72x72", 24 | "type": "image\/png", 25 | "density": "1.5" 26 | }, 27 | { 28 | "src": "\/android-icon-96x96.png", 29 | "sizes": "96x96", 30 | "type": "image\/png", 31 | "density": "2.0" 32 | }, 33 | { 34 | "src": "\/android-icon-144x144.png", 35 | "sizes": "144x144", 36 | "type": "image\/png", 37 | "density": "3.0" 38 | }, 39 | { 40 | "src": "\/android-icon-192x192.png", 41 | "sizes": "192x192", 42 | "type": "image\/png", 43 | "density": "4.0" 44 | } 45 | ] 46 | } -------------------------------------------------------------------------------- /public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | 4 | /** 5 | * Mock Service Worker (0.42.1). 6 | * @see https://github.com/mswjs/msw 7 | * - Please do NOT modify this file. 8 | * - Please do NOT serve this file on production. 9 | */ 10 | 11 | const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929' 12 | const bypassHeaderName = 'x-msw-bypass' 13 | const activeClientIds = new Set() 14 | 15 | self.addEventListener('install', function () { 16 | return self.skipWaiting() 17 | }) 18 | 19 | self.addEventListener('activate', async function (event) { 20 | return self.clients.claim() 21 | }) 22 | 23 | self.addEventListener('message', async function (event) { 24 | const clientId = event.source.id 25 | 26 | if (!clientId || !self.clients) { 27 | return 28 | } 29 | 30 | const client = await self.clients.get(clientId) 31 | 32 | if (!client) { 33 | return 34 | } 35 | 36 | const allClients = await self.clients.matchAll() 37 | 38 | switch (event.data) { 39 | case 'KEEPALIVE_REQUEST': { 40 | sendToClient(client, { 41 | type: 'KEEPALIVE_RESPONSE', 42 | }) 43 | break 44 | } 45 | 46 | case 'INTEGRITY_CHECK_REQUEST': { 47 | sendToClient(client, { 48 | type: 'INTEGRITY_CHECK_RESPONSE', 49 | payload: INTEGRITY_CHECKSUM, 50 | }) 51 | break 52 | } 53 | 54 | case 'MOCK_ACTIVATE': { 55 | activeClientIds.add(clientId) 56 | 57 | sendToClient(client, { 58 | type: 'MOCKING_ENABLED', 59 | payload: true, 60 | }) 61 | break 62 | } 63 | 64 | case 'MOCK_DEACTIVATE': { 65 | activeClientIds.delete(clientId) 66 | break 67 | } 68 | 69 | case 'CLIENT_CLOSED': { 70 | activeClientIds.delete(clientId) 71 | 72 | const remainingClients = allClients.filter((client) => { 73 | return client.id !== clientId 74 | }) 75 | 76 | // Unregister itself when there are no more clients 77 | if (remainingClients.length === 0) { 78 | self.registration.unregister() 79 | } 80 | 81 | break 82 | } 83 | } 84 | }) 85 | 86 | // Resolve the "main" client for the given event. 87 | // Client that issues a request doesn't necessarily equal the client 88 | // that registered the worker. It's with the latter the worker should 89 | // communicate with during the response resolving phase. 90 | async function resolveMainClient(event) { 91 | const client = await self.clients.get(event.clientId) 92 | 93 | if (client.frameType === 'top-level') { 94 | return client 95 | } 96 | 97 | const allClients = await self.clients.matchAll() 98 | 99 | return allClients 100 | .filter((client) => { 101 | // Get only those clients that are currently visible. 102 | return client.visibilityState === 'visible' 103 | }) 104 | .find((client) => { 105 | // Find the client ID that's recorded in the 106 | // set of clients that have registered the worker. 107 | return activeClientIds.has(client.id) 108 | }) 109 | } 110 | 111 | async function handleRequest(event, requestId) { 112 | const client = await resolveMainClient(event) 113 | const response = await getResponse(event, client, requestId) 114 | 115 | // Send back the response clone for the "response:*" life-cycle events. 116 | // Ensure MSW is active and ready to handle the message, otherwise 117 | // this message will pend indefinitely. 118 | if (client && activeClientIds.has(client.id)) { 119 | ;(async function () { 120 | const clonedResponse = response.clone() 121 | sendToClient(client, { 122 | type: 'RESPONSE', 123 | payload: { 124 | requestId, 125 | type: clonedResponse.type, 126 | ok: clonedResponse.ok, 127 | status: clonedResponse.status, 128 | statusText: clonedResponse.statusText, 129 | body: 130 | clonedResponse.body === null ? null : await clonedResponse.text(), 131 | headers: serializeHeaders(clonedResponse.headers), 132 | redirected: clonedResponse.redirected, 133 | }, 134 | }) 135 | })() 136 | } 137 | 138 | return response 139 | } 140 | 141 | async function getResponse(event, client, requestId) { 142 | const { request } = event 143 | const requestClone = request.clone() 144 | const getOriginalResponse = () => fetch(requestClone) 145 | 146 | // Bypass mocking when the request client is not active. 147 | if (!client) { 148 | return getOriginalResponse() 149 | } 150 | 151 | // Bypass initial page load requests (i.e. static assets). 152 | // The absence of the immediate/parent client in the map of the active clients 153 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 154 | // and is not ready to handle requests. 155 | if (!activeClientIds.has(client.id)) { 156 | return await getOriginalResponse() 157 | } 158 | 159 | // Bypass requests with the explicit bypass header 160 | if (requestClone.headers.get(bypassHeaderName) === 'true') { 161 | const cleanRequestHeaders = serializeHeaders(requestClone.headers) 162 | 163 | // Remove the bypass header to comply with the CORS preflight check. 164 | delete cleanRequestHeaders[bypassHeaderName] 165 | 166 | const originalRequest = new Request(requestClone, { 167 | headers: new Headers(cleanRequestHeaders), 168 | }) 169 | 170 | return fetch(originalRequest) 171 | } 172 | 173 | // Send the request to the client-side MSW. 174 | const reqHeaders = serializeHeaders(request.headers) 175 | const body = await request.text() 176 | 177 | const clientMessage = await sendToClient(client, { 178 | type: 'REQUEST', 179 | payload: { 180 | id: requestId, 181 | url: request.url, 182 | method: request.method, 183 | headers: reqHeaders, 184 | cache: request.cache, 185 | mode: request.mode, 186 | credentials: request.credentials, 187 | destination: request.destination, 188 | integrity: request.integrity, 189 | redirect: request.redirect, 190 | referrer: request.referrer, 191 | referrerPolicy: request.referrerPolicy, 192 | body, 193 | bodyUsed: request.bodyUsed, 194 | keepalive: request.keepalive, 195 | }, 196 | }) 197 | 198 | switch (clientMessage.type) { 199 | case 'MOCK_SUCCESS': { 200 | return delayPromise( 201 | () => respondWithMock(clientMessage), 202 | clientMessage.payload.delay, 203 | ) 204 | } 205 | 206 | case 'MOCK_NOT_FOUND': { 207 | return getOriginalResponse() 208 | } 209 | 210 | case 'NETWORK_ERROR': { 211 | const { name, message } = clientMessage.payload 212 | const networkError = new Error(message) 213 | networkError.name = name 214 | 215 | // Rejecting a request Promise emulates a network error. 216 | throw networkError 217 | } 218 | 219 | case 'INTERNAL_ERROR': { 220 | const parsedBody = JSON.parse(clientMessage.payload.body) 221 | 222 | console.error( 223 | `\ 224 | [MSW] Uncaught exception in the request handler for "%s %s": 225 | 226 | ${parsedBody.location} 227 | 228 | This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ 229 | `, 230 | request.method, 231 | request.url, 232 | ) 233 | 234 | return respondWithMock(clientMessage) 235 | } 236 | } 237 | 238 | return getOriginalResponse() 239 | } 240 | 241 | self.addEventListener('fetch', function (event) { 242 | const { request } = event 243 | const accept = request.headers.get('accept') || '' 244 | 245 | // Bypass server-sent events. 246 | if (accept.includes('text/event-stream')) { 247 | return 248 | } 249 | 250 | // Bypass navigation requests. 251 | if (request.mode === 'navigate') { 252 | return 253 | } 254 | 255 | // Opening the DevTools triggers the "only-if-cached" request 256 | // that cannot be handled by the worker. Bypass such requests. 257 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 258 | return 259 | } 260 | 261 | // Bypass all requests when there are no active clients. 262 | // Prevents the self-unregistered worked from handling requests 263 | // after it's been deleted (still remains active until the next reload). 264 | if (activeClientIds.size === 0) { 265 | return 266 | } 267 | 268 | const requestId = uuidv4() 269 | 270 | return event.respondWith( 271 | handleRequest(event, requestId).catch((error) => { 272 | if (error.name === 'NetworkError') { 273 | console.warn( 274 | '[MSW] Successfully emulated a network error for the "%s %s" request.', 275 | request.method, 276 | request.url, 277 | ) 278 | return 279 | } 280 | 281 | // At this point, any exception indicates an issue with the original request/response. 282 | console.error( 283 | `\ 284 | [MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, 285 | request.method, 286 | request.url, 287 | `${error.name}: ${error.message}`, 288 | ) 289 | }), 290 | ) 291 | }) 292 | 293 | function serializeHeaders(headers) { 294 | const reqHeaders = {} 295 | headers.forEach((value, name) => { 296 | reqHeaders[name] = reqHeaders[name] 297 | ? [].concat(reqHeaders[name]).concat(value) 298 | : value 299 | }) 300 | return reqHeaders 301 | } 302 | 303 | function sendToClient(client, message) { 304 | return new Promise((resolve, reject) => { 305 | const channel = new MessageChannel() 306 | 307 | channel.port1.onmessage = (event) => { 308 | if (event.data && event.data.error) { 309 | return reject(event.data.error) 310 | } 311 | 312 | resolve(event.data) 313 | } 314 | 315 | client.postMessage(JSON.stringify(message), [channel.port2]) 316 | }) 317 | } 318 | 319 | function delayPromise(cb, duration) { 320 | return new Promise((resolve) => { 321 | setTimeout(() => resolve(cb()), duration) 322 | }) 323 | } 324 | 325 | function respondWithMock(clientMessage) { 326 | return new Response(clientMessage.payload.body, { 327 | ...clientMessage.payload, 328 | headers: clientMessage.payload.headers, 329 | }) 330 | } 331 | 332 | function uuidv4() { 333 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 334 | const r = (Math.random() * 16) | 0 335 | const v = c == 'x' ? r : (r & 0x3) | 0x8 336 | return v.toString(16) 337 | }) 338 | } 339 | -------------------------------------------------------------------------------- /public/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/ms-icon-144x144.png -------------------------------------------------------------------------------- /public/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/ms-icon-150x150.png -------------------------------------------------------------------------------- /public/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/ms-icon-310x310.png -------------------------------------------------------------------------------- /public/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/bookshelf/32e9e87db958de863bead65761bfbe2dec0eafd4/public/ms-icon-70x70.png -------------------------------------------------------------------------------- /public/serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "redirects": [ 3 | { 4 | "source": "/", 5 | "destination": "/list", 6 | "type": 302 7 | } 8 | ] 9 | } -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "node", 3 | "container": { 4 | "startScript": "start", 5 | "port": 3000, 6 | "node": "14" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /scripts/build-variants.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const pkg = require('../package.json') 4 | const {spawnSync, getExtraCreditTitles} = require('./utils') 5 | 6 | const branch = spawnSync('git rev-parse --abbrev-ref HEAD') 7 | if (branch === 'main') { 8 | console.log('Cannot run swap on main as there are no exercises.') 9 | } else { 10 | go() 11 | } 12 | 13 | function go() { 14 | const variants = [ 15 | 'exercise', 16 | ...getExtraCreditTitles().map((x, i) => i + 1), 17 | 'final', 18 | ] 19 | 20 | const originalHomepage = pkg.homepage 21 | 22 | function updateHomepage(pathname = '') { 23 | const url = new URL(originalHomepage) 24 | // must end in "/" 25 | url.pathname = pathname.endsWith('/') ? pathname : `${pathname}/` 26 | const newHomepage = url.toString() 27 | fs.writeFileSync( 28 | 'package.json', 29 | JSON.stringify({...pkg, homepage: newHomepage}, null, 2) + '\n', 30 | ) 31 | } 32 | 33 | const buildPath = path.join('node_modules', '.cache', 'build') 34 | if (!fs.existsSync(buildPath)) { 35 | fs.mkdirSync(buildPath, {recursive: true}) 36 | } 37 | 38 | function getRedirect(baseRoute) { 39 | baseRoute = baseRoute.endsWith('/') ? baseRoute : `${baseRoute}/` 40 | baseRoute = baseRoute.startsWith('/') ? baseRoute : `/${baseRoute}` 41 | return ` 42 | ${baseRoute} ${baseRoute}list 302! 43 | ${baseRoute}* ${baseRoute}index.html 200 44 | `.trim() 45 | } 46 | 47 | let redirects = [] 48 | 49 | const getDirname = variant => 50 | typeof variant === 'number' ? `extra-${variant}` : variant 51 | 52 | function buildVariant(variant, {dirname = getDirname(variant)} = {}) { 53 | console.log(`▶️ Starting build for "${variant}" in "${dirname}"`) 54 | try { 55 | updateHomepage(dirname) 56 | spawnSync(`node ./scripts/swap ${variant}`, {stdio: 'inherit'}) 57 | spawnSync(`npx react-scripts build --profile`, {stdio: 'inherit'}) 58 | if (variant !== 'exercise') { 59 | spawnSync(`npm run test:coverage`, {stdio: 'inherit'}) 60 | } 61 | if (dirname) { 62 | const dirPath = path.join('node_modules', '.cache', 'build', dirname) 63 | if (fs.existsSync(dirPath)) { 64 | fs.rmdirSync(dirPath, {recursive: true}) 65 | } 66 | fs.renameSync('build', dirPath) 67 | } 68 | console.log(`✅ finished build for "${variant}" in "${dirname}"`) 69 | redirects.push(getRedirect(dirname)) 70 | } catch (error) { 71 | console.log(`🚨 error building for "${variant}" in "${dirname}"`) 72 | throw error 73 | } 74 | } 75 | 76 | for (const variant of variants) { 77 | buildVariant(variant) 78 | } 79 | 80 | // build the final as the main thing with the homepage set to the root 81 | buildVariant('final', {dirname: ''}) 82 | 83 | console.log( 84 | '✅ all variants have been built, moving them to build and creating redirects file', 85 | ) 86 | for (const variant of variants) { 87 | const dirname = getDirname(variant) 88 | const oldPath = path.join('node_modules', '.cache', 'build', dirname) 89 | const newPath = path.join('build', dirname) 90 | fs.renameSync(oldPath, newPath) 91 | } 92 | fs.writeFileSync('build/_redirects', redirects.join('\n\n')) 93 | console.log('✅ all done. Ready to deploy') 94 | } 95 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const {spawnSync} = require('./utils') 2 | 3 | const branch = 4 | process.env.BRANCH || spawnSync('git rev-parse --abbrev-ref HEAD') 5 | 6 | console.log(`Building for branch "${branch}"`) 7 | 8 | if (branch.startsWith('exercises/')) { 9 | spawnSync('node ./scripts/build-variants', {stdio: 'inherit'}) 10 | } else { 11 | spawnSync('npx react-scripts build --profile', {stdio: 'inherit'}) 12 | } 13 | -------------------------------------------------------------------------------- /scripts/git-diffs.js: -------------------------------------------------------------------------------- 1 | const {spawnSync, getVariants} = require('./utils') 2 | 3 | const variants = getVariants() 4 | 5 | const [, , end, start = 'exercise'] = process.argv 6 | 7 | function getVariant(variant, requested) { 8 | if (variant[requested]) return variant[requested] 9 | return variant.extras.find(e => e.number === Number(requested)) 10 | } 11 | 12 | function getFilesForVariant() { 13 | return Object.values(variants) 14 | .map(variant => { 15 | const before = getVariant(variant, start) 16 | const after = getVariant(variant, end) 17 | return { 18 | before: before ? before.file : null, 19 | after: after ? after.file : null, 20 | } 21 | }) 22 | .filter(({before, after}) => before && after) 23 | } 24 | 25 | const files = getFilesForVariant() 26 | const commands = files.map( 27 | ({before, after}) => 28 | `diff -u "${before}" "${after}" | delta --theme="night-owlish" --paging=never`, 29 | ) 30 | 31 | console.log(commands.join('\n'), '\n\n') 32 | 33 | for (const cmd of commands) { 34 | spawnSync(cmd, {stdio: 'inherit'}) 35 | } 36 | -------------------------------------------------------------------------------- /scripts/pre-commit.js: -------------------------------------------------------------------------------- 1 | var spawnSync = require('child_process').spawnSync 2 | const {username} = require('os').userInfo() 3 | 4 | if (username === 'kentcdodds') { 5 | const result = spawnSync('npm run validate', {stdio: 'inherit', shell: true}) 6 | 7 | if (result.status !== 0) { 8 | process.exit(result.status) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /scripts/pre-push.js: -------------------------------------------------------------------------------- 1 | try { 2 | const {username} = require('os').userInfo() 3 | const { 4 | repository: {url: repoUrl}, 5 | } = require('../package.json') 6 | 7 | const remote = process.env.HUSKY_GIT_PARAMS.split(' ')[1] 8 | const repoName = repoUrl.match(/(?:.(?!\/))+\.git$/)[0] 9 | if (username !== 'kentcdodds' && remote.includes(`kentcdodds${repoName}`)) { 10 | console.log( 11 | `You're trying to push to Kent's repo directly. If you want to save and push your work or even make a contribution to the workshop material, you'll need to fork the repo first and push changes to your fork. Learn how here: https://docs.github.com/en/free-pro-team@latest/github/getting-started-with-github/fork-a-repo`, 12 | ) 13 | process.exit(1) 14 | } 15 | } catch (error) { 16 | // ignore 17 | } 18 | -------------------------------------------------------------------------------- /scripts/setup.js: -------------------------------------------------------------------------------- 1 | var spawnSync = require('child_process').spawnSync 2 | 3 | var styles = { 4 | // got these from playing around with what I found from: 5 | // https://github.com/istanbuljs/istanbuljs/blob/0f328fd0896417ccb2085f4b7888dd8e167ba3fa/packages/istanbul-lib-report/lib/file-writer.js#L84-L96 6 | // they're the best I could find that works well for light or dark terminals 7 | success: {open: '\u001b[32;1m', close: '\u001b[0m'}, 8 | danger: {open: '\u001b[31;1m', close: '\u001b[0m'}, 9 | info: {open: '\u001b[36;1m', close: '\u001b[0m'}, 10 | subtitle: {open: '\u001b[2;1m', close: '\u001b[0m'}, 11 | } 12 | 13 | function color(modifier, string) { 14 | return styles[modifier].open + string + styles[modifier].close 15 | } 16 | 17 | console.log(color('info', '▶️ Starting workshop setup...')) 18 | 19 | var output = spawnSync('npm --version', {shell: true}).stdout.toString().trim() 20 | var outputParts = output.split('.') 21 | var major = Number(outputParts[0]) 22 | var minor = Number(outputParts[1]) 23 | if (major < 8 || (major === 8 && minor < 16)) { 24 | console.error( 25 | color( 26 | 'danger', 27 | '🚨 npm version is ' + 28 | output + 29 | ' which is out of date. Please install npm@8.16.0 or greater', 30 | ), 31 | ) 32 | throw new Error('npm version is out of date') 33 | } 34 | 35 | var command = 36 | 'npx "https://gist.github.com/kentcdodds/bb452ffe53a5caa3600197e1d8005733" -q -- --no-autofill' 37 | console.log( 38 | color('subtitle', ' Running the following command: ' + command), 39 | ) 40 | 41 | var result = spawnSync(command, {stdio: 'inherit', shell: true}) 42 | 43 | if (result.status === 0) { 44 | console.log(color('success', '✅ Workshop setup complete...')) 45 | } else { 46 | process.exit(result.status) 47 | } 48 | 49 | /* 50 | eslint 51 | no-var: "off", 52 | "vars-on-top": "off", 53 | */ 54 | -------------------------------------------------------------------------------- /scripts/swap.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const {spawnSync, getVariants, getExtraCreditTitles} = require('./utils') 3 | 4 | const branch = spawnSync('git rev-parse --abbrev-ref HEAD') 5 | if (branch === 'main') { 6 | throw new Error('Cannot run swap on main as there are no exercises.') 7 | } 8 | 9 | go() 10 | 11 | async function go() { 12 | let {2: match} = process.argv 13 | 14 | const allowedTypes = [/^exercise$/, /^final$/, /^\d+$/] 15 | 16 | const variants = getVariants() 17 | const maxExtra = Math.max( 18 | ...Object.values(variants) 19 | .reduce((m, v) => [...m, ...v.extras], []) 20 | .map(e => e.number), 21 | ) 22 | 23 | if (!Object.keys(variants).length) { 24 | console.log(`There are no variants needing a swap.`) 25 | return 26 | } 27 | 28 | const extraCreditTitles = getExtraCreditTitles() 29 | 30 | if (!match) { 31 | const prompt = await ( 32 | await import('inquirer') 33 | ).default.prompt([ 34 | { 35 | name: 'matchVal', 36 | message: `Which modules do you want loaded?`, 37 | type: 'list', 38 | choices: [ 39 | {name: 'Exercise', value: 'exercise'}, 40 | {name: 'Final', value: 'final'}, 41 | ...Array.from({length: maxExtra}, (v, i) => ({ 42 | name: `Extra Credit ${i + 1}: ${extraCreditTitles[i]}`, 43 | value: i + 1, 44 | })), 45 | ], 46 | }, 47 | ]) 48 | match = prompt.matchVal 49 | } 50 | 51 | if (!allowedTypes.some(t => t.test(match))) { 52 | throw new Error( 53 | `The given match of "${match}" is not one of the allowed types of: ${allowedTypes.join( 54 | ', ', 55 | )}`, 56 | ) 57 | } 58 | 59 | console.log(`Changing used files to those matching "${match}"`) 60 | 61 | function getmainFileContents({main, exercise, final, extras}) { 62 | let uncommentedLines 63 | if (match === 'exercise') { 64 | uncommentedLines = exercise.exportLines 65 | } else if (match === 'final') { 66 | uncommentedLines = final ? final.exportLines : exercise.exportLines 67 | } else if (Number.isFinite(Number(match))) { 68 | const availableECs = extras 69 | .map(e => e.number) 70 | .filter(n => n <= Number(match)) 71 | const maxEC = Math.max(...availableECs) 72 | const maxExtra = extras.find(e => e.number === maxEC) 73 | uncommentedLines = (maxExtra || final || exercise).exportLines 74 | } else { 75 | console.log('this should not happen...', match) 76 | } 77 | 78 | if (!uncommentedLines) { 79 | throw new Error(`No variant found to enable for "${match}" in "${main}"`) 80 | } 81 | const l = lines => 82 | lines 83 | ? lines === uncommentedLines 84 | ? lines.join('\n') 85 | : `// ${lines.join('\n// ')}` 86 | : '' 87 | const extrasLines = extras 88 | .map( 89 | ({exportLines, title}) => 90 | `// 💯 ${title}\n${l(exportLines) || '// no extra credit'}`, 91 | ) 92 | .join('\n\n') 93 | return ( 94 | ` 95 | ${l((final || {}).exportLines) || '// no final'} 96 | 97 | ${l(exercise.exportLines) || '// no exercise'} 98 | 99 | ${extrasLines} 100 | `.trim() + '\n' 101 | ) 102 | } 103 | 104 | for (const [main, {final, exercise, extras}] of Object.entries(variants)) { 105 | const contents = getmainFileContents({main, final, exercise, extras}) 106 | fs.writeFileSync(main, contents) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /scripts/update-branch.js: -------------------------------------------------------------------------------- 1 | const {updateExerciseBranch, spawnSync} = require('./utils') 2 | 3 | process.env.HUSKY_SKIP_HOOKS = 1 4 | 5 | updateExerciseBranch(spawnSync('git rev-parse --abbrev-ref HEAD')) 6 | -------------------------------------------------------------------------------- /scripts/update-deps: -------------------------------------------------------------------------------- 1 | # prettier-ignore 2 | npx npm-check-updates --upgrade --reject husky,react-query,react-query-devtools,debounce-fn,@emotion/core,@emotion/styled,jest,chalk,faker,jest-watch-typeahead 3 | rm -rf node_modules package-lock.json 4 | npx npm@8 install 5 | npm run validate 6 | -------------------------------------------------------------------------------- /scripts/update-exercises.js: -------------------------------------------------------------------------------- 1 | const {username} = require('os').userInfo() 2 | const { 3 | spawnSync, 4 | getExerciseBranches, 5 | updateExerciseBranch, 6 | } = require('./utils') 7 | 8 | const branch = spawnSync('git rev-parse --abbrev-ref HEAD') 9 | if (branch === 'main' && username === 'kentcdodds') { 10 | updateExercises() 11 | } else { 12 | console.log( 13 | `The branch ${branch} is not "main" or the username ${username} is not kentcdodds. So skipping post-commit hook.`, 14 | ) 15 | } 16 | 17 | function updateExercises() { 18 | console.log('▶️ Updating exercise branches') 19 | const exerciseBranches = getExerciseBranches() 20 | exerciseBranches.forEach(branch => { 21 | const didUpdate = updateExerciseBranch(branch) 22 | console.log(` ✅ ${branch} is up to date.`) 23 | if (didUpdate) { 24 | console.log(`Force pushing ${branch}`) 25 | spawnSync('git push -f') 26 | } 27 | }) 28 | spawnSync('git checkout main') 29 | console.log('✅ All exercises up to date.') 30 | } 31 | -------------------------------------------------------------------------------- /scripts/update-links.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const fs = require('fs') 3 | const {spawnSync} = require('./utils') 4 | const pkg = require(path.join(process.cwd(), 'package.json')) 5 | const {homepage: projectHomepage} = pkg 6 | 7 | const branch = spawnSync('git rev-parse --abbrev-ref HEAD') 8 | const exerciseNumberRegex = /^exercises\/(\d+)/ 9 | const exerciseNumber = exerciseNumberRegex.test(branch) 10 | ? branch.match(exerciseNumberRegex)[1] 11 | : null 12 | if (exerciseNumber) { 13 | const contents = fs.readFileSync('INSTRUCTIONS.md', {encoding: 'utf-8'}) 14 | const newContents = [contents] 15 | .map(getLinesWithProdDeploys) 16 | .map(getLinesWithUpdatedFeedbackLink)[0] 17 | 18 | if (contents !== newContents) { 19 | fs.writeFileSync('INSTRUCTIONS.md', newContents) 20 | } 21 | } 22 | 23 | function getLinesWithProdDeploys(contents) { 24 | const lines = contents.split('\n') 25 | const exerciseProdDeployLines = ` 26 | Production deploys: 27 | 28 | - [Exercise](${projectHomepage}exercise) 29 | - [Final](${projectHomepage}) 30 | ` 31 | .trim() 32 | .split('\n') 33 | 34 | const getExtraDeployLines = extraNumber => [ 35 | `[Production deploy](${projectHomepage}extra-${extraNumber})`, 36 | ] 37 | 38 | const newLines = [] 39 | for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { 40 | const line = lines[lineIndex] 41 | newLines.push(line) 42 | 43 | const extraCreditMatch = line.match(/### (?\d+)\. 💯 /) 44 | 45 | if (/## Exercise$/.test(line)) { 46 | newLines.push('', ...exerciseProdDeployLines) 47 | if (lines[lineIndex + 2].startsWith('Production deploys:')) { 48 | // already existed, skip indexes 49 | lineIndex = lineIndex + exerciseProdDeployLines.length + 1 50 | } 51 | } else if (extraCreditMatch) { 52 | const number = extraCreditMatch.groups.number 53 | const extraDeployLines = getExtraDeployLines(number) 54 | newLines.push('', ...extraDeployLines) 55 | if (lines[lineIndex + 2].startsWith('[Production deploy]')) { 56 | // already existed, skip indexes 57 | lineIndex = lineIndex + extraDeployLines.length + 1 58 | } 59 | } 60 | } 61 | return newLines.join('\n') 62 | } 63 | 64 | function getLinesWithUpdatedFeedbackLink(contents) { 65 | const feedbackLinkRegex = /^https?:\/\/ws\.kcd\.im.*?&em=$/m 66 | 67 | const firstLine = contents.split('\n')[0] 68 | const titleMatch = firstLine.match(/# (?.*)$/) 69 | if (!titleMatch) { 70 | throw new Error(`Title is invalid`) 71 | } 72 | const title = titleMatch.groups.title.trim() 73 | const workshop = encodeURIComponent('Build React Apps') 74 | const exercise = encodeURIComponent(`${exerciseNumber}: ${title}`) 75 | const link = `https://ws.kcd.im/?ws=${workshop}&e=${exercise}&em=` 76 | if (contents.includes(link)) { 77 | return contents 78 | } 79 | if (!feedbackLinkRegex.test(contents)) { 80 | throw new Error( 81 | `Exercise "${exerciseNumber}" is missing workshop feedback link`, 82 | ) 83 | } 84 | return contents.replace(feedbackLinkRegex, link) 85 | } 86 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const cp = require('child_process') 3 | const fs = require('fs') 4 | const glob = require('glob') 5 | 6 | function spawnSync(command, options) { 7 | const result = cp.spawnSync(command, {shell: true, stdio: 'pipe', ...options}) 8 | result.stdout = result.stdout || '' 9 | result.stderr = result.stderr || '' 10 | if (result.status === 0) { 11 | return result.stdout.toString().trim() 12 | } else { 13 | throw new Error( 14 | `\n\nError executing command: ${command}\n\nERROR CODE: ${result.status}\n\nSTDERR:\n${result.stderr}\n\nSTDOUT:\n${result.stdout}`, 15 | ) 16 | } 17 | } 18 | 19 | function getExtraCreditNumberFromFilename(line) { 20 | const m = line.match(/\.extra-(?<number>\d+).js$/) 21 | if (!m) { 22 | return null 23 | } 24 | return Number(m.groups.number) 25 | } 26 | 27 | function getExtraCreditTitles() { 28 | const instructions = fs.readFileSync('INSTRUCTIONS.md', {encoding: 'utf-8'}) 29 | return instructions 30 | .split('\n') 31 | .filter(l => l.includes('💯')) 32 | .map(l => l.replace(/^.*💯/, '').trim()) 33 | } 34 | 35 | function getVariants() { 36 | const extraCreditTitles = getExtraCreditTitles() 37 | const files = glob.sync('./+(src|cypress)/**/*.+(exercise|final|extra-)*.js') 38 | const filesByMaster = {} 39 | for (const file of files) { 40 | const {dir, name, base, ext} = path.parse(file) 41 | const contents = fs.readFileSync(file).toString() 42 | const hasDefaultExport = /^export default /m.test(contents) 43 | const hasCJSExport = /^module.exports /m.test(contents) 44 | let exportLines = [ 45 | `export * from './${name}'`, 46 | hasDefaultExport ? `export {default} from './${name}'` : null, 47 | ].filter(Boolean) 48 | if (hasCJSExport) { 49 | exportLines = [`module.exports = require('./${name}')`] 50 | } 51 | const number = getExtraCreditNumberFromFilename(base) 52 | const main = path.join(dir, name.replace(/\..*$/, ext)) 53 | 54 | filesByMaster[main] = filesByMaster[main] || {extras: []} 55 | 56 | const info = { 57 | exportLines, 58 | number, 59 | title: extraCreditTitles[number - 1], 60 | file, 61 | } 62 | 63 | if (base.includes('.final')) filesByMaster[main].final = info 64 | if (base.includes('.exercise')) filesByMaster[main].exercise = info 65 | if (base.includes('.extra')) filesByMaster[main].extras.push(info) 66 | } 67 | return filesByMaster 68 | } 69 | 70 | function getExerciseBranches() { 71 | const branches = spawnSync( 72 | `git for-each-ref --format="%(refname:short)"`, 73 | ).split('\n') 74 | return branches 75 | .filter(b => b.startsWith('origin/exercises/')) 76 | .map(b => b.replace('origin/', '')) 77 | } 78 | 79 | function updateExerciseBranch(branch) { 80 | const mainCommit = spawnSync('git rev-parse main') 81 | spawnSync(`git checkout ${branch}`) 82 | const exerciseCommit = spawnSync(`git rev-parse ${branch}`) 83 | const parentCommit = spawnSync(`git rev-parse ${branch}^`) 84 | if (mainCommit === parentCommit) { 85 | return false 86 | } 87 | console.log( 88 | `> The ${branch} exercise commit SHA: ${exerciseCommit} (save this in case something goes wrong).`, 89 | ) 90 | spawnSync(`git reset --hard main`) 91 | try { 92 | const result = spawnSync( 93 | `git cherry-pick ${exerciseCommit} --strategy-option theirs`, 94 | ) 95 | if (!result.includes('error: could not apply')) { 96 | return true 97 | } 98 | } catch (error) { 99 | // let's try to fix things maybe... This might be a terrible idea though.. 100 | } 101 | // the conflict is probably because files were deleted in the branch and we 102 | // should delete them again. For some reason --strategy-option theres doesn't 103 | // do this by default. 🤔 104 | spawnSync(`git status | sed -n 's/deleted by them://p' | xargs git rm`) 105 | const status = spawnSync('git status') 106 | if ( 107 | status.includes('Changes not staged for commit') || 108 | status.includes('Unmerged') 109 | ) { 110 | console.error( 111 | '❌ Merge conflict. Fix the conflict, then run the update-exercises script again to be sure you have everything up to date.', 112 | ) 113 | throw status 114 | } 115 | spawnSync(`git cherry-pick --quit`) 116 | spawnSync(`git commit -am "${branch}"`) 117 | return true 118 | } 119 | 120 | module.exports = { 121 | spawnSync, 122 | getVariants, 123 | getExtraCreditTitles, 124 | getExerciseBranches, 125 | updateExerciseBranch, 126 | } 127 | -------------------------------------------------------------------------------- /scripts/validate-exercises.js: -------------------------------------------------------------------------------- 1 | const {spawnSync, getVariants} = require('./utils') 2 | 3 | const mainFiles = Object.keys(getVariants()) 4 | 5 | if (mainFiles.length) { 6 | spawnSync('node ./scripts/swap final') 7 | spawnSync('node ./scripts/update-links') 8 | spawnSync(`git add INSTRUCTIONS.md "${mainFiles.join('" "')}"`) 9 | } 10 | -------------------------------------------------------------------------------- /setup.js: -------------------------------------------------------------------------------- 1 | require('./scripts/setup') 2 | -------------------------------------------------------------------------------- /src/__tests__/book-screen.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | render, 4 | screen, 5 | waitForLoadingToFinish, 6 | userEvent, 7 | loginAsUser, 8 | } from 'test/app-test-utils' 9 | import faker from 'faker' 10 | import {server, rest} from 'test/server' 11 | import {buildBook, buildListItem} from 'test/generate' 12 | import * as booksDB from 'test/data/books' 13 | import * as listItemsDB from 'test/data/list-items' 14 | import {formatDate} from 'utils/misc' 15 | import {App} from 'app' 16 | 17 | const apiURL = process.env.REACT_APP_API_URL 18 | 19 | const fakeTimerUserEvent = userEvent.setup({ 20 | advanceTimers: () => jest.runOnlyPendingTimers(), 21 | }) 22 | 23 | async function renderBookScreen({user, book, listItem} = {}) { 24 | if (user === undefined) { 25 | user = await loginAsUser() 26 | } 27 | if (book === undefined) { 28 | book = await booksDB.create(buildBook()) 29 | } 30 | if (listItem === undefined) { 31 | listItem = await listItemsDB.create(buildListItem({owner: user, book})) 32 | } 33 | const route = `/book/${book.id}` 34 | 35 | const utils = await render(<App />, {user, route}) 36 | 37 | return { 38 | ...utils, 39 | book, 40 | user, 41 | listItem, 42 | } 43 | } 44 | 45 | test('renders all the book information', async () => { 46 | const {book} = await renderBookScreen({listItem: null}) 47 | 48 | expect(screen.getByRole('heading', {name: book.title})).toBeInTheDocument() 49 | expect(screen.getByText(book.author)).toBeInTheDocument() 50 | expect(screen.getByText(book.publisher)).toBeInTheDocument() 51 | expect(screen.getByText(book.synopsis)).toBeInTheDocument() 52 | expect(screen.getByRole('img', {name: /book cover/i})).toHaveAttribute( 53 | 'src', 54 | book.coverImageUrl, 55 | ) 56 | expect(screen.getByRole('button', {name: /add to list/i})).toBeInTheDocument() 57 | 58 | expect( 59 | screen.queryByRole('button', {name: /remove from list/i}), 60 | ).not.toBeInTheDocument() 61 | expect( 62 | screen.queryByRole('button', {name: /mark as read/i}), 63 | ).not.toBeInTheDocument() 64 | expect( 65 | screen.queryByRole('button', {name: /mark as unread/i}), 66 | ).not.toBeInTheDocument() 67 | expect( 68 | screen.queryByRole('textbox', {name: /notes/i}), 69 | ).not.toBeInTheDocument() 70 | expect(screen.queryByRole('radio', {name: /star/i})).not.toBeInTheDocument() 71 | expect(screen.queryByLabelText(/start date/i)).not.toBeInTheDocument() 72 | }) 73 | 74 | test('can create a list item for the book', async () => { 75 | await renderBookScreen({listItem: null}) 76 | 77 | const addToListButton = screen.getByRole('button', {name: /add to list/i}) 78 | await userEvent.click(addToListButton) 79 | expect(addToListButton).toBeDisabled() 80 | 81 | await waitForLoadingToFinish() 82 | 83 | expect( 84 | screen.getByRole('button', {name: /mark as read/i}), 85 | ).toBeInTheDocument() 86 | expect( 87 | screen.getByRole('button', {name: /remove from list/i}), 88 | ).toBeInTheDocument() 89 | expect(screen.getByRole('textbox', {name: /notes/i})).toBeInTheDocument() 90 | 91 | const startDateNode = screen.getByLabelText(/start date/i) 92 | expect(startDateNode).toHaveTextContent(formatDate(Date.now())) 93 | 94 | expect( 95 | screen.queryByRole('button', {name: /add to list/i}), 96 | ).not.toBeInTheDocument() 97 | expect( 98 | screen.queryByRole('button', {name: /mark as unread/i}), 99 | ).not.toBeInTheDocument() 100 | expect(screen.queryByRole('radio', {name: /star/i})).not.toBeInTheDocument() 101 | }) 102 | 103 | test('can remove a list item for the book', async () => { 104 | await renderBookScreen() 105 | 106 | const removeFromListButton = screen.getByRole('button', { 107 | name: /remove from list/i, 108 | }) 109 | await userEvent.click(removeFromListButton) 110 | expect(removeFromListButton).toBeDisabled() 111 | 112 | await waitForLoadingToFinish() 113 | 114 | expect(screen.getByRole('button', {name: /add to list/i})).toBeInTheDocument() 115 | 116 | expect( 117 | screen.queryByRole('button', {name: /remove from list/i}), 118 | ).not.toBeInTheDocument() 119 | expect( 120 | screen.queryByRole('button', {name: /mark as read/i}), 121 | ).not.toBeInTheDocument() 122 | expect( 123 | screen.queryByRole('button', {name: /mark as unread/i}), 124 | ).not.toBeInTheDocument() 125 | expect( 126 | screen.queryByRole('textbox', {name: /notes/i}), 127 | ).not.toBeInTheDocument() 128 | expect(screen.queryByRole('radio', {name: /star/i})).not.toBeInTheDocument() 129 | expect(screen.queryByLabelText(/start date/i)).not.toBeInTheDocument() 130 | }) 131 | 132 | test('can mark a list item as read', async () => { 133 | const user = await loginAsUser() 134 | const book = await booksDB.create(buildBook()) 135 | const listItem = await listItemsDB.create( 136 | buildListItem({owner: user, book, finishDate: null}), 137 | ) 138 | await renderBookScreen({user, book, listItem}) 139 | 140 | const markAsReadButton = screen.getByRole('button', {name: /mark as read/i}) 141 | await userEvent.click(markAsReadButton) 142 | expect(markAsReadButton).toBeDisabled() 143 | 144 | await waitForLoadingToFinish() 145 | expect( 146 | screen.getByRole('button', {name: /mark as unread/i}), 147 | ).toBeInTheDocument() 148 | expect(screen.getAllByRole('radio', {name: /star/i})).toHaveLength(5) 149 | 150 | const startAndFinishDateNode = screen.getByLabelText(/start and finish date/i) 151 | expect(startAndFinishDateNode).toHaveTextContent( 152 | `${formatDate(listItem.startDate)} — ${formatDate(Date.now())}`, 153 | ) 154 | 155 | expect( 156 | screen.queryByRole('button', {name: /mark as read/i}), 157 | ).not.toBeInTheDocument() 158 | }) 159 | 160 | test('can edit a note', async () => { 161 | // using fake timers to skip debounce time 162 | jest.useFakeTimers() 163 | const {listItem} = await renderBookScreen() 164 | 165 | const newNotes = faker.lorem.words() 166 | const notesTextarea = screen.getByRole('textbox', {name: /notes/i}) 167 | 168 | await fakeTimerUserEvent.clear(notesTextarea) 169 | await fakeTimerUserEvent.type(notesTextarea, newNotes) 170 | 171 | // wait for the loading spinner to show up 172 | await screen.findByLabelText(/loading/i) 173 | // wait for the loading spinner to go away 174 | await waitForLoadingToFinish() 175 | 176 | expect(notesTextarea.value).toBe(newNotes) 177 | 178 | expect(await listItemsDB.read(listItem.id)).toMatchObject({ 179 | notes: newNotes, 180 | }) 181 | }) 182 | 183 | describe('console errors', () => { 184 | beforeAll(() => { 185 | jest.spyOn(console, 'error').mockImplementation(() => {}) 186 | }) 187 | 188 | afterAll(() => { 189 | console.error.mockRestore() 190 | }) 191 | 192 | test('shows an error message when the book fails to load', async () => { 193 | const book = {id: 'BAD_ID'} 194 | await renderBookScreen({listItem: null, book}) 195 | 196 | expect( 197 | (await screen.findByRole('alert')).textContent, 198 | ).toMatchInlineSnapshot(`"There was an error: Book not found"`) 199 | expect(console.error).toHaveBeenCalled() 200 | }) 201 | 202 | test('note update failures are displayed', async () => { 203 | jest.useFakeTimers() 204 | // using fake timers to skip debounce time 205 | await renderBookScreen() 206 | 207 | const newNotes = faker.lorem.words() 208 | const notesTextarea = screen.getByRole('textbox', {name: /notes/i}) 209 | 210 | const testErrorMessage = '__test_error_message__' 211 | server.use( 212 | rest.put(`${apiURL}/list-items/:listItemId`, async (req, res, ctx) => { 213 | return res( 214 | ctx.status(400), 215 | ctx.json({status: 400, message: testErrorMessage}), 216 | ) 217 | }), 218 | ) 219 | 220 | await fakeTimerUserEvent.type(notesTextarea, newNotes) 221 | // wait for the loading spinner to show up 222 | await screen.findByLabelText(/loading/i) 223 | // wait for the loading spinner to go away 224 | await waitForLoadingToFinish() 225 | 226 | expect(screen.getByRole('alert').textContent).toMatchInlineSnapshot( 227 | `"There was an error: __test_error_message__"`, 228 | ) 229 | }) 230 | }) 231 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {useAuth} from './context/auth-context' 3 | import {FullPageSpinner} from './components/lib' 4 | 5 | const AuthenticatedApp = React.lazy(() => 6 | import(/* webpackPrefetch: true */ './authenticated-app'), 7 | ) 8 | const UnauthenticatedApp = React.lazy(() => import('./unauthenticated-app')) 9 | 10 | function App() { 11 | const {user} = useAuth() 12 | return ( 13 | <React.Suspense fallback={<FullPageSpinner />}> 14 | {user ? <AuthenticatedApp /> : <UnauthenticatedApp />} 15 | </React.Suspense> 16 | ) 17 | } 18 | 19 | export {App} 20 | -------------------------------------------------------------------------------- /src/assets/book-placeholder.svg: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <svg width="400px" height="600px" viewBox="0 0 400 600" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> 3 | <title>Book Placeholder 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/auth-provider.js: -------------------------------------------------------------------------------- 1 | // pretend this is firebase, netlify, or auth0's code. 2 | // you shouldn't have to implement something like this in your own app 3 | 4 | const localStorageKey = '__auth_provider_token__' 5 | 6 | async function getToken() { 7 | // if we were a real auth provider, this is where we would make a request 8 | // to retrieve the user's token. (It's a bit more complicated than that... 9 | // but you're probably not an auth provider so you don't need to worry about it). 10 | return window.localStorage.getItem(localStorageKey) 11 | } 12 | 13 | function handleUserResponse({user}) { 14 | window.localStorage.setItem(localStorageKey, user.token) 15 | return user 16 | } 17 | 18 | function login({username, password}) { 19 | return client('login', {username, password}).then(handleUserResponse) 20 | } 21 | 22 | function register({username, password}) { 23 | return client('register', {username, password}).then(handleUserResponse) 24 | } 25 | 26 | async function logout() { 27 | window.localStorage.removeItem(localStorageKey) 28 | } 29 | 30 | // an auth provider wouldn't use your client, they'd have their own 31 | // so that's why we're not just re-using the client 32 | const authURL = process.env.REACT_APP_AUTH_URL 33 | 34 | async function client(endpoint, data) { 35 | const config = { 36 | method: 'POST', 37 | body: JSON.stringify(data), 38 | headers: {'Content-Type': 'application/json'}, 39 | } 40 | 41 | return window.fetch(`${authURL}/${endpoint}`, config).then(async response => { 42 | const data = await response.json() 43 | if (response.ok) { 44 | return data 45 | } else { 46 | return Promise.reject(data) 47 | } 48 | }) 49 | } 50 | 51 | export {getToken, login, register, logout, localStorageKey} 52 | -------------------------------------------------------------------------------- /src/authenticated-app.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core' 3 | 4 | import {Routes, Route, Link as RouterLink, useMatch} from 'react-router-dom' 5 | import {ErrorBoundary} from 'react-error-boundary' 6 | import {Button, ErrorMessage, FullPageErrorFallback} from './components/lib' 7 | import * as mq from './styles/media-queries' 8 | import * as colors from './styles/colors' 9 | import {useAuth} from './context/auth-context' 10 | import {ReadingListScreen} from './screens/reading-list' 11 | import {FinishedScreen} from './screens/finished' 12 | import {DiscoverBooksScreen} from './screens/discover' 13 | import {BookScreen} from './screens/book' 14 | import {NotFoundScreen} from './screens/not-found' 15 | 16 | function ErrorFallback({error}) { 17 | return ( 18 | 28 | ) 29 | } 30 | 31 | function AuthenticatedApp() { 32 | const {user, logout} = useAuth() 33 | return ( 34 | 35 |
44 | {user.username} 45 | 48 |
49 |
65 |
66 |
68 |
69 | 70 | 71 | 72 |
73 |
74 |
75 | ) 76 | } 77 | 78 | function NavLink(props) { 79 | const match = useMatch(props.to) 80 | return ( 81 | 110 | ) 111 | } 112 | 113 | function Nav(params) { 114 | return ( 115 | 145 | ) 146 | } 147 | 148 | function AppRoutes() { 149 | return ( 150 | 151 | } /> 152 | } /> 153 | } /> 154 | } /> 155 | } /> 156 | 157 | ) 158 | } 159 | 160 | export default AuthenticatedApp 161 | -------------------------------------------------------------------------------- /src/bootstrap.js: -------------------------------------------------------------------------------- 1 | import 'stop-runaway-react-effects/hijack' 2 | import './test/server' 3 | import 'bootstrap/dist/css/bootstrap-reboot.css' 4 | import '@reach/dialog/styles.css' 5 | import '@reach/menu-button/styles.css' 6 | import '@reach/tooltip/styles.css' 7 | import './styles/global.css' 8 | -------------------------------------------------------------------------------- /src/components/__mocks__/profiler.js: -------------------------------------------------------------------------------- 1 | // we have this mock here so the profiler doesn't actually 2 | // attempt to send profile reports during tests. 3 | const Profiler = ({children}) => children 4 | 5 | export {Profiler} 6 | -------------------------------------------------------------------------------- /src/components/__tests__/modal.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen, within} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {Modal, ModalContents, ModalOpenButton} from '../modal' 5 | 6 | test('can be opened and closed', async () => { 7 | const label = 'Modal Label' 8 | const title = 'Modal Title' 9 | render( 10 | 11 | 12 | 13 | 14 | 15 |
Modal Content
16 |
17 |
, 18 | ) 19 | await userEvent.click(screen.getByRole('button', {name: 'Open'})) 20 | 21 | const modal = screen.getByRole('dialog') 22 | expect(modal).toHaveAttribute('aria-label', label) 23 | const inModal = within(screen.getByRole('dialog')) 24 | expect(inModal.getByRole('heading', {name: title})).toBeInTheDocument() 25 | 26 | await userEvent.click(inModal.getByRole('button', {name: /close/i})) 27 | 28 | expect(screen.queryByRole('dialog')).not.toBeInTheDocument() 29 | }) 30 | -------------------------------------------------------------------------------- /src/components/__tests__/rating.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen, waitFor, loginAsUser} from 'test/app-test-utils' 3 | import userEvent from '@testing-library/user-event' 4 | import {buildBook, buildListItem} from 'test/generate' 5 | import * as booksDB from 'test/data/books' 6 | import * as listItemsDB from 'test/data/list-items' 7 | import {Rating} from '../rating' 8 | 9 | async function renderRating({rating = 0} = {}) { 10 | const book = await booksDB.create(buildBook()) 11 | const user = await loginAsUser() 12 | const listItem = await listItemsDB.create({ 13 | ...buildListItem({owner: user, book}), 14 | rating, 15 | }) 16 | const utils = await render(, {user}) 17 | return {...utils, book, user, listItem} 18 | } 19 | 20 | test('it updates the rating', async () => { 21 | const {listItem} = await renderRating() 22 | const firstStar = screen.getByLabelText('1 star') 23 | 24 | await userEvent.click(firstStar) 25 | 26 | await waitFor(async () => { 27 | const updatedListItem = await listItemsDB.read(listItem.id) 28 | expect(updatedListItem.rating).toBe(1) 29 | }) 30 | }) 31 | 32 | test(`it shows the correct rating for 0 stars`, async () => { 33 | await renderRating() 34 | 35 | expect(screen.getByLabelText('1 star')).not.toBeChecked() 36 | expect(screen.getByLabelText('2 stars')).not.toBeChecked() 37 | expect(screen.getByLabelText('3 stars')).not.toBeChecked() 38 | expect(screen.getByLabelText('4 stars')).not.toBeChecked() 39 | expect(screen.getByLabelText('5 stars')).not.toBeChecked() 40 | }) 41 | 42 | test.each` 43 | rating | selectedLabel 44 | ${1} | ${'1 star'} 45 | ${2} | ${'2 stars'} 46 | ${3} | ${'3 stars'} 47 | ${4} | ${'4 stars'} 48 | ${5} | ${'5 stars'} 49 | `( 50 | `it shows the correct rating for $selectedLabel`, 51 | async ({rating, selectedLabel}) => { 52 | await renderRating({rating}) 53 | 54 | expect(screen.getByLabelText(selectedLabel)).toBeChecked() 55 | }, 56 | ) 57 | -------------------------------------------------------------------------------- /src/components/book-row.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core' 3 | 4 | import {Link} from 'react-router-dom' 5 | import {useListItem} from 'utils/list-items' 6 | import * as mq from 'styles/media-queries' 7 | import * as colors from 'styles/colors' 8 | import {StatusButtons} from './status-buttons' 9 | import {Rating} from './rating' 10 | 11 | function BookRow({book}) { 12 | const {title, author, coverImageUrl} = book 13 | const listItem = useListItem(book.id) 14 | 15 | const id = `book-row-book-${book.id}` 16 | 17 | return ( 18 |
26 | 46 |
54 | {`${title} 59 |
60 |
61 |
62 |
63 |

71 | {title} 72 |

73 | {listItem?.finishDate ? : null} 74 |
75 |
76 |
83 | {author} 84 |
85 | {book.publisher} 86 |
87 |
88 | 89 | {book.synopsis.substring(0, 500)}... 90 | 91 |
92 | 93 |
105 | 106 |
107 |
108 | ) 109 | } 110 | 111 | export {BookRow} 112 | -------------------------------------------------------------------------------- /src/components/lib.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core' 3 | 4 | import {Link as RouterLink} from 'react-router-dom' 5 | import styled from '@emotion/styled/macro' 6 | import {keyframes} from '@emotion/core' 7 | import * as colors from 'styles/colors' 8 | import * as mq from 'styles/media-queries' 9 | import {Dialog as ReachDialog} from '@reach/dialog' 10 | import {FaSpinner} from 'react-icons/fa' 11 | 12 | const spin = keyframes({ 13 | '0%': {transform: 'rotate(0deg)'}, 14 | '100%': {transform: 'rotate(360deg)'}, 15 | }) 16 | 17 | const CircleButton = styled.button({ 18 | borderRadius: '30px', 19 | padding: '0', 20 | width: '40px', 21 | height: '40px', 22 | lineHeight: '1', 23 | display: 'flex', 24 | alignItems: 'center', 25 | justifyContent: 'center', 26 | background: colors.base, 27 | color: colors.text, 28 | border: `1px solid ${colors.gray10}`, 29 | cursor: 'pointer', 30 | }) 31 | 32 | const BookListUL = styled.ul({ 33 | listStyle: 'none', 34 | padding: '0', 35 | display: 'grid', 36 | gridTemplateRows: 'repeat(auto-fill, minmax(100px, 1fr))', 37 | gridGap: '1em', 38 | }) 39 | 40 | const Spinner = styled(FaSpinner)({ 41 | animation: `${spin} 1s linear infinite`, 42 | }) 43 | Spinner.defaultProps = { 44 | 'aria-label': 'loading', 45 | } 46 | 47 | const buttonVariants = { 48 | primary: { 49 | background: colors.indigo, 50 | color: colors.base, 51 | }, 52 | secondary: { 53 | background: colors.gray, 54 | color: colors.text, 55 | }, 56 | } 57 | const Button = styled.button( 58 | { 59 | padding: '10px 15px', 60 | border: '0', 61 | lineHeight: '1', 62 | borderRadius: '3px', 63 | }, 64 | ({variant = 'primary'}) => buttonVariants[variant], 65 | ) 66 | 67 | const inputStyles = { 68 | border: '1px solid #f1f1f4', 69 | background: '#f1f2f7', 70 | padding: '8px 12px', 71 | } 72 | 73 | const Input = styled.input({borderRadius: '3px'}, inputStyles) 74 | const Textarea = styled.textarea(inputStyles) 75 | 76 | const Dialog = styled(ReachDialog)({ 77 | maxWidth: '450px', 78 | borderRadius: '3px', 79 | paddingBottom: '3.5em', 80 | boxShadow: '0 10px 30px -5px rgba(0, 0, 0, 0.2)', 81 | margin: '20vh auto', 82 | [mq.small]: { 83 | width: '100%', 84 | margin: '10vh auto', 85 | }, 86 | }) 87 | 88 | const FormGroup = styled.div({ 89 | display: 'flex', 90 | flexDirection: 'column', 91 | }) 92 | 93 | function FullPageSpinner() { 94 | return ( 95 |
105 | 106 |
107 | ) 108 | } 109 | 110 | const Link = styled(RouterLink)({ 111 | color: colors.indigo, 112 | ':hover': { 113 | color: colors.indigoDarken10, 114 | textDecoration: 'underline', 115 | }, 116 | }) 117 | 118 | const errorMessageVariants = { 119 | stacked: {display: 'block'}, 120 | inline: {display: 'inline-block'}, 121 | } 122 | 123 | function ErrorMessage({error, variant = 'stacked', ...props}) { 124 | return ( 125 |
130 | There was an error: 131 |
137 |         {error.message}
138 |       
139 |
140 | ) 141 | } 142 | 143 | function FullPageErrorFallback({error}) { 144 | return ( 145 |
156 |

Uh oh... There's a problem. Try refreshing the app.

157 |
{error.message}
158 |
159 | ) 160 | } 161 | 162 | export { 163 | FullPageErrorFallback, 164 | ErrorMessage, 165 | CircleButton, 166 | BookListUL, 167 | Spinner, 168 | Button, 169 | Input, 170 | Textarea, 171 | Dialog, 172 | FormGroup, 173 | FullPageSpinner, 174 | Link, 175 | } 176 | -------------------------------------------------------------------------------- /src/components/list-item-list.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core' 3 | 4 | import {useListItems} from 'utils/list-items' 5 | import {BookListUL} from './lib' 6 | import {BookRow} from './book-row' 7 | import {Profiler} from './profiler' 8 | 9 | function ListItemList({filterListItems, noListItems, noFilteredListItems}) { 10 | const listItems = useListItems() 11 | 12 | const filteredListItems = listItems.filter(filterListItems) 13 | 14 | if (!listItems.length) { 15 | return
{noListItems}
16 | } 17 | if (!filteredListItems.length) { 18 | return ( 19 |
20 | {noFilteredListItems} 21 |
22 | ) 23 | } 24 | 25 | return ( 26 | 30 | 31 | {filteredListItems.map(listItem => ( 32 |
  • 33 | 34 |
  • 35 | ))} 36 |
    37 |
    38 | ) 39 | } 40 | 41 | export {ListItemList} 42 | -------------------------------------------------------------------------------- /src/components/logo.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | const Logo = ({width = '48', height = '48'}) => { 4 | return ( 5 | 11 | Bookshelf 12 | 13 | 17 | 21 | 25 | 29 | 33 | 37 | 41 | 45 | 49 | 50 | 51 | ) 52 | } 53 | 54 | export {Logo} 55 | -------------------------------------------------------------------------------- /src/components/modal.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core' 3 | 4 | import * as React from 'react' 5 | import VisuallyHidden from '@reach/visually-hidden' 6 | import {Dialog, CircleButton} from './lib' 7 | 8 | const callAll = 9 | (...fns) => 10 | (...args) => 11 | fns.forEach(fn => fn && fn(...args)) 12 | 13 | const ModalContext = React.createContext() 14 | 15 | function Modal(props) { 16 | const [isOpen, setIsOpen] = React.useState(false) 17 | 18 | return 19 | } 20 | 21 | function ModalDismissButton({children: child}) { 22 | const [, setIsOpen] = React.useContext(ModalContext) 23 | return React.cloneElement(child, { 24 | onClick: callAll(() => setIsOpen(false), child.props.onClick), 25 | }) 26 | } 27 | 28 | function ModalOpenButton({children: child}) { 29 | const [, setIsOpen] = React.useContext(ModalContext) 30 | return React.cloneElement(child, { 31 | onClick: callAll(() => setIsOpen(true), child.props.onClick), 32 | }) 33 | } 34 | 35 | function ModalContentsBase(props) { 36 | const [isOpen, setIsOpen] = React.useContext(ModalContext) 37 | return ( 38 | setIsOpen(false)} {...props} /> 39 | ) 40 | } 41 | 42 | function ModalContents({title, children, ...props}) { 43 | return ( 44 | 45 |
    46 | 47 | 48 | Close 49 | × 50 | 51 | 52 |
    53 |

    {title}

    54 | {children} 55 |
    56 | ) 57 | } 58 | 59 | export {Modal, ModalDismissButton, ModalOpenButton, ModalContents} 60 | -------------------------------------------------------------------------------- /src/components/profiler.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {client} from 'utils/api-client' 3 | 4 | let queue = [] 5 | 6 | setInterval(sendProfileQueue, 5000) 7 | 8 | function sendProfileQueue() { 9 | if (!queue.length) { 10 | return Promise.resolve({success: true}) 11 | } 12 | const queueToSend = [...queue] 13 | queue = [] 14 | return client('profile', {data: queueToSend}) 15 | } 16 | 17 | // By wrapping the Profile like this, we can set the onRender to whatever 18 | // we want and we get the additional benefit of being able to include 19 | // additional data and filter phases 20 | function Profiler({metadata, phases, ...props}) { 21 | function reportProfile( 22 | id, // the "id" prop of the Profiler tree that has just committed 23 | phase, // either "mount" (if the tree just mounted) or "update" (if it re-rendered) 24 | actualDuration, // time spent rendering the committed update 25 | baseDuration, // estimated time to render the entire subtree without memoization 26 | startTime, // when React began rendering this update 27 | commitTime, // when React committed this update 28 | interactions, // the Set of interactions belonging to this update 29 | ) { 30 | if (!phases || phases.includes(phase)) { 31 | queue.push({ 32 | metadata, 33 | id, 34 | phase, 35 | actualDuration, 36 | baseDuration, 37 | startTime, 38 | commitTime, 39 | interactions, 40 | }) 41 | } 42 | } 43 | return 44 | } 45 | 46 | export {Profiler} 47 | -------------------------------------------------------------------------------- /src/components/rating.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core' 3 | 4 | import * as React from 'react' 5 | import {useUpdateListItem} from 'utils/list-items' 6 | import {FaStar} from 'react-icons/fa' 7 | import * as colors from 'styles/colors' 8 | import {ErrorMessage} from 'components/lib' 9 | 10 | const visuallyHiddenCSS = { 11 | border: '0', 12 | clip: 'rect(0 0 0 0)', 13 | height: '1px', 14 | margin: '-1px', 15 | overflow: 'hidden', 16 | padding: '0', 17 | position: 'absolute', 18 | width: '1px', 19 | } 20 | 21 | function Rating({listItem}) { 22 | const [isTabbing, setIsTabbing] = React.useState(false) 23 | 24 | const [mutate, {error, isError}] = useUpdateListItem() 25 | 26 | React.useEffect(() => { 27 | function handleKeyDown(event) { 28 | if (event.key === 'Tab') { 29 | setIsTabbing(true) 30 | } 31 | } 32 | document.addEventListener('keydown', handleKeyDown, {once: true}) 33 | return () => document.removeEventListener('keydown', handleKeyDown) 34 | }, []) 35 | 36 | const rootClassName = `list-item-${listItem.id}` 37 | 38 | const stars = Array.from({length: 5}).map((x, i) => { 39 | const ratingId = `rating-${listItem.id}-${i}` 40 | const ratingValue = i + 1 41 | return ( 42 | 43 | { 50 | mutate({id: listItem.id, rating: ratingValue}) 51 | }} 52 | css={[ 53 | visuallyHiddenCSS, 54 | { 55 | [`.${rootClassName} &:checked ~ label`]: {color: colors.gray20}, 56 | [`.${rootClassName} &:checked + label`]: {color: colors.orange}, 57 | // !important is here because we're doing special non-css-in-js things 58 | // and so we have to deal with specificity and cascade. But, I promise 59 | // this is better than trying to make this work with JavaScript. 60 | // So deal with it 😎 61 | [`.${rootClassName} &:hover ~ label`]: { 62 | color: `${colors.gray20} !important`, 63 | }, 64 | [`.${rootClassName} &:hover + label`]: { 65 | color: 'orange !important', 66 | }, 67 | [`.${rootClassName} &:focus + label svg`]: { 68 | outline: isTabbing 69 | ? ['1px solid orange', '-webkit-focus-ring-color auto 5px'] 70 | : 'initial', 71 | }, 72 | }, 73 | ]} 74 | /> 75 | 88 | 89 | ) 90 | }) 91 | return ( 92 |
    e.stopPropagation()} 94 | className={rootClassName} 95 | css={{ 96 | display: 'inline-flex', 97 | alignItems: 'center', 98 | [`&.${rootClassName}:hover input + label`]: { 99 | color: colors.orange, 100 | }, 101 | }} 102 | > 103 | {stars} 104 | {isError ? ( 105 | 110 | ) : null} 111 |
    112 | ) 113 | } 114 | 115 | export {Rating} 116 | -------------------------------------------------------------------------------- /src/components/status-buttons.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core' 3 | 4 | import * as React from 'react' 5 | import { 6 | FaCheckCircle, 7 | FaPlusCircle, 8 | FaMinusCircle, 9 | FaBook, 10 | FaTimesCircle, 11 | } from 'react-icons/fa' 12 | import Tooltip from '@reach/tooltip' 13 | import { 14 | useListItem, 15 | useUpdateListItem, 16 | useRemoveListItem, 17 | useCreateListItem, 18 | } from 'utils/list-items' 19 | import * as colors from 'styles/colors' 20 | import {useAsync} from 'utils/hooks' 21 | import {CircleButton, Spinner} from './lib' 22 | 23 | function TooltipButton({label, highlight, onClick, icon, ...rest}) { 24 | const {isLoading, isError, error, run, reset} = useAsync() 25 | 26 | function handleClick() { 27 | if (isError) { 28 | reset() 29 | } else { 30 | run(onClick()) 31 | } 32 | } 33 | 34 | return ( 35 | 36 | 52 | {isLoading ? : isError ? : icon} 53 | 54 | 55 | ) 56 | } 57 | 58 | function StatusButtons({book}) { 59 | const listItem = useListItem(book.id) 60 | 61 | const [mutate] = useUpdateListItem({throwOnError: true}) 62 | const [handleRemoveClick] = useRemoveListItem({throwOnError: true}) 63 | const [handleAddClick] = useCreateListItem({throwOnError: true}) 64 | 65 | return ( 66 | 67 | {listItem ? ( 68 | Boolean(listItem.finishDate) ? ( 69 | mutate({id: listItem.id, finishDate: null})} 73 | icon={} 74 | /> 75 | ) : ( 76 | mutate({id: listItem.id, finishDate: Date.now()})} 80 | icon={} 81 | /> 82 | ) 83 | ) : null} 84 | {listItem ? ( 85 | handleRemoveClick({id: listItem.id})} 89 | icon={} 90 | /> 91 | ) : ( 92 | handleAddClick({bookId: book.id})} 96 | icon={} 97 | /> 98 | )} 99 | 100 | ) 101 | } 102 | 103 | export {StatusButtons} 104 | -------------------------------------------------------------------------------- /src/context/auth-context.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core' 3 | 4 | import * as React from 'react' 5 | import {queryCache} from 'react-query' 6 | import * as auth from 'auth-provider' 7 | import {client} from 'utils/api-client' 8 | import {useAsync} from 'utils/hooks' 9 | import {setQueryDataForBook} from 'utils/books' 10 | import {FullPageSpinner, FullPageErrorFallback} from 'components/lib' 11 | 12 | async function bootstrapAppData() { 13 | let user = null 14 | 15 | const token = await auth.getToken() 16 | if (token) { 17 | const data = await client('bootstrap', {token}) 18 | queryCache.setQueryData('list-items', data.listItems, { 19 | staleTime: 5000, 20 | }) 21 | for (const listItem of data.listItems) { 22 | setQueryDataForBook(listItem.book) 23 | } 24 | user = data.user 25 | } 26 | return user 27 | } 28 | 29 | const AuthContext = React.createContext() 30 | AuthContext.displayName = 'AuthContext' 31 | 32 | function AuthProvider(props) { 33 | const { 34 | data: user, 35 | status, 36 | error, 37 | isLoading, 38 | isIdle, 39 | isError, 40 | isSuccess, 41 | run, 42 | setData, 43 | } = useAsync() 44 | 45 | React.useEffect(() => { 46 | const appDataPromise = bootstrapAppData() 47 | run(appDataPromise) 48 | }, [run]) 49 | 50 | const login = React.useCallback( 51 | form => auth.login(form).then(user => setData(user)), 52 | [setData], 53 | ) 54 | const register = React.useCallback( 55 | form => auth.register(form).then(user => setData(user)), 56 | [setData], 57 | ) 58 | const logout = React.useCallback(() => { 59 | auth.logout() 60 | queryCache.clear() 61 | setData(null) 62 | }, [setData]) 63 | 64 | const value = React.useMemo( 65 | () => ({user, login, logout, register}), 66 | [login, logout, register, user], 67 | ) 68 | 69 | if (isLoading || isIdle) { 70 | return 71 | } 72 | 73 | if (isError) { 74 | return 75 | } 76 | 77 | if (isSuccess) { 78 | return 79 | } 80 | 81 | throw new Error(`Unhandled status: ${status}`) 82 | } 83 | 84 | function useAuth() { 85 | const context = React.useContext(AuthContext) 86 | if (context === undefined) { 87 | throw new Error(`useAuth must be used within a AuthProvider`) 88 | } 89 | return context 90 | } 91 | 92 | function useClient() { 93 | const {user} = useAuth() 94 | const token = user?.token 95 | return React.useCallback( 96 | (endpoint, config) => client(endpoint, {...config, token}), 97 | [token], 98 | ) 99 | } 100 | 101 | export {AuthProvider, useAuth, useClient} 102 | -------------------------------------------------------------------------------- /src/context/index.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {BrowserRouter as Router} from 'react-router-dom' 3 | import {ReactQueryConfigProvider} from 'react-query' 4 | import {AuthProvider} from './auth-context' 5 | 6 | const queryConfig = { 7 | queries: { 8 | useErrorBoundary: true, 9 | refetchOnWindowFocus: false, 10 | retry(failureCount, error) { 11 | if (error.status === 404) return false 12 | else if (failureCount < 2) return true 13 | else return false 14 | }, 15 | }, 16 | } 17 | 18 | function AppProviders({children}) { 19 | return ( 20 | 21 | 22 | {children} 23 | 24 | 25 | ) 26 | } 27 | 28 | export {AppProviders} 29 | -------------------------------------------------------------------------------- /src/dev-tools/dev-tools.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx, Global} from '@emotion/core' 3 | 4 | import '@reach/tabs/styles.css' 5 | import '@reach/tooltip/styles.css' 6 | 7 | import * as React from 'react' 8 | import {createRoot} from 'react-dom/client' 9 | import {FaTools} from 'react-icons/fa' 10 | import {Tooltip} from '@reach/tooltip' 11 | import {Tabs, TabList, TabPanels, TabPanel, Tab} from '@reach/tabs' 12 | import * as reactQuery from 'react-query' 13 | // pulling the development thing directly because I'm not worried about 14 | // bundle size since this won't be loaded in prod unless the query string/localStorage key is set 15 | import {ReactQueryDevtoolsPanel} from 'react-query-devtools/dist/react-query-devtools.development' 16 | import * as colors from 'styles/colors' 17 | 18 | function install() { 19 | // add some things to window to make it easier to debug 20 | window.reactQuery = reactQuery 21 | 22 | const requireDevToolsLocal = require.context( 23 | './', 24 | false, 25 | /dev-tools\.local\.js/, 26 | ) 27 | const local = requireDevToolsLocal.keys()[0] 28 | if (local) { 29 | requireDevToolsLocal(local).default 30 | } 31 | 32 | function DevTools() { 33 | const rootRef = React.useRef() 34 | const [hovering, setHovering] = React.useState(false) 35 | const [persist, setPersist] = useLocalStorageState( 36 | '__bookshelf_devtools_persist__', 37 | false, 38 | ) 39 | const [tabIndex, setTabIndex] = useLocalStorageState( 40 | '__bookshelf_devtools_tab_index__', 41 | 0, 42 | ) 43 | 44 | const show = persist || hovering 45 | const toggleShow = () => setPersist(v => !v) 46 | React.useEffect(() => { 47 | function updateHoverState(event) { 48 | setHovering(rootRef.current?.contains(event.target) ?? false) 49 | } 50 | document.body.addEventListener('mousemove', updateHoverState) 51 | return () => 52 | document.body.removeEventListener('mousemove', updateHoverState) 53 | }, []) 54 | return ( 55 |
    103 |
    125 | 126 | 159 | 160 | {show ? ( 161 | setTabIndex(i)} 165 | > 166 | 167 | Controls 168 | Request Failures 169 | React Query 170 | 171 |
    177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | ) : null} 190 |
    191 | {show ? ( 192 | 199 | ) : null} 200 |
    201 | ) 202 | } 203 | // add dev tools UI to the page 204 | const devToolsRoot = document.createElement('div') 205 | document.body.appendChild(devToolsRoot) 206 | createRoot(devToolsRoot).render() 207 | } 208 | 209 | function ControlsPanel() { 210 | return ( 211 |
    220 | 221 | 222 | 223 | 224 | 225 |
    226 | ) 227 | } 228 | 229 | function ClearLocalStorage() { 230 | function clear() { 231 | window.localStorage.clear() 232 | window.location.assign(window.location) 233 | } 234 | return 235 | } 236 | 237 | function FailureRate() { 238 | const [failureRate, setFailureRate] = useLocalStorageState( 239 | '__bookshelf_failure_rate__', 240 | 0, 241 | ) 242 | 243 | const handleChange = event => setFailureRate(Number(event.target.value) / 100) 244 | 245 | return ( 246 |
    254 | 255 | 265 |
    266 | ) 267 | } 268 | 269 | function EnableDevTools() { 270 | const [enableDevTools, setEnableDevTools] = useLocalStorageState( 271 | 'dev-tools', 272 | process.env.NODE_ENV === 'development', 273 | ) 274 | 275 | const handleChange = event => setEnableDevTools(event.target.checked) 276 | 277 | return ( 278 |
    285 | 292 | 293 |
    294 | ) 295 | } 296 | 297 | function RequestMinTime() { 298 | const [minTime, setMinTime] = useLocalStorageState( 299 | '__bookshelf_min_request_time__', 300 | 400, 301 | ) 302 | 303 | const handleChange = event => setMinTime(Number(event.target.value)) 304 | 305 | return ( 306 |
    314 | 315 | 325 |
    326 | ) 327 | } 328 | 329 | function RequestVarTime() { 330 | const [varTime, setVarTime] = useLocalStorageState( 331 | '__bookshelf_variable_request_time__', 332 | 400, 333 | ) 334 | 335 | const handleChange = event => setVarTime(Number(event.target.value)) 336 | 337 | return ( 338 |
    346 | 347 | 357 |
    358 | ) 359 | } 360 | 361 | function RequestFailUI() { 362 | const [failConfig, setFailConfig] = useLocalStorageState( 363 | '__bookshelf_request_fail_config__', 364 | [], 365 | ) 366 | 367 | function handleRemoveClick(index) { 368 | setFailConfig(c => [...c.slice(0, index), ...c.slice(index + 1)]) 369 | } 370 | 371 | function handleSubmit(event) { 372 | event.preventDefault() 373 | const {requestMethod, urlMatch} = event.target.elements 374 | setFailConfig(c => [ 375 | ...c, 376 | {requestMethod: requestMethod.value, urlMatch: urlMatch.value}, 377 | ]) 378 | requestMethod.value = '' 379 | urlMatch.value = '' 380 | } 381 | 382 | return ( 383 |
    389 |
    400 |
    408 | 409 | 417 |
    418 |
    419 | 422 | 429 |
    430 |
    431 | 434 |
    435 |
    436 |
      445 | {failConfig.map(({requestMethod, urlMatch}, index) => ( 446 |
    • 459 |
      460 | {requestMethod}: 461 | {urlMatch} 462 |
      463 | 474 |
    • 475 | ))} 476 |
    477 |
    478 | ) 479 | } 480 | 481 | /** 482 | * 483 | * @param {String} key The key to set in localStorage for this value 484 | * @param {Object} defaultValue The value to use if it is not already in localStorage 485 | * @param {{serialize: Function, deserialize: Function}} options The serialize and deserialize functions to use (defaults to JSON.stringify and JSON.parse respectively) 486 | */ 487 | function useLocalStorageState( 488 | key, 489 | defaultValue = '', 490 | {serialize = JSON.stringify, deserialize = JSON.parse} = {}, 491 | ) { 492 | const [state, setState] = React.useState(() => { 493 | const valueInLocalStorage = window.localStorage.getItem(key) 494 | if (valueInLocalStorage) { 495 | return deserialize(valueInLocalStorage) 496 | } 497 | return typeof defaultValue === 'function' ? defaultValue() : defaultValue 498 | }) 499 | 500 | React.useDebugValue(`${key}: ${serialize(state)}`) 501 | 502 | const prevKeyRef = React.useRef(key) 503 | 504 | React.useEffect(() => { 505 | const prevKey = prevKeyRef.current 506 | if (prevKey !== key) { 507 | window.localStorage.removeItem(prevKey) 508 | } 509 | prevKeyRef.current = key 510 | }, [key]) 511 | 512 | React.useEffect(() => { 513 | window.localStorage.setItem(key, serialize(state)) 514 | }, [key, state, serialize]) 515 | 516 | return [state, setState] 517 | } 518 | 519 | export {install} 520 | 521 | /* 522 | eslint 523 | no-unused-expressions: "off", 524 | */ 525 | -------------------------------------------------------------------------------- /src/dev-tools/load.js: -------------------------------------------------------------------------------- 1 | function loadDevTools(callback) { 2 | // check URL first 3 | const url = new URL(window.location) 4 | const setInUrl = url.searchParams.has('dev-tools') 5 | const urlEnabled = url.searchParams.get('dev-tools') === 'true' 6 | if (setInUrl) { 7 | if (urlEnabled) { 8 | return go() 9 | } else { 10 | return callback() 11 | } 12 | } 13 | 14 | // then check localStorage 15 | const localStorageValue = window.localStorage.getItem('dev-tools') 16 | const setInLocalStorage = localStorageValue != undefined 17 | const localStorageEnabled = localStorageValue === 'true' 18 | if (setInLocalStorage) { 19 | if (localStorageEnabled) { 20 | return go() 21 | } else { 22 | return callback() 23 | } 24 | } 25 | 26 | // the default is off in Cypress 27 | if (window.Cypress) return callback() 28 | 29 | // the default is on in development 30 | if (process.env.NODE_ENV === 'development') return go() 31 | 32 | return callback() 33 | 34 | function go() { 35 | // use a dynamic import so the dev-tools code isn't bundled with the regular 36 | // app code so we don't worry about bundle size. 37 | import('./dev-tools') 38 | .then(devTools => devTools.install()) 39 | .finally(callback) 40 | } 41 | } 42 | 43 | export {loadDevTools} 44 | 45 | /* 46 | eslint 47 | eqeqeq: "off", 48 | */ 49 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import {loadDevTools} from './dev-tools/load' 2 | import './bootstrap' 3 | import * as React from 'react' 4 | import {createRoot} from 'react-dom/client' 5 | import {Profiler} from 'components/profiler' 6 | import {App} from './app' 7 | import {AppProviders} from './context' 8 | 9 | loadDevTools(() => { 10 | createRoot(document.getElementById('root')).render( 11 | 12 | 13 | 14 | 15 | , 16 | ) 17 | }) 18 | -------------------------------------------------------------------------------- /src/screens/book.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx} from '@emotion/core' 3 | 4 | import * as React from 'react' 5 | import debounceFn from 'debounce-fn' 6 | import {FaRegCalendarAlt} from 'react-icons/fa' 7 | import Tooltip from '@reach/tooltip' 8 | import {useParams} from 'react-router-dom' 9 | import {useBook} from 'utils/books' 10 | import {formatDate} from 'utils/misc' 11 | import {useListItem, useUpdateListItem} from 'utils/list-items' 12 | import * as mq from 'styles/media-queries' 13 | import * as colors from 'styles/colors' 14 | import {Spinner, Textarea, ErrorMessage} from 'components/lib' 15 | import {Rating} from 'components/rating' 16 | import {Profiler} from 'components/profiler' 17 | import {StatusButtons} from 'components/status-buttons' 18 | 19 | function BookScreen() { 20 | const {bookId} = useParams() 21 | const book = useBook(bookId) 22 | const listItem = useListItem(bookId) 23 | 24 | const {title, author, coverImageUrl, publisher, synopsis} = book 25 | 26 | return ( 27 | 28 |
    29 |
    41 | {`${title} 46 |
    47 |
    48 |
    49 |

    {title}

    50 |
    51 | {author} 52 | | 53 | {publisher} 54 |
    55 |
    56 |
    66 | {book.loadingBook ? null : } 67 |
    68 |
    69 |
    70 | {listItem?.finishDate ? : null} 71 | {listItem ? : null} 72 |
    73 |
    74 |

    75 | {synopsis} 76 |

    77 |
    78 |
    79 | {!book.loadingBook && listItem ? ( 80 | 81 | ) : null} 82 |
    83 |
    84 | ) 85 | } 86 | 87 | function ListItemTimeframe({listItem}) { 88 | const timeframeLabel = listItem.finishDate 89 | ? 'Start and finish date' 90 | : 'Start date' 91 | 92 | return ( 93 | 94 |
    95 | 96 | 97 | {formatDate(listItem.startDate)}{' '} 98 | {listItem.finishDate ? `— ${formatDate(listItem.finishDate)}` : null} 99 | 100 |
    101 |
    102 | ) 103 | } 104 | 105 | function NotesTextarea({listItem}) { 106 | const [mutate, {error, isError, isLoading}] = useUpdateListItem() 107 | 108 | const debouncedMutate = React.useMemo( 109 | () => debounceFn(mutate, {wait: 300}), 110 | [mutate], 111 | ) 112 | 113 | function handleNotesChange(e) { 114 | debouncedMutate({id: listItem.id, notes: e.target.value}) 115 | } 116 | 117 | return ( 118 | 119 |
    120 | 132 | {isError ? ( 133 | 138 | ) : null} 139 | {isLoading ? : null} 140 |
    141 |