Commit 115d1bac authored by Johnny's avatar Johnny

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

parent 792d58b7
...@@ -105,8 +105,6 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({ ...@@ -105,8 +105,6 @@ const MemoEditorImpl: React.FC<MemoEditorProps> = ({
// Notify parent component of successful save // Notify parent component of successful save
onConfirm?.(result.memoName); onConfirm?.(result.memoName);
toast.success("Saved successfully");
} catch (error) { } catch (error) {
handleError(error, toast.error, { handleError(error, toast.error, {
context: "Failed to save memo", context: "Failed to save memo",
......
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;
...@@ -15,7 +15,7 @@ import type { MemoRenderContext } from "../MasonryView"; ...@@ -15,7 +15,7 @@ import type { MemoRenderContext } from "../MasonryView";
import MasonryView from "../MasonryView"; import MasonryView from "../MasonryView";
import MemoEditor from "../MemoEditor"; import MemoEditor from "../MemoEditor";
import MemoFilters from "../MemoFilters"; import MemoFilters from "../MemoFilters";
import MemoSkeleton from "../MemoSkeleton"; import Skeleton from "../Skeleton";
interface Props { interface Props {
renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element; renderer: (memo: Memo, context?: MemoRenderContext) => JSX.Element;
...@@ -143,9 +143,7 @@ const PagedMemoList = (props: Props) => { ...@@ -143,9 +143,7 @@ const PagedMemoList = (props: Props) => {
<div className="flex flex-col justify-start items-start w-full max-w-full"> <div className="flex flex-col justify-start items-start w-full max-w-full">
{/* Show skeleton loader during initial load */} {/* Show skeleton loader during initial load */}
{isLoading ? ( {isLoading ? (
<div className="w-full flex flex-col justify-start items-center"> <Skeleton type="memo" showCreator={props.showCreator} count={4} />
<MemoSkeleton showCreator={props.showCreator} count={4} />
</div>
) : ( ) : (
<> <>
<MasonryView <MasonryView
...@@ -162,12 +160,8 @@ const PagedMemoList = (props: Props) => { ...@@ -162,12 +160,8 @@ const PagedMemoList = (props: Props) => {
listMode={layout === "LIST"} listMode={layout === "LIST"}
/> />
{/* Loading indicator for pagination */} {/* Loading indicator for pagination - use skeleton for content consistency */}
{isFetchingNextPage && ( {isFetchingNextPage && <Skeleton type="pagination" showCreator={props.showCreator} count={2} />}
<div className="w-full flex flex-row justify-center items-center my-4">
<LoaderIcon className="animate-spin text-muted-foreground" />
</div>
)}
{/* Empty state or back-to-top button */} {/* Empty state or back-to-top button */}
{!isFetchingNextPage && ( {!isFetchingNextPage && (
......
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;
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;
...@@ -2,12 +2,12 @@ import { Suspense, useEffect, useMemo } from "react"; ...@@ -2,12 +2,12 @@ import { Suspense, useEffect, useMemo } from "react";
import { Outlet, useLocation, useSearchParams } from "react-router-dom"; import { Outlet, useLocation, useSearchParams } from "react-router-dom";
import usePrevious from "react-use/lib/usePrevious"; import usePrevious from "react-use/lib/usePrevious";
import Navigation from "@/components/Navigation"; import Navigation from "@/components/Navigation";
import Skeleton from "@/components/Skeleton";
import { useInstance } from "@/contexts/InstanceContext"; import { useInstance } from "@/contexts/InstanceContext";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useCurrentUser from "@/hooks/useCurrentUser"; import useCurrentUser from "@/hooks/useCurrentUser";
import useMediaQuery from "@/hooks/useMediaQuery"; import useMediaQuery from "@/hooks/useMediaQuery";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Loading from "@/pages/Loading";
import { redirectOnAuthFailure } from "@/utils/auth-redirect"; import { redirectOnAuthFailure } from "@/utils/auth-redirect";
const RootLayout = () => { const RootLayout = () => {
...@@ -47,7 +47,7 @@ const RootLayout = () => { ...@@ -47,7 +47,7 @@ const RootLayout = () => {
</div> </div>
)} )}
<main className="w-full h-auto grow shrink flex flex-col justify-start items-center"> <main className="w-full h-auto grow shrink flex flex-col justify-start items-center">
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" />}>
<Outlet /> <Outlet />
</Suspense> </Suspense>
</main> </main>
......
import "@github/relative-time-element"; import "@github/relative-time-element";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 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 { createRoot } from "react-dom/client";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
...@@ -12,7 +12,6 @@ import { AuthProvider, useAuth } from "@/contexts/AuthContext"; ...@@ -12,7 +12,6 @@ import { AuthProvider, useAuth } from "@/contexts/AuthContext";
import { InstanceProvider, useInstance } from "@/contexts/InstanceContext"; import { InstanceProvider, useInstance } from "@/contexts/InstanceContext";
import { ViewProvider } from "@/contexts/ViewContext"; import { ViewProvider } from "@/contexts/ViewContext";
import { queryClient } from "@/lib/query-client"; import { queryClient } from "@/lib/query-client";
import Loading from "@/pages/Loading";
import router from "./router"; import router from "./router";
import { applyLocaleEarly } from "./utils/i18n"; import { applyLocaleEarly } from "./utils/i18n";
import { applyThemeEarly } from "./utils/theme"; import { applyThemeEarly } from "./utils/theme";
...@@ -26,22 +25,21 @@ applyLocaleEarly(); ...@@ -26,22 +25,21 @@ applyLocaleEarly();
function AppInitializer({ children }: { children: React.ReactNode }) { function AppInitializer({ children }: { children: React.ReactNode }) {
const { isInitialized: authInitialized, initialize: initAuth } = useAuth(); const { isInitialized: authInitialized, initialize: initAuth } = useAuth();
const { isInitialized: instanceInitialized, initialize: initInstance } = useInstance(); 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(() => { useEffect(() => {
if (initStarted) return; if (initStartedRef.current) return;
setInitStarted(true); initStartedRef.current = true;
const init = async () => { const init = async () => {
await initInstance(); await Promise.all([initInstance(), initAuth()]);
await initAuth();
}; };
init(); init();
}, [initAuth, initInstance, initStarted]); }, [initAuth, initInstance]);
if (!authInitialized || !instanceInitialized) { if (!authInitialized || !instanceInitialized) {
return <Loading />; return undefined;
} }
return <>{children}</>; return <>{children}</>;
......
import { timestampDate } from "@bufbuild/protobuf/wkt"; import { timestampDate } from "@bufbuild/protobuf/wkt";
import { LoaderIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { setAccessToken } from "@/auth-state"; import { setAccessToken } from "@/auth-state";
import Spinner from "@/components/Spinner";
import { authServiceClient } from "@/connect"; import { authServiceClient } from "@/connect";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { absolutifyLink } from "@/helpers/utils"; import { absolutifyLink } from "@/helpers/utils";
...@@ -113,7 +113,7 @@ const AuthCallback = () => { ...@@ -113,7 +113,7 @@ const AuthCallback = () => {
return ( return (
<div className="p-4 py-24 w-full h-full flex justify-center items-center"> <div className="p-4 py-24 w-full h-full flex justify-center items-center">
{state.loading ? ( {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> <div className="max-w-lg font-mono whitespace-pre-wrap opacity-80">{state.errorMessage}</div>
)} )}
......
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;
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { createBrowserRouter } from "react-router-dom"; import { createBrowserRouter } from "react-router-dom";
import App from "@/App"; import App from "@/App";
import Skeleton from "@/components/Skeleton";
import MainLayout from "@/layouts/MainLayout"; import MainLayout from "@/layouts/MainLayout";
import RootLayout from "@/layouts/RootLayout"; import RootLayout from "@/layouts/RootLayout";
import Home from "@/pages/Home"; import Home from "@/pages/Home";
import Loading from "@/pages/Loading";
const AdminSignIn = lazy(() => import("@/pages/AdminSignIn")); const AdminSignIn = lazy(() => import("@/pages/AdminSignIn"));
const Archived = lazy(() => import("@/pages/Archived")); const Archived = lazy(() => import("@/pages/Archived"));
...@@ -39,7 +39,7 @@ const router = createBrowserRouter([ ...@@ -39,7 +39,7 @@ const router = createBrowserRouter([
{ {
path: "", path: "",
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<SignIn /> <SignIn />
</Suspense> </Suspense>
), ),
...@@ -47,7 +47,7 @@ const router = createBrowserRouter([ ...@@ -47,7 +47,7 @@ const router = createBrowserRouter([
{ {
path: "admin", path: "admin",
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<AdminSignIn /> <AdminSignIn />
</Suspense> </Suspense>
), ),
...@@ -55,7 +55,7 @@ const router = createBrowserRouter([ ...@@ -55,7 +55,7 @@ const router = createBrowserRouter([
{ {
path: "signup", path: "signup",
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<SignUp /> <SignUp />
</Suspense> </Suspense>
), ),
...@@ -63,7 +63,7 @@ const router = createBrowserRouter([ ...@@ -63,7 +63,7 @@ const router = createBrowserRouter([
{ {
path: "callback", path: "callback",
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<AuthCallback /> <AuthCallback />
</Suspense> </Suspense>
), ),
...@@ -84,7 +84,7 @@ const router = createBrowserRouter([ ...@@ -84,7 +84,7 @@ const router = createBrowserRouter([
{ {
path: Routes.EXPLORE, path: Routes.EXPLORE,
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<Explore /> <Explore />
</Suspense> </Suspense>
), ),
...@@ -92,7 +92,7 @@ const router = createBrowserRouter([ ...@@ -92,7 +92,7 @@ const router = createBrowserRouter([
{ {
path: Routes.ARCHIVED, path: Routes.ARCHIVED,
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<Archived /> <Archived />
</Suspense> </Suspense>
), ),
...@@ -100,7 +100,7 @@ const router = createBrowserRouter([ ...@@ -100,7 +100,7 @@ const router = createBrowserRouter([
{ {
path: "u/:username", path: "u/:username",
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<UserProfile /> <UserProfile />
</Suspense> </Suspense>
), ),
...@@ -110,7 +110,7 @@ const router = createBrowserRouter([ ...@@ -110,7 +110,7 @@ const router = createBrowserRouter([
{ {
path: Routes.ATTACHMENTS, path: Routes.ATTACHMENTS,
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<Attachments /> <Attachments />
</Suspense> </Suspense>
), ),
...@@ -118,7 +118,7 @@ const router = createBrowserRouter([ ...@@ -118,7 +118,7 @@ const router = createBrowserRouter([
{ {
path: Routes.CALENDAR, path: Routes.CALENDAR,
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<Calendar /> <Calendar />
</Suspense> </Suspense>
), ),
...@@ -126,7 +126,7 @@ const router = createBrowserRouter([ ...@@ -126,7 +126,7 @@ const router = createBrowserRouter([
{ {
path: Routes.INBOX, path: Routes.INBOX,
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<Inboxes /> <Inboxes />
</Suspense> </Suspense>
), ),
...@@ -134,7 +134,7 @@ const router = createBrowserRouter([ ...@@ -134,7 +134,7 @@ const router = createBrowserRouter([
{ {
path: Routes.SETTING, path: Routes.SETTING,
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<Setting /> <Setting />
</Suspense> </Suspense>
), ),
...@@ -142,7 +142,7 @@ const router = createBrowserRouter([ ...@@ -142,7 +142,7 @@ const router = createBrowserRouter([
{ {
path: "memos/:uid", path: "memos/:uid",
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<MemoDetail /> <MemoDetail />
</Suspense> </Suspense>
), ),
...@@ -151,7 +151,7 @@ const router = createBrowserRouter([ ...@@ -151,7 +151,7 @@ const router = createBrowserRouter([
{ {
path: "m/:uid", path: "m/:uid",
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<MemoDetailRedirect /> <MemoDetailRedirect />
</Suspense> </Suspense>
), ),
...@@ -159,7 +159,7 @@ const router = createBrowserRouter([ ...@@ -159,7 +159,7 @@ const router = createBrowserRouter([
{ {
path: "403", path: "403",
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<PermissionDenied /> <PermissionDenied />
</Suspense> </Suspense>
), ),
...@@ -167,7 +167,7 @@ const router = createBrowserRouter([ ...@@ -167,7 +167,7 @@ const router = createBrowserRouter([
{ {
path: "404", path: "404",
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<NotFound /> <NotFound />
</Suspense> </Suspense>
), ),
...@@ -175,7 +175,7 @@ const router = createBrowserRouter([ ...@@ -175,7 +175,7 @@ const router = createBrowserRouter([
{ {
path: "*", path: "*",
element: ( element: (
<Suspense fallback={<Loading />}> <Suspense fallback={<Skeleton type="route" showEditor={false} />}>
<NotFound /> <NotFound />
</Suspense> </Suspense>
), ),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment