(frontend) update doc header ui

Modification of the header style to be consistent with the new UI :
- We replace the option menu with the DropdownMenu component
- We add a dowload button
- We put an input in place of an editable div.
This commit is contained in:
Nathan Panchout
2024-11-25 12:08:49 +01:00
committed by Anthony LC
parent 1d85eee78f
commit d5670640f5
8 changed files with 243 additions and 247 deletions

View File

@@ -89,6 +89,7 @@ and this project adheres to
- ✨(frontend) add sentry #424 - ✨(frontend) add sentry #424
- ✨(frontend) add crisp chatbot #450 - ✨(frontend) add crisp chatbot #450
- 💄(frontend) update DocsGridOptions component #432 - 💄(frontend) update DocsGridOptions component #432
- 💄(frontend) update DocHeader ui #446
## Changed ## Changed

View File

@@ -1,3 +1,5 @@
import { css } from 'styled-components';
import { Text, TextType } from '@/components'; import { Text, TextType } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
@@ -40,23 +42,21 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
); );
}; };
interface IconOptionsProps { type IconOptionsProps = TextType & {
isOpen: boolean; isHorizontal?: boolean;
'aria-label': string; };
}
export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => { export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
return ( return (
<Text <Text
aria-label={props['aria-label']} {...props}
$isMaterialIcon $isMaterialIcon
$css={` $css={css`
transition: all 0.3s ease-in-out;
transform: rotate(${isOpen ? '90' : '0'}deg);
user-select: none; user-select: none;
${props.$css}
`} `}
> >
more_vert {isHorizontal ? 'more_horiz' : 'more_vert'}
</Text> </Text>
); );
}; };

View File

@@ -0,0 +1,31 @@
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
export enum SeparatorVariant {
LIGHT = 'light',
DARK = 'dark',
}
type Props = {
variant?: SeparatorVariant;
};
export const HorizontalSeparator = ({
variant = SeparatorVariant.LIGHT,
}: Props) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$height="1px"
$width="100%"
$margin={{ vertical: 'base' }}
$background={
variant === SeparatorVariant.DARK
? '#e5e5e533'
: colorsTokens()['greyscale-100']
}
/>
);
};

View File

@@ -352,8 +352,8 @@ input:-webkit-autofill:focus {
} }
.c__button--nano { .c__button--nano {
padding: 0 var(--c--theme--spacings--2xs) !important; padding: 0 var(--c--theme--spacings--2xs);
gap: var(--c--theme--spacings--2xs) !important; gap: var(--c--theme--spacings--2xs);
} }
.c__button--medium { .c__button--medium {

View File

@@ -1,14 +1,18 @@
import { Fragment } from 'react'; import { DateTime } from 'luxon';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { css } from 'styled-components'; import { css } from 'styled-components';
import { Box, Card, StyledLink, Text } from '@/components'; import { Box, Icon, Text } from '@/components';
import { HorizontalSeparator } from '@/components/separators/HorizontalSeparator';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { Doc, currentDocRole, useTrans } from '@/features/docs/doc-management'; import {
import { useDate } from '@/hook'; Doc,
LinkReach,
currentDocRole,
useTrans,
} from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
import { DocTagPublic } from './DocTagPublic';
import { DocTitle } from './DocTitle'; import { DocTitle } from './DocTitle';
import { DocToolBox } from './DocToolBox'; import { DocToolBox } from './DocToolBox';
@@ -17,90 +21,78 @@ interface DocHeaderProps {
} }
export const DocHeader = ({ doc }: DocHeaderProps) => { export const DocHeader = ({ doc }: DocHeaderProps) => {
const { colorsTokens } = useCunninghamTheme(); const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const spacings = spacingsTokens();
const colors = colorsTokens();
const { t } = useTranslation(); const { t } = useTranslation();
const { formatDate } = useDate(); const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const { transRole } = useTrans(); const { transRole } = useTrans();
const { isMobile, isSmallMobile } = useResponsiveStore();
return ( return (
<> <>
<Card <Box
$width="100%" $width="100%"
$margin={isMobile ? 'tiny' : 'small'} $padding={{ vertical: 'base' }}
$gap={spacings['base']}
aria-label={t('It is the card information about the document.')} aria-label={t('It is the card information about the document.')}
> >
<Box {docIsPublic && (
$padding={isMobile ? 'tiny' : 'small'}
$direction="row"
$align="center"
>
<StyledLink href="/">
<Text
$isMaterialIcon
$theme="primary"
$variation="600"
$size="2rem"
$css={css`
&:hover {
background-color: ${colorsTokens()['primary-100']};
}
`}
$hasTransition
$radius="5px"
$padding="tiny"
>
home
</Text>
</StyledLink>
<Box <Box
$width="1px" aria-label={t('Public document')}
$height="70%" $color={colors['primary-600']}
$background={colorsTokens()['greyscale-100']} $background={colors['primary-100']}
$margin={{ horizontal: 'tiny' }} $radius={spacings['3xs']}
/> $direction="row"
$padding="xs"
$flex={1}
$align="center"
$gap={spacings['3xs']}
$css={css`
border: 1px solid var(--c--theme--colors--primary-300, #e3e3fd);
`}
>
<Icon data-testid="public-icon" iconName="public" />
<Text>{t('Public document')}</Text>
</Box>
)}
<Box $direction="row" $align="center" $width="100%">
<Box <Box
$direction="row" $direction="row"
$justify="space-between" $justify="space-between"
$css="flex:1;" $css="flex:1;"
$gap="0.5rem 1rem" $gap="0.5rem 1rem"
$wrap="wrap"
$align="center" $align="center"
> >
<DocTitle doc={doc} /> <Box $gap={spacings['3xs']}>
<DocTitle doc={doc} />
<Box $direction="row">
{isDesktop && (
<>
<Text $variation="400" $size="s" $weight="bold">
{transRole(currentDocRole(doc.abilities))}&nbsp;·&nbsp;
</Text>
<Text $variation="400" $size="s">
{t('Last update: {{update}}', {
update: DateTime.fromISO(doc.updated_at).toRelative(),
})}
</Text>
</>
)}
{!isDesktop && (
<Text $variation="400" $size="s">
{DateTime.fromISO(doc.updated_at).toRelative()}
</Text>
)}
</Box>
</Box>
<DocToolBox doc={doc} /> <DocToolBox doc={doc} />
</Box> </Box>
</Box> </Box>
<Box <HorizontalSeparator />
$direction={isSmallMobile ? 'column' : 'row'} </Box>
$align={isSmallMobile ? 'start' : 'center'}
$css="border-top:1px solid #eee"
$padding={{
horizontal: isMobile ? 'tiny' : 'big',
vertical: 'tiny',
}}
$gap="0.5rem 2rem"
$justify="space-between"
$wrap="wrap"
$position="relative"
>
<Box
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'start' : 'center'}
$gap="0.5rem 2rem"
$wrap="wrap"
>
<DocTagPublic doc={doc} />
<Text $size="s" $display="inline">
{t('Created at')} <strong>{formatDate(doc.created_at)}</strong>
</Text>
</Box>
<Text $size="s" $display="inline">
{t('Your role:')}{' '}
<strong>{transRole(currentDocRole(doc.abilities))}</strong>
</Text>
</Box>
</Card>
</> </>
); );
}; };

View File

