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/client/components/dialog.jsx b/client/components/dialog.jsx index 0cdda2dee..e88d06c99 100644 --- a/client/components/dialog.jsx +++ b/client/components/dialog.jsx @@ -6,10 +6,8 @@ function Dialog({ dismisskeys = [], closeText = 'Close', blocking = false, ...re const dialogRef = useRef(null); useEffect(()=>{ - if(dismisskeys.length !== 0) { - blocking ? dialogRef.current?.showModal() : dialogRef.current?.show(); - } - }, [dialogRef.current, dismisskeys]); + blocking ? dialogRef.current?.showModal() : dialogRef.current?.show(); + }, []); const dismiss = ()=>{ dismisskeys.forEach((key)=>{ diff --git a/client/homebrew/brewRenderer/brewRenderer.jsx b/client/homebrew/brewRenderer/brewRenderer.jsx index 5563c5e04..17f261c2d 100644 --- a/client/homebrew/brewRenderer/brewRenderer.jsx +++ b/client/homebrew/brewRenderer/brewRenderer.jsx @@ -16,8 +16,11 @@ const Frame = require('react-frame-component').default; const dedent = require('dedent-tabs').default; const { printCurrentBrew } = require('../../../shared/helpers.js'); +import HeaderNav from './headerNav/headerNav.jsx'; + import { safeHTML } from './safeHTML.js'; + const PAGE_HEIGHT = 1056; const INITIAL_CONTENT = dedent` @@ -50,8 +53,8 @@ const BrewPage = (props)=>{ props.onVisibilityChange(props.index + 1, true, false); // add page to array of visible pages. else props.onVisibilityChange(props.index + 1, false, false); - } - )}, + }); + }, { threshold: .3, rootMargin: '0px 0px 0px 0px' } // detect when >30% of page is within bounds. ); @@ -61,8 +64,8 @@ const BrewPage = (props)=>{ entries.forEach((entry)=>{ if(entry.isIntersecting) props.onVisibilityChange(props.index + 1, true, true); // Set this page as the center page - } - )}, + }); + }, { threshold: 0, rootMargin: '-50% 0px -50% 0px' } // Detect when the page is at the center ); @@ -115,7 +118,10 @@ const BrewRenderer = (props)=>{ pageShadows : true }); + const [headerState, setHeaderState] = useState(false); + const mainRef = useRef(null); + const pagesRef = useRef(null); if(props.renderer == 'legacy') { rawPages = props.text.split('\\page'); @@ -287,7 +293,7 @@ const BrewRenderer = (props)=>{ - 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length}/> + 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/> {/*render in iFrame so broken code doesn't crash the site.*/} { && <> {renderedStyle} -
+
{renderedPages}
}
+ {headerState ? : <>} ); diff --git a/client/homebrew/brewRenderer/brewRenderer.less b/client/homebrew/brewRenderer/brewRenderer.less index b40e9062e..68c688fb6 100644 --- a/client/homebrew/brewRenderer/brewRenderer.less +++ b/client/homebrew/brewRenderer/brewRenderer.less @@ -70,6 +70,7 @@ .pane { position : relative; } + @media print { .toolBar { display : none; } .brewRenderer { @@ -82,4 +83,7 @@ & > .page { box-shadow : unset; } } } + .headerNav { + visibility: hidden; + } } \ No newline at end of file diff --git a/client/homebrew/brewRenderer/errorBar/errorBar.jsx b/client/homebrew/brewRenderer/errorBar/errorBar.jsx index d2f847306..78b36d70c 100644 --- a/client/homebrew/brewRenderer/errorBar/errorBar.jsx +++ b/client/homebrew/brewRenderer/errorBar/errorBar.jsx @@ -1,75 +1,53 @@ require('./errorBar.less'); const React = require('react'); -const createClass = require('create-react-class'); -const _ = require('lodash'); -const ErrorBar = createClass({ - displayName : 'ErrorBar', - getDefaultProps : function() { - return { - errors : [] - }; - }, +import Dialog from '../../../components/dialog.jsx'; - hasOpenError : false, - hasCloseError : false, - hasMatchError : false, +const DISMISS_BUTTON = ; - renderErrors : function(){ - this.hasOpenError = false; - this.hasCloseError = false; - this.hasMatchError = false; +const ErrorBar = (props)=>{ + if(!props.errors.length) return null; + let hasOpenError = false, hasCloseError = false, hasMatchError = false; + props.errors.map((err)=>{ + if(err.id === 'OPEN') hasOpenError = true; + if(err.id === 'CLOSE') hasCloseError = true; + if(err.id === 'MISMATCH') hasMatchError = true; + }); - const errors = _.map(this.props.errors, (err, idx)=>{ - if(err.id == 'OPEN') this.hasOpenError = true; - if(err.id == 'CLOSE') this.hasCloseError = true; - if(err.id == 'MISMATCH') this.hasMatchError = true; - return
  • - Line {err.line} : {err.text}, '{err.type}' tag -
  • ; - }); + const renderErrors = ()=>( +
      + {props.errors.map((err, idx)=>{ + return
    • + Line {err.line} : {err.text}, '{err.type}' tag +
    • ; + })} +
    + ); - return
      {errors}
    ; - }, - - renderProtip : function(){ - const msg = []; - if(this.hasOpenError){ - msg.push(
    - An unmatched opening tag means there's an opened tag that isn't closed. You need to close your tags, like this {'
    '}. Make sure to match types! - ); - } - - if(this.hasCloseError){ - msg.push(
    - An unmatched closing tag means you closed a tag without opening it. Either remove it, or check to where you think you opened it. -
    ); - } - - if(this.hasMatchError){ - msg.push(
    - A type mismatch means you closed a tag, but the last open tag was a different type. -
    ); - } - return
    + const renderProtip = ()=>( +

    Protips!

    - {msg} -
    ; - }, + {hasOpenError &&
    Unmatched opening tag. Close your tags, like this {'
    '}. Match types!
    } + {hasCloseError &&
    Unmatched closing tag. Either remove it or check where it was opened.
    } + {hasMatchError &&
    Type mismatch. Closed a tag with a different type.
    } + + ); - render : function(){ - if(!this.props.errors.length) return null; - - return
    - -

    There are HTML errors in your markup

    - If these aren't fixed your brew will not render properly when you print it to PDF or share it - {this.renderErrors()} + return ( + +
    + +

    There are HTML errors in your markup

    + + If these aren't fixed your brew will not render properly when you print it to PDF or share it + + {renderErrors()} +

    - {this.renderProtip()} -
    ; - } -}); + {renderProtip()} + + ); +}; 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..68963129f --- /dev/null +++ b/client/homebrew/brewRenderer/headerNav/headerNav.jsx @@ -0,0 +1,106 @@ +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; + + const selector = [ + '.pages > .page', // All page elements, which by definition have IDs + '.page:not(:has(.toc)) > [id]', // All direct children of non-ToC .page with an ID (Legacy) + '.page:not(:has(.toc)) > .columnWrapper > [id]', // All direct children of non-ToC .page > .columnWrapper with an ID (V3) + '.page:not(:has(.toc)) h2', // All non-ToC 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)=>{ + if(el.className.match(/\bpage\b/)) { + let text = `Page ${el.id.slice(1)}`; // The ID of a page *should* always be equal to `p` followed by the page number + if(el.querySelector('.toc')){ // If the page contains a table of contents, add "- Contents" to the display text + text += ' - Contents'; + }; + navList.push({ + depth : 0, // Pages are always at the least indented level + text : text, + link : el.id, + className : 'pageLink' + }); + return; + } + if(el.localName.match(/^h[1-6]/)){ // Header elements H1 through H6 + navList.push({ + depth : el.localName[1], // Depth is set by the header level + text : el.textContent, // Use `textContent` because `innerText` is affected by rendering, e.g. 'content-visibility: auto' + link : el.id + }); + return; + } + navList.push({ + 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 + }); + }); + + return _.map(navList, (navItem, index)=>{ + return ; + }); + + }; + + 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..8b35041d9 --- /dev/null +++ b/client/homebrew/brewRenderer/headerNav/headerNav.less @@ -0,0 +1,47 @@ +.headerNav { + position: fixed; + top: 32px; + left: 0px; + padding: 5px 10px; + background-color: #ccc; + border-radius: 5px; + max-height: calc(100vh - 32px); + max-width: 40vw; + overflow-y: auto; + &.active { + padding-bottom: 10px; + .navIcon { + padding-bottom: 10px; + } + } + .navIcon { + cursor: pointer; + } + li { + list-style-type: none; + a { + display: inline-block; + width: 100%; + font-family: 'Open Sans'; + font-size: 12px; + padding: 2px; + color: inherit; + text-decoration: none; + cursor: pointer; + &:hover { + text-decoration: underline; + } + &.pageLink { + font-weight: 900; + } + + @depths: 1,2,3,4,5,6,7; + + each(@depths, { + &.depth-@{value} { + padding-left: ((@value - 1) * 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 0c8fc4b8c..b2045f13d 100644 --- a/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx +++ b/client/homebrew/brewRenderer/notificationPopup/notificationPopup.jsx @@ -49,6 +49,7 @@ const NotificationPopup = ()=>{ )); }; + if(!notifications.length) return; return
    diff --git a/client/homebrew/brewRenderer/toolBar/toolBar.jsx b/client/homebrew/brewRenderer/toolBar/toolBar.jsx index ce60971aa..f11d1f127 100644 --- a/client/homebrew/brewRenderer/toolBar/toolBar.jsx +++ b/client/homebrew/brewRenderer/toolBar/toolBar.jsx @@ -9,7 +9,7 @@ import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anch const MAX_ZOOM = 300; const MIN_ZOOM = 10; -const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPages })=>{ +const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPages, headerState, setHeaderState })=>{ const [pageNum, setPageNum] = useState(1); const [toolsVisible, setToolsVisible] = useState(true); @@ -62,7 +62,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa // find the page with the largest single dim (height or width) so that zoom can be adapted to fit it. 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 + else minDimRatio = [...pages].reduce((minRatio, page)=>Math.min(minRatio, iframeWidth / page.offsetWidth, iframeHeight / page.offsetHeight), Infinity); desiredZoom = minDimRatio * 100; @@ -76,7 +76,10 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa return (