diff --git a/app/scripts/components/common/page-header.tsx b/app/scripts/components/common/page-header.tsx index fbf57779e..d5605c64d 100644 --- a/app/scripts/components/common/page-header.tsx +++ b/app/scripts/components/common/page-header.tsx @@ -427,7 +427,6 @@ function PageHeader() { {getString('stories').other} - {/* Temporarily add hub link through env variables. This does not scale for the different instances, but it's a diff --git a/app/scripts/components/common/page-local-nav.js b/app/scripts/components/common/page-local-nav.js index 3151d9d8f..1967ce68c 100644 --- a/app/scripts/components/common/page-local-nav.js +++ b/app/scripts/components/common/page-local-nav.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import T from 'prop-types'; import styled from 'styled-components'; import { Link, NavLink, useMatch } from 'react-router-dom'; @@ -90,7 +90,7 @@ const NavBlock = styled.div` } `; -const LocalMenu = styled.ul` +const LocalMenuWrapper = styled.ul` ${listReset()} display: flex; flex-flow: row nowrap; @@ -171,7 +171,6 @@ const pagePath = '/datasets/:dataId/:page'; function PageLocalNav(props) { const { localMenuCmp, parentName, parentLabel, parentTo, items, currentId } = props; - // Keep the url structure on dataset pages const datasetPageMatch = useMatch(pagePath); const currentPage = datasetPageMatch ? datasetPageMatch.params.page : ''; @@ -246,24 +245,49 @@ PageLocalNav.propTypes = { export function DatasetsLocalMenu(props) { const { dataset } = props; + const options = useMemo(() => { + const datasetPath = getDatasetPath(dataset.data); + const datasetExplorePath = getDatasetExplorePath(dataset.data); + return [ + { + label: 'Overview', + to: datasetPath + }, + { + label: 'Exploration', + to: datasetExplorePath + } + ]; + }, [dataset]); + + return ; +} + +DatasetsLocalMenu.propTypes = { + dataset: T.object +}; + +export function LocalMenu({ options }) { return ( - -
  • - - Overview - -
  • -
  • - - Exploration - -
  • -
    + + {options.map((option) => ( +
  • + + {option.label} + +
  • + ))} +
    ); } -DatasetsLocalMenu.propTypes = { - dataset: T.object +LocalMenu.propTypes = { + options: T.arrayOf( + T.shape({ + label: T.string, + to: T.string + }) + ) }; diff --git a/app/scripts/components/home/index.tsx b/app/scripts/components/home/index.tsx index 4680a658e..f6f82b43c 100644 --- a/app/scripts/components/home/index.tsx +++ b/app/scripts/components/home/index.tsx @@ -23,6 +23,7 @@ import { ComponentOverride, ContentOverride } from '$components/common/page-overrides'; +import { PUBLICATION_EDITOR_SLUG } from '$components/publication-tool'; const homeContent = getOverride('homeContent'); @@ -192,6 +193,18 @@ function RootHome() { + {process.env.NODE_ENV !== 'production' && ( + + Authoring tools + +
  • + + Story editor + +
  • +
    +
    + )} diff --git a/app/scripts/components/publication-tool/atoms.ts b/app/scripts/components/publication-tool/atoms.ts new file mode 100644 index 000000000..62787f13b --- /dev/null +++ b/app/scripts/components/publication-tool/atoms.ts @@ -0,0 +1,272 @@ +import { atomWithStorage } from 'jotai/utils'; +import { useParams } from 'react-router'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useCallback, useMemo } from 'react'; +import { EditorStory } from './types'; +import { toEditorDataStory, toMDXDocument } from './utils'; + +const DEFAULT_STORY: EditorStory = { + frontmatter: { + id: 'example-data-story', + name: 'Example Data Story', + description: 'This is an example data story', + pubDate: '2023-01-01', + taxonomy: [] + }, + currentBlockId: '1', + blocks: [ + { + id: '1', + tag: 'Block', + mdx: ` + + ### Your markdown header + + Your markdown contents comes here. + + + + Levels in 10¹⁵ molecules cm⁻². Darker colors indicate higher nitrogen dioxide (NO₂) levels associated and more activity. Lighter colors indicate lower levels of NO₂ and less activity. + + ` + }, + { + id: '2', + tag: 'Block', + mdx: ` + + ### Second header + + Let's tell a story of _data_. + + ` + } + ] +}; + +export const DEFAULT_STORY_STRING = toMDXDocument(DEFAULT_STORY); + +export const DataStoriesAtom = atomWithStorage( + 'dataStories', + [ + DEFAULT_STORY, + { + frontmatter: { + id: 'example-data-story-2', + name: 'Example Data Story 2', + description: 'This is an example data story', + taxonomy: [], + pubDate: '2023-01-01' + }, + blocks: [ + { + id: '1', + tag: 'Block', + mdx: ` + + ### Your markdown header + + Your markdown contents comes here. + ` + } + ] + } + ] +); + +export const useCreateEditorDataStoryFromMDXDocument = () => { + const [dataStories, setDataStories] = useAtom(DataStoriesAtom); + return useCallback( + (mdxDocument: string) => { + let editorDataStory; + try { + editorDataStory = toEditorDataStory(mdxDocument); + const { frontmatter } = editorDataStory; + if (!frontmatter.id) { + throw new Error('id is required'); + } + if (dataStories.map((p) => p.frontmatter.id).includes(frontmatter.id)) { + throw new Error(`id ${frontmatter.id} already exists`); + } + } catch (error) { + return { id: null, error }; + } + + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories, editorDataStory]; + return newDataStories; + }); + return { id: editorDataStory.frontmatter.id, error: null }; + }, + [setDataStories, dataStories] + ); +}; + +export const useCurrentDataStory = () => { + const { storyId } = useParams(); + const dataStories = useAtomValue(DataStoriesAtom); + const currentDataStory = useMemo( + () => dataStories.find((p) => p.frontmatter.id === storyId), + [dataStories, storyId] + ); + return currentDataStory; +}; + +export const useDeleteDataStory = () => { + const [dataStories, setDataStories] = useAtom(DataStoriesAtom); + return useCallback((storyId: string) => { + if (window.confirm('Are you sure you want to delete this story?')) { + const storyIndex = dataStories.findIndex((p) => p.frontmatter.id === storyId); + setDataStories((oldDataStories) => { + const newDataStories = [ + ...oldDataStories.slice(0, storyIndex), + ...oldDataStories.slice(storyIndex + 1) + ]; + return newDataStories; + }); + } + }, [dataStories, setDataStories]); +}; + +export const useCurrentBlockId = () => { + const currentDataStory = useCurrentDataStory(); + const currentBlockId = currentDataStory?.currentBlockId; + return currentBlockId; +}; + +export const useStoryIndex = () => { + const { storyId } = useParams(); + const dataStories = useAtomValue(DataStoriesAtom); + const storyIndex = useMemo( + () => dataStories.findIndex((p) => p.frontmatter.id === storyId), + [dataStories, storyId] + ); + return storyIndex; +}; + +export const useBlockIndex = (blockId: string) => { + const currentDataStory = useCurrentDataStory(); + const blockIndex = currentDataStory?.blocks.findIndex( + (b) => b.id === blockId + ); + return blockIndex ?? -1; +}; + +const useCRUDUtils = (blockId: string) => { + const setDataStories = useSetAtom(DataStoriesAtom); + const storyIndex = useStoryIndex(); + const blockIndex = useBlockIndex(blockId); + const currentStory = useCurrentDataStory(); + return { setDataStories, storyIndex, currentStory, blockIndex }; +}; + +export const useSetCurrentBlockId = (blockId: string) => { + const { setDataStories, storyIndex } = useCRUDUtils(blockId); + return useCallback(() => { + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories]; + newDataStories[storyIndex].currentBlockId = blockId; + return newDataStories; + }); + }, [blockId, setDataStories, storyIndex]); +}; + +export const useRemoveBlock = (blockId: string) => { + const { setDataStories, storyIndex, blockIndex, currentStory } = useCRUDUtils(blockId); + const isAvailable = useMemo(() => currentStory?.blocks && currentStory.blocks.length > 1, [currentStory?.blocks]); + const remove = useCallback(() => { + if (window.confirm('Are you sure you want to delete this block?')) { + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories]; + newDataStories[storyIndex].blocks = [ + ...newDataStories[storyIndex].blocks.slice(0, blockIndex), + ...newDataStories[storyIndex].blocks.slice(blockIndex + 1) + ]; + return newDataStories; + }); + } + }, [setDataStories, storyIndex, blockIndex]); + return { isAvailable, remove }; +}; + +export const useAddBlock = (afterBlockId: string) => { + const { setDataStories, storyIndex, blockIndex } = useCRUDUtils(afterBlockId); + return useCallback(() => { + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories]; + const newBlockId = new Date().getTime().toString(); + newDataStories[storyIndex].currentBlockId = newBlockId; + newDataStories[storyIndex].blocks = [ + ...newDataStories[storyIndex].blocks.slice(0, blockIndex + 1), + { + id: newBlockId, + tag: 'Block', + mdx: ` +### Hello, new block! + +Let's tell a story of _data_. + +` + }, + ...newDataStories[storyIndex].blocks.slice(blockIndex + 1) + ]; + return newDataStories; + }); + }, [setDataStories, storyIndex, blockIndex]); +}; + +export const useSetBlockMDX = (blockId: string) => { + const { setDataStories, storyIndex, blockIndex } = useCRUDUtils(blockId); + const callback = useCallback( + (mdx: string) => { + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories]; + newDataStories[storyIndex].blocks[blockIndex].mdx = mdx; + return newDataStories; + }); + }, + [setDataStories, storyIndex, blockIndex] + ); + return blockId ? callback : undefined; +}; + +export const useSetBlockOrder = (blockId: string, direction: 'up' | 'down') => { + const { setDataStories, storyIndex, blockIndex } = useCRUDUtils(blockId); + const currentDataStory = useCurrentDataStory(); + const isAvailable = useMemo(() => { + const canGoUp = blockIndex > 0; + const canGoDown = currentDataStory + ? blockIndex < currentDataStory.blocks.length - 1 + : false; + return direction === 'up' ? canGoUp : canGoDown; + }, [blockIndex, currentDataStory, direction]); + + const setBlockOrder = useCallback(() => { + setDataStories((oldDataStories) => { + const newDataStories = [...oldDataStories]; + const block = newDataStories[storyIndex].blocks[blockIndex]; + + if (direction === 'up') { + newDataStories[storyIndex].blocks[blockIndex] = + newDataStories[storyIndex].blocks[blockIndex - 1]; + newDataStories[storyIndex].blocks[blockIndex - 1] = block; + } else { + newDataStories[storyIndex].blocks[blockIndex] = + newDataStories[storyIndex].blocks[blockIndex + 1]; + newDataStories[storyIndex].blocks[blockIndex + 1] = block; + } + return newDataStories; + }); + }, [setDataStories, storyIndex, blockIndex, direction]); + return { isAvailable, setBlockOrder }; +}; diff --git a/app/scripts/components/publication-tool/block-with-error.tsx b/app/scripts/components/publication-tool/block-with-error.tsx new file mode 100644 index 000000000..759f2e434 --- /dev/null +++ b/app/scripts/components/publication-tool/block-with-error.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { generalErrorMessage } from '../common/blocks/block-constant'; +import { HintedErrorDisplay, docsMessage } from '$utils/hinted-error'; +import { BlockComponent } from '$components/common/blocks'; + +export const MDXBlockError = ({ error }: any) => { + return ( + + ); +}; + +class ErrorBoundaryWithCRAReset extends ErrorBoundary { + static getDerivedStateFromError(error: Error) { + (error as any).CRAOverlayIgnore = true; + return { didCatch: true, error }; + } +} + +export const MDXBlockWithError = (props) => { + // const result = useContext(MDXContext); + + return ( + + + + ); +}; \ No newline at end of file diff --git a/app/scripts/components/publication-tool/data-story.tsx b/app/scripts/components/publication-tool/data-story.tsx new file mode 100644 index 000000000..b849e52b5 --- /dev/null +++ b/app/scripts/components/publication-tool/data-story.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { createPortal } from 'react-dom'; +import { ErrorBoundary } from 'react-error-boundary'; +import EditorBlock from './editor-block'; +import { useCurrentDataStory } from './atoms'; +import Editor from './editor'; +import { PageMainContent } from '$styles/page'; + +class ErrorBoundaryWithCRAReset extends ErrorBoundary { + static getDerivedStateFromError(error: Error) { + (error as any).CRAOverlayIgnore = true; + return { didCatch: true, error }; + } +} + +const GlobalError = () => { + return
    Something went wrong
    ; +}; + +export default function DataStoryEditor() { + const currentDataStory = useCurrentDataStory(); + const [currentHighlightedBlockId, setCurrentHighlightedBlockId] = + useState(); + + return ( + <> + {currentDataStory?.currentBlockId && + document.getElementById(`block${currentDataStory?.currentBlockId}`) && + createPortal( + , + document.getElementById( + `block${currentDataStory?.currentBlockId}` + ) as HTMLElement + )} + + +
    + {currentDataStory?.blocks.map((block) => { + return ( + + ); + })} +
    +
    +
    + + ); +} diff --git a/app/scripts/components/publication-tool/editor-block.tsx b/app/scripts/components/publication-tool/editor-block.tsx new file mode 100644 index 000000000..534f073ef --- /dev/null +++ b/app/scripts/components/publication-tool/editor-block.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { evaluate } from '@mdx-js/mdx'; +import { themeVal } from '@devseed-ui/theme-provider'; +import { Button, ButtonGroup } from '@devseed-ui/button'; +import styled from 'styled-components'; +import { MDXContent } from 'mdx/types'; +import remarkGfm from 'remark-gfm'; +import * as runtime from 'react/jsx-runtime'; +import { useMDXComponents } from '@mdx-js/react'; +import MDXRenderer from './mdx-renderer'; +import { MDXBlockWithError } from './block-with-error'; +import { + useAddBlock, + useCurrentDataStory, + useRemoveBlock, + useSetBlockOrder, + useSetCurrentBlockId +} from './atoms'; + +interface useMDXReturnProps { + source: string; + result: MDXContent | null; + error: any; +} + +const useMDX = (source: string) => { + const remarkPlugins = [remarkGfm]; + const [state, setState] = useState({ + source, + result: null, + error: null + }); + + async function renderMDX() { + let result: MDXContent | null = null; + try { + const wrappedSource = `${source}`; + result = ( + await evaluate(wrappedSource, { + ...runtime, + useMDXComponents, + useDynamicImport: true, + remarkPlugins + } as any) + ).default; + setState((oldState) => { + return { ...oldState, source, result, error: null }; + }); + } catch (error) { + setState((oldState) => { + return { ...oldState, source, result: null, error }; + }); + } + } + + useEffect(() => { + renderMDX(); + }, [source]); + + return state; +}; + +const MDXRendererControls = styled.div<{ + highlighted: boolean; + editing: boolean; +}>` + background: ${(props) => + props.editing + ? `repeating-linear-gradient( + 45deg, + transparent, + transparent 10px, + #eee 10px, + #eee 20px + )` + : 'transparent'}; + position: relative; + border-color: ${(props) => + props.highlighted ? themeVal('color.base') : 'transparent'}; + border-width: 2px; + border-style: solid; +`; + +const MDXRendererActions = styled.div` + position: sticky; + bottom: 0; + display: flex; + justify-content: flex-end; + padding: 1rem; +`; + +export default function EditorBlock({ + mdx, + id, + onHighlight, + highlighted +}: { + mdx: string; + id: string; + onHighlight: (id: string) => void; + highlighted: boolean; +}) { + const { result, error } = useMDX(mdx); + const errorHumanReadable = useMemo(() => { + if (!error) return null; + const { line, message } = JSON.parse(JSON.stringify(error)); + return { message: `At line ${line - 1}: ${message}` }; + }, [error]); + + const onEditClick = useSetCurrentBlockId(id); + const { remove: onRemoveClick, isAvailable: isRemoveAvailable } = + useRemoveBlock(id); + const onAddClick = useAddBlock(id); + const { isAvailable: canGoUp, setBlockOrder: onUpClick } = useSetBlockOrder( + id, + 'up' + ); + const { isAvailable: canGoDown, setBlockOrder: onDownClick } = + useSetBlockOrder(id, 'down'); + + const currentDataStory = useCurrentDataStory(); + + const editing = id === currentDataStory?.currentBlockId; + + return ( + onHighlight(id)} + > + {error ? ( + + ) : ( + + )} + {highlighted && ( + + + + + + + + + + )} +
    + + ); +} diff --git a/app/scripts/components/publication-tool/editor.tsx b/app/scripts/components/publication-tool/editor.tsx new file mode 100644 index 000000000..413757c43 --- /dev/null +++ b/app/scripts/components/publication-tool/editor.tsx @@ -0,0 +1,93 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import CodeMirror from 'rodemirror'; +import { EditorView, basicSetup } from 'codemirror'; +import { oneDark } from '@codemirror/theme-one-dark'; +import { Extension } from '@codemirror/state'; +import { markdown as langMarkdown } from '@codemirror/lang-markdown'; +import { useCurrentBlockId, useCurrentDataStory, useSetBlockMDX } from './atoms'; + +const DEFAULT_CONTENT = '(your content here)'; + +const EditorWrapper = styled.div` + margin: 0 3rem 1rem; +`; + +const CodeMirrorWrapper = styled.div` + overflow: scroll; + max-height: 500px; +`; + +const TitleBar = styled.div` + background-color: #1e1e1e; + color: #fff; + padding: 10px; + font-size: 14px; + font-weight: bold; + border-bottom: 1px solid #000; + cursor: move; +`; + +export default function Editor() { + const extensions = useMemo( + () => [basicSetup, oneDark, langMarkdown()], + [] + ); + + const [editorView, setEditorView] = useState(null); + const currentDataStory = useCurrentDataStory(); + + // Update editor on current editing change + useEffect(() => { + if (!editorView) return; + const currentBlock = currentDataStory?.blocks.find( + (block) => block.id === currentDataStory?.currentBlockId + ); + editorView.dispatch({ + changes: { + from: 0, + to: editorView.state.doc.length, + insert: currentBlock?.mdx + } + }); + }, [editorView, currentDataStory?.currentBlockId]); + + const currentBlockId = useCurrentBlockId(); + const setMDX = useSetBlockMDX(currentBlockId!); + const onEditorUpdated = useCallback( + (v) => { + if (v.docChanged) { + let blockSource = v.state.doc.toString(); + // This is a hack to prevent the editor from being cleared + // when the user deletes all the text. + // because when that happens, React throws an order of hooks error + blockSource = blockSource || DEFAULT_CONTENT; + + + if (setMDX) setMDX(blockSource); + // const allSource = [ + // ...source.slice(0, currentEditingBlockIndexRef.current), + // blockSource, + // ...source.slice(currentEditingBlockIndexRef.current + 1) + // ]; + // setSource(allSource); + } + }, + [setMDX] + ); + + return ( + + MDX Editor + + + setEditorView(editorView)} + onUpdate={onEditorUpdated} + extensions={extensions} + /> + + + ); +} diff --git a/app/scripts/components/publication-tool/index.tsx b/app/scripts/components/publication-tool/index.tsx new file mode 100644 index 000000000..cbaf691d0 --- /dev/null +++ b/app/scripts/components/publication-tool/index.tsx @@ -0,0 +1,177 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Route, Routes, useNavigate, useParams } from 'react-router'; +import { useAtomValue } from 'jotai'; +import { Button, ButtonGroup } from '@devseed-ui/button'; +import styled from 'styled-components'; +import { themeVal } from '@devseed-ui/theme-provider'; +import DataStoryEditor from './data-story'; +import { + DEFAULT_STORY_STRING, + DataStoriesAtom, + useCreateEditorDataStoryFromMDXDocument, + useDeleteDataStory +} from './atoms'; +import { LayoutProps } from '$components/common/layout-root'; +import { resourceNotFound } from '$components/uhoh'; +import PageHero from '$components/common/page-hero'; +import { LocalMenu } from '$components/common/page-local-nav'; +import { + Fold, + FoldHeader, + FoldProse, + FoldTitle +} from '$components/common/fold'; +import { PageMainContent } from '$styles/page'; + +export const PUBLICATION_EDITOR_SLUG = '/story-editor'; + +function DataStoryEditorLayout() { + const { storyId } = useParams(); + const dataStories = useAtomValue(DataStoriesAtom); + + const page = dataStories.find((p) => p.frontmatter.id === storyId); + if (!page) throw resourceNotFound(); + + const items = useMemo( + () => + dataStories.map((dataStory) => ({ + id: dataStory.frontmatter.id, + name: dataStory.frontmatter.name, + to: `${PUBLICATION_EDITOR_SLUG}/${dataStory.frontmatter.id}` + })), + [dataStories] + ); + + return ( + <> + + ) + }} + /> + + + + + + + ); +} + +const Error = styled.div` + color: ${themeVal('color.danger')}; +`; + +function PublicationTool() { + const dataStories = useAtomValue(DataStoriesAtom); + const [newStory, setNewStory] = useState(DEFAULT_STORY_STRING); + const [error, setError] = useState(''); + const createEditorDataStoryFromMDXDocument = + useCreateEditorDataStoryFromMDXDocument(); + const navigate = useNavigate(); + + const onCreate = useCallback(() => { + const { id, error } = createEditorDataStoryFromMDXDocument(newStory); + if (error) { + setError(error.message); + return; + } + setNewStory(''); + setError(''); + navigate(`${PUBLICATION_EDITOR_SLUG}/${id}`); + }, [createEditorDataStoryFromMDXDocument, newStory, navigate]); + + const deleteStory = useDeleteDataStory(); + + return ( + + } /> + + + + + + Data Stories + + + + + + + + + + + {dataStories.map((dataStory) => ( + + + + + ))} + +
    TitleActions
    {dataStory.frontmatter.name} + + + + +
    +
    +

    Create new Story

    +
    +

    From existing MDX document:

    +