From 8b73aa3644f8b2112e9a944619c9c587d986aa24 Mon Sep 17 00:00:00 2001 From: Cyril Date: Tue, 14 Oct 2025 15:28:21 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20create=20skeleton=20featu?= =?UTF-8?q?re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit creating a skeleton to be display during doc creation Signed-off-by: Cyril --- CHANGELOG.md | 4 + .../docs/doc-editor/components/DocEditor.tsx | 12 +- .../docs/doc-management/api/useCreateDoc.tsx | 6 +- .../doc-management/components/DocPage403.tsx | 9 ++ .../components/LeftPanelHeaderButton.tsx | 38 ++++- .../components/DocEditorSkeleton.tsx | 153 ++++++++++++++++++ .../skeletons/components/Skeleton.tsx | 71 ++++++++ .../features/skeletons/components/index.ts | 2 + .../impress/src/features/skeletons/index.ts | 2 + .../src/features/skeletons/store/index.ts | 1 + .../skeletons/store/useSkeletonStore.tsx | 12 ++ .../apps/impress/src/layouts/MainLayout.tsx | 5 + .../impress/src/pages/docs/[id]/index.tsx | 19 ++- 13 files changed, 326 insertions(+), 8 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/skeletons/components/DocEditorSkeleton.tsx create mode 100644 src/frontend/apps/impress/src/features/skeletons/components/Skeleton.tsx create mode 100644 src/frontend/apps/impress/src/features/skeletons/components/index.ts create mode 100644 src/frontend/apps/impress/src/features/skeletons/index.ts create mode 100644 src/frontend/apps/impress/src/features/skeletons/store/index.ts create mode 100644 src/frontend/apps/impress/src/features/skeletons/store/useSkeletonStore.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 20e9d47fb0..23a0631b46 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 7ad0a9415a..0cde312081 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 46c475d4c1..ed03cf087c 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 b6eeb5121b..b76142902d 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 5ca2315913..4a623ce88d 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 0000000000..92584011d1 --- /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 0000000000..be42bb9310 --- /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 0000000000..3094571d28 --- /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 0000000000..06989a5056 --- /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 0000000000..7f45e9f8e2 --- /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 0000000000..0c56df738f --- /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 f7e6cec0d3..3862dd5690 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 44ef667dbc..7b46591301 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 () => {