feat: implement unified loading screens across various components

- Introduced a new UnifiedLoadingScreen component for consistent loading indicators in the application.
- Replaced existing loading implementations in LoginPage, AuthCallbackPage, DashboardLayout, and other components with the new unified loading screen.
- Updated translations for loading messages to enhance user experience and clarity.
- Improved loading states in the ElectricProvider and TokenHandler components to utilize the new loading screen, ensuring a cohesive look and feel during loading processes.
This commit is contained in:
Anish Sarkar 2026-01-24 19:42:07 +05:30
parent bba3cb1cf9
commit 22bd5e0f39
14 changed files with 191 additions and 141 deletions

View file

@ -1,12 +1,12 @@
"use client";
import { Loader2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner";
import { Logo } from "@/components/Logo";
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config";
import { AmbientBackground } from "./AmbientBackground";
@ -59,7 +59,11 @@ function LoginContent() {
});
// Show toast with conditional retry action
const toastOptions: any = {
const toastOptions: {
description: string;
duration: number;
action?: { label: string; onClick: () => void };
} = {
description: errorDescription,
duration: 6000,
};
@ -90,18 +94,7 @@ function LoginContent() {
// Show loading state while determining auth type
if (isLoading) {
return (
<div className="relative w-full overflow-hidden">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">{tCommon("loading")}</span>
</div>
</div>
</div>
);
return <UnifiedLoadingScreen variant="login" message={tCommon("loading")} />;
}
if (authType === "GOOGLE") {
@ -182,23 +175,9 @@ function LoginContent() {
);
}
// Loading fallback for Suspense
const LoadingFallback = () => (
<div className="relative w-full overflow-hidden">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span>
</div>
</div>
</div>
);
export default function LoginPage() {
return (
<Suspense fallback={<LoadingFallback />}>
<Suspense fallback={<UnifiedLoadingScreen variant="login" message="Loading" />}>
<LoginContent />
</Suspense>
);

View file

@ -0,0 +1,19 @@
import { useTranslations } from "next-intl";
import { Spinner } from "@/components/ui/spinner";
export default function AuthCallbackLoading() {
const t = useTranslations("auth");
return (
<div className="fixed inset-0 z-[9999] flex min-h-screen flex-col items-center justify-center bg-background">
<div className="flex flex-col items-center space-y-4">
<div className="h-12 w-12 flex items-center justify-center">
<Spinner size="xl" className="text-primary" />
</div>
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-md px-4">
{t("processing_authentication")}
</span>
</div>
</div>
);
}

View file

@ -1,23 +1,20 @@
"use client";
import { Suspense } from "react";
import { useTranslations } from "next-intl";
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
import TokenHandler from "@/components/TokenHandler";
export default function AuthCallbackPage() {
const t = useTranslations("auth");
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Authentication Callback</h1>
<Suspense
fallback={
<div className="flex items-center justify-center min-h-[200px]">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
}
>
<TokenHandler
redirectPath="/dashboard"
tokenParamName="token"
storageKey="surfsense_bearer_token"
/>
</Suspense>
</div>
<Suspense fallback={<UnifiedLoadingScreen variant="default" message={t("processing_authentication")} />}>
<TokenHandler
redirectPath="/dashboard"
tokenParamName="token"
storageKey="surfsense_bearer_token"
/>
</Suspense>
);
}

View file

@ -1,7 +1,6 @@
"use client";
import { useAtomValue, useSetAtom } from "jotai";
import { Loader2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import type React from "react";
@ -19,6 +18,7 @@ import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
export function DashboardClientLayout({
children,
@ -153,23 +153,10 @@ export function DashboardClientLayout({
isAutoConfiguring
) {
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">
{isAutoConfiguring ? "Setting up AI..." : t("loading_config")}
</CardTitle>
<CardDescription>
{isAutoConfiguring
? "Auto-configuring with available settings"
: t("checking_llm_prefs")}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
</CardContent>
</Card>
</div>
<UnifiedLoadingScreen
variant="default"
message={isAutoConfiguring ? t("setting_up_ai") : t("checking_llm_prefs")}
/>
);
}

View file

@ -9,6 +9,7 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
import { useParams, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
@ -34,6 +35,7 @@ import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { Spinner } from "@/components/ui/spinner";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesElectric } from "@/hooks/use-messages-electric";
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
@ -236,6 +238,7 @@ interface ThinkingStepData {
}
export default function NewChatPage() {
const t = useTranslations("dashboard");
const params = useParams();
const queryClient = useQueryClient();
const [isInitializing, setIsInitializing] = useState(true);
@ -1475,8 +1478,9 @@ export default function NewChatPage() {
// Show loading state only when loading an existing thread
if (isInitializing) {
return (
<div className="flex h-[calc(100vh-64px)] items-center justify-center">
<div className="text-muted-foreground">Loading chat...</div>
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
<Spinner size="lg" />
<div className="text-sm text-muted-foreground">{t("loading_chat")}</div>
</div>
);
}

View file

@ -1,8 +1,8 @@
"use client";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useTranslations } from "next-intl";
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
interface DashboardLayoutProps {
@ -10,6 +10,7 @@ interface DashboardLayoutProps {
}
export default function DashboardLayout({ children }: DashboardLayoutProps) {
const t = useTranslations("dashboard");
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
useEffect(() => {
@ -25,19 +26,7 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
// Show loading screen while checking authentication
if (isCheckingAuth) {
return (
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">Loading Dashboard</CardTitle>
<CardDescription>Checking authentication...</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
</CardContent>
</Card>
</div>
);
return <UnifiedLoadingScreen variant="default" message={t("checking_auth")} />;
}
return (

View file

@ -0,0 +1,21 @@
"use client";
import { useTranslations } from "next-intl";
import { Spinner } from "@/components/ui/spinner";
export default function DashboardLoading() {
const t = useTranslations("common");
return (
<div className="fixed inset-0 z-[9999] flex min-h-screen flex-col items-center justify-center bg-background">
<div className="flex flex-col items-center space-y-4">
<div className="h-12 w-12 flex items-center justify-center">
<Spinner size="xl" className="text-primary" />
</div>
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-md px-4">
{t("loading")}
</span>
</div>
</div>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Loader2, Plus, Search } from "lucide-react";
import { AlertCircle, Plus, Search } from "lucide-react";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
@ -18,37 +18,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
function LoadingScreen() {
const t = useTranslations("dashboard");
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
>
<Card className="w-full max-w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">{t("loading")}</CardTitle>
<CardDescription>{t("fetching_spaces")}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
>
<Loader2 className="h-12 w-12 text-primary" />
</motion.div>
</CardContent>
<CardFooter className="border-t pt-4 text-sm text-muted-foreground">
{t("may_take_moment")}
</CardFooter>
</Card>
</motion.div>
</div>
);
}
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
function ErrorScreen({ message }: { message: string }) {
const t = useTranslations("dashboard");
@ -121,6 +91,7 @@ export default function DashboardPage() {
const router = useRouter();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const t = useTranslations("dashboard");
const { data: searchSpaces = [], isLoading, error } = useAtomValue(searchSpacesAtom);
useEffect(() => {
@ -131,11 +102,11 @@ export default function DashboardPage() {
}
}, [isLoading, searchSpaces, router]);
if (isLoading) return <LoadingScreen />;
if (isLoading) return <UnifiedLoadingScreen variant="default" message={t("fetching_spaces")} />;
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
if (searchSpaces.length > 0) {
return <LoadingScreen />;
return <UnifiedLoadingScreen variant="default" message={t("fetching_spaces")} />;
}
return (

View file

@ -1,7 +1,9 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
import { trackLoginSuccess } from "@/lib/posthog/events";
@ -25,6 +27,7 @@ const TokenHandler = ({
tokenParamName = "token",
storageKey = "surfsense_bearer_token",
}: TokenHandlerProps) => {
const t = useTranslations("auth");
const searchParams = useSearchParams();
useEffect(() => {
@ -67,9 +70,7 @@ const TokenHandler = ({
}, [searchParams, tokenParamName, storageKey, redirectPath]);
return (
<div className="flex items-center justify-center min-h-[200px]">
<p className="text-gray-500">Processing authentication...</p>
</div>
<UnifiedLoadingScreen variant="default" message={t("processing_authentication")} />
);
};

View file

@ -7,11 +7,11 @@ import {
ExternalLink,
FileText,
Hash,
Loader2,
Sparkles,
X,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useTranslations } from "next-intl";
import type React from "react";
import { forwardRef, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
@ -20,6 +20,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import type {
GetDocumentByChunkResponse,
GetSurfsenseDocsByChunkResponse,
@ -63,7 +64,7 @@ interface ChunkCardProps {
}
const ChunkCard = forwardRef<HTMLDivElement, ChunkCardProps>(
({ chunk, index, totalChunks, isCited, isActive, disableLayoutAnimation }, ref) => {
({ chunk, index, totalChunks, isCited }, ref) => {
return (
<div
ref={ref}
@ -122,12 +123,13 @@ export function SourceDetailPanel({
children,
isDocsChunk = false,
}: SourceDetailPanelProps) {
const t = useTranslations("dashboard");
const scrollAreaRef = useRef<HTMLDivElement>(null);
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
const [summaryOpen, setSummaryOpen] = useState(false);
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
const [mounted, setMounted] = useState(false);
const [hasScrolledToCited, setHasScrolledToCited] = useState(false);
const [_hasScrolledToCited, setHasScrolledToCited] = useState(false);
const shouldReduceMotion = useReducedMotion();
useEffect(() => {
@ -382,11 +384,8 @@ export function SourceDetailPanel({
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
>
<div className="relative">
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl" />
<Loader2 className="h-12 w-12 animate-spin text-primary relative" />
</div>
<p className="text-sm text-muted-foreground font-medium">Loading document</p>
<Spinner size="lg"/>
<p className="text-sm text-muted-foreground font-medium">{t("loading_document")}</p>
</motion.div>
</div>
)}

View file

@ -1,8 +1,10 @@
"use client";
import { useAtomValue } from "jotai";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { UnifiedLoadingScreen } from "@/components/ui/unified-loading-screen";
import { getBearerToken } from "@/lib/auth-utils";
import {
cleanupElectric,
@ -28,6 +30,7 @@ interface ElectricProviderProps {
* 5. Provides client via context - hooks should use useElectricClient()
*/
export function ElectricProvider({ children }: ElectricProviderProps) {
const t = useTranslations("common");
const [electricClient, setElectricClient] = useState<ElectricClient | null>(null);
const [error, setError] = useState<Error | null>(null);
const {
@ -120,9 +123,7 @@ export function ElectricProvider({ children }: ElectricProviderProps) {
if (!electricClient && !error) {
return (
<ElectricContext.Provider value={null}>
<div className="flex items-center justify-center min-h-screen">
<div className="text-muted-foreground">Initializing...</div>
</div>
<UnifiedLoadingScreen variant="default" message={t("initializing")} />
</ElectricContext.Provider>
);
}

View file

@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { AmbientBackground } from "@/app/(home)/login/AmbientBackground";
interface UnifiedLoadingScreenProps {
/** Optional message to display below the spinner */
message?: string;
/** Visual style variant */
variant?: "login" | "default";
}
export function UnifiedLoadingScreen({
message,
variant = "default",
}: UnifiedLoadingScreenProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Fixed-size container to prevent layout shifts
const spinnerContainer = (
<div className="h-12 w-12 flex items-center justify-center">
<Spinner
size={variant === "login" ? "lg" : "xl"}
className={variant === "login" ? "text-muted-foreground" : "text-primary"}
/>
</div>
);
const content = variant === "login" ? (
<div className="fixed inset-0 z-[9999] relative w-full overflow-hidden bg-background">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<div className="mt-8 flex flex-col items-center space-y-4">
{spinnerContainer}
{message && (
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-xs">
{message}
</span>
)}
</div>
</div>
</div>
) : (
<div className="fixed inset-0 z-[9999] flex min-h-screen flex-col items-center justify-center bg-background">
<div className="flex flex-col items-center space-y-4">
{spinnerContainer}
{message && (
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-md px-4">
{message}
</span>
)}
</div>
</div>
);
// Render inline during SSR, use portal after mounting
// This prevents the black flash during initial render
if (!mounted) {
return content;
}
return createPortal(content, document.body);
}

View file

@ -2,7 +2,8 @@
"common": {
"app_name": "SurfSense",
"welcome": "Welcome",
"loading": "Loading...",
"loading": "Loading",
"initializing": "Initializing",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
@ -76,9 +77,10 @@
"passwords_no_match": "Passwords do not match",
"password_mismatch": "Password Mismatch",
"passwords_no_match_desc": "The passwords you entered do not match",
"creating_account": "Creating your account...",
"creating_account_btn": "Creating account...",
"redirecting_login": "Redirecting to login page..."
"creating_account": "Creating your account",
"creating_account_btn": "Creating account",
"redirecting_login": "Redirecting to login page",
"processing_authentication": "Processing authentication"
},
"searchSpace": {
"create_title": "Create Search Space",
@ -143,12 +145,15 @@
"api_keys": "API Keys",
"profile": "Profile",
"loading_dashboard": "Loading Dashboard",
"checking_auth": "Checking authentication...",
"checking_auth": "Checking authentication",
"loading_config": "Loading Configuration",
"checking_llm_prefs": "Checking your LLM preferences...",
"checking_llm_prefs": "Checking your LLM preferences",
"setting_up_ai": "Setting up AI",
"config_error": "Configuration Error",
"failed_load_llm_config": "Failed to load your LLM configuration",
"error_loading_chats": "Error loading chats",
"loading_chat": "Loading chat",
"loading_document": "Loading document",
"no_recent_chats": "No recent chats",
"error_loading_space": "Error loading search space",
"unknown_search_space": "Unknown Search Space",
@ -165,7 +170,7 @@
"create_search_space": "Create Search Space",
"add_new_search_space": "Add New Search Space",
"loading": "Loading",
"fetching_spaces": "Fetching your search spaces...",
"fetching_spaces": "Fetching your search spaces",
"may_take_moment": "This may take a moment",
"error": "Error",
"something_wrong": "Something went wrong",

View file

@ -3,6 +3,7 @@
"app_name": "SurfSense",
"welcome": "欢迎",
"loading": "加载中...",
"initializing": "正在初始化",
"save": "保存",
"cancel": "取消",
"delete": "删除",
@ -76,9 +77,10 @@
"passwords_no_match": "密码不匹配",
"password_mismatch": "密码不匹配",
"passwords_no_match_desc": "您输入的密码不一致",
"creating_account": "正在创建您的账户...",
"creating_account_btn": "创建中...",
"redirecting_login": "正在跳转到登录页面..."
"creating_account": "正在创建您的账户",
"creating_account_btn": "创建中",
"redirecting_login": "正在跳转到登录页面",
"processing_authentication": "正在处理身份验证"
},
"searchSpace": {
"create_title": "创建搜索空间",
@ -128,12 +130,15 @@
"api_keys": "API 密钥",
"profile": "个人资料",
"loading_dashboard": "正在加载仪表盘",
"checking_auth": "正在检查身份验证...",
"checking_auth": "正在检查身份验证",
"loading_config": "正在加载配置",
"checking_llm_prefs": "正在检查您的 LLM 偏好设置...",
"checking_llm_prefs": "正在检查您的 LLM 偏好设置",
"setting_up_ai": "正在设置 AI",
"config_error": "配置错误",
"failed_load_llm_config": "无法加载您的 LLM 配置",
"error_loading_chats": "加载对话失败",
"loading_chat": "正在加载对话",
"loading_document": "正在加载文档",
"no_recent_chats": "暂无最近对话",
"error_loading_space": "加载搜索空间失败",
"unknown_search_space": "未知搜索空间",
@ -150,7 +155,7 @@
"create_search_space": "创建搜索空间",
"add_new_search_space": "添加新的搜索空间",
"loading": "加载中",
"fetching_spaces": "正在获取您的搜索空间...",
"fetching_spaces": "正在获取您的搜索空间",
"may_take_moment": "这可能需要一些时间",
"error": "错误",
"something_wrong": "出现错误",