✨(frontend) create skeleton feature
creating a skeleton to be display during doc creation Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
@@ -6,6 +6,10 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) create skeleton component for DocEditor #1491
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) adapt custom blocks to new implementation #1375
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/docs/doc-management';
|
||||
import { TableContent } from '@/docs/doc-table-content/';
|
||||
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { BlockNoteEditor, BlockNoteEditorVersion } from './BlockNoteEditor';
|
||||
@@ -26,9 +27,16 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const isVersion = !!versionId && typeof versionId === 'string';
|
||||
const { provider, isReady } = useProviderStore();
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
const isProviderReady = isReady && provider;
|
||||
|
||||
// TODO: Use skeleton instead of loading
|
||||
if (!provider || !isReady) {
|
||||
useEffect(() => {
|
||||
if (isProviderReady) {
|
||||
setIsSkeletonVisible(false);
|
||||
}
|
||||
}, [isProviderReady, setIsSkeletonVisible]);
|
||||
|
||||
if (!isProviderReady) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@ export const createDoc = async (): Promise<Doc> => {
|
||||
|
||||
interface CreateDocProps {
|
||||
onSuccess: (data: Doc) => void;
|
||||
onError?: (error: APIError) => void;
|
||||
}
|
||||
|
||||
export function useCreateDoc({ onSuccess }: CreateDocProps) {
|
||||
export function useCreateDoc({ onSuccess, onError }: CreateDocProps) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Doc, APIError>({
|
||||
mutationFn: createDoc,
|
||||
@@ -32,5 +33,8 @@ export function useCreateDoc({ onSuccess }: CreateDocProps) {
|
||||
});
|
||||
onSuccess(data);
|
||||
},
|
||||
onError: (error) => {
|
||||
onError?.(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
@@ -8,6 +9,7 @@ import img403 from '@/assets/icons/icon-403.png';
|
||||
import { Box, Icon, Loading, StyledLink, Text } from '@/components';
|
||||
import { ButtonAccessRequest } from '@/docs/doc-share';
|
||||
import { useDocAccessRequests } from '@/docs/doc-share/api/useDocAccessRequest';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
width: fit-content;
|
||||
@@ -19,6 +21,13 @@ interface DocProps {
|
||||
|
||||
export const DocPage403 = ({ id }: DocProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure the skeleton overlay is hidden on 403 page
|
||||
setIsSkeletonVisible(false);
|
||||
}, [setIsSkeletonVisible]);
|
||||
|
||||
const {
|
||||
data: requests,
|
||||
isLoading: isLoadingRequest,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Icon } from '@/components';
|
||||
import { useCreateDoc } from '@/features/docs/doc-management';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
|
||||
import { useLeftPanelStore } from '../stores';
|
||||
|
||||
@@ -11,19 +13,47 @@ export const LeftPanelHeaderButton = () => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
const { setIsSkeletonVisible } = useSkeletonStore();
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
const { mutate: createDoc, isPending: isDocCreating } = useCreateDoc({
|
||||
onSuccess: (doc) => {
|
||||
void router.push(`/docs/${doc.id}`);
|
||||
togglePanel();
|
||||
setIsNavigating(true);
|
||||
// Wait for navigation to complete
|
||||
router
|
||||
.push(`/docs/${doc.id}`)
|
||||
.then(() => {
|
||||
// The skeleton will be disabled by the [id] page once the data is loaded
|
||||
setIsNavigating(false);
|
||||
togglePanel();
|
||||
})
|
||||
.catch(() => {
|
||||
// In case of navigation error, disable the skeleton
|
||||
setIsSkeletonVisible(false);
|
||||
setIsNavigating(false);
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
// If there's an error, disable the skeleton
|
||||
setIsSkeletonVisible(false);
|
||||
setIsNavigating(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
setIsSkeletonVisible(true);
|
||||
createDoc();
|
||||
};
|
||||
|
||||
const isLoading = isDocCreating || isNavigating;
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-testid="new-doc-button"
|
||||
color="primary"
|
||||
onClick={() => createDoc()}
|
||||
onClick={handleClick}
|
||||
icon={<Icon $variation="000" iconName="add" aria-hidden="true" />}
|
||||
disabled={isDocCreating}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('New doc')}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { css, keyframes } from 'styled-components';
|
||||
|
||||
import { Box, BoxType } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
type SkeletonLineProps = Partial<BoxType>;
|
||||
|
||||
type SkeletonCircleProps = Partial<BoxType>;
|
||||
|
||||
export const DocEditorSkeleton = () => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const SkeletonLine = ({ $css, ...props }: SkeletonLineProps) => {
|
||||
return (
|
||||
<Box
|
||||
$width="100%"
|
||||
$height="16px"
|
||||
$css={css`
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${colorsTokens['greyscale-100']} 0%,
|
||||
${colorsTokens['greyscale-200']} 50%,
|
||||
${colorsTokens['greyscale-100']} 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
animation: ${shimmer} 2s infinite linear;
|
||||
border-radius: 4px;
|
||||
${$css}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SkeletonCircle = ({ $css, ...props }: SkeletonCircleProps) => {
|
||||
return (
|
||||
<Box
|
||||
$width="32px"
|
||||
$height="32px"
|
||||
$css={css`
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
${colorsTokens['greyscale-100']} 0%,
|
||||
${colorsTokens['greyscale-200']} 50%,
|
||||
${colorsTokens['greyscale-100']} 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
animation: ${shimmer} 2s infinite linear;
|
||||
border-radius: 50%;
|
||||
${$css}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Editor Container */}
|
||||
<Box
|
||||
$maxWidth="868px"
|
||||
$width="100%"
|
||||
$height="100%"
|
||||
className="--docs--doc-editor-skeleton"
|
||||
>
|
||||
{/* Header Skeleton */}
|
||||
<Box
|
||||
$padding={{ horizontal: isDesktop ? '70px' : 'base' }}
|
||||
className="--docs--doc-editor-header-skeleton"
|
||||
>
|
||||
<Box
|
||||
$width="100%"
|
||||
$padding={{ top: isDesktop ? '65px' : 'md' }}
|
||||
$gap={spacingsTokens['base']}
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$width="100%"
|
||||
$padding={{ bottom: 'xs' }}
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$css="flex:1;"
|
||||
$gap="0.5rem 1rem"
|
||||
$align="center"
|
||||
$maxWidth="100%"
|
||||
>
|
||||
{/* Title and metadata skeleton */}
|
||||
<Box $gap="0.25rem" $css="flex:1;">
|
||||
{/* Title - "Document sans titre" style */}
|
||||
<SkeletonLine $width="35%" $height="40px" />
|
||||
|
||||
{/* Metadata (role and last update) */}
|
||||
<Box $direction="row" $gap="0.5rem" $align="center">
|
||||
<SkeletonLine $maxWidth="260px" $height="12px" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Toolbox skeleton (buttons) */}
|
||||
<Box $direction="row" $gap="0.75rem" $align="center">
|
||||
{/* Partager button */}
|
||||
<SkeletonLine $width="90px" $height="40px" />
|
||||
{/* Download icon */}
|
||||
<SkeletonCircle $width="40px" $height="40px" />
|
||||
{/* Menu icon */}
|
||||
<SkeletonCircle $width="40px" $height="40px" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Separator */}
|
||||
<SkeletonLine $height="1px" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content Skeleton */}
|
||||
<Box
|
||||
$direction="row"
|
||||
$width="100%"
|
||||
$css="overflow-x: clip; flex: 1;"
|
||||
$position="relative"
|
||||
className="--docs--doc-editor-content-skeleton"
|
||||
>
|
||||
<Box
|
||||
$css="flex:1;"
|
||||
$position="relative"
|
||||
$width="100%"
|
||||
$padding={{ horizontal: isDesktop ? '70px' : 'base', top: 'lg' }}
|
||||
>
|
||||
{/* Placeholder text similar to screenshot */}
|
||||
<Box $gap="0rem">
|
||||
{/* Single placeholder line like in the screenshot */}
|
||||
<SkeletonLine $width="85%" $height="20px" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { PropsWithChildren, useEffect, useRef, useState } from 'react';
|
||||
import { css, keyframes } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
|
||||
const FADE_DURATION_MS = 250;
|
||||
|
||||
const fadeOut = keyframes`
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Skeleton = ({ children }: PropsWithChildren) => {
|
||||
const { isSkeletonVisible } = useSkeletonStore();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const [isVisible, setIsVisible] = useState(isSkeletonVisible);
|
||||
const [isFadingOut, setIsFadingOut] = useState(true);
|
||||
const timeoutVisibleRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSkeletonVisible) {
|
||||
setIsVisible(true);
|
||||
setIsFadingOut(false);
|
||||
} else {
|
||||
setIsFadingOut(true);
|
||||
if (!timeoutVisibleRef.current) {
|
||||
timeoutVisibleRef.current = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
}, FADE_DURATION_MS * 2);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutVisibleRef.current) {
|
||||
clearTimeout(timeoutVisibleRef.current);
|
||||
timeoutVisibleRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isSkeletonVisible]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="--docs--skeleton"
|
||||
$align="center"
|
||||
$width="100%"
|
||||
$height="100%"
|
||||
$background={colorsTokens['greyscale-000']}
|
||||
$css={css`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
overflow: hidden;
|
||||
will-change: opacity;
|
||||
animation: ${isFadingOut && fadeOut} ${FADE_DURATION_MS}ms ease-in-out
|
||||
forwards;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DocEditorSkeleton';
|
||||
export * from './Skeleton';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './components';
|
||||
export * from './store';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useSkeletonStore';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface SkeletonStore {
|
||||
isSkeletonVisible: boolean;
|
||||
setIsSkeletonVisible: (isSkeletonVisible: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSkeletonStore = create<SkeletonStore>((set) => ({
|
||||
isSkeletonVisible: false,
|
||||
setIsSkeletonVisible: (isSkeletonVisible: boolean) =>
|
||||
set({ isSkeletonVisible }),
|
||||
}));
|
||||
@@ -7,6 +7,7 @@ import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Header } from '@/features/header';
|
||||
import { HEADER_HEIGHT } from '@/features/header/conf';
|
||||
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
|
||||
import { DocEditorSkeleton, Skeleton } from '@/features/skeletons';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { MAIN_LAYOUT_ID } from './conf';
|
||||
@@ -66,6 +67,7 @@ export function MainLayoutContent({
|
||||
$flex={1}
|
||||
$width="100%"
|
||||
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
|
||||
$position="relative"
|
||||
$padding={{
|
||||
all: isDesktop ? 'base' : '0',
|
||||
}}
|
||||
@@ -79,6 +81,9 @@ export function MainLayoutContent({
|
||||
overflow-x: clip;
|
||||
`}
|
||||
>
|
||||
<Skeleton>
|
||||
<DocEditorSkeleton />
|
||||
</Skeleton>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/docs/doc-management/';
|
||||
import { KEY_AUTH, setAuthUrl, useAuth } from '@/features/auth';
|
||||
import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/';
|
||||
import { useSkeletonStore } from '@/features/skeletons';
|
||||
import { MainLayout } from '@/layouts';
|
||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||
import { useBroadcastStore } from '@/stores';
|
||||
@@ -61,6 +62,7 @@ interface DocProps {
|
||||
|
||||
const DocPage = ({ id }: DocProps) => {
|
||||
const { hasLostConnection, resetLostConnection } = useProviderStore();
|
||||
const { isSkeletonVisible, setIsSkeletonVisible } = useSkeletonStore();
|
||||
const {
|
||||
data: docQuery,
|
||||
isError,
|
||||
@@ -92,6 +94,15 @@ const DocPage = ({ id }: DocProps) => {
|
||||
const { authenticated } = useAuth();
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
/**
|
||||
* Show skeleton when loading a document
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!doc && !isError && !isSkeletonVisible) {
|
||||
setIsSkeletonVisible(true);
|
||||
}
|
||||
}, [doc, isError, isSkeletonVisible, setIsSkeletonVisible]);
|
||||
|
||||
/**
|
||||
* Scroll to top when navigating to a new document
|
||||
* We use a timeout to ensure the scroll happens after the layout has updated.
|
||||
@@ -129,7 +140,13 @@ const DocPage = ({ id }: DocProps) => {
|
||||
|
||||
setDoc(docQuery);
|
||||
setCurrentDoc(docQuery);
|
||||
}, [docQuery, setCurrentDoc, isFetching]);
|
||||
}, [
|
||||
docQuery,
|
||||
setCurrentDoc,
|
||||
isFetching,
|
||||
isSkeletonVisible,
|
||||
setIsSkeletonVisible,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
Reference in New Issue
Block a user