0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-01-25 05:23:01 +00:00

Compare commits

..

5 Commits

Author SHA1 Message Date
Trevor Buckner
c5071aa27e Restore unsaved warning timeout duration to 15 mins 2025-09-22 19:55:39 -04:00
Trevor Buckner
f0baa763ec lint 2025-09-22 19:52:42 -04:00
Trevor Buckner
3ec650557e Fix Autosave and unsaved changes warning
Use normal setTimeout for autosave instead of _.debounce. Fixes a lot of issues with functional component.

Also fix existing bug where multiple "unsaved data" warnings could be queued up if the user keeps typing while the warning is being displayed.
2025-09-22 19:49:57 -04:00
Trevor Buckner
242ff8712f Merge pull request #4420 from naturalcrit/MoveShareDropdownMenuToSeparateComponent
Move "share" dropdown to own component
2025-09-18 22:48:08 -04:00
Trevor Buckner
31a8101df7 Move "share" dropdown to own component 2025-09-13 19:37:59 -04:00
5 changed files with 124 additions and 231 deletions

View File

@@ -0,0 +1,35 @@
import React from 'react';
import dedent from 'dedent-tabs';
import Nav from 'naturalcrit/nav/nav.jsx';
const getShareId = (brew)=>(
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}) => (
<Nav.dropdown>
<Nav.item color='teal' icon='fas fa-share-alt'>
share
</Nav.item>
<Nav.item color='blue' href={`/share/${getShareId(brew)}`}>
view
</Nav.item>
<Nav.item color='blue' onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${getShareId(brew)}`);}}>
copy url
</Nav.item>
<Nav.item color='blue' href={getRedditLink(brew)} newTab rel='noopener noreferrer'>
post to reddit
</Nav.item>
</Nav.dropdown>
);

View File

@@ -15,6 +15,7 @@ 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';
@@ -46,6 +47,7 @@ const EditPage = (props)=>{
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));
@@ -57,56 +59,56 @@ const EditPage = (props)=>{
const [alertTrashedGoogleBrew , setAlertTrashedGoogleBrew ] = useState(props.brew.trashed);
const [alertLoginToTransfer , setAlertLoginToTransfer ] = useState(false);
const [confirmGoogleTransfer , setConfirmGoogleTransfer ] = useState(false);
const [url , setUrl ] = useState('');
const [autoSaveEnabled , setAutoSaveEnabled ] = useState(true);
const [autoSaveWarning , setAutoSaveWarning ] = useState(true);
const [unsavedTime , setUnsavedTime ] = useState(new Date());
const [warnUnsavedChanges , setWarnUnsavedChanges ] = useState(true);
const editorRef = useRef(null);
const savedBrew = useRef(_.cloneDeep(props.brew));
const warningTimer = useRef(null);
const debounceSave = useCallback(_.debounce((brew, saveToGoogle)=>save(brew, saveToGoogle), SAVE_TIMEOUT), []);
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
useEffect(()=>{
setUrl(window.location.href);
const autoSavePref = JSON.parse(localStorage.getItem('AUTOSAVE_ON') ?? true);
setAutoSaveEnabled(autoSavePref);
setAutoSaveWarning(!autoSavePref);
setWarnUnsavedChanges(!autoSavePref);
setHTMLErrors(Markdown.validate(currentBrew.text));
fetchThemeBundle(setError, setThemeBundle, currentBrew.renderer, currentBrew.theme);
document.addEventListener('keydown', handleControlKeys);
window.onbeforeunload = ()=>{
if(isSaving || unsavedChanges)
return 'You have unsaved changes!';
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(unsavedChangesRef.current)
return 'You have unsaved changes!';
};
return ()=>{
document.removeEventListener('keydown', handleControlKeys);
window.onbeforeunload = null;
window.onBeforeUnload = null;
};
}, []);
useEffect(()=>{
const hasChange = !_.isEqual(currentBrew, savedBrew.current);
trySaveRef.current = trySave;
unsavedChangesRef.current = unsavedChanges;
});
useEffect(()=>{
const hasChange = !_.isEqual(currentBrew, lastSavedBrew.current);
setUnsavedChanges(hasChange);
if(hasChange && autoSaveEnabled) trySave();
if(autoSaveEnabled) trySave(false, hasChange);
}, [currentBrew]);
const handleControlKeys = (e)=>{
if(!(e.ctrlKey || e.metaKey)) return;
const S_KEY = 83;
const P_KEY = 80;
if(e.keyCode === S_KEY) trySave(true);
if(e.keyCode === P_KEY) printCurrentBrew();
if(e.keyCode === S_KEY || e.keyCode === P_KEY) {
e.stopPropagation();
e.preventDefault();
}
};
const handleSplitMove = ()=>{
editorRef.current?.update();
};
@@ -155,22 +157,10 @@ const EditPage = (props)=>{
snippets : newData.snippets
}));
const trySave = (immediate = false)=>{
//debounceSave = _.debounce(save, SAVE_TIMEOUT);
if(isSaving) return;
const hasChange = !_.isEqual(currentBrew, savedBrew.current);
if(immediate) {
debounceSave(currentBrew, saveGoogle);
debounceSave.flush?.();
return;
}
if(hasChange)
debounceSave(currentBrew, saveGoogle);
else
debounceSave.cancel?.();
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
};
const handleGoogleClick = ()=>{
@@ -196,11 +186,26 @@ const EditPage = (props)=>{
trySave(true);
};
const save = async (brew, saveToGoogle)=>{
debounceSave?.cancel?.();
const trySave = (immediate = false, hasChanges = true)=>{
clearTimeout(saveTimeout.current);
if(isSaving) return;
if(!hasChanges && !immediate) return;
const newTimeout = immediate ? 0 : SAVE_TIMEOUT;
setIsSaving(true);
setError(null);
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);
};
const save = async (brew, saveToGoogle)=>{
setHTMLErrors(Markdown.validate(brew.text));
await updateHistory(brew).catch(console.error);
@@ -211,9 +216,10 @@ const EditPage = (props)=>{
...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(savedBrew.current.text.normalize('NFC')), encodeURI(brew.text.normalize('NFC')))),
hash : await md5(savedBrew.current.text),
textBin : undefined
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(brewToSave)));
@@ -231,22 +237,24 @@ const EditPage = (props)=>{
});
if(!res) return;
const { googleId, editId, shareId, version } = res.body;
savedBrew.current = {
...brew,
googleId : googleId ?? null,
editId,
shareId,
version
const updatedFields = {
googleId : res.body.googleId ?? null,
editId : res.body.editId,
shareId : res.body.shareId,
version : res.body.version
};
setCurrentBrew(savedBrew.current);
lastSavedBrew.current = {
...brew,
...updatedFields
};
setIsSaving(false);
setUnsavedTime(new Date());
setCurrentBrew((prevBrew)=>({
...prevBrew,
...updatedFields
}));
history.replaceState(null, null, `/edit/${editId}`);
history.replaceState(null, null, `/edit/${res.body.editId}`);
};
const renderGoogleDriveIcon = ()=>(
@@ -267,7 +275,7 @@ const EditPage = (props)=>{
{alertLoginToTransfer && (
<div className='errorContainer' onClick={closeAlerts}>
You must be signed in to a Google account to transfer between the homebrewery and Google Drive!
<a target='_blank' rel='noopener noreferrer' href={`https://www.naturalcrit.com/login?redirect=${url}`}>
<a target='_blank' rel='noopener noreferrer' href={`https://www.naturalcrit.com/login?redirect=${window.location.href}`}>
<div className='confirm'> Sign In </div>
</a>
<div className='deny'> Not Now </div>
@@ -290,17 +298,17 @@ const EditPage = (props)=>{
return <Nav.item className='save' icon='fas fa-spinner fa-spin'>saving...</Nav.item>;
// #2 - Unsaved changes exist, autosave is OFF and warning timer has expired, show AUTOSAVE WARNING
if(unsavedChanges && autoSaveWarning) {
resetAutoSaveWarning();
const elapsedTime = Math.round((new Date() - unsavedTime) / 1000 / 60);
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 <Nav.item className='save error' icon='fas fa-exclamation-circle'>
Reminder...
<div className='errorContainer'>{text}</div>
</Nav.item>
<div className='errorContainer'>{text}</div>
</Nav.item>;
}
// #3 - Unsaved changes exist, click to save, show SAVE NOW
@@ -316,15 +324,11 @@ const EditPage = (props)=>{
};
const toggleAutoSave = ()=>{
if(warningTimer.current) clearTimeout(warningTimer.current);
clearTimeout(warnUnsavedTimeout.current);
clearTimeout(saveTimeout.current);
localStorage.setItem('AUTOSAVE_ON', JSON.stringify(!autoSaveEnabled));
setAutoSaveWarning(autoSaveWarning);
setAutoSaveEnabled(!autoSaveEnabled);
};
const resetAutoSaveWarning = ()=>{
setTimeout(()=>setAutoSaveWarning(false), UNSAVED_WARNING_POPUP_TIMEOUT); // Hide the warning after 4 seconds
warningTimer.current = setTimeout(()=>setAutoSaveWarning(true), UNSAVED_WARNING_TIMEOUT); // 15 minutes between unsaved changes warnings
setWarnUnsavedChanges(autoSaveEnabled);
};
const renderAutoSaveButton = ()=>(
@@ -333,31 +337,12 @@ const EditPage = (props)=>{
</Nav.item>
);
const processShareId = ()=>(
currentBrew.googleId && !currentBrew.stubbed
? currentBrew.googleId + currentBrew.shareId
: currentBrew.shareId
);
const getRedditLink = ()=>{
const shareLink = processShareId();
const systems = currentBrew.systems.length > 0 ? ` [${currentBrew.systems.join(' - ')}]` : '';
const title = `${currentBrew.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)}`;
};
const clearError = ()=>{
setError(null);
setIsSaving(false);
};
const renderNavbar = ()=>{
const shareLink = processShareId();
return <Navbar>
<Nav.section>
<Nav.item className='brewTitle'>{currentBrew.title}</Nav.item>
@@ -368,25 +353,12 @@ const EditPage = (props)=>{
{error
? <ErrorNavItem error={error} clearError={clearError} />
: <Nav.dropdown className='save-menu'>
{renderSaveButton()}
{renderAutoSaveButton()}
</Nav.dropdown>}
{renderSaveButton()}
{renderAutoSaveButton()}
</Nav.dropdown>}
<NewBrewItem/>
<HelpNavItem/>
<Nav.dropdown>
<Nav.item color='teal' icon='fas fa-share-alt'>
share
</Nav.item>
<Nav.item color='blue' href={`/share/${shareLink}`}>
view
</Nav.item>
<Nav.item color='blue' onClick={()=>{navigator.clipboard.writeText(`${global.config.baseUrl}/share/${shareLink}`);}}>
copy url
</Nav.item>
<Nav.item color='blue' href={getRedditLink()} newTab={true} rel='noopener noreferrer'>
post to reddit
</Nav.item>
</Nav.dropdown>
<ShareNavItem brew={currentBrew} />
<PrintNavItem />
<VaultNavItem />
<RecentNavItem brew={currentBrew} storageKey='edit' />

View File

@@ -1,14 +1,13 @@
import react from "eslint-plugin-react";
import jest from "eslint-plugin-jest";
import globals from "globals";
import localPlugin from "./eslint_plugins/index.js";
export default [{
ignores: ["build/"]
},
{
files : ['**/*.js', '**/*.jsx'],
plugins : { react, jest, local: localPlugin },
plugins : { react, jest },
languageOptions : {
ecmaVersion : "latest",
sourceType : "module",
@@ -47,9 +46,9 @@ export default [{
"semi" : ["warn", "always"],
/** Whitespace **/
//"array-bracket-spacing" : ["warn", "never"],
"array-bracket-spacing" : ["warn", "never"],
"arrow-spacing" : ["warn", { before: false, after: false }],
//"comma-spacing" : ["warn", { before: false, after: true }],
"comma-spacing" : ["warn", { before: false, after: true }],
"indent" : ["warn", "tab", { MemberExpression: "off" }],
"linebreak-style" : "off",
"no-trailing-spaces" : "warn",
@@ -66,10 +65,7 @@ export default [{
"key-spacing" : ["warn", {
multiLine : { beforeColon: true, afterColon: true, align: "colon" },
singleLine : { beforeColon: false, afterColon: true }
}],
"local/aligned-useState-pairs": "warn"
}]
}
}
];

View File

@@ -1,7 +0,0 @@
import alignedUseStatePairs from './rules/aligned-useState-pairs.js';
export default {
rules: {
'aligned-useState-pairs': alignedUseStatePairs
}
};

View File

@@ -1,103 +0,0 @@
export default {
meta: {
type: "layout",
docs: {
description: "Enforce alignment of adjacent useState variable pairs",
},
fixable: "whitespace",
schema: [],
},
create(context) {
const sourceCode = context.getSourceCode();
const useStateDeclarations = [];
return {
VariableDeclaration(node) {
for (const decl of node.declarations) {
const init = decl.init;
if (
init &&
init.type === "CallExpression" &&
init.callee.name === "useState" &&
decl.id.type === "ArrayPattern"
) {
useStateDeclarations.push(decl);
}
}
},
"Program:exit"() {
if (useStateDeclarations.length < 2) return;
// Sort by line number
useStateDeclarations.sort(
(a, b) => a.loc.start.line - b.loc.start.line
);
// Group adjacent lines
const groups = [];
let currentGroup = [useStateDeclarations[0]];
for (let i = 1; i < useStateDeclarations.length; i++) {
const prev = useStateDeclarations[i - 1];
const curr = useStateDeclarations[i];
if (curr.loc.start.line === prev.loc.end.line + 1) {
currentGroup.push(curr);
} else {
if (currentGroup.length > 1) groups.push(currentGroup);
currentGroup = [curr];
}
}
if (currentGroup.length > 1) groups.push(currentGroup);
// Analyze each group
for (const group of groups) {
const positions = group.map((decl) => {
const text = sourceCode.getText(decl);
const commaIndex = text.indexOf(",");
const closingBracketIndex = text.lastIndexOf("]");
return {
node: decl,
comma: commaIndex,
closing: closingBracketIndex,
};
});
const maxComma = Math.max(...positions.map((p) => p.comma));
const maxClosing = Math.max(
...positions.map((p) => p.closing)
);
for (const pos of positions) {
if (
pos.comma !== maxComma ||
pos.closing !== maxClosing
) {
console.log(context);
context.report({
node: pos.node,
message: "useState pair is not aligned with others in its block.",
fix(fixer) {
const text = sourceCode.getText(pos.node);
const parts = text.match(/^\[\s*(.+?)\s*,\s*(.+?)\s*\]\s*=\s*useState\((.+)\)$/);
if (!parts) return null;
const [_, left, right, value] = parts;
const paddedLeft = left.padEnd(maxComma - 1);
const paddedRight = right.padEnd(maxClosing - maxComma - 2);
const aligned = `[${paddedLeft}, ${paddedRight}] = useState(${value})`;
return [
fixer.replaceText(pos.node, aligned),
fixer.insertTextBefore(pos.node.parent, ""),
fixer.insertTextAfter(pos.node.parent, "")
];
}
});
}
}
}
},
};
},
};