mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-09-20 16:42:58 +00:00
Merge branch 'master' into v4Metadata
This commit is contained in:
@@ -308,7 +308,7 @@ const CodeEditor = forwardRef(
|
|||||||
view.dispatch({
|
view.dispatch({
|
||||||
effects : themeCompartment.reconfigure(themeExtension),
|
effects : themeCompartment.reconfigure(themeExtension),
|
||||||
});
|
});
|
||||||
}, [editorTheme]);
|
}, [editorTheme, tab]);
|
||||||
|
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
//rebuild syntax highlight when changing tab or renderer
|
//rebuild syntax highlight when changing tab or renderer
|
||||||
|
|||||||
@@ -1,33 +1,44 @@
|
|||||||
/* eslint max-lines: ["error", { "max": 300 }] */
|
/* eslint max-lines: ["error", { "max": 300 }] */
|
||||||
import { keymap } from '@codemirror/view';
|
import { keymap } from '@codemirror/view';
|
||||||
import { undo, redo, indentMore, deleteLine } from '@codemirror/commands';
|
import { undo, redo, indentMore, indentLess, deleteLine } from '@codemirror/commands';
|
||||||
|
import { EditorSelection } from '@codemirror/state';
|
||||||
import { Prec } from '@codemirror/state';
|
import { Prec } from '@codemirror/state';
|
||||||
|
|
||||||
const insertTab = (view)=>{
|
const insertTab = (view)=>{
|
||||||
const { from, to } = view.state.selection.main;
|
// If any selection spans multiple lines, delegates to CodeMirror's indentMore
|
||||||
|
// Otherwise inserts two spaces at each cursor/selection
|
||||||
|
const shouldIndent = view.state.selection.ranges.some((range)=>view.state.doc.lineAt(range.from).number !==
|
||||||
|
view.state.doc.lineAt(range.to).number
|
||||||
|
);
|
||||||
|
|
||||||
|
if(shouldIndent) return indentMore(view);
|
||||||
|
|
||||||
|
const changes = [];
|
||||||
|
|
||||||
|
for (const range of view.state.selection.ranges) {
|
||||||
|
changes.push({
|
||||||
|
from : range.from,
|
||||||
|
to : range.to,
|
||||||
|
insert : ' ' // Insert two spaces, not a tab char!
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Create a transaction so we can map old positions to
|
||||||
|
// their new positions after the edits are applied
|
||||||
|
const mappedChanges = view.state.update({ changes });
|
||||||
|
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes : { from, to, insert: ' ' },
|
changes,
|
||||||
selection : { anchor: from + 2 }
|
selection : EditorSelection.create(
|
||||||
|
view.state.selection.ranges.map((range)=>EditorSelection.cursor(
|
||||||
|
mappedChanges.changes.mapPos(range.from, 1) + 2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const indentLess = (view)=>{
|
|
||||||
const { from, to } = view.state.selection.main;
|
|
||||||
const lines = [];
|
|
||||||
for (let l = view.state.doc.lineAt(from).number; l <= view.state.doc.lineAt(to).number; l++) {
|
|
||||||
const line = view.state.doc.line(l);
|
|
||||||
const match = line.text.match(/^ {1,2}/); // match up to 2 spaces
|
|
||||||
if(match) {
|
|
||||||
lines.push({ from: line.from, to: line.from + match[0].length, insert: '' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(lines.length > 0) view.dispatch({ changes: lines });
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapSelection = (prefix, suffix)=>(view)=>{
|
const wrapSelection = (prefix, suffix)=>(view)=>{
|
||||||
const changes = [];
|
const changes = [];
|
||||||
|
|
||||||
@@ -169,16 +180,16 @@ const newPage = (view)=>{
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const generalKeymap = Prec.high(keymap.of([
|
export const generalKeymap = Prec.high(keymap.of([
|
||||||
{ key: 'Tab', run: insertTab },
|
{ key: 'Tab', run: insertTab }, //runs indentMore if multiple lines selected in a single selection
|
||||||
{ key: 'Mod-z', run: undo }, //i think it may be unnecessary
|
{ key: 'Shift-Tab', run: indentLess },
|
||||||
|
{ key: 'Mod-z', run: undo }, //it may be unnecessary
|
||||||
{ key: 'Mod-Shift-z', run: redo },
|
{ key: 'Mod-Shift-z', run: redo },
|
||||||
{ key: 'Mod-y', run: redo },
|
{ key: 'Mod-y', run: redo }, //user asked, so double keybind
|
||||||
{ key: 'Mod-d', run: deleteLine },
|
{ key: 'Mod-d', run: deleteLine }, //annoyingly overrides "selectNextOccurrence" because users asked
|
||||||
]));
|
]));
|
||||||
|
|
||||||
export const markdownKeymap = Prec.highest(keymap.of([
|
export const markdownKeymap = Prec.highest(keymap.of([
|
||||||
//{ key: 'Shift-Tab', run: indentMore },
|
|
||||||
{ key: 'Shift-Tab', run: indentLess },
|
|
||||||
{ key: 'Mod-b', run: wrapSelection('**', '**') }, // makeBold
|
{ key: 'Mod-b', run: wrapSelection('**', '**') }, // makeBold
|
||||||
{ key: 'Mod-i', run: wrapSelection('*', '*') }, // makeItalic
|
{ key: 'Mod-i', run: wrapSelection('*', '*') }, // makeItalic
|
||||||
{ key: 'Mod-u', run: wrapSelection('<u>', '</u>') }, // makeUnderline
|
{ key: 'Mod-u', run: wrapSelection('<u>', '</u>') }, // makeUnderline
|
||||||
|
|||||||
@@ -92,12 +92,13 @@ const Dropdown = ({ groupName, className = null, icon, children, color = null, c
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={['menu-wrapper', className].join(' ')} role='none' >
|
<li className='menu-wrapper' role='none'>
|
||||||
<button
|
<button
|
||||||
id={`${menuId}-trigger`}
|
id={`${menuId}-trigger`}
|
||||||
className={['menu-item', color].join(' ')}
|
className={['menu-item', color].join(' ')}
|
||||||
popoverTarget={menuId}
|
popoverTarget={menuId}
|
||||||
aria-haspopup='menu'
|
aria-haspopup='menu'
|
||||||
|
aria-label={groupName}
|
||||||
role='menuitem'
|
role='menuitem'
|
||||||
disabled={!React.Children.count(children)}
|
disabled={!React.Children.count(children)}
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
@@ -105,18 +106,19 @@ const Dropdown = ({ groupName, className = null, icon, children, color = null, c
|
|||||||
{trigger(groupName, icon)}
|
{trigger(groupName, icon)}
|
||||||
</button>
|
</button>
|
||||||
<MenuDepthContext.Provider value={depth + 1}>
|
<MenuDepthContext.Provider value={depth + 1}>
|
||||||
<div
|
<ul
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
id={menuId}
|
id={menuId}
|
||||||
className='menu-list'
|
className='menu-list'
|
||||||
popover='auto'
|
popover='auto'
|
||||||
role='menu'
|
role='menu'
|
||||||
|
aria-label={`${groupName} Submenu`}
|
||||||
onClick={handleMenuActionClick}
|
onClick={handleMenuActionClick}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</ul>
|
||||||
</MenuDepthContext.Provider>
|
</MenuDepthContext.Provider>
|
||||||
</div>
|
</li>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
.menu-wrapper {
|
@property --menuColor {
|
||||||
position: relative;
|
syntax: '<color>';
|
||||||
&:is(.menu-bar > .menu-section > .menu-wrapper){
|
inherits: true;
|
||||||
display: inline-block;
|
initial-value: #DDD;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@property --activeTriggerColor {
|
||||||
|
syntax: '<color>';
|
||||||
|
inherits: true;
|
||||||
|
initial-value: #DDD;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root{
|
||||||
|
--activeTriggerColor : var(--activeTriggerColor);
|
||||||
|
}
|
||||||
.menu-list {
|
.menu-list {
|
||||||
|
contain : content;
|
||||||
position : fixed;
|
position : fixed;
|
||||||
z-index : 1000;
|
|
||||||
top : anchor(bottom);
|
top : anchor(bottom);
|
||||||
left : anchor(left);
|
left : anchor(left);
|
||||||
position-try: flip-inline flip-block;
|
position-try: flip-inline flip-block;
|
||||||
color: inherit; // [popover] gets a `canvastext` color value from useragent.
|
color: inherit; // [popover] gets a `canvastext` color value from useragent.
|
||||||
> .menu-wrapper {
|
background: var(--menuColor);
|
||||||
position:relative;
|
li > .menu-list {
|
||||||
> .menu-list {
|
|
||||||
margin: 0 0px;
|
margin: 0 0px;
|
||||||
top : anchor(top);
|
top : anchor(top);
|
||||||
left : anchor(right);
|
left : anchor(right);
|
||||||
position-try: flip-inline;
|
position-try: flip-inline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.menu-wrapper:has(:popover-open) > button { // if menu is open...
|
||||||
|
background-color: var(--activeTriggerColor, hsl(from var(--menuColor) h s calc(l * .85))); // tint menu triggers based on menu color
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,7 @@ const TOOLBAR_STATE_KEY = 'HB_renderer_toolbarState';
|
|||||||
|
|
||||||
const INITIAL_CONTENT = dedent`
|
const INITIAL_CONTENT = dedent`
|
||||||
<!DOCTYPE html><html><head>
|
<!DOCTYPE html><html><head>
|
||||||
|
<title>Rendered Brew Content</title>
|
||||||
<link href='/homebrew/bundle.css' type="text/css" rel='stylesheet' />
|
<link href='/homebrew/bundle.css' type="text/css" rel='stylesheet' />
|
||||||
<link href="${brewRendererStylesUrl}" rel="stylesheet" />
|
<link href="${brewRendererStylesUrl}" rel="stylesheet" />
|
||||||
<link href="${headerNavStylesUrl}" rel="stylesheet" />
|
<link href="${headerNavStylesUrl}" rel="stylesheet" />
|
||||||
@@ -348,10 +349,11 @@ const BrewRenderer = (props)=>{
|
|||||||
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={state.visiblePages.length > 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/>
|
<ToolBar displayOptions={displayOptions} onDisplayOptionsChange={handleDisplayOptionsChange} visiblePages={state.visiblePages.length > 0 ? state.visiblePages : [state.centerPage]} totalPages={rawPages.length} headerState={headerState} setHeaderState={setHeaderState}/>
|
||||||
|
|
||||||
{/*render in iFrame so broken code doesn't crash the site.*/}
|
{/*render in iFrame so broken code doesn't crash the site.*/}
|
||||||
<Frame id='BrewRenderer' initialContent={INITIAL_CONTENT}
|
<Frame id='BrewRenderer' title="Rendered Brew Content" initialContent={INITIAL_CONTENT}
|
||||||
style={{ width: '100%', height: '100%', visibility: state.visibility }}
|
style={{ width: '100%', height: '100%', visibility: state.visibility }}
|
||||||
contentDidMount={frameDidMount}
|
contentDidMount={frameDidMount}
|
||||||
onClick={()=>{emitClick();}}
|
onClick={()=>{emitClick();}}
|
||||||
|
sandbox="allow-same-origin allow-modals allow-top-navigation"
|
||||||
>
|
>
|
||||||
<div className='brewRenderer'
|
<div className='brewRenderer'
|
||||||
onKeyDown={handleControlKeys}
|
onKeyDown={handleControlKeys}
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ function safeHTML(htmlString) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Check remaining elements for blacklisted attributes
|
// Check remaining elements for blacklisted attributes
|
||||||
for (const attribute of element.attributes){
|
[...element.attributes].forEach((attribute)=>{
|
||||||
if(blacklistAttrs.some((test)=>{return test(attribute);})) {
|
if(blacklistAttrs.some((test)=>{return test(attribute);})) {
|
||||||
element.removeAttribute(attribute.localName);
|
element.removeAttribute(attribute.name);
|
||||||
break;
|
return;
|
||||||
};
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,11 +99,16 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
|||||||
return (
|
return (
|
||||||
<div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'>
|
<div id='preview-toolbar' className={`toolBar ${toolsVisible ? 'visible' : 'hidden'}`} role='toolbar'>
|
||||||
<div className='toggleButton'>
|
<div className='toggleButton'>
|
||||||
<button data-tooltip-right={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`} onClick={()=>{
|
<button data-tooltip-right={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`}
|
||||||
setToolsVisible(!toolsVisible);
|
aria-label={`${toolsVisible ? 'Hide' : 'Show'} Preview Toolbar`}
|
||||||
localStorage.setItem(TOOLBAR_VISIBILITY, !toolsVisible);
|
onClick={()=>{ setToolsVisible(!toolsVisible); localStorage.setItem(TOOLBAR_VISIBILITY, !toolsVisible); }}>
|
||||||
}}><i className='fas fa-glasses' /></button>
|
<i aria-hidden='true' className='fas fa-glasses' />
|
||||||
<button data-tooltip-right={`${headerState ? 'Hide' : 'Show'} Header Navigation`} onClick={()=>{setHeaderState(!headerState);}}><i className='fas fa-rectangle-list' /></button>
|
</button>
|
||||||
|
<button data-tooltip-right={`${headerState ? 'Hide' : 'Show'} Header Navigation`}
|
||||||
|
aria-label={`${headerState ? 'Hide' : 'Show'} Header Navigation`}
|
||||||
|
onClick={()=>{setHeaderState(!headerState);}}>
|
||||||
|
<i aria-hidden='true' className='fas fa-rectangle-list' />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/*v=====----------------------< Zoom Controls >---------------------=====v*/}
|
{/*v=====----------------------< Zoom Controls >---------------------=====v*/}
|
||||||
<div className='group' role='group' aria-label='Zoom' aria-hidden={!toolsVisible}>
|
<div className='group' role='group' aria-label='Zoom' aria-hidden={!toolsVisible}>
|
||||||
@@ -111,17 +116,19 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
|||||||
id='fill-width'
|
id='fill-width'
|
||||||
className='tool'
|
className='tool'
|
||||||
data-tooltip-bottom='Set zoom to fill preview with one page'
|
data-tooltip-bottom='Set zoom to fill preview with one page'
|
||||||
|
aria-label='Set zoom to fill preview with one page'
|
||||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fill'))}
|
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fill'))}
|
||||||
>
|
>
|
||||||
<i className='fac fit-width' />
|
<i aria-hidden='true' className='fac fit-width' />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id='zoom-to-fit'
|
id='zoom-to-fit'
|
||||||
className='tool'
|
className='tool'
|
||||||
data-tooltip-bottom='Set zoom to fit entire page in preview'
|
data-tooltip-bottom='Set zoom to fit entire page in preview'
|
||||||
|
aria-label='Set zoom to fit entire page in preview'
|
||||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fit'))}
|
onClick={()=>handleZoomButton(displayOptions.zoomLevel + calculateChange('fit'))}
|
||||||
>
|
>
|
||||||
<i className='fac zoom-to-fit' />
|
<i aria-hidden='true' className='fac zoom-to-fit' />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id='zoom-out'
|
id='zoom-out'
|
||||||
@@ -129,8 +136,9 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
|||||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel - 20)}
|
onClick={()=>handleZoomButton(displayOptions.zoomLevel - 20)}
|
||||||
disabled={displayOptions.zoomLevel <= MIN_ZOOM}
|
disabled={displayOptions.zoomLevel <= MIN_ZOOM}
|
||||||
data-tooltip-bottom='Zoom Out'
|
data-tooltip-bottom='Zoom Out'
|
||||||
|
aria-label='Zoom Out'
|
||||||
>
|
>
|
||||||
<i className='fas fa-magnifying-glass-minus' />
|
<i aria-hidden='true' className='fas fa-magnifying-glass-minus' />
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
id='zoom-slider'
|
id='zoom-slider'
|
||||||
@@ -138,6 +146,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
|||||||
type='range'
|
type='range'
|
||||||
name='zoom'
|
name='zoom'
|
||||||
list='zoomLevels'
|
list='zoomLevels'
|
||||||
|
aria-label='Zoom Amount'
|
||||||
min={MIN_ZOOM}
|
min={MIN_ZOOM}
|
||||||
max={MAX_ZOOM}
|
max={MAX_ZOOM}
|
||||||
step='1'
|
step='1'
|
||||||
@@ -154,8 +163,9 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
|||||||
onClick={()=>handleZoomButton(displayOptions.zoomLevel + 20)}
|
onClick={()=>handleZoomButton(displayOptions.zoomLevel + 20)}
|
||||||
disabled={displayOptions.zoomLevel >= MAX_ZOOM}
|
disabled={displayOptions.zoomLevel >= MAX_ZOOM}
|
||||||
data-tooltip-bottom='Zoom In'
|
data-tooltip-bottom='Zoom In'
|
||||||
|
aria-label='Zoom In'
|
||||||
>
|
>
|
||||||
<i className='fas fa-magnifying-glass-plus' />
|
<i aria-hidden='true' className='fas fa-magnifying-glass-plus' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -166,27 +176,32 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
|||||||
id='single-spread'
|
id='single-spread'
|
||||||
className='tool'
|
className='tool'
|
||||||
data-tooltip-bottom='Single Page'
|
data-tooltip-bottom='Single Page'
|
||||||
|
aria-label='Single Page Spread'
|
||||||
onClick={()=>{handleOptionChange('spread', 'single');}}
|
onClick={()=>{handleOptionChange('spread', 'single');}}
|
||||||
aria-checked={displayOptions.spread === 'single'}
|
aria-checked={displayOptions.spread === 'single'}
|
||||||
><i className='fac single-spread' /></button>
|
><i aria-hidden='true' className='fac single-spread' /></button>
|
||||||
<button role='radio'
|
<button role='radio'
|
||||||
id='facing-spread'
|
id='facing-spread'
|
||||||
className='tool'
|
className='tool'
|
||||||
data-tooltip-bottom='Facing Pages'
|
data-tooltip-bottom='Facing Pages'
|
||||||
|
aria-label='Facing Pages Spread'
|
||||||
onClick={()=>{handleOptionChange('spread', 'facing');}}
|
onClick={()=>{handleOptionChange('spread', 'facing');}}
|
||||||
aria-checked={displayOptions.spread === 'facing'}
|
aria-checked={displayOptions.spread === 'facing'}
|
||||||
><i className='fac facing-spread' /></button>
|
><i aria-hidden='true' className='fac facing-spread' /></button>
|
||||||
<button role='radio'
|
<button role='radio'
|
||||||
id='flow-spread'
|
id='flow-spread'
|
||||||
className='tool'
|
className='tool'
|
||||||
data-tooltip-bottom='Flow Pages'
|
data-tooltip-bottom='Flow Pages'
|
||||||
|
aria-label='Flow Pages Spread'
|
||||||
onClick={()=>{handleOptionChange('spread', 'flow');}}
|
onClick={()=>{handleOptionChange('spread', 'flow');}}
|
||||||
aria-checked={displayOptions.spread === 'flow'}
|
aria-checked={displayOptions.spread === 'flow'}
|
||||||
><i className='fac flow-spread' /></button>
|
><i aria-hidden='true' className='fac flow-spread' /></button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<Anchored>
|
<Anchored>
|
||||||
<AnchoredTrigger id='spread-settings' className='tool' data-tooltip-bottom='Spread options'><i className='fas fa-gear' /></AnchoredTrigger>
|
<AnchoredTrigger id='spread-settings' className='tool' aria-label='Spread options' data-tooltip-bottom='Spread options'>
|
||||||
|
<i aria-hidden='true' className='fas fa-gear' />
|
||||||
|
</AnchoredTrigger>
|
||||||
<AnchoredBox>
|
<AnchoredBox>
|
||||||
<h1>Options</h1>
|
<h1>Options</h1>
|
||||||
<label data-tooltip-left='Modify the horizontal space between pages.'>
|
<label data-tooltip-left='Modify the horizontal space between pages.'>
|
||||||
@@ -217,10 +232,11 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
|||||||
className='previousPage tool'
|
className='previousPage tool'
|
||||||
type='button'
|
type='button'
|
||||||
data-tooltip-bottom='Previous Page(s)'
|
data-tooltip-bottom='Previous Page(s)'
|
||||||
|
aria-label='Previous Page'
|
||||||
onClick={()=>scrollToPage(_.min(visiblePages) - visiblePages.length)}
|
onClick={()=>scrollToPage(_.min(visiblePages) - visiblePages.length)}
|
||||||
disabled={visiblePages.includes(1)}
|
disabled={visiblePages.includes(1)}
|
||||||
>
|
>
|
||||||
<i className='fas fa-arrow-left'></i>
|
<i aria-hidden='true' className='fas fa-arrow-left'></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className='tool'>
|
<div className='tool'>
|
||||||
@@ -230,6 +246,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
|||||||
type='text'
|
type='text'
|
||||||
name='page'
|
name='page'
|
||||||
data-tooltip-bottom='Current page(s) in view'
|
data-tooltip-bottom='Current page(s) in view'
|
||||||
|
aria-label='Current page in view'
|
||||||
inputMode='numeric'
|
inputMode='numeric'
|
||||||
pattern='[0-9]'
|
pattern='[0-9]'
|
||||||
value={pageNum}
|
value={pageNum}
|
||||||
@@ -239,7 +256,7 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
|||||||
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
|
onKeyDown={(e)=>e.key == 'Enter' && scrollToPage(pageNum)}
|
||||||
style={{ width: `${pageNum.length}ch` }}
|
style={{ width: `${pageNum.length}ch` }}
|
||||||
/>
|
/>
|
||||||
<span id='page-count' data-tooltip-bottom='Total Page Count'>/ {totalPages}</span>
|
<span id='page-count' aria-label={`${totalPages} Total Pages`} data-tooltip-bottom='Total Page Count'><span aria-hidden='true'>/ {totalPages}</span></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -247,10 +264,11 @@ const ToolBar = ({ displayOptions, onDisplayOptionsChange, visiblePages, totalPa
|
|||||||
className='tool'
|
className='tool'
|
||||||
type='button'
|
type='button'
|
||||||
data-tooltip-bottom='Next Page(s)'
|
data-tooltip-bottom='Next Page(s)'
|
||||||
|
aria-label='Next Page'
|
||||||
onClick={()=>scrollToPage(_.max(visiblePages) + 1)}
|
onClick={()=>scrollToPage(_.max(visiblePages) + 1)}
|
||||||
disabled={visiblePages.includes(totalPages)}
|
disabled={visiblePages.includes(totalPages)}
|
||||||
>
|
>
|
||||||
<i className='fas fa-arrow-right'></i>
|
<i aria-hidden='true' className='fas fa-arrow-right'></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const MetadataEditor = createReactClass({
|
|||||||
|
|
||||||
getInitialState : function(){
|
getInitialState : function(){
|
||||||
return {
|
return {
|
||||||
|
isOwner : global.account?.username && global.account?.username === this.props.metadata?.authors[0],
|
||||||
showThumbnail : true
|
showThumbnail : true
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -144,6 +145,15 @@ const MetadataEditor = createReactClass({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleDeleteAuthor : function(author){
|
||||||
|
if(!confirm('Are you sure you want to remove this author? They will lose all edit access to this brew, and it will dissapear from their userpage.')) return;
|
||||||
|
if(!this.props.metadata.authors.includes(author)) return;
|
||||||
|
this.props.onChange({
|
||||||
|
...this.props.metadata,
|
||||||
|
authors : this.props.metadata.authors.filter((a)=>a !== author)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
renderPublish : function(){
|
renderPublish : function(){
|
||||||
if(this.props.metadata.published){
|
if(this.props.metadata.published){
|
||||||
return <button className='unpublish' onClick={()=>this.handlePublish(false)}>
|
return <button className='unpublish' onClick={()=>this.handlePublish(false)}>
|
||||||
@@ -170,16 +180,54 @@ const MetadataEditor = createReactClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
renderAuthors : function(){
|
renderAuthors : function(){
|
||||||
let text = 'None.';
|
const authors = this.props.metadata.authors;
|
||||||
if(this.props.metadata.authors && this.props.metadata.authors.length){
|
if(!this.state.isOwner || authors.length < 2) return (
|
||||||
text = this.props.metadata.authors.join(', ');
|
<div className='field authors'>
|
||||||
}
|
|
||||||
return <div className='field authors'>
|
|
||||||
<label>authors</label>
|
<label>authors</label>
|
||||||
<div className='value'>
|
<div className='value'>
|
||||||
{text}
|
{authors.length > 0 && (
|
||||||
|
<a href={`/user/${authors[0]}`} className='author-link' target="_blank" title={`Owner - Click to open ${authors[0]}'s profile in a new tab`}>
|
||||||
|
{authors[0]}{authors.length > 1 && ', '}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{authors.length > 1 && authors.slice(1).map((author, i)=>(
|
||||||
|
<a href={`/user/${author}`} className='author-link' title={`Author - Click to open ${author}'s profile in a new tab`}>
|
||||||
|
{author}{i+2 < authors.length && ', '}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>;
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className='field authors'>
|
||||||
|
<label>Authors</label>
|
||||||
|
<ul className='list'>
|
||||||
|
{authors.length > 0 && (
|
||||||
|
<li className='tag owner' title='Owner'>
|
||||||
|
<a href={`/user/${authors[0]}`} className='author-link' title={`Owner - Click to open ${authors[0]}'s profile in a new tab`}>
|
||||||
|
{authors[0]}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authors.length > 1 && authors.slice(1).map((author, i)=>(
|
||||||
|
<li className='tag author' key={i + 1} title='Author'>
|
||||||
|
<a href={`/user/${author}`} className='author-link' title={`Author - Click to open ${authors[0]}'s profile in a new tab`}>
|
||||||
|
{author}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={()=>this.handleDeleteAuthor(author)}
|
||||||
|
className='delete'
|
||||||
|
title={`Remove ${author} as an author`}
|
||||||
|
>
|
||||||
|
<i className='fa fa-times fa-fw' />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
renderThemeDropdown : function(){
|
renderThemeDropdown : function(){
|
||||||
|
|||||||
@@ -173,7 +173,51 @@
|
|||||||
.colorButton(@red);
|
.colorButton(@red);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.authors.field .value { line-height : 1.5em; }
|
.authors.field {
|
||||||
|
.tag {
|
||||||
|
font-weight:300;
|
||||||
|
transition:background-color 0.2s;
|
||||||
|
|
||||||
|
&.owner {
|
||||||
|
position: relative;
|
||||||
|
background-color:@silverLight;
|
||||||
|
min-width:25px;
|
||||||
|
display:grid;
|
||||||
|
place-items:center;
|
||||||
|
font-weight: 900;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "\f521";
|
||||||
|
font-family: "Font Awesome 6 Free";
|
||||||
|
color:gold;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width:15px;
|
||||||
|
height:15px;
|
||||||
|
rotate:-25deg;
|
||||||
|
translate:-30% -50%;
|
||||||
|
transform: scaleY(0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:has(button) a {
|
||||||
|
padding-right:5px;
|
||||||
|
}
|
||||||
|
&:has(button:hover) {
|
||||||
|
background:#d97d7d;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
color:@red;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color:black;
|
||||||
|
text-decoration:unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.themes.field {
|
.themes.field {
|
||||||
& .dropdown-container {
|
& .dropdown-container {
|
||||||
@@ -275,13 +319,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
padding : 0.3em;
|
padding : 0.35em;
|
||||||
margin : 2px;
|
margin : 2px;
|
||||||
font-size : 0.9em;
|
font-size : 0.95em;
|
||||||
background-color : #DDDDDD;
|
background-color : #DDDDDD;
|
||||||
border-radius : 0.5em;
|
border-radius : 0.5em;
|
||||||
|
|
||||||
.icon { #groupedIcon; }
|
.icon { #groupedIcon; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor : pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group {
|
.input-group {
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ const Snippetbar = createReactClass({
|
|||||||
const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view);
|
const snippets = this.state.snippets.filter((snippetGroup)=>snippetGroup.view === this.props.view);
|
||||||
if(snippets.length === 0) return null;
|
if(snippets.length === 0) return null;
|
||||||
|
|
||||||
return <div className='snippets'>
|
return <ul className='snippets' role='menubar' aria-label='Snippets Menubar'>
|
||||||
{_.map(snippets, (snippetGroup)=>{
|
{_.map(snippets, (snippetGroup)=>{
|
||||||
return <SnippetGroup
|
return <SnippetGroup
|
||||||
brew={this.props.brew}
|
brew={this.props.brew}
|
||||||
@@ -201,7 +201,7 @@ const Snippetbar = createReactClass({
|
|||||||
/>;
|
/>;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</div>;
|
</ul>;
|
||||||
},
|
},
|
||||||
|
|
||||||
replaceContent : function(item){
|
replaceContent : function(item){
|
||||||
@@ -327,12 +327,14 @@ const SnippetGroup = createReactClass({
|
|||||||
return _.map(snippets, (snippet)=>{
|
return _.map(snippets, (snippet)=>{
|
||||||
if(!snippet.subsnippets){
|
if(!snippet.subsnippets){
|
||||||
return (
|
return (
|
||||||
<button className='menu-item' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)} role='menuitem'>
|
<li key={snippet.name} role='none'>
|
||||||
|
<button className='menu-item' onClick={(e)=>this.handleSnippetClick(e, snippet)} role='menuitem' aria-label={snippet.name} disabled={snippet.disabled}>
|
||||||
<i className={snippet.icon} />
|
<i className={snippet.icon} />
|
||||||
<span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span>
|
<span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span>
|
||||||
{snippet.experimental && <span className='beta'>beta</span>}
|
{snippet.experimental && <span className='status'>beta</span>}
|
||||||
{snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
|
{snippet.disabled && <span className='status' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
|
||||||
</button>
|
</button>
|
||||||
|
</li>
|
||||||
);
|
);
|
||||||
} else if(snippet.subsnippets){
|
} else if(snippet.subsnippets){
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
@import (less) '@themes/fonts/5e/fonts.less';
|
@import (less) '@themes/fonts/5e/fonts.less';
|
||||||
|
|
||||||
.snippetBar {
|
.snippetBar {
|
||||||
|
--activeTriggerColor: inherit;
|
||||||
|
--menuColor : #DDDDDD;
|
||||||
@menuHeight : 25px;
|
@menuHeight : 25px;
|
||||||
position : relative;
|
position : relative;
|
||||||
display : flex;
|
display : flex;
|
||||||
@@ -16,12 +18,6 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
|
||||||
.snippets {
|
|
||||||
display : flex;
|
|
||||||
justify-content : flex-start;
|
|
||||||
min-width : 499.35px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
|
|
||||||
}
|
|
||||||
|
|
||||||
.editors {
|
.editors {
|
||||||
display : flex;
|
display : flex;
|
||||||
justify-content : flex-end;
|
justify-content : flex-end;
|
||||||
@@ -118,23 +114,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.snippetBarButton {
|
|
||||||
display : inline-block;
|
|
||||||
height : @menuHeight;
|
|
||||||
padding : 0px 5px;
|
|
||||||
font-size : 0.625em;
|
|
||||||
font-weight : 800;
|
|
||||||
line-height : @menuHeight;
|
|
||||||
text-transform : uppercase;
|
|
||||||
text-wrap : nowrap;
|
|
||||||
cursor : pointer;
|
|
||||||
&:hover, &.selected { background-color : #999999; }
|
|
||||||
i {
|
|
||||||
margin-right : 3px;
|
|
||||||
font-size : 1.4em;
|
|
||||||
vertical-align : middle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.toggleMeta {
|
.toggleMeta {
|
||||||
position : absolute;
|
position : absolute;
|
||||||
top : 0px;
|
top : 0px;
|
||||||
@@ -143,19 +123,14 @@
|
|||||||
.tooltipLeft('Edit Brew Properties');
|
.tooltipLeft('Edit Brew Properties');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.snippets {
|
||||||
|
display : flex;
|
||||||
|
justify-content : flex-start;
|
||||||
|
min-width : 565.95px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
|
||||||
|
}
|
||||||
|
|
||||||
.menu-wrapper {
|
// removed caret for top level items, by request (makes buttons too wide).
|
||||||
.menu-item:is(.snippets > .menu-wrapper > .menu-item):first-child {
|
.menu-wrapper .menu-item:is(.snippets > .menu-wrapper > .menu-item):first-child .caret { display: none; }
|
||||||
.caret {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.menu-list {
|
|
||||||
padding : 0px;
|
|
||||||
background-color : #DDDDDD;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item {
|
.menu-item {
|
||||||
position : relative;
|
position : relative;
|
||||||
@@ -166,9 +141,8 @@
|
|||||||
padding : 5px;
|
padding : 5px;
|
||||||
cursor : pointer;
|
cursor : pointer;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
.animate(background-color);
|
|
||||||
&:is(.menu-list .menu-item) [class*="name"] {
|
&:is(.menu-list .menu-item) [class*="name"] {
|
||||||
padding-inline: 8px;
|
padding-inline: 8px; // additional space between icon and name (helpful in Fonts menu especially).
|
||||||
}
|
}
|
||||||
.menu-name {
|
.menu-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -214,8 +188,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.name { margin-right : auto; }
|
.name { margin-right : auto; }
|
||||||
.disabled { text-decoration : line-through; }
|
.status {
|
||||||
.beta {
|
|
||||||
align-self : center;
|
align-self : center;
|
||||||
padding : 4px 6px;
|
padding : 4px 6px;
|
||||||
margin-left : 5px;
|
margin-left : 5px;
|
||||||
@@ -234,15 +207,8 @@
|
|||||||
&:hover { background-color: unset; }
|
&:hover { background-color: unset; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabledSnippets {
|
|
||||||
color: grey;
|
|
||||||
cursor: not-allowed;
|
|
||||||
|
|
||||||
&:hover { background-color: #DDDDDD;}
|
|
||||||
}
|
}
|
||||||
|
@container editor (width < 816px) {
|
||||||
}
|
|
||||||
@container editor (width < 750px) {
|
|
||||||
.snippetBar {
|
.snippetBar {
|
||||||
.editors {
|
.editors {
|
||||||
flex : 1;
|
flex : 1;
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import './navbar.less';
|
import './navbar.less';
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import createReactClass from 'create-react-class';
|
|
||||||
import _ from 'lodash';
|
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
|
|
||||||
import NaturalCritIcon from '@components/svg/naturalcrit-d20.svg.jsx';
|
import NaturalCritIcon from '@components/svg/naturalcrit-d20.svg.jsx';
|
||||||
|
|
||||||
const Nav = {
|
const Nav = {
|
||||||
base : createReactClass({
|
base : ({ children, className, ...props })=>{
|
||||||
displayName : 'Nav.base',
|
return <nav className={className}>
|
||||||
render : function(){
|
{children}
|
||||||
return <nav>
|
|
||||||
{this.props.children}
|
|
||||||
</nav>;
|
</nav>;
|
||||||
}
|
},
|
||||||
}),
|
logo : ()=>{
|
||||||
logo : function(){
|
|
||||||
return <a className='navLogo' href='https://www.naturalcrit.com'>
|
return <a className='navLogo' href='https://www.naturalcrit.com'>
|
||||||
<NaturalCritIcon />
|
<NaturalCritIcon />
|
||||||
<span className='name'>
|
<span className='name'>
|
||||||
@@ -24,50 +19,26 @@ const Nav = {
|
|||||||
</a>;
|
</a>;
|
||||||
},
|
},
|
||||||
|
|
||||||
section : createReactClass({
|
section : ({ children, className, ...props })=>{
|
||||||
displayName : 'Nav.section',
|
return <div className={cx([`navSection`, className])}>
|
||||||
render : function(){
|
{children}
|
||||||
return <div className={`navSection ${this.props.className ?? ''}`}>
|
|
||||||
{this.props.children}
|
|
||||||
</div>;
|
</div>;
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
item : createReactClass({
|
|
||||||
displayName : 'Nav.item',
|
|
||||||
getDefaultProps : function() {
|
|
||||||
return {
|
|
||||||
icon : null,
|
|
||||||
href : null,
|
|
||||||
newTab : false,
|
|
||||||
onClick : function(){},
|
|
||||||
color : null
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
handleClick : function(e){
|
|
||||||
this.props.onClick(e);
|
|
||||||
},
|
|
||||||
render : function(){
|
|
||||||
const classes = cx('navItem', this.props.color, this.props.className);
|
|
||||||
|
|
||||||
let icon;
|
item : ({ icon, href, newTab, onClick, color, children, className, ...props })=>{
|
||||||
if(this.props.icon) icon = <i className={this.props.icon} />;
|
const classes = cx('navItem', color, className);
|
||||||
|
if(href){
|
||||||
const props = _.omit(this.props, ['newTab']);
|
return <a className={classes} href={href} target={newTab ? '_blank' : '_self'} {...props}>
|
||||||
|
{children}
|
||||||
if(this.props.href){
|
{icon && <i className={icon}></i>}
|
||||||
return <a {...props} className={classes} target={this.props.newTab ? '_blank' : '_self'} >
|
|
||||||
{this.props.children}
|
|
||||||
{icon}
|
|
||||||
</a>;
|
</a>;
|
||||||
} else {
|
} else {
|
||||||
return <div {...props} className={classes} onClick={this.handleClick} >
|
return <button {...props} className={classes} onClick={onClick} >
|
||||||
{this.props.children}
|
{children}
|
||||||
{icon}
|
{icon && <i className={icon}></i>}
|
||||||
</div>;
|
</button>;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}),
|
|
||||||
|
|
||||||
dropdown : function dropdown(props) {
|
dropdown : function dropdown(props) {
|
||||||
props = Object.assign({}, props, {
|
props = Object.assign({}, props, {
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ const BrewItem = ({
|
|||||||
if(!brew.editId) return null;
|
if(!brew.editId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a className='deleteLink' onClick={deleteBrew}>
|
<button aria-label={`Delete ${brew.title}`} className='deleteLink' onClick={deleteBrew}>
|
||||||
<i className='fas fa-trash-alt' title='Delete' />
|
<i className='fas fa-trash-alt' aria-hidden='true' title='Delete' />
|
||||||
</a>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ const BrewItem = ({
|
|||||||
if(brew.googleId && !brew.stubbed) editLink = brew.googleId + editLink;
|
if(brew.googleId && !brew.stubbed) editLink = brew.googleId + editLink;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a className='editLink' href={`/edit/${editLink}`} target='_blank' rel='noopener noreferrer'>
|
<a className='editLink' href={`/edit/${editLink}`} aria-label={`Edit ${brew.title}`} target='_blank' rel='noopener noreferrer'>
|
||||||
<i className='fas fa-pencil-alt' title='Edit' />
|
<i className='fas fa-pencil-alt' title='Edit' />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
@@ -67,7 +67,7 @@ const BrewItem = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a className='shareLink' href={`/share/${shareLink}`} target='_blank' rel='noopener noreferrer'>
|
<a className='shareLink' href={`/share/${shareLink}`} aria-label={`Share ${brew.title}`} target='_blank' rel='noopener noreferrer'>
|
||||||
<i className='fas fa-share-alt' title='Share' />
|
<i className='fas fa-share-alt' title='Share' />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
@@ -82,7 +82,7 @@ const BrewItem = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a className='downloadLink' href={`/download/${shareLink}`}>
|
<a className='downloadLink' aria-label={`Download ${brew.title}`} href={`/download/${shareLink}`}>
|
||||||
<i className='fas fa-download' title='Download' />
|
<i className='fas fa-download' title='Download' />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
@@ -94,7 +94,7 @@ const BrewItem = ({
|
|||||||
return (
|
return (
|
||||||
<span title={brew.webViewLink ? 'Your Google Drive Storage' : 'Another User\'s Google Drive Storage'}>
|
<span title={brew.webViewLink ? 'Your Google Drive Storage' : 'Another User\'s Google Drive Storage'}>
|
||||||
<a href={brew.webViewLink} target='_blank'>
|
<a href={brew.webViewLink} target='_blank'>
|
||||||
<img className='googleDriveIcon' src={googleDriveIcon} alt='googleDriveIcon' />
|
<img className='googleDriveIcon' src={googleDriveIcon} alt='Google Drive Storage' />
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -102,7 +102,7 @@ const BrewItem = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span title='Homebrewery Storage'>
|
<span title='Homebrewery Storage'>
|
||||||
<img className='homebreweryIcon' src={homebreweryIcon} alt='homebreweryIcon' />
|
<img className='homebreweryIcon' src={homebreweryIcon} alt='Homebrewery Storage' />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -148,19 +148,20 @@ const BrewItem = ({
|
|||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<br />
|
<br />
|
||||||
<span title={`Last viewed: ${moment(brew.lastViewed).local().format(dateFormatString)}`}>
|
<span aria-label={`Viewed ${brew.views} times`} title={`Last viewed: ${moment(brew.lastViewed).local().format(dateFormatString)}`}>
|
||||||
<i className='fas fa-eye' /> {brew.views}
|
<span aria-hidden='true'><i className='fas fa-eye' /> {brew.views}</span>
|
||||||
</span>
|
</span>
|
||||||
{brew.pageCount && (
|
{brew.pageCount && (
|
||||||
<span title={`Page count: ${brew.pageCount}`}>
|
<span aria-label={`${brew.pageCount} pages`} title={`Page count: ${brew.pageCount}`}>
|
||||||
<i className='far fa-file' /> {brew.pageCount}
|
<span aria-hidden='true'><i className='far fa-file' /> {brew.pageCount}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
|
aria-label={`Last updated ${moment(brew.updatedAt).fromNow()}`}
|
||||||
title={dedent` Created: ${moment(brew.createdAt).local().format(dateFormatString)}
|
title={dedent` Created: ${moment(brew.createdAt).local().format(dateFormatString)}
|
||||||
Last updated: ${moment(brew.updatedAt).local().format(dateFormatString)}`}
|
Last updated: ${moment(brew.updatedAt).local().format(dateFormatString)}`}
|
||||||
>
|
>
|
||||||
<i className='fas fa-sync-alt' /> {moment(brew.updatedAt).fromNow()}
|
<span aria-hidden='true'><i className='fas fa-sync-alt' /> {moment(brew.updatedAt).fromNow()}</span>
|
||||||
</span>
|
</span>
|
||||||
{renderStorageIcon()}
|
{renderStorageIcon()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
&::before { content : '\f518'; }
|
&::before { content : '\f518'; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
&:hover {
|
&:hover, &:focus-within {
|
||||||
.links { opacity : 1; }
|
.links { opacity : 1; }
|
||||||
}
|
}
|
||||||
&:nth-child(2n + 1) { margin-right : 0px; }
|
&:nth-child(2n + 1) { margin-right : 0px; }
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
text-align : center;
|
text-align : center;
|
||||||
background-color : fade(black, 60%);
|
background-color : fade(black, 60%);
|
||||||
opacity : 0;
|
opacity : 0;
|
||||||
a {
|
a, button {
|
||||||
.animate(opacity);
|
.animate(opacity);
|
||||||
display : block;
|
display : block;
|
||||||
margin : 8px 0px;
|
margin : 8px 0px;
|
||||||
@@ -111,6 +111,7 @@
|
|||||||
color : white;
|
color : white;
|
||||||
text-decoration : unset;
|
text-decoration : unset;
|
||||||
opacity : 0.6;
|
opacity : 0.6;
|
||||||
|
width : 100%;
|
||||||
&:hover { opacity : 1; }
|
&:hover { opacity : 1; }
|
||||||
i { cursor : pointer; }
|
i { cursor : pointer; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ const EditPage = (props)=>{
|
|||||||
|
|
||||||
// #5 - No unsaved changes, and has never been saved, hide the button
|
// #5 - No unsaved changes, and has never been saved, hide the button
|
||||||
if(neverSaved)
|
if(neverSaved)
|
||||||
return <Nav.item className='save neverSaved'>save now</Nav.item>;
|
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
|
||||||
|
|
||||||
// DEFAULT - No unsaved changes, show SAVED
|
// DEFAULT - No unsaved changes, show SAVED
|
||||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ const HomePage =(props)=>{
|
|||||||
|
|
||||||
// #5 - No unsaved changes, and has never been saved, hide the button
|
// #5 - No unsaved changes, and has never been saved, hide the button
|
||||||
if(neverSaved)
|
if(neverSaved)
|
||||||
return <Nav.item className='save neverSaved'>save now</Nav.item>;
|
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
|
||||||
|
|
||||||
// DEFAULT - No unsaved changes, show SAVED
|
// DEFAULT - No unsaved changes, show SAVED
|
||||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ const NewPage = (props)=>{
|
|||||||
|
|
||||||
// #5 - No unsaved changes, and has never been saved, hide the button
|
// #5 - No unsaved changes, and has never been saved, hide the button
|
||||||
if(neverSaved)
|
if(neverSaved)
|
||||||
return <Nav.item className='save neverSaved'>save now</Nav.item>;
|
return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
|
||||||
|
|
||||||
// DEFAULT - No unsaved changes, show SAVED
|
// DEFAULT - No unsaved changes, show SAVED
|
||||||
return <Nav.item className='save saved'>saved</Nav.item>;
|
return <Nav.item className='save saved'>saved</Nav.item>;
|
||||||
|
|||||||
Generated
+3577
-3911
File diff suppressed because it is too large
Load Diff
+22
-21
@@ -86,33 +86,33 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.29.7",
|
"@babel/core": "^8.0.1",
|
||||||
"@babel/plugin-transform-runtime": "^7.29.7",
|
"@babel/plugin-transform-runtime": "^8.0.1",
|
||||||
"@babel/preset-env": "^7.29.5",
|
"@babel/preset-env": "^8.0.2",
|
||||||
"@babel/preset-react": "^7.29.7",
|
"@babel/preset-react": "^8.0.1",
|
||||||
"@babel/runtime": "^7.29.2",
|
"@babel/runtime": "^8.0.0",
|
||||||
"@codemirror/autocomplete": "^6.20.3",
|
"@codemirror/autocomplete": "^6.20.3",
|
||||||
"@codemirror/commands": "^6.10.3",
|
"@codemirror/commands": "^6.10.3",
|
||||||
"@codemirror/highlight": "^0.19.8",
|
"@codemirror/highlight": "^0.19.8",
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.5",
|
"@codemirror/lang-javascript": "^6.2.5",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@codemirror/lang-markdown": "^6.5.2",
|
||||||
"@codemirror/language": "^6.12.2",
|
"@codemirror/language": "^6.12.2",
|
||||||
"@codemirror/language-data": "^6.5.2",
|
"@codemirror/language-data": "^6.5.2",
|
||||||
"@codemirror/search": "^6.6.0",
|
"@codemirror/search": "^6.6.0",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/view": "^6.43.1",
|
"@codemirror/view": "^6.43.8",
|
||||||
"@dmsnell/diff-match-patch": "^1.1.0",
|
"@dmsnell/diff-match-patch": "^1.1.0",
|
||||||
"@googleapis/drive": "^20.2.0",
|
"@googleapis/drive": "^20.2.0",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@oddbird/css-anchor-positioning": "^0.9.0",
|
"@oddbird/css-anchor-positioning": "^0.10.1",
|
||||||
"@sanity/diff-match-patch": "^3.2.0",
|
"@sanity/diff-match-patch": "^3.2.0",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.3.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"codemirror-5-themes": "^1.5.1",
|
"codemirror-5-themes": "^1.5.1",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"core-js": "^3.49.0",
|
"core-js": "^3.50.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"create-react-class": "^15.7.0",
|
"create-react-class": "^15.7.0",
|
||||||
"dedent": "^1.7.2",
|
"dedent": "^1.7.2",
|
||||||
@@ -123,13 +123,14 @@
|
|||||||
"fs-extra": "^11.3.5",
|
"fs-extra": "^11.3.5",
|
||||||
"hash-wasm": "^4.12.0",
|
"hash-wasm": "^4.12.0",
|
||||||
"idb-keyval": "^6.2.5",
|
"idb-keyval": "^6.2.5",
|
||||||
"js-yaml": "^4.2.0",
|
"js-yaml": "^5.3.0",
|
||||||
"jwt-simple": "^0.5.6",
|
"jwt-simple": "^0.5.6",
|
||||||
"less": "^4.6.4",
|
"less": "^4.8.1",
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"marked": "15.0.12",
|
"marked": "15.0.12",
|
||||||
"marked-alignment-paragraphs": "^1.0.0",
|
"marked-alignment-paragraphs": "^1.0.0",
|
||||||
"marked-definition-lists": "^1.0.1",
|
"marked-definition-lists": "^1.0.1",
|
||||||
|
"marked-diagrams-markdeep": "^1.0.1",
|
||||||
"marked-emoji": "^2.0.3",
|
"marked-emoji": "^2.0.3",
|
||||||
"marked-extended-tables": "^2.0.1",
|
"marked-extended-tables": "^2.0.1",
|
||||||
"marked-gfm-heading-id": "^4.1.4",
|
"marked-gfm-heading-id": "^4.1.4",
|
||||||
@@ -139,34 +140,34 @@
|
|||||||
"marked-variables": "^1.0.5",
|
"marked-variables": "^1.0.5",
|
||||||
"markedLegacy": "npm:marked@^0.3.19",
|
"markedLegacy": "npm:marked@^0.3.19",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mongoose": "^9.7.0",
|
"mongoose": "^9.9.2",
|
||||||
"nanoid": "5.1.11",
|
"nanoid": "6.0.1",
|
||||||
"nconf": "^0.13.0",
|
"nconf": "^0.13.0",
|
||||||
"node": "^25.9.0",
|
"node": "^26.7.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-frame-component": "^5.3.2",
|
"react-frame-component": "^5.3.2",
|
||||||
"react-router": "^7.17.0",
|
"react-router": "^8.3.0",
|
||||||
"sanitize-filename": "1.6.4",
|
"sanitize-filename": "1.6.4",
|
||||||
"superagent": "^10.2.1"
|
"superagent": "^10.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@stylistic/stylelint-plugin": "^5.0.1",
|
"@stylistic/stylelint-plugin": "^5.3.0",
|
||||||
"babel-jest": "^30.4.1",
|
"babel-jest": "^30.4.1",
|
||||||
"babel-plugin-transform-import-meta": "^2.3.3",
|
"babel-plugin-transform-import-meta": "^3.0.0",
|
||||||
"eslint": "9.7",
|
"eslint": "9.7",
|
||||||
"eslint-plugin-jest": "^29.15.1",
|
"eslint-plugin-jest": "^29.15.1",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"globals": "^16.4.0",
|
"globals": "^16.4.0",
|
||||||
"jest": "^30.4.2",
|
"jest": "^30.4.2",
|
||||||
"jest-expect-message": "^1.1.3",
|
"jest-expect-message": "^1.1.3",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^30.0.1",
|
||||||
"jsdom-global": "^3.0.2",
|
"jsdom-global": "^3.0.2",
|
||||||
"postcss-less": "^6.0.0",
|
"postcss-less": "^6.0.0",
|
||||||
"stylelint": "^17.11.1",
|
"stylelint": "^17.11.1",
|
||||||
"stylelint-config-recess-order": "^7.7.0",
|
"stylelint-config-recess-order": "^7.7.0",
|
||||||
"stylelint-config-recommended": "^18.0.0",
|
"stylelint-config-recommended": "^18.0.0",
|
||||||
"supertest": "^7.1.4",
|
"supertest": "^7.1.4",
|
||||||
"vite": "^7.3.1"
|
"vite": "^8.2.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import createApp from './server/app.js';
|
|||||||
import config from './server/config.js';
|
import config from './server/config.js';
|
||||||
import { createServer as createViteServer } from 'vite';
|
import { createServer as createViteServer } from 'vite';
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'local';
|
const isDev = config.get('local_environments').includes(process.env.NODE_ENV);
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
let vite;
|
let vite;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import express from 'express';
|
|||||||
import zlib from 'zlib';
|
import zlib from 'zlib';
|
||||||
import GoogleActions from './googleActions.js';
|
import GoogleActions from './googleActions.js';
|
||||||
import Markdown from '../shared/markdown.js';
|
import Markdown from '../shared/markdown.js';
|
||||||
import yaml from 'js-yaml';
|
import * as yaml from 'js-yaml';
|
||||||
import asyncHandler from 'express-async-handler';
|
import asyncHandler from 'express-async-handler';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { makePatches, applyPatches, stringifyPatches, parsePatch } from '@sanity/diff-match-patch';
|
import { makePatches, applyPatches, stringifyPatches, parsePatch } from '@sanity/diff-match-patch';
|
||||||
@@ -195,7 +195,6 @@ const api = {
|
|||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
getCSS : async (req, res)=>{
|
getCSS : async (req, res)=>{
|
||||||
const { brew } = req;
|
const { brew } = req;
|
||||||
if(!brew) return res.status(404).send('');
|
if(!brew) return res.status(404).send('');
|
||||||
@@ -208,7 +207,6 @@ const api = {
|
|||||||
});
|
});
|
||||||
return res.status(200).send(brew.style);
|
return res.status(200).send(brew.style);
|
||||||
},
|
},
|
||||||
|
|
||||||
mergeBrewText : (brew)=>{
|
mergeBrewText : (brew)=>{
|
||||||
let text = brew.text;
|
let text = brew.text;
|
||||||
if(brew.style !== undefined) {
|
if(brew.style !== undefined) {
|
||||||
@@ -234,7 +232,6 @@ const api = {
|
|||||||
`${text}`;
|
`${text}`;
|
||||||
return text;
|
return text;
|
||||||
},
|
},
|
||||||
|
|
||||||
getGoodBrewTitle : (text)=>{
|
getGoodBrewTitle : (text)=>{
|
||||||
const tokens = Markdown.marked.lexer(text);
|
const tokens = Markdown.marked.lexer(text);
|
||||||
return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title')
|
return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title')
|
||||||
|
|||||||
+148
-155
@@ -203,7 +203,6 @@ describe('Tests for api', ()=>{
|
|||||||
expect(id).toEqual('abcdefghij');
|
expect(id).toEqual('abcdefghij');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getBrew', ()=>{
|
describe('getBrew', ()=>{
|
||||||
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
|
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
|
||||||
const notFoundError = { HBErrorCode: '05', message: 'Brew not found', name: 'BrewLoad Error', status: 404, accessType: 'share', brewId: '1' };
|
const notFoundError = { HBErrorCode: '05', message: 'Brew not found', name: 'BrewLoad Error', status: 404, accessType: 'share', brewId: '1' };
|
||||||
@@ -400,7 +399,68 @@ describe('Tests for api', ()=>{
|
|||||||
await expect(fn(req, null, next)).rejects.toEqual({ 'HBErrorCode': '51', 'brewId': '1', 'brewTitle': 'test brew', 'code': 404, 'message': 'brew locked' });
|
await expect(fn(req, null, next)).rejects.toEqual({ 'HBErrorCode': '51', 'brewId': '1', 'brewTitle': 'test brew', 'code': 404, 'message': 'brew locked' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('Get CSS', ()=>{
|
||||||
|
it('should return brew style content as CSS text', async ()=>{
|
||||||
|
const testBrew = { title: 'test brew', text: '```css\n\nI Have a style!\n```\n\n' };
|
||||||
|
|
||||||
|
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
|
||||||
|
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
|
||||||
|
model.get = jest.fn(()=>toBrewPromise(testBrew));
|
||||||
|
|
||||||
|
const fn = api.getBrew('share', true);
|
||||||
|
const req = { brew: {} };
|
||||||
|
const next = jest.fn();
|
||||||
|
await fn(req, null, next);
|
||||||
|
await api.getCSS(req, res);
|
||||||
|
|
||||||
|
expect(req.brew).toEqual(testBrew);
|
||||||
|
expect(req.brew).toHaveProperty('style', '\nI Have a style!\n');
|
||||||
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('\nI Have a style!\n');
|
||||||
|
expect(res.set).toHaveBeenCalledWith({
|
||||||
|
'Cache-Control' : 'no-cache',
|
||||||
|
'Content-Type' : 'text/css'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 when brew has no style content', async ()=>{
|
||||||
|
const testBrew = { title: 'test brew', text: 'I don\'t have a style!' };
|
||||||
|
|
||||||
|
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
|
||||||
|
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
|
||||||
|
model.get = jest.fn(()=>toBrewPromise(testBrew));
|
||||||
|
|
||||||
|
const fn = api.getBrew('share', true);
|
||||||
|
const req = { brew: {} };
|
||||||
|
const next = jest.fn();
|
||||||
|
await fn(req, null, next);
|
||||||
|
await api.getCSS(req, res);
|
||||||
|
|
||||||
|
expect(req.brew).toEqual(testBrew);
|
||||||
|
expect(req.brew).toHaveProperty('style');
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 when brew does not exist', async ()=>{
|
||||||
|
const testBrew = { };
|
||||||
|
|
||||||
|
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
|
||||||
|
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
|
||||||
|
model.get = jest.fn(()=>toBrewPromise(testBrew));
|
||||||
|
|
||||||
|
const fn = api.getBrew('share', true);
|
||||||
|
const req = { brew: {} };
|
||||||
|
const next = jest.fn();
|
||||||
|
await fn(req, null, next);
|
||||||
|
await api.getCSS(req, res);
|
||||||
|
|
||||||
|
expect(req.brew).toEqual(testBrew);
|
||||||
|
expect(req.brew).toHaveProperty('style');
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('');
|
||||||
|
});
|
||||||
|
});
|
||||||
describe('mergeBrewText', ()=>{
|
describe('mergeBrewText', ()=>{
|
||||||
it('should set metadata and no style if it is not present', ()=>{
|
it('should set metadata and no style if it is not present', ()=>{
|
||||||
const result = api.mergeBrewText({
|
const result = api.mergeBrewText({
|
||||||
@@ -531,7 +591,6 @@ hello yes i am css
|
|||||||
brew`);
|
brew`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('exclusion methods', ()=>{
|
describe('exclusion methods', ()=>{
|
||||||
it('excludePropsFromUpdate removes the correct keys', ()=>{
|
it('excludePropsFromUpdate removes the correct keys', ()=>{
|
||||||
const sent = Object.assign({}, googleBrew);
|
const sent = Object.assign({}, googleBrew);
|
||||||
@@ -568,7 +627,6 @@ brew`);
|
|||||||
expect(result.pageCount).toBe(1);
|
expect(result.pageCount).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('beforeNewSave', ()=>{
|
describe('beforeNewSave', ()=>{
|
||||||
it('sets the title if none', ()=>{
|
it('sets the title if none', ()=>{
|
||||||
const brew = {
|
const brew = {
|
||||||
@@ -610,7 +668,6 @@ brew`);
|
|||||||
expect(hbBrew.text).toEqual('merged');
|
expect(hbBrew.text).toEqual('merged');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('newGoogleBrew', ()=>{
|
describe('newGoogleBrew', ()=>{
|
||||||
it('should call the correct methods', ()=>{
|
it('should call the correct methods', ()=>{
|
||||||
api.excludeGoogleProps = jest.fn(()=>'newBrew');
|
api.excludeGoogleProps = jest.fn(()=>'newBrew');
|
||||||
@@ -624,7 +681,6 @@ brew`);
|
|||||||
expect(google.newGoogleBrew).toHaveBeenCalledWith('client', 'newBrew');
|
expect(google.newGoogleBrew).toHaveBeenCalledWith('client', 'newBrew');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('newBrew', ()=>{
|
describe('newBrew', ()=>{
|
||||||
it('should set up a default brew via Homebrew model', async ()=>{
|
it('should set up a default brew via Homebrew model', async ()=>{
|
||||||
await api.newBrew({ body: { text: 'asdf' }, query: {}, account: { username: 'test user' } }, res);
|
await api.newBrew({ body: { text: 'asdf' }, query: {}, account: { username: 'test user' } }, res);
|
||||||
@@ -754,17 +810,6 @@ brew`);
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteGoogleBrew', ()=>{
|
|
||||||
it('should check auth and delete brew', async ()=>{
|
|
||||||
const result = await api.deleteGoogleBrew({ username: 'test user' }, 'id', 'editId', res);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
expect(google.authCheck).toHaveBeenCalledWith({ username: 'test user' }, expect.objectContaining({}));
|
|
||||||
expect(google.deleteGoogleBrew).toHaveBeenCalledWith('client', 'id', 'editId');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Theme bundle', ()=>{
|
describe('Theme bundle', ()=>{
|
||||||
it('should return Theme Bundle for a User Theme', async ()=>{
|
it('should return Theme Bundle for a User Theme', async ()=>{
|
||||||
const brews = {
|
const brews = {
|
||||||
@@ -908,7 +953,94 @@ brew`);
|
|||||||
status : 422 });
|
status : 422 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('updateBrew', ()=>{
|
||||||
|
it('should return error on version mismatch', async ()=>{
|
||||||
|
const brewFromClient = { version: 1 };
|
||||||
|
const brewFromServer = { version: 1000, text: '' };
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
brew : brewFromServer,
|
||||||
|
body : brewFromClient
|
||||||
|
};
|
||||||
|
|
||||||
|
await api.updateBrew(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(409);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server version is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error on hash mismatch', async ()=>{
|
||||||
|
const brewFromClient = { version: 1, hash: '1234' };
|
||||||
|
const brewFromServer = { version: 1, text: 'test' };
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
brew : brewFromServer,
|
||||||
|
body : brewFromClient
|
||||||
|
};
|
||||||
|
|
||||||
|
await api.updateBrew(req, res);
|
||||||
|
|
||||||
|
expect(req.brew.hash).toBe('098f6bcd4621d373cade4e832627b4f6');
|
||||||
|
expect(res.status).toHaveBeenCalledWith(409);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Commenting this one out for now, since we are no longer throwing this error while we monitor
|
||||||
|
// it('should return error on applying patches', async ()=>{
|
||||||
|
// const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: 'not a valid patch string' };
|
||||||
|
// const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
|
||||||
|
|
||||||
|
// const req = {
|
||||||
|
// brew : brewFromServer,
|
||||||
|
// body : brewFromClient,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// let err;
|
||||||
|
// try {
|
||||||
|
// await api.updateBrew(req, res);
|
||||||
|
// } catch (e) {
|
||||||
|
// err = e;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// expect(err).toEqual(Error('Invalid patch string: not a valid patch string'));
|
||||||
|
// });
|
||||||
|
|
||||||
|
it('should save brew, no ID', async ()=>{
|
||||||
|
const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: '' };
|
||||||
|
const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
|
||||||
|
|
||||||
|
model.save = jest.fn((brew)=>{return brew;});
|
||||||
|
|
||||||
|
const req = {
|
||||||
|
brew : brewFromServer,
|
||||||
|
body : brewFromClient,
|
||||||
|
query : { saveToGoogle: false, removeFromGoogle: false }
|
||||||
|
};
|
||||||
|
|
||||||
|
await api.updateBrew(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
_id : '1',
|
||||||
|
description : 'Test Description',
|
||||||
|
hash : '098f6bcd4621d373cade4e832627b4f6',
|
||||||
|
title : 'Test Title',
|
||||||
|
version : 2
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteGoogleBrew', ()=>{
|
||||||
|
it('should check auth and delete brew', async ()=>{
|
||||||
|
const result = await api.deleteGoogleBrew({ username: 'test user' }, 'id', 'editId', res);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(google.authCheck).toHaveBeenCalledWith({ username: 'test user' }, expect.objectContaining({}));
|
||||||
|
expect(google.deleteGoogleBrew).toHaveBeenCalledWith('client', 'id', 'editId');
|
||||||
|
});
|
||||||
|
});
|
||||||
describe('deleteBrew', ()=>{
|
describe('deleteBrew', ()=>{
|
||||||
it('should handle case where fetching the brew returns an error', async ()=>{
|
it('should handle case where fetching the brew returns an error', async ()=>{
|
||||||
api.getBrew = jest.fn(()=>async ()=>{ throw { message: 'err', HBErrorCode: '02' }; });
|
api.getBrew = jest.fn(()=>async ()=>{ throw { message: 'err', HBErrorCode: '02' }; });
|
||||||
@@ -1129,68 +1261,7 @@ brew`);
|
|||||||
expect(saved.googleId).toEqual(brew.googleId);
|
expect(saved.googleId).toEqual(brew.googleId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('Get CSS', ()=>{
|
|
||||||
it('should return brew style content as CSS text', async ()=>{
|
|
||||||
const testBrew = { title: 'test brew', text: '```css\n\nI Have a style!\n```\n\n' };
|
|
||||||
|
|
||||||
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
|
|
||||||
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
|
|
||||||
model.get = jest.fn(()=>toBrewPromise(testBrew));
|
|
||||||
|
|
||||||
const fn = api.getBrew('share', true);
|
|
||||||
const req = { brew: {} };
|
|
||||||
const next = jest.fn();
|
|
||||||
await fn(req, null, next);
|
|
||||||
await api.getCSS(req, res);
|
|
||||||
|
|
||||||
expect(req.brew).toEqual(testBrew);
|
|
||||||
expect(req.brew).toHaveProperty('style', '\nI Have a style!\n');
|
|
||||||
expect(res.status).toHaveBeenCalledWith(200);
|
|
||||||
expect(res.send).toHaveBeenCalledWith('\nI Have a style!\n');
|
|
||||||
expect(res.set).toHaveBeenCalledWith({
|
|
||||||
'Cache-Control' : 'no-cache',
|
|
||||||
'Content-Type' : 'text/css'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 when brew has no style content', async ()=>{
|
|
||||||
const testBrew = { title: 'test brew', text: 'I don\'t have a style!' };
|
|
||||||
|
|
||||||
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
|
|
||||||
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
|
|
||||||
model.get = jest.fn(()=>toBrewPromise(testBrew));
|
|
||||||
|
|
||||||
const fn = api.getBrew('share', true);
|
|
||||||
const req = { brew: {} };
|
|
||||||
const next = jest.fn();
|
|
||||||
await fn(req, null, next);
|
|
||||||
await api.getCSS(req, res);
|
|
||||||
|
|
||||||
expect(req.brew).toEqual(testBrew);
|
|
||||||
expect(req.brew).toHaveProperty('style');
|
|
||||||
expect(res.status).toHaveBeenCalledWith(404);
|
|
||||||
expect(res.send).toHaveBeenCalledWith('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 404 when brew does not exist', async ()=>{
|
|
||||||
const testBrew = { };
|
|
||||||
|
|
||||||
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
|
|
||||||
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
|
|
||||||
model.get = jest.fn(()=>toBrewPromise(testBrew));
|
|
||||||
|
|
||||||
const fn = api.getBrew('share', true);
|
|
||||||
const req = { brew: {} };
|
|
||||||
const next = jest.fn();
|
|
||||||
await fn(req, null, next);
|
|
||||||
await api.getCSS(req, res);
|
|
||||||
|
|
||||||
expect(req.brew).toEqual(testBrew);
|
|
||||||
expect(req.brew).toHaveProperty('style');
|
|
||||||
expect(res.status).toHaveBeenCalledWith(404);
|
|
||||||
expect(res.send).toHaveBeenCalledWith('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe('Split Text, Style, and Metadata', ()=>{
|
describe('Split Text, Style, and Metadata', ()=>{
|
||||||
|
|
||||||
it('basic splitting', async ()=>{
|
it('basic splitting', async ()=>{
|
||||||
@@ -1302,82 +1373,4 @@ brew`);
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateBrew', ()=>{
|
|
||||||
it('should return error on version mismatch', async ()=>{
|
|
||||||
const brewFromClient = { version: 1 };
|
|
||||||
const brewFromServer = { version: 1000, text: '' };
|
|
||||||
|
|
||||||
const req = {
|
|
||||||
brew : brewFromServer,
|
|
||||||
body : brewFromClient
|
|
||||||
};
|
|
||||||
|
|
||||||
await api.updateBrew(req, res);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(409);
|
|
||||||
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server version is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error on hash mismatch', async ()=>{
|
|
||||||
const brewFromClient = { version: 1, hash: '1234' };
|
|
||||||
const brewFromServer = { version: 1, text: 'test' };
|
|
||||||
|
|
||||||
const req = {
|
|
||||||
brew : brewFromServer,
|
|
||||||
body : brewFromClient
|
|
||||||
};
|
|
||||||
|
|
||||||
await api.updateBrew(req, res);
|
|
||||||
|
|
||||||
expect(req.brew.hash).toBe('098f6bcd4621d373cade4e832627b4f6');
|
|
||||||
expect(res.status).toHaveBeenCalledWith(409);
|
|
||||||
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Commenting this one out for now, since we are no longer throwing this error while we monitor
|
|
||||||
// it('should return error on applying patches', async ()=>{
|
|
||||||
// const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: 'not a valid patch string' };
|
|
||||||
// const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
|
|
||||||
|
|
||||||
// const req = {
|
|
||||||
// brew : brewFromServer,
|
|
||||||
// body : brewFromClient,
|
|
||||||
// };
|
|
||||||
|
|
||||||
// let err;
|
|
||||||
// try {
|
|
||||||
// await api.updateBrew(req, res);
|
|
||||||
// } catch (e) {
|
|
||||||
// err = e;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// expect(err).toEqual(Error('Invalid patch string: not a valid patch string'));
|
|
||||||
// });
|
|
||||||
|
|
||||||
it('should save brew, no ID', async ()=>{
|
|
||||||
const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: '' };
|
|
||||||
const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
|
|
||||||
|
|
||||||
model.save = jest.fn((brew)=>{return brew;});
|
|
||||||
|
|
||||||
const req = {
|
|
||||||
brew : brewFromServer,
|
|
||||||
body : brewFromClient,
|
|
||||||
query : { saveToGoogle: false, removeFromGoogle: false }
|
|
||||||
};
|
|
||||||
|
|
||||||
await api.updateBrew(req, res);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(200);
|
|
||||||
expect(res.send).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
_id : '1',
|
|
||||||
description : 'Test Description',
|
|
||||||
hash : '098f6bcd4621d373cade4e832627b4f6',
|
|
||||||
title : 'Test Title',
|
|
||||||
version : 2
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import yaml from 'js-yaml';
|
import * as yaml from 'js-yaml';
|
||||||
import request from '../client/homebrew/utils/request-middleware.js';
|
import request from '../client/homebrew/utils/request-middleware.js';
|
||||||
|
|
||||||
// Convert the templates from a brew to a Snippets Structure.
|
// Convert the templates from a brew to a Snippets Structure.
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { markedVariables,
|
|||||||
import { markedSmartypantsLite as MarkedSmartypantsLite } from 'marked-smartypants-lite';
|
import { markedSmartypantsLite as MarkedSmartypantsLite } from 'marked-smartypants-lite';
|
||||||
import { gfmHeadingId as MarkedGFMHeadingId, resetHeadings as MarkedGFMResetHeadingIDs } from 'marked-gfm-heading-id';
|
import { gfmHeadingId as MarkedGFMHeadingId, resetHeadings as MarkedGFMResetHeadingIDs } from 'marked-gfm-heading-id';
|
||||||
import { markedEmoji as MarkedEmojis } from 'marked-emoji';
|
import { markedEmoji as MarkedEmojis } from 'marked-emoji';
|
||||||
|
import MarkedDiagramsMarkdeep from 'marked-diagrams-markdeep';
|
||||||
|
|
||||||
|
|
||||||
//Icon fonts included so they can appear in emoji autosuggest dropdown
|
//Icon fonts included so they can appear in emoji autosuggest dropdown
|
||||||
import diceFont from '../themes/fonts/iconFonts/diceFont.js';
|
import diceFont from '../themes/fonts/iconFonts/diceFont.js';
|
||||||
@@ -353,7 +355,10 @@ const tableTerminators = [
|
|||||||
` *{{[^{\n]*\n.*?\n}}` // mustacheDiv
|
` *{{[^{\n]*\n.*?\n}}` // mustacheDiv
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const markdeepOptions = { langs: ['asciiArt'] };
|
||||||
|
|
||||||
Marked.use(markedVariables());
|
Marked.use(markedVariables());
|
||||||
|
Marked.use(MarkedDiagramsMarkdeep(markdeepOptions));
|
||||||
Marked.use(MarkedDefinitionLists());
|
Marked.use(MarkedDefinitionLists());
|
||||||
Marked.use({ extensions: [forcedParagraphBreaks, mustacheSpans, mustacheDivs, mustacheInjectInline] });
|
Marked.use({ extensions: [forcedParagraphBreaks, mustacheSpans, mustacheDivs, mustacheInjectInline] });
|
||||||
Marked.use(mustacheInjectBlock);
|
Marked.use(mustacheInjectBlock);
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ test('Javascript via inline event handler - onMouseOver', function() {
|
|||||||
expect(rendered).toBe('<div>Hover over me</div>');
|
expect(rendered).toBe('<div>Hover over me</div>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Javascript via multiple inline event handlers - onClick + onMouseOver', function() {
|
||||||
|
const source = `<div onclick="alert('This is a JavaScript injection via inline event handler')" onmouseover="alert('This is a JavaScript injection via inline event handler')">Hover over or Click me</div>`;
|
||||||
|
const rendered = safeHTML(source);
|
||||||
|
expect(rendered).toBe('<div>Hover over or Click me</div>');
|
||||||
|
});
|
||||||
|
|
||||||
test('Javascript via data attribute', function() {
|
test('Javascript via data attribute', function() {
|
||||||
const source = `<div data-code="javascript:alert('This is a JavaScript injection via data attribute')">Test</div>`;
|
const source = `<div data-code="javascript:alert('This is a JavaScript injection via data attribute')">Test</div>`;
|
||||||
const rendered = safeHTML(source);
|
const rendered = safeHTML(source);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ String.prototype.trimReturns = function(){
|
|||||||
return this.replace(/\r?\n|\r/g, '').trim();
|
return this.replace(/\r?\n|\r/g, '').trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
renderAllPages = function(pages){
|
const renderAllPages = function(pages){
|
||||||
const outputs = [];
|
const outputs = [];
|
||||||
pages.forEach((page, index)=>{
|
pages.forEach((page, index)=>{
|
||||||
const output = Markdown.render(page, index);
|
const output = Markdown.render(page, index);
|
||||||
|
|||||||
Reference in New Issue
Block a user