├── .all-contributorsrc ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github └── workflows │ ├── node.js.yml │ └── storybook.yml ├── .gitignore ├── .npmrc ├── .prettierrc.cjs ├── .storybook ├── main.ts ├── manager.ts ├── preview.css └── preview.tsx ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── __test__ └── data │ ├── jsonp.test.html │ └── response.test.html ├── index.html ├── package-lock.json ├── package.json ├── sample ├── demo.tsx └── sample.png ├── sonar-project.properties ├── src ├── components │ ├── Book.module.css │ ├── Book.test.tsx │ ├── Book.tsx │ ├── BookList.module.css │ ├── BookList.test.tsx │ ├── BookList.tsx │ ├── Cover.module.css │ ├── Cover.test.tsx │ ├── Cover.tsx │ ├── GoodreadsBookshelf.module.css │ ├── GoodreadsBookshelf.test.tsx │ ├── GoodreadsBookshelf.tsx │ ├── Loader.tsx │ ├── Placeholder.test.tsx │ ├── Placeholder.tsx │ ├── Rating.test.tsx │ └── Rating.tsx ├── globals.d.ts ├── hooks │ ├── useGoodreadsShelf.test.tsx │ └── useGoodreadsShelf.ts ├── index.ts ├── types │ ├── api.ts │ ├── book.ts │ ├── index.ts │ └── props.ts └── util │ ├── api.test.ts │ ├── api.ts │ ├── get-url.ts │ ├── html-utils.test.ts │ ├── html-utils.ts │ └── index.ts ├── stories ├── 0-GoodreadsBookshelf.stories.tsx └── GoodreadBookshelf.mdx ├── tsconfig.json ├── vite.config.ts └── vitest.setup.ts /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "commitType": "docs", 8 | "commitConvention": "angular", 9 | "contributors": [ 10 | { 11 | "login": "kylekarpack", 12 | "name": "Kyle", 13 | "avatar_url": "https://avatars.githubusercontent.com/u/2429580?v=4", 14 | "profile": "http://www.kylekarpack.com", 15 | "contributions": [ 16 | "code" 17 | ] 18 | }, 19 | { 20 | "login": "thedaviddias", 21 | "name": "David Dias", 22 | "avatar_url": "https://avatars.githubusercontent.com/u/237229?v=4", 23 | "profile": "https://thedaviddias.dev", 24 | "contributions": [ 25 | "a11y" 26 | ] 27 | }, 28 | { 29 | "login": "eligundry", 30 | "name": "Eli Gundry", 31 | "avatar_url": "https://avatars.githubusercontent.com/u/439936?v=4", 32 | "profile": "https://eligundry.com/", 33 | "contributions": [ 34 | "code" 35 | ] 36 | }, 37 | { 38 | "login": "BassemMohamed", 39 | "name": "Bassem Ibrahim", 40 | "avatar_url": "https://avatars.githubusercontent.com/u/17043634?v=4", 41 | "contributions": [ 42 | "code" 43 | ] 44 | } 45 | ], 46 | "contributorsPerLine": 7, 47 | "skipCi": true, 48 | "repoType": "github", 49 | "repoHost": "https://github.com", 50 | "projectName": "react-goodreads-shelf", 51 | "projectOwner": "kylekarpack" 52 | } 53 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | stories/ 4 | jest.setup.js 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "./node_modules/gts/", 4 | "plugin:storybook/recommended" 5 | ], 6 | "rules": { 7 | "quotes": [ 8 | "warn", 9 | "double" 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Exclude some test files from language stats 5 | __test__/* linguist-generated 6 | __test__/data/response.test.html linguist-generated 7 | __test__/data/jsonp.test.html linguist-generated 8 | response.test.html linguist-generated 9 | jsonp.test.html linguist-generated 10 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Build and Test 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - name: Install Packages 27 | run: npm ci 28 | - name: Build 29 | run: npm run build 30 | - name: Run Tests 31 | run: npm run test -- --coverage --update 32 | - name: Analyze with SonarCloud 33 | uses: sonarsource/sonarcloud-github-action@master 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 36 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/storybook.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Storybook 2 | on: 3 | push: 4 | branches: [main] 5 | paths: ["stories/**", "src/components/**", "./.storybook/**"] # Trigger the action only when files change in the folders defined here 6 | jobs: 7 | build-and-deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 🛎️ 11 | uses: actions/checkout@v2.3.1 12 | with: 13 | persist-credentials: false 14 | - name: Install and Build 🔧 15 | run: | # Install npm packages and build the Storybook files 16 | npm ci 17 | BASE_PATH=/react-goodreads-shelf/ npm run build-storybook 18 | - name: Deploy 🚀 19 | uses: JamesIves/github-pages-deploy-action@3.6.2 20 | with: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | BRANCH: gh-pages # The branch the action should deploy to. 23 | FOLDER: docs-build # The folder that the build-storybook script generates files. 24 | CLEAN: true # Automatically remove deleted files from the deploy branch 25 | TARGET_FOLDER: docs # The folder that we serve our Storybook files from 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | 78 | dist/ 79 | __snapshots__/ 80 | test-report.xml 81 | docs-build/ 82 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | registry=https://registry.npmjs.org 3 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require("gts/.prettierrc.json"), 3 | bracketSpacing: true, 4 | semi: true, 5 | arrowParens: "always", 6 | trailingComma: "none", 7 | singleQuote: false, 8 | printWidth: 120, 9 | useTabs: false, 10 | tabWidth: 2, 11 | eol: "lf" 12 | }; 13 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/react-vite"; 2 | import { mergeConfig } from "vite"; 3 | 4 | const config: StorybookConfig = { 5 | stories: ["../stories/**/*.stories.{ts,tsx}", "../stories/**/*.mdx"], 6 | addons: ["@storybook/addon-controls", "@storybook/addon-links", "@storybook/addon-essentials", "storybook-dark-mode"], 7 | typescript: { 8 | check: false 9 | }, 10 | async viteFinal(config) { 11 | return mergeConfig(config, { 12 | base: process.env.BASE_PATH || config.base 13 | }); 14 | }, 15 | framework: { 16 | name: "@storybook/react-vite", 17 | options: {} 18 | }, 19 | docs: { 20 | autodocs: "tag" 21 | } 22 | }; 23 | export default config; 24 | -------------------------------------------------------------------------------- /.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from "@storybook/addons"; 2 | 3 | addons.setConfig({ 4 | isFullscreen: false, 5 | showNav: true, 6 | showPanel: true, 7 | showToolbar: true, 8 | panelPosition: "right", 9 | sidebarAnimations: true, 10 | enableShortcuts: true, 11 | isToolshown: true, 12 | theme: undefined, 13 | selectedPanel: "controls", 14 | initialActive: "controls", 15 | sidebar: { 16 | showRoots: false 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /.storybook/preview.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Nunito Sans", -apple-system, ".SFNSText-Regular", "San Francisco", BlinkMacSystemFont, "Segoe UI", 3 | "Helvetica Neue", Helvetica, Arial, sans-serif; 4 | } 5 | 6 | .lights-out { 7 | background-color: #222; 8 | color: #fff; 9 | } 10 | -------------------------------------------------------------------------------- /.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import { themes } from "@storybook/theming"; 2 | import "./preview.css"; 3 | 4 | const parameters = { 5 | darkMode: { 6 | // Override the default dark theme 7 | dark: { ...themes.dark, appBg: "black", appContentBg: "black" }, 8 | // Override the default light theme 9 | light: { ...themes.normal, appBg: "white" }, 10 | darkClass: "lights-out", 11 | lightClass: "lights-on", 12 | stylePreview: true 13 | } 14 | }; 15 | 16 | const preview = { 17 | parameters 18 | }; 19 | 20 | export default preview; 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "typescript.enablePromptUseWorkspaceTsdk": true 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Kyle Karpack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-goodreads-shelf 2 | ![build](https://github.com/kylekarpack/react-goodreads-shelf/workflows/build/badge.svg) ![CodeQL](https://github.com/kylekarpack/react-goodreads-shelf/workflows/CodeQL/badge.svg) 3 | [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=kylekarpack_react-goodreads-shelf&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=kylekarpack_react-goodreads-shelf) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=kylekarpack_react-goodreads-shelf&metric=coverage)](https://sonarcloud.io/dashboard?id=kylekarpack_react-goodreads-shelf) 4 | 5 | This React component allows you to display a public Goodreads shelf in a React application. It's a lot like the Goodreads JavaScript widget, but allows for more customization, better async loading, and React-like usage. 6 | 7 | ## Demo 8 | [Live Demo](https://kylekarpack.github.io/react-goodreads-shelf) 9 | 10 | Preview 11 | 12 | ![Example image](/sample/sample.png) 13 | 14 | ## Installation 15 | 16 | ``` 17 | npm install --save react-goodreads-shelf 18 | ``` 19 | or 20 | ``` 21 | yarn add react-goodreads-shelf 22 | ``` 23 | 24 | ## Usage 25 | 26 | ```jsx 27 | import React from "react"; 28 | import GoodreadsBookshelf from "react-goodreads-shelf"; 29 | 30 | export default function App() { 31 | return ( 32 | 33 | ); 34 | } 35 | ``` 36 | 37 | ## Customization 38 | 39 | You can also set some options as supported by the Goodreads list page: 40 | 41 | | Option | Type | Description | Default | 42 | | ------ | ---- | ----------- | ------- | 43 | | shelf | string | The shelf from which to fetch books | read | 44 | | sort | string | The field by which to sort the results returned | date_read | 45 | | limit | number | The maximum number of books to be returned | 10 | 46 | | width | number | Minimum width allowed for each book | 100 | 47 | | search | string | Search text | "" | 48 | 49 | ## Development 50 | - `npm run start` to watch changes and build 51 | - `npm run storybook` to launch storybook for testing 52 | 53 | ## Contribution 54 | 55 | Please feel free to open issues or pull requests and I will review as promptly as I am able. 56 | 57 | ### Contributors 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 |
Kyle
Kyle

💻
David Dias
David Dias

️️️️♿️
Eli Gundry
Eli Gundry

💻
Bassem Ibrahim
Bassem Ibrahim
💻
72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /__test__/data/response.test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 11 | 23 | 27 | 34 | 38 | 42 | 46 | 55 | 59 | 63 | 67 | 71 | 81 | 104 | 111 | 117 | 123 | 129 | 135 | 139 | 149 | 159 | 165 | 169 | 173 | 177 | 181 | 185 | 193 | 194 | 195 | 196 | 200 | 204 | 216 | 224 | 231 | 235 | 239 | 243 | 252 | 256 | 260 | 264 | 268 | 278 | 301 | 308 | 314 | 320 | 326 | 332 | 336 | 346 | 356 | 362 | 366 | 370 | 374 | 378 | 382 | 390 | 391 | 392 | 393 | 397 | 401 | 413 | 422 | 426 | 430 | 434 | 438 | 447 | 451 | 455 | 459 | 463 | 473 | 496 | 503 | 509 | 515 | 521 | 527 | 531 | 541 | 551 | 557 | 561 | 565 | 569 | 573 | 577 | 585 | 586 | 587 | 588 | 592 | 596 | 608 | 616 | 620 | 624 | 628 | 632 | 641 | 645 | 649 | 653 | 657 | 667 | 690 | 697 | 703 | 709 | 715 | 721 | 725 | 735 | 745 | 751 | 755 | 759 | 763 | 767 | 771 | 779 | 780 | 781 | 782 | 786 | 790 | 802 | 813 | 817 | 821 | 825 | 829 | 838 | 842 | 846 | 850 | 854 | 864 | 887 | 894 | 900 | 906 | 912 | 918 | 922 | 932 | 942 | 948 | 952 | 956 | 960 | 964 | 968 | 976 | 977 |
4 | 5 |
 
6 |
12 | 13 |
14 | The Club 21 |
22 |
24 | 25 | 26 | 28 | 29 |
30 | Lloyd, Ellery 31 | * 32 |
33 |
56 | 57 |
3.39
58 |
72 | 73 |
74 | liked it 79 |
80 |
82 | 83 |
84 | 97 | 98 | 99 |
100 | add to shelves 101 |
102 |
103 |
150 | 151 |
152 |
153 |
154 | Aug 30, 2022 155 |
156 |
157 |
158 |
160 | 161 |
162 | Aug 30, 2022 163 |
164 |
186 | 187 |
188 |
189 | view 190 |
191 |
192 |
197 | 198 |
 
199 |
205 | 206 |
207 | Garden City: Work, Rest, and the Art of Being Human. 214 |
215 |
217 | 218 | 223 | 225 | 226 |
227 | Comer, John Mark 228 | * 229 |
230 |
253 | 254 |
4.46
255 |
269 | 270 |
271 | it was amazing 276 |
277 |
279 | 280 |
281 | 294 | 295 | 296 |
297 | add to shelves 298 |
299 |
300 |
347 | 348 |
349 |
350 |
351 | Aug 15, 2022 352 |
353 |
354 |
355 |
357 | 358 |
359 | Jan 19, 2020 360 |
361 |
383 | 384 |
385 |
386 | view 387 |
388 |
389 |
394 | 395 |
 
396 |
402 | 403 |
404 | The Golden Compass (His Dark Materials, #1) 411 |
412 |
414 | 415 | 421 | 423 | 424 | 425 | 448 | 449 |
4.01
450 |
464 | 465 |
466 | 471 |
472 |
474 | 475 |
476 | 489 | 490 | 491 |
492 | add to shelves 493 |
494 |
495 |
542 | 543 |
544 |
545 |
546 | Jul 11, 2022 547 |
548 |
549 |
550 |
552 | 553 |
554 | Jul 03, 2022 555 |
556 |
578 | 579 |
580 |
581 | view 582 |
583 |
584 |
589 | 590 |
 
591 |
597 | 598 |
599 | Great Lodges of the National Parks 606 |
607 |
609 | 610 | 615 | 617 | 618 | 619 | 642 | 643 |
4.25
644 |
658 | 659 |
660 | 665 |
666 |
668 | 669 |
670 | 683 | 684 | 685 |
686 | add to shelves 687 |
688 |
689 |
736 | 737 |
738 |
739 |
740 | Jul 03, 2022 741 |
742 |
743 |
744 |
746 | 747 |
748 | Jun 20, 2022 749 |
750 |
772 | 773 |
774 |
775 | view 776 |
777 |
778 |
783 | 784 |
 
785 |
791 | 792 |
793 | Great Lodges of the National Parks, Volume Two 800 |
801 |
803 | 804 | 812 | 814 | 815 | 816 | 839 | 840 |
4.33
841 |
855 | 856 |
857 | 862 |
863 |
865 | 866 |
867 | 880 | 881 | 882 |
883 | add to shelves 884 |
885 |
886 |
933 | 934 |
935 |
936 |
937 | Jun 20, 2022 938 |
939 |
940 |
941 |
943 | 944 |
945 | Jun 20, 2022 946 |
947 |
969 | 970 |
971 |
972 | view 973 |
974 |
975 |
978 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Demo Page 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-goodreads-shelf", 3 | "type": "module", 4 | "version": "3.1.5", 5 | "description": "A React widget for displaying a user's public book shelf", 6 | "main": "./dist/index.umd.js", 7 | "module": "./dist/index.es.js", 8 | "typings": "./dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "import": "./dist/index.es.js", 12 | "require": "./dist/index.umd.js" 13 | } 14 | }, 15 | "files": [ 16 | "dist" 17 | ], 18 | "repository": "https://github.com/kylekarpack/react-goodreads-shelf", 19 | "author": "Kyle Karpack ", 20 | "license": "MIT", 21 | "private": false, 22 | "engines": { 23 | "node": ">=14" 24 | }, 25 | "engineStrict": true, 26 | "devDependencies": { 27 | "@storybook/addon-actions": "^7.0.12", 28 | "@storybook/addon-controls": "^7.0.12", 29 | "@storybook/addon-essentials": "^7.0.12", 30 | "@storybook/addon-links": "^7.0.12", 31 | "@storybook/addon-mdx-gfm": "^7.0.12", 32 | "@storybook/addons": "^7.0.12", 33 | "@storybook/client-api": "^7.0.12", 34 | "@storybook/preview-web": "^7.0.12", 35 | "@storybook/react": "^7.0.12", 36 | "@storybook/react-vite": "^7.0.12", 37 | "@testing-library/jest-dom": "^5.16.5", 38 | "@testing-library/react": "^14.0.0", 39 | "@testing-library/react-hooks": "^8.0.1", 40 | "@types/node": "^17.0.12", 41 | "@types/react": "^18.0.21", 42 | "@types/react-dom": "^18.0.6", 43 | "@vitejs/plugin-react": "^2.1.0", 44 | "@vitest/coverage-c8": "^0.30.1", 45 | "c8": "^7.11.0", 46 | "eslint-plugin-storybook": "^0.6.12", 47 | "gts": "^3.1.0", 48 | "jsdom": "^19.0.0", 49 | "prettier": "^2.5.1", 50 | "react": "^18.2.0", 51 | "react-dom": "^18.2.0", 52 | "release-it": "^15.4.2", 53 | "storybook": "^7.0.12", 54 | "storybook-dark-mode": "^3.0.0", 55 | "typescript": "^5.0.4", 56 | "typescript-plugin-css-modules": "^4.2.2", 57 | "vite": "^4.3.3", 58 | "vite-plugin-css-injected-by-js": "^3.1.0", 59 | "vite-plugin-dts": "^2.3.0", 60 | "vitest": "^0.30.1" 61 | }, 62 | "scripts": { 63 | "test": "vitest run", 64 | "test:coverage": "vitest run --coverage", 65 | "test:watch": "vitest --reporter=verbose", 66 | "test:related": "vitest related", 67 | "dev": "vite", 68 | "build": "vite build", 69 | "preview": "vite preview", 70 | "prepublish": "npm run build", 71 | "storybook": "STORYBOOK=true storybook dev -p 6006", 72 | "build-storybook": "STORYBOOK=true storybook build -o docs-build", 73 | "lint": "gts lint src/**/*.{ts,tsx}", 74 | "clean": "gts clean", 75 | "compile": "tsc", 76 | "fix": "gts fix src/**/*.{ts,tsx}", 77 | "release": "release-it" 78 | }, 79 | "release-it": { 80 | "github": { 81 | "release": true, 82 | "web": true 83 | }, 84 | "hooks": { 85 | "before:init": [ 86 | "npm run test" 87 | ], 88 | "after:bump": "npm run build" 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /sample/demo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { GoodreadsBookshelf } from "../src"; 4 | 5 | const container = document.getElementById("home"); 6 | const root = createRoot(container as HTMLElement); 7 | root.render( 8 |
9 | 10 |

React Goodreads Shelf Demo Page

11 | 12 |
13 | ); 14 | -------------------------------------------------------------------------------- /sample/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kylekarpack/react-goodreads-shelf/1345314afa30f2d63438679e80b101f732718fce/sample/sample.png -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.organization=kylekarpack 2 | sonar.projectKey=kylekarpack_react-goodreads-shelf 3 | 4 | sonar.sources=src 5 | sonar.coverage.exclusions=**/*.test.ts,**/*.test.tsx 6 | sonar.exclusions=**/*.test.html,**/*.test.xml 7 | sonar.language=js 8 | 9 | sonar.javascript.lcov.reportPaths=coverage/lcov.info 10 | -------------------------------------------------------------------------------- /src/components/Book.module.css: -------------------------------------------------------------------------------- 1 | .book { 2 | text-align: left; 3 | } 4 | 5 | .details { 6 | margin-top: 0.5em; 7 | } 8 | 9 | .title { 10 | font-weight: 700; 11 | font-size: 1.1em; 12 | margin-bottom: 0.25em; 13 | } 14 | 15 | .subtitle { 16 | margin-bottom: 0.25em; 17 | } 18 | 19 | .author { 20 | margin-bottom: 0.25em; 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Book.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from "@testing-library/react"; 2 | import { describe, expect, it } from "vitest"; 3 | import Book from "./Book"; 4 | import { Book as BookType } from "../types"; 5 | 6 | describe("book component", () => { 7 | it("renders without crashing", () => { 8 | const book = render(); 9 | expect(book.container).toBeInTheDocument(); 10 | }); 11 | 12 | it("renders image", () => { 13 | const data: BookType = { id: "1", title: "test", imageUrl: "test.jpg" }; 14 | const book = render(); 15 | act(() => { 16 | expect(book.getByAltText(data.title)).toBeInTheDocument(); 17 | }); 18 | }); 19 | 20 | it("renders a placeholder", async () => { 21 | const data: BookType = { title: "Test", id: "1", imageUrl: "fail.jpg" }; 22 | const screen = render(); 23 | const img = screen.getByAltText(data.title); 24 | fireEvent.error(img); 25 | expect(img).not.toBeInTheDocument(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/components/Book.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "react"; 2 | import type { Book as BookType, Props } from "../types"; 3 | import styles from "./Book.module.css"; 4 | import Cover from "./Cover"; 5 | import Rating from "./Rating"; 6 | 7 | const Details: FunctionComponent<{ book: BookType; options?: Props }> = ({ book, options }) => { 8 | const hideDetails = options?.displayOptions?.hideDetails; 9 | if (hideDetails === true) { 10 | return null; 11 | } 12 | const shouldShow = (item: keyof BookType): boolean => { 13 | if (hideDetails === false) { 14 | return true; 15 | } 16 | return !hideDetails?.[item]; 17 | }; 18 | 19 | return ( 20 |
21 | {shouldShow("title") &&
{book.title}
} 22 | {shouldShow("subtitle") && book.subtitle ? ( 23 |
{book.subtitle}
24 | ) : null} 25 | {shouldShow("author") &&
{book.author}
} 26 | {shouldShow("rating") && } 27 |
28 | ); 29 | }; 30 | 31 | const Book: FunctionComponent<{ book: BookType; options?: Props }> = ({ book, options }) => { 32 | if (!book) { 33 | return null; 34 | } 35 | 36 | return ( 37 |
38 |
39 | 40 | 41 | 42 |
43 |
44 |
45 | ); 46 | }; 47 | 48 | export default Book; 49 | -------------------------------------------------------------------------------- /src/components/BookList.module.css: -------------------------------------------------------------------------------- 1 | .shelf { 2 | display: grid; 3 | justify-content: space-around; 4 | align-items: top; 5 | list-style: none; 6 | padding: 0; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/BookList.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import BookList from "./BookList"; 3 | 4 | describe("book list component", () => { 5 | it("renders without crashing", () => { 6 | const { container } = render(); 7 | expect(container).toBeInTheDocument(); 8 | }); 9 | 10 | it("passes props correctly", () => { 11 | const id = Math.round(Math.random() * 100000); 12 | const books = [{ id: id.toString() }]; 13 | const list = render(); 14 | const items = list.container; 15 | expect(items.childElementCount).toEqual(books.length); 16 | }); 17 | 18 | it("handles widths", () => { 19 | const books = [{ id: "1" }, { id: "2" }]; 20 | const list = render(); 21 | const elements = list.getByTestId("book-list"); 22 | expect(elements.childElementCount).toBe(2); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/BookList.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, FunctionComponent } from "react"; 2 | import { Book as BookType, Props } from "../types"; 3 | import Book from "./Book"; 4 | import styles from "./BookList.module.css"; 5 | 6 | const shelfStyle = (options?: Props): CSSProperties => { 7 | let { width } = options || {}; 8 | 9 | if (typeof width === "number") { 10 | width = `${width}px`; 11 | } 12 | 13 | width = width || "170px"; 14 | 15 | const gap = options?.displayOptions?.hideDetails ? `calc(${width} / 8)` : `calc(${width} / 6)`; 16 | 17 | return { 18 | gridTemplateColumns: `repeat(auto-fill, minmax(${width}, 1fr))`, 19 | columnGap: options?.displayOptions?.gridStyle?.columnGap ?? gap, 20 | rowGap: options?.displayOptions?.gridStyle?.rowGap ?? gap 21 | }; 22 | }; 23 | 24 | const BookList: FunctionComponent<{ books: BookType[]; options?: Props }> = ({ books, options }) => { 25 | return ( 26 |
    27 | {books.map((book) => { 28 | return ( 29 |
  • 30 | 31 |
  • 32 | ); 33 | })} 34 |
35 | ); 36 | }; 37 | 38 | export default BookList; 39 | -------------------------------------------------------------------------------- /src/components/Cover.module.css: -------------------------------------------------------------------------------- 1 | .image { 2 | display: block; 3 | -o-object-fit: contain; 4 | object-fit: contain; 5 | width: 100%; 6 | aspect-ratio: 2 / 3; 7 | position: relative; 8 | z-index: 2; 9 | -webkit-backdrop-filter: blur(6px); 10 | backdrop-filter: blur(6px); 11 | } 12 | 13 | .cover { 14 | background-size: cover; 15 | background-repeat: no-repeat; 16 | background-position: 50% 0; 17 | transition: opacity 0.25s ease-in-out; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/Cover.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import { Book } from "../types"; 3 | import Cover from "./Cover"; 4 | 5 | describe("cover component", () => { 6 | const book: Book = { 7 | id: "1", 8 | title: "test" 9 | }; 10 | 11 | it("renders with no props", () => { 12 | const { container } = render(); 13 | expect(container.childElementCount).toBe(1); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/Cover.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, FunctionComponent, useEffect, useMemo, useRef, useState } from "react"; 2 | import type { Book, Props } from "../types"; 3 | import Placeholder from "./Placeholder"; 4 | import styles from "./Cover.module.css"; 5 | 6 | const Cover: FunctionComponent<{ book: Book; options?: Props }> = ({ book, options }) => { 7 | const [state, setState] = useState({ error: false }); 8 | const [isInView, setIsInView] = useState(false); 9 | 10 | const root = useRef(null); 11 | 12 | useEffect(() => { 13 | const observer = new window.IntersectionObserver( 14 | (entries) => { 15 | const { isIntersecting } = entries[0]; 16 | if (isIntersecting) { 17 | observer.disconnect(); 18 | } 19 | 20 | setIsInView(isIntersecting); 21 | }, 22 | { threshold: 0 } 23 | ); 24 | if (root?.current) { 25 | observer.observe(root.current); 26 | } 27 | }, []); 28 | 29 | let { imageUrl } = book; 30 | if (!imageUrl) { 31 | if (book.isbn) { 32 | imageUrl = `https://images-na.ssl-images-amazon.com/images/P/${book.isbn}.01._SCLZZZZZZZ_.jpg`; 33 | } else if (book.asin) { 34 | imageUrl = `https://images-na.ssl-images-amazon.com/images/P/${book.asin}.01._SCLZZZZZZZ_.jpg`; 35 | } 36 | } 37 | 38 | const onError = () => setState({ error: true }); 39 | 40 | const getBackground = useMemo( 41 | () => 42 | (imageUrl: string): CSSProperties => { 43 | let backgroundImage = ""; 44 | 45 | if (!options?.displayOptions?.hideBackgroundImages) { 46 | if (isInView) { 47 | backgroundImage = `url("${imageUrl}")`; 48 | } 49 | } 50 | 51 | return { 52 | backgroundImage, 53 | opacity: isInView ? 1 : 0 54 | }; 55 | }, 56 | [imageUrl, isInView, options?.displayOptions?.hideBackgroundImages] 57 | ); 58 | 59 | return ( 60 |
61 | {state.error ? ( 62 |
63 | 64 |
65 | ) : ( 66 |
67 | {book.title} 74 |
75 | )} 76 |
77 | ); 78 | }; 79 | 80 | export default Cover; 81 | -------------------------------------------------------------------------------- /src/components/GoodreadsBookshelf.module.css: -------------------------------------------------------------------------------- 1 | .groupTitle { 2 | font-size: 2em; 3 | margin-bottom: 0.5em; 4 | } 5 | 6 | .group { 7 | margin-bottom: 2em; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/GoodreadsBookshelf.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render } from "@testing-library/react"; 2 | import GoodreadsBookshelf from "./GoodreadsBookshelf"; 3 | 4 | describe("bookshelf component", () => { 5 | it("renders without crashing", async () => { 6 | await act(async () => { 7 | const shelf = render(); 8 | expect(shelf.container).toBeInTheDocument(); 9 | }); 10 | }); 11 | 12 | it("works with a user ID", async () => { 13 | await act(async () => { 14 | const shelf = render(); 15 | expect(shelf.container).toBeInTheDocument(); 16 | }); 17 | }); 18 | 19 | it("passes props properly", async () => { 20 | await act(async () => { 21 | const shelf = render(); 22 | expect(shelf.container).toBeInTheDocument(); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/GoodreadsBookshelf.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "react"; 2 | import useGoodreadsShelf from "../hooks/useGoodreadsShelf"; 3 | import { Props } from "../types"; 4 | import { ALL_GROUP_TITLE } from "../util"; 5 | import BookList from "./BookList"; 6 | import styles from "./GoodreadsBookshelf.module.css"; 7 | import Loader from "./Loader"; 8 | 9 | /** Display a Goodreads bookshelf component */ 10 | const GoodreadsBookshelf: FunctionComponent = (props) => { 11 | const { books, loading, error } = useGoodreadsShelf(props); 12 | 13 | return ( 14 |
15 | {loading ? ( 16 | 17 | ) : ( 18 |
19 | {books.map((el) => { 20 | return ( 21 |
22 | {el.title !== ALL_GROUP_TITLE && ( 23 |
24 | {el.title}{" "} 25 | 26 | ({el.books.length} {el.books.length === 1 ? "book" : "books"}) 27 | 28 |
29 | )} 30 | 31 |
32 | ); 33 | })} 34 |
35 | )} 36 | 37 | {error &&
Sorry, we couldn't load books right now
} 38 |
39 | ); 40 | }; 41 | 42 | export default GoodreadsBookshelf; 43 | -------------------------------------------------------------------------------- /src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "react"; 2 | 3 | const Loader: FunctionComponent = () => { 4 | return ( 5 |
6 | 13 | 14 | 15 | 24 | 33 | 34 | 35 | 44 | 53 | 54 | 55 | 64 | 73 | 74 | 75 | 84 | 93 | 94 | 95 | 104 | 113 | 114 | 115 | 124 | 133 | 134 | 135 |
136 | ); 137 | }; 138 | 139 | export default Loader; 140 | -------------------------------------------------------------------------------- /src/components/Placeholder.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import Placeholder from "./Placeholder"; 3 | 4 | describe("placeholder component", () => { 5 | it("renders without crashing", () => { 6 | const placeholder = render(); 7 | expect(placeholder.container).toBeInTheDocument(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/Placeholder.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | const Placeholder: FunctionComponent = () => { 4 | return ( 5 |
6 | 7 | 8 | 9 | 13 | 17 | 18 | 22 | 23 | 24 | 28 | 32 | 36 | 37 | 38 | 39 |
40 | ); 41 | }; 42 | 43 | export default Placeholder; 44 | -------------------------------------------------------------------------------- /src/components/Rating.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import Rating from "./Rating"; 3 | 4 | describe("rating component", () => { 5 | it("hides if no stars", () => { 6 | const { container } = render(); 7 | expect(container.childElementCount).toBe(0); 8 | }); 9 | 10 | it("renders the correct number of stars", () => { 11 | const { getByRole } = render(); 12 | const container = getByRole("presentation"); 13 | expect(container.childElementCount).toBe(3); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/Rating.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from "react"; 2 | 3 | const Rating: FunctionComponent<{ stars?: number }> = ({ stars }) => { 4 | if (stars) { 5 | const arr = new Array(stars).fill("★"); 6 | return ( 7 |
8 | {arr.map((el, i) => ( 9 | {el} 10 | ))} 11 |
12 | ); 13 | } 14 | return null; 15 | }; 16 | 17 | export default Rating; 18 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | import type { Props } from "./types"; 2 | 3 | declare module "*.css" { 4 | const content: Record; 5 | export default content; 6 | } 7 | 8 | declare module "*.css?inline" { 9 | const content: Record; 10 | export default content; 11 | } 12 | 13 | export { Props }; 14 | -------------------------------------------------------------------------------- /src/hooks/useGoodreadsShelf.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react-hooks"; 2 | import { vi } from "vitest"; 3 | import useGoodreadsShelf from "./useGoodreadsShelf"; 4 | 5 | describe("use shelf hook", () => { 6 | it("handles loading state", async () => { 7 | const { result, waitForNextUpdate } = renderHook(() => useGoodreadsShelf({ userId: "kyle" })); 8 | expect(result.current.loading).toBe(true); 9 | await waitForNextUpdate(); 10 | expect(result.current.loading).toBe(false); 11 | }); 12 | 13 | it("handles errors", async () => { 14 | const message = "some error message"; 15 | const spy = vi.spyOn(window, "fetch"); 16 | spy.mockImplementationOnce(() => Promise.reject(message)); 17 | 18 | const { result, waitForNextUpdate } = renderHook(() => useGoodreadsShelf({ userId: "kyle" })); 19 | expect(result.current.error).toBeNull(); 20 | await waitForNextUpdate(); 21 | expect(result.current.error).toBe(message); 22 | expect(spy).toHaveBeenCalledTimes(1); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/hooks/useGoodreadsShelf.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { BookGroup, Props } from "../types"; 3 | import { fetchAllBooks } from "../util"; 4 | 5 | const useGoodreadsShelf = (props: Props) => { 6 | const { userId, limit, order, search, shelf, sort } = props; 7 | const [books, setBooks] = useState([]); 8 | const [loading, setLoading] = useState(false); 9 | const [error, setError] = useState(null); 10 | 11 | const refresh = async () => { 12 | setLoading(true); 13 | setError(null); 14 | 15 | try { 16 | const books = await fetchAllBooks(props); 17 | setBooks(books); 18 | } catch (err: unknown) { 19 | setError(err as Error); 20 | } finally { 21 | setLoading(false); 22 | } 23 | }; 24 | 25 | useEffect(() => { 26 | if (!userId) { 27 | return; 28 | } 29 | refresh(); 30 | }, [userId, limit, order, search, shelf, sort]); 31 | 32 | return { books, loading, error }; 33 | }; 34 | 35 | export default useGoodreadsShelf; 36 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import GoodreadsBookshelf from "./components/GoodreadsBookshelf"; 2 | 3 | export { GoodreadsBookshelf }; 4 | export type { Props, Book } from "./types"; 5 | export default GoodreadsBookshelf; 6 | -------------------------------------------------------------------------------- /src/types/api.ts: -------------------------------------------------------------------------------- 1 | import { Book } from "./book"; 2 | 3 | export type Status = { end: number; total: number }; 4 | 5 | export type FetchResults = { 6 | books: Book[]; 7 | status: Status; 8 | }; 9 | 10 | export type BookGroup = { 11 | title: string; 12 | books: Book[]; 13 | }; 14 | -------------------------------------------------------------------------------- /src/types/book.ts: -------------------------------------------------------------------------------- 1 | export type Book = { 2 | id: string; 3 | isbn?: string; 4 | asin?: string; 5 | title?: string; 6 | subtitle?: string; 7 | description?: string; 8 | author?: string; 9 | authors?: { 10 | author: { 11 | name: string; 12 | }; 13 | }; 14 | imageUrl?: string; 15 | link?: string; 16 | rating?: number; 17 | dateRead?: Date; 18 | dateAdded?: Date; 19 | }; 20 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api"; 2 | export * from "./book"; 3 | export * from "./props"; 4 | -------------------------------------------------------------------------------- /src/types/props.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | import { Book } from "./book"; 3 | 4 | type SortKey = 5 | | "title" 6 | | "author" 7 | | "cover" 8 | | "rating" 9 | | "year_pub" 10 | | "date_pub" 11 | | "date_pub_edition" 12 | | "date_started" 13 | | "date_read" 14 | | "date_updated" 15 | | "date_added" 16 | | "recommender" 17 | | "avg_rating" 18 | | "num_ratings" 19 | | "review" 20 | | "read_count" 21 | | "votes" 22 | | "random" 23 | | "comments" 24 | | "notes" 25 | | "isbn" 26 | | "isbn13" 27 | | "asin" 28 | | "num_pages" 29 | | "format" 30 | | "position" 31 | | "shelves" 32 | | "owned" 33 | | "date_purchased" 34 | | "purchase_location" 35 | | "condition"; 36 | 37 | type GroupBy = "year"; 38 | 39 | type HideDetails = { [Property in keyof Book]?: boolean }; 40 | 41 | type DisplayOptions = { 42 | hideDetails?: boolean | HideDetails; 43 | hideBackgroundImages?: boolean; 44 | gridStyle?: Pick; 45 | }; 46 | 47 | export type Props = { 48 | /** The user ID for whom to fetch books */ 49 | userId: string; 50 | /** The shelf from which to fetch books */ 51 | shelf?: "read" | "currently-reading" | "to-read" | string; 52 | /** Minimum width allowed for each book */ 53 | width?: string | number; 54 | /** The field by which to sort the results returned */ 55 | sort?: SortKey; 56 | /** The direction in which to sort the results */ 57 | order?: "a" | "d"; 58 | /** The maximum number of books to be returned */ 59 | limit?: number; 60 | /** Optional search text */ 61 | search?: string; 62 | /** Shelf display options */ 63 | displayOptions?: DisplayOptions; 64 | /** Filter book option */ 65 | filter?: (book: Book) => boolean; 66 | /** Group by option */ 67 | groupBy?: GroupBy; 68 | }; 69 | -------------------------------------------------------------------------------- /src/util/api.test.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | import { ALL_GROUP_TITLE, fetchAllBooks } from "./api"; 3 | import fs from "fs"; 4 | import path from "path"; 5 | 6 | const filePath = path.join(__dirname, "../../__test__/data/jsonp.test.html"); 7 | const sampleResponse = fs.readFileSync(filePath).toString(); 8 | 9 | describe("api", () => { 10 | beforeAll(() => { 11 | const fetch = vi.fn(() => 12 | Promise.resolve({ 13 | text: () => Promise.resolve(sampleResponse) 14 | }) 15 | ); 16 | 17 | vi.stubGlobal("fetch", fetch); 18 | }); 19 | 20 | it("fetches books", async () => { 21 | const [bookGroup] = await fetchAllBooks({ userId: "123" }); 22 | expect(bookGroup.books.length).toBe(30); 23 | expect(bookGroup.title).toBe(ALL_GROUP_TITLE); 24 | }); 25 | 26 | it("fetches grouped books", async () => { 27 | const groups = await fetchAllBooks({ userId: "123", groupBy: "year" }); 28 | expect(groups.length).toBe(2); 29 | expect(groups[0].title).toBe("2022"); 30 | expect(groups[1].title).toBe("2021"); 31 | }); 32 | 33 | it("fetches with limit", async () => { 34 | const [bookGroup] = await fetchAllBooks({ userId: "123", limit: 5 }); 35 | expect(bookGroup.books.length).toBe(5); 36 | }); 37 | 38 | it("fetches with filter", async () => { 39 | const [bookGroup] = await fetchAllBooks({ 40 | userId: "123", 41 | filter: (book) => book.title?.includes("The Club") ?? false 42 | }); 43 | 44 | expect(bookGroup.books.length).toBe(1); 45 | }); 46 | 47 | it("stops when getting a 204", async () => { 48 | const fetch = vi.fn(() => 49 | Promise.resolve({ 50 | status: 204 51 | }) 52 | ); 53 | 54 | vi.stubGlobal("fetch", fetch); 55 | const [bookGroup] = await fetchAllBooks({ userId: "123" }); 56 | expect(bookGroup.books.length).toBe(0); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/util/api.ts: -------------------------------------------------------------------------------- 1 | import { Book, BookGroup, FetchResults, Props, Status } from "../types"; 2 | import { getUrl } from "./get-url"; 3 | import { getBooksFromHtml } from "./html-utils"; 4 | 5 | const GOODREADS_PAGE_SIZE = 30; 6 | 7 | export const ALL_GROUP_TITLE = "All"; 8 | 9 | export const fetchAllBooks = async (props: Props): Promise => { 10 | // Get first page 11 | const firstPage = await fetchPage(1, props); 12 | const maxBooks = Math.min(props.limit ?? 10, firstPage.status.total); 13 | const maxPages = Math.ceil(maxBooks / GOODREADS_PAGE_SIZE); 14 | const promises: Promise[] = []; 15 | 16 | for (let i = 2; i <= maxPages; i++) { 17 | promises.push(fetchPage(i, props)); 18 | } 19 | 20 | const data = await Promise.all(promises); 21 | 22 | data.sort((a, b) => { 23 | return a.status.end - b.status.end; 24 | }); 25 | 26 | let books = data.reduce((prev, cur) => { 27 | return prev.concat(cur.books); 28 | }, firstPage.books); 29 | 30 | // Optionally filter the books 31 | if (props.filter) { 32 | books = books.filter(props.filter); 33 | } 34 | 35 | if (props.limit) { 36 | books = books.slice(0, props.limit); 37 | } 38 | 39 | if (props.groupBy) { 40 | const grouped = books.reduce((prev: { [key: string]: Book[] }, cur: Book) => { 41 | const key = String((cur.dateRead || cur.dateAdded)?.getFullYear()); 42 | prev[key] = prev[key] || []; 43 | prev[key].push(cur); 44 | return prev; 45 | }, {}); 46 | 47 | const groups: BookGroup[] = []; 48 | for (const key in grouped) { 49 | groups.push({ 50 | title: key, 51 | books: grouped[key] 52 | }); 53 | } 54 | groups.sort((a, b) => { 55 | return Number(b.title) - Number(a.title); 56 | }); 57 | return groups; 58 | } else { 59 | return [ 60 | { 61 | title: ALL_GROUP_TITLE, 62 | books 63 | } 64 | ]; 65 | } 66 | }; 67 | 68 | const fetchPage = async (page: number, props: Props): Promise => { 69 | // Get the books from Goodreads 70 | const url = getUrl(props, page); 71 | url.searchParams.append("page", String(page)); 72 | const response = await window.fetch(url.toString(), { headers: { accept: "text/javascript" } }); 73 | 74 | // Simulate success if we get a 204 No Content response 75 | if (response.status === 204) { 76 | return { 77 | books: [], 78 | status: { 79 | end: page * 30, 80 | total: 0 81 | } 82 | }; 83 | } 84 | 85 | const responseBody = await response.text(); 86 | const { html, status } = parseJsonP(responseBody); 87 | const table = `${html}
`; 88 | const books = getBooksFromHtml(table, props.width); 89 | return { 90 | books, 91 | status 92 | }; 93 | }; 94 | 95 | const parseJsonP = (jsonp: string): { html: string; status: Status } => { 96 | const [html, status] = jsonp.split("\n"); 97 | 98 | // eslint-disable-next-line quotes 99 | const json = html.replace('Element.insert("booksBody", ', "").replace(" });", "}").replace("bottom", '"bottom"'); 100 | const output: string = JSON.parse(json).bottom; 101 | 102 | const matches = status.match(/(?\d*) of (?\d*) loaded/); 103 | return { 104 | html: output, 105 | status: { 106 | end: parseInt(matches?.groups?.end ?? "0"), 107 | total: parseInt(matches?.groups?.total ?? "0") 108 | } 109 | }; 110 | }; 111 | -------------------------------------------------------------------------------- /src/util/get-url.ts: -------------------------------------------------------------------------------- 1 | import { Props } from "../types"; 2 | 3 | export const getUrl = (props: Props, page: number): URL => { 4 | // Build a request to the Goodreads API 5 | const url = new URL(`https://www.goodreads.com/review/list/${props.userId}`); 6 | 7 | url.searchParams.set("shelf", props.shelf || "read"); 8 | url.searchParams.set("sort", props.sort || "date_read"); 9 | url.searchParams.set("order", props.order || "d"); 10 | url.searchParams.set("page", String(page)); 11 | 12 | // If this is provided as an empty string, you can get wildly different results for currently-reading 13 | if (props.search) { 14 | url.searchParams.set("search[query]", props.search); 15 | } 16 | 17 | return new URL(`https://cors.kylekarpack.workers.dev/corsproxy/?apiurl=${encodeURIComponent(url.toString())}`); 18 | }; 19 | -------------------------------------------------------------------------------- /src/util/html-utils.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { getBooksFromHtml } from "./html-utils"; 4 | 5 | const filePath = path.join(__dirname, "../../__test__/data/response.test.html"); 6 | const sampleHtml = fs.readFileSync(filePath).toString(); 7 | 8 | describe("html utilities", () => { 9 | it("returns books from empty html", () => { 10 | const books = getBooksFromHtml(""); 11 | expect(books.length).toBe(0); 12 | }); 13 | 14 | it("returns books from sample html", () => { 15 | const books = getBooksFromHtml(sampleHtml); 16 | expect(books.length).toBe(5); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/util/html-utils.ts: -------------------------------------------------------------------------------- 1 | import { Book } from "../types"; 2 | 3 | export const getBooksFromHtml = (html: string, width: string | number | undefined = 150): Book[] => { 4 | const parser = new DOMParser(); 5 | const goodreadsDocument = parser.parseFromString(html, "text/html"); 6 | const bookElements = goodreadsDocument.querySelectorAll("tr"); 7 | const bookArray = Array.from(bookElements); 8 | // Get width if not a number 9 | let newWidth: number; 10 | if (typeof width !== "number") { 11 | newWidth = parseInt(width) || 100; 12 | } else { 13 | newWidth = width; 14 | } 15 | return bookArray.map((el) => bookMapper(el, newWidth)); 16 | }; 17 | 18 | const bookMapper = (row: Element, thumbnailWidth: number): Book => { 19 | const isbn = row?.querySelector("td.field.isbn .value")?.textContent?.trim(); 20 | const asin = row?.querySelector("td.field.asin .value")?.textContent?.trim(); 21 | let title = row?.querySelector("td.field.title a")?.getAttribute("title") ?? ""; 22 | const author = row 23 | ?.querySelector("td.field.author .value") 24 | ?.textContent?.trim() 25 | .replace(" *", "") 26 | .split(", ") 27 | .reverse() 28 | .join(" "); 29 | const imageUrl = row 30 | ?.querySelector("td.field.cover img") 31 | ?.getAttribute("src") 32 | // Get a thumbnail of the requested width 33 | // Add some padding factor for higher-quality rendering 34 | ?.replace(/\._(S[Y|X]\d+_?){1,2}_/i, `._SX${thumbnailWidth * 2}_`); 35 | const href = row?.querySelector("td.field.cover a")?.getAttribute("href"); 36 | const rating = row?.querySelectorAll("td.field.rating .staticStars .p10")?.length; 37 | 38 | const dateReadString = row?.querySelector("td.field.date_read .date_read_value")?.textContent; 39 | const dateAddedString = row?.querySelector("td.field.date_added span")?.textContent; 40 | const dateRead = dateReadString ? new Date(dateReadString) : undefined; 41 | const dateAdded = dateAddedString ? new Date(dateAddedString) : undefined; 42 | 43 | let subtitle = ""; 44 | const splitTitle = title.split(":"); 45 | if (splitTitle.length > 1) { 46 | title = splitTitle[0]; 47 | subtitle = splitTitle[1]; 48 | } 49 | 50 | const parens = title.match(/\(.*\)/); 51 | if (parens) { 52 | const [match] = parens; 53 | subtitle = match.replace("(", "").replace(")", ""); 54 | title = title.replace(match, ""); 55 | } 56 | 57 | return { 58 | id: `${isbn || asin || window.crypto.randomUUID()}`, 59 | isbn, 60 | asin, 61 | title, 62 | subtitle, 63 | author, 64 | imageUrl, 65 | rating, 66 | dateRead, 67 | dateAdded, 68 | link: `https://www.goodreads.com/${href}` 69 | }; 70 | }; 71 | -------------------------------------------------------------------------------- /src/util/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./api"; 2 | export * from "./get-url"; 3 | export * from "./html-utils"; 4 | -------------------------------------------------------------------------------- /stories/0-GoodreadsBookshelf.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryFn } from "@storybook/react"; 2 | import React from "react"; 3 | import GoodreadsBookshelf from "../src"; 4 | import { Props } from "../src/types"; 5 | 6 | const sorts = [ 7 | "title", 8 | "author", 9 | "cover", 10 | "rating", 11 | "year_pub", 12 | "date_pub", 13 | "date_pub_edition", 14 | "date_started", 15 | "date_read", 16 | "date_updated", 17 | "date_added", 18 | "recommender", 19 | "avg_rating", 20 | "num_ratings", 21 | "review", 22 | "read_count", 23 | "votes", 24 | "random", 25 | "comments", 26 | "notes", 27 | "isbn", 28 | "isbn13", 29 | "asin", 30 | "num_pages", 31 | "format", 32 | "position", 33 | "shelves", 34 | "owned", 35 | "date_purchased", 36 | "purchase_location", 37 | "condition" 38 | ]; 39 | 40 | const shelves = ["read", "currently-reading", "to-read"]; 41 | 42 | export default { 43 | title: "React Goodreads Shelf", 44 | component: GoodreadsBookshelf, 45 | argTypes: { 46 | userId: { 47 | name: "User ID", 48 | control: { 49 | type: "text" 50 | } 51 | }, 52 | width: { 53 | name: "Book Width", 54 | control: { 55 | type: "number", 56 | min: 25, 57 | max: 250 58 | } 59 | }, 60 | hideDetails: { 61 | name: "Hide Details", 62 | control: "check", 63 | options: ["title", "subtitle", "author", "rating"] 64 | }, 65 | hideBackgroundImages: { 66 | name: "Hide background images", 67 | control: "boolean" 68 | }, 69 | limit: { 70 | name: "Number of Books", 71 | control: { 72 | type: "number", 73 | min: 1, 74 | max: 250 75 | } 76 | }, 77 | shelf: { 78 | name: "Shelf Name", 79 | control: "select", 80 | options: shelves 81 | }, 82 | sort: { 83 | name: "Sort Field", 84 | control: "select", 85 | options: sorts 86 | }, 87 | order: { 88 | name: "Order", 89 | control: "inline-radio", 90 | options: ["a", "d"] 91 | }, 92 | search: { 93 | name: "Search Text", 94 | control: "text" 95 | }, 96 | displayOptions: { 97 | table: { 98 | disable: true 99 | } 100 | }, 101 | filter: { 102 | table: { 103 | disable: true 104 | } 105 | }, 106 | groupBy: { 107 | table: { 108 | disable: true 109 | } 110 | } 111 | } 112 | } as Meta; 113 | 114 | /** 115 | * Storybook component types 116 | */ 117 | type StorybookProps = Props & { 118 | hideDetails: string[]; 119 | hideBackgroundImages: boolean; 120 | }; 121 | 122 | // Map from a Storybook control value to component props 123 | const mapHide = (toHide: string[]): Record => { 124 | const output = {}; 125 | for (let key of toHide || []) { 126 | output[key] = true; 127 | } 128 | 129 | return output; 130 | }; 131 | 132 | // Get the display options from Storybook control values 133 | const getDisplayOptions = (args: StorybookProps) => { 134 | return { 135 | hideDetails: mapHide(args.hideDetails), 136 | hideBackgroundImages: args.hideBackgroundImages 137 | }; 138 | }; 139 | 140 | const Template: StoryFn = (args) => { 141 | const displayOptions = getDisplayOptions(args); 142 | return ; 143 | }; 144 | 145 | const Primary: StoryFn = Template.bind({}); 146 | Primary.args = { 147 | userId: "63515611", 148 | width: 100, 149 | limit: 12, 150 | shelf: "read", 151 | sort: "date_read", 152 | order: "d", 153 | search: "" 154 | }; 155 | 156 | export const MinimalShelf: StoryFn = Template.bind({}); 157 | MinimalShelf.args = { 158 | ...Primary.args, 159 | hideDetails: ["title", "subtitle", "author", "rating"], 160 | limit: 20 161 | }; 162 | 163 | export const StandardShelf: StoryFn = Template.bind({}); 164 | StandardShelf.args = { 165 | ...Primary.args, 166 | hideDetails: ["rating"], 167 | width: 130 168 | }; 169 | 170 | export const GroupedShelf: StoryFn = Template.bind({}); 171 | GroupedShelf.args = { 172 | ...Primary.args, 173 | width: 130, 174 | groupBy: "year", 175 | limit: 100 176 | }; 177 | -------------------------------------------------------------------------------- /stories/GoodreadBookshelf.mdx: -------------------------------------------------------------------------------- 1 | import { Meta, Description, Story, Canvas } from "@storybook/blocks"; 2 | 3 | import * as GoodreadsBookshelfStories from "./0-GoodreadsBookshelf.stories"; 4 | 5 | 6 | 7 | # react-goodreads-shelf 8 | 9 | This React component allows you to display a public Goodreads shelf in a React application. It's a lot like the Goodreads JavaScript widget, but allows for more customization, better async loading, and React-like usage. 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/index.ts", "src/globals.d.ts"], 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "target": "es5", 6 | "module": "commonjs", 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "skipLibCheck": true, 11 | "noImplicitAny": true, 12 | "noEmit": true, 13 | "allowJs": true, 14 | "resolveJsonModule": true, 15 | "types": ["vitest/globals", "vite/client"], 16 | "plugins": [{ "name": "typescript-plugin-css-modules" }] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vitest/config"; 3 | import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js"; 4 | import dts from "vite-plugin-dts"; 5 | 6 | export default defineConfig({ 7 | test: { 8 | globals: true, 9 | environment: "jsdom", 10 | setupFiles: "./vitest.setup.ts", 11 | coverage: { 12 | reporter: ["lcov", "text", "html"], 13 | exclude: ["vitest.setup.ts", "**/*.test.{ts,tsx}"], 14 | provider: "c8" 15 | } 16 | }, 17 | plugins: [react(), dts({ entryRoot: "src", include: ["src"] }), cssInjectedByJsPlugin()], 18 | build: { 19 | outDir: "./dist", 20 | lib: { 21 | entry: "./src/index.ts", 22 | formats: ["cjs", "es", "umd"], 23 | fileName: (format) => `index.${format}.js`, 24 | name: "index" 25 | }, 26 | minify: "esbuild", 27 | rollupOptions: { 28 | // make sure to externalize deps that shouldn't be bundled 29 | // into your library 30 | external: ["react", "react-dom"], 31 | output: { 32 | // Provide global variables to use in the UMD build 33 | // for externalized deps 34 | globals: { 35 | react: "React", 36 | "react-dom": "ReactDOM" 37 | }, 38 | exports: "named" 39 | } 40 | } 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; // needed for testing-library assertions 2 | import { vi } from "vitest"; 3 | 4 | // Mock fetch 5 | const fetch = vi.fn(() => 6 | Promise.resolve({ 7 | text: () => Promise.resolve([]) 8 | }) 9 | ); 10 | 11 | vi.stubGlobal("fetch", fetch); 12 | 13 | // Mock IntersectionObserver 14 | const IntersectionObserverMock = vi.fn(() => ({ 15 | disconnect: vi.fn(), 16 | observe: vi.fn(), 17 | takeRecords: vi.fn(), 18 | unobserve: vi.fn() 19 | })); 20 | 21 | vi.stubGlobal("IntersectionObserver", IntersectionObserverMock); 22 | 23 | // Mock crypto 24 | const crypto = { 25 | randomUUID: vi.fn() 26 | }; 27 | 28 | vi.stubGlobal("crypto", crypto); 29 | --------------------------------------------------------------------------------