From 115d1bacd7602340c6b1b4fdf5a5e2b24706b227 Mon Sep 17 00:00:00 2001 From: Johnny Date: Sun, 28 Dec 2025 17:31:21 +0800 Subject: [PATCH] refactor: replace MemoSkeleton with a new Skeleton component for improved loading states --- web/src/components/MemoEditor/index.tsx | 2 - web/src/components/MemoSkeleton.tsx | 56 ------------- .../PagedMemoList/PagedMemoList.tsx | 14 +--- web/src/components/Skeleton.tsx | 80 +++++++++++++++++++ web/src/components/Spinner.tsx | 19 +++++ web/src/layouts/RootLayout.tsx | 4 +- web/src/main.tsx | 18 ++--- web/src/pages/AuthCallback.tsx | 4 +- web/src/pages/Loading.tsx | 13 --- web/src/router/index.tsx | 34 ++++---- 10 files changed, 132 insertions(+), 112 deletions(-) delete mode 100644 web/src/components/MemoSkeleton.tsx create mode 100644 web/src/components/Skeleton.tsx create mode 100644 web/src/components/Spinner.tsx delete mode 100644 web/src/pages/Loading.tsx diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 145c0843f..a576c56a0 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -105,8 +105,6 @@ const MemoEditorImpl: React.FC = ({ // Notify parent component of successful save onConfirm?.(result.memoName); - - toast.success("Saved successfully"); } catch (error) { handleError(error, toast.error, { context: "Failed to save memo", diff --git a/web/src/components/MemoSkeleton.tsx b/web/src/components/MemoSkeleton.tsx deleted file mode 100644 index 1201e7ed0..000000000 --- a/web/src/components/MemoSkeleton.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { cn } from "@/lib/utils"; - -interface Props { - showCreator?: boolean; - count?: number; -} - -const MemoSkeleton = ({ showCreator = false, count = 6 }: Props) => { - return ( - <> - {Array.from({ length: count }).map((_, index) => ( -
- {/* Header section */} -
-
- {showCreator ? ( -
- {/* Avatar skeleton */} -
-
- {/* Creator name skeleton */} -
- {/* Timestamp skeleton */} -
-
-
- ) : ( -
- )} -
- {/* Action buttons skeleton */} -
-
-
-
-
- - {/* Content section */} -
- {/* Text content skeleton - varied heights for realism */} -
-
-
- {index % 2 === 0 &&
} -
-
-
- ))} - - ); -}; - -export default MemoSkeleton; diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index a102ae42d..832440d06 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -15,7 +15,7 @@ import type { MemoRenderContext } from "../MasonryView"; import MasonryView from "../MasonryView"; import MemoEditor from "../MemoEditor"; import MemoFilters from "../MemoFilters"; -import MemoSkeleton from "../MemoSkeleton"; +import Skeleton from "../Skeleton"; interface Props { renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element; @@ -143,9 +143,7 @@ const PagedMemoList = (props: Props) => {
{/* Show skeleton loader during initial load */} {isLoading ? ( -
- -
+ ) : ( <> { listMode={layout === "LIST"} /> - {/* Loading indicator for pagination */} - {isFetchingNextPage && ( -
- -
- )} + {/* Loading indicator for pagination - use skeleton for content consistency */} + {isFetchingNextPage && } {/* Empty state or back-to-top button */} {!isFetchingNextPage && ( diff --git a/web/src/components/Skeleton.tsx b/web/src/components/Skeleton.tsx new file mode 100644 index 000000000..75c3d6a91 --- /dev/null +++ b/web/src/components/Skeleton.tsx @@ -0,0 +1,80 @@ +import { cn } from "@/lib/utils"; + +interface Props { + type?: "route" | "memo" | "pagination"; + showCreator?: boolean; + count?: number; + showEditor?: boolean; +} + +// Memo card skeleton component +const MemoCardSkeleton = ({ showCreator = false, index = 0 }: { showCreator?: boolean; index?: number }) => ( +
+ {/* Header section */} +
+
+ {showCreator ? ( +
+
+
+
+
+
+
+ ) : ( +
+ )} +
+ {/* Action buttons skeleton */} +
+
+
+
+
+ + {/* Content section */} +
+
+
+
+ {index % 2 === 0 &&
} +
+
+
+); + +const Skeleton = ({ type = "route", showCreator = false, count = 4, showEditor = true }: Props) => { + // Pagination type: simpler, just memos + if (type === "pagination") { + return ( +
+
+ {Array.from({ length: count }).map((_, index) => ( + + ))} +
+
+ ); + } + + // Route or memo type: with optional wrapper + return ( +
+
+ {/* Editor skeleton - only for route type */} + {type === "route" && showEditor && ( +
+
+
+ )} + + {/* Memo skeletons */} + {Array.from({ length: count }).map((_, index) => ( + + ))} +
+
+ ); +}; + +export default Skeleton; diff --git a/web/src/components/Spinner.tsx b/web/src/components/Spinner.tsx new file mode 100644 index 000000000..39a58a7e2 --- /dev/null +++ b/web/src/components/Spinner.tsx @@ -0,0 +1,19 @@ +import { LoaderIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface Props { + className?: string; + size?: "sm" | "md" | "lg"; +} + +const Spinner = ({ className, size = "md" }: Props) => { + const sizeClasses = { + sm: "w-4 h-4", + md: "w-6 h-6", + lg: "w-8 h-8", + }; + + return ; +}; + +export default Spinner; diff --git a/web/src/layouts/RootLayout.tsx b/web/src/layouts/RootLayout.tsx index cf2454a83..69c2a172e 100644 --- a/web/src/layouts/RootLayout.tsx +++ b/web/src/layouts/RootLayout.tsx @@ -2,12 +2,12 @@ import { Suspense, useEffect, useMemo } from "react"; import { Outlet, useLocation, useSearchParams } from "react-router-dom"; import usePrevious from "react-use/lib/usePrevious"; import Navigation from "@/components/Navigation"; +import Skeleton from "@/components/Skeleton"; import { useInstance } from "@/contexts/InstanceContext"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import useMediaQuery from "@/hooks/useMediaQuery"; import { cn } from "@/lib/utils"; -import Loading from "@/pages/Loading"; import { redirectOnAuthFailure } from "@/utils/auth-redirect"; const RootLayout = () => { @@ -47,7 +47,7 @@ const RootLayout = () => {
)}
- }> + }>
diff --git a/web/src/main.tsx b/web/src/main.tsx index 35b06a136..9bc61fc4d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,7 +1,7 @@ import "@github/relative-time-element"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef } from "react"; import { createRoot } from "react-dom/client"; import { Toaster } from "react-hot-toast"; import { RouterProvider } from "react-router-dom"; @@ -12,7 +12,6 @@ import { AuthProvider, useAuth } from "@/contexts/AuthContext"; import { InstanceProvider, useInstance } from "@/contexts/InstanceContext"; import { ViewProvider } from "@/contexts/ViewContext"; import { queryClient } from "@/lib/query-client"; -import Loading from "@/pages/Loading"; import router from "./router"; import { applyLocaleEarly } from "./utils/i18n"; import { applyThemeEarly } from "./utils/theme"; @@ -26,22 +25,21 @@ applyLocaleEarly(); function AppInitializer({ children }: { children: React.ReactNode }) { const { isInitialized: authInitialized, initialize: initAuth } = useAuth(); const { isInitialized: instanceInitialized, initialize: initInstance } = useInstance(); - const [initStarted, setInitStarted] = useState(false); + const initStartedRef = useRef(false); - // Initialize on mount + // Initialize on mount - run in parallel for better performance useEffect(() => { - if (initStarted) return; - setInitStarted(true); + if (initStartedRef.current) return; + initStartedRef.current = true; const init = async () => { - await initInstance(); - await initAuth(); + await Promise.all([initInstance(), initAuth()]); }; init(); - }, [initAuth, initInstance, initStarted]); + }, [initAuth, initInstance]); if (!authInitialized || !instanceInitialized) { - return ; + return undefined; } return <>{children}; diff --git a/web/src/pages/AuthCallback.tsx b/web/src/pages/AuthCallback.tsx index 3e9200937..e7ebf2d62 100644 --- a/web/src/pages/AuthCallback.tsx +++ b/web/src/pages/AuthCallback.tsx @@ -1,8 +1,8 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; -import { LoaderIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { setAccessToken } from "@/auth-state"; +import Spinner from "@/components/Spinner"; import { authServiceClient } from "@/connect"; import { useAuth } from "@/contexts/AuthContext"; import { absolutifyLink } from "@/helpers/utils"; @@ -113,7 +113,7 @@ const AuthCallback = () => { return (
{state.loading ? ( - + ) : (
{state.errorMessage}
)} diff --git a/web/src/pages/Loading.tsx b/web/src/pages/Loading.tsx deleted file mode 100644 index 455d3b996..000000000 --- a/web/src/pages/Loading.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { LoaderIcon } from "lucide-react"; - -function Loading() { - return ( -
-
- -
-
- ); -} - -export default Loading; diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 25387d59f..655675f71 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -1,10 +1,10 @@ import { lazy, Suspense } from "react"; import { createBrowserRouter } from "react-router-dom"; import App from "@/App"; +import Skeleton from "@/components/Skeleton"; import MainLayout from "@/layouts/MainLayout"; import RootLayout from "@/layouts/RootLayout"; import Home from "@/pages/Home"; -import Loading from "@/pages/Loading"; const AdminSignIn = lazy(() => import("@/pages/AdminSignIn")); const Archived = lazy(() => import("@/pages/Archived")); @@ -39,7 +39,7 @@ const router = createBrowserRouter([ { path: "", element: ( - }> + }> ), @@ -47,7 +47,7 @@ const router = createBrowserRouter([ { path: "admin", element: ( - }> + }> ), @@ -55,7 +55,7 @@ const router = createBrowserRouter([ { path: "signup", element: ( - }> + }> ), @@ -63,7 +63,7 @@ const router = createBrowserRouter([ { path: "callback", element: ( - }> + }> ), @@ -84,7 +84,7 @@ const router = createBrowserRouter([ { path: Routes.EXPLORE, element: ( - }> + }> ), @@ -92,7 +92,7 @@ const router = createBrowserRouter([ { path: Routes.ARCHIVED, element: ( - }> + }> ), @@ -100,7 +100,7 @@ const router = createBrowserRouter([ { path: "u/:username", element: ( - }> + }> ), @@ -110,7 +110,7 @@ const router = createBrowserRouter([ { path: Routes.ATTACHMENTS, element: ( - }> + }> ), @@ -118,7 +118,7 @@ const router = createBrowserRouter([ { path: Routes.CALENDAR, element: ( - }> + }> ), @@ -126,7 +126,7 @@ const router = createBrowserRouter([ { path: Routes.INBOX, element: ( - }> + }> ), @@ -134,7 +134,7 @@ const router = createBrowserRouter([ { path: Routes.SETTING, element: ( - }> + }> ), @@ -142,7 +142,7 @@ const router = createBrowserRouter([ { path: "memos/:uid", element: ( - }> + }> ), @@ -151,7 +151,7 @@ const router = createBrowserRouter([ { path: "m/:uid", element: ( - }> + }> ), @@ -159,7 +159,7 @@ const router = createBrowserRouter([ { path: "403", element: ( - }> + }> ), @@ -167,7 +167,7 @@ const router = createBrowserRouter([ { path: "404", element: ( - }> + }> ), @@ -175,7 +175,7 @@ const router = createBrowserRouter([ { path: "*", element: ( - }> + }> ),