@@ -5,12 +5,12 @@ import {
VariantType, VariantType,
useToastProvider, useToastProvider,
} from '@openfun/cunningham-react'; } from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components'; import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { useHeadingStore } from '@/features/docs/doc-editor';
import { import {
Doc, Doc,
KEY_DOC, KEY_DOC,
@@ -19,7 +19,6 @@ import {
useUpdateDoc, useUpdateDoc,
} from '@/features/docs/doc-management'; } from '@/features/docs/doc-management';
import { useBroadcastStore, useResponsiveStore } from '@/stores'; import { useBroadcastStore, useResponsiveStore } from '@/stores';
import { isFirefox } from '@/utils/userAgent';
interface DocTitleProps { interface DocTitleProps {
doc: Doc; doc: Doc;
@@ -32,7 +31,7 @@ export const DocTitle = ({ doc }: DocTitleProps) => {
return ( return (
<Text <Text
as="h2" as="h2"
$margin={{ all: 'none', left: 'tiny' }} $margin={{ all: 'none', left: 'none' }}
$size={isMobile ? 'h4' : 'h2'} $size={isMobile ? 'h4' : 'h2'}
> >
{doc.title} {doc.title}
@@ -44,20 +43,18 @@ export const DocTitle = ({ doc }: DocTitleProps) => {
}; };
const DocTitleInput = ({ doc }: DocTitleProps) => { const DocTitleInput = ({ doc }: DocTitleProps) => {
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation(); const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme(); const { colorsTokens } = useCunninghamTheme();
const [titleDisplay, setTitleDisplay] = useState(doc.title); const [titleDisplay, setTitleDisplay] = useState(doc.title);
const { toast } = useToastProvider(); const { toast } = useToastProvider();
const { untitledDocument } = useTrans(); const { untitledDocument } = useTrans();
const isUntitled = titleDisplay === untitledDocument; const isUntitled = titleDisplay === untitledDocument;
const { headings } = useHeadingStore();
const headingText = headings?.[0]?.contentText;
const debounceRef = useRef<NodeJS.Timeout>();
const { isMobile } = useResponsiveStore();
const { broadcast } = useBroadcastStore(); const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({ const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC], listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
onSuccess(data) { onSuccess(data) {
if (data.title !== untitledDocument) { if (data.title !== untitledDocument) {
toast(t('Document title updated successfully'), VariantType.SUCCESS); toast(t('Document title updated successfully'), VariantType.SUCCESS);
@@ -81,10 +78,7 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
// If mutation we update // If mutation we update
if (sanitizedTitle !== doc.title) { if (sanitizedTitle !== doc.title) {
if (debounceRef.current) { setTitleDisplay(sanitizedTitle);
clearTimeout(debounceRef.current);
debounceRef.current = undefined;
}
updateDoc({ id: doc.id, title: sanitizedTitle }); updateDoc({ id: doc.id, title: sanitizedTitle });
} }
}, },
@@ -98,74 +92,38 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
} }
}; };
const handleOnClick = () => {
if (isUntitled) {
setTitleDisplay('');
}
};
useEffect(() => {
setTitleDisplay(doc.title);
}, [doc.title]);
useEffect(() => {
if ((!debounceRef.current && !isUntitled) || !headingText) {
return;
}
setTitleDisplay(headingText);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
handleTitleSubmit(headingText);
debounceRef.current = undefined;
}, 3000);
}, [isUntitled, handleTitleSubmit, headingText]);
return ( return (
<> <>
<Tooltip content={t('Rename')} placement="top"> <Tooltip content={t('Rename')} placement="top">
<Box <Box
as="h2" as="span"
$radius="4px" role="textbox"
$padding={{ horizontal: 'tiny', vertical: '4px' }} contentEditable
$margin="none" defaultValue={isUntitled ? undefined : titleDisplay}
$minWidth="200px"
contentEditable={isFirefox() ? 'true' : 'plaintext-only'}
onClick={handleOnClick}
onBlurCapture={(e) =>
handleTitleSubmit(e.currentTarget.textContent || '')
}
onKeyDownCapture={handleKeyDown} onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
$color={ aria-label={t('doc title input')}
isUntitled onBlurCapture={(event) =>
? colorsTokens()['greyscale-200'] handleTitleSubmit(event.target.textContent || '')
: colorsTokens()['greyscale-text']
} }
$css={` $color={colorsTokens()['greyscale-text']}
${isUntitled && 'font-style: italic;'} $margin={{ left: '-2px', right: '10px' }}
cursor: text; $css={css`
font-size: ${isMobile ? '1.2rem' : '1.5rem'}; &[contenteditable='true']:empty:not(:focus):before {
transition: box-shadow 0.5s, border-color 0.5s; content: '${untitledDocument}';
border: 1px dashed transparent; color: grey;
pointer-events: none;
&:hover { font-style: italic;
border-color: rgba(0, 123, 255, 0.25);
border-style: dashed;
} }
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
&:focus { outline: none;
outline: none;
border-color: transparent;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
`} `}
> >
{titleDisplay} {isUntitled ? '' : titleDisplay}
</Box> </Box>
</Tooltip> </Tooltip>
</> </>

View File

@@ -4,11 +4,19 @@ import {
useToastProvider, useToastProvider,
} from '@openfun/cunningham-react'; } from '@openfun/cunningham-react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, DropButton, IconOptions } from '@/components'; import {
Box,
DropdownMenu,
DropdownMenuOption,
Icon,
IconOptions,
} from '@/components';
import { useAuthStore } from '@/core'; import { useAuthStore } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { import {
useEditorStore, useEditorStore,
usePanelEditorStore, usePanelEditorStore,
@@ -32,10 +40,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
query: { versionId }, query: { versionId },
} = useRouter(); } = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
const colors = colorsTokens();
const [isModalShareOpen, setIsModalShareOpen] = useState(false); const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false); const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false); const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
const [isDropOpen, setIsDropOpen] = useState(false);
const { setIsPanelOpen, setIsPanelTableContentOpen } = usePanelEditorStore(); const { setIsPanelOpen, setIsPanelTableContentOpen } = usePanelEditorStore();
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false); const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
const { isSmallMobile } = useResponsiveStore(); const { isSmallMobile } = useResponsiveStore();
@@ -43,6 +56,66 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { editor } = useEditorStore(); const { editor } = useEditorStore();
const { toast } = useToastProvider(); const { toast } = useToastProvider();
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: t('Share'),
icon: 'upload',
callback: () => {
setIsModalShareOpen(true);
},
},
{
label: t('Export'),
icon: 'download',
callback: () => {
setIsModalPDFOpen(true);
},
},
]
: []),
{
label: t('Version history'),
icon: 'history',
disabled: !doc.abilities.versions_list,
callback: () => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(false);
},
},
{
label: t('Table of contents'),
icon: 'summarize',
callback: () => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(true);
},
},
{
label: t('Copy as {{format}}', { format: 'Markdown' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('markdown');
},
},
{
label: t('Copy as {{format}}', { format: 'HTML' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('html');
},
},
{
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
},
];
const copyCurrentEditorToClipboard = async ( const copyCurrentEditorToClipboard = async (
asFormat: 'html' | 'markdown', asFormat: 'html' | 'markdown',
) => { ) => {
@@ -87,9 +160,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
</Button> </Button>
</Box> </Box>
)} )}
<Box $direction="row" $margin={{ left: 'auto' }} $gap="1rem"> <Box $direction="row" $margin={{ left: 'auto' }} $gap={spacings['2xs']}>
{authenticated && ( {authenticated && !isSmallMobile && (
<Button <Button
color="primary-text"
onClick={() => { onClick={() => {
setIsModalShareOpen(true); setIsModalShareOpen(true);
}} }}
@@ -98,91 +172,34 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
{t('Share')} {t('Share')}
</Button> </Button>
)} )}
<DropButton {!isSmallMobile && (
button={ <Button
<IconOptions color="primary-text"
isOpen={isDropOpen} icon={
aria-label={t('Open the document options')} <Icon iconName="download" $theme="primary" $variation="800" />
/> }
} onClick={() => {
onOpenChange={(isOpen) => setIsDropOpen(isOpen)} setIsModalPDFOpen(true);
isOpen={isDropOpen} }}
> size={isSmallMobile ? 'small' : 'medium'}
<Box> />
{doc.abilities.versions_list && ( )}
<Button <DropdownMenu options={options}>
onClick={() => { <IconOptions
setIsPanelOpen(true); isHorizontal
setIsPanelTableContentOpen(false); $theme="primary"
setIsDropOpen(false); $radius={spacings['3xs']}
}} $css={
color="primary-text" isSmallMobile
icon={<span className="material-icons">history</span>} ? css`
size="small" padding: 10px;
> border: 1px solid ${colors['greyscale-300']};
{t('Version history')} `
</Button> : ''
)} }
<Button aria-label={t('Open the document options')}
onClick={() => { />
setIsPanelOpen(true); </DropdownMenu>
setIsPanelTableContentOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">summarize</span>}
size="small"
>
{t('Table of contents')}
</Button>
<Button
onClick={() => {
setIsModalPDFOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">file_download</span>}
size="small"
>
{t('Export')}
</Button>
{doc.abilities.destroy && (
<Button
onClick={() => {
setIsModalRemoveOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">delete</span>}
size="small"
>
{t('Delete document')}
</Button>
)}
<Button
onClick={() => {
setIsDropOpen(false);
void copyCurrentEditorToClipboard('markdown');
}}
color="primary-text"
icon={<span className="material-icons">content_copy</span>}
size="small"
>
{t('Copy as {{format}}', { format: 'Markdown' })}
</Button>
<Button
onClick={() => {
setIsDropOpen(false);
void copyCurrentEditorToClipboard('html');
}}
color="primary-text"
icon={<span className="material-icons">content_copy</span>}
size="small"
>
{t('Copy as {{format}}', { format: 'HTML' })}
</Button>
</Box>
</DropButton>
</Box> </Box>
{isModalShareOpen && ( {isModalShareOpen && (
<ModalShare onClose={() => setIsModalShareOpen(false)} doc={doc} /> <ModalShare onClose={() => setIsModalShareOpen(false)} doc={doc} />

View File

@@ -1,5 +1,5 @@
import { Button } from '@openfun/cunningham-react'; import { Button } from '@openfun/cunningham-react';
import React, { PropsWithChildren, useState } from 'react'; import { PropsWithChildren, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions, StyledLink, Text } from '@/components'; import { Box, DropButton, IconOptions, StyledLink, Text } from '@/components';
@@ -70,10 +70,7 @@ export const VersionItem = ({
{isActive && versionId && ( {isActive && versionId && (
<DropButton <DropButton
button={ button={
<IconOptions <IconOptions aria-label={t('Open the version options')} />
isOpen={isDropOpen}
aria-label={t('Open the version options')}
/>
} }
onOpenChange={(isOpen) => setIsDropOpen(isOpen)} onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen} isOpen={isDropOpen}