diff --git a/.circleci/config.yml b/.circleci/config.yml index 274ec25ac..2025e8fe7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,7 +10,7 @@ orbs: jobs: build: docker: - - image: cimg/node:20.8.0 + - image: cimg/node:20.17.0 - image: mongo:4.4 working_directory: ~/homebrewery @@ -27,7 +27,7 @@ jobs: # fallback to using the latest cache if no exact match is found - v1-dependencies- - - run: sudo npm install -g npm@10.2.0 + - run: sudo npm install -g npm@10.8.2 - node/install-packages: app-dir: ~/homebrewery cache-path: node_modules @@ -45,7 +45,7 @@ jobs: test: docker: - - image: cimg/node:20.8.0 + - image: cimg/node:20.17.0 working_directory: ~/homebrewery parallelism: 1 @@ -70,15 +70,27 @@ jobs: - run: name: Test - Hard Breaks command: npm run test:hard-breaks + - run: + name: Test - Non-Breaking Spaces + command: npm run test:non-breaking-spaces - run: name: Test - Variables command: npm run test:variables + - run: + name: Test - Emojis + command: npm run test:emojis - run: name: Test - Routes command: npm run test:route + - run: + name: Test - HTML sanitization + command: npm run test:safehtml - run: name: Test - Coverage command: npm run test:coverage + - run: + name: Test - Content Negotiation + command: npm run test:content-negotiation workflows: build_and_test: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b02835726..020653272 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,26 +1,29 @@ - +> [!TIP] +> Before submitting a Pull Request, please consider the following to speed up reviews: +> - 👷‍♀️ Create small PRs. Large PRs can usually be broken down into incremental PRs. +> - 🚩 Do you already have several open PRs? Consider finishing or asking for help with existing PRs first. +> - 🔧 Does your PR reference a discussed and approved issue, especially for personal or edge-case requests? +> - 💡 Is the solution agreed upon? Save rework time by discussing strategy before coding. ## Description +_Describe what your PR accomplishes. Consider walking through the main changes to aid reviewers in following your code, especially if it covers multiple files._ ## Related Issues or Discussions +> [!CAUTION] +> If no issue exists yet, create it, and get agreement on the approach (or paste in a previous agreement from chat, etc.) before moving forward. (Experimental PRs are OK without prior discussion, but do not expect to get merged.) + - Closes # ## QA Instructions, Screenshots, Recordings -_Please replace this line with instructions on how to test or view your changes, as well as any before/after -images for UI changes._ +_Replace this line with instructions on how to test or view your changes, as well as any before/after +screenshots or recordings for UI changes._ ### Reviewer Checklist -_Please replace the list below with specific features you want reviewers to look at._ +_Replace the list below with specific features you want reviewers to look at._ *Reviewers, refer to this list when testing features, or suggest new items * - [ ] Verify new features are functional @@ -32,5 +35,3 @@ _Please replace the list below with specific features you want reviewers to look - [ ] Feature A handles negative numbers - [ ] Identify opportunities for simplification and refactoring - [ ] Check for code legibility and appropriate comments - -
Copy this list diff --git a/.stylelintrc.json b/.stylelintrc.json index 2c7a9afdf..b5f2e7712 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,48 +1,48 @@ { - "extends": [ - "stylelint-config-recess-order", - "stylelint-config-recommended"], - "plugins": [ - "@stylistic/stylelint-plugin", - "./stylelint_plugins/declaration-colon-align.js", - "./stylelint_plugins/declaration-colon-min-space-before", - "./stylelint_plugins/declaration-block-multi-line-min-declarations" - ], - "customSyntax": "postcss-less", - "rules": { - "no-descending-specificity" : null, - "at-rule-no-unknown" : null, - "function-no-unknown" : null, - "font-family-no-missing-generic-family-keyword" : null, - "font-weight-notation" : "named-where-possible", - "font-family-name-quotes" : "always-unless-keyword", - "@stylistic/indentation" : "tab", - "no-duplicate-selectors" : true, - "@stylistic/color-hex-case" : "upper", - "color-hex-length" : "long", - "@stylistic/selector-combinator-space-after" : "always", - "@stylistic/selector-combinator-space-before" : "always", - "@stylistic/selector-attribute-operator-space-before" : "never", - "@stylistic/selector-attribute-operator-space-after" : "never", - "@stylistic/selector-attribute-brackets-space-inside" : "never", - "selector-attribute-quotes" : "always", - "selector-pseudo-element-colon-notation" : "double", - "@stylistic/selector-pseudo-class-parentheses-space-inside" : "never", - "@stylistic/block-opening-brace-space-before" : "always", - "naturalcrit/declaration-colon-min-space-before" : 1, - "@stylistic/declaration-block-trailing-semicolon" : "always", - "@stylistic/declaration-colon-space-after" : "always", - "@stylistic/number-leading-zero" : "always", - "function-url-quotes" : ["always", { "except": ["empty"] }], - "function-url-scheme-disallowed-list" : ["data","http"], - "comment-whitespace-inside" : "always", - "@stylistic/string-quotes" : "single", - "@stylistic/media-feature-range-operator-space-before" : "always", - "@stylistic/media-feature-range-operator-space-after" : "always", - "@stylistic/media-feature-parentheses-space-inside" : "never", - "@stylistic/media-feature-colon-space-before" : "always", - "@stylistic/media-feature-colon-space-after" : "always", - "naturalcrit/declaration-colon-align" : true, - "naturalcrit/declaration-block-multi-line-min-declarations": 1 - } + "extends": [ + "stylelint-config-recess-order", + "stylelint-config-recommended"], + "plugins": [ + "@stylistic/stylelint-plugin", + "./stylelint_plugins/declaration-colon-align.js", + "./stylelint_plugins/declaration-colon-min-space-before", + "./stylelint_plugins/declaration-block-multi-line-min-declarations" + ], + "customSyntax": "postcss-less", + "rules": { + "no-descending-specificity" : null, + "at-rule-no-unknown" : null, + "function-no-unknown" : null, + "font-family-no-missing-generic-family-keyword" : null, + "font-weight-notation" : "named-where-possible", + "font-family-name-quotes" : "always-unless-keyword", + "@stylistic/indentation" : "tab", + "no-duplicate-selectors" : true, + "@stylistic/color-hex-case" : "upper", + "color-hex-length" : "long", + "@stylistic/selector-combinator-space-after" : "always", + "@stylistic/selector-combinator-space-before" : "always", + "@stylistic/selector-attribute-operator-space-before" : "never", + "@stylistic/selector-attribute-operator-space-after" : "never", + "@stylistic/selector-attribute-brackets-space-inside" : "never", + "selector-attribute-quotes" : "always", + "selector-pseudo-element-colon-notation" : "double", + "@stylistic/selector-pseudo-class-parentheses-space-inside" : "never", + "@stylistic/block-opening-brace-space-before" : "always", + "naturalcrit/declaration-colon-min-space-before" : 1, + "@stylistic/declaration-block-trailing-semicolon" : "always", + "@stylistic/declaration-colon-space-after" : "always", + "@stylistic/number-leading-zero" : "always", + "function-url-quotes" : ["always", { "except": ["empty"] }], + "function-url-scheme-disallowed-list" : ["data","http"], + "comment-whitespace-inside" : "always", + "@stylistic/string-quotes" : "single", + "@stylistic/media-feature-range-operator-space-before" : "always", + "@stylistic/media-feature-range-operator-space-after" : "always", + "@stylistic/media-feature-parentheses-space-inside" : "never", + "@stylistic/media-feature-colon-space-before" : "always", + "@stylistic/media-feature-colon-space-after" : "always", + "naturalcrit/declaration-colon-align" : true, + "naturalcrit/declaration-block-multi-line-min-declarations" : 1 + } } diff --git a/Dockerfile b/Dockerfile index 84652fbf9..17d02b01f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine +FROM node:22-alpine RUN apk --no-cache add git ENV NODE_ENV=docker @@ -9,7 +9,10 @@ WORKDIR /usr/src/app # Copy package.json into the image, then run yarn install # This improves caching so we don't have to download the dependencies every time the code changes COPY package.json ./ +COPY config/docker.json usr/src/app/config # --ignore-scripts tells yarn not to run postbuild. We run it explicitly later +RUN node --version +RUN npm --version RUN npm install --ignore-scripts # Bundle app source and build application diff --git a/README.DOCKER.md b/README.DOCKER.md index 356ac398a..4dfbef045 100644 --- a/README.DOCKER.md +++ b/README.DOCKER.md @@ -1,12 +1,119 @@ -# Running Homebrewery via Docker +# Offline Install Instructions: Docker -The repo includes a Dockerfile and a docker-compose.yml file. +These instructions are for setting up a persistent instance of the Homebrewery application locally using Docker. -To run the application via docker-compose.yml: -`docker-compose up -d` +If you intend to develop with Homebrewery, following the Homebrewery application section of this guide is not recommended. Using docker to deploy MongoDB locally for development is not a bad idea at all, however. -To stop the application: -`docker-compose down` +# Install Docker + +## Docker Desktop (MacOS/Windows) + +Windows and Mac installs use Docker Desktop. Current install instructions are below. + +* [Mac](https://docs.docker.com/desktop/mac/install/) +* [Windows](https://docs.docker.com/desktop/windows/install/) + +You can set up the docker engine to start on boot via the Docker desktop UI. + +## Docker Engine + +Linux installs use Docker Engine. Docker provides installers and instructions for several of the most common distrubutions. If you do not see yours listed, it is very likely supported indirectly by your distribution. + +* [Arch](https://docs.docker.com/desktop/setup/install/linux/archlinux/) +* [CentOS](https://docs.docker.com/engine/install/centos/) +* [Debian](https://docs.docker.com/engine/install/debian/) +* [Fedora](https://docs.docker.com/engine/install/fedora/) +* [RHEL](https://docs.docker.com/engine/install/rhel/) +* [Ubuntu](https://docs.docker.com/engine/install/ubuntu/) + +### Post installation steps +[Manage Docker as a non-root user (highly recommended)](https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user) +[Enable Docker to start on boot (highly recommended)](https://docs.docker.com/engine/install/linux-postinstall/#configure-docker-to-start-on-boot) + +# Build Homebrewery Image + +Next we build the homebrewery docker image. Start by cloning the repository. + +```shell +git clone https://github.com/naturalcrit/homebrewery.git +cd homebrewery +``` + +Make an changes you need to `config/docker.json` then build the image. If it does not exist,the below as a template. + +``` +{ +"host" : "localhost:8000", +"naturalcrit_url" : "local.naturalcrit.com:8010", +"secret" : "secret", +"web_port" : 8000, +"enable_v3" : true, +"mongodb_uri": "mongodb://172.17.0.2/homebrewery", +"enable_themes" : true, +} +``` + +```shell +docker-compose build homebrewery +``` + +# Add Mongo container + +Once docker is installed and running, it is time to set up the containers. First up, Mongo. + +```shell +docker run --name homebrewery-mongodb -d --restart unless-stopped -v mongodata:/data/db -p 27017:27017 mongo:latest +``` + +Older CPUs may run into an issue with AVX support. +``` +WARNING: MongoDB 5.0+ requires a CPU with AVX support, and your current system does not appear to have that! + see https://jira.mongodb.org/browse/SERVER-54407 + see also https://www.mongodb.com/community/forums/t/mongodb-5-0-cpu-intel-g4650-compatibility/116610/2 + see also https://github.com/docker-library/mongo/issues/485#issuecomment-891991814 +``` +If you see a message similar to this, try using the bitnami mongo instead. + +```shell +docker run --name homebrewery-mongodb -d --restart unless-stopped -v mongodata:/data/db -p 27017:27017 bitnami/mongo:latest +``` + +If your distribution is running on an arm device such as a Raspberry Pi, you will need to run the arm-built MongoDB v4.4. + +```shell +docker run --name homebrewery-mongodb -d --restart unless-stopped -v mongodata:/data/db -p 27017:27017 arm64v8/mongo:4.4 +``` + +## Run the Homebrewery Image +```shell +# Make sure you run this in the homebrewery directory +docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v $(pwd)/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest +``` + +## Updating the Image + +When Homebrewery code updates, your docker container will not automatically follow the changes. To do so you will need to rebuild your homebrewery image. + +First, return to your homebrewery clone (from Build Homebrewery Image above) or recreate the clone if you deleted your copy of the code. + +First, delete the existing image. + +```shell +docker rm -f homebrewery-app +``` + +Next, update the clone's code to the latest version. + +```shell +cd homebrewery +git checkout master +git pull upstream master +``` + +Finally, rebuild and restart the homebrewery image. + +```shell +docker-compose build homebrewery +docker run --name homebrewery-app -d --restart unless-stopped -e NODE_ENV=docker -v $(pwd)/config/docker.json:/usr/src/app/config/docker.json -p 8000:8000 docker.io/library/homebrewery:latest +``` -To stop the application and remove all data: -`docker-compose down -v` diff --git a/babel.config.json b/babel.config.json new file mode 100644 index 000000000..5e768ec31 --- /dev/null +++ b/babel.config.json @@ -0,0 +1,10 @@ +{ + "presets": [ + "@babel/preset-env", + "@babel/preset-react" + ], + "plugins": [ + "@babel/plugin-transform-runtime", + "babel-plugin-transform-import-meta" + ] +} diff --git a/changelog.md b/changelog.md index 9d1ddf32d..f560ce1e5 100644 --- a/changelog.md +++ b/changelog.md @@ -77,13 +77,285 @@ pre { } .varSyntaxTable th:first-of-type { - width:6cm; + width:6cm; +} + +.page .exampleTable td,th { + border:1px dashed #00000030; } ``` ## changelog For a full record of development, visit our [Github Page](https://github.com/naturalcrit/homebrewery). +### Tuesday 03/18/2025 - v3.18.1 + +{{taskList +##### G-Ambatte +* [x] Revert colon rendering from br elements to blank divs + +##### 5e-Cleric +* [x] Allow for local connections within a same network when running a local version +Fixes issue [#4094](https://github.com/naturalcrit/homebrewery/issues/4094) + +* [x] Add US Letter size page snippet +Fixes issue [#3893](https://github.com/naturalcrit/homebrewery/issues/3893) +}} + +### Monday 03/10/2025 - v3.18.0 + +{{taskList +##### dbolack +* [x] Add ability to paste in any Share ID/URL into a brew's {{openSans :fas_circle_info: **Properties** :fas_arrow_right: **THEMES**}} selection, as long as that brew has been tagged as `meta:theme`. You can now share your custom brew themes without needing to make a personal copy. +* [x] Begin migration of custom Markdown extensions into their own NPM packages, for easier adoption by other users or projects +* [x] Fix external HTML appearing in open codeblocks + +Fixes issue [#3206](https://github.com/naturalcrit/homebrewery/issues/3206) + +* [x] Fix tables not rendering when directly after text + + +##### G-Ambatte +* [x] Cleanup of "cover pages" in the {{openSans :fas_rectangle_list: **NAVIGATION**}} list +* [x] Fix autosave triggering when no changes are present + +Fixes issue [#4051](https://github.com/naturalcrit/homebrewery/issues/4051) + +* [x] Remove empty table rows resulting from rowspan + +Fixes issue [#1729](https://github.com/naturalcrit/homebrewery/issues/1729) + +##### 5e-Cleric +* [x] Style fixes for covers art and logos on A4 size pages +* [x] Fix crash when trying to open brews that don't exist +* [x] Tweaks and style update styling on {{openSans **VAULT** :fas_dungeon:}} page. + +Fixes issue [#4079](https://github.com/naturalcrit/homebrewery/issues/4079) + +##### Calculuschild +* [x] `꞉꞉꞉꞉` now produces `
` instead of a `
` +* [x] Fix typos in tables freezing the editor + +Fixes issue [#4059](https://github.com/naturalcrit/homebrewery/issues/4059) + + +##### MollyMaclachlan (New Contributor!) +* [x] Fixed typos in the Monster Stat Block snippet + +Fixes issue [#4073](https://github.com/naturalcrit/homebrewery/issues/4073) + + +##### All +* [x] Update dependencies and scripts +* [x] Refactor components and backend tools +}} + +\column + +### Thursday 01/30/2025 - v3.17.0 + +{{taskList +##### 5e-Cleric + +* [x] Update FAQ + +* [x] Fix styling for Vault buttons and checkboxes + +* [x] Improve navigation bar styling + +* [x] Add feature to change username at https://www.naturalcrit.com/account + +* [x] Fix Reddit link crash when title has non-latin chars + +##### dbolack + +* [x] Fix page shadows toolbar option + +Fixes issue [#3919](https://github.com/naturalcrit/homebrewery/issues/3919) + +* [x] Add `:>>>` syntax for horizontal :>>>>> spaces + +* [x] Update Docker install instructions + +Fixes issue [#1930](https://github.com/naturalcrit/homebrewery/issues/1930) + +* [x] Allow styling pages via `\page{myStyles}` (with calculuschild) + +Fixes issue [#3901](https://github.com/naturalcrit/homebrewery/issues/3901) + +* [x] Update Ubuntu install instructions + +Fixes issue [#1952](https://github.com/naturalcrit/homebrewery/issues/1952) + +* [x] Add `:-:` `:-` `-:` syntax for paragraph alignment, similar to table column alignment; for example: + +-: -: Right-aligned + +:-: :-: Centered + +* [x] Add `:-- 50% --:` syntax to allow setting table column widths by percentage; for example: +``` +| Narrow | Wide | +|:- 10% -:|:-90%--:| +| Cell | Cell | +``` + + +| Narrow | Wide | +|:- 10% -:|:-90%--:| +|Cell | Cell | +{exampleTable} + +##### G-Ambatte + +* [x] Fix crash when opening brew Properties tab + +Fixes issue [#3927](https://github.com/naturalcrit/homebrewery/issues/3927) + +* [x] Update error pages with steps to refresh credentials + +Fixes issue [#3955](https://github.com/naturalcrit/homebrewery/issues/3955) + +* [x] Add {{openSans :fas_rectangle_list: **NAVIGATION**}} menu to the viewer toolbar + +##### calculuschild + +* [x] Reduce display lag on large brews + +##### Gazook89 + +* [x] Smarter detection of current page number + +Fixes issue [#3824](https://github.com/naturalcrit/homebrewery/issues/3824) + +##### All +* [x] Update dependencies and scripts +* [x] Refactor components and fix various errors +}} + +\page + +### Wednesday 11/27/2024 - v3.16.1 + +{{taskList +##### 5e-Cleric + +* [x] Allow linking to specific HTML IDs via `#ID` at the end of the URL, e.g.: `homebrewery.naturalcrit.com/share/share/a6RCXwaDS58i#p4` to link to Page 4 directly + +Fixes issues [#2820](https://github.com/naturalcrit/homebrewery/issues/2820), [#3505](https://github.com/naturalcrit/homebrewery/issues/3505) + +* [x] Fix generation of link to certain Google Drive brews + +Fixes issue [#3776](https://github.com/naturalcrit/homebrewery/issues/3776) + +##### abquintic + +* [x] Fix blank pages appearing when pasting text + +Fixes issue [#3718](https://github.com/naturalcrit/homebrewery/issues/3718) + +##### Gazook89 + +* [x] Add new brew viewing options to the view toolbar +- {{fac,single-spread}} {{openSans **SINGLE PAGE**}} +- {{fac,facing-spread}} {{openSans **TWO PAGE**}} +- {{fac,flow-spread}} {{openSans **GRID**}} + +Fixes issue [#1379](https://github.com/naturalcrit/homebrewery/issues/1379) + +* [x] Updates to tag input boxes + +##### G-Ambatte + +* [x] Admin tools to fix certain corrupted documents + +Fixes issue [#3801](https://github.com/naturalcrit/homebrewery/issues/3801) + +* [x] Fix print window being affected by document zoom + +Fixes issue [#3744](https://github.com/naturalcrit/homebrewery/issues/3744) + + +##### calculuschild, 5e-Cleric, G-Ambatte, Gazook89, abquintic + +* [x] Multiple code refactors, cleanups, and security fixes +}} + +### Saturday 10/12/2024 - v3.16.0 + +{{taskList +##### 5e-Cleric + +* [x] Added a new API endpoint `/metadata/:shareId` to fetch metadata about individual brews + +Fixes issue [#2638](https://github.com/naturalcrit/homebrewery/issues/2638) + +* [x] Added A3, A5, and Card page size snippets under {{openSans **:fas_paintbrush: STYLE TAB :fas_arrow_right: :fas_print: PRINT**}} + +* [x] Adjust navbar styling for very long titles + +Fixes issue [#2071](https://github.com/naturalcrit/homebrewery/issues/2071) + +* [x] Added some sorting options to the {{openSans **VAULT** {{fas,fa-dungeon}}}} page + +* [x] Fix `language` property not working in share page + +Fixes issue [#3776](https://github.com/naturalcrit/homebrewery/issues/3776) + +##### abquintic + +* [x] New {{openSans **:fas_pencil: TEXT EDITOR :fas_arrow_right: :fas_bookmark: PAGE NUMBER :fas_arrow_right:**}} +{{openSans **:fas_xmark: SKIP PAGE NUMBER**}} and {{openSans **:fas_arrow_rotate_left: RESTART PAGE NUMBER**}} snippets for more control over automatic page numbering. + +Fixes issue [#513](https://github.com/naturalcrit/homebrewery/issues/513) + +* [x] New Table of Contents control options via {{openSans **:fas_pencil: TEXT EDITOR :fas_arrow_right: :fas_book: TABLE OF CONTENTS**}} submenus. By default, H1-H3 is included in the ToC generation, but the new options allow marking `{{blocks}}` to include or exclude specific or ranges of contained headers. Also, a global option to increase the default range of H1-H3 to H1-H4/5/6. After applying these markers, you must regenerate the Table of Contents to see the changes. + +* [x] Added a ":fas_lock: SYNC VIEWS" button onto the divider bar. When locked, scrolling on either panel will sync the other panel to the same page. + +Fixes issue [#241](https://github.com/naturalcrit/homebrewery/issues/241) + +##### Gazook89 + +* [x] Added a :fas_glasses: HIDE button to the page navigation bar + +##### G-Ambatte + +* [x] Automatic local backups of your files, in case of accidental data loss. Stores up to 5 snapshots of each brew edited in your browser, incrementing from a few minutes old to a maximum of several days. Restore a backup by clicking an entry in the new {{openSans **:fas_clock_rotate_left: HISTORY**}} button in the snippet bar. + +Fixes issue [#3070](https://github.com/naturalcrit/homebrewery/issues/3070) + +* [x] Fix issue with legacy brews breaking on Share page + +Fixes issue [#3764](https://github.com/naturalcrit/homebrewery/issues/3764) + +* [x] Fix print size when printing a zoomed document + +Fixes issue [#3744](https://github.com/naturalcrit/homebrewery/issues/3744) + +##### All + +* [x] Background code cleanup, security fixes, dev tool improvements, dependency updates, prep for upcoming features, etc. +}} + +### Wednesday 9/25/2024 - v3.15.1 + +{{taskList +##### calculuschild + +* [x] Background fixes to handle Google Drive issues + +* [x] Remove duplicate error logging + +##### calculuschild, 5e-Cleric + +* [x] Fix links in {{openSans **RECENT BREWS :fas_clock_rotate_left:**}} and user {{openSans **BREWS :fas_beer_mug_empty:**}} pointing to trashed Google Drive files after transferring from Google to Homebrewery storage + +Fixes issue [#3776](https://github.com/naturalcrit/homebrewery/issues/3776) +}} + +\page + ### Wednesday 9/04/2024 - v3.15.0 {{taskList @@ -1931,4 +2203,4 @@ Massive changelog incoming: * Added `phb.standalone.css` plus a build system for creating it * Added page numbers and footer text -* Page accent now flips each page +* Page accent now flips each page \ No newline at end of file diff --git a/client/admin/admin.jsx b/client/admin/admin.jsx index 088847a66..025d78abf 100644 --- a/client/admin/admin.jsx +++ b/client/admin/admin.jsx @@ -1,67 +1,50 @@ -require('./admin.less'); -const React = require('react'); -const createClass = require('create-react-class'); - - -const BrewCleanup = require('./brewCleanup/brewCleanup.jsx'); -const BrewLookup = require('./brewLookup/brewLookup.jsx'); -const BrewCompress = require ('./brewCompress/brewCompress.jsx'); -const Stats = require('./stats/stats.jsx'); - +import './admin.less'; +import React, { useEffect, useState } from 'react'; +const BrewUtils = require('./brewUtils/brewUtils.jsx'); +const NotificationUtils = require('./notificationUtils/notificationUtils.jsx'); +import AuthorUtils from './authorUtils/authorUtils.jsx'; const LockTools = require('./lockTools/lockTools.jsx'); -const tabGroups = ['brews', 'locks']; +const tabGroups = ['brew', 'notifications', 'authors']; -const Admin = createClass({ - getDefaultProps : function() { - return {}; - }, +const Admin = ()=>{ + const [currentTab, setCurrentTab] = useState('brew'); - getInitialState : function() { - return { - currentTab : 'brews' - }; - }, + useEffect(()=>{ + setCurrentTab(localStorage.getItem('hbAdminTab')); + }, []); - handleClick : function(newTab) { - if(this.state.currentTab === newTab) return; - this.setState({ - currentTab : newTab - }); - }, - - renderBrewTools : function(){ - return <> - -
- -
- -
- - ; - }, - - render : function(){ - return
+ useEffect(()=>{ + localStorage.setItem('hbAdminTab', currentTab); + }, [currentTab]); + return ( +
- homebrewery admin + The Homebrewery Admin Page + back to homepage
-
-
- {tabGroups.map((name, idx)=>{ - return ; - })} -
- {this.state.currentTab == 'brews' && this.renderBrewTools()} - {this.state.currentTab == 'locks' && } -
-
; - } -}); +
+ + {currentTab === 'brew' && } + {currentTab === 'notifications' && } + {currentTab === 'authors' && } + {currentTab === 'locks' && } +
+
+ ); +}; module.exports = Admin; diff --git a/client/admin/admin.less b/client/admin/admin.less index 39a66bd54..1955f8a93 100644 --- a/client/admin/admin.less +++ b/client/admin/admin.less @@ -6,57 +6,132 @@ @import 'font-awesome/css/font-awesome.css'; -html,body, #reactContainer, .naturalCrit{ - min-height : 100%; -} +html,body, #reactContainer, .naturalCrit { min-height : 100%; } @sidebarWidth : 250px; -body{ - background-color : #eee; - font-family : 'Open Sans', sans-serif; - color : #4b5055; - font-weight : 100; - text-rendering : optimizeLegibility; - margin : 0; +body { + height : 100%; padding : 0; - height : 100%; + margin : 0; + font-family : 'Open Sans', sans-serif; + font-weight : 100; + color : #4B5055; + background-color : #EEEEEE; + text-rendering : optimizeLegibility; } -.admin{ - - header{ +:where(.admin) { + padding-bottom : 50px; + header { + padding : 20px 0px; + margin-bottom : 30px; + font-size : 2em; + color : white; background-color : @red; - font-size: 2em; - padding : 20px 0px; - color : white; - margin-bottom: 30px; - i{ - margin-right: 30px; + i { margin-right : 30px; } + a { float : right; } + } + + hr { margin : 30px 0px; } + + :where(.container) { + input { + height : 33px; + padding : 0px 10px; + margin-bottom : 20px; + font-family : monospace; + } + + button { + height : 37px; + vertical-align : middle; + } + + dl { + display : grid; + grid-template-columns : 120px 1fr; + row-gap : 10px; + align-items : center; + justify-items : start; + padding-top : 0.5em; + dt { + float : left; + clear : left; + height : fit-content; + font-weight : 900; + text-align : right; + &::after { content : ' : '; } + } + dd { height : fit-content; } + } + + .tabs button { + margin-right : 3px; + margin-left : 3px; + color : black; + background-color : #EEEEEE; + border : 1px solid #444444; + border-radius : 5px; + &:hover { + color : #EEEEEE; + background-color : #444444; + } + &.active { + margin-right : 2px; + margin-left : 2px; + text-decoration : underline; + background-color : #CCCCCC; + border : 2px solid #444444; + } + } + + .notificationUtils { + display : flex; + gap : 50px; + justify-content : space-between; } } - hr{ - margin : 30px 0px; - } + table { + padding : 10px; - .tabs { - border-bottom: 1px solid black; - } + tr { + border-bottom : 1px solid; + &:last-of-type { border : none; } + &:nth-child(even) { background : #DDDDDD; } + } - button.tab { - margin: 2px 2px 0px; - border-width: 1px 1px 0px; - border-style: solid; - border-color: black; - border-radius: 5px 5px 0px 0px; - color: black; - background-color: transparent; - &.active { - background-color: #000000a0; - color: white; - text-decoration: underline; + thead { + background : rgb(193,236,230); + border-bottom : 2px solid; + } + + th, td { + padding : 5px 10px; + vertical-align : middle; + text-align : center; + border-right : 1px solid; + + &:last-child { border-right : none; } + } + + th { font-weight : 900; } + + td { + &:first-child { + font-weight : 900; + text-align : left; + } } } + .error { + float : right; + padding : 10px; + margin-block : 10px; + font-weight : 900; + color : white; + background : rgb(178, 54, 54); + } } diff --git a/client/admin/authorUtils/authorLookup/authorLookup.jsx b/client/admin/authorUtils/authorLookup/authorLookup.jsx new file mode 100644 index 000000000..abdece6f7 --- /dev/null +++ b/client/admin/authorUtils/authorLookup/authorLookup.jsx @@ -0,0 +1,87 @@ +import './authorLookup.less'; + +import React from 'react'; +import request from 'superagent'; + +const authorLookup = ()=>{ + const [author, setAuthor] = React.useState(''); + const [searching, setSearching] = React.useState(false); + const [results, setResults] = React.useState([]); + + const lookup = async ()=>{ + if(!author) return; + + setSearching(true); + setResults([]); + + const brews = await request.get(`/admin/user/list/${author}`); + setResults(brews.body); + setSearching(false); + }; + + const renderResults = ()=>{ + if(results.length == 0) return <> +

