diff --git a/packages_rs/nextclade-web/package.json b/packages_rs/nextclade-web/package.json index 7c5b95673..3c6f8d524 100644 --- a/packages_rs/nextclade-web/package.json +++ b/packages_rs/nextclade-web/package.json @@ -143,6 +143,7 @@ "react-resize-detector": "7.0.0", "react-select": "5.3.0", "react-toggle": "4.1.2", + "react-use-wizard": "2.2.3", "react-virtualized-auto-sizer": "1.0.6", "react-virtualized-select": "3.1.3", "react-window": "1.8.7", diff --git a/packages_rs/nextclade-web/src/components/About/About.tsx b/packages_rs/nextclade-web/src/components/About/About.tsx new file mode 100644 index 000000000..7f15d6fde --- /dev/null +++ b/packages_rs/nextclade-web/src/components/About/About.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +import AboutContent from './AboutContent.mdx' + +export function About() { + return +} diff --git a/packages_rs/nextclade-web/src/components/About/AboutContent.mdx b/packages_rs/nextclade-web/src/components/About/AboutContent.mdx new file mode 100644 index 000000000..9f97e267a --- /dev/null +++ b/packages_rs/nextclade-web/src/components/About/AboutContent.mdx @@ -0,0 +1,43 @@ +import { CladeSchema } from 'src/components/Main/CladeSchema.tsx' + +## What is Nextclade? + +Nextclade is a tool that performs genetic sequence alignment, clade assignment, mutation calling, phylogenetic placement, and quality checks for SARS-CoV-2, Influenza (Flu), Mpox (Monkeypox), Respiratory Syncytial Virus (RSV) and other pathogens. + +Nextclade identifies differences between your sequences and a reference sequence, uses these differences to assign your sequences to clades, reports potential sequence quality issues in your data, and shows how the sequences are related to each other by placing them into an existing phylogenetic tree (we call it "phylogenetic placement"). You can use the tool to analyze sequences before you upload them to a database, or if you want to assign Nextstrain clades to a set of sequences. + +To analyze your data, drag a fasta file onto the upload box or paste sequences into the text box. These sequences will then be analyzed in your browser - data never leave your computer. Since your computer is doing the work rather than a server, it is advisable to analyze at most a few hundred sequences at a time. + +The Nextclade app and algorithms are opensource. The code is available on [GitHub](https://github.com/nextstrain/nextclade). The user manual is available at [docs.nextstrain.org/projects/nextclade](https://docs.nextstrain.org/projects/nextclade). + + +### What are the SARS-CoV-2 clades? + +Nextclade was originally developed during COVID-19 pandemic, primarily focused on SARS-CoV-2. This section describes clades with application to SARS-CoV-2, but Nextclade can analyse other pathogens too. + + + +Since its emergence in late 2019, SARS-CoV-2 has diversified into several different co-circulating variants. To facilitate discussion of these variants, we have grouped them into __clades__ which are defined by specific signature mutations. + +We currently define more than 30 clades (see [this blog post](https://nextstrain.org/blog/2021-01-06-updated-SARS-CoV-2-clade-naming) for details): + +- 19A and 19B emerged in Wuhan and have dominated the early outbreak +- 20A emerged from 19A out of dominated the European outbreak in March and has since spread globally +- 20B and 20C are large genetically distinct subclades 20A emerged in early 2020 +- 20D to 20J have emerged over the summer of 2020 and include three "Variants of Concern" (VoC). +- 21A to 21F include the VoC __delta__ and several Variants of Interest (VoI). +- 21K onwards are different clades within the diverse VoC __omicron__. + +Within Nextstrain, we define each clade by its combination of signature mutations. You can find the exact clade definition in [github.com/nextstrain/ncov/defaults/clades.tsv](https://github.com/nextstrain/ncov/blob/master/defaults/clades.tsv). When available, we will include [WHO labels for VoCs and VoIs](https://www.who.int/en/activities/tracking-SARS-CoV-2-variants/). + +Learn more about how Nextclade assigns clades in the [documentation](https://docs.nextstrain.org/projects/nextclade/en/stable/user/algorithm/). + +### Other pathogens + +Besides SARS-CoV-2, we provide Nextclade datasets to analyze the following other pathogens: + + * Seasonal Influenza viruses (HA and NA for A/H3N2, A/H1N1pdm, B/Vic, and B/Yam) + * Mpox virus (the overall clade structure, as well as fine-grained lineages within the recent sustained human-to-human transmission) + * Respiratory Syncytial Virus (RSV) (subtypes A and B) + +You can also put together your own dataset to analyse other pathogens. diff --git a/packages_rs/nextclade-web/src/components/Common/Markdown.tsx b/packages_rs/nextclade-web/src/components/Common/Markdown.tsx index e9c0e22d1..8c5766e8e 100644 --- a/packages_rs/nextclade-web/src/components/Common/Markdown.tsx +++ b/packages_rs/nextclade-web/src/components/Common/Markdown.tsx @@ -3,25 +3,21 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' import rehypeSanitize from 'rehype-sanitize' -import { LinkExternal } from 'src/components/Link/LinkExternal' import { useAxiosQuery } from 'src/helpers/useAxiosQuery' import { LOADING } from 'src/components/Loading/Loading' +import { mdxComponents } from 'src/mdx-components' const REMARK_PLUGINS = [remarkGfm] const REHYPE_PLUGINS = [rehypeRaw, rehypeSanitize] -const MD_COMPONENTS = { - a: LinkExternal, -} - export interface MarkdownProps { content: string } export function Markdown({ content }: MarkdownProps) { return ( - + {content} ) diff --git a/packages_rs/nextclade-web/src/components/Common/SearchBox.tsx b/packages_rs/nextclade-web/src/components/Common/SearchBox.tsx index c5b634d7a..17104a884 100644 --- a/packages_rs/nextclade-web/src/components/Common/SearchBox.tsx +++ b/packages_rs/nextclade-web/src/components/Common/SearchBox.tsx @@ -79,6 +79,7 @@ export function SearchBox({ searchTitle, searchTerm, onSearchTermChange, ...rest { return [ { url: '/', content: t('Start'), title: t('Show start page') }, + { + url: '/dataset-suggest', + content: t('Suggest'), + title: t('Show dataset suggestion page'), + hidden: !isInSuggestMode, + }, + { + url: dataset ? '/dataset' : undefined, + content: t('Dataset'), + title: dataset ? t('Show dataset selection page') : t('Please select dataset first'), + }, { url: hasRan ? '/results' : undefined, content: t('Results'), - title: hasRan ? t('Show analysis results table') : t('Please run the analysis first'), + title: hasRan ? t('Show analysis results table') : t('Please select a dataset and run the analysis first'), }, { url: hasTree ? '/tree' : undefined, content: t('Tree'), - title: hasTree ? t('Show phylogenetic tree') : t('Please run the analysis on a dataset with reference tree'), + title: hasTree + ? t('Show phylogenetic tree') + : t('Please select a dataset with reference tree and run the analysis first'), }, { url: canDownload ? '/export' : undefined, @@ -154,6 +171,9 @@ export function NavigationBar() { title: canDownload ? t('Export results') : t('Please run the analysis first.'), }, ].map((desc, i) => { + if (desc.hidden) { + return null + } const link = if (i === 0) { return [link] @@ -161,7 +181,7 @@ export function NavigationBar() { const arrow = return [arrow, link] }) - }, [canDownload, hasRan, hasTree, pathname, t]) + }, [canDownload, dataset, hasRan, hasTree, isInSuggestMode, pathname, t]) const linksRight = useMemo(() => { return [ diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetAutodetectPage.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetAutodetectPage.tsx new file mode 100644 index 000000000..2f4e75683 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/DatasetAutodetectPage.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useMemo, useState } from 'react' +import styled from 'styled-components' +import { Layout } from 'src/components/Layout/Layout' +import { Dataset } from '_SchemaRoot' +import { useRouter } from 'next/router' +import { useRecoilState, useRecoilValue } from 'recoil' +import { FaChevronLeft as IconLeft, FaChevronRight as IconRight } from 'react-icons/fa6' +import { + FlexCol, + FlexRow, + Footer, + WizardContainer, + WizardMain, + WizardNavigationButton, + WizardNavigationForm, +} from 'src/components/Main/Wizard' +import { QuerySequenceFilePicker } from 'src/components/Main/QuerySequenceFilePicker' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { hasAutodetectResultsAtom } from 'src/state/autodetect.state' +import { datasetCurrentAtom } from 'src/state/dataset.state' +import { globalErrorAtom } from 'src/state/error.state' +import { DatasetAutosuggestionResultsList } from 'src/components/Main/DatasetSelector' + +const Main = styled.div` + display: flex; + flex: 1 1 100%; + overflow: hidden; + padding: 0; + margin: 0 auto; + + width: 100%; + max-width: 1400px; +` + +export function DatasetAutodetectPage() { + return ( + +
+ +
+
+ ) +} + +export function WizardAuto() { + const { t } = useTranslationSafe() + const { push } = useRouter() + const [dataset, setDataset] = useRecoilState(datasetCurrentAtom) + const [datasetHighlighted, setDatasetHighlighted] = useState(dataset) + + const onPrevious = useCallback(() => { + void push('/') // eslint-disable-line no-void + }, [push]) + + const onNext = useCallback(() => { + setDataset(datasetHighlighted) + void push('/dataset') // eslint-disable-line no-void + }, [datasetHighlighted, push, setDataset]) + + const hasAutodetectResults = useRecoilValue(hasAutodetectResultsAtom) + const hasErrors = !!useRecoilValue(globalErrorAtom) + + const { isRunButtonDisabled, runButtonColor, runButtonTooltip } = useMemo(() => { + const hasDatasetHighlighted = !!datasetHighlighted + const isRunButtonDisabled = !hasAutodetectResults || !hasDatasetHighlighted || hasErrors + return { + isRunButtonDisabled, + runButtonColor: isRunButtonDisabled ? 'secondary' : 'success', + runButtonTooltip: isRunButtonDisabled + ? t('Please provide sequence data and select one of the datasets') + : t('Go to the next step!'), + } + }, [datasetHighlighted, hasAutodetectResults, hasErrors, t]) + + return ( + + + + + + + + + + + +
+ + + + {t('Previous')} + + + + {t('Next')} + + + +
+
+ ) +} diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetContentSection.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetContentSection.tsx index 2c1d98107..e13ca509f 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetContentSection.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetContentSection.tsx @@ -1,18 +1,18 @@ import classnames from 'classnames' -import React, { PropsWithChildren, useCallback, useMemo, useRef, useState } from 'react' +import React, { PropsWithChildren, useCallback, useState } from 'react' import styled from 'styled-components' import { Nav as NavBase, NavItem as NavItemBase, NavLink as NavLinkBase, - TabPane, + TabPane as TabPaneBase, TabContent as TabContentBase, NavItemProps, - TabPaneProps, } from 'reactstrap' import { useRecoilValue } from 'recoil' import { MarkdownRemote } from 'src/components/Common/Markdown' import { datasetCurrentAtom } from 'src/state/dataset.state' +import { DatasetContentTabAdvanced } from 'src/components/Main/DatasetContentTabAdvanced' export function DatasetContentSection() { const [activeTabId, setActiveTabId] = useState(0) @@ -20,21 +20,30 @@ export function DatasetContentSection() { return ( - - + {currentDataset?.files.readme && } - - + + {currentDataset?.files.changelog && } - + + {currentDataset && } ) @@ -58,40 +67,40 @@ export function TabLabel({ tabId, activeTabId, setActiveTabId, children, ...rest ) } -export interface TabContentPaneProps extends PropsWithChildren { - tabId: number - activeTabId: number -} +const ContentSection = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +` -export function TabContentPane({ tabId, activeTabId, children, ...rest }: TabContentPaneProps) { - const active = activeTabId === tabId - return ( - - - {children} - - - ) -} +const TabContent = styled(TabContentBase)` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; -export interface LazyProps { - visible: boolean -} + margin-top: -1px; +` -export function LazyRender({ visible, children }: PropsWithChildren) { - const rendered = useRef(visible) - const style = useMemo(() => ({ display: visible ? 'block' : 'none' }), [visible]) - if (visible && !rendered.current) { - rendered.current = true - } - if (!rendered.current) return null - return
{children}
-} +const TabPane = styled(TabPaneBase)` + display: flex; + flex: 1; + flex-direction: column; + overflow: auto; -const ContentSection = styled.div` - max-width: 100%; + border: 1px #ccc9 solid; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; + border-top-right-radius: 5px; + + padding: 1rem; ` +export interface LazyProps { + visible: boolean +} + const Nav = styled(NavBase)` border-bottom: 0 !important; ` @@ -108,6 +117,8 @@ const NavItem = styled(NavItemBase)` border-bottom: 0 !important; + z-index: 2; + .active { font-weight: bold; background-color: ${(props) => props.theme.white} !important; @@ -135,8 +146,3 @@ const NavItem = styled(NavItemBase)` const NavLink = styled(NavLinkBase)` color: ${(props) => props.theme.bodyColor}; ` - -const TabContent = styled(TabContentBase)` - border: #ddd 1px solid; - margin-top: -1px; -` diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetContentTabAdvanced.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetContentTabAdvanced.tsx new file mode 100644 index 000000000..dd177a0a8 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/DatasetContentTabAdvanced.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import styled from 'styled-components' +import { FilePickerAdvanced } from 'src/components/FilePicker/FilePickerAdvanced' +import AdvancedModeExplanationContent from 'src/components/Main/AdvancedModeExplanation.mdx' + +export function DatasetContentTabAdvanced() { + return ( + +
+ + + +
+ +
+ +
+
+ ) +} + +export const AdvancedModeExplanationWrapper = styled.div` + > p { + margin: 0; + } +` + +const Container = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +` + +const Header = styled.div` + flex: 0; +` + +const Main = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: auto; + width: 100%; +` diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetCurrent.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetCurrent.tsx index 0c4d5167e..1ff57fea0 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetCurrent.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetCurrent.tsx @@ -1,16 +1,13 @@ -import { isNil } from 'lodash' -import React, { useCallback, useMemo, useState } from 'react' -import { Button, Col, Collapse, Row, UncontrolledAlert } from 'reactstrap' -import { useRecoilState, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil' +import React from 'react' +import { Col, Row } from 'reactstrap' +import { useRecoilValue } from 'recoil' +import { DatasetContentSection } from 'src/components/Main/DatasetContentSection' import styled from 'styled-components' import { useUpdatedDataset } from 'src/io/fetchDatasets' -import { datasetCurrentAtom, datasetUpdatedAtom } from 'src/state/dataset.state' +import { datasetCurrentAtom } from 'src/state/dataset.state' import { useTranslationSafe } from 'src/helpers/useTranslationSafe' -import { ButtonCustomize } from 'src/components/Main/ButtonCustomize' -import { FilePickerAdvanced } from 'src/components/FilePicker/FilePickerAdvanced' -import { LinkExternal } from 'src/components/Link/LinkExternal' -import { DatasetInfo } from './DatasetInfo' -import AdvancedModeExplanationContent from './AdvancedModeExplanation.mdx' +import { DatasetInfo } from 'src/components/Main/DatasetInfo' +import { DatasetCurrentUpdateNotification } from 'src/components/Main/DatasetCurrentUpdateNotification' export const CurrentDatasetInfoContainer = styled.div` display: flex; @@ -27,44 +24,40 @@ export const CurrentDatasetInfoHeader = styled.section` const DatasetInfoH4 = styled.h4` flex: 1; margin: auto 0; + margin-top: 12px; ` export const CurrentDatasetInfoBody = styled.section` display: flex; flex-direction: column; - margin: 0; padding: 12px; border: 1px #ccc9 solid; border-radius: 5px; - height: 100%; ` -export const Left = styled.section` - flex: 1 1 auto; +const Container = styled.div` display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +` + +const Header = styled.div` + flex: 0; ` -export const Right = styled.section` - flex: 0 0 250px; +const Main = styled.div` display: flex; + flex: 1; flex-direction: column; - height: 100%; -` + overflow: auto; + width: 100%; -export const ChangeButton = styled(Button)` - flex: 0 0 auto; - height: 2.1rem; - min-width: 100px; - margin-left: auto; + margin: 0.5rem 0; ` -export const AdvancedModeExplanationWrapper = styled.div` - max-width: 550px; - margin-top: 0.5rem; - - > p { - margin: 0; - } +export const FlexLeft = styled.div` + flex: 1; ` export function DatasetCurrent() { @@ -72,37 +65,7 @@ export function DatasetCurrent() { useUpdatedDataset() const { t } = useTranslationSafe() - const [advancedOpen, setAdvancedOpen] = useState(false) const datasetCurrent = useRecoilValue(datasetCurrentAtom) - const resetDatasetCurrent = useResetRecoilState(datasetCurrentAtom) - - const onChangeClicked = useCallback(() => { - resetDatasetCurrent() - }, [resetDatasetCurrent]) - - const onCustomizeClicked = useCallback(() => setAdvancedOpen((advancedOpen) => !advancedOpen), []) - - const customize = useMemo(() => { - if (datasetCurrent?.path === 'autodetect') { - return null - } - - return ( - - - - - - - - - - - - - - ) - }, [advancedOpen, datasetCurrent?.path, onCustomizeClicked]) if (!datasetCurrent) { return null @@ -113,77 +76,25 @@ export function DatasetCurrent() { {t('Selected pathogen')} - - - - - - - - - - - - - {t('Change')} - - - - - - {customize} - + +
+ + + + + + + + + + + +
+ +
+ +
+
) } - -function DatasetCurrentUpdateNotification() { - const { t } = useTranslationSafe() - const [datasetUpdated, setDatasetUpdated] = useRecoilState(datasetUpdatedAtom) - const setDatasetCurrent = useSetRecoilState(datasetCurrentAtom) - - const onDatasetUpdateClicked = useCallback(() => { - setDatasetCurrent(datasetUpdated) - setDatasetUpdated(undefined) - }, [datasetUpdated, setDatasetCurrent, setDatasetUpdated]) - - if (isNil(datasetUpdated)) { - return null - } - - return ( - - - - -

{t('A new version of this dataset is available.')}

-

- - {"What's new?"} - -

-
- - - - {t('Update')} - - -
- -
- ) -} - -const AlertTextWrapper = styled.div` - flex: 1; -` - -const AlertButtonWrapper = styled.div` - flex: 0; -` diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetCurrentUpdateNotification.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetCurrentUpdateNotification.tsx new file mode 100644 index 000000000..75f30bc79 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/DatasetCurrentUpdateNotification.tsx @@ -0,0 +1,66 @@ +import { isNil } from 'lodash' +import React, { useCallback } from 'react' +import { Button, Col, Row, UncontrolledAlert } from 'reactstrap' +import { useRecoilState, useSetRecoilState } from 'recoil' +import { LinkExternal } from 'src/components/Link/LinkExternal' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { datasetCurrentAtom, datasetUpdatedAtom } from 'src/state/dataset.state' +import styled from 'styled-components' + +export function DatasetCurrentUpdateNotification() { + const { t } = useTranslationSafe() + const [datasetUpdated, setDatasetUpdated] = useRecoilState(datasetUpdatedAtom) + const setDatasetCurrent = useSetRecoilState(datasetCurrentAtom) + + const onDatasetUpdateClicked = useCallback(() => { + setDatasetCurrent(datasetUpdated) + setDatasetUpdated(undefined) + }, [datasetUpdated, setDatasetCurrent, setDatasetUpdated]) + + if (isNil(datasetUpdated)) { + return null + } + + return ( + + + + +

{t('A new version of this dataset is available.')}

+

+ + {"What's new?"} + +

+
+ + + + {t('Update')} + + +
+ +
+ ) +} + +const AlertTextWrapper = styled.div` + flex: 1; +` + +const AlertButtonWrapper = styled.div` + flex: 0; +` + +const ChangeButton = styled(Button)` + flex: 0 0 auto; + height: 2.1rem; + min-width: 100px; + margin-left: auto; +` diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetInfo.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetInfo.tsx index db8e79afc..59613b3ea 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetInfo.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetInfo.tsx @@ -7,29 +7,14 @@ import { colorHash } from 'src/helpers/colorHash' import { formatDateIsoUtcSimple } from 'src/helpers/formatDate' import { firstLetter } from 'src/helpers/string' import { useTranslationSafe } from 'src/helpers/useTranslationSafe' -import { - autodetectResultsByDatasetAtom, - DATASET_ID_UNDETECTED, - numberAutodetectResultsAtom, -} from 'src/state/autodetect.state' +import { autodetectResultsByDatasetAtom, numberAutodetectResultsAtom } from 'src/state/autodetect.state' import type { Dataset } from 'src/types' import styled from 'styled-components' export const Container = styled.div` display: flex; - //border: 1px #ccc9 solid; - //border-radius: 5px; - - //margin-top: 3px !important; - //margin-bottom: 3px !important; - //margin-left: 5px; - //padding: 15px; - margin: 0; - padding: 15px; - box-shadow: 0 0 12px 0 #0002; - border: 1px #ccc9 solid; - border-radius: 5px; + width: 100%; ` export const FlexLeft = styled.div` @@ -58,6 +43,7 @@ export const DatasetInfoLine = styled.p` font-size: 0.9rem; padding: 0; margin: 0; + width: 100%; &:after { content: ' '; @@ -66,6 +52,7 @@ export const DatasetInfoLine = styled.p` ` const DatasetInfoBadge = styled(Badge)` + margin-left: auto; font-size: 0.8rem; margin-top: 2px !important; padding: 0.25rem 0.5rem; @@ -73,9 +60,10 @@ const DatasetInfoBadge = styled(Badge)` export interface DatasetInfoProps { dataset: Dataset + showSuggestions?: boolean } -export function DatasetInfo({ dataset }: DatasetInfoProps) { +export function DatasetInfo({ dataset, showSuggestions }: DatasetInfoProps) { const { t } = useTranslationSafe() const { attributes, official, deprecated, enabled, experimental, path, version } = dataset const { name, reference } = attributes @@ -88,18 +76,18 @@ export function DatasetInfo({ dataset }: DatasetInfoProps) { return updatedAt }, [version?.tag, version?.updatedAt]) - if (!enabled) { - return null + if (path === 'autodetect') { + return } - if (path === DATASET_ID_UNDETECTED) { - return + if (!enabled) { + return null } return ( - + @@ -160,37 +148,69 @@ export function DatasetInfo({ dataset }: DatasetInfoProps) { ) } +export function DatasetAutodetectInfo({ dataset }: { dataset: Dataset }) { + const { t } = useTranslationSafe() + + return ( + + + + + + + + {t('Suggest automatically')} + + + {t('Suggest dataset automatically from sequences.')} + + + {t('Nextclade will attempt to guess the dataset from sequences in an additional step')} + + {'\u00A0'} + + + ) +} + export function DatasetUndetectedInfo() { const { t } = useTranslationSafe() return ( - {t('Autodetect')} + {t('Not detected')} - {t('Detect pathogen automatically from sequences')} - - + {t('Unable to deduce dataset')} + {'\u00A0'} + {'\u00A0'} + {'\u00A0'} ) } export interface DatasetInfoCircleProps { + text?: string + color?: string dataset: Dataset + showSuggestions?: boolean } -function DatasetInfoAutodetectProgressCircle({ dataset }: DatasetInfoCircleProps) { +function DatasetInfoAutodetectProgressCircle({ text, color, dataset, showSuggestions }: DatasetInfoCircleProps) { const { attributes, path } = dataset const { name } = attributes - const circleBg = useMemo(() => darken(0.1)(colorHash(path, { saturation: 0.5, reverse: true })), [path]) + const circleBg = useMemo( + () => color ?? darken(0.1)(colorHash(path, { saturation: 0.5, reverse: true })), + [color, path], + ) const records = useRecoilValue(autodetectResultsByDatasetAtom(path)) const numberAutodetectResults = useRecoilValue(numberAutodetectResultsAtom) const { circleText, countText, percentage } = useMemo(() => { - if (isNil(records)) { + if (!showSuggestions || isNil(records)) { return { - circleText: (firstLetter(name.valueFriendly ?? name.value) ?? ' ').toUpperCase(), + circleText: text ?? (firstLetter(name.valueFriendly ?? name.value) ?? ' ').toUpperCase(), percentage: 0, countText: '\u00A0', } @@ -203,7 +223,7 @@ function DatasetInfoAutodetectProgressCircle({ dataset }: DatasetInfoCircleProps return { circleText, percentage, countText } } return { circleText: `0%`, percentage: 0, countText: `0 / ${numberAutodetectResults}` } - }, [records, name.value, name.valueFriendly, numberAutodetectResults]) + }, [showSuggestions, records, numberAutodetectResults, text, name.valueFriendly, name.value]) return ( <> diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetListEntry.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetListEntry.tsx new file mode 100644 index 000000000..b99055e42 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/DatasetListEntry.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import styled from 'styled-components' +import type { Dataset } from 'src/types' +import { DatasetInfo } from 'src/components/Main/DatasetInfo' + +export const Container = styled.div` + display: flex; + padding: 15px; + box-shadow: 0 0 12px 0 #0002; + border: 1px #ccc9 solid; + border-radius: 5px; + width: 100%; +` + +export interface DatasetListEntryProps { + dataset: Dataset + showSuggestions?: boolean +} + +export function DatasetListEntry({ dataset, showSuggestions }: DatasetListEntryProps) { + return ( + + + + ) +} diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetPage.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetPage.tsx new file mode 100644 index 000000000..350c0c032 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/DatasetPage.tsx @@ -0,0 +1,125 @@ +import React, { useCallback, useEffect, useMemo } from 'react' +import { Layout } from 'src/components/Layout/Layout' +import { useRouter } from 'next/router' +import { useRecoilState, useRecoilValue } from 'recoil' +import styled from 'styled-components' +import { Button } from 'reactstrap' +import { + Footer, + WizardContainer, + WizardMain, + WizardNavigationButton, + WizardNavigationForm, +} from 'src/components/Main/Wizard' +import { isDatasetPageVisitedAtom } from 'src/state/navigation.state' +import { WizardManualStep } from 'src/components/Main/WizardManualStep' +import { FaChevronLeft as IconLeft, FaChevronRight as IconRight } from 'react-icons/fa6' +import { ToggleRunAutomatically } from 'src/components/Main/RunPanel' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { useRunAnalysis } from 'src/hooks/useRunAnalysis' +import { useRecoilToggle } from 'src/hooks/useToggle' +import { AlgorithmInputDefault } from 'src/io/AlgorithmInput' +import { datasetCurrentAtom } from 'src/state/dataset.state' +import { hasInputErrorsAtom } from 'src/state/error.state' +import { hasRequiredInputsAtom, useQuerySeqInputs } from 'src/state/inputs.state' +import { canRunAtom } from 'src/state/results.state' +import { shouldRunAutomaticallyAtom } from 'src/state/settings.state' + +const Main = styled.div` + display: flex; + flex: 1 1 100%; + overflow: hidden; + padding: 0; + margin: 0 auto; + + width: 100%; + max-width: 1400px; +` + +export function DatasetPage() { + return ( + +
+ +
+
+ ) +} + +export function WizardManual() { + const { t } = useTranslationSafe() + const { back } = useRouter() + const [datasetCurrent, _setDatasetCurrent] = useRecoilState(datasetCurrentAtom) + const { addQryInputs } = useQuerySeqInputs() + const hasRequiredInputs = useRecoilValue(hasRequiredInputsAtom) + const hasInputErrors = useRecoilValue(hasInputErrorsAtom) + const { state: shouldRunAutomatically } = useRecoilToggle(shouldRunAutomaticallyAtom) + const canRun = useRecoilValue(canRunAtom) + const runAnalysis = useRunAnalysis() + const { enable: setDatasetPageVisited } = useRecoilToggle(isDatasetPageVisitedAtom) + + useEffect(() => { + setDatasetPageVisited() + }, [setDatasetPageVisited]) + + const setExampleSequences = useCallback(() => { + if (datasetCurrent) { + addQryInputs([new AlgorithmInputDefault(datasetCurrent)]) + if (shouldRunAutomatically) { + runAnalysis() + } + } + }, [addQryInputs, datasetCurrent, runAnalysis, shouldRunAutomatically]) + + const { isRunButtonDisabled, runButtonColor, runButtonTooltip } = useMemo(() => { + const isRunButtonDisabled = !(canRun && hasRequiredInputs) || hasInputErrors + return { + isRunButtonDisabled, + runButtonColor: isRunButtonDisabled ? 'secondary' : 'success', + runButtonTooltip: isRunButtonDisabled + ? t('Please provide sequence data for the algorithm') + : t('Launch the algorithm!'), + } + }, [canRun, hasInputErrors, hasRequiredInputs, t]) + + const onPrevious = useCallback(() => { + back() + }, [back]) + + return ( + + + + +
+ + + + {t('Previous')} + + + + + + + + {t('Run')} + + + +
+
+ ) +} diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx index ca7f290b4..52d9eed1d 100644 --- a/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx +++ b/packages_rs/nextclade-web/src/components/Main/DatasetSelector.tsx @@ -1,21 +1,164 @@ -import React, { HTMLProps, useState } from 'react' +import { get, isNil, sortBy } from 'lodash' +import React, { useEffect, useMemo, useState } from 'react' import { useRecoilState, useRecoilValue } from 'recoil' +import { Container as ContainerBase } from 'reactstrap' +import { DatasetSelectorListImpl } from 'src/components/Main/DatasetSelectorListImpl' +import { PROJECT_NAME } from 'src/constants' +import { + autodetectResultsAtom, + AutodetectRunState, + autodetectRunStateAtom, + groupByDatasets, +} from 'src/state/autodetect.state' import styled from 'styled-components' -import { ThreeDots } from 'react-loader-spinner' -import { SuggestionPanel } from 'src/components/Main/SuggestionPanel' +import type { Dataset } from 'src/types' import { useTranslationSafe } from 'src/helpers/useTranslationSafe' -import { datasetCurrentAtom, datasetsAtom } from 'src/state/dataset.state' +import { datasetsAtom } from 'src/state/dataset.state' import { SearchBox } from 'src/components/Common/SearchBox' -import { DatasetSelectorList } from 'src/components/Main/DatasetSelectorList' -export function DatasetSelector() { - const { t } = useTranslationSafe() - const [searchTerm, setSearchTerm] = useState('') +// HACK: dataset entry for 'autodetect' option. This is not a real dataset. +export const DATASET_AUTODETECT: Dataset = { + path: 'autodetect', + enabled: true, + official: true, + attributes: { + name: { value: 'autodetect', valueFriendly: 'Autodetect' }, + reference: { value: 'autodetect', valueFriendly: 'Autodetect' }, + }, + files: { + reference: '', + pathogenJson: '', + }, +} + +export interface DatasetSelectorProps { + datasetHighlighted?: Dataset + onDatasetHighlighted?(dataset?: Dataset): void +} + +export function DatasetSelector({ datasetHighlighted, onDatasetHighlighted }: DatasetSelectorProps) { const { datasets } = useRecoilValue(datasetsAtom) - const [datasetCurrent, setDatasetCurrent] = useRecoilState(datasetCurrentAtom) - const isBusy = datasets.length === 0 + const datasetsActive = useMemo(() => { + return [DATASET_AUTODETECT, ...datasets] + }, [datasets]) + + return ( + + ) +} + +export function DatasetAutosuggestionResultsList({ datasetHighlighted, onDatasetHighlighted }: DatasetSelectorProps) { + const { datasets } = useRecoilValue(datasetsAtom) + + const autodetectResults = useRecoilValue(autodetectResultsAtom) + const [autodetectRunState, setAutodetectRunState] = useRecoilState(autodetectRunStateAtom) + + const autodetectResult = useMemo(() => { + if (isNil(autodetectResults) || autodetectResults.length === 0) { + return undefined + } + + const recordsByDataset = groupByDatasets(autodetectResults) + + let itemsInclude = datasets.filter((candidate) => + Object.entries(recordsByDataset).some(([dataset, _]) => dataset === candidate.path), + ) + + itemsInclude = sortBy(itemsInclude, (dataset) => -get(recordsByDataset, dataset.path, []).length) + + const itemsNotInclude = datasets.filter((candidate) => !itemsInclude.map((it) => it.path).includes(candidate.path)) + + return { itemsStartWith: [], itemsInclude, itemsNotInclude } + }, [autodetectResults, datasets]) + + const datasetsActive = useMemo(() => { + if (!autodetectResult) { + return [] + } + const { itemsStartWith, itemsInclude } = autodetectResult + return [...itemsStartWith, ...itemsInclude] + }, [autodetectResult]) + + useEffect(() => { + const topSuggestion = autodetectResult?.itemsInclude[0] + if (autodetectRunState === AutodetectRunState.Done) { + onDatasetHighlighted?.(topSuggestion) + setAutodetectRunState(AutodetectRunState.Idle) + } + }, [autodetectRunState, autodetectResult?.itemsInclude, onDatasetHighlighted, setAutodetectRunState]) + + if (!autodetectResults) { + return + } + return ( + + ) +} + +function DatasetAutosuggestionInstructions() { + const { t } = useTranslationSafe() + return ( +
+ {t('Dataset autosuggestion')} + +
+

+ {t('{{projectName}} will try to guess dataset from data and will present its suggestions here.', { + projectName: PROJECT_NAME, + })} +

+

{t('Please provide sequences to start.')}

+
+
+
+ ) +} + +const Heading = styled.h4` + padding-top: 12px; + margin-bottom: 0; + margin-left: 7px; + width: 100%; +` + +const Wrapper = styled.div` + display: flex; + height: 100%; + width: 100%; + padding: 10px; + border: 1px #ccc9 solid; + border-radius: 5px; + margin: 7px; +` + +export interface DatasetSelectorImplProps { + datasetsActive: Dataset[] + datasetsInactive?: Dataset[] + datasetHighlighted?: Dataset + onDatasetHighlighted?(dataset?: Dataset): void + showSuggestions?: boolean +} + +export function DatasetSelectorImpl({ + datasetsActive, + datasetsInactive, + datasetHighlighted, + onDatasetHighlighted, + showSuggestions, +}: DatasetSelectorImplProps) { + const { t } = useTranslationSafe() + const [searchTerm, setSearchTerm] = useState('') return (
@@ -25,38 +168,26 @@ export function DatasetSelector() {
- {!isBusy && ( - - )} - - {isBusy && ( - - - - - - )} +
- -
- -
) } -const Container = styled.div` +const Container = styled(ContainerBase)` display: flex; flex: 1; flex-direction: column; - height: 100%; overflow: hidden; - margin-right: 10px; + margin: 0 auto; + max-width: 800px; ` const Header = styled.div` @@ -74,28 +205,12 @@ const Main = styled.div` overflow: hidden; ` -const Footer = styled.div` - display: flex; - flex: 0; -` +// const Footer = styled.div` +// display: flex; +// flex: 0; +// ` const Title = styled.h4` flex: 1; margin: auto 0; ` - -const SpinnerWrapper = styled.div>` - width: 100%; - height: 100%; - display: flex; -` - -const SpinnerWrapperInternal = styled.div` - margin: auto; -` - -const Spinner = styled(ThreeDots)` - flex: 1; - margin: auto; - height: 100%; -` diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx deleted file mode 100644 index ec5fd62d5..000000000 --- a/packages_rs/nextclade-web/src/components/Main/DatasetSelectorList.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { get, isNil, sortBy } from 'lodash' -import { lighten } from 'polished' -import React, { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react' -import { useRecoilState, useRecoilValue } from 'recoil' -import { ListGenericCss } from 'src/components/Common/List' -import { DatasetInfo } from 'src/components/Main/DatasetInfo' -import { search } from 'src/helpers/search' -import { - autodetectResultsAtom, - AutodetectRunState, - autodetectRunStateAtom, - groupByDatasets, -} from 'src/state/autodetect.state' -import type { Dataset } from 'src/types' -import { areDatasetsEqual } from 'src/types' -import styled from 'styled-components' - -export interface DatasetSelectorListProps { - datasets: Dataset[] - searchTerm: string - datasetHighlighted?: Dataset - - onDatasetHighlighted(dataset?: Dataset): void -} - -export function DatasetSelectorList({ - datasets, - searchTerm, - datasetHighlighted, - onDatasetHighlighted, -}: DatasetSelectorListProps) { - const onItemClick = useCallback((dataset: Dataset) => () => onDatasetHighlighted(dataset), [onDatasetHighlighted]) - - const autodetectResults = useRecoilValue(autodetectResultsAtom) - const [autodetectRunState, setAutodetectRunState] = useRecoilState(autodetectRunStateAtom) - - const autodetectResult = useMemo(() => { - if (isNil(autodetectResults) || autodetectResults.length === 0) { - return { itemsStartWith: [], itemsInclude: datasets, itemsNotInclude: [] } - } - - const recordsByDataset = groupByDatasets(autodetectResults) - - let itemsInclude = datasets.filter((candidate) => - Object.entries(recordsByDataset).some(([dataset, _]) => dataset === candidate.path), - ) - - itemsInclude = sortBy(itemsInclude, (dataset) => -get(recordsByDataset, dataset.path, []).length) - - const itemsNotInclude = datasets.filter((candidate) => !itemsInclude.map((it) => it.path).includes(candidate.path)) - - return { itemsStartWith: [], itemsInclude, itemsNotInclude } - }, [autodetectResults, datasets]) - - const searchResult = useMemo(() => { - if (searchTerm.trim().length === 0) { - return autodetectResult - } - - return search( - [...autodetectResult.itemsStartWith, ...autodetectResult.itemsInclude, ...autodetectResult.itemsNotInclude], - searchTerm, - (dataset) => [ - dataset.attributes.name.value, - dataset.attributes.name.valueFriendly ?? '', - dataset.attributes.reference.value, - dataset.attributes.reference.valueFriendly ?? '', - ], - ) - }, [autodetectResult, searchTerm]) - - const { itemsStartWith, itemsInclude, itemsNotInclude } = searchResult - - const itemsRef = useRef>(new Map()) - - function scrollToId(itemId: string) { - const node = itemsRef.current.get(itemId) - node?.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'center', - }) - } - - if (datasetHighlighted) { - scrollToId(datasetHighlighted.path) - } - - useEffect(() => { - const topSuggestion = autodetectResult.itemsInclude[0] - if (autodetectRunState === AutodetectRunState.Done) { - onDatasetHighlighted(topSuggestion) - setAutodetectRunState(AutodetectRunState.Idle) - } - }, [autodetectRunState, autodetectResult.itemsInclude, onDatasetHighlighted, setAutodetectRunState]) - - const ulRef = useRef(null) - useEffect(() => ulRef.current?.scrollTo({ top: 0, left: 0, behavior: 'smooth' }), [searchTerm]) - - const listItems = useMemo(() => { - return ( - <> - {[itemsStartWith, itemsInclude].map((datasets) => - datasets.map((dataset) => ( - - )), - )} - - {[itemsNotInclude].map((datasets) => - datasets.map((dataset) => ( - - )), - )} - - ) - }, [datasetHighlighted, itemsInclude, itemsNotInclude, itemsStartWith, onItemClick]) - - return
    {listItems}
-} - -function nodeRefSetOrDelete(map: Map, key: string) { - return function nodeRefSetOrDeleteImpl(node: T) { - if (node) { - map.set(key, node) - } else { - map.delete(key) - } - } -} - -export const Ul = styled.ul` - ${ListGenericCss}; - flex: 1; - overflow: auto; - padding: 5px 5px; - border-radius: 0 !important; -` - -export const Li = styled.li<{ $active?: boolean; $isDimmed?: boolean }>` - cursor: pointer; - opacity: ${(props) => props.$isDimmed && 0.4}; - background-color: transparent; - - margin: 3px 3px !important; - padding: 0 !important; - border-radius: 5px !important; - - ${(props) => - props.$active && - ` - color: ${props.theme.white}; - background-color: ${lighten(0.033)(props.theme.primary)}; - box-shadow: -3px 3px 12px 3px #0005; - opacity: ${props.$isDimmed && 0.66}; - `}; -` - -interface DatasetSelectorListItemProps { - dataset: Dataset - isCurrent?: boolean - isDimmed?: boolean - onClick?: () => void -} - -const DatasetSelectorListItem = forwardRef( - function DatasetSelectorListItemWithRef({ dataset, isCurrent, isDimmed, onClick }, ref) { - return ( -
  • - -
  • - ) - }, -) diff --git a/packages_rs/nextclade-web/src/components/Main/DatasetSelectorListImpl.tsx b/packages_rs/nextclade-web/src/components/Main/DatasetSelectorListImpl.tsx new file mode 100644 index 000000000..8cad63967 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/DatasetSelectorListImpl.tsx @@ -0,0 +1,152 @@ +import React, { forwardRef, useCallback, useMemo, useRef } from 'react' +import { lighten } from 'polished' +import { ListGroup } from 'reactstrap' +import styled from 'styled-components' +import type { Dataset } from 'src/types' +import { areDatasetsEqual } from 'src/types' +import { search } from 'src/helpers/search' +import { ListGenericCss } from 'src/components/Common/List' +import { DatasetListEntry } from 'src/components/Main/DatasetListEntry' + +export interface DatasetSelectorListImplProps { + datasetsActive: Dataset[] + datasetsInactive?: Dataset[] + datasetHighlighted?: Dataset + onDatasetHighlighted?(dataset?: Dataset): void + searchTerm: string + showSuggestions?: boolean +} + +export function DatasetSelectorListImpl({ + datasetsActive, + datasetsInactive = [], + datasetHighlighted, + onDatasetHighlighted, + searchTerm, + showSuggestions, +}: DatasetSelectorListImplProps) { + const onItemClick = useCallback((dataset: Dataset) => () => onDatasetHighlighted?.(dataset), [onDatasetHighlighted]) + + const listItemsRef = useScrollListToDataset(datasetHighlighted) + + const searchResult = useMemo(() => { + if (searchTerm.trim().length === 0) { + return { itemsStartWith: [], itemsInclude: datasetsActive, itemsNotInclude: datasetsInactive } + } + + return search([...datasetsActive, ...datasetsInactive], searchTerm, (dataset) => [ + dataset.attributes.name.value, + dataset.attributes.name.valueFriendly ?? '', + dataset.attributes.reference.value, + dataset.attributes.reference.valueFriendly ?? '', + dataset.path, + ]) + }, [datasetsActive, datasetsInactive, searchTerm]) + + const { itemsStartWith, itemsInclude, itemsNotInclude } = searchResult + + return useMemo( + () => ( +
      + {[...itemsStartWith, ...itemsInclude].map((dataset) => ( + + ))} + + {itemsNotInclude.map((dataset) => ( + + ))} +
    + ), + [datasetHighlighted, itemsInclude, itemsNotInclude, itemsStartWith, listItemsRef, onItemClick, showSuggestions], + ) +} + +function nodeRefSetOrDelete(map: Map, key: string) { + return function nodeRefSetOrDeleteImpl(node: T) { + if (node) { + map.set(key, node) + } else { + map.delete(key) + } + } +} + +function useScrollListToDataset(datasetHighlighted?: Dataset) { + const itemsRef = useRef>(new Map()) + + const scrollToId = useCallback((itemId: string) => { + const node = itemsRef.current.get(itemId) + node?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }) + }, []) + + if (datasetHighlighted) { + scrollToId(datasetHighlighted.path) + } + + return itemsRef +} + +export const Ul = styled(ListGroup)` + ${ListGenericCss}; + flex: 1; + overflow: auto; + padding: 5px 5px; + border-radius: 0 !important; +` + +export const Li = styled.li<{ $active?: boolean; $isDimmed?: boolean }>` + cursor: pointer; + opacity: ${(props) => props.$isDimmed && 0.4}; + background-color: transparent; + + margin: 3px 3px !important; + padding: 0 !important; + border-radius: 5px !important; + + ${(props) => + props.$active && + ` + color: ${props.theme.white}; + background-color: ${lighten(0.033)(props.theme.primary)}; + color: ${props.theme.gray100}; + box-shadow: -3px 3px 12px 3px #0005; + opacity: ${props.$isDimmed && 0.66}; + `}; +` + +interface DatasetSelectorListItemProps { + dataset: Dataset + isCurrent?: boolean + isDimmed?: boolean + showSuggestions?: boolean + onClick?: () => void +} + +const DatasetSelectorListItem = forwardRef( + function DatasetSelectorListItemWithRef({ dataset, isCurrent, isDimmed, onClick, showSuggestions }, ref) { + return ( +
  • + +
  • + ) + }, +) diff --git a/packages_rs/nextclade-web/src/components/Main/Downloads.tsx b/packages_rs/nextclade-web/src/components/Main/Downloads.tsx new file mode 100644 index 000000000..d0c7a1597 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/Downloads.tsx @@ -0,0 +1,168 @@ +import React, { HTMLProps, ReactNode } from 'react' + +import { useTranslationSafe as useTranslation } from 'src/helpers/useTranslationSafe' +import { FaBook, FaDocker, FaDownload, FaGithub, FaGlobeAmericas } from 'react-icons/fa' +import { + Card as ReactstrapCard, + CardBody as ReactstrapCardBody, + CardHeader as ReactstrapCardHeader, + Col, + Row, +} from 'reactstrap' +import styled from 'styled-components' + +import { LinkExternal as LinkExternalBase } from 'src/components/Link/LinkExternal' + +const DownloadLinkList = styled.ul` + display: flex; + flex-direction: column; + list-style: none; + padding: 0; +` + +const DownloadLinkListItem = styled.li` + display: flex; + flex: 1; + margin: auto; +` + +const LinkExternal = styled(LinkExternalBase)` + width: 200px; + height: 55px; + margin: 0.25rem; + padding: 1rem; +` + +const Card = styled(ReactstrapCard)` + margin: 5px; + height: 100%; +` + +const CardBody = styled(ReactstrapCardBody)` + padding: 0.5rem; +` + +const CardHeader = styled(ReactstrapCardHeader)` + padding: 1rem; +` + +const iconDownload = +const iconGithub = +const iconDocker = +const iconBook = +const iconGlobe = + +export interface DownloadLinkProps extends HTMLProps { + Icon: ReactNode + text: string + url: string +} + +export function DownloadLink({ Icon, text, url }: DownloadLinkProps) { + return ( + + + {Icon} + {text} + + + ) +} + +export function Downloads() { + const { t } = useTranslation() + + return ( + + + + +

    {t('For more advanced use-cases:')}

    + +
    + + + + + +

    {'Nextclade CLI'}

    +

    {t('faster, more configurable command-line version of this application')}

    +
    + + + + + + + + +
    + + + + + +

    {'Nextalign CLI'}

    +

    + {t('pairwise reference alignment and translation tool used by Nextclade')} +

    +
    + + + + + + + + +
    + + + + + +

    {'Nextstrain'}

    +

    + {t('our parent project, an open-source initiative to harness the potential of pathogen genome data')} +

    +
    + + + + + + + + + +
    + +
    + +
    + ) +} diff --git a/packages_rs/nextclade-web/src/components/Main/MainInputForm.tsx b/packages_rs/nextclade-web/src/components/Main/MainInputForm.tsx deleted file mode 100644 index e8e9993b4..000000000 --- a/packages_rs/nextclade-web/src/components/Main/MainInputForm.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { QuerySequenceFilePicker } from 'src/components/Main/QuerySequenceFilePicker' -import styled from 'styled-components' -import { Col as ColBase, Row as RowBase } from 'reactstrap' -import { useUpdatedDatasetIndex } from 'src/io/fetchDatasets' -import { DatasetSelector } from 'src/components/Main/DatasetSelector' - -const Container = styled.div` - margin: 0 auto; - height: 100%; - overflow: hidden; - margin-top: 10px; -` - -const Row = styled(RowBase)` - overflow: hidden; - height: 100%; -` - -const Col = styled(ColBase)` - overflow: hidden; - height: 100%; -` - -export function MainInputForm() { - // This periodically fetches dataset index and updates the list of datasets. - useUpdatedDatasetIndex() - - return ( - - - - - - - - - - - ) -} diff --git a/packages_rs/nextclade-web/src/components/Main/MainPage.tsx b/packages_rs/nextclade-web/src/components/Main/MainPage.tsx index be7f8592c..97cb4669f 100644 --- a/packages_rs/nextclade-web/src/components/Main/MainPage.tsx +++ b/packages_rs/nextclade-web/src/components/Main/MainPage.tsx @@ -1,12 +1,87 @@ -import React from 'react' - +import { Dataset } from '_SchemaRoot' +import { isNil } from 'lodash' +import { useRouter } from 'next/router' +import React, { useCallback, useState } from 'react' +import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' +import styled from 'styled-components' +import { isDatasetPageVisitedAtom } from 'src/state/navigation.state' +import { isInSuggestModeAtom } from 'src/state/autodetect.state' +import { datasetCurrentAtom } from 'src/state/dataset.state' import { Layout } from 'src/components/Layout/Layout' -import { MainInputForm } from 'src/components/Main/MainInputForm' +import { DatasetSelector } from 'src/components/Main/DatasetSelector' +import { MainSectionTitle } from 'src/components/Main/MainSectionTitle' +import { WizardNavigationBar } from 'src/components/Main/Wizard' export function MainPage() { + const { push, replace } = useRouter() + const [dataset, setDataset] = useRecoilState(datasetCurrentAtom) + const [datasetHighlighted, setDatasetHighlighted] = useState(dataset) + const setIsInSuggestMode = useSetRecoilState(isInSuggestModeAtom) + + const onNext = useCallback(() => { + if (typeof window !== 'undefined') { + if (datasetHighlighted?.path === 'autodetect') { + setIsInSuggestMode(true) + void push('/dataset-suggest') // eslint-disable-line no-void + } else { + setIsInSuggestMode(false) + setDataset(datasetHighlighted) + void push('/dataset') // eslint-disable-line no-void + } + } + }, [datasetHighlighted, push, setDataset, setIsInSuggestMode]) + + const isDatasetPageVisited = useRecoilValue(isDatasetPageVisitedAtom) + if (!isDatasetPageVisited && !isNil(dataset)) { + // Trigger Suspense (loading screen) until the routing promise is resolved + throw replace('/dataset') // eslint-disable-line @typescript-eslint/no-throw-literal + } + return ( - +
    + +
    + +
    +
    + +
    +
    + +
    +
    +
    ) } + +const Main = styled.div` + display: flex; + flex: 1 1 100%; + overflow: hidden; + padding: 0; + margin: 0 auto; + + width: 100%; +` + +const Container = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +` + +const Header = styled.div` + display: flex; + flex: 0; + padding-left: 10px; + margin-top: 10px; + margin-bottom: 3px; +` + +const Footer = styled.div` + display: flex; + flex: 0; +` diff --git a/packages_rs/nextclade-web/src/components/Main/MainSectionInfo.tsx b/packages_rs/nextclade-web/src/components/Main/MainSectionInfo.tsx new file mode 100644 index 000000000..47669bece --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/MainSectionInfo.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +import { Col, Row } from 'reactstrap' + +import { About } from 'src/components/About/About' +import { Downloads } from 'src/components/Main/Downloads' + +export function MainSectionInfo() { + return ( + + + + + + + + + + + + + + + + ) +} diff --git a/packages_rs/nextclade-web/src/components/Main/MainSectionTitle.tsx b/packages_rs/nextclade-web/src/components/Main/MainSectionTitle.tsx new file mode 100644 index 000000000..798444f96 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/MainSectionTitle.tsx @@ -0,0 +1,19 @@ +import React from 'react' + +import { useTranslationSafe as useTranslation } from 'src/helpers/useTranslationSafe' +import { Col, Row } from 'reactstrap' + +import { Subtitle, Title } from 'src/components/Main/Title' + +export function MainSectionTitle() { + const { t } = useTranslation() + + return ( + + + + <Subtitle>{t('Clade assignment, mutation calling, and sequence quality checks')}</Subtitle> + </Col> + </Row> + ) +} diff --git a/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx b/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx index 0c69f6f48..1eae8c6e5 100644 --- a/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx +++ b/packages_rs/nextclade-web/src/components/Main/QuerySequenceFilePicker.tsx @@ -3,7 +3,6 @@ import { useRecoilValue } from 'recoil' import styled from 'styled-components' import type { AlgorithmInput } from 'src/types' import { QuerySequenceList } from 'src/components/Main/QuerySequenceList' -import { RunPanel } from 'src/components/Main/RunPanel' import { useRunAnalysis } from 'src/hooks/useRunAnalysis' import { useRunSeqAutodetect } from 'src/hooks/useRunSeqAutodetect' import { useRecoilToggle } from 'src/hooks/useToggle' @@ -67,10 +66,6 @@ export function QuerySequenceFilePicker() { <Main> <QuerySequenceList /> </Main> - - <Footer> - <RunPanel /> - </Footer> </Container> ) } @@ -98,7 +93,7 @@ const Main = styled.div` overflow: hidden; ` -const Footer = styled.div` - display: flex; - flex: 0; -` +// const Footer = styled.div` +// display: flex; +// flex: 0; +// ` diff --git a/packages_rs/nextclade-web/src/components/Main/RunPanel.tsx b/packages_rs/nextclade-web/src/components/Main/RunPanel.tsx index 9e573dc0d..30a26283e 100644 --- a/packages_rs/nextclade-web/src/components/Main/RunPanel.tsx +++ b/packages_rs/nextclade-web/src/components/Main/RunPanel.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useMemo } from 'react' import styled from 'styled-components' -import { Button, Form as FormBase, FormGroup } from 'reactstrap' +import { Button, Form as FormBase, FormGroup as FormGroupBase, FormGroupProps } from 'reactstrap' import { useRecoilValue } from 'recoil' import { useRunAnalysis } from 'src/hooks/useRunAnalysis' import { useRunSeqAutodetect } from 'src/hooks/useRunSeqAutodetect' @@ -15,6 +15,27 @@ import { useTranslationSafe } from 'src/helpers/useTranslationSafe' import { AlgorithmInputDefault } from 'src/io/AlgorithmInput' import { hasRequiredInputsAtom, useQuerySeqInputs } from 'src/state/inputs.state' +export function ToggleRunAutomatically({ ...restProps }: FormGroupProps) { + const { t } = useTranslationSafe() + const { state: shouldRunAutomatically, toggle: toggleRunAutomatically } = useRecoilToggle(shouldRunAutomaticallyAtom) + return ( + <FormGroup inline {...restProps}> + <Toggle + identifier="toggle-run-automatically" + checked={shouldRunAutomatically} + onCheckedChanged={toggleRunAutomatically} + > + <span title={t('Run Nextclade automatically after sequence data is provided')}>{t('Run automatically')}</span> + </Toggle> + </FormGroup> + ) +} + +const FormGroup = styled(FormGroupBase)` + display: flex; + margin: auto 0; +` + export function RunPanel() { const { t } = useTranslationSafe() @@ -22,7 +43,7 @@ export function RunPanel() { const { addQryInputs } = useQuerySeqInputs() const canRun = useRecoilValue(canRunAtom) - const { state: shouldRunAutomatically, toggle: toggleRunAutomatically } = useRecoilToggle(shouldRunAutomaticallyAtom) + const shouldRunAutomatically = useRecoilValue(shouldRunAutomaticallyAtom) const shouldSuggestDatasets = useRecoilValue(shouldSuggestDatasetsAtom) const hasRequiredInputs = useRecoilValue(hasRequiredInputsAtom) @@ -58,17 +79,7 @@ export function RunPanel() { <Container> <Form> <FlexLeft> - <FormGroup> - <Toggle - identifier="toggle-run-automatically" - checked={shouldRunAutomatically} - onCheckedChanged={toggleRunAutomatically} - > - <span title={t('Run Nextclade automatically after sequence data is provided')}> - {t('Run automatically')} - </span> - </Toggle> - </FormGroup> + <ToggleRunAutomatically /> </FlexLeft> <FlexRight> diff --git a/packages_rs/nextclade-web/src/components/Main/Title.tsx b/packages_rs/nextclade-web/src/components/Main/Title.tsx new file mode 100644 index 000000000..d1fd27a33 --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/Title.tsx @@ -0,0 +1,82 @@ +import React from 'react' + +import styled from 'styled-components' + +import { TITLE_COLORS } from 'src/constants' + +// eslint-disable-next-line prefer-destructuring +const PACKAGE_VERSION = process.env.PACKAGE_VERSION + +// Borrowed with modifications from Nextstrain.org +// https://github.com/nextstrain/nextstrain.org/blob/master/static-site/src/components/splash/title.jsx + +const TitleH1 = styled.h1` + display: inline; + margin-top: 0; + margin-bottom: 0; + font-weight: 300; + letter-spacing: -1px; + font-size: 6rem; + + @media (max-width: 767.98px) { + font-size: 5rem; + } + + @media (max-width: 576px) { + font-size: 3.5rem; + } +` + +const VersionNumberBadge = styled.p` + display: inline; + font-size: 0.85rem; + color: #7b838a; + + @media (max-width: 767.98px) { + left: -35px; + font-size: 0.8rem; + } + + @media (max-width: 576px) { + left: -30px; + font-size: 0.75rem; + } +` + +const LetterSpan = styled.span<{ pos: number }>` + color: ${(props) => TITLE_COLORS[props.pos]}; +` + +export function Title() { + return ( + <span> + <TitleH1> + {'Nextclade'.split('').map((letter, i) => ( + // eslint-disable-next-line react/no-array-index-key + <LetterSpan key={`${i}_${letter}`} pos={i}> + {letter} + </LetterSpan> + ))} + </TitleH1> + {PACKAGE_VERSION && <VersionNumberBadge color="secondary">{`v${PACKAGE_VERSION}`}</VersionNumberBadge>} + </span> + ) +} + +export const Subtitle = styled.h2` + text-align: center; + font-size: 2rem; + font-weight: 300; + + @media (max-width: 991.98px) { + font-size: 1.5rem; + } + + @media (max-width: 767.98px) { + font-size: 1.2rem; + } + + @media (max-width: 576px) { + font-size: 1rem; + } +` diff --git a/packages_rs/nextclade-web/src/components/Main/Wizard.tsx b/packages_rs/nextclade-web/src/components/Main/Wizard.tsx new file mode 100644 index 000000000..1e0a4379d --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/Wizard.tsx @@ -0,0 +1,102 @@ +import React, { useMemo } from 'react' +import styled from 'styled-components' +import { Button, Col as ColBase, Row as RowBase, Form as FormBase } from 'reactstrap' +import { FaChevronLeft as IconLeft, FaChevronRight as IconRight } from 'react-icons/fa6' +import { useTranslationSafe } from 'src/helpers/useTranslationSafe' + +export const WizardContainer = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +` + +export const WizardMain = styled.div` + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; +` + +export const Footer = styled.div` + display: flex; + flex: 0; +` + +export const FlexRow = styled(RowBase)` + height: 100%; +` + +export const FlexCol = styled(ColBase)` + display: flex; + height: 100%; +` + +export interface WizardNavigationBarProps { + prevDisabled?: boolean + nextDisabled?: boolean + onPrev?(): void + onNext?(): void +} + +export function WizardNavigationBar({ onPrev, onNext, prevDisabled, nextDisabled }: WizardNavigationBarProps) { + const { t } = useTranslationSafe() + + const prev = useMemo(() => { + if (!onPrev) { + return null + } + return ( + <WizardNavigationButton + color={prevDisabled ? 'secondary' : 'danger'} + className="mr-auto" + onClick={onPrev} + disabled={prevDisabled} + > + <IconLeft size={15} className="mr-1" /> + {t('Previous')} + </WizardNavigationButton> + ) + }, [onPrev, prevDisabled, t]) + + const next = useMemo(() => { + if (!onNext) { + return null + } + return ( + <WizardNavigationButton + color={nextDisabled ? 'secondary' : 'success'} + className="ml-auto" + onClick={onNext} + disabled={nextDisabled} + > + {t('Next')} + <IconRight size={15} className="ml-1" /> + </WizardNavigationButton> + ) + }, [nextDisabled, onNext, t]) + + return ( + <WizardNavigationForm> + {prev} + {next} + </WizardNavigationForm> + ) +} + +export const WizardNavigationForm = styled(FormBase)` + display: flex; + width: 100%; + height: 100%; + margin-top: auto; + padding: 10px; + border: 1px #ccc9 solid; + border-radius: 5px; +` + +export const WizardNavigationButton = styled(Button)` + min-width: 140px; + min-height: 40px; + text-align: center; + vertical-align: middle; +` diff --git a/packages_rs/nextclade-web/src/components/Main/WizardManualStep.tsx b/packages_rs/nextclade-web/src/components/Main/WizardManualStep.tsx new file mode 100644 index 000000000..1af5ef2ee --- /dev/null +++ b/packages_rs/nextclade-web/src/components/Main/WizardManualStep.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { Row as RowBase, Col as ColBase } from 'reactstrap' +import styled from 'styled-components' +import { QuerySequenceFilePicker } from 'src/components/Main/QuerySequenceFilePicker' +import { DatasetCurrent } from 'src/components/Main/DatasetCurrent' + +export function WizardManualStep() { + return ( + <Container> + <Row noGutters className="flex-column-reverse flex-lg-row h-100"> + <Col lg={6}> + <DatasetCurrent /> + </Col> + <Col lg={6}> + <QuerySequenceFilePicker /> + </Col> + </Row> + </Container> + ) +} + +const Container = styled.div` + width: 100% !important; + + @media (min-width: 991.98px) { + overflow: hidden; + height: 100%; + } +` + +const Row = styled(RowBase)` + width: 100% !important; + + @media (min-width: 991.98px) { + overflow: hidden; + height: 100%; + } +` + +const Col = styled(ColBase)` + width: 100% !important; + + @media (min-width: 991.98px) { + overflow: hidden; + height: 100%; + } +` diff --git a/packages_rs/nextclade-web/src/mdx-components.tsx b/packages_rs/nextclade-web/src/mdx-components.tsx new file mode 100644 index 000000000..a08990d95 --- /dev/null +++ b/packages_rs/nextclade-web/src/mdx-components.tsx @@ -0,0 +1,136 @@ +import React from 'react' +import styled from 'styled-components' +import { TableSlim } from 'src/components/Common/TableSlim' +import { LinkExternal } from 'src/components/Link/LinkExternal' + +export const H1 = styled.h1` + font-weight: bold; + font-size: 1.55rem; +` + +export const H2 = styled.h2` + font-size: 1.33rem; + font-weight: bold; + + margin-top: 1.25rem; + margin-bottom: 0.75rem; + + :first-child { + margin-top: 0; + } + + border-radius: 5px; +` + +export const H3 = styled.h3` + font-size: 1.25rem; + font-weight: bold; + + margin: 0 !important; + margin-top: 1rem !important; + + code { + font-size: 1.2rem; + background-color: #eaeaea; + border-radius: 5px; + overflow-wrap: break-word; + white-space: pre-wrap; + + @media (max-width: 992px) { + font-size: 1.1rem; + } + } +` + +export const H4 = styled.h4` + font-size: 1.2rem; + font-weight: bold; + + @media (max-width: 992px) { + font-size: 1.2rem; + margin-top: 1.2rem; + } +` + +export const H5 = styled.h5` + font-size: 1.1rem; + font-weight: bold; + margin-top: 1.1rem; + + @media (max-width: 992px) { + font-size: 1rem; + margin-top: 1.1rem; + } +` + +export const H6 = styled.h6` + font-size: 1rem; + font-weight: bold; +` + +export const Blockquote = styled.blockquote` + border-radius: 3px; + background-color: #f4ebbd; +` + +export const P = styled.p` + margin: 3px 7px; + + code { + font-size: 0.8rem; + padding: 1px 5px; + background-color: #eaeaea; + border-radius: 5px; + overflow-wrap: break-word; + white-space: pre-wrap; + } +` + +export const Pre = styled.pre` + margin: 0.3rem 0.8rem; + padding: 0.7rem 0.5rem; + + background-color: #eaeaea; + border-radius: 5px; + overflow: hidden; + overflow-wrap: break-word; + white-space: pre-wrap; + line-height: 1.33rem; + font-size: 0.8rem; +` + +export const Code = styled.code` + font-size: 0.8rem; + padding: 1px 0; + background-color: #eaeaea; + border-radius: 5px; + overflow-wrap: break-word; + white-space: pre-wrap; +` + +export const Li = styled.li` + & > p { + margin: 0; + padding: 0; + } +` + +function Table({ ...restProps }) { + return <TableSlim striped {...restProps} /> +} + +export const mdxComponents = { + h1: H1, + h2: H2, + h3: H3, + h4: H4, + h5: H5, + h6: H6, + p: P, + a: LinkExternal, + blockquote: Blockquote, + li: Li, + pre: Pre, + code: Code, + table: Table, +} diff --git a/packages_rs/nextclade-web/src/pages/_app.tsx b/packages_rs/nextclade-web/src/pages/_app.tsx index 68eb1b18f..4c3daf765 100644 --- a/packages_rs/nextclade-web/src/pages/_app.tsx +++ b/packages_rs/nextclade-web/src/pages/_app.tsx @@ -13,6 +13,7 @@ import { useRunAnalysis } from 'src/hooks/useRunAnalysis' import i18nAuspice, { changeAuspiceLocale } from 'src/i18n/i18n.auspice' import { createInputFastasFromUrlParam, createInputFromUrlParamMaybe } from 'src/io/createInputFromUrlParamMaybe' import LoadingPage from 'src/pages/loading' +import { mdxComponents } from 'src/mdx-components' import { globalErrorAtom } from 'src/state/error.state' import { geneMapInputAtom, @@ -42,7 +43,6 @@ import { parseUrl } from 'src/helpers/parseUrl' import { getDatasetServerUrl, initializeDatasets } from 'src/io/fetchDatasets' import { fetchSingleDataset } from 'src/io/fetchSingleDataset' import { ErrorPopup } from 'src/components/Error/ErrorPopup' -import { LinkExternal } from 'src/components/Link/LinkExternal' import { SEO } from 'src/components/Common/SEO' import { Plausible } from 'src/components/Common/Plausible' import i18n, { changeLocale, getLocaleWithKey } from 'src/i18n/i18n' @@ -172,8 +172,6 @@ export function RecoilStateInitializer() { return null } -const mdxComponents = { a: LinkExternal } - const REACT_QUERY_OPTIONS: QueryClientConfig = { defaultOptions: { queries: { suspense: true, retry: 1 } }, } @@ -184,7 +182,7 @@ export function MyApp({ Component, pageProps, router }: AppProps) { const fallback = useMemo(() => <LoadingPage />, []) useEffect(() => { - if (process.env.NODE_ENV !== 'development' && !['/', '/loading'].includes(router.pathname)) { + if (process.env.NODE_ENV !== 'development' && !['/', '/loading', '/dataset-suggest'].includes(router.pathname)) { void router.replace('/') // eslint-disable-line no-void } diff --git a/packages_rs/nextclade-web/src/pages/_error.tsx b/packages_rs/nextclade-web/src/pages/_error.tsx index 4c50d1e22..5e32c2140 100644 --- a/packages_rs/nextclade-web/src/pages/_error.tsx +++ b/packages_rs/nextclade-web/src/pages/_error.tsx @@ -7,6 +7,7 @@ import { ErrorContent } from 'src/components/Error/ErrorContent' import { RestartButton } from 'src/components/Error/ErrorStyles' import { Layout } from 'src/components/Layout/Layout' import { useTranslationSafe } from 'src/helpers/useTranslationSafe' +import { MainSectionTitle } from 'src/components/Main/MainSectionTitle' import styled from 'styled-components' export const Container = styled(ContainerBase)` @@ -71,6 +72,8 @@ function ErrorPage({ statusCode, title, error }: ErrorPageProps) { return ( <Layout> <MainContent> + <MainSectionTitle /> + <Row noGutters> <Col className="text-center text-danger"> <h2>{titleText}</h2> diff --git a/packages_rs/nextclade-web/src/pages/dataset-suggest.tsx b/packages_rs/nextclade-web/src/pages/dataset-suggest.tsx new file mode 100644 index 000000000..a9f1c0cc9 --- /dev/null +++ b/packages_rs/nextclade-web/src/pages/dataset-suggest.tsx @@ -0,0 +1 @@ +export { DatasetAutodetectPage as default } from 'src/components/Main/DatasetAutodetectPage' diff --git a/packages_rs/nextclade-web/src/pages/dataset.tsx b/packages_rs/nextclade-web/src/pages/dataset.tsx new file mode 100644 index 000000000..539c02fbf --- /dev/null +++ b/packages_rs/nextclade-web/src/pages/dataset.tsx @@ -0,0 +1 @@ +export { DatasetPage as default } from 'src/components/Main/DatasetPage' diff --git a/packages_rs/nextclade-web/src/state/autodetect.state.ts b/packages_rs/nextclade-web/src/state/autodetect.state.ts index 5697f4875..10a2e0634 100644 --- a/packages_rs/nextclade-web/src/state/autodetect.state.ts +++ b/packages_rs/nextclade-web/src/state/autodetect.state.ts @@ -1,9 +1,8 @@ /* eslint-disable no-loops/no-loops */ -import unique from 'fork-ts-checker-webpack-plugin/lib/utils/array/unique' -import { isEmpty, isNil } from 'lodash' +import { isEmpty, isNil, uniq } from 'lodash' import { atom, atomFamily, DefaultValue, selector, selectorFamily } from 'recoil' -import type { MinimizerIndexJson, MinimizerSearchRecord } from 'src/types' import { isDefaultValue } from 'src/state/utils/isDefaultValue' +import type { MinimizerIndexJson, MinimizerSearchRecord } from 'src/types' export const minimizerIndexAtom = atom<MinimizerIndexJson>({ key: 'minimizerIndexAtom', @@ -51,7 +50,7 @@ export const autodetectResultByIndexAtom = selectorFamily<MinimizerSearchRecord, export const DATASET_ID_UNDETECTED = 'undetected' export function groupByDatasets(records: MinimizerSearchRecord[]): Record<string, MinimizerSearchRecord[]> { - const names = unique(records.flatMap((record) => record.result.datasets.map((dataset) => dataset.name))) + const names = uniq(records.flatMap((record) => record.result.datasets.map((dataset) => dataset.name))) let byDataset = {} for (const name of names) { const selectedRecords = records.filter((record) => record.result.datasets.some((dataset) => dataset.name === name)) @@ -131,3 +130,8 @@ export const autodetectRunStateAtom = atom<AutodetectRunState>({ key: 'autodetectRunStateAtom', default: AutodetectRunState.Idle, }) + +export const isInSuggestModeAtom = atom({ + key: 'isInSuggestModeAtom', + default: false, +}) diff --git a/packages_rs/nextclade-web/src/state/navigation.state.ts b/packages_rs/nextclade-web/src/state/navigation.state.ts new file mode 100644 index 000000000..cf2c83a04 --- /dev/null +++ b/packages_rs/nextclade-web/src/state/navigation.state.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil' + +export const isDatasetPageVisitedAtom = atom({ + key: 'isDatasetPageVisitedAtom', + default: false, +}) diff --git a/packages_rs/nextclade-web/yarn.lock b/packages_rs/nextclade-web/yarn.lock index df7774898..0f5801e0b 100644 --- a/packages_rs/nextclade-web/yarn.lock +++ b/packages_rs/nextclade-web/yarn.lock @@ -13525,6 +13525,11 @@ react-transition-group@^4.3.0, react-transition-group@^4.4.2: loose-envify "^1.4.0" prop-types "^15.6.2" +react-use-wizard@2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/react-use-wizard/-/react-use-wizard-2.2.3.tgz#d2e57b299b6c35fcca90f3a6deb2d0a81318b466" + integrity sha512-Oh3EfpmWwF7vW1YZ2EgSLoU9VKJ13+TyLNmT56rBhLoXr3FXZoDEmtyxZ46GVkcicl/8qiHX0C42M6n2oKDkEQ== + react-virtualized-auto-sizer@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.6.tgz#66c5b1c9278064c5ef1699ed40a29c11518f97ca" diff --git a/vercel.json b/vercel.json index dbc825718..ee2b31265 100644 --- a/vercel.json +++ b/vercel.json @@ -1,5 +1,5 @@ { - "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }], + "cleanUrls": true, "headers": [ { "source": "/_next/static/(chunks|css|media|wasm)/(.*)",