refactor: replace MemoSkeleton with a new Skeleton component for improved loading states

This commit is contained in:
Johnny 2025-12-28 17:31:21 +08:00
parent 792d58b74d
commit 115d1bacd7
10 changed files with 132 additions and 112 deletions

View File

@ -105,8 +105,6 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
// Notify parent component of successful save
onConfirm?.(result.memoName);
toast.success("Saved successfully");
} catch (error) {
handleError(error, toast.error, {
context: "Failed to save memo",

View File

@ -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) => (
<div
key={index}
className="relative flex flex-col justify-start items-start bg-card w-full max-w-2xl mx-auto px-4 py-3 mb-2 gap-2 rounded-lg border border-border animate-pulse"
>
{/* Header section */}
<div className="w-full flex flex-row justify-between items-center gap-2">
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
{showCreator ? (
<div className="w-full flex flex-row justify-start items-center gap-2">
{/* Avatar skeleton */}
<div className="w-8 h-8 rounded-full bg-muted shrink-0" />
<div className="w-full flex flex-col justify-center items-start gap-1">
{/* Creator name skeleton */}
<div className="h-4 w-24 bg-muted rounded" />
{/* Timestamp skeleton */}
<div className="h-3 w-16 bg-muted rounded" />
</div>
</div>
) : (
<div className="h-4 w-32 bg-muted rounded" />
)}
</div>
{/* Action buttons skeleton */}
<div className="flex flex-row gap-2">
<div className="w-4 h-4 bg-muted rounded" />
<div className="w-4 h-4 bg-muted rounded" />
</div>
</div>
{/* Content section */}
<div className="w-full flex flex-col gap-2">
{/* Text content skeleton - varied heights for realism */}
<div className="space-y-2">
<div className={cn("h-4 bg-muted rounded", index % 3 === 0 ? "w-full" : index % 3 === 1 ? "w-4/5" : "w-5/6")} />
<div className={cn("h-4 bg-muted rounded", index % 2 === 0 ? "w-3/4" : "w-4/5")} />
{index % 2 === 0 && <div className="h-4 w-2/3 bg-muted rounded" />}
</div>
</div>
</div>
))}
</>
);
};
export default MemoSkeleton;

View File

