diff --git a/shared/naturalcrit/splitPane/splitPane.jsx b/client/components/splitPane/splitPane.jsx similarity index 100% rename from shared/naturalcrit/splitPane/splitPane.jsx rename to client/components/splitPane/splitPane.jsx diff --git a/shared/naturalcrit/splitPane/splitPane.less b/client/components/splitPane/splitPane.less similarity index 100% rename from shared/naturalcrit/splitPane/splitPane.less rename to client/components/splitPane/splitPane.less diff --git a/client/homebrew/brewRenderer/brewRenderer.jsx b/client/homebrew/brewRenderer/brewRenderer.jsx index 6bcfc87ec..7a101e9f9 100644 --- a/client/homebrew/brewRenderer/brewRenderer.jsx +++ b/client/homebrew/brewRenderer/brewRenderer.jsx @@ -39,8 +39,8 @@ const BrewPage = (props)=>{ index : 0, ...props }; - const pageRef = useRef(null); - const cleanText = safeHTML(`${props.contents}\n
\n`); + const pageRef = useRef(null); + const cleanText = safeHTML(props.contents); useEffect(()=>{ if(!pageRef.current) return; diff --git a/client/homebrew/navbar/error-navitem.jsx b/client/homebrew/navbar/error-navitem.jsx index ec72ace7d..6d9bec444 100644 --- a/client/homebrew/navbar/error-navitem.jsx +++ b/client/homebrew/navbar/error-navitem.jsx @@ -1,157 +1,138 @@ require('./error-navitem.less'); const React = require('react'); const Nav = require('naturalcrit/nav/nav.jsx'); -const createClass = require('create-react-class'); -const ErrorNavItem = createClass({ - getDefaultProps : function() { - return { - error : '', - parent : null - }; - }, - render : function() { - const clearError = ()=>{ - const state = { - error : null - }; - if(this.props.parent.state.isSaving) { - state.isSaving = false; - } - this.props.parent.setState(state); - }; +const ErrorNavItem = ({error = '', clearError})=>{ + const response = error.response; + const errorCode = error.code + const status = response?.status; + const HBErrorCode = response?.body?.HBErrorCode; + const message = response?.body?.message; - const error = this.props.error; - const response = error.response; - const status = response?.status; - const errorCode = error.code - const HBErrorCode = response?.body?.HBErrorCode; - const message = response?.body?.message; - let errMsg = ''; - try { - errMsg += `${error.toString()}\n\n`; - errMsg += `\`\`\`\n${error.stack}\n`; - errMsg += `${JSON.stringify(response?.error, null, ' ')}\n\`\`\``; - console.log(errMsg); - } catch (e){} - - if(status === 409) { - return - Oops! -
- {message ?? 'Conflict: please refresh to get latest changes'} -
-
; - } - - if(status === 412) { - return - Oops! -
- {message ?? 'Your client is out of date. Please save your changes elsewhere and refresh.'} -
-
; - } - - if(HBErrorCode === '04') { - return - Oops! -
- You are no longer signed in as an author of - this brew! Were you signed out from a different - window? Visit our log in page, then try again! -

- -
- Sign In -
-
-
- Not Now -
-
-
; - } - - if(response?.body?.errors?.[0].reason == 'storageQuotaExceeded') { - return - Oops! -
- Can't save because your Google Drive seems to be full! -
-
; - } - - if(response?.req.url.match(/^\/api.*Google.*$/m)){ - return - Oops! -
- Looks like your Google credentials have - expired! Visit our log in page to sign out - and sign back in with Google, - then try saving again! -

- -
- Sign In -
-
-
- Not Now -
-
-
; - } - - if(HBErrorCode === '09') { - return - Oops! -
- Looks like there was a problem retreiving - the theme, or a theme that it inherits, - for this brew. Verify that brew - {response.body.brewId} still exists! -
-
; - } - - if(HBErrorCode === '10') { - return - Oops! -
- Looks like the brew you have selected - as a theme is not tagged for use as a - theme. Verify that - brew - {response.body.brewId} has the meta:theme tag! -
-
; - } - - if(errorCode === 'ECONNABORTED') { - return - Oops! -
- The request to the server was interrupted or timed out. - This can happen due to a network issue, or if - trying to save a particularly large brew. - Please check your internet connection and try again. -
-
; - } + let errMsg = ''; + try { + errMsg += `${error.toString()}\n\n`; + errMsg += `\`\`\`\n${error.stack}\n`; + errMsg += `${JSON.stringify(response?.error, null, ' ')}\n\`\`\``; + console.log(errMsg); + } catch (e){} + if(status === 409) { return Oops! -
- Looks like there was a problem saving.
- Report the issue - here - . +
+ {message ?? 'Conflict: please refresh to get latest changes'}
; } -}); + + if(status === 412) { + return + Oops! +
+ {message ?? 'Your client is out of date. Please save your changes elsewhere and refresh.'} +
+
; + } + + if(HBErrorCode === '04') { + return + Oops! +
+ You are no longer signed in as an author of + this brew! Were you signed out from a different + window? Visit our log in page, then try again! +

+ +
+ Sign In +
+
+
+ Not Now +
+
+
; + } + + if(response?.body?.errors?.[0].reason == 'storageQuotaExceeded') { + return + Oops! +
+ Can't save because your Google Drive seems to be full! +
+
; + } + + if(response?.req.url.match(/^\/api.*Google.*$/m)){ + return + Oops! +
+ Looks like your Google credentials have + expired! Visit our log in page to sign out + and sign back in with Google, + then try saving again! +

+ +
+ Sign In +
+
+
+ Not Now +
+
+
; + } + + if(HBErrorCode === '09') { + return + Oops! +
+ Looks like there was a problem retreiving + the theme, or a theme that it inherits, + for this brew. Verify that brew + {response.body.brewId} still exists! +
+
; + } + + if(HBErrorCode === '10') { + return + Oops! +
+ Looks like the brew you have selected + as a theme is not tagged for use as a + theme. Verify that + brew + {response.body.brewId} has the meta:theme tag! +
+
; + } + + if(errorCode === 'ECONNABORTED') { + return + Oops! +
+ The request to the server was interrupted or timed out. + This can happen due to a network issue, or if + trying to save a particularly large brew. + Please check your internet connection and try again. +
+
; + } + + return + Oops! +
+ Looks like there was a problem saving.
+ Report the issue + here + . +
+
; +}; module.exports = ErrorNavItem; diff --git a/client/homebrew/navbar/newbrew.navitem.jsx b/client/homebrew/navbar/newbrew.navitem.jsx index 30d53c675..ccade4e8b 100644 --- a/client/homebrew/navbar/newbrew.navitem.jsx +++ b/client/homebrew/navbar/newbrew.navitem.jsx @@ -5,33 +5,45 @@ const { splitTextStyleAndMetadata } = require('../../../shared/helpers.js'); // const BREWKEY = 'homebrewery-new'; const STYLEKEY = 'homebrewery-new-style'; -const METAKEY = 'homebrewery-new-meta'; +const METAKEY = 'homebrewery-new-meta'; const NewBrew = ()=>{ const handleFileChange = (e)=>{ const file = e.target.files[0]; - if(file) { - const reader = new FileReader(); - reader.onload = (e)=>{ - const fileContent = e.target.result; - const newBrew = { - text : fileContent, - style : '' - }; - if(fileContent.startsWith('```metadata')) { - splitTextStyleAndMetadata(newBrew); // Modify newBrew directly - localStorage.setItem(BREWKEY, newBrew.text); - localStorage.setItem(STYLEKEY, newBrew.style); - localStorage.setItem(METAKEY, JSON.stringify(_.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang']))); - window.location.href = '/new'; - } else { - alert('This file is invalid, please, enter a valid file'); - } - }; - reader.readAsText(file); - } + if(!file) return; + + const currentNew = localStorage.getItem(BREWKEY); + if(currentNew && !confirm( + `You have some text in the new brew space, if you load a file that text will be lost, are you sure you want to load the file?` + )) return; + + const reader = new FileReader(); + reader.onload = (e)=>{ + const fileContent = e.target.result; + const newBrew = { text: fileContent, style: '' }; + + if(fileContent.startsWith('```metadata')) { + splitTextStyleAndMetadata(newBrew); + localStorage.setItem(BREWKEY, newBrew.text); + localStorage.setItem(STYLEKEY, newBrew.style); + localStorage.setItem(METAKEY, JSON.stringify( + _.pick(newBrew, ['title', 'description', 'tags', 'systems', 'renderer', 'theme', 'lang']) + )); + window.location.href = '/new'; + return; + } + + const type = file.name.split('.').pop().toLowerCase(); + + alert(`This file is invalid: ${!type ? "Missing file extension" :`.${type} files are not supported`}. Only .txt files exported from the Homebrewery are allowed.`); + + + console.log(file); + }; + reader.readAsText(file); }; + return ( ( + brew.googleId && !brew.stubbed + ? brew.googleId + brew.shareId + : brew.shareId + ); + + const getRedditLink = (brew)=>{ + const text = dedent` + Hey guys! I've been working on this homebrew. I'd love your feedback. Check it out. + + **[Homebrewery Link](${global.config.baseUrl}/share/${getShareId(brew)})**`; + + return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(brew.title.toWellFormed())}&text=${encodeURIComponent(text)}`; + }; + +export default ({brew}) => ( + + + share + + + view + + {navigator.clipboard.writeText(`${global.config.baseUrl}/share/${getShareId(brew)}`);}}> + copy url + + + post to reddit + + +); diff --git a/client/homebrew/pages/basePages/uiPage/uiPage.less b/client/homebrew/pages/basePages/uiPage/uiPage.less index 39ccf1d74..f00b484c0 100644 --- a/client/homebrew/pages/basePages/uiPage/uiPage.less +++ b/client/homebrew/pages/basePages/uiPage/uiPage.less @@ -29,6 +29,7 @@ &::before { margin-right : 5px; font-family : 'Font Awesome 6 Free'; + font-weight : 900; content : '\f00c'; } } diff --git a/client/homebrew/pages/editPage/editPage.jsx b/client/homebrew/pages/editPage/editPage.jsx index 29dad9de0..6c2220ec1 100644 --- a/client/homebrew/pages/editPage/editPage.jsx +++ b/client/homebrew/pages/editPage/editPage.jsx @@ -1,529 +1,418 @@ /* eslint-disable max-lines */ -require('./editPage.less'); -const React = require('react'); -const _ = require('lodash'); -const createClass = require('create-react-class'); -import {makePatches, applyPatches, stringifyPatches, parsePatches} from '@sanity/diff-match-patch'; -import { md5 } from 'hash-wasm'; -import { gzipSync, strToU8 } from 'fflate'; +import './editPage.less'; -import request from '../../utils/request-middleware.js'; -const { Meta } = require('vitreum/headtags'); +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import request from '../../utils/request-middleware.js'; +import Markdown from 'naturalcrit/markdown.js'; -const Nav = require('naturalcrit/nav/nav.jsx'); -const Navbar = require('../../navbar/navbar.jsx'); +import _ from 'lodash';; +import { makePatches, stringifyPatches } from '@sanity/diff-match-patch'; +import { md5 } from 'hash-wasm'; +import { gzipSync, strToU8 } from 'fflate'; +import { Meta } from 'vitreum/headtags'; -const NewBrew = require('../../navbar/newbrew.navitem.jsx'); -const HelpNavItem = require('../../navbar/help.navitem.jsx'); -const PrintNavItem = require('../../navbar/print.navitem.jsx'); -const ErrorNavItem = require('../../navbar/error-navitem.jsx'); -const Account = require('../../navbar/account.navitem.jsx'); -const RecentNavItem = require('../../navbar/recent.navitem.jsx').both; -const VaultNavItem = require('../../navbar/vault.navitem.jsx'); +import Nav from 'naturalcrit/nav/nav.jsx'; +import Navbar from '../../navbar/navbar.jsx'; +import NewBrewItem from '../../navbar/newbrew.navitem.jsx'; +import AccountNavItem from '../../navbar/account.navitem.jsx'; +import ShareNavItem from '../../navbar/share.navitem.jsx'; +import ErrorNavItem from '../../navbar/error-navitem.jsx'; +import HelpNavItem from '../../navbar/help.navitem.jsx'; +import VaultNavItem from '../../navbar/vault.navitem.jsx'; +import PrintNavItem from '../../navbar/print.navitem.jsx'; +import { both as RecentNavItem } from '../../navbar/recent.navitem.jsx'; -const SplitPane = require('naturalcrit/splitPane/splitPane.jsx'); -const Editor = require('../../editor/editor.jsx'); -const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx'); +import SplitPane from 'client/components/splitPane/splitPane.jsx'; +import Editor from '../../editor/editor.jsx'; +import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; -const LockNotification = require('./lockNotification/lockNotification.jsx'); +import LockNotification from './lockNotification/lockNotification.jsx'; -import Markdown from 'naturalcrit/markdown.js'; - -const { DEFAULT_BREW_LOAD } = require('../../../../server/brewDefaults.js'); -const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js'); +import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js'; +import { printCurrentBrew, fetchThemeBundle } from '../../../../shared/helpers.js'; import { updateHistory, versionHistoryGarbageCollection } from '../../utils/versionHistory.js'; -const googleDriveIcon = require('../../googleDrive.svg'); +import googleDriveIcon from '../../googleDrive.svg'; const SAVE_TIMEOUT = 10000; +const UNSAVED_WARNING_TIMEOUT = 900000; //Warn user afer 15 minutes of unsaved changes +const UNSAVED_WARNING_POPUP_TIMEOUT = 4000; //Show the warning for 4 seconds -const EditPage = createClass({ - displayName : 'EditPage', - getDefaultProps : function() { - return { - brew : DEFAULT_BREW_LOAD - }; - }, +const EditPage = (props)=>{ + props = { + brew : DEFAULT_BREW_LOAD, + ...props + }; - getInitialState : function() { - return { - brew : this.props.brew, - isSaving : false, - unsavedChanges : false, - alertTrashedGoogleBrew : this.props.brew.trashed, - alertLoginToTransfer : false, - saveGoogle : this.props.brew.googleId ? true : false, - confirmGoogleTransfer : false, - error : null, - htmlErrors : Markdown.validate(this.props.brew.text), - url : '', - autoSave : true, - autoSaveWarning : false, - unsavedTime : new Date(), - currentEditorViewPageNum : 1, - currentEditorCursorPageNum : 1, - currentBrewRendererPageNum : 1, - displayLockMessage : this.props.brew.lock || false, - themeBundle : {} - }; - }, + const [currentBrew , setCurrentBrew ] = useState(props.brew); + const [isSaving , setIsSaving ] = useState(false); + const [lastSavedTime , setLastSavedTime ] = useState(new Date()); + const [saveGoogle , setSaveGoogle ] = useState(!!props.brew.googleId); + const [error , setError ] = useState(null); + const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text)); + const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1); + const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); + const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); + const [themeBundle , setThemeBundle ] = useState({}); + const [unsavedChanges , setUnsavedChanges ] = useState(false); + const [alertTrashedGoogleBrew , setAlertTrashedGoogleBrew ] = useState(props.brew.trashed); + const [alertLoginToTransfer , setAlertLoginToTransfer ] = useState(false); + const [confirmGoogleTransfer , setConfirmGoogleTransfer ] = useState(false); + const [autoSaveEnabled , setAutoSaveEnabled ] = useState(true); + const [warnUnsavedChanges , setWarnUnsavedChanges ] = useState(true); - editor : React.createRef(null), - savedBrew : null, + const editorRef = useRef(null); + const lastSavedBrew = useRef(_.cloneDeep(props.brew)); + const saveTimeout = useRef(null); + const warnUnsavedTimeout = useRef(null); + const trySaveRef = useRef(trySave); // CTRL+S listener lives outside React and needs ref to use trySave with latest copy of brew + const unsavedChangesRef = useRef(unsavedChanges); // Similarly, onBeforeUnload lives outside React and needs ref to unsavedChanges - componentDidMount : function(){ - this.setState({ - url : window.location.href - }); + useEffect(()=>{ + const autoSavePref = JSON.parse(localStorage.getItem('AUTOSAVE_ON') ?? true); + setAutoSaveEnabled(autoSavePref); + setWarnUnsavedChanges(!autoSavePref); + setHTMLErrors(Markdown.validate(currentBrew.text)); + fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme); - this.savedBrew = JSON.parse(JSON.stringify(this.props.brew)); //Deep copy - - this.setState({ autoSave: JSON.parse(localStorage.getItem('AUTOSAVE_ON')) ?? true }, ()=>{ - if(this.state.autoSave){ - this.trySave(); - } else { - this.setState({ autoSaveWarning: true }); + const handleControlKeys = (e)=>{ + if(!(e.ctrlKey || e.metaKey)) return; + if(e.keyCode === 83) trySaveRef.current(true); + if(e.keyCode === 80) printCurrentBrew(); + if([83, 80].includes(e.keyCode)) { + e.stopPropagation(); + e.preventDefault(); } - }); + }; + document.addEventListener('keydown', handleControlKeys); window.onbeforeunload = ()=>{ - if(this.state.isSaving || this.state.unsavedChanges){ + if(unsavedChangesRef.current) return 'You have unsaved changes!'; - } }; + return ()=>{ + document.removeEventListener('keydown', handleControlKeys); + window.onBeforeUnload = null; + }; + }, []); - this.setState((prevState)=>({ - htmlErrors : Markdown.validate(prevState.brew.text) - })); + useEffect(()=>{ + trySaveRef.current = trySave; + unsavedChangesRef.current = unsavedChanges; + }); - fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme); + useEffect(()=>{ + const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current); + setUnsavedChanges(hasChange); - document.addEventListener('keydown', this.handleControlKeys); - }, - componentWillUnmount : function() { - window.onbeforeunload = function(){}; - document.removeEventListener('keydown', this.handleControlKeys); - }, - componentDidUpdate : function(){ - const hasChange = this.hasChanges(); - if(this.state.unsavedChanges != hasChange){ - this.setState({ - unsavedChanges : hasChange - }); - } - }, + if(autoSaveEnabled) trySave(false, hasChange); + }, [currentBrew]); - handleControlKeys : function(e){ - if(!(e.ctrlKey || e.metaKey)) return; - const S_KEY = 83; - const P_KEY = 80; - if(e.keyCode == S_KEY) this.trySave(true); - if(e.keyCode == P_KEY) printCurrentBrew(); - if(e.keyCode == P_KEY || e.keyCode == S_KEY){ - e.stopPropagation(); - e.preventDefault(); - } - }, + const handleSplitMove = ()=>{ + editorRef.current?.update(); + }; - handleSplitMove : function(){ - this.editor.current.update(); - }, + const handleEditorViewPageChange = (pageNumber)=>{ + setCurrentEditorViewPageNum(pageNumber); + }; - handleEditorViewPageChange : function(pageNumber){ - this.setState({ currentEditorViewPageNum: pageNumber }); - }, + const handleEditorCursorPageChange = (pageNumber)=>{ + setCurrentEditorCursorPageNum(pageNumber); + }; - handleEditorCursorPageChange : function(pageNumber){ - this.setState({ currentEditorCursorPageNum: pageNumber }); - }, + const handleBrewRendererPageChange = (pageNumber)=>{ + setCurrentBrewRendererPageNum(pageNumber); + }; - handleBrewRendererPageChange : function(pageNumber){ - this.setState({ currentBrewRendererPageNum: pageNumber }); - }, + const handleTextChange = (text)=>{ + //If there are HTML errors, run the validator on every change to give quick feedback + if(HTMLErrors.length) + setHTMLErrors(Markdown.validate(text)); + setCurrentBrew((prevBrew)=>({ ...prevBrew, text })); + }; - handleTextChange : function(text){ - //If there are errors, run the validator on every change to give quick feedback - let htmlErrors = this.state.htmlErrors; - if(htmlErrors.length) htmlErrors = Markdown.validate(text); + const handleStyleChange = (style)=>{ + setCurrentBrew((prevBrew)=>({ ...prevBrew, style })); + }; - this.setState((prevState)=>({ - brew : { ...prevState.brew, text: text }, - htmlErrors : htmlErrors, - }), ()=>{if(this.state.autoSave) this.trySave();}); - }, + const handleSnipChange = (snippet)=>{ + //If there are HTML errors, run the validator on every change to give quick feedback + if(HTMLErrors.length) + setHTMLErrors(Markdown.validate(snippet)); + setCurrentBrew((prevBrew)=>({ ...prevBrew, snippets: snippet })); + }; - handleSnipChange : function(snippet){ - //If there are errors, run the validator on every change to give quick feedback - let htmlErrors = this.state.htmlErrors; - if(htmlErrors.length) htmlErrors = Markdown.validate(snippet); + const handleMetaChange = (metadata, field = undefined)=>{ + if(field === 'theme' || field === 'renderer') + fetchThemeBundle(setError, setThemeBundle, metadata.renderer, metadata.theme); - this.setState((prevState)=>({ - brew : { ...prevState.brew, snippets: snippet }, - unsavedChanges : true, - htmlErrors : htmlErrors, - }), ()=>{if(this.state.autoSave) this.trySave();}); - }, + setCurrentBrew((prev)=>({ ...prev, ...metadata })); + }; - handleStyleChange : function(style){ - this.setState((prevState)=>({ - brew : { ...prevState.brew, style: style } - }), ()=>{if(this.state.autoSave) this.trySave();}); - }, + const updateBrew = (newData)=>setCurrentBrew((prevBrew)=>({ + ...prevBrew, + style : newData.style, + text : newData.text, + snippets : newData.snippets + })); - handleMetaChange : function(metadata, field=undefined){ - if(field == 'theme' || field == 'renderer') // Fetch theme bundle only if theme or renderer was changed - fetchThemeBundle(this, metadata.renderer, metadata.theme); + const resetWarnUnsavedTimer = ()=>{ + setTimeout(()=>setWarnUnsavedChanges(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds + clearTimeout(warnUnsavedTimeout.current); + warnUnsavedTimeout.current = setTimeout(()=>setWarnUnsavedChanges(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved work warnings + }; - this.setState((prevState)=>({ - brew : { - ...prevState.brew, - ...metadata - } - }), ()=>{if(this.state.autoSave) this.trySave();}); - }, - - hasChanges : function(){ - return !_.isEqual(this.state.brew, this.savedBrew); - }, - - updateBrew : function(newData){ - this.setState((prevState)=>({ - brew : { - ...prevState.brew, - style : newData.style, - text : newData.text, - snippets : newData.snippets - } - })); - }, - - trySave : function(immediate=false){ - if(!this.debounceSave) this.debounceSave = _.debounce(this.save, SAVE_TIMEOUT); - if(this.state.isSaving) - return; - - if(immediate) { - this.debounceSave(); - this.debounceSave.flush(); - return; - } - - if(this.hasChanges()) - this.debounceSave(); - else - this.debounceSave.cancel(); - }, - - handleGoogleClick : function(){ + const handleGoogleClick = ()=>{ if(!global.account?.googleId) { - this.setState({ - alertLoginToTransfer : true - }); + setAlertLoginToTransfer(true); return; } - this.setState((prevState)=>({ - confirmGoogleTransfer : !prevState.confirmGoogleTransfer - })); - this.setState({ - error : null - }); - }, - closeAlerts : function(event){ - event.stopPropagation(); //Only handle click once so alert doesn't reopen - this.setState({ - alertTrashedGoogleBrew : false, - alertLoginToTransfer : false, - confirmGoogleTransfer : false - }); - }, + setConfirmGoogleTransfer((prev)=>!prev); + setError(null); + }; - toggleGoogleStorage : function(){ - this.setState((prevState)=>({ - saveGoogle : !prevState.saveGoogle, - error : null - }), ()=>this.trySave(true)); - }, + const closeAlerts = (e)=>{ + e.stopPropagation(); //Only handle click once so alert doesn't reopen + setAlertTrashedGoogleBrew(false); + setAlertLoginToTransfer(false); + setConfirmGoogleTransfer(false); + }; - save : async function(){ - if(this.debounceSave && this.debounceSave.cancel) this.debounceSave.cancel(); + const toggleGoogleStorage = ()=>{ + setSaveGoogle((prev)=>!prev); + setError(null); + trySave(true); + }; - const brewState = this.state.brew; // freeze the current state - const preSaveSnapshot = { ...brewState }; + const trySave = (immediate = false, hasChanges = true)=>{ + clearTimeout(saveTimeout.current); + if(isSaving) return; + if(!hasChanges && !immediate) return; + const newTimeout = immediate ? 0 : SAVE_TIMEOUT; - this.setState((prevState)=>({ - isSaving : true, - error : null, - htmlErrors : Markdown.validate(prevState.brew.text) - })); + saveTimeout.current = setTimeout(async ()=>{ + setIsSaving(true); + setError(null); + await save(currentBrew, saveGoogle) + .catch((err)=>{ + setError(err); + }); + setIsSaving(false); + setLastSavedTime(new Date()); + if(!autoSaveEnabled) resetWarnUnsavedTimer(); + }, newTimeout); + }; - await updateHistory(this.state.brew).catch(console.error); + const save = async (brew, saveToGoogle)=>{ + setHTMLErrors(Markdown.validate(brew.text)); + + await updateHistory(brew).catch(console.error); await versionHistoryGarbageCollection().catch(console.error); //Prepare content to send to server - const brew = { ...brewState }; - brew.text = brew.text.normalize('NFC'); - this.savedBrew.text = this.savedBrew.text.normalize('NFC'); - brew.pageCount = ((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1; - brew.patches = stringifyPatches(makePatches(encodeURI(this.savedBrew.text), encodeURI(brew.text))); - brew.hash = await md5(this.savedBrew.text); - //brew.text = undefined; - Temporary parallel path - brew.textBin = undefined; + const brewToSave = { + ...brew, + text : brew.text.normalize('NFC'), + pageCount : ((brew.renderer === 'legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1, + patches : stringifyPatches(makePatches(encodeURI(lastSavedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))), + hash : await md5(lastSavedBrew.current.text), + textBin : undefined, + version : lastSavedBrew.current.version + }; - const compressedBrew = gzipSync(strToU8(JSON.stringify(brew))); + const compressedBrew = gzipSync(strToU8(JSON.stringify(brewToSave))); + const transfer = saveToGoogle === _.isNil(brew.googleId); + const params = transfer ? `?${saveToGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : ''; - const transfer = this.state.saveGoogle == _.isNil(this.state.brew.googleId); - const params = `${transfer ? `?${this.state.saveGoogle ? 'saveToGoogle' : 'removeFromGoogle'}=true` : ''}`; const res = await request - .put(`/api/update/${brew.editId}${params}`) + .put(`/api/update/${brewToSave.editId}${params}`) .set('Content-Encoding', 'gzip') .set('Content-Type', 'application/json') .send(compressedBrew) .catch((err)=>{ - console.log('Error Updating Local Brew'); - this.setState({ error: err }); + console.error('Error Updating Local Brew'); + setError(err); }); if(!res) return; - this.savedBrew = { - ...preSaveSnapshot, - googleId : res.body.googleId ? res.body.googleId : null, - editId : res.body.editId, + const updatedFields = { + googleId : res.body.googleId ?? null, + editId : res.body.editId, shareId : res.body.shareId, version : res.body.version }; - this.setState((prevState) => ({ - brew: { - ...prevState.brew, - googleId : res.body.googleId ? res.body.googleId : null, - editId : res.body.editId, - shareId : res.body.shareId, - version : res.body.version - }, - isSaving : false, - unsavedTime : new Date() - }), ()=>{ - this.setState({ unsavedChanges : this.hasChanges() }); - }); + lastSavedBrew.current = { + ...brew, + ...updatedFields + }; - history.replaceState(null, null, `/edit/${this.savedBrew.editId}`); - }, + setCurrentBrew((prevBrew)=>({ + ...prevBrew, + ...updatedFields + })); - renderGoogleDriveIcon : function(){ - return - Google Drive icon + history.replaceState(null, null, `/edit/${res.body.editId}`); + }; - {this.state.confirmGoogleTransfer && -
- { this.state.saveGoogle - ? `Would you like to transfer this brew from your Google Drive storage back to the Homebrewery?` - : `Would you like to transfer this brew from the Homebrewery to your personal Google Drive storage?` - } + const renderGoogleDriveIcon = ()=>( + + Google Drive icon + + {confirmGoogleTransfer && ( +
+ {saveGoogle + ? 'Would you like to transfer this brew from your Google Drive storage back to the Homebrewery?' + : 'Would you like to transfer this brew from the Homebrewery to your personal Google Drive storage?'}
-
- Yes -
-
- No -
+
Yes
+
No
- } + )} - {this.state.alertLoginToTransfer && -
- You must be signed in to a Google account to transfer - between the homebrewery and Google Drive! - -
- Sign In -
+ {alertLoginToTransfer && ( +
- } + )} - {this.state.alertTrashedGoogleBrew && -
- This brew is currently in your Trash folder on Google Drive!
If you want to keep it, make sure to move it before it is deleted permanently!
-
- OK -
+ {alertTrashedGoogleBrew && ( +
+ This brew is currently in your Trash folder on Google Drive!
+ If you want to keep it, make sure to move it before it is deleted permanently!
+
OK
- } - ; - }, - - renderSaveButton : function(){ + )} + + ); + const renderSaveButton = ()=>{ // #1 - Currently saving, show SAVING - if(this.state.isSaving){ + if(isSaving) return saving...; - } // #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING - if(this.state.unsavedChanges && this.state.autoSaveWarning){ - this.setAutosaveWarning(); - const elapsedTime = Math.round((new Date() - this.state.unsavedTime) / 1000 / 60); - const text = elapsedTime == 0 ? 'Autosave is OFF.' : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`; + if(unsavedChanges && warnUnsavedChanges) { + resetWarnUnsavedTimer(); + const elapsedTime = Math.round((new Date() - lastSavedTime) / 1000 / 60); + const text = elapsedTime === 0 + ? 'Autosave is OFF.' + : `Autosave is OFF, and you haven't saved for ${elapsedTime} minutes.`; return - Reminder... -
- {text} -
+ Reminder... +
{text}
; } // #3 - Unsaved changes exist, click to save, show SAVE NOW - // Use trySave(true) instead of save() to use debounced save function - if(this.state.unsavedChanges){ - return this.trySave(true)} color='blue' icon='fas fa-save'>Save Now; - } + if(unsavedChanges) + return trySave(true)} color='blue' icon='fas fa-save'>Save Now; + // #4 - No unsaved changes, autosave is ON, show AUTO-SAVED - if(this.state.autoSave){ + if(autoSaveEnabled) return auto-saved.; - } + // DEFAULT - No unsaved changes, show SAVED return saved.; - }, + }; - handleAutoSave : function(){ - if(this.warningTimer) clearTimeout(this.warningTimer); - this.setState((prevState)=>({ - autoSave : !prevState.autoSave, - autoSaveWarning : prevState.autoSave - }), ()=>{ - localStorage.setItem('AUTOSAVE_ON', JSON.stringify(this.state.autoSave)); - }); - }, + const toggleAutoSave = ()=>{ + clearTimeout(warnUnsavedTimeout.current); + clearTimeout(saveTimeout.current); + localStorage.setItem('AUTOSAVE_ON', JSON.stringify(!autoSaveEnabled)); + setAutoSaveEnabled(!autoSaveEnabled); + setWarnUnsavedChanges(autoSaveEnabled); + }; - setAutosaveWarning : function(){ - setTimeout(()=>this.setState({ autoSaveWarning: false }), 4000); // 4 seconds to display - this.warningTimer = setTimeout(()=>{this.setState({ autoSaveWarning: true });}, 900000); // 15 minutes between warnings - this.warningTimer; - }, + const renderAutoSaveButton = ()=>( + + Autosave + + ); - errorReported : function(error) { - this.setState({ - error - }); - }, - - renderAutoSaveButton : function(){ - return - Autosave - ; - }, - - processShareId : function() { - return this.state.brew.googleId && !this.state.brew.stubbed ? - this.state.brew.googleId + this.state.brew.shareId : - this.state.brew.shareId; - }, - - getRedditLink : function(){ - - const shareLink = this.processShareId(); - const systems = this.props.brew.systems.length > 0 ? ` [${this.props.brew.systems.join(' - ')}]` : ''; - const title = `${this.props.brew.title} ${systems}`; - const text = `Hey guys! I've been working on this homebrew. I'd love your feedback. Check it out. - -**[Homebrewery Link](${global.config.baseUrl}/share/${shareLink})**`; - - return `https://www.reddit.com/r/UnearthedArcana/submit?title=${encodeURIComponent(title.toWellFormed())}&text=${encodeURIComponent(text)}`; - }, - - renderNavbar : function(){ - const shareLink = this.processShareId(); + const clearError = ()=>{ + setError(null); + setIsSaving(false); + }; + const renderNavbar = ()=>{ return - {this.state.brew.title} + {currentBrew.title} - {this.renderGoogleDriveIcon()} - {this.state.error ? - : - - {this.renderSaveButton()} - {this.renderAutoSaveButton()} - - } - + {renderGoogleDriveIcon()} + {error + ? + : + {renderSaveButton()} + {renderAutoSaveButton()} + } + - - - share - - - view - - {navigator.clipboard.writeText(`${global.config.baseUrl}/share/${shareLink}`);}}> - copy url - - - post to reddit - - + - - + + - ; - }, + }; - render : function(){ - return
+ return ( +
- {this.renderNavbar()} - {this.props.brew.lock && } + {renderNavbar()} + + {currentBrew.lock && } +
- +
-
; - } -}); +
+ ); +}; module.exports = EditPage; diff --git a/client/homebrew/pages/homePage/homePage.jsx b/client/homebrew/pages/homePage/homePage.jsx index d03e30c91..84967b1ff 100644 --- a/client/homebrew/pages/homePage/homePage.jsx +++ b/client/homebrew/pages/homePage/homePage.jsx @@ -1,90 +1,91 @@ -require('./homePage.less'); -const React = require('react'); -const createClass = require('create-react-class'); -const cx = require('classnames'); -import request from '../../utils/request-middleware.js'; -const { Meta } = require('vitreum/headtags'); +import './homePage.less'; -const Nav = require('naturalcrit/nav/nav.jsx'); -const Navbar = require('../../navbar/navbar.jsx'); -const NewBrewItem = require('../../navbar/newbrew.navitem.jsx'); -const HelpNavItem = require('../../navbar/help.navitem.jsx'); -const VaultNavItem = require('../../navbar/vault.navitem.jsx'); -const RecentNavItem = require('../../navbar/recent.navitem.jsx').both; -const AccountNavItem = require('../../navbar/account.navitem.jsx'); -const ErrorNavItem = require('../../navbar/error-navitem.jsx'); -const { fetchThemeBundle } = require('../../../../shared/helpers.js'); +import React from 'react'; +import { useEffect, useState, useRef } from 'react'; +import request from '../../utils/request-middleware.js'; +import { Meta } from 'vitreum/headtags'; -const SplitPane = require('naturalcrit/splitPane/splitPane.jsx'); -const Editor = require('../../editor/editor.jsx'); -const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx'); +import Nav from 'naturalcrit/nav/nav.jsx'; +import Navbar from '../../navbar/navbar.jsx'; +import NewBrewItem from '../../navbar/newbrew.navitem.jsx'; +import HelpNavItem from '../../navbar/help.navitem.jsx'; +import VaultNavItem from '../../navbar/vault.navitem.jsx'; +import { both as RecentNavItem } from '../../navbar/recent.navitem.jsx'; +import AccountNavItem from '../../navbar/account.navitem.jsx'; +import ErrorNavItem from '../../navbar/error-navitem.jsx'; +import { fetchThemeBundle } from '../../../../shared/helpers.js'; -const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js'); +import SplitPane from 'client/components/splitPane/splitPane.jsx'; +import Editor from '../../editor/editor.jsx'; +import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; -const HomePage = createClass({ - displayName : 'HomePage', - getDefaultProps : function() { - return { - brew : DEFAULT_BREW, - ver : '0.0.0' - }; - }, - getInitialState : function() { - return { - brew : this.props.brew, - welcomeText : this.props.brew.text, - error : undefined, - currentEditorViewPageNum : 1, - currentEditorCursorPageNum : 1, - currentBrewRendererPageNum : 1, - themeBundle : {} - }; - }, +import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; - editor : React.createRef(null), +const HomePage =(props)=>{ + props = { + brew : DEFAULT_BREW, + ver : '0.0.0', + ...props + }; - componentDidMount : function() { - fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme); - }, + const [brew , setBrew] = useState(props.brew); + const [welcomeText , setWelcomeText] = useState(props.brew.text); + const [error , setError] = useState(undefined); + const [currentEditorViewPageNum , setCurrentEditorViewPageNum] = useState(1); + const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); + const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); + const [themeBundle , setThemeBundle] = useState({}); + const [isSaving , setIsSaving] = useState(false); - handleSave : function(){ + const editorRef = useRef(null); + + useEffect(()=>{ + fetchThemeBundle(setError, setThemeBundle, brew.renderer, brew.theme); + }, []); + + const save = ()=>{ request.post('/api') - .send(this.state.brew) + .send(brew) .end((err, res)=>{ if(err) { - this.setState({ error: err }); + setError(err); return; } - const brew = res.body; - window.location = `/edit/${brew.editId}`; + const saved = res.body; + window.location = `/edit/${saved.editId}`; }); - }, - handleSplitMove : function(){ - this.editor.current.update(); - }, + }; - handleEditorViewPageChange : function(pageNumber){ - this.setState({ currentEditorViewPageNum: pageNumber }); - }, + const handleSplitMove = ()=>{ + editorRef.current.update(); + }; - handleEditorCursorPageChange : function(pageNumber){ - this.setState({ currentEditorCursorPageNum: pageNumber }); - }, + const handleEditorViewPageChange = (pageNumber)=>{ + setCurrentEditorViewPageNum(pageNumber); + }; + + const handleEditorCursorPageChange = (pageNumber)=>{ + setCurrentEditorCursorPageNum(pageNumber); + }; + + const handleBrewRendererPageChange = (pageNumber)=>{ + setCurrentBrewRendererPageNum(pageNumber); + }; - handleBrewRendererPageChange : function(pageNumber){ - this.setState({ currentBrewRendererPageNum: pageNumber }); - }, + const handleTextChange = (text)=>{ + setBrew((prevBrew) => ({ ...prevBrew, text })); + }; - handleTextChange : function(text){ - this.setState((prevState)=>({ - brew : { ...prevState.brew, text: text }, - })); - }, - renderNavbar : function(){ - return + const clearError = ()=>{ + setError(null); + setIsSaving(false); + }; + + const renderNavbar = ()=>{ + return - {this.state.error ? - : + {error ? + : null } @@ -94,48 +95,48 @@ const HomePage = createClass({ ; - }, + }; - render : function(){ - return
+ return ( +
- {this.renderNavbar()} + {renderNavbar()}
- +
-
+
Save current
Create your own -
; - } -}); +
+ ) +}; module.exports = HomePage; diff --git a/client/homebrew/pages/newPage/newPage.jsx b/client/homebrew/pages/newPage/newPage.jsx index 64fac86c0..bb21441cf 100644 --- a/client/homebrew/pages/newPage/newPage.jsx +++ b/client/homebrew/pages/newPage/newPage.jsx @@ -1,275 +1,251 @@ /*eslint max-lines: ["warn", {"max": 300, "skipBlankLines": true, "skipComments": true}]*/ -require('./newPage.less'); -const React = require('react'); -const createClass = require('create-react-class'); -import request from '../../utils/request-middleware.js'; +import './newPage.less'; -import Markdown from 'naturalcrit/markdown.js'; +import React, { useState, useEffect, useRef } from 'react'; +import request from '../../utils/request-middleware.js'; +import Markdown from 'naturalcrit/markdown.js'; -const Nav = require('naturalcrit/nav/nav.jsx'); -const PrintNavItem = require('../../navbar/print.navitem.jsx'); -const Navbar = require('../../navbar/navbar.jsx'); -const AccountNavItem = require('../../navbar/account.navitem.jsx'); -const ErrorNavItem = require('../../navbar/error-navitem.jsx'); -const RecentNavItem = require('../../navbar/recent.navitem.jsx').both; -const HelpNavItem = require('../../navbar/help.navitem.jsx'); +import Nav from 'naturalcrit/nav/nav.jsx'; +import Navbar from '../../navbar/navbar.jsx'; +import AccountNavItem from '../../navbar/account.navitem.jsx'; +import ErrorNavItem from '../../navbar/error-navitem.jsx'; +import HelpNavItem from '../../navbar/help.navitem.jsx'; +import PrintNavItem from '../../navbar/print.navitem.jsx'; +import { both as RecentNavItem } from '../../navbar/recent.navitem.jsx'; -const SplitPane = require('naturalcrit/splitPane/splitPane.jsx'); -const Editor = require('../../editor/editor.jsx'); -const BrewRenderer = require('../../brewRenderer/brewRenderer.jsx'); +import SplitPane from 'client/components/splitPane/splitPane.jsx'; +import Editor from '../../editor/editor.jsx'; +import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; -const { DEFAULT_BREW } = require('../../../../server/brewDefaults.js'); -const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpers.js'); +import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; +import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '../../../../shared/helpers.js'; const BREWKEY = 'homebrewery-new'; const STYLEKEY = 'homebrewery-new-style'; const METAKEY = 'homebrewery-new-meta'; -let SAVEKEY; +const SAVEKEY = `HOMEBREWERY-DEFAULT-SAVE-LOCATION-${global.account?.username || ''}`; +const NewPage = (props) => { + props = { + brew: DEFAULT_BREW, + ...props + }; -const NewPage = createClass({ - displayName : 'NewPage', - getDefaultProps : function() { - return { - brew : DEFAULT_BREW + const [currentBrew , setCurrentBrew ] = useState(props.brew); + const [isSaving , setIsSaving ] = useState(false); + const [saveGoogle , setSaveGoogle ] = useState(global.account?.googleId ? true : false); + const [error , setError ] = useState(null); + const [HTMLErrors , setHTMLErrors ] = useState(Markdown.validate(props.brew.text)); + const [currentEditorViewPageNum , setCurrentEditorViewPageNum ] = useState(1); + const [currentEditorCursorPageNum, setCurrentEditorCursorPageNum] = useState(1); + const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); + const [themeBundle , setThemeBundle ] = useState({}); + + const editorRef = useRef(null); + + useEffect(() => { + document.addEventListener('keydown', handleControlKeys); + loadBrew(); + fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme); + + return () => { + document.removeEventListener('keydown', handleControlKeys); }; - }, + }, []); - getInitialState : function() { - const brew = this.props.brew; - - return { - brew : brew, - isSaving : false, - saveGoogle : (global.account && global.account.googleId ? true : false), - error : null, - htmlErrors : Markdown.validate(brew.text), - currentEditorViewPageNum : 1, - currentEditorCursorPageNum : 1, - currentBrewRendererPageNum : 1, - themeBundle : {} - }; - }, - - editor : React.createRef(null), - - componentDidMount : function() { - document.addEventListener('keydown', this.handleControlKeys); - - const brew = this.state.brew; - - if(!this.props.brew.shareId && typeof window !== 'undefined') { //Load from localStorage if in client browser + const loadBrew = ()=>{ + const brew = { ...currentBrew }; + if(!brew.shareId && typeof window !== 'undefined') { //Load from localStorage if in client browser const brewStorage = localStorage.getItem(BREWKEY); const styleStorage = localStorage.getItem(STYLEKEY); - const metaStorage = JSON.parse(localStorage.getItem(METAKEY)); + const metaStorage = JSON.parse(localStorage.getItem(METAKEY)); - brew.text = brewStorage ?? brew.text; - brew.style = styleStorage ?? brew.style; - // brew.title = metaStorage?.title || this.state.brew.title; - // brew.description = metaStorage?.description || this.state.brew.description; + brew.text = brewStorage ?? brew.text; + brew.style = styleStorage ?? brew.style; brew.renderer = metaStorage?.renderer ?? brew.renderer; brew.theme = metaStorage?.theme ?? brew.theme; brew.lang = metaStorage?.lang ?? brew.lang; } - SAVEKEY = `HOMEBREWERY-DEFAULT-SAVE-LOCATION-${global.account?.username || ''}`; const saveStorage = localStorage.getItem(SAVEKEY) || 'HOMEBREWERY'; - this.setState({ - brew : brew, - saveGoogle : (saveStorage == 'GOOGLE-DRIVE' && this.state.saveGoogle) - }); - - fetchThemeBundle(this, this.props.brew.renderer, this.props.brew.theme); + setCurrentBrew(brew); + setSaveGoogle(saveStorage == 'GOOGLE-DRIVE' && saveGoogle); localStorage.setItem(BREWKEY, brew.text); if(brew.style) localStorage.setItem(STYLEKEY, brew.style); - localStorage.setItem(METAKEY, JSON.stringify({ 'renderer': brew.renderer, 'theme': brew.theme, 'lang': brew.lang })); - if(window.location.pathname != '/new') { + localStorage.setItem(METAKEY, JSON.stringify({ renderer: brew.renderer, theme: brew.theme, lang: brew.lang })); + if(window.location.pathname !== '/new') window.history.replaceState({}, window.location.title, '/new/'); - } - }, - componentWillUnmount : function() { - document.removeEventListener('keydown', this.handleControlKeys); - }, + }; - handleControlKeys : function(e){ - if(!(e.ctrlKey || e.metaKey)) return; + const handleControlKeys = (e) => { + if (!(e.ctrlKey || e.metaKey)) return; const S_KEY = 83; const P_KEY = 80; - if(e.keyCode == S_KEY) this.save(); - if(e.keyCode == P_KEY) printCurrentBrew(); - if(e.keyCode == P_KEY || e.keyCode == S_KEY){ - e.stopPropagation(); + if (e.keyCode === S_KEY) save(); + if (e.keyCode === P_KEY) printCurrentBrew(); + if (e.keyCode === S_KEY || e.keyCode === P_KEY) { e.preventDefault(); + e.stopPropagation(); } - }, + }; - handleSplitMove : function(){ - this.editor.current.update(); - }, + const handleSplitMove = ()=>{ + editorRef.current.update(); + }; - handleEditorViewPageChange : function(pageNumber){ - this.setState({ currentEditorViewPageNum: pageNumber }); - }, + const handleEditorViewPageChange = (pageNumber)=>{ + setCurrentEditorViewPageNum(pageNumber); + }; + + const handleEditorCursorPageChange = (pageNumber)=>{ + setCurrentEditorCursorPageNum(pageNumber); + }; + + const handleBrewRendererPageChange = (pageNumber)=>{ + setCurrentBrewRendererPageNum(pageNumber); + }; - handleEditorCursorPageChange : function(pageNumber){ - this.setState({ currentEditorCursorPageNum: pageNumber }); - }, + const handleTextChange = (text)=>{ + //If there are HTML errors, run the validator on every change to give quick feedback + if(HTMLErrors.length) + HTMLErrors = Markdown.validate(text); - handleBrewRendererPageChange : function(pageNumber){ - this.setState({ currentBrewRendererPageNum: pageNumber }); - }, - - handleTextChange : function(text){ - //If there are errors, run the validator on every change to give quick feedback - let htmlErrors = this.state.htmlErrors; - if(htmlErrors.length) htmlErrors = Markdown.validate(text); - - this.setState((prevState)=>({ - brew : { ...prevState.brew, text: text }, - htmlErrors : htmlErrors, - })); + setHTMLErrors(HTMLErrors); + setCurrentBrew((prevBrew) => ({ ...prevBrew, text })); localStorage.setItem(BREWKEY, text); - }, + }; - handleStyleChange : function(style){ - this.setState((prevState)=>({ - brew : { ...prevState.brew, style: style }, - })); + const handleStyleChange = (style) => { + setCurrentBrew(prevBrew => ({ ...prevBrew, style })); localStorage.setItem(STYLEKEY, style); - }, + }; - handleSnipChange : function(snippet){ - //If there are errors, run the validator on every change to give quick feedback - let htmlErrors = this.state.htmlErrors; - if(htmlErrors.length) htmlErrors = Markdown.validate(snippet); + const handleSnipChange = (snippet)=>{ + //If there are HTML errors, run the validator on every change to give quick feedback + if(HTMLErrors.length) + HTMLErrors = Markdown.validate(snippet); - this.setState((prevState)=>({ - brew : { ...prevState.brew, snippets: snippet }, - htmlErrors : htmlErrors, - }), ()=>{if(this.state.autoSave) this.trySave();}); - }, + setHTMLErrors(HTMLErrors); + setCurrentBrew((prevBrew) => ({ ...prevBrew, snippets: snippet })); + }; - handleMetaChange : function(metadata, field=undefined){ - if(field == 'theme' || field == 'renderer') // Fetch theme bundle only if theme or renderer was changed - fetchThemeBundle(this, metadata.renderer, metadata.theme); + const handleMetaChange = (metadata, field = undefined) => { + if (field === 'theme' || field === 'renderer') + fetchThemeBundle(setError, setThemeBundle, metadata.renderer, metadata.theme); - this.setState((prevState)=>({ - brew : { ...prevState.brew, ...metadata }, - }), ()=>{ - localStorage.setItem(METAKEY, JSON.stringify({ - // 'title' : this.state.brew.title, - // 'description' : this.state.brew.description, - 'renderer' : this.state.brew.renderer, - 'theme' : this.state.brew.theme, - 'lang' : this.state.brew.lang - })); - }); - ; - }, + setCurrentBrew(prev => ({ ...prev, ...metadata })); + localStorage.setItem(METAKEY, JSON.stringify({ + renderer : metadata.renderer, + theme : metadata.theme, + lang : metadata.lang + })); + }; - save : async function(){ - this.setState({ - isSaving : true - }); + const save = async () => { + setIsSaving(true); - let brew = this.state.brew; - // Split out CSS to Style if CSS codefence exists - if(brew.text.startsWith('```css') && brew.text.indexOf('```\n\n') > 0) { - const index = brew.text.indexOf('```\n\n'); - brew.style = `${brew.style ? `${brew.style}\n` : ''}${brew.text.slice(7, index - 1)}`; - brew.text = brew.text.slice(index + 5); - } + let updatedBrew = { ...currentBrew }; + splitTextStyleAndMetadata(updatedBrew); + + const pageRegex = updatedBrew.renderer === 'legacy' ? /\\page/g : /^\\page$/gm; + updatedBrew.pageCount = (updatedBrew.text.match(pageRegex) || []).length + 1; - brew.pageCount=((brew.renderer=='legacy' ? brew.text.match(/\\page/g) : brew.text.match(/^\\page$/gm)) || []).length + 1; const res = await request - .post(`/api${this.state.saveGoogle ? '?saveToGoogle=true' : ''}`) - .send(brew) - .catch((err)=>{ - this.setState({ isSaving: false, error: err }); + .post(`/api${saveGoogle ? '?saveToGoogle=true' : ''}`) + .send(updatedBrew) + .catch((err) => { + setIsSaving(false); + setError(err); }); - if(!res) return; - brew = res.body; + setIsSaving(false) + if (!res) return; + + const savedBrew = res.body; + localStorage.removeItem(BREWKEY); localStorage.removeItem(STYLEKEY); localStorage.removeItem(METAKEY); - window.location = `/edit/${brew.editId}`; - }, + window.location = `/edit/${savedBrew.editId}`; + }; - renderSaveButton : function(){ - if(this.state.isSaving){ + const renderSaveButton = ()=>{ + if(isSaving){ return save... ; } else { - return + return save ; } - }, + }; - renderNavbar : function(){ - return + const clearError = ()=>{ + setError(null); + setIsSaving(false); + }; + const renderNavbar = () => ( + - {this.state.brew.title} + {currentBrew.title} - {this.state.error ? - : - this.renderSaveButton() - } + {error + ? + : renderSaveButton()} - ; - }, + + ); - render : function(){ - return
- {this.renderNavbar()} + return ( +
+ {renderNavbar()}
- +
-
; - } -}); +
+ ); +}; module.exports = NewPage; diff --git a/client/homebrew/pages/sharePage/sharePage.jsx b/client/homebrew/pages/sharePage/sharePage.jsx index e9c5540a2..50104a665 100644 --- a/client/homebrew/pages/sharePage/sharePage.jsx +++ b/client/homebrew/pages/sharePage/sharePage.jsx @@ -17,15 +17,11 @@ const { printCurrentBrew, fetchThemeBundle } = require('../../../../shared/helpe const SharePage = (props)=>{ const { brew = DEFAULT_BREW_LOAD, disableMeta = false } = props; - const [state, setState] = useState({ - themeBundle : {}, - currentBrewRendererPageNum : 1, - }); + const [themeBundle, setThemeBundle] = useState({}); + const [currentBrewRendererPageNum, setCurrentBrewRendererPageNum] = useState(1); const handleBrewRendererPageChange = useCallback((pageNumber)=>{ - setState((prevState)=>({ - currentBrewRendererPageNum : pageNumber, - ...prevState })); + setCurrentBrewRendererPageNum(pageNumber); }, []); const handleControlKeys = (e)=>{ @@ -40,11 +36,7 @@ const SharePage = (props)=>{ useEffect(()=>{ document.addEventListener('keydown', handleControlKeys); - fetchThemeBundle( - { setState }, - brew.renderer, - brew.theme - ); + fetchThemeBundle(undefined, setThemeBundle, brew.renderer, brew.theme); return ()=>{ document.removeEventListener('keydown', handleControlKeys); @@ -114,9 +106,9 @@ const SharePage = (props)=>{ lang={brew.lang} renderer={brew.renderer} theme={brew.theme} - themeBundle={state.themeBundle} + themeBundle={themeBundle} onPageChange={handleBrewRendererPageChange} - currentBrewRendererPageNum={state.currentBrewRendererPageNum} + currentBrewRendererPageNum={currentBrewRendererPageNum} allowPrint={true} />
diff --git a/client/homebrew/pages/userPage/userPage.jsx b/client/homebrew/pages/userPage/userPage.jsx index f6fae639d..e4a8b0b4e 100644 --- a/client/homebrew/pages/userPage/userPage.jsx +++ b/client/homebrew/pages/userPage/userPage.jsx @@ -39,10 +39,14 @@ const UserPage = (props)=>{ }] : []) ]; + const clearError = ()=>{ + setError(null); + }; + const navItems = ( - {error && ()} + {error && ()} diff --git a/client/homebrew/pages/vaultPage/vaultPage.jsx b/client/homebrew/pages/vaultPage/vaultPage.jsx index f979aa4f7..e098bb1a2 100644 --- a/client/homebrew/pages/vaultPage/vaultPage.jsx +++ b/client/homebrew/pages/vaultPage/vaultPage.jsx @@ -12,7 +12,7 @@ const Account = require('../../navbar/account.navitem.jsx'); const NewBrew = require('../../navbar/newbrew.navitem.jsx'); const HelpNavItem = require('../../navbar/help.navitem.jsx'); const BrewItem = require('../basePages/listPage/brewItem/brewItem.jsx'); -const SplitPane = require('../../../../shared/naturalcrit/splitPane/splitPane.jsx'); +const SplitPane = require('client/components/splitPane/splitPane.jsx'); const ErrorIndex = require('../errorPage/errors/errorIndex.js'); import request from '../../utils/request-middleware.js'; diff --git a/client/homebrew/utils/request-middleware.spec.js b/client/homebrew/utils/request-middleware.spec.js new file mode 100644 index 000000000..d7c198394 --- /dev/null +++ b/client/homebrew/utils/request-middleware.spec.js @@ -0,0 +1,74 @@ +import requestMiddleware from './request-middleware'; + +jest.mock('superagent'); +import request from 'superagent'; + +describe('request-middleware', ()=>{ + let version; + + let setFn; + let testFn; + + beforeEach(()=>{ + jest.resetAllMocks(); + version = global.version; + + global.version = '999'; + + setFn = jest.fn(); + testFn = jest.fn(()=>{ return { set: setFn }; }); + }); + + afterEach(()=>{ + global.version = version; + }); + + it('should add header to get', ()=>{ + // Ensure tests functions have been reset + expect(testFn).not.toHaveBeenCalled(); + expect(setFn).not.toHaveBeenCalled(); + + request.get = testFn; + + requestMiddleware.get('path'); + + expect(testFn).toHaveBeenCalledWith('path'); + expect(setFn).toHaveBeenCalledWith('Homebrewery-Version', '999'); + }); + + it('should add header to put', ()=>{ + expect(testFn).not.toHaveBeenCalled(); + expect(setFn).not.toHaveBeenCalled(); + + request.put = testFn; + + requestMiddleware.put('path'); + + expect(testFn).toHaveBeenCalledWith('path'); + expect(setFn).toHaveBeenCalledWith('Homebrewery-Version', '999'); + }); + + it('should add header to post', ()=>{ + expect(testFn).not.toHaveBeenCalled(); + expect(setFn).not.toHaveBeenCalled(); + + request.post = testFn; + + requestMiddleware.post('path'); + + expect(testFn).toHaveBeenCalledWith('path'); + expect(setFn).toHaveBeenCalledWith('Homebrewery-Version', '999'); + }); + + it('should add header to delete', ()=>{ + expect(testFn).not.toHaveBeenCalled(); + expect(setFn).not.toHaveBeenCalled(); + + request.delete = testFn; + + requestMiddleware.delete('path'); + + expect(testFn).toHaveBeenCalledWith('path'); + expect(setFn).toHaveBeenCalledWith('Homebrewery-Version', '999'); + }); +}); diff --git a/package.json b/package.json index 25800fff6..77f192afc 100644 --- a/package.json +++ b/package.json @@ -137,19 +137,19 @@ "written-number": "^0.11.1" }, "devDependencies": { - "@stylistic/stylelint-plugin": "^3.1.3", + "@stylistic/stylelint-plugin": "^4.0.0", "babel-plugin-transform-import-meta": "^2.3.3", - "eslint": "^9.31.0", + "eslint": "^9.35.0", "eslint-plugin-jest": "^29.0.1", "eslint-plugin-react": "^7.37.5", "globals": "^16.3.0", - "jest": "^30.0.5", + "jest": "^30.1.3", "jest-expect-message": "^1.1.3", "jsdom-global": "^3.0.2", "postcss-less": "^6.0.0", - "stylelint": "^16.22.0", - "stylelint-config-recess-order": "^7.1.0", - "stylelint-config-recommended": "^16.0.0", + "stylelint": "^16.24.0", + "stylelint-config-recess-order": "^7.3.0", + "stylelint-config-recommended": "^17.0.0", "supertest": "^7.1.4" } } diff --git a/server/app.js b/server/app.js index 869fe6555..afba0997b 100644 --- a/server/app.js +++ b/server/app.js @@ -487,8 +487,8 @@ app.get('/account', asyncHandler(async (req, res, next)=>{ const query = { authors: req.account.username, googleId: { $exists: false } }; const mongoCount = await HomebrewModel.countDocuments(query) .catch((err)=>{ - mongoCount = 0; console.log(err); + return 0; }); data.accountDetails = { diff --git a/shared/helpers.js b/shared/helpers.js index e09b0bdc4..3f91583d6 100644 --- a/shared/helpers.js +++ b/shared/helpers.js @@ -116,27 +116,21 @@ const printCurrentBrew = ()=>{ } }; -const fetchThemeBundle = async (obj, renderer, theme)=>{ +const fetchThemeBundle = async (setError, setThemeBundle, renderer, theme)=>{ if(!renderer || !theme) return; const res = await request .get(`/api/theme/${renderer}/${theme}`) .catch((err)=>{ - obj.setState({ error: err }); + setError(err) }); if(!res) { - obj.setState((prevState)=>({ - ...prevState, - themeBundle : {} - })); + setThemeBundle({}); return; } const themeBundle = res.body; themeBundle.joinedStyles = themeBundle.styles.map((style)=>``).join('\n\n'); - obj.setState((prevState)=>({ - ...prevState, - themeBundle : themeBundle, - error : null - })); + setThemeBundle(themeBundle); + setError(null); }; const debugTextMismatch = (clientTextRaw, serverTextRaw, label) => { diff --git a/themes/V3/UnearthedArcana/dropdownPreview.png b/themes/V3/UnearthedArcana/dropdownPreview.png new file mode 100644 index 000000000..cfc1e36bc Binary files /dev/null and b/themes/V3/UnearthedArcana/dropdownPreview.png differ diff --git a/themes/V3/UnearthedArcana/dropdownTexture.png b/themes/V3/UnearthedArcana/dropdownTexture.png new file mode 100644 index 000000000..d0c0256c0 Binary files /dev/null and b/themes/V3/UnearthedArcana/dropdownTexture.png differ diff --git a/themes/V3/UnearthedArcana/settings.json b/themes/V3/UnearthedArcana/settings.json new file mode 100644 index 000000000..273f0bb2f --- /dev/null +++ b/themes/V3/UnearthedArcana/settings.json @@ -0,0 +1,6 @@ +{ + "name" : "UnearthedArcana", + "renderer" : "V3", + "baseTheme" : false, + "baseSnippets" : false +} diff --git a/themes/V3/UnearthedArcana/style.less b/themes/V3/UnearthedArcana/style.less new file mode 100644 index 000000000..695924d37 --- /dev/null +++ b/themes/V3/UnearthedArcana/style.less @@ -0,0 +1,38 @@ +@import (less) './themes/fonts/5e/fonts.less'; +@import (less) './themes/assets/assets.less'; + +:root { + //Colors + --HB_Color_Background : #FFFFFF; // White + --HB_Color_WatercolorStain : #000000; // Black +} + +.page { + font-family: Cambria,Georgia,serif; + font-size: 14px; + h1, h2, h3, h4 { + font-variant: small-caps; + font-weight: normal; + } + h1 { + column-span: all; + -webkit-column-span: all; + font-size: 40px; + } + h2 { + font-size: 26px; + } + h3 { + font-size: 20px; + border-bottom: 2px solid black; + } + h4 { + font-size: 18px; + } + h5 { + font-size: 16px; + } + h6 { + font-size: 14px; + } +} diff --git a/themes/themes.json b/themes/themes.json index 16a4b9b13..7e01b180c 100644 --- a/themes/themes.json +++ b/themes/themes.json @@ -35,6 +35,13 @@ "baseTheme": "Blank", "baseSnippets": "5ePHB", "path": "Journal" + }, + "UnearthedArcana": { + "name": "UnearthedArcana", + "renderer": "V3", + "baseTheme": false, + "baseSnippets": false, + "path": "UnearthedArcana" } } } \ No newline at end of file