mirror of
https://github.com/naturalcrit/homebrewery.git
synced 2026-08-06 06:47:38 +00:00
Merge branch 'master' of https://github.com/naturalcrit/homebrewery into add-cm-features
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
|
|
||||||
|
|
||||||
.anchored-box {
|
.anchored-box {
|
||||||
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
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'>
|
||||||
}
|
<label>authors</label>
|
||||||
return <div className='field authors'>
|
<div className='value'>
|
||||||
<label>authors</label>
|
{authors.length > 0 && (
|
||||||
<div className='value'>
|
<a href={`/user/${authors[0]}`} className='author-link' target="_blank" title={`Owner - Click to open ${authors[0]}'s profile in a new tab`}>
|
||||||
{text}
|
{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){
|
||||||
<i className={snippet.icon} />
|
return (
|
||||||
<span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span>
|
<button className='menu-item' key={snippet.name} onClick={(e)=>this.handleSnippetClick(e, snippet)} role='menuitem'>
|
||||||
{snippet.experimental && <span className='beta'>beta</span>}
|
<i className={snippet.icon} />
|
||||||
{snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
|
<span className={`name${snippet.disabled ? ' disabled' : ''}`} title={snippet.name}>{snippet.name}</span>
|
||||||
{snippet.subsnippets && <>
|
{snippet.experimental && <span className='beta'>beta</span>}
|
||||||
<i className='fas fa-caret-right'></i>
|
{snippet.disabled && <span className='beta' title='temporarily disabled due to large slowdown; under re-design'>disabled</span>}
|
||||||
<div className='dropdown side'>
|
</button>
|
||||||
|
);
|
||||||
|
} 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}>
|
{this.renderSnippets(this.props.snippets)}
|
||||||
<div className='text'>
|
</Dropdown>;
|
||||||
<i className={this.props.icon} />
|
|
||||||
<span className='groupName'>{this.props.groupName}</span>
|
|
||||||
</div>
|
|
||||||
<div className='dropdown'>
|
|
||||||
{this.renderSnippets(this.props.snippets)}
|
|
||||||
</div>
|
|
||||||
</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,91 +148,97 @@
|
|||||||
border-left : 1px solid black;
|
border-left : 1px solid black;
|
||||||
.tooltipLeft('Edit Brew Properties');
|
.tooltipLeft('Edit Brew Properties');
|
||||||
}
|
}
|
||||||
.snippetGroup {
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
& > .dropdown { visibility : visible; }
|
|
||||||
}
|
|
||||||
.dropdown {
|
|
||||||
position : absolute;
|
|
||||||
top : 100%;
|
|
||||||
z-index : 1000;
|
|
||||||
visibility : hidden;
|
|
||||||
padding : 0px;
|
|
||||||
margin-left : -5px;
|
|
||||||
background-color : #DDDDDD;
|
|
||||||
.snippet {
|
|
||||||
position : relative;
|
|
||||||
display : flex;
|
|
||||||
align-items : center;
|
|
||||||
min-width : max-content;
|
|
||||||
padding : 5px;
|
|
||||||
font-size : 10px;
|
|
||||||
cursor : pointer;
|
|
||||||
.animate(background-color);
|
|
||||||
i {
|
|
||||||
min-width : 25px;
|
|
||||||
height : 1.2em;
|
|
||||||
margin-right : 8px;
|
|
||||||
font-size : 1.2em;
|
|
||||||
text-align : center;
|
|
||||||
& ~ i {
|
|
||||||
margin-right : 0;
|
|
||||||
margin-left : 5px;
|
|
||||||
}
|
|
||||||
/* Fonts */
|
|
||||||
&.font {
|
|
||||||
height : auto;
|
|
||||||
&::before {
|
|
||||||
font-size : 1em;
|
|
||||||
content : 'ABC';
|
|
||||||
}
|
|
||||||
|
|
||||||
&.OpenSans {font-family : 'OpenSans';}
|
.menu-wrapper {
|
||||||
&.CodeBold {font-family : 'CodeBold';}
|
.menu-item:is(.snippets > .menu-wrapper > .menu-item):first-child {
|
||||||
&.CodeLight {font-family : 'CodeLight';}
|
.caret {
|
||||||
&.ScalySansRemake {font-family : 'ScalySansRemake';}
|
display: none;
|
||||||
&.BookInsanityRemake {font-family : 'BookInsanityRemake';}
|
|
||||||
&.MrEavesRemake {font-family : 'MrEavesRemake';}
|
|
||||||
&.SolberaImitationRemake {font-family : 'SolberaImitationRemake';}
|
|
||||||
&.ScalySansSmallCapsRemake {font-family : 'ScalySansSmallCapsRemake';}
|
|
||||||
&.WalterTurncoat {font-family : 'WalterTurncoat';}
|
|
||||||
&.Lato {font-family : 'Lato';}
|
|
||||||
&.Courier {font-family : 'Courier';}
|
|
||||||
&.NodestoCapsCondensed {font-family : 'NodestoCapsCondensed';}
|
|
||||||
&.Overpass {font-family : 'Overpass';}
|
|
||||||
&.Davek {font-family : 'Davek';}
|
|
||||||
&.Iokharic {font-family : 'Iokharic';}
|
|
||||||
&.Rellanic {font-family : 'Rellanic';}
|
|
||||||
&.TimesNewRoman {font-family : 'Times New Roman';}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.name { margin-right : auto; }
|
|
||||||
.disabled { text-decoration : line-through; }
|
|
||||||
.beta {
|
|
||||||
align-self : center;
|
|
||||||
padding : 4px 6px;
|
|
||||||
margin-left : 5px;
|
|
||||||
font-family : monospace;
|
|
||||||
line-height : 1em;
|
|
||||||
color : white;
|
|
||||||
background : grey;
|
|
||||||
border-radius : 12px;
|
|
||||||
}
|
|
||||||
&:hover {
|
|
||||||
background-color : #999999;
|
|
||||||
& > .dropdown {
|
|
||||||
visibility : visible;
|
|
||||||
&.side {
|
|
||||||
top : 0%;
|
|
||||||
left : 100%;
|
|
||||||
margin-left : 0;
|
|
||||||
box-shadow : -1px 1px 2px 0px #999999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.menu-list {
|
||||||
|
padding : 0px;
|
||||||
|
background-color : #DDDDDD;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
position : relative;
|
||||||
|
display : flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items : center;
|
||||||
|
min-width : max-content;
|
||||||
|
padding : 5px;
|
||||||
|
cursor : pointer;
|
||||||
|
width: 100%;
|
||||||
|
.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 {
|
||||||
|
min-width : 25px;
|
||||||
|
height : .85rem;
|
||||||
|
font-size : 1.2em;
|
||||||
|
text-align : center;
|
||||||
|
&.caret {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
&.caret:is(.menu-wrapper .menu-wrapper * ) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
/* Fonts */
|
||||||
|
&.font {
|
||||||
|
height : auto;
|
||||||
|
&::before {
|
||||||
|
font-size : 1em;
|
||||||
|
content : 'ABC';
|
||||||
|
}
|
||||||
|
|
||||||
|
&.OpenSans {font-family : 'OpenSans';}
|
||||||
|
&.CodeBold {font-family : 'CodeBold';}
|
||||||
|
&.CodeLight {font-family : 'CodeLight';}
|
||||||
|
&.ScalySansRemake {font-family : 'ScalySansRemake';}
|
||||||
|
&.BookInsanityRemake {font-family : 'BookInsanityRemake';}
|
||||||
|
&.MrEavesRemake {font-family : 'MrEavesRemake';}
|
||||||
|
&.SolberaImitationRemake {font-family : 'SolberaImitationRemake';}
|
||||||
|
&.ScalySansSmallCapsRemake {font-family : 'ScalySansSmallCapsRemake';}
|
||||||
|
&.WalterTurncoat {font-family : 'WalterTurncoat';}
|
||||||
|
&.Lato {font-family : 'Lato';}
|
||||||
|
&.Courier {font-family : 'Courier';}
|
||||||
|
&.NodestoCapsCondensed {font-family : 'NodestoCapsCondensed';}
|
||||||
|
&.Overpass {font-family : 'Overpass';}
|
||||||
|
&.Davek {font-family : 'Davek';}
|
||||||
|
&.Iokharic {font-family : 'Iokharic';}
|
||||||
|
&.Rellanic {font-family : 'Rellanic';}
|
||||||
|
&.TimesNewRoman {font-family : 'Times New Roman';}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.name { margin-right : auto; }
|
||||||
|
.disabled { text-decoration : line-through; }
|
||||||
|
.beta {
|
||||||
|
align-self : center;
|
||||||
|
padding : 4px 6px;
|
||||||
|
margin-left : 5px;
|
||||||
|
font-family : monospace;
|
||||||
|
line-height : 1em;
|
||||||
|
color : white;
|
||||||
|
background : grey;
|
||||||
|
border-radius : 12px;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color : #999999;
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
color: gray;
|
||||||
|
cursor: not-allowed;
|
||||||
|
&:hover { background-color: unset; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.disabledSnippets {
|
.disabledSnippets {
|
||||||
color: grey;
|
color: grey;
|
||||||
@@ -244,7 +248,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@container editor (width < 745px) {
|
@container editor (width < 841px) {
|
||||||
.snippetBar {
|
.snippetBar {
|
||||||
.editors {
|
.editors {
|
||||||
flex : 1;
|
flex : 1;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const Homebrew = (props)=>{
|
|||||||
global.account = account;
|
global.account = account;
|
||||||
global.version = version;
|
global.version = version;
|
||||||
global.config = config;
|
global.config = config;
|
||||||
global.enablev4 = enablev4;
|
global.enablev4 = enablev4;
|
||||||
|
|
||||||
const backgroundObject = ()=>{
|
const backgroundObject = ()=>{
|
||||||
if(config?.deployment || (config?.local && config?.development)) {
|
if(config?.deployment || (config?.local && config?.development)) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Generated
+549
-412
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,3 +21,5 @@
|
|||||||
text-transform : unset;
|
text-transform : unset;
|
||||||
background-color : unset;
|
background-color : unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:where(i){ text-box-trim: trim-both }
|
||||||
|
|||||||
+16
-21
@@ -109,28 +109,24 @@ export default [
|
|||||||
gen : MonsterBlockGen.monster('monster,frame,wide', 4),
|
gen : MonsterBlockGen.monster('monster,frame,wide', 4),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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',
|
||||||
@@ -209,11 +205,10 @@ 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',
|
||||||
icon : 'fac davek',
|
icon : 'fac davek',
|
||||||
|
|||||||
@@ -195,10 +195,9 @@ 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',
|
||||||
|
|||||||
@@ -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 : {
|
||||||
|
|||||||
Reference in New Issue
Block a user