Results

+

None found.

+ ; + + return <> +

{`Results - ${results.length} brews` }

+ + + + + + + + + + + + {results + .sort((a, b)=>{ // Sort brews from most recently updated + if(a.updatedAt > b.updatedAt) return -1; + return 1; + }) + .map((brew, idx)=>{ + return + + + + + + ; + })} + +
TitleShareEditLast UpdateStorage
{brew.title}{brew.shareId}{brew.editId}{brew.updatedAt}{brew.googleId ? 'Google' : 'Homebrewery'}
+ ; + }; + + const handleKeyPress = (evt)=>{ + if(evt.key === 'Enter') return lookup(); + }; + + const handleChange = (evt)=>{ + setAuthor(evt.target.value); + }; + + return ( +
+
+

Author Lookup

+ +
+
+ {renderResults()} +
+
+ ); +}; + +module.exports = authorLookup; diff --git a/client/admin/authorUtils/authorLookup/authorLookup.less b/client/admin/authorUtils/authorLookup/authorLookup.less new file mode 100644 index 000000000..8c37e80d1 --- /dev/null +++ b/client/admin/authorUtils/authorLookup/authorLookup.less @@ -0,0 +1,29 @@ +.authorLookup { + position : relative; + display : flex; + flex-direction : column; + + .field { + display : flex; + gap : 5px; + align-items : center; + justify-items : stretch; + width : 100%; + margin-bottom : 20px; + + + input { + height : 33px; + padding : 0px 10px; + margin-bottom : unset; + font-family : monospace; + } + + button { + width : 50px; + + i { margin-right : 10px; } + } + } + +} \ No newline at end of file diff --git a/client/admin/authorUtils/authorUtils.jsx b/client/admin/authorUtils/authorUtils.jsx new file mode 100644 index 000000000..a96eea528 --- /dev/null +++ b/client/admin/authorUtils/authorUtils.jsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import AuthorLookup from './authorLookup/authorLookup.jsx'; + +const authorUtils = ()=>{ + return ( +
+ +
+ ); +}; + +module.exports = authorUtils; \ No newline at end of file diff --git a/client/admin/brewCleanup/brewCleanup.less b/client/admin/brewCleanup/brewCleanup.less deleted file mode 100644 index ec7582855..000000000 --- a/client/admin/brewCleanup/brewCleanup.less +++ /dev/null @@ -1,10 +0,0 @@ -.BrewCleanup{ - .removeBox{ - margin-top: 20px; - button{ - background-color: @red; - margin-right: 10px; - } - } - -} \ No newline at end of file diff --git a/client/admin/brewCompress/brewCompress.less b/client/admin/brewCompress/brewCompress.less deleted file mode 100644 index 2a2bf42ea..000000000 --- a/client/admin/brewCompress/brewCompress.less +++ /dev/null @@ -1,10 +0,0 @@ -.BrewCompress{ - .removeBox{ - margin-top: 20px; - button{ - background-color: @red; - margin-right: 10px; - } - } - -} \ No newline at end of file diff --git a/client/admin/brewLookup/brewLookup.less b/client/admin/brewLookup/brewLookup.less deleted file mode 100644 index 61eeec424..000000000 --- a/client/admin/brewLookup/brewLookup.less +++ /dev/null @@ -1,30 +0,0 @@ - -.brewLookup{ - input{ - height : 33px; - margin-bottom : 20px; - padding : 0px 10px; - font-family : monospace; - } - button{ - vertical-align : middle; - height : 37px; - } - dl{ - @maxItemWidth : 132px; - dt{ - float : left; - clear : left; - width : @maxItemWidth; - text-align : right; - &::after { - content: " : "; - } - } - dd{ - height : 1em; - margin-left : @maxItemWidth + 6px; - padding : 0 0 0.5em 0; - } - } -} \ No newline at end of file diff --git a/client/admin/brewCleanup/brewCleanup.jsx b/client/admin/brewUtils/brewCleanup/brewCleanup.jsx similarity index 86% rename from client/admin/brewCleanup/brewCleanup.jsx rename to client/admin/brewUtils/brewCleanup/brewCleanup.jsx index a166ae112..d4b17c570 100644 --- a/client/admin/brewCleanup/brewCleanup.jsx +++ b/client/admin/brewUtils/brewCleanup/brewCleanup.jsx @@ -1,10 +1,8 @@ -require('./brewCleanup.less'); const React = require('react'); const createClass = require('create-react-class'); const request = require('superagent'); - const BrewCleanup = createClass({ displayName : 'BrewCleanup', getDefaultProps(){ @@ -39,9 +37,9 @@ const BrewCleanup = createClass({ if(!this.state.primed) return; if(!this.state.count){ - return
No Matching Brews found.
; + return
No Matching Brews found.
; } - return
+ return
; }, render(){ - return
+ return

Brew Cleanup

Removes very short brews to tidy up the database

@@ -65,7 +63,7 @@ const BrewCleanup = createClass({ {this.renderPrimed()} {this.state.error - &&
{this.state.error.toString()}
+ &&
{this.state.error.toString()}
}
; } diff --git a/client/admin/brewCompress/brewCompress.jsx b/client/admin/brewUtils/brewCompress/brewCompress.jsx similarity index 92% rename from client/admin/brewCompress/brewCompress.jsx rename to client/admin/brewUtils/brewCompress/brewCompress.jsx index 2c8e5b023..ccb59e027 100644 --- a/client/admin/brewCompress/brewCompress.jsx +++ b/client/admin/brewUtils/brewCompress/brewCompress.jsx @@ -1,10 +1,7 @@ -require('./brewCompress.less'); const React = require('react'); const createClass = require('create-react-class'); - const request = require('superagent'); - const BrewCompress = createClass({ displayName : 'BrewCompress', getDefaultProps(){ @@ -53,9 +50,9 @@ const BrewCompress = createClass({ if(!this.state.primed) return; if(!this.state.count){ - return
No Matching Brews found.
; + return
No Matching Brews found.
; } - return
+ return
; }, render(){ - return
+ return

Brew Compression

Compresses the text in brews to binary

diff --git a/client/admin/brewLookup/brewLookup.jsx b/client/admin/brewUtils/brewLookup/brewLookup.jsx similarity index 57% rename from client/admin/brewLookup/brewLookup.jsx rename to client/admin/brewUtils/brewLookup/brewLookup.jsx index c9212d990..fb780f29e 100644 --- a/client/admin/brewLookup/brewLookup.jsx +++ b/client/admin/brewUtils/brewLookup/brewLookup.jsx @@ -1,4 +1,3 @@ -require('./brewLookup.less'); const React = require('react'); const createClass = require('create-react-class'); const cx = require('classnames'); @@ -13,27 +12,48 @@ const BrewLookup = createClass({ }, getInitialState() { return { - query : '', - foundBrew : null, - searching : false, - error : null + query : '', + foundBrew : null, + searching : false, + error : null, + scriptCount : 0 }; }, handleChange(e){ this.setState({ query: e.target.value }); }, lookup(){ - this.setState({ searching: true, error: null }); + this.setState({ searching: true, error: null, scriptCount: 0 }); request.get(`/admin/lookup/${this.state.query}`) - .then((res)=>this.setState({ foundBrew: res.body })) + .then((res)=>{ + const foundBrew = res.body; + const scriptCheck = foundBrew?.text.match(/(<\/?s)cript/g); + this.setState({ + foundBrew : foundBrew, + scriptCount : scriptCheck?.length || 0, + }); + }) .catch((err)=>this.setState({ error: err })) - .finally(()=>this.setState({ searching: false })); + .finally(()=>{ + this.setState({ + searching : false + }); + }); + }, + + async cleanScript(){ + if(!this.state.foundBrew?.shareId) return; + + await request.put(`/admin/clean/script/${this.state.foundBrew.shareId}`) + .catch((err)=>{ this.setState({ error: err }); return; }); + + this.lookup(); }, renderFoundBrew(){ const brew = this.state.foundBrew; - return
+ return
Title
{brew.title}
@@ -47,17 +67,28 @@ const BrewLookup = createClass({
Share Link
/share/{brew.shareId}
+
Created Time
+
{brew.createdAt ? Moment(brew.createdAt).toLocaleString() : 'No creation date'}
+
Last Updated
{Moment(brew.updatedAt).fromNow()}
Num of Views
{brew.views}
+ +
SCRIPT tags detected
+
{this.state.scriptCount}
+ {this.state.scriptCount > 0 && +
+ +
+ }
; }, render(){ - return
+ return

Brew Lookup

; } diff --git a/client/admin/brewUtils/brewUtils.jsx b/client/admin/brewUtils/brewUtils.jsx new file mode 100644 index 000000000..bab2cb82f --- /dev/null +++ b/client/admin/brewUtils/brewUtils.jsx @@ -0,0 +1,24 @@ +const React = require('react'); +const createClass = require('create-react-class'); +require('./brewUtils.less'); + +const BrewCleanup = require('./brewCleanup/brewCleanup.jsx'); +const BrewLookup = require('./brewLookup/brewLookup.jsx'); +const BrewCompress = require ('./brewCompress/brewCompress.jsx'); +const Stats = require('./stats/stats.jsx'); + +const BrewUtils = createClass({ + render : function(){ + return <> + +
+ +
+ +
+ + ; + } +}); + +module.exports = BrewUtils; diff --git a/client/admin/brewUtils/brewUtils.less b/client/admin/brewUtils/brewUtils.less new file mode 100644 index 000000000..5bbbc3f69 --- /dev/null +++ b/client/admin/brewUtils/brewUtils.less @@ -0,0 +1,29 @@ +.brewUtil { + .result { + margin-top : 20px; + button { + margin-right : 10px; + background-color : @red; + } + } + .cleanButton { + display : inline-block; + width : 100%; + } +} + +.stats { + position : relative; + + .pending { + position : absolute; + top : 0.5em; + left : 100px; + width : 100%; + height : 100%; + } + + &:has(.pending) { opacity : 0.5; } + + dl { grid-template-columns : 200px 250px; } +} \ No newline at end of file diff --git a/client/admin/stats/stats.jsx b/client/admin/brewUtils/stats/stats.jsx similarity index 81% rename from client/admin/stats/stats.jsx rename to client/admin/brewUtils/stats/stats.jsx index 85ce10610..7f96618f9 100644 --- a/client/admin/stats/stats.jsx +++ b/client/admin/brewUtils/stats/stats.jsx @@ -1,11 +1,8 @@ -require('./stats.less'); const React = require('react'); const createClass = require('create-react-class'); -const cx = require('classnames'); const request = require('superagent'); - const Stats = createClass({ displayName : 'Stats', getDefaultProps(){ @@ -14,7 +11,8 @@ const Stats = createClass({ getInitialState(){ return { stats : { - totalBrews : 0 + totalBrews : 0, + totalPublishedBrews : 0 }, fetching : false }; @@ -29,11 +27,13 @@ const Stats = createClass({ .finally(()=>this.setState({ fetching: false })); }, render(){ - return
+ return

Stats

Total Brew Count
{this.state.stats.totalBrews}
+
Total Brews Published
+
{this.state.stats.totalPublishedBrews}
{this.state.fetching diff --git a/client/admin/notificationUtils/notificationAdd/notificationAdd.jsx b/client/admin/notificationUtils/notificationAdd/notificationAdd.jsx new file mode 100644 index 000000000..0cca1047e --- /dev/null +++ b/client/admin/notificationUtils/notificationAdd/notificationAdd.jsx @@ -0,0 +1,109 @@ +require('./notificationAdd.less'); +const React = require('react'); +const { useState, useRef } = require('react'); +const request = require('superagent'); + +const NotificationAdd = ()=>{ + const [notificationResult, setNotificationResult] = useState(null); + const [searching, setSearching] = useState(false); + const [error, setError] = useState(null); + + const dismissKeyRef = useRef(null); + const titleRef = useRef(null); + const textRef = useRef(null); + const startAtRef = useRef(null); + const stopAtRef = useRef(null); + + const saveNotification = async ()=>{ + const dismissKey = dismissKeyRef.current.value; + const title = titleRef.current.value; + const text = textRef.current.value; + const startAt = new Date(startAtRef.current.value); + const stopAt = new Date(stopAtRef.current.value); + + // Basic validation + if(!dismissKey || !title || !text || isNaN(startAt.getTime()) || isNaN(stopAt.getTime())) { + setError('All fields are required'); + return; + } + if(startAt >= stopAt) { + setError('End date must be after the start date!'); + return; + } + + const data = { + dismissKey, + title, + text, + startAt : startAt?.toISOString() ?? '', + stopAt : stopAt?.toISOString() ?? '', + }; + + try { + setSearching(true); + setError(null); + const response = await request.post('/admin/notification/add').send(data); + console.log(response.body); + + // Reset form fields + dismissKeyRef.current.value = ''; + titleRef.current.value = ''; + textRef.current.value = ''; + + setNotificationResult('Notification successfully created.'); + setSearching(false); + } catch (err) { + console.log(err.response.body.message); + setError(`Error saving notification: ${err.response.body.message}`); + setSearching(false); + } + }; + + return ( +
+

Add Notification

+ + + + + + + + + + + +
{notificationResult}
+ + + {error &&
{error}
} +
+ ); +}; + +module.exports = NotificationAdd; diff --git a/client/admin/notificationUtils/notificationAdd/notificationAdd.less b/client/admin/notificationUtils/notificationAdd/notificationAdd.less new file mode 100644 index 000000000..14bdabd03 --- /dev/null +++ b/client/admin/notificationUtils/notificationAdd/notificationAdd.less @@ -0,0 +1,38 @@ +.notificationAdd { + position : relative; + display : flex; + flex-direction : column; + width : 500px; + + .field { + display : grid; + grid-template-columns : 120px 200px; + align-items : center; + justify-items : stretch; + width : 100%; + margin-bottom : 20px; + + input { + height : 33px; + padding : 0px 10px; + margin-bottom : unset; + font-family : monospace; + + &[type='date'] { width : 14ch; } + } + + textarea { + width : 50ch; + min-height : 7em; + max-height : 20em; + padding : 10px; + resize : vertical; + } + } + + button { + width : 200px; + + i { margin-right : 10px; } + } +} \ No newline at end of file diff --git a/client/admin/notificationUtils/notificationLookup/notificationLookup.jsx b/client/admin/notificationUtils/notificationLookup/notificationLookup.jsx new file mode 100644 index 000000000..05f81b776 --- /dev/null +++ b/client/admin/notificationUtils/notificationLookup/notificationLookup.jsx @@ -0,0 +1,105 @@ +require('./notificationLookup.less'); + +const React = require('react'); +const { useState } = require('react'); +const request = require('superagent'); +const Moment = require('moment'); + +const NotificationDetail = ({ notification, onDelete })=>( + <> +
+
Key
+
{notification.dismissKey}
+ +
Title
+
{notification.title || 'No Title'}
+ +
Created
+
{Moment(notification.createdAt).format('LLLL')}
+ +
Start
+
{Moment(notification.startAt).format('LLLL') || 'No Start Time'}
+ +
Stop
+
{Moment(notification.stopAt).format('LLLL') || 'No End Time'}
+ +
Text
+
{notification.text || 'No Text'}
+
+ + +); + +const NotificationLookup = ()=>{ + const [searching, setSearching] = useState(false); + const [error, setError] = useState(null); + const [notifications, setNotifications] = useState([]); + + const lookupAll = async ()=>{ + setSearching(true); + setError(null); + + try { + const res = await request.get('/admin/notification/all'); + setNotifications(res.body || []); + } catch (err) { + console.log(err); + setError(`Error looking up notifications: ${err.response.body.message}`); + } finally { + setSearching(false); + } + }; + + const deleteNotification = async (dismissKey)=>{ + if(!dismissKey) return; + + const confirmed = window.confirm( + `Really delete notification ${dismissKey}?` + ); + if(!confirmed) { + console.log('Delete notification cancelled'); + return; + } + console.log('Delete notification confirm'); + try { + await request.delete(`/admin/notification/delete/${dismissKey}`); + lookupAll(); + } catch (err) { + console.log(err); + setError(`Error deleting notification: ${err.response.body.message}`); + }; + }; + + const renderNotificationsList = ()=>{ + if(error) + return
{error}
; + + if(notifications.length === 0) + return
No notifications available.
; + + return ( +
    + {notifications.map((notification)=>( +
  • +
    + {notification.title || 'No Title'} + +
    +
  • + ))} +
+ ); + }; + + return ( +
+

Check all Notifications

+ + {renderNotificationsList()} +
+ ); +}; + +module.exports = NotificationLookup; diff --git a/client/admin/notificationUtils/notificationLookup/notificationLookup.less b/client/admin/notificationUtils/notificationLookup/notificationLookup.less new file mode 100644 index 000000000..65903213c --- /dev/null +++ b/client/admin/notificationUtils/notificationLookup/notificationLookup.less @@ -0,0 +1,35 @@ +.notificationLookup { + width : 450px; + height : fit-content; + + .noNotification { margin-block : 20px; } + .notificationList { + display : flex; + flex-direction : column; + max-height : 500px; + margin-block : 20px; + overflow : auto; + border : 1px solid; + border-radius : 5px; + + li { + padding : 10px; + background : #CCCCCC; + + &:nth-child(even) { background : #DDDDDD; } + &:first-child { + border-top-left-radius : 5px; + border-top-right-radius : 5px; + } + &:last-child { + border-bottom-right-radius : 5px; + border-bottom-left-radius : 5px; + } + + summary { + font-size : 20px; + font-weight : 900; + } + } + } +} \ No newline at end of file diff --git a/client/admin/notificationUtils/notificationUtils.jsx b/client/admin/notificationUtils/notificationUtils.jsx new file mode 100644 index 000000000..22ea21328 --- /dev/null +++ b/client/admin/notificationUtils/notificationUtils.jsx @@ -0,0 +1,15 @@ +const React = require('react'); + +const NotificationLookup = require('./notificationLookup/notificationLookup.jsx'); +const NotificationAdd = require('./notificationAdd/notificationAdd.jsx'); + +const NotificationUtils = ()=>{ + return ( +
+ + +
+ ); +}; + +module.exports = NotificationUtils; diff --git a/client/admin/stats/stats.less b/client/admin/stats/stats.less deleted file mode 100644 index 5337bf671..000000000 --- a/client/admin/stats/stats.less +++ /dev/null @@ -1,28 +0,0 @@ - -.Stats{ - position : relative; - .pending{ - position : absolute; - top : 0px; - left : 0px; - height : 100%; - width : 100%; - background-color : rgba(238,238,238, 0.5); - } - dl{ - @maxItemWidth : 132px; - dt{ - float : left; - clear : left; - width : @maxItemWidth; - text-align : right; - &::after { - content: " : "; - } - } - dd{ - margin : 0 0 0 @maxItemWidth + 10px; - padding : 0 0 0.5em 0; - } - } -} \ No newline at end of file diff --git a/client/components/Anchored.jsx b/client/components/Anchored.jsx new file mode 100644 index 000000000..87af5a6e1 --- /dev/null +++ b/client/components/Anchored.jsx @@ -0,0 +1,96 @@ +import React, { useState, useRef, forwardRef, useEffect, cloneElement, Children } from 'react'; +import './Anchored.less'; + +// Anchored is a wrapper component that must have as children an and a component. +// AnchoredTrigger must have a unique `id` prop, which is passed up to Anchored, saved in state on mount, and +// then passed down through props into AnchoredBox. The `id` is used for the CSS Anchor Positioning properties. +// **The Anchor Positioning API is not available in Firefox yet** +// So in Firefox the positioning isn't perfect but is likely sufficient, and FF team seems to be working on the API quickly. + +// When Anchor Positioning is added to Firefox, this can also be rewritten using the Popover API-- add the `popover` attribute +// to the container div, which will render the container in the *top level* and give it better interactions like +// click outside to dismiss. **Do not** add without Anchor, though, because positioning is very limited with the `popover` +// attribute. + + +const Anchored = ({ children })=>{ + const [visible, setVisible] = useState(false); + const [anchorId, setAnchorId] = useState(null); + const boxRef = useRef(null); + const triggerRef = useRef(null); + + // promote trigger id to Anchored id (to pass it back down to the box as "anchorId") + useEffect(()=>{ + if(triggerRef.current){ + setAnchorId(triggerRef.current.id); + } + }, []); + + // close box on outside click or Escape key + useEffect(()=>{ + const handleClickOutside = (evt)=>{ + if( + boxRef.current && + !boxRef.current.contains(evt.target) && + triggerRef.current && + !triggerRef.current.contains(evt.target) + ) { + setVisible(false); + } + }; + + const handleEscapeKey = (evt)=>{ + if(evt.key === 'Escape') setVisible(false); + }; + + window.addEventListener('click', handleClickOutside); + window.addEventListener('keydown', handleEscapeKey); + + return ()=>{ + window.removeEventListener('click', handleClickOutside); + window.removeEventListener('keydown', handleEscapeKey); + }; + }, []); + + const toggleVisibility = ()=>setVisible((prev)=>!prev); + + // Map children to inject necessary props + const mappedChildren = Children.map(children, (child)=>{ + if(child.type === AnchoredTrigger) { + return cloneElement(child, { ref: triggerRef, toggleVisibility, visible }); + } + if(child.type === AnchoredBox) { + return cloneElement(child, { ref: boxRef, visible, anchorId }); + } + return child; + }); + + return <>{mappedChildren}; +}; + +// forward ref for AnchoredTrigger +const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>( + +)); + +// forward ref for AnchoredBox +const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>( +
+ {children} +
+)); + +export { Anchored, AnchoredTrigger, AnchoredBox }; diff --git a/client/components/Anchored.less b/client/components/Anchored.less new file mode 100644 index 000000000..aeb9f1d5f --- /dev/null +++ b/client/components/Anchored.less @@ -0,0 +1,11 @@ + + +.anchored-box { + position : absolute; + visibility : hidden; + justify-self : anchor-center; + @supports (inset-block-start: anchor(bottom)) { + inset-block-start : anchor(bottom); + } + &.active { visibility : visible; } +} \ No newline at end of file diff --git a/client/components/combobox.jsx b/client/components/combobox.jsx index 5fcc154bc..ae9f1d7f8 100644 --- a/client/components/combobox.jsx +++ b/client/components/combobox.jsx @@ -45,6 +45,7 @@ const Combobox = createClass({ }, handleDropdown : function(show){ this.setState({ + value : show ? '' : this.props.default, showDropdown : show, inputFocused : this.props.autoSuggest.clearAutoSuggestOnClick ? show : false }); @@ -58,10 +59,10 @@ const Combobox = createClass({ this.props.onEntry(e); }); }, - handleSelect : function(e){ + handleSelect : function(value, data=value){ this.setState({ - value : e.currentTarget.getAttribute('data-value') - }, ()=>{this.props.onSelect(this.state.value);}); + value : value + }, ()=>{this.props.onSelect(data);}); ; }, renderTextInput : function(){ @@ -78,10 +79,11 @@ const Combobox = createClass({ if(!e.target.checkValidity()){ this.setState({ value : this.props.default - }, ()=>this.props.onEntry(e)); + }); } }} /> +
); }, @@ -92,11 +94,10 @@ const Combobox = createClass({ const filterOn = _.isString(this.props.autoSuggest.filterOn) ? [this.props.autoSuggest.filterOn] : this.props.autoSuggest.filterOn; const filteredArrays = filterOn.map((attr)=>{ const children = dropdownChildren.filter((item)=>{ - if(suggestMethod === 'includes'){ + if(suggestMethod === 'includes') return item.props[attr]?.toLowerCase().includes(this.state.value.toLowerCase()); - } else if(suggestMethod === 'startsWith'){ + if(suggestMethod === 'startsWith') return item.props[attr]?.toLowerCase().startsWith(this.state.value.toLowerCase()); - } }); return children; }); @@ -111,7 +112,7 @@ const Combobox = createClass({ }, render : function () { const dropdownChildren = this.state.options.map((child, i)=>{ - const clone = React.cloneElement(child, { onClick: (e)=>this.handleSelect(e) }); + const clone = React.cloneElement(child, { onClick: ()=>this.handleSelect(child.props.value, child.props.data) }); return clone; }); return ( diff --git a/client/components/combobox.less b/client/components/combobox.less index 3810a874e..27f78356b 100644 --- a/client/components/combobox.less +++ b/client/components/combobox.less @@ -1,50 +1,46 @@ .dropdown-container { - position:relative; - input { - width: 100%; - } - .dropdown-options { - position:absolute; - background-color: white; - z-index: 100; - width: 100%; - border: 1px solid gray; - overflow-y: auto; - max-height: 200px; + position : relative; + input { width : 100%; } + .item i { + position : absolute; + right : 10px; + color : black; + } + .dropdown-options { + position : absolute; + z-index : 100; + width : 100%; + max-height : 200px; + overflow-y : auto; + background-color : white; + border : 1px solid gray; - &::-webkit-scrollbar { - width: 14px; - } - &::-webkit-scrollbar-track { - background: #ffffff; - } - &::-webkit-scrollbar-thumb { - background-color: #949494; - border-radius: 10px; - border: 3px solid #ffffff; - } - - .item { - position:relative; - font-size: 11px; - font-family: Open Sans; - padding: 5px; - cursor: default; - margin: 0 3px; - //border-bottom: 1px solid darkgray; - &:hover { - filter: brightness(120%); - background-color: rgb(163, 163, 163); - } - .detail { - width:100%; - text-align: left; - color: rgb(124, 124, 124); - font-style:italic; - font-size: 9px; - } - } - - } + &::-webkit-scrollbar { width : 14px; } + &::-webkit-scrollbar-track { background : #FFFFFF; } + &::-webkit-scrollbar-thumb { + background-color : #949494; + border : 3px solid #FFFFFF; + border-radius : 10px; + } + .item { + position : relative; + padding : 5px; + margin : 0 3px; + font-family : 'Open Sans'; + font-size : 11px; + cursor : default; + &:hover { + background-color : rgb(163, 163, 163); + filter : brightness(120%); + } + .detail { + width : 100%; + font-size : 9px; + font-style : italic; + color : rgb(124, 124, 124); + text-align : left; + } + } + } } diff --git a/client/components/dialog.jsx b/client/components/dialog.jsx index 2057ecb87..e88d06c99 100644 --- a/client/components/dialog.jsx +++ b/client/components/dialog.jsx @@ -1,22 +1,24 @@ // Dialog box, for popups and modal blocking messages -const React = require('react'); +import React from 'react'; const { useRef, useEffect } = React; -function Dialog({ dismissKey, closeText = 'Close', blocking = false, ...rest }) { +function Dialog({ dismisskeys = [], closeText = 'Close', blocking = false, ...rest }) { const dialogRef = useRef(null); useEffect(()=>{ - if(!dismissKey || !localStorage.getItem(dismissKey)) { - blocking ? dialogRef.current?.showModal() : dialogRef.current?.show(); - } + blocking ? dialogRef.current?.showModal() : dialogRef.current?.show(); }, []); const dismiss = ()=>{ - dismissKey && localStorage.setItem(dismissKey, true); + dismisskeys.forEach((key)=>{ + if(key) { + localStorage.setItem(key, 'true'); + } + }); dialogRef.current?.close(); }; - return ( + return ( {rest.children} + ); +}; module.exports = ErrorBar; diff --git a/client/homebrew/brewRenderer/errorBar/errorBar.less b/client/homebrew/brewRenderer/errorBar/errorBar.less index f3f2dbaae..163648533 100644 --- a/client/homebrew/brewRenderer/errorBar/errorBar.less +++ b/client/homebrew/brewRenderer/errorBar/errorBar.less @@ -1,60 +1,58 @@ -.errorBar{ +.errorBar { position : absolute; - z-index : 10000; - box-sizing : border-box; + top : 32px; + z-index : 1; width : 100%; - margin-right : 13px; - padding : 20px; - padding-bottom : 10px; - padding-left : 100px; - background-color : @red; color : white; - i{ - position : absolute; - left : 30px; - opacity : 0.8; - font-size : 3em; - } - h3{ - font-size : 1.1em; - font-weight : 800; - } - ul{ - margin-top : 15px; - font-size : 0.8em; - list-style-position : inside; - list-style-type : disc; - li{ - line-height : 1.6em; + background-color : @red; + border : unset; + + div { + > i { + float : left; + margin-right : 10px; + margin-bottom : 20px; + font-size : 3em; + opacity : 0.8; + } + h2 { font-weight : 800; } + ul { + margin-top : 15px; + font-size : 0.8em; + list-style-position : inside; + list-style-type : disc; + li { line-height : 1.6em; } } } - hr{ - box-sizing : border-box; + hr { height : 2px; - width : 150%; margin-top : 25px; margin-bottom : 15px; - margin-left : -100px; background-color : darken(@red, 8%); border : none; } - small{ - font-size: 0.6em; - opacity: 0.7; + small { + font-size : 0.6em; + opacity : 0.7; } - .protips{ - margin-left : -80px; - font-size : 0.6em; - &>div{ - margin-bottom : 10px; - line-height : 1.2em; - } - h4{ - opacity : 0.8; + .protips { + font-size : 0.6em; + line-height : 1.2em; + h4 { font-weight : 800; line-height : 1.5em; text-transform : uppercase; } } + button.dismiss { + position : absolute; + top : 20px; + right : 30px; + padding : unset; + font-size : 40px; + background-color : transparent; + opacity : 0.6; + &:hover { opacity : 1; } + } } \ No newline at end of file diff --git a/client/homebrew/brewRenderer/headerNav/headerNav.jsx b/client/homebrew/brewRenderer/headerNav/headerNav.jsx new file mode 100644 index 000000000..04ced2585 --- /dev/null +++ b/client/homebrew/brewRenderer/headerNav/headerNav.jsx @@ -0,0 +1,113 @@ +require('./headerNav.less'); + +import * as React from 'react'; +import * as _ from 'lodash'; + +const MAX_TEXT_LENGTH = 40; + +const HeaderNav = React.forwardRef(({}, pagesRef)=>{ + + const renderHeaderLinks = ()=>{ + if(!pagesRef.current) return; + + // Top Level Pages + // Pages that contain an element with a specified class (e.g. cover pages, table of contents) + // will NOT have its content scanned for navigation headers, instead displaying a custom label + // --- + // The property name is class that will be used for detecting the page is a top level page + // The property value is a function that returns the text to be used + + const topLevelPages = { + '.frontCover' : (el, pageType)=>{ const text = getHeaderContent(el); return text ? `Cover: ${text}` : 'Cover Page'; }, + '.insideCover' : (el, pageType)=>{ const text = getHeaderContent(el); return text ? `Interior: ${text}` : 'Interior Cover Page'; }, + '.partCover' : (el, pageType)=>{ const text = getHeaderContent(el); return text ? `Section: ${text}` : 'Section Cover Page'; }, + '.backCover' : (el, pageType)=>{ const text = getHeaderContent(el); return text ? `Back: ${text}` : 'Rear Cover Page'; }, + '.toc' : ()=>{ return 'Table of Contents'; }, + }; + + const getHeaderContent = (el)=>el.querySelector('h1')?.textContent; + + const topLevelPageSelector = Object.keys(topLevelPages).join(','); + + const selector = [ + '.pages > .page', // All page elements, which by definition have IDs + `.page:not(:has(${topLevelPageSelector})) > [id]`, // All direct children of non-excluded .pages with an ID (Legacy) + `.page:not(:has(${topLevelPageSelector})) > .columnWrapper > [id]`, // All direct children of non-excluded .page > .columnWrapper with an ID (V3) + `.page:not(:has(${topLevelPageSelector})) h2`, // All non-excluded H2 titles, like Monster frame titles + ]; + const elements = pagesRef.current.querySelectorAll(selector.join(',')); + if(!elements) return; + const navList = []; + + // navList is a list of objects which have the following structure: + // { + // depth : how deeply indented the item should be + // text : the text to display in the nav link + // link : the hyperlink to navigate to when clicked + // className : [optional] the class to apply to the nav link for styling + // } + + elements.forEach((el)=>{ + const navEntry = { // Default structure of a navList entry + depth : 7, // All unmatched elements with IDs are set to the maximum depth (7) + text : el.textContent, // Use `textContent` because `innerText` is affected by rendering, e.g. 'content-visibility: auto' + link : el.id + }; + if(el.classList.contains('page')) { + let text = `Page ${el.id.slice(1)}`; // Get the page # by trimming off the 'p' from the ID + const pageType = Object.keys(topLevelPages).find((pageType)=>el.querySelector(pageType)); + if(pageType) + text += ` - ${topLevelPages[pageType](el, pageType)}`; // If a Top Level Page, add extra label + + navEntry.depth = 0; // Pages are always at the least indented level + navEntry.text = text; + navEntry.className = 'pageLink'; + } else if(el.localName.match(/^h[1-6]/)){ // Header elements H1 through H6 + navEntry.depth = el.localName[1]; // Depth is set by the header level + } + navList.push(navEntry); + }); + + return _.map(navList, (navItem, index)=> + ); + }; + + return ; +}); + +const HeaderNavItem = ({ link, text, depth, className })=>{ + + const trimString = (text, prefixLength = 0)=>{ + // Sanity check nav link strings + let output = text; + + // If the string has a line break, only use the first line + if(text.indexOf('\n')){ + output = text.split('\n')[0]; + } + + // Trim unecessary spaces from string + output = output.trim(); + + // Reduce excessively long strings + const maxLength = MAX_TEXT_LENGTH - prefixLength; + if(output.length > maxLength){ + return `${output.slice(0, maxLength).trim()}...`; + } + return output; + }; + + if(!link || !text) return; + + return
  • + + {trimString(text, depth)} + +
  • ; +}; + +export default HeaderNav; \ No newline at end of file diff --git a/client/homebrew/brewRenderer/headerNav/headerNav.less b/client/homebrew/brewRenderer/headerNav/headerNav.less new file mode 100644 index 000000000..a5fd11f5e --- /dev/null +++ b/client/homebrew/brewRenderer/headerNav/headerNav.less @@ -0,0 +1,39 @@ +.headerNav { + position : fixed; + top : 32px; + left : 0px; + max-width : 40vw; + max-height : calc(100vh - 32px); + padding : 5px 10px; + overflow-y : auto; + background-color : #CCCCCC; + border-radius : 5px; + &.active { + padding-bottom : 10px; + .navIcon { padding-bottom : 10px; } + } + .navIcon { cursor : pointer; } + li { + list-style-type : none; + a { + display : inline-block; + width : 100%; + padding : 2px; + font-family : 'Open Sans'; + font-size : 12px; + color : inherit; + text-decoration : none; + cursor : pointer; + &:hover { text-decoration : underline; } + &.pageLink { font-weight : 900; } + + @depths: 0,1,2,3,4,5,6,7; + + each(@depths, { + &.depth-@{value} { + padding-left: ((@value) * 0.5em); + } + }); + } + } +} \ No newline at end of file diff --git a/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx b/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx index cba6a629c..38a85e0c7 100644 --- a/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx +++ b/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx @@ -1,44 +1,63 @@ require('./notificationPopup.less'); -const React = require('react'); -const _ = require('lodash'); +import React, { useEffect, useState } from 'react'; +import request from '../../utils/request-middleware.js'; +import Markdown from 'naturalcrit/markdown.js'; import Dialog from '../../../components/dialog.jsx'; -const DISMISS_KEY = 'dismiss_notification04-09-24'; const DISMISS_BUTTON = ; const NotificationPopup = ()=>{ - return + const [notifications, setNotifications] = useState([]); + const [dissmissKeyList, setDismissKeyList] = useState([]); + const [error, setError] = useState(null); + + useEffect(()=>{ + getNotifications(); + }, []); + + const getNotifications = async ()=>{ + setError(null); + try { + const res = await request.get('/admin/notification/all'); + pickActiveNotifications(res.body || []); + } catch (err) { + console.log(err); + setError(`Error looking up notifications: ${err?.response?.body?.message || err.message}`); + } + }; + + const pickActiveNotifications = (notifs)=>{ + const now = new Date(); + const filteredNotifications = notifs.filter((notification)=>{ + const startDate = new Date(notification.startAt); + const stopDate = new Date(notification.stopAt); + const dismissed = localStorage.getItem(notification.dismissKey) ? true : false; + return now >= startDate && now <= stopDate && !dismissed; + }); + setNotifications(filteredNotifications); + setDismissKeyList(filteredNotifications.map((notif)=>notif.dismissKey)); + }; + + const renderNotificationsList = ()=>{ + if(error) return
    {error}
    ; + return notifications.map((notification)=>( +
  • + {notification.title}
    +

    +
  • + )); + }; + + if(!notifications.length) return; + return

    Notice

    This website is always improving and we are still adding new features and squashing bugs. Keep the following in mind:
      -
    • - Search brews with our new page!
      - We have been working very hard in making this possible, now you can share your work and look at it in the new Vault page! - All PUBLISHED brews will be available to anyone searching there, by title or author, and filtering by renderer. - - More features will be coming. -
    • - -
    • - Don't delete your Homebrewery folder on Google Drive!
      - We have had several reports of users losing their brews, not realizing - that they had deleted the files on their Google Drive. If you have a Homebrewery folder - on your Google Drive with *.txt files inside, do not delete it! - We cannot help you recover files that you have deleted from your own - Google Drive. -
    • - -
    • - Protect your work!
      - If you opt not to use your Google Drive, keep in mind that we do not save a history of your projects. Please make frequent backups of your brews!  - - See the FAQ - to learn how to avoid losing your work! -
    • + {renderNotificationsList()}
    ; }; diff --git a/client/homebrew/brewRenderer/notificationPopup/notificationPopup.less b/client/homebrew/brewRenderer/notificationPopup/notificationPopup.less index 2982055c8..85d4c8365 100644 --- a/client/homebrew/brewRenderer/notificationPopup/notificationPopup.less +++ b/client/homebrew/brewRenderer/notificationPopup/notificationPopup.less @@ -48,14 +48,46 @@ } ul { margin-top : 15px; - font-size : 0.8em; + font-size : 0.9em; list-style-position : outside; list-style-type : disc; li { - margin-top : 1.4em; - font-size : 0.8em; - line-height : 1.4em; - em { font-weight : 800; } + padding-left : 1em; + margin-top : 1.5em; + font-size : 0.9em; + line-height : 1.5em; + em { + font-weight : 800; + text-transform : capitalize; + } + li { + margin-top : 0; + line-height : 1.2em; + list-style-type : square; + } + } + ul ul,ol ol,ul ol,ol ul { + margin-bottom : 0px; + margin-left : 1.5em; } } -} + + /* Markdown styling */ + code { + padding : 0.1em 0.5em; + font-family : 'Courier New', 'Courier', monospace; + overflow-wrap : break-word; + white-space : pre-wrap; + background : #08115A; + border-radius : 2px; + } + pre code { + display : inline-block; + width : 100%; + } + .blank { + height : 1em; + margin-top : 0; + & + * { margin-top : 0; } + } +} \ No newline at end of file diff --git a/client/homebrew/brewRenderer/safeHTML.js b/client/homebrew/brewRenderer/safeHTML.js new file mode 100644 index 000000000..2574f4cfe --- /dev/null +++ b/client/homebrew/brewRenderer/safeHTML.js @@ -0,0 +1,46 @@ +// Derived from the vue-html-secure package, customized for Homebrewery + +let doc = null; +let div = null; + +function safeHTML(htmlString) { + // If the Document interface doesn't exist, exit + if(typeof document == 'undefined') return null; + // If the test document and div don't exist, create them + if(!doc) doc = document.implementation.createHTMLDocument(''); + if(!div) div = doc.createElement('div'); + + // Set the test div contents to the evaluation string + div.innerHTML = htmlString; + // Grab all nodes from the test div + const elements = div.querySelectorAll('*'); + + // Blacklisted tags + const blacklistTags = ['script', 'noscript', 'noembed']; + // Tests to remove attributes + const blacklistAttrs = [ + (test)=>{return test.localName.indexOf('on') == 0;}, + (test)=>{return test.localName.indexOf('type') == 0 && test.value.match(/submit/i);}, + (test)=>{return test.value.replace(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205f\u3000]/g, '').toLowerCase().trim().indexOf('javascript:') == 0;} + ]; + + + elements.forEach((element)=>{ + // Check each element for blacklisted type + if(blacklistTags.includes(element?.localName?.toLowerCase())) { + element.remove(); + return; + } + // Check remaining elements for blacklisted attributes + for (const attribute of element.attributes){ + if(blacklistAttrs.some((test)=>{return test(attribute);})) { + element.removeAttribute(attribute.localName); + break; + }; + }; + }); + + return div.innerHTML; +}; + +module.exports.safeHTML = safeHTML; \ No newline at end of file diff --git a/client/homebrew/brewRenderer/toolBar/toolBar.jsx b/client/homebrew/brewRenderer/toolBar/toolBar.jsx index 73b48d778..f11d1f127 100644 --- a/client/homebrew/brewRenderer/toolBar/toolBar.jsx +++ b/client/homebrew/brewRenderer/toolBar/toolBar.jsx @@ -1,28 +1,31 @@ +/* eslint-disable max-lines */ require('./toolBar.less'); const React = require('react'); const { useState, useEffect } = React; const _ = require('lodash'); +import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx'; const MAX_ZOOM = 300; const MIN_ZOOM = 10; -const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{ +const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPages, headerState, setHeaderState })=>{ - const [zoomLevel, setZoomLevel] = useState(100); - const [pageNum, setPageNum] = useState(currentPage); + const [pageNum, setPageNum] = useState(1); const [toolsVisible, setToolsVisible] = useState(true); useEffect(()=>{ - onZoomChange(zoomLevel); - }, [zoomLevel]); - - useEffect(()=>{ - setPageNum(currentPage); - }, [currentPage]); + // format multiple visible pages as a range (e.g. "150-153") + const pageRange = visiblePages.length === 1 ? `${visiblePages[0]}` : `${visiblePages[0]} - ${visiblePages.at(-1)}`; + setPageNum(pageRange); + }, [visiblePages]); const handleZoomButton = (zoom)=>{ - setZoomLevel(_.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM))); + handleOptionChange('zoomLevel', _.round(_.clamp(zoom, MIN_ZOOM, MAX_ZOOM))); + }; + + const handleOptionChange = (optionKey, newValue)=>{ + onDisplayOptionsChange({ ...displayOptions, [optionKey]: newValue }); }; const handlePageInput = (pageInput)=>{ @@ -30,16 +33,16 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{ setPageNum(parseInt(pageInput)); // input type is 'text', so `page` comes in as a string, not number. }; + // scroll to a page, used in the Prev/Next Page buttons. const scrollToPage = (pageNumber)=>{ + if(typeof pageNumber !== 'number') return; pageNumber = _.clamp(pageNumber, 1, totalPages); const iframe = document.getElementById('BrewRenderer'); const brewRenderer = iframe?.contentWindow?.document.querySelector('.brewRenderer'); const page = brewRenderer?.querySelector(`#p${pageNumber}`); page?.scrollIntoView({ block: 'start' }); - setPageNum(pageNumber); }; - const calculateChange = (mode)=>{ const iframe = document.getElementById('BrewRenderer'); const iframeWidth = iframe.getBoundingClientRect().width; @@ -55,55 +58,66 @@ const ToolBar = ({ onZoomChange, currentPage, onPageChange, totalPages })=>{ desiredZoom = (iframeWidth / widestPage) * 100; } else if(mode == 'fit'){ + let minDimRatio; // find the page with the largest single dim (height or width) so that zoom can be adapted to fit it. - const minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity); + if(displayOptions.spread === 'facing') + minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth / 2), Infinity); // if 'facing' spread, fit two pages in view + else + minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity); desiredZoom = minDimRatio * 100; } const margin = 5; // extra space so page isn't edge to edge (not truly "to fill") - const deltaZoom = (desiredZoom - zoomLevel) - margin; + const deltaZoom = (desiredZoom - displayOptions.zoomLevel) - margin; return deltaZoom; }; return ( -
    - +