0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-08-06 08:57:38 +00:00

Merge branch 'master' of https://github.com/naturalcrit/homebrewery into add-cm-features

This commit is contained in:
Víctor Losada Hernández
2026-07-26 11:20:38 +02:00
37 changed files with 1203 additions and 784 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ orbs:
jobs: jobs:
build: build:
docker: docker:
- image: cimg/node:20.18.0 - image: cimg/node:26.4
- image: mongo:4.4 - image: mongo:4.4
working_directory: ~/homebrewery working_directory: ~/homebrewery
@@ -27,7 +27,7 @@ jobs:
# fallback to using the latest cache if no exact match is found # fallback to using the latest cache if no exact match is found
- v1-dependencies- - v1-dependencies-
- run: sudo npm install -g npm@10.8.2 - run: sudo npm install -g npm@11.17.0
- node/install-packages: - node/install-packages:
app-dir: ~/homebrewery app-dir: ~/homebrewery
cache-path: node_modules cache-path: node_modules
@@ -45,7 +45,7 @@ jobs:
test: test:
docker: docker:
- image: cimg/node:20.17.0 - image: cimg/node:26.4
working_directory: ~/homebrewery working_directory: ~/homebrewery
parallelism: 1 parallelism: 1
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine FROM node:26.4.0-alpine
RUN apk --no-cache add git RUN apk --no-cache add git
ENV NODE_ENV=docker ENV NODE_ENV=docker
+2
View File
@@ -1,6 +1,8 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import Admin from './admin.jsx'; import Admin from './admin.jsx';
import { bootstrapAnchorPositioningPolyfill } from '@components/anchorPositioningPolyfill.js';
const props = window.__INITIAL_PROPS__ || {}; const props = window.__INITIAL_PROPS__ || {};
createRoot(document.getElementById('reactRoot')).render(<Admin {...props} />); createRoot(document.getElementById('reactRoot')).render(<Admin {...props} />);
bootstrapAnchorPositioningPolyfill();
+11 -4
View File
@@ -71,10 +71,14 @@ const Anchored = ({ children })=>{
// forward ref for AnchoredTrigger // forward ref for AnchoredTrigger
const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>( const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, className, ...props }, ref)=>(
<button <button
ref={ref} ref={(el)=>{
// setAttribute bypasses React's style sanitization so the anchor polyfill can read it
el?.setAttribute('style', `anchor-name: --${props.id}`);
if(typeof ref === 'function') ref(el);
else if(ref) ref.current = el;
}}
className={`anchored-trigger${visible ? ' active' : ''} ${className}`} className={`anchored-trigger${visible ? ' active' : ''} ${className}`}
onClick={toggleVisibility} onClick={toggleVisibility}
style={{ anchorName: `--${props.id}` }} // setting anchor properties here allows greater recyclability.
{...props} {...props}
> >
{children} {children}
@@ -84,9 +88,12 @@ const AnchoredTrigger = forwardRef(({ toggleVisibility, visible, children, class
// forward ref for AnchoredBox // forward ref for AnchoredBox
const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>( const AnchoredBox = forwardRef(({ visible, children, className, anchorId, ...props }, ref)=>(
<div <div
ref={ref} ref={(el)=>{
el?.setAttribute('style', `position-anchor: --${anchorId}`);
if(typeof ref === 'function') ref(el);
else if(ref) ref.current = el;
}}
className={`anchored-box${visible ? ' active' : ''} ${className}`} className={`anchored-box${visible ? ' active' : ''} ${className}`}
style={{ positionAnchor: `--${anchorId}` }} // setting anchor properties here allows greater recyclability.
{...props} {...props}
> >
{children} {children}
-2
View File
@@ -4,8 +4,6 @@
position : absolute; position : absolute;
visibility : hidden; visibility : hidden;
justify-self : anchor-center; justify-self : anchor-center;
@supports (inset-block-start: anchor(bottom)) {
inset-block-start : anchor(bottom); inset-block-start : anchor(bottom);
}
&.active { visibility : visible; } &.active { visibility : visible; }
} }
@@ -0,0 +1,27 @@
/*
This file basically checks support for Anchor Positioning API in the browser,
and then loads the Oddbird polyfill if support is lacking.
*/
let polyfillPromise;
// look for `anchorName` in the computed styles
const supportsAnchorPositioning = ()=>'anchorName' in document.documentElement.style;
export const bootstrapAnchorPositioningPolyfill = ()=>{
if(supportsAnchorPositioning()) return Promise.resolve(false);
if(polyfillPromise) return polyfillPromise;
polyfillPromise = (async ()=>{
try {
const { default: polyfill } = await import('@oddbird/css-anchor-positioning/fn');
await polyfill();
return true;
} catch (error){
polyfillPromise = undefined;
throw error;
}
})();
return polyfillPromise;
};
@@ -1,6 +1,7 @@
/* 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';
import * as prettier from 'prettier/standalone'; import * as prettier from 'prettier/standalone';
import * as postcssPlugin from 'prettier/plugins/postcss'; import * as postcssPlugin from 'prettier/plugins/postcss';
@@ -57,30 +58,40 @@ export async function formatCSS(view) {
return true; return true;
} }
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 = [];
@@ -222,11 +233,12 @@ 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 cssKeymap = Prec.highest(keymap.of([ export const cssKeymap = Prec.highest(keymap.of([
@@ -235,8 +247,7 @@ export const cssKeymap = Prec.highest(keymap.of([
])); ]));
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
+123
View File
@@ -0,0 +1,123 @@
/**
* A dropdown menu component that uses the Anchor Positioning API to position the elements. It supports nested submenus as well.
* Anchor Positioning is now supported in all major browsers. A polyfill is conditionally loaded for older browsers.
*
* As-is, the menus will always open down aligned on left to trigger, submenus open to the right initially.
* If no space, menus will still open down, but aligned to the right of the trigger. Submenus will flip to the other side of the top menu.
* This could be customized either in more specific CSS, or as a `direction` prop on the component (in future iterations).
*
* @param {string} props.groupName - Name of the menu. Appears as the trigger text.
* @param {string} [props.icon] - Icon to display in the trigger.
* @param {string} [props.color] - Color class to add to the trigger.
* @param {string} [props.className] - Additional classes for the menu wrapper.
* @param {React.ReactNode} [props.customTrigger] - Custom element to use as a trigger.
* @param {React.ReactNode} [props.children] - Child elements to render in the menu.
* @returns {React.JSX.Element}
*/
import './dropdown.less';
import React, { useEffect, useId, useRef } from 'react';
import _ from 'lodash';
// use react context to keep track of the menu depth (menus in menus)
const MenuDepthContext = React.createContext(0);
const Dropdown = ({ groupName, className = null, icon, children, color = null, customTrigger, ...props })=>{
const reactId = useId();
const safeId = reactId.replace(/[^a-zA-Z0-9_-]/g, '');
const menuId = `${_.kebabCase(groupName)}-${safeId}-menu`;
const anchorName = `--${menuId}`;
const depth = React.useContext(MenuDepthContext);
// A menu is a submenu if depth > 0
const isSubMenu = depth > 0;
const triggerRef = useRef(null);
const menuRef = useRef(null);
// use setAttribute instead of the React style prop because React strips unknown CSS
// properties (like anchor-name) from inline styles in browsers that don't support them.
// setAttribute writes raw CSS text that the anchor positioning polyfill can read
useEffect(()=>{
triggerRef.current?.setAttribute('style', `anchor-name: ${anchorName}`);
menuRef.current?.setAttribute('style', `position-anchor: ${anchorName}`);
}, [anchorName]);
// hide popover with click inside iframe (not supported by light dismiss)
useEffect(()=>{
const menuElement = document.getElementById(menuId);
if(!menuElement) return;
const handleClick = ()=>{
if(menuElement.matches(':popover-open')) {
menuElement.hidePopover();
}
};
// Listen for clicks from both the main document and the iframe
document.addEventListener('iframe-click', handleClick);
return ()=>{
document.removeEventListener('iframe-click', handleClick);
};
}, [menuId]);
// the trigger is the piece placed inside the opening button of the menu.
// This method allows for creating a generic span with the group name,
// or using a bespoke element (like a graphic) passed in from props to be used as the trigger
const trigger = (groupName = 'menu', icon = '')=>{
if(!customTrigger){
return <>
<i className={icon}></i><span className='menu-name'>{groupName}</span><i className={`caret fas fa-caret-${isSubMenu ? 'right' : 'down'}`}></i>
</>;
} else {
return customTrigger;
}
};
// handle clicks on menu items. By default, actions do dismiss.
const handleMenuActionClick = (event)=>{
const menuElement = menuRef.current;
if(!menuElement) return;
const menuAction = event.target.closest('button, a, [role="menuitem"]');
if(!menuAction || !menuElement.contains(menuAction)) return;
// don't dismiss if the target triggers a submenu
if(menuAction.hasAttribute('popovertarget')) return;
// don't dismiss if the target has `no-dismiss` attribute
const noDismissValue = menuAction.getAttribute('no-dismiss')?.toLowerCase();
if(noDismissValue === '' || noDismissValue === 'true') return;
document.querySelectorAll('.menu-list:popover-open').forEach((openMenu)=>openMenu.hidePopover());
};
return (
<div className={['menu-wrapper', className].join(' ')} role='none' >
<button
id={`${menuId}-trigger`}
className={['menu-item', color].join(' ')}
popoverTarget={menuId}
aria-haspopup='menu'
role='menuitem'
disabled={!React.Children.count(children)}
ref={triggerRef}
>
{trigger(groupName, icon)}
</button>
<MenuDepthContext.Provider value={depth + 1}>
<div
ref={menuRef}
id={menuId}
className='menu-list'
popover='auto'
role='menu'
onClick={handleMenuActionClick}
>
{children}
</div>
</MenuDepthContext.Provider>
</div>
);
};
export { Dropdown };
+24
View File
@@ -0,0 +1,24 @@
.menu-wrapper {
position: relative;
&:is(.menu-bar > .menu-section > .menu-wrapper){
display: inline-block;
}
}
.menu-list {
position : fixed;
z-index : 1000;
top : anchor(bottom);
left : anchor(left);
position-try: flip-inline flip-block;
color: inherit; // [popover] gets a `canvastext` color value from useragent.
> .menu-wrapper {
position:relative;
> .menu-list {
margin: 0 0px;
top : anchor(top);
left : anchor(right);
position-try: flip-inline;
}
}
}
@@ -11,7 +11,7 @@ import ErrorBar from './errorBar/errorBar.jsx';
import ToolBar from './toolBar/toolBar.jsx'; import ToolBar from './toolBar/toolBar.jsx';
//TODO: move to the brew renderer //TODO: move to the brew renderer
import RenderWarnings from '../../components/renderWarnings/renderWarnings.jsx'; import RenderWarnings from '@components/renderWarnings/renderWarnings.jsx';
import NotificationPopup from './notificationPopup/notificationPopup.jsx'; import NotificationPopup from './notificationPopup/notificationPopup.jsx';
import Frame from 'react-frame-component'; import Frame from 'react-frame-component';
import dedent from 'dedent'; import dedent from 'dedent';
@@ -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" />
@@ -210,7 +211,7 @@ const BrewRenderer = (props)=>{
classes = [classes, injectedTags.classes].join(' ').trim(); classes = [classes, injectedTags.classes].join(' ').trim();
attributes = injectedTags.attributes; attributes = injectedTags.attributes;
if(global.enablev4) { if(global.enablev4) {
if (attributes && Object.hasOwn(attributes, 'hbtemplate')) { if(attributes && Object.hasOwn(attributes, 'hbtemplate')) {
pageTemplates[index] = attributes['hbtemplate']; pageTemplates[index] = attributes['hbtemplate'];
} }
} }
@@ -220,7 +221,7 @@ const BrewRenderer = (props)=>{
if(!pageTemplates[index]) { if(!pageTemplates[index]) {
for (let i=index;i>=0; i--) { for (let i=index;i>=0; i--) {
// If one is found, add the template attribute // If one is found, add the template attribute
if (pageTemplates[i]) attributes['hbtemplate'] = pageTemplates[i]; if(pageTemplates[i]) attributes['hbtemplate'] = pageTemplates[i];
} }
} }
} }
@@ -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}
@@ -1,7 +1,7 @@
import './errorBar.less'; import './errorBar.less';
import React from 'react'; import React from 'react';
import Dialog from '../../../components/dialog.jsx'; import Dialog from '@components/dialog.jsx';
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />; const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
import Markdown from '@shared/markdown.js'; import Markdown from '@shared/markdown.js';
import Dialog from '../../../components/dialog.jsx'; import Dialog from '@components/dialog.jsx';
const DISMISS_BUTTON = <i className='fas fa-times dismiss' />; const DISMISS_BUTTON = <i className='fas fa-times dismiss' />;
@@ -3,7 +3,7 @@ import './toolBar.less';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import { Anchored, AnchoredBox, AnchoredTrigger } from '../../../components/Anchored.jsx'; import { Anchored, AnchoredBox, AnchoredTrigger } from '@components/Anchored.jsx';
const MAX_ZOOM = 300; const MAX_ZOOM = 300;
const MIN_ZOOM = 10; const MIN_ZOOM = 10;
+1 -1
View File
@@ -5,7 +5,7 @@ import createReactClass from 'create-react-class';
import _ from 'lodash'; import _ from 'lodash';
import dedent from 'dedent'; import dedent from 'dedent';
import CodeEditor from '../../components/codeEditor/codeEditor.jsx'; import CodeEditor from '@components/codeEditor/codeEditor.jsx';
import SnippetBar from './snippetbar/snippetbar.jsx'; import SnippetBar from './snippetbar/snippetbar.jsx';
import MetadataEditor from './metadataEditor/metadataEditor.jsx'; import MetadataEditor from './metadataEditor/metadataEditor.jsx';
@@ -4,7 +4,7 @@ import React from 'react';
import createReactClass from 'create-react-class'; import createReactClass from 'create-react-class';
import _ from 'lodash'; import _ from 'lodash';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
import Combobox from '../../../components/combobox.jsx'; import Combobox from '@components/combobox.jsx';
import TagInput from '../tagInput/tagInput.jsx'; import TagInput from '../tagInput/tagInput.jsx';
@@ -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 {
@@ -2,6 +2,7 @@
import './snippetbar.less'; import './snippetbar.less';
import React from 'react'; import React from 'react';
import createReactClass from 'create-react-class'; import createReactClass from 'create-react-class';
import { Dropdown } from '@components/dropdown/dropdown.jsx';
import _ from 'lodash'; import _ from 'lodash';
import cx from 'classnames'; import cx from 'classnames';
@@ -325,36 +326,33 @@ const SnippetGroup = createReactClass({
}; };
}, },
handleSnippetClick : function(e, snippet){ handleSnippetClick : function(e, snippet){
e.stopPropagation();
this.props.onSnippetClick(execute(snippet.gen, this.props)); this.props.onSnippetClick(execute(snippet.gen, this.props));
}, },
renderSnippets : function(snippets){ renderSnippets : function(snippets){
return _.map(snippets, (snippet)=>{ return _.map(snippets, (snippet)=>{
return <div className='snippet' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)}> if(!snippet.subsnippets){
return (
<button className='menu-item' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)} role='menuitem'>
<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='beta'>beta</span>}
{snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>} {snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
{snippet.subsnippets && <> </button>
<i className='fas fa-caret-right'></i> );
<div className='dropdown side'> } else if(snippet.subsnippets){
return (
<Dropdown groupName={snippet.name} icon={snippet.icon} key={snippet.name}>
{this.renderSnippets(snippet.subsnippets)} {this.renderSnippets(snippet.subsnippets)}
</div></>} </Dropdown>
</div>; )
}
}); });
}, },
render : function(){ render : function(){
const snippetGroup = `snippetGroup snippetBarButton ${this.props.snippets.length === 0 ? 'disabledSnippets' : ''}`; return <Dropdown groupName={this.props.groupName} id={this.props.groupName} icon={this.props.icon}>
return <div className={snippetGroup}>
<div className='text'>
<i className={this.props.icon} />
<span className='groupName'>{this.props.groupName}</span>
</div>
<div className='dropdown'>
{this.renderSnippets(this.props.snippets)} {this.renderSnippets(this.props.snippets)}
</div> </Dropdown>;
</div>;
}, },
}); });
@@ -11,18 +11,22 @@
height : auto; height : auto;
color : black; color : black;
background-color : #DDDDDD; background-color : #DDDDDD;
font-size : .65rem;
font-family: 'Open Sans', sans-serif;
text-transform: uppercase;
font-weight: 800;
.snippets { .snippets {
display : flex; display : flex;
justify-content : flex-start; justify-content : flex-start;
min-width : 470px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied min-width : 565px; //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;
min-width : 275px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied min-width : 275px; //must be controlled every time an item is added, must be hardcoded for the wrapping as it is applied
font-size: .85rem;
&:only-child {min-width : unset; margin-left : auto;} &:only-child {min-width : unset; margin-left : auto;}
>div { >div {
@@ -57,25 +61,21 @@
} }
&.undo { &.undo {
.tooltipLeft('Undo'); .tooltipLeft('Undo');
font-size : 0.75em;
color : grey; color : grey;
&.active { color : inherit; } &.active { color : inherit; }
} }
&.redo { &.redo {
.tooltipLeft('Redo'); .tooltipLeft('Redo');
font-size : 0.75em;
color : grey; color : grey;
&.active { color : inherit; } &.active { color : inherit; }
} }
&.foldAll { &.foldAll {
.tooltipLeft('Fold All'); .tooltipLeft('Fold All');
font-size : 0.75em;
color : grey; color : grey;
&.active { color : inherit; } &.active { color : inherit; }
} }
&.unfoldAll { &.unfoldAll {
.tooltipLeft('Unfold All'); .tooltipLeft('Unfold All');
font-size : 0.75em;
color : grey; color : grey;
&.active { color : inherit; } &.active { color : inherit; }
} }
@@ -88,7 +88,6 @@
&.history { &.history {
.tooltipLeft('History'); .tooltipLeft('History');
position : relative; position : relative;
font-size : 0.75em;
color : grey; color : grey;
border : none; border : none;
&.active { color : inherit; } &.active { color : inherit; }
@@ -99,7 +98,6 @@
} }
&.editorTheme { &.editorTheme {
.tooltipLeft('Editor Themes'); .tooltipLeft('Editor Themes');
font-size : 0.75em;
color : inherit; color : inherit;
&.active { &.active {
position : relative; position : relative;
@@ -150,37 +148,49 @@
border-left : 1px solid black; border-left : 1px solid black;
.tooltipLeft('Edit Brew Properties'); .tooltipLeft('Edit Brew Properties');
} }
.snippetGroup {
&:hover {
& > .dropdown { visibility : visible; } .menu-wrapper {
.menu-item:is(.snippets > .menu-wrapper > .menu-item):first-child {
.caret {
display: none;
} }
.dropdown { }
position : absolute; .menu-list {
top : 100%;
z-index : 1000;
visibility : hidden;
padding : 0px; padding : 0px;
margin-left : -5px;
background-color : #DDDDDD; background-color : #DDDDDD;
.snippet {
}
}
.menu-item {
position : relative; position : relative;
display : flex; display : flex;
justify-content: space-between;
align-items : center; align-items : center;
min-width : max-content; min-width : max-content;
padding : 5px; padding : 5px;
font-size : 10px;
cursor : pointer; cursor : pointer;
width: 100%;
.animate(background-color); .animate(background-color);
&:is(.menu-list .menu-item) [class*="name"] {
padding-inline: 8px;
}
.menu-name {
flex: 1;
text-align: left;
text-box-trim: trim-end;
}
i { i {
min-width : 25px; min-width : 25px;
height : 1.2em; height : .85rem;
margin-right : 8px;
font-size : 1.2em; font-size : 1.2em;
text-align : center; text-align : center;
& ~ i { &.caret {
margin-right : 0; margin-right: 0;
margin-left : 5px; }
&.caret:is(.menu-wrapper .menu-wrapper * ) {
text-align: right;
} }
/* Fonts */ /* Fonts */
&.font { &.font {
@@ -223,17 +233,11 @@
} }
&:hover { &:hover {
background-color : #999999; background-color : #999999;
& > .dropdown {
visibility : visible;
&.side {
top : 0%;
left : 100%;
margin-left : 0;
box-shadow : -1px 1px 2px 0px #999999;
}
}
}
} }
&:disabled {
color: gray;
cursor: not-allowed;
&:hover { background-color: unset; }
} }
} }
.disabledSnippets { .disabledSnippets {
@@ -244,7 +248,7 @@
} }
} }
@container editor (width < 745px) { @container editor (width < 841px) {
.snippetBar { .snippetBar {
.editors { .editors {
flex : 1; flex : 1;
+1 -1
View File
@@ -1,6 +1,6 @@
import './tagInput.less'; import './tagInput.less';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Combobox from '../../../components/combobox.jsx'; import Combobox from '@components/combobox.jsx';
import { tagSuggestionList, canonizationList } from './curatedTagSuggestionList.js'; import { tagSuggestionList, canonizationList } from './curatedTagSuggestionList.js';
+2
View File
@@ -1,6 +1,8 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import Homebrew from './homebrew.jsx'; import Homebrew from './homebrew.jsx';
import { bootstrapAnchorPositioningPolyfill } from '@components/anchorPositioningPolyfill.js';
const props = window.__INITIAL_PROPS__ || {}; const props = window.__INITIAL_PROPS__ || {};
createRoot(document.getElementById('reactRoot')).render(<Homebrew {...props} />); createRoot(document.getElementById('reactRoot')).render(<Homebrew {...props} />);
bootstrapAnchorPositioningPolyfill();
+1 -1
View File
@@ -4,7 +4,7 @@ import createReactClass from 'create-react-class';
import _ from 'lodash'; 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 : createReactClass({
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import moment from 'moment'; import moment from 'moment';
import UIPage from '../basePages/uiPage/uiPage.jsx'; import UIPage from '../basePages/uiPage/uiPage.jsx';
import NaturalCritIcon from '../../../components/svg/naturalcrit-d20.svg.jsx'; import NaturalCritIcon from '@components/svg/naturalcrit-d20.svg.jsx';
let SAVEKEY = ''; let SAVEKEY = '';
+1 -1
View File
@@ -10,7 +10,7 @@ import _ from 'lodash';
import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW_LOAD } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js'; import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import SplitPane from '../../../components/splitPane/splitPane.jsx'; import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx'; import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
@@ -1,7 +1,7 @@
import './lockNotification.less'; import './lockNotification.less';
import * as React from 'react'; import * as React from 'react';
import request from '../../../utils/request-middleware.js'; import request from '../../../utils/request-middleware.js';
import Dialog from '../../../../components/dialog.jsx'; import Dialog from '@components/dialog.jsx';
function LockNotification(props) { function LockNotification(props) {
props = { props = {
+1 -1
View File
@@ -10,7 +10,7 @@ import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js'; import { printCurrentBrew, fetchThemeBundle } from '@shared/helpers.js';
import SplitPane from '../../../components/splitPane/splitPane.jsx'; import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx'; import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
+1 -1
View File
@@ -10,7 +10,7 @@ import _ from 'lodash';
import { DEFAULT_BREW } from '../../../../server/brewDefaults.js'; import { DEFAULT_BREW } from '../../../../server/brewDefaults.js';
import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js'; import { printCurrentBrew, fetchThemeBundle, splitTextStyleAndMetadata } from '@shared/helpers.js';
import SplitPane from '../../../components/splitPane/splitPane.jsx'; import SplitPane from '@components/splitPane/splitPane.jsx';
import Editor from '../../editor/editor.jsx'; import Editor from '../../editor/editor.jsx';
import BrewRenderer from '../../brewRenderer/brewRenderer.jsx'; import BrewRenderer from '../../brewRenderer/brewRenderer.jsx';
@@ -11,7 +11,7 @@ import Account from '@navbar/account.navitem.jsx';
import NewBrew from '@navbar/newbrew.navitem.jsx'; import NewBrew from '@navbar/newbrew.navitem.jsx';
import HelpNavItem from '@navbar/help.navitem.jsx'; import HelpNavItem from '@navbar/help.navitem.jsx';
import BrewItem from '../basePages/listPage/brewItem/brewItem.jsx'; import BrewItem from '../basePages/listPage/brewItem/brewItem.jsx';
import SplitPane from '../../../components/splitPane/splitPane.jsx'; import SplitPane from '@components/splitPane/splitPane.jsx';
import ErrorIndex from '../errorPage/errors/errorIndex.js'; import ErrorIndex from '../errorPage/errors/errorIndex.js';
import request from '../../utils/request-middleware.js'; import request from '../../utils/request-middleware.js';
+549 -412
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"engines": { "engines": {
"npm": ">=10.8 <12", "npm": ">=10.8 <12",
"node": ">=20.18 <25" "node": ">=26.4.0"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -105,6 +105,7 @@
"@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",
"@sanity/diff-match-patch": "^3.2.0", "@sanity/diff-match-patch": "^3.2.0",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
+1 -1
View File
@@ -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;
-3
View File
@@ -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) {
@@ -226,7 +224,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
View File
@@ -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' };
@@ -380,7 +379,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({
@@ -437,7 +497,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);
@@ -474,7 +533,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 = {
@@ -516,7 +574,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');
@@ -530,7 +587,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);
@@ -620,17 +676,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 = {
@@ -774,7 +819,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' }; });
@@ -995,68 +1127,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 ()=>{
@@ -1095,82 +1166,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
})
);
});
});
}); });
+2
View File
@@ -21,3 +21,5 @@
text-transform : unset; text-transform : unset;
background-color : unset; background-color : unset;
} }
:where(i){ text-box-trim: trim-both }
-5
View File
@@ -112,25 +112,21 @@ export default [
name : 'Front Cover Page', name : 'Front Cover Page',
icon : 'fac book-front-cover', icon : 'fac book-front-cover',
gen : CoverPageGen.front, gen : CoverPageGen.front,
experimental : true
}, },
{ {
name : 'Inside Cover Page', name : 'Inside Cover Page',
icon : 'fac book-inside-cover', icon : 'fac book-inside-cover',
gen : CoverPageGen.inside, gen : CoverPageGen.inside,
experimental : true
}, },
{ {
name : 'Part Cover Page', name : 'Part Cover Page',
icon : 'fac book-part-cover', icon : 'fac book-part-cover',
gen : CoverPageGen.part, gen : CoverPageGen.part,
experimental : true
}, },
{ {
name : 'Back Cover Page', name : 'Back Cover Page',
icon : 'fac book-back-cover', icon : 'fac book-back-cover',
gen : CoverPageGen.back, gen : CoverPageGen.back,
experimental : true
}, },
{ {
name : 'Magic Item', name : 'Magic Item',
@@ -212,7 +208,6 @@ export default [
name : 'Rune Table', name : 'Rune Table',
icon : 'fas fa-language', icon : 'fas fa-language',
gen : scriptGen.dwarvish, gen : scriptGen.dwarvish,
experimental : true,
subsnippets : [ subsnippets : [
{ {
name : 'Dwarvish', name : 'Dwarvish',
+5 -6
View File
@@ -198,7 +198,6 @@ export default [
name : 'Index', name : 'Index',
icon : 'fas fa-bars', icon : 'fas fa-bars',
gen : indexGen, gen : indexGen,
experimental : true
}, },
] ]
@@ -330,7 +329,7 @@ export default [
}, },
{ {
name : 'DTRPG Community Content', name : 'DTRPG Community Content',
incon : 'fab fa-dtrpg', icon : null,
subsnippets : [ subsnippets : [
{ {
name : 'Chronicle System Guild Colophon', name : 'Chronicle System Guild Colophon',
@@ -520,13 +519,13 @@ export default [
{ {
name : 'MIT License', name : 'MIT License',
icon : 'fas fa-mit', icon : null,
gen : LicenseGen.mit, gen : LicenseGen.mit,
}, },
{ {
name : 'Mongoose Publishing Fair Use', name : 'Mongoose Publishing Fair Use',
icon : 'fas fa-mongoosepub', icon : null,
subsnippets : [ subsnippets : [
{ {
name : 'Long Form Fair Use', name : 'Long Form Fair Use',
@@ -554,14 +553,14 @@ export default [
{ {
name : 'ORC Notice', name : 'ORC Notice',
icon : 'fas fa-Paizo', icon : null,
gen : LicenseGen.orc1, gen : LicenseGen.orc1,
}, },
{ {
name : 'Shadowdark', name : 'Shadowdark',
icon : 'fab fa-shadowdark', icon : null,
subsnippets : [ subsnippets : [
{ {
name : 'Logos', name : 'Logos',
+1
View File
@@ -13,6 +13,7 @@ export default defineConfig({
'@sharedStyles' : path.resolve(__dirname, './shared/naturalcrit/styles'), '@sharedStyles' : path.resolve(__dirname, './shared/naturalcrit/styles'),
'@navbar' : path.resolve(__dirname, './client/homebrew/navbar'), '@navbar' : path.resolve(__dirname, './client/homebrew/navbar'),
'@themes' : path.resolve(__dirname, './themes'), '@themes' : path.resolve(__dirname, './themes'),
'@components' : path.resolve(__dirname, './client/components')
}, },
}, },
build : { build : {