= ({
+ type,
+ count = 8,
+ animated = true
+}) => {
+ const animationStyle = animated ? { animation: 'pulse 2s infinite' } : {};
+
+ const renderPageGridSkeleton = () => (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+
+ const renderFileGridSkeleton = () => (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+
+ const renderControlsSkeleton = () => (
+
+
+
+
+
+ );
+
+ const renderViewerSkeleton = () => (
+
+ {/* Toolbar skeleton */}
+
+
+
+
+
+
+ {/* Main content skeleton */}
+
+
+ );
+
+ switch (type) {
+ case 'pageGrid':
+ return renderPageGridSkeleton();
+ case 'fileGrid':
+ return renderFileGridSkeleton();
+ case 'controls':
+ return renderControlsSkeleton();
+ case 'viewer':
+ return renderViewerSkeleton();
+ default:
+ return null;
+ }
+};
+
+export default SkeletonLoader;
\ No newline at end of file
diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx
index cab50337a..41b261673 100644
--- a/frontend/src/components/shared/TopControls.tsx
+++ b/frontend/src/components/shared/TopControls.tsx
@@ -1,5 +1,5 @@
-import React from "react";
-import { Button, SegmentedControl } from "@mantine/core";
+import React, { useState, useCallback } from "react";
+import { Button, SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import LanguageSelector from "./LanguageSelector";
import rainbowStyles from '../../styles/rainbow.module.css';
@@ -11,11 +11,16 @@ import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core";
-const VIEW_OPTIONS = [
+// This will be created inside the component to access switchingTo
+const createViewOptions = (switchingTo: string | null) => [
{
label: (
-
+ {switchingTo === "viewer" ? (
+
+ ) : (
+
+ )}
),
value: "viewer",
@@ -23,7 +28,11 @@ const VIEW_OPTIONS = [
{
label: (
-
+ {switchingTo === "pageEditor" ? (
+
+ ) : (
+
+ )}
),
value: "pageEditor",
@@ -31,7 +40,11 @@ const VIEW_OPTIONS = [
{
label: (
-
+ {switchingTo === "fileEditor" ? (
+
+ ) : (
+
+ )}
),
value: "fileEditor",
@@ -48,6 +61,23 @@ const TopControls = ({
setCurrentView,
}: TopControlsProps) => {
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
+ const [switchingTo, setSwitchingTo] = useState(null);
+
+ const handleViewChange = useCallback((view: string) => {
+ // Show immediate feedback
+ setSwitchingTo(view);
+
+ // Defer the heavy view change to next frame so spinner can render
+ requestAnimationFrame(() => {
+ // Give the spinner one more frame to show
+ requestAnimationFrame(() => {
+ setCurrentView(view);
+
+ // Clear the loading state after view change completes
+ setTimeout(() => setSwitchingTo(null), 300);
+ });
+ });
+ }, [setCurrentView]);
const getThemeIcon = () => {
if (isRainbowMode) return ;
@@ -80,14 +110,18 @@ const TopControls = ({
diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx
index bbf218016..91d7bacf3 100644
--- a/frontend/src/components/viewer/Viewer.tsx
+++ b/frontend/src/components/viewer/Viewer.tsx
@@ -11,6 +11,7 @@ import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
import { useLocalStorage } from "@mantine/hooks";
import { fileStorage } from "../../services/fileStorage";
+import SkeletonLoader from '../shared/SkeletonLoader';
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
@@ -414,9 +415,9 @@ const Viewer = ({
) : loading ? (
-
-
-
+
+
+
) : (
}
+ | { type: 'SET_MERGED_DOCUMENT'; payload: { key: string; document: PDFDocument } }
+ | { type: 'CLEAR_MERGED_DOCUMENTS' }
+ | { type: 'SET_CURRENT_VIEW'; payload: ViewType }
+ | { type: 'SET_CURRENT_TOOL'; payload: ToolType }
+ | { type: 'SET_SELECTED_FILES'; payload: string[] }
+ | { type: 'SET_SELECTED_PAGES'; payload: string[] }
+ | { type: 'CLEAR_SELECTIONS' }
+ | { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
+ | { type: 'UPDATE_VIEWER_CONFIG'; payload: Partial }
+ | { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } }
+ | { type: 'ADD_FILE_OPERATION'; payload: FileOperation }
+ | { type: 'SET_EXPORT_CONFIG'; payload: FileContextState['lastExportConfig'] }
+ | { type: 'RESET_CONTEXT' }
+ | { type: 'LOAD_STATE'; payload: Partial };
+
+// Reducer
+function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
+ switch (action.type) {
+ case 'SET_ACTIVE_FILES':
+ return {
+ ...state,
+ activeFiles: action.payload,
+ selectedFileIds: [], // Clear selections when files change
+ selectedPageIds: []
+ };
+
+ case 'ADD_FILES':
+ return {
+ ...state,
+ activeFiles: [...state.activeFiles, ...action.payload]
+ };
+
+ case 'REMOVE_FILES':
+ const remainingFiles = state.activeFiles.filter(file =>
+ !action.payload.includes(file.name) // Simple ID for now, could use file.name or generate IDs
+ );
+ return {
+ ...state,
+ activeFiles: remainingFiles,
+ selectedFileIds: state.selectedFileIds.filter(id => !action.payload.includes(id))
+ };
+
+ case 'SET_PROCESSED_FILES':
+ return {
+ ...state,
+ processedFiles: action.payload
+ };
+
+ case 'SET_MERGED_DOCUMENT':
+ const newMergedDocuments = new Map(state.mergedDocuments);
+ newMergedDocuments.set(action.payload.key, action.payload.document);
+ return {
+ ...state,
+ mergedDocuments: newMergedDocuments
+ };
+
+ case 'CLEAR_MERGED_DOCUMENTS':
+ return {
+ ...state,
+ mergedDocuments: new Map()
+ };
+
+ case 'SET_CURRENT_VIEW':
+ return {
+ ...state,
+ currentView: action.payload,
+ // Clear tool when switching views
+ currentTool: null
+ };
+
+ case 'SET_CURRENT_TOOL':
+ return {
+ ...state,
+ currentTool: action.payload
+ };
+
+ case 'SET_SELECTED_FILES':
+ return {
+ ...state,
+ selectedFileIds: action.payload
+ };
+
+ case 'SET_SELECTED_PAGES':
+ return {
+ ...state,
+ selectedPageIds: action.payload
+ };
+
+ case 'CLEAR_SELECTIONS':
+ return {
+ ...state,
+ selectedFileIds: [],
+ selectedPageIds: []
+ };
+
+ case 'SET_PROCESSING':
+ return {
+ ...state,
+ isProcessing: action.payload.isProcessing,
+ processingProgress: action.payload.progress
+ };
+
+ case 'UPDATE_VIEWER_CONFIG':
+ return {
+ ...state,
+ viewerConfig: {
+ ...state.viewerConfig,
+ ...action.payload
+ }
+ };
+
+ case 'ADD_PAGE_OPERATIONS':
+ const newHistory = new Map(state.fileEditHistory);
+ const existing = newHistory.get(action.payload.fileId);
+ newHistory.set(action.payload.fileId, {
+ fileId: action.payload.fileId,
+ pageOperations: existing ?
+ [...existing.pageOperations, ...action.payload.operations] :
+ action.payload.operations,
+ lastModified: Date.now()
+ });
+ return {
+ ...state,
+ fileEditHistory: newHistory
+ };
+
+ case 'ADD_FILE_OPERATION':
+ return {
+ ...state,
+ globalFileOperations: [...state.globalFileOperations, action.payload]
+ };
+
+ case 'SET_EXPORT_CONFIG':
+ return {
+ ...state,
+ lastExportConfig: action.payload
+ };
+
+ case 'RESET_CONTEXT':
+ return {
+ ...initialState,
+ mergedDocuments: new Map() // Ensure clean state
+ };
+
+ case 'LOAD_STATE':
+ return {
+ ...state,
+ ...action.payload
+ };
+
+ default:
+ return state;
+ }
+}
+
+// Context
+const FileContext = createContext(undefined);
+
+// Provider component
+export function FileContextProvider({
+ children,
+ enableUrlSync = true,
+ enablePersistence = true,
+ maxCacheSize = 1024 * 1024 * 1024 // 1GB
+}: FileContextProviderProps) {
+ const [state, dispatch] = useReducer(fileContextReducer, initialState);
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ // Cleanup timers and refs
+ const cleanupTimers = useRef