Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 />;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,5 +33,8 @@ export function useCreateDoc({ onSuccess }: CreateDocProps) {
});
onSuccess(data);
},
onError: (error) => {
onError?.(error);
},
});
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,59 @@
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';

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>
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
</>
);
};
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './DocEditorSkeleton';
export * from './Skeleton';
2 changes: 2 additions & 0 deletions src/frontend/apps/impress/src/features/skeletons/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './components';
export * from './store';
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useSkeletonStore';
Original file line number Diff line number Diff line change
@@ -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 }),
}));
5 changes: 5 additions & 0 deletions src/frontend/apps/impress/src/layouts/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -66,6 +67,7 @@ export function MainLayoutContent({
$flex={1}
$width="100%"
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
$position="relative"
$padding={{
all: isDesktop ? 'base' : '0',
}}
Expand All @@ -79,6 +81,9 @@ export function MainLayoutContent({
overflow-x: clip;
`}
>
<Skeleton>
<DocEditorSkeleton />
</Skeleton>
{children}
</Box>
);
Expand Down
Loading
Loading