(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:
Cyril
2025-10-14 15:28:21 +02:00
parent dd56a8abeb
commit 8b73aa3644
13 changed files with 326 additions and 8 deletions

View File

@@ -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

View File

@@ -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 />;
}

View File

@@ -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);
},
});
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,2 @@
export * from './DocEditorSkeleton';
export * from './Skeleton';

View File

@@ -0,0 +1,2 @@
export * from './components';
export * from './store';

View File

@@ -0,0 +1 @@
export * from './useSkeletonStore';

View File

@@ -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 }),
}));

View File

@@ -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>
);

View File

@@ -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 () => {