@ -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) => {
<div className="flex flex-col justify-start items-start w-full max-w-full">
{/* Show skeleton loader during initial load */}
{isLoading ? (
<div className="w-full flex flex-col justify-start items-center">
<MemoSkeleton showCreator={props.showCreator} count={4} />
</div>
<Skeleton type="memo" showCreator={props.showCreator} count={4} />
) : (
<>
<MasonryView
@ -162,12 +160,8 @@ const PagedMemoList = (props: Props) => {
listMode={layout === "LIST"}
/>
{/* Loading indicator for pagination */}
{isFetchingNextPage && (
<div className="w-full flex flex-row justify-center items-center my-4">
<LoaderIcon className="animate-spin text-muted-foreground" />
</div>
)}
{/* Loading indicator for pagination - use skeleton for content consistency */}
{isFetchingNextPage && <Skeleton type="pagination" showCreator={props.showCreator} count={2} />}
{/* Empty state or back-to-top button */}
{!isFetchingNextPage && (

View File

@ -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 }) => (
<div className="relative flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-2 gap-2 rounded-lg border border-border animate-pulse">
{/* Header section */}
<div className="w-full flex flex-row justify-between items-center gap-2">
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
{showCreator ? (
<div className="w-full flex flex-row justify-start items-center gap-2">
<div className="w-8 h-8 rounded-full bg-muted shrink-0" />
<div className="w-full flex flex-col justify-center items-start gap-1">
<div className="h-4 w-24 bg-muted rounded" />
<div className="h-3 w-16 bg-muted rounded" />
</div>
</div>
) : (
<div className="h-4 w-32 bg-muted rounded" />
)}
</div>
{/* Action buttons skeleton */}
<div className="flex flex-row gap-2">
<div className="w-4 h-4 bg-muted rounded" />
<div className="w-4 h-4 bg-muted rounded" />
</div>
</div>
{/* Content section */}
<div className="w-full flex flex-col gap-2">
<div className="space-y-2">
<div className={cn("h-4 bg-muted rounded", index % 3 === 0 ? "w-full" : index % 3 === 1 ? "w-4/5" : "w-5/6")} />
<div className={cn("h-4 bg-muted rounded", index % 2 === 0 ? "w-3/4" : "w-4/5")} />
{index % 2 === 0 && <div className="h-4 w-2/3 bg-muted rounded" />}
</div>
</div>
</div>
);
const Skeleton = ({ type = "route", showCreator = false, count = 4, showEditor = true }: Props) => {
// Pagination type: simpler, just memos
if (type === "pagination") {
return (
<div className="w-full flex flex-col justify-center items-center my-4">
<div className="w-full max-w-2xl mx-auto">
{Array.from({ length: count }).map((_, index) => (
<MemoCardSkeleton key={index} showCreator={showCreator} index={index} />
))}
</div>
</div>
);
}
// Route or memo type: with optional wrapper
return (
<div className="w-full max-w-full px-4 py-6">
<div className="w-full max-w-2xl mx-auto">
{/* Editor skeleton - only for route type */}
{type === "route" && showEditor && (
<div className="relative flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-4 gap-2 rounded-lg border border-border animate-pulse">
<div className="w-full h-12 bg-muted rounded" />
</div>
)}
{/* Memo skeletons */}
{Array.from({ length: count }).map((_, index) => (
<MemoCardSkeleton key={index} showCreator={showCreator} index={index} />
))}
</div>
</div>
);
};
export default Skeleton;

View File

@ -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 <LoaderIcon className={cn("animate-spin", sizeClasses[size], className)} />;
};
export default Spinner;

View File

@ -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 = () => {
</div>
)}
<main className="w-full h-auto grow shrink flex flex-col justify-start items-center">
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" />}>
<Outlet />
</Suspense>
</main>

View File

@ -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 <Loading />;
return undefined;
}
return <>{children}</>;

View File

@ -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 (
<div className="p-4 py-24 w-full h-full flex justify-center items-center">
{state.loading ? (
<LoaderIcon className="animate-spin text-foreground" />
<Spinner size="lg" />
) : (
<div className="max-w-lg font-mono whitespace-pre-wrap opacity-80">{state.errorMessage}</div>
)}

View File

@ -1,13 +0,0 @@
import { LoaderIcon } from "lucide-react";
function Loading() {
return (
<div className="fixed w-full h-full flex flex-row justify-center items-center">
<div className="w-80 max-w-full h-full py-4 flex flex-col justify-center items-center">
<LoaderIcon className="animate-spin text-foreground" />
</div>
</div>
);
}
export default Loading;

View File

@ -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: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<SignIn />
</Suspense>
),
@ -47,7 +47,7 @@ const router = createBrowserRouter([
{
path: "admin",
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<AdminSignIn />
</Suspense>
),
@ -55,7 +55,7 @@ const router = createBrowserRouter([
{
path: "signup",
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<SignUp />
</Suspense>
),
@ -63,7 +63,7 @@ const router = createBrowserRouter([
{
path: "callback",
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<AuthCallback />
</Suspense>
),
@ -84,7 +84,7 @@ const router = createBrowserRouter([
{
path: Routes.EXPLORE,
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<Explore />
</Suspense>
),
@ -92,7 +92,7 @@ const router = createBrowserRouter([
{
path: Routes.ARCHIVED,
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<Archived />
</Suspense>
),
@ -100,7 +100,7 @@ const router = createBrowserRouter([
{
path: "u/:username",
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<UserProfile />
</Suspense>
),
@ -110,7 +110,7 @@ const router = createBrowserRouter([
{
path: Routes.ATTACHMENTS,
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<Attachments />
</Suspense>
),
@ -118,7 +118,7 @@ const router = createBrowserRouter([
{
path: Routes.CALENDAR,
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<Calendar />
</Suspense>
),
@ -126,7 +126,7 @@ const router = createBrowserRouter([
{
path: Routes.INBOX,
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<Inboxes />
</Suspense>
),
@ -134,7 +134,7 @@ const router = createBrowserRouter([
{
path: Routes.SETTING,
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<Setting />
</Suspense>
),
@ -142,7 +142,7 @@ const router = createBrowserRouter([
{
path: "memos/:uid",
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<MemoDetail />
</Suspense>
),
@ -151,7 +151,7 @@ const router = createBrowserRouter([
{
path: "m/:uid",
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<MemoDetailRedirect />
</Suspense>
),
@ -159,7 +159,7 @@ const router = createBrowserRouter([
{
path: "403",
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<PermissionDenied />
</Suspense>
),
@ -167,7 +167,7 @@ const router = createBrowserRouter([
{
path: "404",
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<NotFound />
</Suspense>
),
@ -175,7 +175,7 @@ const router = createBrowserRouter([
{
path: "*",
element: (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<NotFound />
</Suspense>
),