Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Story authoring tool #604

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/scripts/components/common/page-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,6 @@ function PageHeader() {
{getString('stories').other}
</GlobalMenuLink>
</li>

{/*
Temporarily add hub link through env variables.
This does not scale for the different instances, but it's a
Expand Down
58 changes: 41 additions & 17 deletions app/scripts/components/common/page-local-nav.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -90,7 +90,7 @@ const NavBlock = styled.div`
}
`;

const LocalMenu = styled.ul`
const LocalMenuWrapper = styled.ul`
${listReset()}
display: flex;
flex-flow: row nowrap;
Expand Down Expand Up @@ -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 : '';
Expand Down Expand Up @@ -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 <LocalMenu options={options} />;
}

DatasetsLocalMenu.propTypes = {
dataset: T.object
};

export function LocalMenu({ options }) {
return (
<NavBlock>
<LocalMenu>
<li>
<LocalMenuLink to={getDatasetPath(dataset.data)} end>
Overview
</LocalMenuLink>
</li>
<li>
<LocalMenuLink to={getDatasetExplorePath(dataset.data)} end>
Exploration
</LocalMenuLink>
</li>
</LocalMenu>
<LocalMenuWrapper>
{options.map((option) => (
<li key={option.to}>
<LocalMenuLink to={option.to} end>
{option.label}
</LocalMenuLink>
</li>
))}
</LocalMenuWrapper>
</NavBlock>
);
}

DatasetsLocalMenu.propTypes = {
dataset: T.object
LocalMenu.propTypes = {
options: T.arrayOf(
T.shape({
label: T.string,
to: T.string
})
)
};
13 changes: 13 additions & 0 deletions app/scripts/components/home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
ComponentOverride,
ContentOverride
} from '$components/common/page-overrides';
import { PUBLICATION_EDITOR_SLUG } from '$components/publication-tool';

const homeContent = getOverride('homeContent');

Expand Down Expand Up @@ -192,6 +193,18 @@ function RootHome() {
</li>
</ConnectionsList>
</ConnectionsBlock>
{process.env.NODE_ENV !== 'production' && (
<ConnectionsBlock>
<ConnectionsBlockTitle>Authoring tools</ConnectionsBlockTitle>
<ConnectionsList>
<li>
<Link to={PUBLICATION_EDITOR_SLUG}>
<CollecticonChevronRightSmall /> Story editor
</Link>
</li>
</ConnectionsList>
</ConnectionsBlock>
)}
</Connections>
</ContentOverride>
</PageMainContent>
Expand Down
272 changes: 272 additions & 0 deletions app/scripts/components/publication-tool/atoms.ts
Original file line number Diff line number Diff line change
@@ -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: `
<Prose>
### Your markdown header

Your markdown contents comes here.

<Map
datasetId='no2'
layerId='no2-monthly'
center={[120.11, 34.95]}
zoom={4.5}
dateTime='2020-02-01'
compareDateTime='2022-02-01'
/>
<Caption
attrAuthor='NASA'
attrUrl='https://nasa.gov/'
>
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.
</Caption>
</Prose>`
},
{
id: '2',
tag: 'Block',
mdx: `
<Prose>
### Second header

Let's tell a story of _data_.

</Prose>`
}
]
};

export const DEFAULT_STORY_STRING = toMDXDocument(DEFAULT_STORY);

export const DataStoriesAtom = atomWithStorage<EditorStory[]>(
'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: `
<Prose>
### Your markdown header

Your markdown contents comes here.
</Prose>`
}
]
}
]
);

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: `<Prose>
### Hello, new block!

Let's tell a story of _data_.

</Prose>`
},
...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 };
};
Loading
Loading