diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20e9d47f..23a0631b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx
index 7ad0a941..0cde3120 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx
@@ -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 ;
}
diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateDoc.tsx
index 46c475d4..ed03cf08 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateDoc.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateDoc.tsx
@@ -20,9 +20,10 @@ export const createDoc = async (): Promise => {
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({
mutationFn: createDoc,
@@ -32,5 +33,8 @@ export function useCreateDoc({ onSuccess }: CreateDocProps) {
});
onSuccess(data);
},
+ onError: (error) => {
+ onError?.(error);
+ },
});
}
diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/DocPage403.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/DocPage403.tsx
index b6eeb512..b7614290 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-management/components/DocPage403.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/DocPage403.tsx
@@ -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,
diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx
index 5ca23159..4a623ce8 100644
--- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx
+++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx
@@ -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 (
diff --git a/src/frontend/apps/impress/src/features/skeletons/components/DocEditorSkeleton.tsx b/src/frontend/apps/impress/src/features/skeletons/components/DocEditorSkeleton.tsx
new file mode 100644
index 00000000..92584011
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/skeletons/components/DocEditorSkeleton.tsx
@@ -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;
+
+type SkeletonCircleProps = Partial;
+
+export const DocEditorSkeleton = () => {
+ const { isDesktop } = useResponsiveStore();
+ const { spacingsTokens, colorsTokens } = useCunninghamTheme();
+
+ const SkeletonLine = ({ $css, ...props }: SkeletonLineProps) => {
+ return (
+
+ );
+ };
+
+ const SkeletonCircle = ({ $css, ...props }: SkeletonCircleProps) => {
+ return (
+
+ );
+ };
+
+ return (
+ <>
+ {/* Main Editor Container */}
+
+ {/* Header Skeleton */}
+
+
+
+
+ {/* Title and metadata skeleton */}
+
+ {/* Title - "Document sans titre" style */}
+
+
+ {/* Metadata (role and last update) */}
+
+
+
+
+
+ {/* Toolbox skeleton (buttons) */}
+
+ {/* Partager button */}
+
+ {/* Download icon */}
+
+ {/* Menu icon */}
+
+
+
+
+
+ {/* Separator */}
+
+
+
+
+ {/* Content Skeleton */}
+
+
+ {/* Placeholder text similar to screenshot */}
+
+ {/* Single placeholder line like in the screenshot */}
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/skeletons/components/Skeleton.tsx b/src/frontend/apps/impress/src/features/skeletons/components/Skeleton.tsx
new file mode 100644
index 00000000..be42bb93
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/skeletons/components/Skeleton.tsx
@@ -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(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 (
+
+ {children}
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/features/skeletons/components/index.ts b/src/frontend/apps/impress/src/features/skeletons/components/index.ts
new file mode 100644
index 00000000..3094571d
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/skeletons/components/index.ts
@@ -0,0 +1,2 @@
+export * from './DocEditorSkeleton';
+export * from './Skeleton';
diff --git a/src/frontend/apps/impress/src/features/skeletons/index.ts b/src/frontend/apps/impress/src/features/skeletons/index.ts
new file mode 100644
index 00000000..06989a50
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/skeletons/index.ts
@@ -0,0 +1,2 @@
+export * from './components';
+export * from './store';
diff --git a/src/frontend/apps/impress/src/features/skeletons/store/index.ts b/src/frontend/apps/impress/src/features/skeletons/store/index.ts
new file mode 100644
index 00000000..7f45e9f8
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/skeletons/store/index.ts
@@ -0,0 +1 @@
+export * from './useSkeletonStore';
diff --git a/src/frontend/apps/impress/src/features/skeletons/store/useSkeletonStore.tsx b/src/frontend/apps/impress/src/features/skeletons/store/useSkeletonStore.tsx
new file mode 100644
index 00000000..0c56df73
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/skeletons/store/useSkeletonStore.tsx
@@ -0,0 +1,12 @@
+import { create } from 'zustand';
+
+interface SkeletonStore {
+ isSkeletonVisible: boolean;
+ setIsSkeletonVisible: (isSkeletonVisible: boolean) => void;
+}
+
+export const useSkeletonStore = create((set) => ({
+ isSkeletonVisible: false,
+ setIsSkeletonVisible: (isSkeletonVisible: boolean) =>
+ set({ isSkeletonVisible }),
+}));
diff --git a/src/frontend/apps/impress/src/layouts/MainLayout.tsx b/src/frontend/apps/impress/src/layouts/MainLayout.tsx
index f7e6cec0..3862dd56 100644
--- a/src/frontend/apps/impress/src/layouts/MainLayout.tsx
+++ b/src/frontend/apps/impress/src/layouts/MainLayout.tsx
@@ -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;
`}
>
+
+
+
{children}
);
diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx
index 44ef667d..7b465913 100644
--- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx
+++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx
@@ -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 () => {