diff --git a/ENHANCEMENT_PLAN.md b/ENHANCEMENT_PLAN.md
new file mode 100644
index 00000000..a6a7c855
--- /dev/null
+++ b/ENHANCEMENT_PLAN.md
@@ -0,0 +1,605 @@
+# React File Manager - Enhancement Plan
+
+## Team Composition
+
+| Role | Count | Responsibilities |
+|------|-------|-----------------|
+| Frontend Developers | 6 | Core component development, state management, performance, testing |
+| Backend Developers | 4 | API design, storage integrations, real-time features, security |
+| UI/UX Engineers | 5 | Design systems, interaction design, accessibility, user research, motion design |
+
+---
+
+## Current State Assessment
+
+### What Exists
+- Grid & list views with column sorting
+- File CRUD operations (create folder, rename, delete, upload, download)
+- Copy/cut/paste with clipboard context
+- Drag-and-drop file moving
+- Breadcrumb & sidebar navigation with collapsible nav pane
+- Context menu with submenus
+- Keyboard shortcuts (15+ shortcuts)
+- File preview (images, video, audio, PDF, text)
+- Multi-selection (click, Ctrl+click, Shift+click, Ctrl+A)
+- i18n support (24 languages)
+- Theming via CSS variables (primary color, font family)
+- Permissions system (create, upload, move, copy, rename, download, delete)
+- Column resize for navigation pane
+
+### What's Missing
+- Zero test coverage (no test files exist)
+- No TypeScript (entire codebase is JavaScript)
+- No accessibility (ARIA attributes, screen reader support, focus management)
+- No search/filter functionality
+- No file thumbnails or image previews in grid view
+- No undo/redo system
+- No file info/properties panel
+- No favorites/bookmarks
+- No recent files
+- No tagging system
+- No virtual scrolling (performance concern for large directories)
+- No dark mode / theme presets
+- No animations or transitions
+- No toast/notification system
+- No progress tracking for bulk operations
+- No file type filtering in views
+- No status bar with directory info
+- No responsive/mobile design
+- No plugin/extension system
+
+---
+
+## Iteration 1: Foundation & Quality (Weeks 1-3)
+
+> **Theme: "Build the Foundation Right"**
+> Establish testing, TypeScript, accessibility, and design system fundamentals before adding features.
+
+### Dev Team (10 developers)
+
+#### D1-D2: TypeScript Migration
+- Convert all `.jsx`/`.js` files to `.tsx`/`.ts`
+- Define proper interfaces for `File`, `FileUploadConfig`, `Permissions`, `SortConfig`
+- Type all context providers (`FilesContext`, `SelectionContext`, `ClipboardContext`, `FileNavigationContext`, `LayoutContext`)
+- Type all hooks (`useFileList`, `useShortcutHandler`, `useColumnResize`, `useDetectOutsideClick`, `useKeyPress`)
+- Type all utility functions (`sortFiles`, `getDataSize`, `formatDate`, `getParentPath`, etc.)
+- Add strict TypeScript config with `strict: true`
+- Export typed public API from `index.ts`
+
+#### D3-D4: Test Infrastructure & Core Tests
+- Set up Vitest + React Testing Library + jsdom
+- Configure coverage thresholds (aim for 80%+ on critical paths)
+- Write unit tests for all utility functions (`sortFiles`, `getDataSize`, `formatDate`, `duplicateNameHandler`, `getFileExtension`, `getParentPath`, `createFolderTree`)
+- Write unit tests for all validators (`propValidators`)
+- Write integration tests for context providers (FilesContext, SelectionContext, ClipboardContext, FileNavigationContext)
+- Write component tests for `FileItem`, `FileList`, `Toolbar`, `BreadCrumb`, `ContextMenu`
+- Write tests for keyboard shortcut handler
+- Add CI pipeline with GitHub Actions for lint + test + build
+
+#### D5-D6: Accessibility (WCAG 2.1 AA)
+- Add proper ARIA roles: `tree`, `treeitem`, `grid`, `gridcell`, `toolbar`, `navigation`, `menu`, `menuitem`
+- Add `aria-label`, `aria-selected`, `aria-expanded`, `aria-checked` to all interactive elements
+- Implement roving tabindex for file list navigation (arrow keys up/down/left/right)
+- Add focus visible styles (`:focus-visible` ring) across all interactive elements
+- Add screen reader announcements for file operations (live regions with `aria-live="polite"`)
+- Add skip navigation link
+- Ensure all modals trap focus and return focus on close
+- Ensure proper heading hierarchy within the component
+- Add `role="status"` to loader component
+- Test with axe-core automated checks and add to CI
+
+#### D7-D8: State Management Refactor
+- Replace the 5 separate contexts with a single `useReducer`-based store or adopt Zustand for simpler state
+- Implement proper action dispatching for all file operations (CREATE_FOLDER, RENAME, DELETE, MOVE, COPY, PASTE, SELECT, DESELECT)
+- Add undo/redo system using an action history stack (max 50 actions)
+- Add `Ctrl+Z` (undo) and `Ctrl+Shift+Z` (redo) keyboard shortcuts
+- Undo support for: rename, delete (soft), move, create folder
+- Persist undo stack per session
+
+#### D9-D10: Performance Foundations
+- Implement virtual scrolling for file lists using `react-window` or custom virtualization
+- Add `React.memo` to `FileItem` with proper comparison function
+- Memoize expensive computations in contexts (`currentPathFiles` filtering, `sortFiles`)
+- Implement debounced/throttled handlers for drag events and resize
+- Add lazy loading for `PreviewFile`, `UploadFile`, `Delete` action modals (React.lazy + Suspense)
+- Profile and eliminate unnecessary re-renders (React DevTools Profiler audit)
+
+### UX Team (5 engineers)
+
+#### UX1: Design System & Tokens
+- Define a complete design token system:
+ - **Colors**: primary, secondary, surface, background, text-primary, text-secondary, border, error, warning, success, info
+ - **Spacing**: 4px base unit scale (4, 8, 12, 16, 20, 24, 32, 40, 48, 64)
+ - **Typography**: font sizes (12, 13, 14, 16, 18, 20, 24), weights (400, 500, 600, 700), line heights
+ - **Shadows**: elevation-1 through elevation-4
+ - **Radii**: sm (4px), md (6px), lg (8px), xl (12px)
+ - **Transitions**: durations (100ms, 200ms, 300ms), easings (ease-in-out, spring)
+- All tokens as CSS custom properties for runtime theming
+
+#### UX2: Dark Mode Design
+- Design complete dark theme palette
+- Create color mappings: light token -> dark token
+- Design contrast-safe icon colors for both themes
+- Design smooth theme transition animation (150ms crossfade)
+- Selection states, hover states, active states for both themes
+- Handle user OS preference detection (`prefers-color-scheme`)
+
+#### UX3: Interaction Audit & Redesign
+- Audit all click targets (ensure minimum 44x44px touch targets)
+- Design improved file selection visual states (selected, focused, hover, drag-over, cut/moving)
+- Design breadcrumb overflow behavior (collapse with ellipsis menu)
+- Design improved upload dropzone with animated border
+- Design empty state illustrations for empty folders
+- Design loading skeleton states to replace simple spinner
+
+#### UX4: Iconography System
+- Design/select a cohesive icon set for all file types (replace the mixed react-icons approach)
+- Create colored file type icons for grid view (PDF red, Word blue, Excel green, etc.)
+- Design folder icons with state variants (open, closed, shared, locked)
+- Design action icons with consistent stroke width and sizing
+- Create icon sprites or component library for optimal loading
+
+#### UX5: Motion & Animation Design
+- Design micro-interactions for:
+ - File selection (subtle scale + highlight)
+ - Drag and drop (shadow lift, drop zone pulse)
+ - Context menu appear/dismiss (scale + fade)
+ - Modal open/close (slide up + fade)
+ - Upload progress (smooth progress bar)
+ - File deletion (fade out + collapse)
+ - Breadcrumb path changes (slide transition)
+ - Layout toggle (grid <-> list morphing transition)
+- Establish animation duration standards (fast: 100ms, normal: 200ms, slow: 300ms)
+
+---
+
+## Iteration 2: Core Feature Expansion (Weeks 4-6)
+
+> **Theme: "Power User Features"**
+> Add the features that users expect from a modern file manager.
+
+### Dev Team (10 developers)
+
+#### D1-D2: Search & Filter System
+- Add search bar in the toolbar with real-time filtering
+- Implement fuzzy search matching (support partial names, case-insensitive)
+- Add option for recursive search (search within all subdirectories)
+- Highlight matched text in file names during search
+- Add file type filter dropdown (Images, Documents, Videos, Audio, Code, Archives, All)
+- Add quick filter chips below toolbar (e.g., "Images", "Documents", "This Week")
+- Add `onSearch` callback prop for server-side search delegation
+- Add keyboard shortcut `Ctrl+F` to focus search bar
+
+#### D3-D4: Details/Properties Panel
+- Add a resizable right-side panel (info panel) that shows selected file details
+- Single file selected: name, type, size, path, created date, modified date, thumbnail preview
+- Multiple files selected: count, total size, common parent path
+- Folder selected: name, path, item count (files + subfolders), total size
+- No selection: current directory info, total items, storage usage summary
+- Add `onFileDetails` callback prop for custom metadata
+- Toggle panel with `Alt+P` shortcut and toolbar button
+- Panel should be collapsible/resizable like the navigation pane
+
+#### D5-D6: Advanced File Preview
+- Add thumbnail generation/display for images in grid view
+- Add `thumbnailUrl` field support in the `File` type
+- Support GIF, WEBP, SVG, BMP image preview
+- Add code file preview with syntax highlighting (lightweight highlighter)
+- Add Markdown file preview with rendered output
+- Add CSV preview as a formatted table
+- Add preview navigation (previous/next file) with arrow keys in preview modal
+- Add zoom controls for image preview (zoom in, zoom out, fit to screen)
+- Add fullscreen preview mode
+
+#### D7-D8: Favorites & Quick Access
+- Add star/favorite toggle on files and folders
+- Add `onFavoriteToggle` callback prop
+- Add "Quick Access" section at the top of navigation pane showing favorited items
+- Add "Recent Files" section showing last 20 accessed files (tracked via `onFileOpen`)
+- Add `onRecentFiles` callback prop to persist recents
+- Persist favorites in component state, delegate storage to consumer via callbacks
+- Add visual indicator (star icon) on favorited items in file list
+
+#### D9: Toast/Notification System
+- Build a lightweight, self-contained toast component (no external dependency)
+- Toast types: success, error, warning, info
+- Auto-dismiss with configurable duration (default 4s)
+- Stack up to 3 toasts, with newest on top
+- Toast for: file uploaded, file deleted, file renamed, copy/paste complete, errors
+- Add `position` config: top-right (default), top-left, bottom-right, bottom-left
+- Animate in (slide + fade) and out (fade)
+- Add `showNotifications` prop to enable/disable (default: true)
+
+#### D10: Status Bar
+- Add a bottom status bar to the file manager
+- Display: total items in current directory, selected items count, current view mode, sort state
+- Show storage usage if `storageQuota` prop is provided (progress bar)
+- Show last modified time of current directory
+- Add zoom/icon size slider for grid view
+- Make status bar togglable via `showStatusBar` prop (default: true)
+
+### UX Team (5 engineers)
+
+#### UX1: Search & Filter UX
+- Design search bar with integrated filter controls
+- Design search results presentation with highlighted matches
+- Design filter chip system (removable tags)
+- Design "no results" state with suggestions
+- Design progressive search (show results as user types)
+
+#### UX2: Details Panel UX
+- Design the info/details panel layout
+- Design metadata display with clear visual hierarchy
+- Design thumbnail area for file preview in panel
+- Design multi-file selection summary view
+- Design panel resize interaction and collapse animation
+
+#### UX3: File Thumbnail Design
+- Design thumbnail grid layout with consistent aspect ratios
+- Design placeholder/skeleton states during thumbnail loading
+- Design file type overlay badges on thumbnails
+- Design video thumbnail with play button overlay
+- Design document thumbnail with page preview appearance
+
+#### UX4: Notification & Toast UX
+- Design toast component with type variants (success, error, warning, info)
+- Design toast entrance/exit animations
+- Design toast stacking behavior
+- Design action toasts (e.g., "File deleted" with "Undo" button)
+- Design the status bar layout and information hierarchy
+
+#### UX5: Quick Access & Favorites UX
+- Design the Quick Access section layout in navigation pane
+- Design star/favorite toggle interaction on files
+- Design Recent Files section with timeline grouping (Today, Yesterday, This Week)
+- Design pinned folders concept in navigation
+- Design empty state for Quick Access when no favorites exist
+
+---
+
+## Iteration 3: Advanced Interactions (Weeks 7-9)
+
+> **Theme: "Seamless Power"**
+> Advanced drag-and-drop, multi-tab navigation, and real-time collaboration hooks.
+
+### Dev Team (10 developers)
+
+#### D1-D2: Multi-Tab Navigation
+- Add tabbed interface above the breadcrumb area
+- Support opening folders in new tabs (Ctrl+click or context menu "Open in New Tab")
+- Each tab maintains its own path, selection state, and sort config
+- Add tab close (x button), tab reorder (drag tabs), tab context menu
+- Limit to configurable max tabs (default: 10)
+- Add `Ctrl+T` (new tab), `Ctrl+W` (close tab), `Ctrl+Tab` (next tab) shortcuts
+- Add `enableTabs` prop (default: false) to opt-in
+- Add `onTabChange` callback prop
+
+#### D3-D4: Advanced Drag & Drop
+- Add external file drop support (drag files from OS desktop into file manager)
+- Add visual drop zone indicators with animation (expand folder on hover-hold for 1s)
+- Add drag-to-breadcrumb support (drop onto breadcrumb segments to move files)
+- Add drag-to-navigation-pane support (drop onto folder tree nodes)
+- Add multi-file drag preview showing count badge (e.g., "3 items")
+- Add drag scroll (auto-scroll when dragging near edges)
+- Add `onExternalDrop` callback prop for handling OS file drops
+- Improve drag ghost image with file icons and count
+
+#### D5-D6: Batch Operations & Progress
+- Add batch operation progress modal for long-running operations
+- Show individual file progress + overall progress for bulk copy/move/delete
+- Add cancel button for in-progress batch operations
+- Add operation queue system (operations run sequentially, show queue status)
+- Add `onOperationProgress` callback prop
+- Add retry failed operations within the batch modal
+- Show operation summary on completion (X succeeded, Y failed, Z skipped)
+
+#### D7-D8: Tagging System
+- Add ability to tag files with colored labels
+- Predefined tag colors: red, orange, yellow, green, blue, purple, gray
+- Add tag assignment via context menu > "Tag" submenu
+- Add tag filter in search/filter system
+- Add `tags` field to `File` type interface
+- Add `onTagChange` callback prop
+- Show tags as colored dots/pills on file items (both grid and list view)
+- Support multiple tags per file
+
+#### D9: Column Customization (List View)
+- Add ability to show/hide columns (right-click header or settings menu)
+- Default columns: Name, Modified, Size
+- Additional columns: Type, Tags, Path, Created Date
+- Add column reorder via drag-and-drop on headers
+- Persist column configuration via `onColumnConfigChange` callback
+- Add `columns` prop to set initial visible columns
+- Add auto-resize column to fit content
+
+#### D10: Clipboard Enhancements
+- Add visual clipboard indicator showing what's in clipboard (floating chip)
+- Show source path and operation type (copy/move) in clipboard chip
+- Add clipboard clear button
+- Add paste destination conflict resolution dialog (Replace, Skip, Keep Both)
+- Handle duplicate name resolution on paste with auto-rename (e.g., "file (1).txt")
+- Add cross-instance clipboard support via `onClipboardChange` callback
+
+### UX Team (5 engineers)
+
+#### UX1: Tab Navigation UX
+- Design tab bar with overflow handling (scroll arrows or dropdown for many tabs)
+- Design tab drag-to-reorder interaction
+- Design tab context menu (Close, Close Others, Close All, Duplicate)
+- Design tab loading state and active indicator
+- Design tab appearance for narrow widths
+
+#### UX2: Advanced Drag & Drop UX
+- Design enhanced drag ghost/preview with file count badge
+- Design breadcrumb drop zone highlighting
+- Design folder auto-expand on hover during drag
+- Design drag-scroll indicators at container edges
+- Design drop zone visual feedback (accepted/rejected states with color coding)
+
+#### UX3: Batch Operations UX
+- Design batch progress modal with individual + aggregate progress
+- Design operation queue visualization
+- Design error state within batch operations
+- Design completion summary view
+- Design cancel confirmation dialog
+
+#### UX4: Tagging System UX
+- Design tag color palette and selection UI
+- Design tag display on file items (grid + list compact view)
+- Design tag management panel (create, edit, delete tags)
+- Design tag filter integration in search bar
+- Design multi-tag assignment interaction
+
+#### UX5: Responsive & Mobile Design
+- Design mobile-optimized layout (< 768px)
+- Design touch-friendly file selection (long press for multi-select)
+- Design bottom sheet action menu for mobile (replacing context menu)
+- Design mobile navigation (full-screen navigation pane overlay)
+- Design responsive toolbar collapsing and overflow menu
+
+---
+
+## Iteration 4: Polish & Enterprise Features (Weeks 10-12)
+
+> **Theme: "Production Ready"**
+> Enterprise-grade features, responsiveness, plugin system, and final polish.
+
+### Dev Team (10 developers)
+
+#### D1-D2: Responsive Design Implementation
+- Implement mobile layout (< 768px): full-width file list, stacked toolbar
+- Implement tablet layout (768px-1024px): collapsible nav, condensed toolbar
+- Replace context menu with bottom sheet on touch devices
+- Implement touch gestures: long press to select, swipe to reveal actions
+- Test and fix all interactions on iOS Safari and Android Chrome
+- Add `responsive` prop (default: true) to enable/disable responsive behavior
+- Handle virtual keyboard properly for rename/search inputs on mobile
+
+#### D3-D4: Plugin / Extension System
+- Design a plugin registration API: `FileManager.registerPlugin(plugin)`
+- Plugin hooks: `onBeforeAction`, `onAfterAction`, `onRender`, `onContextMenu`
+- Allow plugins to add context menu items
+- Allow plugins to add toolbar buttons
+- Allow plugins to add custom file preview handlers
+- Allow plugins to add custom columns in list view
+- Provide plugin access to file manager state (read-only) and actions (dispatch)
+- Document the plugin API with TypeScript interfaces
+
+#### D5-D6: Advanced Theming
+- Implement full dark mode with `theme` prop ("light" | "dark" | "system")
+- Implement theme CSS custom properties for all token values
+- Support custom theme objects: `theme={{ colors: {...}, spacing: {...} }}`
+- Add smooth theme transition with CSS transitions (no flash)
+- Add high-contrast mode for accessibility
+- Add `onThemeChange` callback
+- Ensure all component states look correct in all theme variants
+
+#### D7-D8: Documentation & Developer Experience
+- Create comprehensive Storybook with all components
+- Document every prop with examples in Storybook stories
+- Create interactive playground component for testing configurations
+- Write migration guide for each major version
+- Add JSDoc comments to all public APIs
+- Generate API documentation from TypeScript types
+- Create example integrations: Next.js, Remix, Vite, CRA
+- Performance benchmarking docs (file count limits, memory usage)
+
+#### D9: Error Handling & Resilience
+- Add error boundaries around each major section (toolbar, nav, file list, preview)
+- Implement graceful degradation (if nav fails, file list still works)
+- Add retry logic for failed file operations
+- Add comprehensive error states with recovery actions
+- Add `onError` callback enhancement with error codes and categories
+- Handle edge cases: empty file names, very long paths, special characters, concurrent operations
+- Add rate limiting for rapid user actions (debounce rapid clicks)
+
+#### D10: Build & Package Optimization
+- Set up tree-shaking optimizations for the published package
+- Create sub-path exports: `@cubone/react-file-manager/icons`, `.../utils`, `.../hooks`
+- Externalize `react-icons` and allow consumers to provide their own icon set
+- Add ESM and CJS dual package exports
+- Minimize CSS output, support CSS modules export
+- Add bundle analysis to CI (bundlephobia-compatible)
+- Ensure zero runtime warnings in React strict mode
+- Target final bundle: < 50KB gzipped (JS) + < 10KB gzipped (CSS)
+
+### UX Team (5 engineers)
+
+#### UX1: Final Visual Polish
+- Pixel-perfect audit of all components in both themes
+- Ensure consistent spacing using design tokens throughout
+- Polish all hover, focus, active, and disabled states
+- Finalize empty states and error states with illustrations
+- Add subtle shadows and depth for floating elements (menus, modals, toasts)
+
+#### UX2: Animation Polish
+- Implement all designed micro-interactions from Iteration 1
+- Fine-tune animation durations and easing curves
+- Add `prefers-reduced-motion` support (disable animations)
+- Ensure animations don't block interaction (< 200ms for interactive elements)
+- Add loading skeleton animations for perceived performance
+
+#### UX3: Accessibility Audit
+- Full WCAG 2.1 AA compliance audit
+- Screen reader testing (NVDA, VoiceOver, JAWS)
+- Keyboard-only navigation testing for all flows
+- Color contrast verification for both themes (4.5:1 minimum)
+- Verify all interactive elements have accessible names
+- Test with browser zoom (up to 200%)
+
+#### UX4: User Testing & Iteration
+- Conduct usability testing on 5 core workflows:
+ 1. Upload and organize files into folders
+ 2. Find a specific file using search and filters
+ 3. Bulk select, copy, and paste files
+ 4. Preview and download files
+ 5. Navigate using keyboard only
+- Document findings and create fix backlog
+- Iterate on top 5 usability issues found
+
+#### UX5: Design Documentation
+- Create complete component design specifications
+- Document all interaction patterns and behaviors
+- Create theming guide for consumers
+- Document responsive breakpoints and adaptation rules
+- Create contribution design guidelines
+
+---
+
+## Iteration 5: Ecosystem & Scalability (Weeks 13-15)
+
+> **Theme: "Scale Without Limits"**
+> Handle massive file systems, real-time updates, and enterprise integrations.
+
+### Dev Team (10 developers)
+
+#### D1-D2: Virtual File System & Lazy Loading
+- Implement on-demand directory loading (only fetch children when a folder is opened)
+- Add `onDirectoryLoad` callback prop for lazy folder content fetching
+- Support pagination for large directories (load 100 items at a time, load more on scroll)
+- Add loading state per directory (skeleton items while loading)
+- Implement file count indicators on folders without loading all children
+- Cache loaded directories to avoid re-fetching
+- Support `total` field on directory responses for proper scroll sizing
+
+#### D3-D4: Real-Time Integration Hooks
+- Add `onFileSystemChange` event system for external updates
+- Support operations: `fileAdded`, `fileRemoved`, `fileRenamed`, `fileMoved`, `fileModified`
+- Automatically update UI when external changes are pushed in
+- Add optimistic updates for local operations (instant UI, reconcile on server response)
+- Add conflict detection (file changed by another user while editing)
+- Add `collaborators` field showing who else is viewing a directory
+- Add presence indicators (colored dots) on files being viewed/edited by others
+
+#### D5-D6: Storage Provider Abstractions
+- Define a `StorageProvider` interface for pluggable backends
+- Build reference implementations:
+ - `LocalStorageProvider` (browser-based, for demos)
+ - `RESTStorageProvider` (generic REST API adapter)
+ - `S3StorageProvider` (AWS S3-compatible adapter)
+- Storage provider handles: list, create, rename, delete, move, copy, upload, download
+- Provider config via `storageProvider` prop
+- Document custom provider creation
+
+#### D7-D8: Security & Permissions v2
+- Add file-level permissions (per-file `permissions` field)
+- Support permission inheritance from parent folders
+- Add `role` support: viewer, editor, admin with predefined permission sets
+- Add visual lock indicators on restricted files/folders
+- Add `onPermissionDenied` callback
+- Prevent drag-drop into restricted folders
+- Sanitize file names on create/rename (prevent path traversal, XSS in names)
+
+#### D9-D10: Comprehensive E2E Testing & Performance
+- Set up Playwright for end-to-end testing
+- Write E2E tests for all 5 core user workflows
+- Write E2E tests for keyboard navigation and accessibility
+- Performance test with 10,000 files in a single directory (virtual scrolling)
+- Performance test with 100 levels of nesting
+- Memory leak testing for long-running sessions
+- Cross-browser testing: Chrome, Firefox, Safari, Edge
+- Add performance budgets to CI (Lighthouse, bundle size checks)
+
+### UX Team (5 engineers)
+
+#### UX1: Large-Scale Data UX
+- Design progressive loading patterns for large directories
+- Design pagination indicators and "load more" triggers
+- Design search within large datasets (results streaming)
+- Design file count badges on folders
+- Design virtual scroll smoothness and overscroll behavior
+
+#### UX2: Collaboration UX
+- Design presence indicators for multiple users
+- Design conflict resolution dialogs
+- Design real-time file change notifications
+- Design "file in use" visual indicators
+- Design collaborative breadcrumb showing other users' locations
+
+#### UX3: Permission UX
+- Design locked/restricted file visual states
+- Design permission denied feedback (inline, not modal)
+- Design admin vs. viewer role visual differences
+- Design permission tooltip on hover
+- Design restricted zone visual treatment in navigation
+
+#### UX4: Onboarding & Help
+- Design first-time user walkthrough overlay
+- Design keyboard shortcut reference sheet (triggered by `?`)
+- Design contextual help tooltips for toolbar items
+- Design "What's New" notification for updates
+- Design error recovery guidance flows
+
+#### UX5: Brand & Marketing Assets
+- Create demo page design for the npm package
+- Design comparison table vs. other file managers
+- Create animated GIF/video demos for README
+- Design feature highlight cards for documentation
+- Create consistent screenshots across all themes
+
+---
+
+## Summary Roadmap
+
+| Iteration | Theme | Key Deliverables | Dev Focus | UX Focus |
+|-----------|-------|------------------|-----------|----------|
+| **1** | Foundation & Quality | TypeScript, tests, a11y, perf, design tokens, dark mode design | Migration + infrastructure | Design system |
+| **2** | Core Feature Expansion | Search, details panel, thumbnails, favorites, toasts, status bar | New features | Feature UX |
+| **3** | Advanced Interactions | Tabs, advanced DnD, batch ops, tags, column config, clipboard | Power user features | Interaction design |
+| **4** | Polish & Enterprise | Responsive, plugins, theming, docs, error handling, bundle size | Production readiness | Polish & testing |
+| **5** | Ecosystem & Scale | Virtual FS, real-time, storage providers, security v2, E2E | Scalability | Collaboration UX |
+
+---
+
+## Key Metrics Per Iteration
+
+| Metric | Iter 1 | Iter 2 | Iter 3 | Iter 4 | Iter 5 |
+|--------|--------|--------|--------|--------|--------|
+| Test Coverage | 80% | 85% | 88% | 92% | 95% |
+| Bundle Size (gzipped) | 45KB | 50KB | 55KB | <50KB | <50KB |
+| Lighthouse A11y Score | 85 | 90 | 92 | 98 | 98 |
+| WCAG Compliance | Partial AA | Partial AA | AA | Full AA | Full AA |
+| Supported File Count | 1K | 5K | 10K | 50K | 100K+ |
+| Locale Count | 24 | 24 | 26 | 28 | 30 |
+
+---
+
+## Risk Mitigation
+
+| Risk | Impact | Mitigation |
+|------|--------|------------|
+| TypeScript migration breaks existing consumers | High | Ship as major version (v2.0.0), maintain v1.x LTS branch |
+| Bundle size grows beyond acceptable limits | Medium | Tree-shake aggressively, make features opt-in via props |
+| Performance degrades with new features | High | Performance budgets in CI, benchmark on every PR |
+| Breaking API changes across iterations | High | Follow semver strictly, deprecate before removing |
+| Plugin system security vulnerabilities | Medium | Sandbox plugin execution, validate plugin inputs |
+
+---
+
+## Release Strategy
+
+- **Iteration 1** -> `v2.0.0-alpha.1` (TypeScript, tests, a11y foundations)
+- **Iteration 2** -> `v2.0.0-beta.1` (Search, details, thumbnails, toasts)
+- **Iteration 3** -> `v2.0.0-rc.1` (Tabs, advanced DnD, tags)
+- **Iteration 4** -> `v2.0.0` (Stable release with full polish)
+- **Iteration 5** -> `v2.1.0` (Scalability features as minor release)
diff --git a/README.md b/README.md
index 5334036a..ef43158b 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,29 @@ An open-source React.js package for easy integration of a file manager into appl
- **Column Sorting**: Click on column headers in list view to sort files by name, modified date, or
size. Click again to toggle between ascending (▲) and descending (▼) order. Folders are always
displayed before files regardless of the sort criteria.
+- **Search & Filter**: Press `Ctrl + F` to open the search bar with fuzzy matching. Filter results
+ by file type (Images, Documents, Videos, Audio, Code, Archives) or by date (This Week). Toggle
+ recursive search to search across all folders.
+- **Details Panel**: A resizable sidebar that displays file metadata including name, type, size,
+ path, and modification date. Supports single-file, multi-selection, and folder summary views.
+ Toggle with `Alt + P` or the toolbar button.
+- **Advanced File Preview**: Enhanced previewer with file navigation (Previous/Next), zoom controls,
+ fullscreen mode, and built-in viewers for code files (with syntax highlighting and line numbers),
+ Markdown, and CSV/TSV files.
+- **Favorites & Quick Access**: Star files and folders for quick access from the navigation pane.
+ Automatically tracks recently accessed files with time-based grouping (Today, Yesterday, This
+ Week, Earlier).
+- **Theming & Theme Injection**: Built-in light and dark themes with a comprehensive design token
+ system. Supports `"light"`, `"dark"`, and `"system"` modes. Use `customTokens` to inject your own
+ design tokens and embed the file manager into any app's design system.
+- **Multi-Tab Navigation**: Open folders in new tabs for parallel browsing. Reorder tabs via
+ drag-and-drop. Keyboard shortcuts: `Ctrl+T`, `Ctrl+W`, `Ctrl+Tab`.
+- **Tagging System**: Tag files with colored labels. 7 predefined colors or custom definitions.
+ Assign via context menu, display as colored dots on file items.
+- **Batch Operations**: Progress modal for bulk copy/move/delete with individual + overall progress,
+ cancel support, and completion summaries.
+- **Clipboard Indicator**: Floating chip showing clipboard contents (cut/copy count and type).
+- **External File Drop**: Drag files from the OS desktop into the file manager.

@@ -106,39 +129,58 @@ type File = {
| `acceptedFileTypes` | string | (Optional) A comma-separated list of allowed file extensions for uploading specific file types (e.g., `.txt, .png, .pdf`). If omitted, all file types are accepted. |
| `className` | string | CSS class names to apply to the FileManager root element. |
| `collapsibleNav` | boolean | Enables a collapsible navigation pane on the left side. When `true`, a toggle will be shown to expand or collapse the navigation pane. `default: false`. |
+| `columns` | Array\ | (Optional) Initial visible columns in list view. Available columns: `"name"`, `"modified"`, `"size"`, `"type"`, `"tags"`, `"path"`. `default: ["name", "modified", "size"]`. |
+| `customTokens` | object | (Optional) An object of CSS custom property overrides for design tokens. Keys can be bare names (e.g., `"color-bg"`) or full CSS variable names (e.g., `"--fm-color-bg"`). Bare names are auto-prefixed with `--fm-`. See [Theme Injection](#-theme-injection) for details. |
+| `defaultDetailsPanelOpen` | boolean | Sets the initial open state of the details panel. `default: false`. |
| `defaultNavExpanded` | boolean | Sets the default expanded (`true`) or collapsed (`false`) state of the navigation pane when `collapsibleNav` is enabled. This only affects the initial render. `default: true`. |
| `enableFilePreview` | boolean | A boolean flag indicating whether to use the default file previewer in the file manager `default: true`. |
+| `enableTabs` | boolean | (Optional) Enables multi-tab navigation above the breadcrumb area. Each tab maintains its own path. `default: false`. |
| `filePreviewPath` | string | The base URL for file previews e.g.`https://example.com`, file path will be appended automatically to it i.e. `https://example.com/yourFilePath`. |
| `filePreviewComponent` | (file: [File](#-file-structure)) => React.ReactNode | (Optional) A callback function that provides a custom file preview. It receives the selected file as its argument and must return a valid React node, JSX element, or HTML. Use this prop to override the default file preview behavior. Example: [Custom Preview Usage](#custom-file-preview). |
| `fileUploadConfig` | { url: string; method?: "POST" \| "PUT"; headers?: { [key: string]: string }; withCredentials?: boolean } | Configuration object for file uploads. It includes the upload URL (`url`), an optional HTTP method (`method`, default is `"POST"`), and an optional `headers` object for setting custom HTTP headers in the upload request. The `method` property allows only `"POST"` or `"PUT"` values. The `headers` object can accept any standard or custom headers required by the server. The `withCredentials` property allows sending HTTP cookies in the request. Example: `{ url: "https://example.com/fileupload", method: "PUT", headers: { Authorization: "Bearer " + TOKEN, "X-Custom-Header": "value" }, withCredentials: true }` |
| `files` | Array<[File](#-file-structure)> | An array of file and folder objects representing the current directory structure. Each object includes `name`, `isDirectory`, and `path` properties. |
| `fontFamily` | string | The font family to be used throughout the component. Accepts any valid CSS font family (e.g., `'Arial, sans-serif'`, `'Roboto'`). You can customize the font styling to match your application's theme. `default: 'Nunito Sans, sans-serif'`. |
+| `formatDate` | (date: string) => string | (Optional) A custom date formatting function. Receives an ISO 8601 date string and should return a formatted string. Used in both the file list and the details panel. Falls back to the built-in formatter if not provided. |
| `height` | string \| number | The height of the component `default: 600px`. Can be a string (e.g., `'100%'`, `'10rem'`) or a number (in pixels). |
+| `initialFavorites` | Array\ | (Optional) An array of file/folder paths to pre-populate as favorites on mount. Use this to restore previously saved favorites (e.g., from localStorage or a server). |
| `initialPath` | string | The path of the directory to be loaded initially e.g. `/Documents`. This should be the path of a folder which is included in `files` array. Default value is `""` |
| `isLoading` | boolean | A boolean state indicating whether the application is currently performing an operation, such as creating, renaming, or deleting a file/folder. Displays a loading state if set `true`. |
| `language` | string | A language code used for translations (e.g., `"en-US"`, `"fr-FR"`, `"tr-TR"`). Defaults to `"en-US"` for English. Allows the user to set the desired translation language manually. **Available languages:** 🇸🇦 `ar-SA` (Arabic, Saudi Arabia) 🇩🇰 `da-DK` (Danish, Denmark) 🇩🇪 `de-DE` (German, Germany) 🇺🇸 `en-US` (English, United States) 🇪🇸 `es-ES` (Spanish, Spain) 🇮🇷 `fa-IR` (Persian, Iran) 🇫🇮 `fi-FI` (Finnish, Finland) 🇫🇷 `fr-FR` (French, France) 🇮🇱 `he-IL` (Hebrew, Israel) 🇮🇳 `hi-IN` (Hindi, India) 🇮🇹 `it-IT` (Italian, Italy) 🇯🇵 `ja-JP` (Japanese, Japan) 🇰🇷 `ko-KR` (Korean, South Korea) 🇳🇴 `nb-NO` (Norwegian, Norway) 🇧🇷 `pt-BR` (Portuguese, Brazil) 🇵🇹 `pt-PT` (Portuguese, Portugal) 🇷🇺 `ru-RU` (Russian, Russia) 🇸🇪 `sv-SE` (Swedish, Sweden) 🇹🇷 `tr-TR` (Turkish, Turkey) 🇺🇦 `uk-UA` (Ukrainian, Ukraine) 🇵🇰 `ur-UR` (Urdu, Pakistan) 🇻🇳 `vi-VN` (Vietnamese, Vietnam) 🇨🇳 `zh-CN` (Chinese, Simplified) 🇵🇱 `pl-PL` (Polish, Poland) |
| `layout` | "list" \| "grid" | Specifies the default layout style for the file manager. Can be either "list" or "grid". Default value is "grid". |
| `maxFileSize` | number | For limiting the maximum upload file size in bytes. |
+| `maxTabs` | number | (Optional) Maximum number of tabs allowed when `enableTabs` is true. `default: 10`. |
+| `onClipboardChange` | (clipboard: object \| null) => void | (Optional) A callback triggered when clipboard contents change (cut/copy). Receives the clipboard state or null when cleared. |
+| `onColumnConfigChange` | (visibleColumns: Array\) => void | (Optional) A callback triggered when column visibility changes in list view. Use to persist the user's column preferences. |
| `onCopy` | (files: Array<[File](#-file-structure)>) => void | (Optional) A callback function triggered when one or more files or folders are copied providing copied files as an argument. Use this function to perform custom actions on copy event. |
| `onCut` | (files: Array<[File](#-file-structure)>) => void | (Optional) A callback function triggered when one or more files or folders are cut, providing the cut files as an argument. Use this function to perform custom actions on the cut event. |
| `onCreateFolder` | (name: string, parentFolder: [File](#-file-structure)) => void | A callback function triggered when a new folder is created. Use this function to update the files state to include the new folder under the specified parent folder using create folder API call to your server. |
| `onDelete` | (files: Array<[File](#-file-structure)>) => void | A callback function is triggered when one or more files or folders are deleted. |
| `onDownload` | (files: Array<[File](#-file-structure)>) => void | A callback function triggered when one or more files or folders are downloaded. |
| `onError` | (error: { type: string, message: string }, file: [File](#-file-structure)) => void | A callback function triggered whenever there is an error in the file manager. Where error is an object containing `type` ("upload", etc.) and a descriptive error `message`. |
+| `onExternalDrop` | (files: File[], event: DragEvent) => void | (Optional) A callback triggered when files are dropped from the OS desktop into the file manager. Receives the dropped File objects and the native DragEvent. |
+| `onFavoriteToggle` | (file: [File](#-file-structure), isFavorited: boolean) => void | (Optional) A callback triggered when a file or folder is starred or unstarred. Receives the file and the new favorited state. Use this to persist favorites to your backend or localStorage. |
+| `onFileDetails` | (files: Array<[File](#-file-structure)>) => void | (Optional) A callback triggered when the details panel opens or when the selection changes while it is open. Receives the currently selected files. |
| `onFileOpen` | (file: [File](#-file-structure)) => void | A callback function triggered when a file or folder is opened. |
| `onFolderChange` | (path: string) => void | A callback function triggered when the active folder changes. Receives the full path of the current folder as a string parameter. Useful for tracking the active folder path. |
| `onFileUploaded` | (response: { [key: string]: any }) => void | A callback function triggered after a file is successfully uploaded. Provides JSON `response` holding uploaded file details, use it to extract the uploaded file details and add it to the `files` state e.g. `setFiles((prev) => [...prev, JSON.parse(response)]);` |
| `onFileUploading` | (file: [File](#-file-structure), parentFolder: [File](#-file-structure)) => { [key: string]: any } | A callback function triggered during the file upload process. You can also return an object with key-value pairs that will be appended to the `FormData` along with the file being uploaded. The object can contain any number of valid properties. |
| `onLayoutChange` | (layout: "list" \| "grid") => void | A callback function triggered when the layout of the file manager is changed. |
+| `onOperationProgress` | (event: object) => void | (Optional) A callback triggered during batch operations (copy/move/delete). Receives progress events with type, status, and item details. |
| `onPaste` | (files: Array<[File](#-file-structure)>, destinationFolder: [File](#-file-structure), operationType: "copy" \| "move") => void | A callback function triggered when when one or more files or folders are pasted into a new location. Depending on `operationType`, use this to either copy or move the `sourceItem` to the `destinationFolder`, updating the files state accordingly. |
+| `onRecentFiles` | (recentFiles: Array<[File](#-file-structure)>) => void | (Optional) A callback triggered whenever the recent files list changes. Receives the updated array of recently accessed files. Use this to persist recents to your backend or localStorage. |
| `onRefresh` | () => void | A callback function triggered when the file manager is refreshed. Use this to refresh the `files` state to reflect any changes or updates. |
| `onRename` | (file: [File](#-file-structure), newName: string) => void | A callback function triggered when a file or folder is renamed. |
+| `onSearch` | (query: string, filters: Array\) => void | (Optional) A callback triggered when the search query or filters change. Receives the query string and active filter names. Use this to implement server-side search. |
+| `onTabChange` | (event: { type: string, tabId?: number, path?: string }) => void | (Optional) A callback triggered when tabs are added, closed, or switched. Only fires when `enableTabs` is true. |
+| `onTagChange` | (file: [File](#-file-structure), tagName: string, action: "add" \| "remove") => void | (Optional) A callback triggered when a tag is added or removed from a file. Use this to persist tag assignments. |
| `onSelectionChange` | (files: Array<[File](#-file-structure)>) => void | (Optional) A callback triggered whenever a file or folder is **selected or deselected**. The function receives the updated array of selected files or folders, allowing you to handle selection-related actions such as displaying file details, enabling toolbar actions, or updating the UI. |
| `onSelect`⚠️(deprecated) | (files: Array<[File](#-file-structure)>) => void | (Optional) Legacy callback triggered only when a file or folder is **selected**. This prop is deprecated and will be removed in the next major release. Please migrate to `onSelectionChange`. |
| `onSortChange` | (sortConfig: { key: "name" \| "modified" \| "size", direction: "asc" \| "desc" }) => void | (Optional) A callback function triggered when the sorting order changes. Receives the new sort configuration with the column key and direction. Useful for persisting sort preferences or updating external state. |
| `permissions` | { create?: boolean; upload?: boolean; move?: boolean; copy?: boolean; rename?: boolean; download?: boolean; delete?: boolean; } | An object that controls the availability of specific file management actions. Setting an action to `false` hides it from the toolbar, context menu, and any relevant UI. All actions default to `true` if not specified. This is useful for implementing role-based access control or restricting certain operations. Example: `{ create: false, delete: false }` disables folder creation and file deletion. |
| `primaryColor` | string | The primary color for the component's theme. Accepts any valid CSS color format (e.g., `'blue'`, `'#E97451'`, `'rgb(52, 152, 219)'`). This color will be applied to buttons, highlights, and other key elements. `default: #6155b4`. |
| `style` | object | Inline styles applied to the FileManager root element. |
+| `tags` | Array\<{ name: string, color: string }\> | (Optional) Available tag definitions for the tagging system. Each tag has a `name` and CSS `color`. Defaults to 7 predefined colors (Red, Orange, Yellow, Green, Blue, Purple, Gray). |
+| `theme` | "light" \| "dark" \| "system" | Sets the color theme. `"light"` and `"dark"` apply the respective theme directly. `"system"` auto-detects the user's OS preference via `prefers-color-scheme`. `default: "light"`. |
| `width` | string \| number | The width of the component `default: 100%`. Can be a string (e.g., `'100%'`, `'10rem'`) or a number (in pixels). |
## ⌨️ Keyboard Shortcuts
@@ -161,6 +203,13 @@ type File = {
| Jump to First File in the List | `Home` |
| Jump to Last File in the List | `End` |
| Refresh File List | `F5` |
+| Search | `CTRL + F` |
+| Toggle Details Panel | `Alt + P` |
+| New Tab | `CTRL + T` |
+| Close Tab | `CTRL + W` |
+| Next Tab | `CTRL + Tab` |
+| Previous File (in preview) | `Left Arrow` |
+| Next File (in preview) | `Right Arrow` |
| Clear Selection | `Esc` |
## 🛡️ Permissions
@@ -239,6 +288,208 @@ function App() {
- If you want to keep the path in sync with user navigation, use a controlled state (as shown
above).
+## 🔍 Search & Filter
+
+Press `Ctrl + F` or click the search icon in the toolbar to open the search bar. Search supports
+fuzzy case-insensitive matching across file names.
+
+### Filter chips
+
+Click filter chips below the search bar to narrow results by file type or date:
+
+- **Type filters**: Images, Documents, Videos, Audio, Code, Archives
+- **Date filter**: This Week (files modified in the last 7 days)
+
+Multiple filters can be active at once. Filters combine with the search query.
+
+### Recursive search
+
+Toggle "Search all folders" to search the entire file tree instead of just the current directory.
+
+### Server-side search
+
+Use the `onSearch` callback to delegate search to your backend:
+
+```jsx
+ {
+ // Call your search API
+ fetchSearchResults(query, filters).then(setFiles);
+ }}
+/>
+```
+
+## 📋 Details Panel
+
+The details panel is a resizable sidebar that shows metadata for the current selection. Toggle it
+with `Alt + P`, the toolbar button, or set it open by default:
+
+```jsx
+ {
+ console.log("Inspecting:", selectedFiles);
+ }}
+/>
+```
+
+The panel displays different information based on the selection:
+
+- **No selection**: Current folder summary (file count, folder count, path)
+- **Single file/folder**: Name, type, size, path, modification date
+- **Multiple selection**: Count of selected items, total size, breakdown of files and folders
+
+The panel is resizable by dragging its left edge (200px to 50% of the container width).
+
+## ⭐ Favorites & Quick Access
+
+Users can star files and folders to add them to the Quick Access section in the navigation pane.
+Recent files are tracked automatically.
+
+```jsx
+ {
+ // Persist to your backend or localStorage
+ saveFavorites(file.path, isFavorited);
+ }}
+ onRecentFiles={(recentFiles) => {
+ localStorage.setItem("recents", JSON.stringify(recentFiles));
+ }}
+/>
+```
+
+The Quick Access section in the navigation pane shows two collapsible groups:
+
+- **Quick Access**: All favorited files and folders
+- **Recent**: Up to 20 recently accessed files, grouped by Today, Yesterday, This Week, and Earlier
+
+## 🎨 Theming
+
+The file manager supports light and dark themes via the `theme` prop:
+
+```jsx
+// Explicit theme
+
+
+// Auto-detect from OS preference
+
+```
+
+The component uses CSS custom properties (design tokens) for all colors, spacing, typography, and
+shadows. Dark theme overrides are applied automatically. The `"system"` option uses
+`prefers-color-scheme` to match the user's OS setting. Animations respect `prefers-reduced-motion`.
+
+### Theme Injection
+
+Use the `customTokens` prop to override any design token and embed the file manager into your app's
+design system. Keys can be bare names or full CSS variable names:
+
+```jsx
+
+```
+
+All token names are listed in `src/styles/_tokens.scss`. Common token groups:
+
+- **Colors**: `color-bg`, `color-text-primary`, `color-primary`, `color-border`, `color-surface-*`
+- **Spacing**: `space-1` through `space-16` (4px increments)
+- **Typography**: `font-size-xs` through `font-size-3xl`, `font-weight-*`
+- **Border radius**: `radius-sm`, `radius-md`, `radius-lg`, `radius-xl`, `radius-full`
+- **Shadows**: `shadow-sm`, `shadow-md`, `shadow-lg`, `shadow-xl`
+
+## 📑 Multi-Tab Navigation
+
+Enable tabbed browsing to let users open multiple folders simultaneously:
+
+```jsx
+ {
+ console.log(event.type, event.path); // "add" | "close" | "switch"
+ }}
+/>
+```
+
+Features:
+- **Ctrl+Click** or context menu "Open in New Tab" to open folders in new tabs
+- Tabs are reorderable via drag-and-drop
+- Close tabs with the X button or middle-click
+- Keyboard shortcuts: `Ctrl+T` (new tab), `Ctrl+W` (close tab), `Ctrl+Tab` (next tab)
+
+## 🏷 Tagging System
+
+Tag files with colored labels for organization:
+
+```jsx
+ {
+ console.log(`${action} tag "${tagName}" on ${file.name}`);
+ }}
+/>
+```
+
+Features:
+- 7 predefined tag colors (Red, Orange, Yellow, Green, Blue, Purple, Gray) or custom definitions
+- Assign tags via the context menu "Tag" submenu
+- Multiple tags per file supported
+- Tag badges displayed as colored dots on file items in both grid and list views
+
+## 📊 Batch Operations
+
+Long-running bulk operations (copy, move, delete) show a progress modal:
+
+```jsx
+ {
+ if (event.type === "progress") {
+ console.log(`Item ${event.itemId}: ${event.status}`);
+ }
+ }}
+/>
+```
+
+The progress modal shows:
+- Individual file progress and overall percentage
+- Cancel button for in-progress operations
+- Completion summary (X succeeded, Y failed, Z skipped)
+
+## 📎 Clipboard Indicator
+
+A floating chip displays the current clipboard contents when files are cut or copied, showing the
+file count and operation type (copy/move). Click the X button to clear the clipboard.
+
+## 📁 External File Drop
+
+Accept files dragged from the OS desktop into the file manager:
+
+```jsx
+ {
+ // Handle dropped files (e.g., upload them)
+ files.forEach((file) => uploadFile(file));
+ }}
+/>
+```
+
## 🤝 Contributing
Contributions are welcome! To contribute:
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 49bb3aa6..05fe3684 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,22 +12,29 @@
"@fontsource/nunito-sans": "^5.2.7",
"i18next": "^25.0.0",
"react-collapsed": "^4.2.0",
- "react-icons": "^5.4.0"
+ "react-icons": "^5.4.0",
+ "react-window": "^2.2.7"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
+ "@vitest/coverage-v8": "^4.0.18",
"axios": "^1.7.7",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.2",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
+ "jsdom": "^28.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sass": "^1.77.6",
"semantic-release": "^24.1.0",
- "vite": "^6.4.1"
+ "vite": "^6.4.1",
+ "vitest": "^4.0.18"
},
"peerDependencies": {
"react": ">=18",
@@ -42,6 +49,75 @@
}
}
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.31",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz",
+ "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz",
+ "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.0.0",
+ "@csstools/css-color-parser": "^4.0.1",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0",
+ "lru-cache": "^11.2.5"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "6.8.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz",
+ "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.1.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.2.6"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@@ -57,16 +133,42 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.25.9",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
- "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@babel/runtime": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
@@ -79,6 +181,43 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
@@ -90,6 +229,138 @@
"node": ">=0.1.90"
}
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz",
+ "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
+ "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz",
+ "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.0.1",
+ "@csstools/css-calc": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.0.27",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz",
+ "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0"
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
@@ -578,6 +849,24 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz",
+ "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@fontsource/nunito-sans": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/nunito-sans/-/nunito-sans-5.2.7.tgz",
@@ -625,6 +914,34 @@
"dev": true,
"license": "BSD-3-Clause"
},
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -679,7 +996,6 @@
"integrity": "sha512-z+j7DixNnfpdToYsOutStDgeRzJSMnbj8T1C/oQjB6Aa+kRfNjs/Fn7W6c8bmlt6mfy3FkgeKBRnDjxQow5dow==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@octokit/auth-token": "^5.0.0",
"@octokit/graphql": "^8.1.2",
@@ -1682,6 +1998,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@swc/core": {
"version": "1.10.9",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.9.tgz",
@@ -1908,6 +2231,122 @@
"@swc/counter": "^0.1.3"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@@ -1935,47 +2374,188 @@
"integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
- "@types/prop-types": "*",
- "csstype": "^3.0.2"
+ "@types/prop-types": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.5",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz",
+ "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/@types/semver": {
+ "version": "7.5.8",
+ "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
+ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz",
+ "integrity": "sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@vitejs/plugin-react-swc": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.2.tgz",
+ "integrity": "sha512-y0byko2b2tSVVf5Gpng1eEhX1OvPC7x8yns1Fx8jDzlJp4LS6CMkCPfLw47cjyoMrshQDoQw4qcgjsU9VvlCew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@swc/core": "^1.7.26"
+ },
+ "peerDependencies": {
+ "vite": "^4 || ^5 || ^6"
+ }
+ },
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
+ "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.0.18",
+ "ast-v8-to-istanbul": "^0.3.10",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.1",
+ "obug": "^2.1.1",
+ "std-env": "^3.10.0",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.0.18",
+ "vitest": "4.0.18"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
+ "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "chai": "^6.2.1",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
+ "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.0.18",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
+ "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
+ "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.0.18",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/@types/react-dom": {
- "version": "18.3.5",
- "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz",
- "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==",
+ "node_modules/@vitest/snapshot": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
+ "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
"dev": true,
"license": "MIT",
- "peerDependencies": {
- "@types/react": "^18.0.0"
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
- "node_modules/@types/semver": {
- "version": "7.5.8",
- "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
- "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@ungap/structured-clone": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz",
- "integrity": "sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==",
+ "node_modules/@vitest/spy": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
+ "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
"dev": true,
- "license": "ISC"
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
},
- "node_modules/@vitejs/plugin-react-swc": {
- "version": "3.7.2",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.2.tgz",
- "integrity": "sha512-y0byko2b2tSVVf5Gpng1eEhX1OvPC7x8yns1Fx8jDzlJp4LS6CMkCPfLw47cjyoMrshQDoQw4qcgjsU9VvlCew==",
+ "node_modules/@vitest/utils": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
+ "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@swc/core": "^1.7.26"
+ "@vitest/pretty-format": "4.0.18",
+ "tinyrainbow": "^3.0.3"
},
- "peerDependencies": {
- "vite": "^4 || ^5 || ^6"
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
@@ -1984,7 +2564,6 @@
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2112,6 +2691,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@@ -2255,6 +2844,35 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz",
+ "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2304,6 +2922,16 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/bottleneck": {
"version": "2.19.5",
"resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz",
@@ -2395,6 +3023,16 @@
"node": ">=6"
}
},
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -2786,6 +3424,53 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/css-tree": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
+ "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.12.2",
+ "source-map-js": "^1.0.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cssstyle": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.0.1.tgz",
+ "integrity": "sha512-IoJs7La+oFp/AB033wBStxNOJt4+9hHMxsXUPANcoXL2b3W4DZKghlJ2cI/eyeRZIQ9ysvYEorVhjrcYctWbog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^4.1.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.0.26",
+ "css-tree": "^3.1.0",
+ "lru-cache": "^11.2.5"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cssstyle/node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -2793,6 +3478,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -2865,6 +3564,13 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@@ -2928,6 +3634,16 @@
"node": ">=0.4.0"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
@@ -2968,6 +3684,14 @@
"node": ">=6.0.0"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/dot-prop": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
@@ -3020,6 +3744,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/env-ci": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/env-ci/-/env-ci-11.1.0.tgz",
@@ -3283,6 +4020,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -3411,7 +4155,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -3615,6 +4358,16 @@
"node": ">=4.0"
}
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -3669,6 +4422,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fast-content-type-parse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz",
@@ -4397,6 +5160,26 @@
"node": "^18.17.0 || >=20.5.0"
}
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -4901,6 +5684,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -5103,6 +5893,45 @@
"node": "^18.17 || >=20.6.1"
}
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
@@ -5150,6 +5979,60 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "28.1.0",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz",
+ "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.31",
+ "@asamuzakjp/dom-selector": "^6.8.1",
+ "@bramus/specificity": "^2.4.2",
+ "@exodus/bytes": "^1.11.0",
+ "cssstyle": "^6.0.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.6",
+ "is-potential-custom-element-name": "^1.0.1",
+ "parse5": "^8.0.0",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.0",
+ "undici": "^7.21.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/parse5": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
+ "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -5359,13 +6242,74 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/magicast": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
+ "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/marked": {
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
"integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"marked": "bin/marked.js"
},
@@ -5418,6 +6362,13 @@
"node": ">= 0.4"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
+ "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/meow": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz",
@@ -5514,6 +6465,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -8585,6 +9546,17 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -8870,6 +9842,13 @@
"node": ">=8"
}
},
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -9026,6 +10005,55 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/pretty-ms": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz",
@@ -9137,7 +10165,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -9163,7 +10190,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -9188,6 +10214,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/react-window": {
+ "version": "2.2.7",
+ "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz",
+ "integrity": "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/read-package-up": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz",
@@ -9307,6 +10343,30 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/redent/node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -9380,6 +10440,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -9568,7 +10638,6 @@
"integrity": "sha512-B1bozCeNQiOgDcLd33e2Cs2U60wZwjUUXzh900ZyQF5qUasvMdDZYbQ566LJu7cqR+sAHlAfO6RMkaID5s6qpA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -9584,6 +10653,19 @@
"@parcel/watcher": "^2.4.1"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -9599,7 +10681,6 @@
"integrity": "sha512-z0/3cutKNkLQ4Oy0HTi3lubnjTsdjjgOqmxdPjeYWe6lhFqUPfwslZxRHv3HDZlN4MhnZitb9SLihDkZNxOXfQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@semantic-release/commit-analyzer": "^13.0.0-beta.1",
"@semantic-release/error": "^4.0.0",
@@ -9861,6 +10942,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -10079,6 +11167,20 @@
"through2": "~2.0.0"
}
},
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/stream-combiner2": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/stream-combiner2/-/stream-combiner2-1.1.1.tgz",
@@ -10259,6 +11361,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -10332,6 +11447,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/temp-dir": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz",
@@ -10450,15 +11572,32 @@
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"license": "MIT"
},
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
- "version": "0.2.13",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
- "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "fdir": "^6.4.4",
- "picomatch": "^4.0.2"
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
@@ -10468,11 +11607,14 @@
}
},
"node_modules/tinyglobby/node_modules/fdir": {
- "version": "6.4.4",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
- "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -10483,12 +11625,11 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
- "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -10496,6 +11637,36 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/tinyrainbow": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
+ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.0.23",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
+ "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.0.23"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.0.23",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz",
+ "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -10509,6 +11680,32 @@
"node": ">=8.0"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
+ "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/traverse": {
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz",
@@ -10659,6 +11856,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/undici": {
+ "version": "7.22.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
+ "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/unicode-emoji-modifier-base": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz",
@@ -10759,7 +11966,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -10850,7 +12056,6 @@
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -10858,6 +12063,145 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/vitest": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
+ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.0.18",
+ "@vitest/mocker": "4.0.18",
+ "@vitest/pretty-format": "4.0.18",
+ "@vitest/runner": "4.0.18",
+ "@vitest/snapshot": "4.0.18",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "es-module-lexer": "^1.7.0",
+ "expect-type": "^1.2.2",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^3.10.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3",
+ "vite": "^6.0.0 || ^7.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.0.18",
+ "@vitest/browser-preview": "4.0.18",
+ "@vitest/browser-webdriverio": "4.0.18",
+ "@vitest/ui": "4.0.18",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -10962,6 +12306,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -11004,6 +12365,23 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 6963bd20..57517443 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,5 +1,5 @@
{
- "name": "@cubone/react-file-manager",
+ "name": "@sush0408/react-file-manager",
"private": false,
"version": "1.10.5",
"type": "module",
@@ -14,31 +14,43 @@
},
"scripts": {
"dev": "vite",
- "build": "vite build",
+ "build": "tsc --noEmit && vite build",
+ "typecheck": "tsc --noEmit",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
"semantic-release": "semantic-release"
},
"dependencies": {
"@fontsource/nunito-sans": "^5.2.7",
"i18next": "^25.0.0",
"react-collapsed": "^4.2.0",
- "react-icons": "^5.4.0"
+ "react-icons": "^5.4.0",
+ "react-window": "^2.2.7"
},
"devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
+ "@vitest/coverage-v8": "^4.0.18",
"axios": "^1.7.7",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.2",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
+ "jsdom": "^28.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sass": "^1.77.6",
"semantic-release": "^24.1.0",
- "vite": "^6.4.1"
+ "typescript": "^5.5.0",
+ "vite": "^6.4.1",
+ "vitest": "^4.0.18"
},
"peerDependencies": {
"react": ">=18",
@@ -56,18 +68,19 @@
"main": "src/index.js",
"repository": {
"type": "git",
- "url": "git+https://github.com/Saifullah-dev/react-file-manager.git"
+ "url": "git+https://github.com/sush0408/react-file-manager.git"
},
"keywords": [
"react",
"file-manager",
"component",
- "react-file explorer"
+ "react-file-explorer",
+ "file-explorer"
],
- "author": "Saifullah Zubair",
+ "author": "sush0408",
"license": "MIT",
"bugs": {
- "url": "https://github.com/Saifullah-dev/react-file-manager/issues"
+ "url": "https://github.com/sush0408/react-file-manager/issues"
},
- "homepage": "https://github.com/Saifullah-dev/react-file-manager#readme"
+ "homepage": "https://github.com/sush0408/react-file-manager#readme"
}
diff --git a/frontend/src/FileManager/Actions/Actions.jsx b/frontend/src/FileManager/Actions/Actions.jsx
index 54a975bb..1762dc9f 100644
--- a/frontend/src/FileManager/Actions/Actions.jsx
+++ b/frontend/src/FileManager/Actions/Actions.jsx
@@ -1,8 +1,10 @@
-import { useEffect, useState } from "react";
+import { lazy, Suspense, useEffect, useState } from "react";
import Modal from "../../components/Modal/Modal";
-import DeleteAction from "./Delete/Delete.action";
-import UploadFileAction from "./UploadFile/UploadFile.action";
-import PreviewFileAction from "./PreviewFile/PreviewFile.action";
+import Loader from "../../components/Loader/Loader";
+
+const DeleteAction = lazy(() => import("./Delete/Delete.action"));
+const UploadFileAction = lazy(() => import("./UploadFile/UploadFile.action"));
+const PreviewFileAction = lazy(() => import("./PreviewFile/PreviewFile.action"));
import { useSelection } from "../../contexts/SelectionContext";
import { useShortcutHandler } from "../../hooks/useShortcutHandler";
import { useTranslation } from "../../contexts/TranslationProvider";
@@ -78,7 +80,9 @@ const Actions = ({
setShow={triggerAction.close}
dialogWidth={activeAction.width}
>
- {activeAction?.component}
+ }>
+ {activeAction?.component}
+
);
}
diff --git a/frontend/src/FileManager/Actions/PreviewFile/FileViewers.jsx b/frontend/src/FileManager/Actions/PreviewFile/FileViewers.jsx
new file mode 100644
index 00000000..cbfebc97
--- /dev/null
+++ b/frontend/src/FileManager/Actions/PreviewFile/FileViewers.jsx
@@ -0,0 +1,470 @@
+import { useEffect, useState, useMemo } from "react";
+
+// =============================================================================
+// CodeViewer
+// =============================================================================
+
+const codeViewerStyles = {
+ container: {
+ display: "flex",
+ width: "100%",
+ height: "100%",
+ overflow: "auto",
+ backgroundColor: "#1e1e1e",
+ borderRadius: "4px",
+ fontFamily: "'Courier New', Consolas, Monaco, monospace",
+ fontSize: "13px",
+ lineHeight: "1.5",
+ },
+ lineNumbers: {
+ padding: "12px 8px",
+ textAlign: "right",
+ color: "#858585",
+ backgroundColor: "#1e1e1e",
+ borderRight: "1px solid #333",
+ userSelect: "none",
+ minWidth: "40px",
+ flexShrink: 0,
+ },
+ lineNumber: {
+ display: "block",
+ paddingRight: "8px",
+ },
+ code: {
+ padding: "12px",
+ margin: 0,
+ color: "#d4d4d4",
+ whiteSpace: "pre",
+ flex: 1,
+ overflow: "visible",
+ },
+};
+
+const CodeViewer = ({ content, filePreviewPath, filePath }) => {
+ const [text, setText] = useState(content || "");
+ const [loading, setLoading] = useState(!content && !!filePreviewPath);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (content) {
+ setText(content);
+ return;
+ }
+ if (filePreviewPath && filePath) {
+ setLoading(true);
+ fetch(`${filePreviewPath}${filePath}`)
+ .then((res) => {
+ if (!res.ok) throw new Error(`Failed to load file: ${res.statusText}`);
+ return res.text();
+ })
+ .then((data) => {
+ setText(data);
+ setLoading(false);
+ })
+ .catch((err) => {
+ setError(err.message);
+ setLoading(false);
+ });
+ }
+ }, [content, filePreviewPath, filePath]);
+
+ const lines = text.split("\n");
+
+ if (loading) {
+ return Loading...
;
+ }
+
+ if (error) {
+ return {error}
;
+ }
+
+ return (
+
+
+ {lines.map((_, i) => (
+
+ {i + 1}
+
+ ))}
+
+
+ {text}
+
+
+ );
+};
+
+// =============================================================================
+// MarkdownViewer
+// =============================================================================
+
+const markdownViewerStyles = {
+ container: {
+ padding: "16px 24px",
+ width: "100%",
+ height: "100%",
+ overflow: "auto",
+ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
+ fontSize: "14px",
+ lineHeight: "1.6",
+ color: "#333",
+ },
+};
+
+/**
+ * Simple regex-based markdown to HTML converter.
+ * Supports: headers, bold, italic, code blocks, inline code, links, unordered/ordered lists.
+ */
+const convertMarkdownToHtml = (md) => {
+ let html = md;
+
+ // Fenced code blocks (``` ... ```)
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
+ const escaped = code
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+ return `${escaped} `;
+ });
+
+ // Split into lines for block-level processing
+ const lines = html.split("\n");
+ const processed = [];
+ let inList = false;
+ let listType = null; // "ul" or "ol"
+
+ for (let i = 0; i < lines.length; i++) {
+ let line = lines[i];
+
+ // Check for unordered list items
+ const ulMatch = line.match(/^(\s*)[-*+]\s+(.+)/);
+ // Check for ordered list items
+ const olMatch = line.match(/^(\s*)\d+\.\s+(.+)/);
+
+ if (ulMatch) {
+ if (!inList || listType !== "ul") {
+ if (inList) processed.push(`${listType}>`);
+ processed.push("");
+ inList = true;
+ listType = "ul";
+ }
+ processed.push(`${ulMatch[2]} `);
+ continue;
+ } else if (olMatch) {
+ if (!inList || listType !== "ol") {
+ if (inList) processed.push(`${listType}>`);
+ processed.push("");
+ inList = true;
+ listType = "ol";
+ }
+ processed.push(`${olMatch[2]} `);
+ continue;
+ } else if (inList) {
+ processed.push(`${listType}>`);
+ inList = false;
+ listType = null;
+ }
+
+ // Headers
+ if (line.match(/^#{1,6}\s/)) {
+ const level = line.match(/^(#{1,6})\s/)[1].length;
+ const text = line.replace(/^#{1,6}\s+/, "");
+ const sizes = { 1: "2em", 2: "1.5em", 3: "1.25em", 4: "1.1em", 5: "1em", 6: "0.9em" };
+ processed.push(
+ `${text} `
+ );
+ continue;
+ }
+
+ // Horizontal rule
+ if (line.match(/^(---|\*\*\*|___)\s*$/)) {
+ processed.push(' ');
+ continue;
+ }
+
+ // Blockquote
+ if (line.match(/^>\s/)) {
+ const text = line.replace(/^>\s+/, "");
+ processed.push(
+ `${text} `
+ );
+ continue;
+ }
+
+ processed.push(line);
+ }
+
+ if (inList) {
+ processed.push(`${listType}>`);
+ }
+
+ html = processed.join("\n");
+
+ // Inline formatting (applied after block-level processing)
+ // Bold (** or __)
+ html = html.replace(/\*\*(.+?)\*\*/g, "$1 ");
+ html = html.replace(/__(.+?)__/g, "$1 ");
+
+ // Italic (* or _) - careful not to match bold markers
+ html = html.replace(/(?$1");
+ html = html.replace(/(?$1");
+
+ // Inline code
+ html = html.replace(
+ /`([^`]+)`/g,
+ '$1'
+ );
+
+ // Links [text](url)
+ html = html.replace(
+ /\[([^\]]+)\]\(([^)]+)\)/g,
+ '$1 '
+ );
+
+ // Images 
+ html = html.replace(
+ /!\[([^\]]*)\]\(([^)]+)\)/g,
+ ' '
+ );
+
+ // Paragraphs: wrap standalone text lines
+ html = html
+ .split("\n")
+ .map((line) => {
+ const trimmed = line.trim();
+ if (!trimmed) return "";
+ // Skip lines that are already block elements
+ if (
+ trimmed.startsWith("${line}
`;
+ })
+ .join("\n");
+
+ return html;
+};
+
+const MarkdownViewer = ({ content, filePreviewPath, filePath }) => {
+ const [text, setText] = useState(content || "");
+ const [loading, setLoading] = useState(!content && !!filePreviewPath);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (content) {
+ setText(content);
+ return;
+ }
+ if (filePreviewPath && filePath) {
+ setLoading(true);
+ fetch(`${filePreviewPath}${filePath}`)
+ .then((res) => {
+ if (!res.ok) throw new Error(`Failed to load file: ${res.statusText}`);
+ return res.text();
+ })
+ .then((data) => {
+ setText(data);
+ setLoading(false);
+ })
+ .catch((err) => {
+ setError(err.message);
+ setLoading(false);
+ });
+ }
+ }, [content, filePreviewPath, filePath]);
+
+ const htmlContent = useMemo(() => convertMarkdownToHtml(text), [text]);
+
+ if (loading) {
+ return Loading...
;
+ }
+
+ if (error) {
+ return {error}
;
+ }
+
+ return (
+
+ );
+};
+
+// =============================================================================
+// CSVViewer
+// =============================================================================
+
+const csvViewerStyles = {
+ container: {
+ width: "100%",
+ height: "100%",
+ overflow: "auto",
+ padding: "8px",
+ },
+ table: {
+ width: "100%",
+ borderCollapse: "collapse",
+ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
+ fontSize: "13px",
+ },
+ th: {
+ padding: "8px 12px",
+ backgroundColor: "#f0f0f0",
+ borderBottom: "2px solid #d0d0d0",
+ border: "1px solid #d0d0d0",
+ fontWeight: 600,
+ textAlign: "left",
+ whiteSpace: "nowrap",
+ position: "sticky",
+ top: 0,
+ },
+ td: {
+ padding: "6px 12px",
+ border: "1px solid #e0e0e0",
+ whiteSpace: "nowrap",
+ },
+ evenRow: {
+ backgroundColor: "#fafafa",
+ },
+ oddRow: {
+ backgroundColor: "#ffffff",
+ },
+};
+
+/**
+ * Parse a CSV string into rows of fields.
+ * Handles basic quoting (fields enclosed in double quotes).
+ */
+const parseCSV = (csvText) => {
+ const rows = [];
+ let currentRow = [];
+ let currentField = "";
+ let inQuotes = false;
+
+ for (let i = 0; i < csvText.length; i++) {
+ const char = csvText[i];
+ const nextChar = csvText[i + 1];
+
+ if (inQuotes) {
+ if (char === '"' && nextChar === '"') {
+ // Escaped quote
+ currentField += '"';
+ i++;
+ } else if (char === '"') {
+ inQuotes = false;
+ } else {
+ currentField += char;
+ }
+ } else {
+ if (char === '"') {
+ inQuotes = true;
+ } else if (char === ",") {
+ currentRow.push(currentField.trim());
+ currentField = "";
+ } else if (char === "\n" || (char === "\r" && nextChar === "\n")) {
+ currentRow.push(currentField.trim());
+ if (currentRow.length > 0 && currentRow.some((f) => f !== "")) {
+ rows.push(currentRow);
+ }
+ currentRow = [];
+ currentField = "";
+ if (char === "\r") i++; // skip \n after \r
+ } else {
+ currentField += char;
+ }
+ }
+ }
+
+ // Handle last field/row
+ currentRow.push(currentField.trim());
+ if (currentRow.length > 0 && currentRow.some((f) => f !== "")) {
+ rows.push(currentRow);
+ }
+
+ return rows;
+};
+
+const CSVViewer = ({ content, filePreviewPath, filePath }) => {
+ const [text, setText] = useState(content || "");
+ const [loading, setLoading] = useState(!content && !!filePreviewPath);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (content) {
+ setText(content);
+ return;
+ }
+ if (filePreviewPath && filePath) {
+ setLoading(true);
+ fetch(`${filePreviewPath}${filePath}`)
+ .then((res) => {
+ if (!res.ok) throw new Error(`Failed to load file: ${res.statusText}`);
+ return res.text();
+ })
+ .then((data) => {
+ setText(data);
+ setLoading(false);
+ })
+ .catch((err) => {
+ setError(err.message);
+ setLoading(false);
+ });
+ }
+ }, [content, filePreviewPath, filePath]);
+
+ const rows = useMemo(() => parseCSV(text), [text]);
+
+ if (loading) {
+ return Loading...
;
+ }
+
+ if (error) {
+ return {error}
;
+ }
+
+ if (rows.length === 0) {
+ return No data
;
+ }
+
+ const headers = rows[0];
+ const dataRows = rows.slice(1);
+
+ return (
+
+
+
+
+ {headers.map((header, i) => (
+
+ {header}
+
+ ))}
+
+
+
+ {dataRows.map((row, rowIndex) => (
+
+ {headers.map((_, colIndex) => (
+
+ {row[colIndex] || ""}
+
+ ))}
+
+ ))}
+
+
+
+ );
+};
+
+export { CodeViewer, MarkdownViewer, CSVViewer };
diff --git a/frontend/src/FileManager/Actions/PreviewFile/PreviewControls.jsx b/frontend/src/FileManager/Actions/PreviewFile/PreviewControls.jsx
new file mode 100644
index 00000000..e3e97928
--- /dev/null
+++ b/frontend/src/FileManager/Actions/PreviewFile/PreviewControls.jsx
@@ -0,0 +1,96 @@
+import { FiZoomIn, FiZoomOut, FiMaximize } from "react-icons/fi";
+import { MdNavigateBefore, MdNavigateNext, MdFullscreen, MdFullscreenExit } from "react-icons/md";
+import { useTranslation } from "../../../contexts/TranslationProvider";
+import "./PreviewControls.scss";
+
+const MIN_ZOOM = 25;
+const MAX_ZOOM = 400;
+const ZOOM_STEP = 25;
+
+const PreviewControls = ({
+ zoomLevel,
+ onZoomIn,
+ onZoomOut,
+ onFitToScreen,
+ isFullscreen,
+ onToggleFullscreen,
+ currentIndex,
+ totalFiles,
+ onPrevious,
+ onNext,
+ showZoomControls,
+}) => {
+ const t = useTranslation();
+
+ return (
+
+
+
+
+
+ {showZoomControls && (
+
+
+
+
+ {zoomLevel}%
+ = MAX_ZOOM}
+ title={t("zoomIn")}
+ aria-label={t("zoomIn")}
+ >
+
+
+
+
+
+
+ )}
+
+
+ {isFullscreen ? : }
+
+
+
+ {currentIndex + 1} {t("ofFiles")} {totalFiles}
+
+
+
= totalFiles - 1}
+ title={t("nextFile")}
+ aria-label={t("nextFile")}
+ >
+
+
+
+ );
+};
+
+export { MIN_ZOOM, MAX_ZOOM, ZOOM_STEP };
+export default PreviewControls;
diff --git a/frontend/src/FileManager/Actions/PreviewFile/PreviewControls.scss b/frontend/src/FileManager/Actions/PreviewFile/PreviewControls.scss
new file mode 100644
index 00000000..8410c46a
--- /dev/null
+++ b/frontend/src/FileManager/Actions/PreviewFile/PreviewControls.scss
@@ -0,0 +1,61 @@
+.preview-controls {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background-color: #f5f5f5;
+ border-top: 1px solid #e0e0e0;
+ border-radius: 0 0 4px 4px;
+ flex-wrap: wrap;
+
+ .preview-control-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border: 1px solid #d0d0d0;
+ border-radius: 4px;
+ background-color: #ffffff;
+ cursor: pointer;
+ color: #333;
+ transition: background-color 0.15s ease, border-color 0.15s ease;
+
+ &:hover:not(:disabled) {
+ background-color: #e8e8e8;
+ border-color: #b0b0b0;
+ }
+
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+ }
+
+ .zoom-controls {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 8px;
+ border-left: 1px solid #d0d0d0;
+ border-right: 1px solid #d0d0d0;
+
+ .zoom-level {
+ font-size: 0.8em;
+ font-weight: 600;
+ min-width: 40px;
+ text-align: center;
+ color: #555;
+ user-select: none;
+ }
+ }
+
+ .file-counter {
+ font-size: 0.8em;
+ color: #666;
+ padding: 0 6px;
+ user-select: none;
+ white-space: nowrap;
+ }
+}
diff --git a/frontend/src/FileManager/Actions/PreviewFile/PreviewFile.action.jsx b/frontend/src/FileManager/Actions/PreviewFile/PreviewFile.action.jsx
index d2307d41..dbf6d3b0 100644
--- a/frontend/src/FileManager/Actions/PreviewFile/PreviewFile.action.jsx
+++ b/frontend/src/FileManager/Actions/PreviewFile/PreviewFile.action.jsx
@@ -1,70 +1,203 @@
-import React, { useMemo, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getFileExtension } from "../../../utils/getFileExtension";
import Loader from "../../../components/Loader/Loader";
import { useSelection } from "../../../contexts/SelectionContext";
+import { useFileNavigation } from "../../../contexts/FileNavigationContext";
import Button from "../../../components/Button/Button";
import { getDataSize } from "../../../utils/getDataSize";
import { MdOutlineFileDownload } from "react-icons/md";
import { useFileIcons } from "../../../hooks/useFileIcons";
import { FaRegFileAlt } from "react-icons/fa";
import { useTranslation } from "../../../contexts/TranslationProvider";
+import PreviewControls, { MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from "./PreviewControls";
+import { CodeViewer, MarkdownViewer, CSVViewer } from "./FileViewers";
import "./PreviewFile.action.scss";
const imageExtensions = ["jpg", "jpeg", "png"];
const videoExtensions = ["mp4", "mov", "avi"];
const audioExtensions = ["mp3", "wav", "m4a"];
const iFrameExtensions = ["txt", "pdf"];
+const codeExtensions = ["js", "jsx", "ts", "tsx", "json", "html", "css", "scss", "xml", "yaml", "yml", "sh", "bash", "py", "rb", "java", "c", "cpp", "h", "go", "rs", "php", "sql"];
+const markdownExtensions = ["md", "markdown"];
+const csvExtensions = ["csv", "tsv"];
const PreviewFileAction = ({ filePreviewPath, filePreviewComponent }) => {
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
- const { selectedFiles, handleDownload: triggerDownload } = useSelection();
+ const [zoomLevel, setZoomLevel] = useState(100);
+ const [isFullscreen, setIsFullscreen] = useState(false);
+
+ const { selectedFiles, setSelectedFiles, handleDownload: triggerDownload } = useSelection();
+ const { currentPathFiles } = useFileNavigation();
const fileIcons = useFileIcons(73);
- const extension = getFileExtension(selectedFiles[0].name)?.toLowerCase();
- const filePath = `${filePreviewPath}${selectedFiles[0].path}`;
const t = useTranslation();
+ const previewRef = useRef(null);
+
+ // Get only non-directory files from current path for navigation
+ const navigableFiles = useMemo(
+ () => currentPathFiles.filter((f) => !f.isDirectory),
+ [currentPathFiles]
+ );
+
+ const currentFile = selectedFiles[0];
+ const currentFileIndex = useMemo(
+ () => navigableFiles.findIndex((f) => f.path === currentFile?.path),
+ [navigableFiles, currentFile]
+ );
+
+ const extension = getFileExtension(currentFile?.name)?.toLowerCase();
+ const filePath = `${filePreviewPath}${currentFile?.path}`;
+
+ const isImage = imageExtensions.includes(extension);
+ const showZoomControls = isImage;
// Custom file preview component
const customPreview = useMemo(
- () => filePreviewComponent?.(selectedFiles[0]),
- [filePreviewComponent]
+ () => filePreviewComponent?.(currentFile),
+ [filePreviewComponent, currentFile]
);
const handleImageLoad = () => {
- setIsLoading(false); // Loading is complete
- setHasError(false); // No error
+ setIsLoading(false);
+ setHasError(false);
};
const handleImageError = () => {
- setIsLoading(false); // Loading is complete
- setHasError(true); // Error occurred
+ setIsLoading(false);
+ setHasError(true);
};
const handleDownload = () => {
- // Delegate to host download handler so the main app controls download (no navigation)
triggerDownload();
};
+ // Navigation handlers
+ const navigateTo = useCallback(
+ (index) => {
+ if (index >= 0 && index < navigableFiles.length) {
+ setSelectedFiles([navigableFiles[index]]);
+ setIsLoading(true);
+ setHasError(false);
+ setZoomLevel(100);
+ }
+ },
+ [navigableFiles, setSelectedFiles]
+ );
+
+ const handlePrevious = useCallback(() => {
+ navigateTo(currentFileIndex - 1);
+ }, [currentFileIndex, navigateTo]);
+
+ const handleNext = useCallback(() => {
+ navigateTo(currentFileIndex + 1);
+ }, [currentFileIndex, navigateTo]);
+
+ // Zoom handlers
+ const handleZoomIn = useCallback(() => {
+ setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM));
+ }, []);
+
+ const handleZoomOut = useCallback(() => {
+ setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM));
+ }, []);
+
+ const handleFitToScreen = useCallback(() => {
+ setZoomLevel(100);
+ }, []);
+
+ // Fullscreen handlers
+ const handleToggleFullscreen = useCallback(() => {
+ if (!previewRef.current) return;
+
+ if (!document.fullscreenElement) {
+ previewRef.current.requestFullscreen().then(() => {
+ setIsFullscreen(true);
+ }).catch(() => {
+ // Fullscreen request failed silently
+ });
+ } else {
+ document.exitFullscreen().then(() => {
+ setIsFullscreen(false);
+ }).catch(() => {
+ // Exit fullscreen failed silently
+ });
+ }
+ }, []);
+
+ // Listen for fullscreen changes (e.g., user pressing Esc)
+ useEffect(() => {
+ const handleFullscreenChange = () => {
+ setIsFullscreen(!!document.fullscreenElement);
+ };
+ document.addEventListener("fullscreenchange", handleFullscreenChange);
+ return () => {
+ document.removeEventListener("fullscreenchange", handleFullscreenChange);
+ };
+ }, []);
+
+ // Keyboard navigation (Left/Right arrows)
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ if (e.key === "ArrowLeft") {
+ e.preventDefault();
+ handlePrevious();
+ } else if (e.key === "ArrowRight") {
+ e.preventDefault();
+ handleNext();
+ }
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [handlePrevious, handleNext]);
+
+ // Mouse wheel zoom (Ctrl + scroll)
+ useEffect(() => {
+ const element = previewRef.current;
+ if (!element || !showZoomControls) return;
+
+ const handleWheel = (e) => {
+ if (e.ctrlKey) {
+ e.preventDefault();
+ if (e.deltaY < 0) {
+ setZoomLevel((prev) => Math.min(prev + ZOOM_STEP, MAX_ZOOM));
+ } else {
+ setZoomLevel((prev) => Math.max(prev - ZOOM_STEP, MIN_ZOOM));
+ }
+ }
+ };
+ element.addEventListener("wheel", handleWheel, { passive: false });
+ return () => {
+ element.removeEventListener("wheel", handleWheel);
+ };
+ }, [showZoomControls]);
+
if (React.isValidElement(customPreview)) {
return customPreview;
}
+ const isSupported = [
+ ...imageExtensions,
+ ...videoExtensions,
+ ...audioExtensions,
+ ...iFrameExtensions,
+ ...codeExtensions,
+ ...markdownExtensions,
+ ...csvExtensions,
+ ].includes(extension);
+
return (
-
- {hasError ||
- (![
- ...imageExtensions,
- ...videoExtensions,
- ...audioExtensions,
- ...iFrameExtensions,
- ].includes(extension) && (
+
+
+ {(hasError || !isSupported) && (
{fileIcons[extension] ?? }
{t("previewUnavailable")}
- {selectedFiles[0].name}
- {selectedFiles[0].size && - }
- {getDataSize(selectedFiles[0].size)}
+ {currentFile.name}
+ {currentFile.size && - }
+ {getDataSize(currentFile.size)}
@@ -73,38 +206,64 @@ const PreviewFileAction = ({ filePreviewPath, filePreviewComponent }) => {
- ))}
- {imageExtensions.includes(extension) && (
- <>
-
-
- >
- )}
- {videoExtensions.includes(extension) && (
-
- )}
- {audioExtensions.includes(extension) && (
-
- )}
- {iFrameExtensions.includes(extension) && (
- <>
-
- >
- )}
-
+ )}
+ {isImage && !hasError && (
+ <>
+
+
+
+
+ >
+ )}
+ {videoExtensions.includes(extension) && (
+
+ )}
+ {audioExtensions.includes(extension) && (
+
+ )}
+ {iFrameExtensions.includes(extension) && (
+ <>
+
+ >
+ )}
+ {codeExtensions.includes(extension) && (
+
+ )}
+ {markdownExtensions.includes(extension) && (
+
+ )}
+ {csvExtensions.includes(extension) && (
+
+ )}
+
+
+
);
};
diff --git a/frontend/src/FileManager/Actions/PreviewFile/PreviewFile.action.scss b/frontend/src/FileManager/Actions/PreviewFile/PreviewFile.action.scss
index 853f4a91..d63e2be7 100644
--- a/frontend/src/FileManager/Actions/PreviewFile/PreviewFile.action.scss
+++ b/frontend/src/FileManager/Actions/PreviewFile/PreviewFile.action.scss
@@ -1,5 +1,23 @@
@import "../../../styles/variables";
+.preview-wrapper {
+ display: flex;
+ flex-direction: column;
+
+ &.preview-fullscreen {
+ background-color: #fff;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ width: 100vw;
+
+ .file-previewer {
+ height: calc(100vh - 56px);
+ flex: 1;
+ }
+ }
+}
+
.file-previewer {
padding: .8em;
height: 40dvh;
@@ -7,11 +25,21 @@
justify-content: center;
font-size: $fm-font-size;
+ .image-zoom-container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+ }
+
.photo-popup-image {
object-fit: contain;
- width: -webkit-fill-available;
+ max-width: 100%;
+ max-height: 100%;
opacity: 1;
- transition: opacity 0.5s ease-in-out;
+ transition: opacity 0.5s ease-in-out, transform 0.2s ease;
}
.img-loading {
@@ -83,4 +111,4 @@
.video-preview {
width: -webkit-fill-available;
-}
\ No newline at end of file
+}
diff --git a/frontend/src/FileManager/BatchProgress/BatchProgress.jsx b/frontend/src/FileManager/BatchProgress/BatchProgress.jsx
new file mode 100644
index 00000000..7156c72f
--- /dev/null
+++ b/frontend/src/FileManager/BatchProgress/BatchProgress.jsx
@@ -0,0 +1,94 @@
+import { MdClose, MdCheck, MdError, MdSkipNext } from "react-icons/md";
+import { useBatchOperations } from "../../contexts/BatchOperationsContext";
+import { useTranslation } from "../../contexts/TranslationProvider";
+import "./BatchProgress.scss";
+
+const BatchProgress = () => {
+ const {
+ isActive,
+ operationType,
+ items,
+ overallProgress,
+ completed,
+ failed,
+ skipped,
+ cancelled,
+ cancelBatch,
+ closeBatch,
+ } = useBatchOperations();
+ const t = useTranslation();
+
+ if (!isActive) return null;
+
+ const isDone = overallProgress === 100 || cancelled;
+ const totalItems = items.length;
+
+ return (
+
+
+
+
+ {isDone
+ ? t("operationComplete") || "Operation Complete"
+ : `${t(operationType) || operationType}... (${completed + failed + skipped}/${totalItems})`}
+
+ {isDone && (
+
+
+
+ )}
+
+
+
+
0 ? "has-errors" : "success") : ""}`}
+ style={{ width: `${overallProgress}%` }}
+ />
+
+
{overallProgress}%
+
+
+ {items.map((item) => (
+
+
+ {item.status === "completed" && }
+ {item.status === "failed" && }
+ {item.status === "skipped" && }
+ {(item.status === "pending" || item.status === "in_progress") && (
+
+ )}
+
+ {item.name}
+
+ {item.status === "in_progress" && `${item.progress || 0}%`}
+ {item.status === "failed" && (item.error || "Failed")}
+
+
+ ))}
+
+
+ {isDone ? (
+
+ {completed} {t("succeeded") || "succeeded"}
+ {failed > 0 && (
+ {failed} {t("failed") || "failed"}
+ )}
+ {skipped > 0 && (
+ {skipped} {t("skipped") || "skipped"}
+ )}
+
+ ) : (
+
+
+ {t("cancel") || "Cancel"}
+
+
+ )}
+
+
+ );
+};
+
+BatchProgress.displayName = "BatchProgress";
+
+export default BatchProgress;
diff --git a/frontend/src/FileManager/BatchProgress/BatchProgress.scss b/frontend/src/FileManager/BatchProgress/BatchProgress.scss
new file mode 100644
index 00000000..bb858fd4
--- /dev/null
+++ b/frontend/src/FileManager/BatchProgress/BatchProgress.scss
@@ -0,0 +1,189 @@
+.fm-batch-overlay {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: var(--fm-z-modal, 400);
+}
+
+.fm-batch-modal {
+ background: var(--fm-color-bg, #fff);
+ border-radius: var(--fm-radius-lg, 8px);
+ box-shadow: var(--fm-shadow-xl);
+ width: 420px;
+ max-width: 90%;
+ max-height: 70%;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+
+ .fm-batch-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px 12px;
+
+ h3 {
+ margin: 0;
+ font-size: var(--fm-font-size-base, 14px);
+ font-weight: var(--fm-font-weight-semibold, 600);
+ color: var(--fm-color-text-primary, #1a1a2e);
+ }
+
+ .fm-batch-close {
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ color: var(--fm-color-text-secondary);
+ border-radius: 4px;
+ padding: 2px;
+
+ &:hover {
+ background: var(--fm-color-surface-hover);
+ }
+ }
+ }
+
+ .fm-batch-progress-bar {
+ height: 4px;
+ background: var(--fm-color-bg-tertiary, #f0f1f3);
+ margin: 0 20px;
+ border-radius: 2px;
+ overflow: hidden;
+
+ .fm-batch-progress-fill {
+ height: 100%;
+ background: var(--fm-color-primary, #6155b4);
+ transition: width 200ms ease;
+ border-radius: 2px;
+
+ &.success {
+ background: var(--fm-color-success, #22c55e);
+ }
+
+ &.has-errors {
+ background: var(--fm-color-warning, #f59e0b);
+ }
+ }
+ }
+
+ .fm-batch-progress-text {
+ font-size: var(--fm-font-size-xs, 11px);
+ color: var(--fm-color-text-tertiary, #8a8aa0);
+ text-align: right;
+ padding: 4px 20px 8px;
+ }
+
+ .fm-batch-items {
+ overflow-y: auto;
+ max-height: 240px;
+ padding: 0 20px;
+
+ .fm-batch-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 0;
+ font-size: var(--fm-font-size-sm, 12px);
+ border-bottom: 1px solid var(--fm-color-border, #e0e0e8);
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ .fm-batch-item-icon {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ width: 16px;
+ }
+
+ .fm-batch-item-name {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--fm-color-text-primary);
+ }
+
+ .fm-batch-item-status {
+ font-size: var(--fm-font-size-xs, 11px);
+ color: var(--fm-color-text-tertiary);
+ flex-shrink: 0;
+ }
+
+ &.fm-batch-item-completed .fm-batch-item-icon {
+ color: var(--fm-color-success, #22c55e);
+ }
+
+ &.fm-batch-item-failed .fm-batch-item-icon {
+ color: var(--fm-color-error, #ef4444);
+ }
+
+ &.fm-batch-item-failed .fm-batch-item-status {
+ color: var(--fm-color-error, #ef4444);
+ }
+
+ &.fm-batch-item-skipped .fm-batch-item-icon {
+ color: var(--fm-color-text-disabled, #b0b0c0);
+ }
+
+ .fm-batch-spinner {
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ border: 2px solid var(--fm-color-border);
+ border-top-color: var(--fm-color-primary);
+ border-radius: 50%;
+ animation: fm-spin 600ms linear infinite;
+ }
+ }
+ }
+
+ .fm-batch-summary {
+ display: flex;
+ gap: 12px;
+ padding: 12px 20px;
+ font-size: var(--fm-font-size-sm, 12px);
+
+ .fm-batch-summary-success {
+ color: var(--fm-color-success, #22c55e);
+ }
+
+ .fm-batch-summary-failed {
+ color: var(--fm-color-error, #ef4444);
+ }
+
+ .fm-batch-summary-skipped {
+ color: var(--fm-color-text-disabled, #b0b0c0);
+ }
+ }
+
+ .fm-batch-actions {
+ padding: 8px 20px 16px;
+ display: flex;
+ justify-content: flex-end;
+
+ .fm-batch-cancel {
+ padding: 6px 16px;
+ border: 1px solid var(--fm-color-border, #e0e0e8);
+ border-radius: var(--fm-radius-md, 6px);
+ background: transparent;
+ color: var(--fm-color-text-primary);
+ cursor: pointer;
+ font-size: var(--fm-font-size-sm, 12px);
+
+ &:hover {
+ background: var(--fm-color-surface-hover);
+ }
+ }
+ }
+}
+
+@keyframes fm-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/frontend/src/FileManager/BreadCrumb/BreadCrumb.jsx b/frontend/src/FileManager/BreadCrumb/BreadCrumb.jsx
index cd9a93b7..1a962731 100644
--- a/frontend/src/FileManager/BreadCrumb/BreadCrumb.jsx
+++ b/frontend/src/FileManager/BreadCrumb/BreadCrumb.jsx
@@ -86,7 +86,7 @@ const BreadCrumb = ({ collapsibleNav, isNavigationPaneOpen, setNavigationPaneOpe
}, [isBreadCrumbOverflowing]);
return (
-
+
{collapsibleNav && (
<>
@@ -117,6 +117,7 @@ const BreadCrumb = ({ collapsibleNav, isNavigationPaneOpen, setNavigationPaneOpe
className="folder-name"
onClick={() => switchPath(folder.path)}
ref={(el) => (foldersRef.current[index] = el)}
+ {...(index === folders.length - 1 ? { "aria-current": "location" } : {})}
>
{index === 0 ?
:
}
{folder.name}
diff --git a/frontend/src/FileManager/BreadCrumb/BreadCrumb.scss b/frontend/src/FileManager/BreadCrumb/BreadCrumb.scss
index 3f40d955..66b270fd 100644
--- a/frontend/src/FileManager/BreadCrumb/BreadCrumb.scss
+++ b/frontend/src/FileManager/BreadCrumb/BreadCrumb.scss
@@ -25,11 +25,13 @@
.nav-toggler {
display: flex;
align-items: center;
+ transition: all 0.15s ease;
}
.divider {
width: 1px;
background-color: $border-color;
+ transition: background-color 0.15s ease;
}
.folder-name {
@@ -38,10 +40,27 @@
gap: 0.25rem;
font-weight: 500;
min-width: fit-content;
+ transition: all 0.15s ease;
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: -2px;
+ left: 0;
+ width: 0;
+ height: 1.5px;
+ background-color: var(--file-manager-primary-color);
+ transition: width 0.15s ease;
+ }
&:hover {
cursor: pointer;
color: var(--file-manager-primary-color);
+
+ &::after {
+ width: 100%;
+ }
}
}
@@ -53,6 +72,7 @@
background-color: transparent;
border: none;
padding: 0;
+ transition: all 0.15s ease;
&:hover,
&:focus {
diff --git a/frontend/src/FileManager/ClipboardIndicator/ClipboardIndicator.jsx b/frontend/src/FileManager/ClipboardIndicator/ClipboardIndicator.jsx
new file mode 100644
index 00000000..3b3947f9
--- /dev/null
+++ b/frontend/src/FileManager/ClipboardIndicator/ClipboardIndicator.jsx
@@ -0,0 +1,38 @@
+import { MdContentCopy, MdContentCut, MdClose } from "react-icons/md";
+import { useClipBoard } from "../../contexts/ClipboardContext";
+import { useTranslation } from "../../contexts/TranslationProvider";
+import "./ClipboardIndicator.scss";
+
+const ClipboardIndicator = () => {
+ const { clipBoard, setClipBoard } = useClipBoard();
+ const t = useTranslation();
+
+ if (!clipBoard || !clipBoard.files || clipBoard.files.length === 0) return null;
+
+ const fileCount = clipBoard.files.length;
+ const operationType = clipBoard.isMoving ? "move" : "copy";
+
+ return (
+
+
+ {clipBoard.isMoving ? : }
+
+
+ {fileCount} {fileCount === 1 ? t("item") || "item" : t("items") || "items"}{" "}
+ ({t(operationType) || operationType})
+
+ setClipBoard(null)}
+ aria-label={t("clearClipboard") || "Clear clipboard"}
+ title={t("clearClipboard") || "Clear clipboard"}
+ >
+
+
+
+ );
+};
+
+ClipboardIndicator.displayName = "ClipboardIndicator";
+
+export default ClipboardIndicator;
diff --git a/frontend/src/FileManager/ClipboardIndicator/ClipboardIndicator.scss b/frontend/src/FileManager/ClipboardIndicator/ClipboardIndicator.scss
new file mode 100644
index 00000000..bedaf9a9
--- /dev/null
+++ b/frontend/src/FileManager/ClipboardIndicator/ClipboardIndicator.scss
@@ -0,0 +1,54 @@
+.fm-clipboard-indicator {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ background: var(--fm-color-primary-light, rgba(97, 85, 180, 0.1));
+ border: 1px solid var(--fm-color-primary, #6155b4);
+ border-radius: var(--fm-radius-full, 9999px);
+ font-size: var(--fm-font-size-xs, 11px);
+ color: var(--fm-color-primary, #6155b4);
+ position: absolute;
+ bottom: 36px;
+ right: 12px;
+ z-index: var(--fm-z-popover, 500);
+ box-shadow: var(--fm-shadow-md);
+ animation: fm-clipboard-slide-in 200ms ease-out;
+
+ .fm-clipboard-icon {
+ display: flex;
+ align-items: center;
+ }
+
+ .fm-clipboard-text {
+ white-space: nowrap;
+ font-weight: var(--fm-font-weight-medium, 500);
+ }
+
+ .fm-clipboard-clear {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ color: inherit;
+ padding: 1px;
+ border-radius: 50%;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.1);
+ }
+ }
+}
+
+@keyframes fm-clipboard-slide-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/frontend/src/FileManager/ColumnCustomizer/ColumnCustomizer.jsx b/frontend/src/FileManager/ColumnCustomizer/ColumnCustomizer.jsx
new file mode 100644
index 00000000..1eda6df4
--- /dev/null
+++ b/frontend/src/FileManager/ColumnCustomizer/ColumnCustomizer.jsx
@@ -0,0 +1,119 @@
+import { useState, useCallback, useMemo } from "react";
+import { MdDragIndicator, MdCheck, MdSettings } from "react-icons/md";
+import { useTranslation } from "../../contexts/TranslationProvider";
+import { useDetectOutsideClick } from "../../hooks/useDetectOutsideClick";
+import "./ColumnCustomizer.scss";
+
+const ALL_COLUMNS = [
+ { key: "name", label: "name", required: true },
+ { key: "modified", label: "modified" },
+ { key: "size", label: "size" },
+ { key: "type", label: "type" },
+ { key: "tags", label: "tags" },
+ { key: "path", label: "path" },
+];
+
+const DEFAULT_VISIBLE = ["name", "modified", "size"];
+
+export const useColumnConfig = (initialColumns, onColumnConfigChange) => {
+ const [visibleColumns, setVisibleColumns] = useState(
+ initialColumns || DEFAULT_VISIBLE
+ );
+ const [columnOrder, setColumnOrder] = useState(
+ initialColumns || DEFAULT_VISIBLE
+ );
+
+ const toggleColumn = useCallback(
+ (key) => {
+ if (key === "name") return; // name is always visible
+ setVisibleColumns((prev) => {
+ const next = prev.includes(key)
+ ? prev.filter((k) => k !== key)
+ : [...prev, key];
+ onColumnConfigChange?.(next);
+ return next;
+ });
+ setColumnOrder((prev) => {
+ if (prev.includes(key)) return prev;
+ return [...prev, key];
+ });
+ },
+ [onColumnConfigChange]
+ );
+
+ const reorderColumn = useCallback(
+ (fromIndex, toIndex) => {
+ setColumnOrder((prev) => {
+ const next = [...prev];
+ const [moved] = next.splice(fromIndex, 1);
+ next.splice(toIndex, 0, moved);
+ return next;
+ });
+ },
+ []
+ );
+
+ const orderedVisibleColumns = useMemo(
+ () => columnOrder.filter((key) => visibleColumns.includes(key)),
+ [columnOrder, visibleColumns]
+ );
+
+ return {
+ visibleColumns,
+ orderedVisibleColumns,
+ toggleColumn,
+ reorderColumn,
+ allColumns: ALL_COLUMNS,
+ };
+};
+
+const ColumnCustomizer = ({ visibleColumns, allColumns, toggleColumn }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const popoverRef = useDetectOutsideClick(() => setIsOpen(false));
+ const t = useTranslation();
+
+ return (
+
+
setIsOpen((prev) => !prev)}
+ title={t("customizeColumns") || "Customize columns"}
+ aria-label={t("customizeColumns") || "Customize columns"}
+ >
+
+
+ {isOpen && (
+
+
+ {t("columns") || "Columns"}
+
+ {allColumns.map((col) => (
+
+
+ {visibleColumns.includes(col.key) && }
+
+ toggleColumn(col.key)}
+ className="fm-column-checkbox-hidden"
+ />
+
+ {!col.required && }
+
+ {t(col.label) || col.label}
+
+ ))}
+
+ )}
+
+ );
+};
+
+ColumnCustomizer.displayName = "ColumnCustomizer";
+
+export default ColumnCustomizer;
diff --git a/frontend/src/FileManager/ColumnCustomizer/ColumnCustomizer.scss b/frontend/src/FileManager/ColumnCustomizer/ColumnCustomizer.scss
new file mode 100644
index 00000000..a712531f
--- /dev/null
+++ b/frontend/src/FileManager/ColumnCustomizer/ColumnCustomizer.scss
@@ -0,0 +1,89 @@
+.fm-column-customizer {
+ position: relative;
+ display: inline-flex;
+
+ .fm-column-customizer-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ color: var(--fm-color-text-tertiary, #8a8aa0);
+ border-radius: var(--fm-radius-sm, 4px);
+ padding: 0;
+
+ &:hover {
+ background: var(--fm-color-surface-hover, rgba(0, 0, 0, 0.04));
+ color: var(--fm-color-text-primary, #1a1a2e);
+ }
+ }
+
+ .fm-column-customizer-menu {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ z-index: var(--fm-z-dropdown, 100);
+ background: var(--fm-color-bg, #fff);
+ border: 1px solid var(--fm-color-border, #e0e0e8);
+ border-radius: var(--fm-radius-md, 6px);
+ box-shadow: var(--fm-shadow-lg);
+ min-width: 180px;
+ padding: 4px 0;
+
+ .fm-column-customizer-title {
+ padding: 8px 12px 4px;
+ font-size: var(--fm-font-size-xs, 11px);
+ font-weight: var(--fm-font-weight-semibold, 600);
+ color: var(--fm-color-text-tertiary, #8a8aa0);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+
+ .fm-column-option {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ cursor: pointer;
+ font-size: var(--fm-font-size-sm, 12px);
+ color: var(--fm-color-text-primary, #1a1a2e);
+ user-select: none;
+
+ &:hover {
+ background: var(--fm-color-surface-hover, rgba(0, 0, 0, 0.04));
+ }
+
+ &.fm-column-required {
+ opacity: 0.6;
+ cursor: default;
+ }
+
+ .fm-column-check {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ color: var(--fm-color-primary, #6155b4);
+ }
+
+ .fm-column-drag {
+ display: flex;
+ align-items: center;
+ color: var(--fm-color-text-disabled, #b0b0c0);
+ cursor: grab;
+ }
+
+ .fm-column-checkbox-hidden {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+ width: 0;
+ height: 0;
+ }
+ }
+ }
+}
diff --git a/frontend/src/FileManager/DetailsPanel/DetailsPanel.jsx b/frontend/src/FileManager/DetailsPanel/DetailsPanel.jsx
new file mode 100644
index 00000000..a0d2b46e
--- /dev/null
+++ b/frontend/src/FileManager/DetailsPanel/DetailsPanel.jsx
@@ -0,0 +1,376 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import PropTypes from "prop-types";
+import { useSelection } from "../../contexts/SelectionContext";
+import { useFileNavigation } from "../../contexts/FileNavigationContext";
+import { useFiles } from "../../contexts/FilesContext";
+import { useDetailsPanel } from "../../contexts/DetailsPanelContext";
+import { useTranslation } from "../../contexts/TranslationProvider";
+import { useFileIcons } from "../../hooks/useFileIcons";
+import { getDataSize } from "../../utils/getDataSize";
+import { FaRegFile, FaRegFolderOpen } from "react-icons/fa6";
+import { MdClose, MdFileCopy } from "react-icons/md";
+import "./DetailsPanel.scss";
+
+// ---- File type label helper ----
+
+const FILE_TYPE_MAP = {
+ // Images
+ jpg: "JPEG Image",
+ jpeg: "JPEG Image",
+ png: "PNG Image",
+ gif: "GIF Image",
+ bmp: "BMP Image",
+ webp: "WebP Image",
+ svg: "SVG Image",
+ ico: "Icon Image",
+ // Documents
+ pdf: "PDF Document",
+ doc: "Word Document",
+ docx: "Word Document",
+ txt: "Text Document",
+ rtf: "Rich Text Document",
+ // Spreadsheets
+ xls: "Excel Spreadsheet",
+ xlsx: "Excel Spreadsheet",
+ csv: "CSV File",
+ // Presentations
+ ppt: "PowerPoint Presentation",
+ pptx: "PowerPoint Presentation",
+ // Video
+ mp4: "MP4 Video",
+ webm: "WebM Video",
+ avi: "AVI Video",
+ mov: "QuickTime Video",
+ mkv: "MKV Video",
+ // Audio
+ mp3: "MP3 Audio",
+ m4a: "M4A Audio",
+ wav: "WAV Audio",
+ ogg: "OGG Audio",
+ flac: "FLAC Audio",
+ // Code
+ html: "HTML File",
+ css: "CSS File",
+ js: "JavaScript File",
+ jsx: "JSX File",
+ ts: "TypeScript File",
+ tsx: "TSX File",
+ json: "JSON File",
+ xml: "XML File",
+ sql: "SQL File",
+ py: "Python File",
+ java: "Java File",
+ cpp: "C++ File",
+ c: "C File",
+ php: "PHP File",
+ md: "Markdown File",
+ // Archives
+ zip: "ZIP Archive",
+ rar: "RAR Archive",
+ "7z": "7-Zip Archive",
+ tar: "TAR Archive",
+ gz: "GZ Archive",
+ // Executables
+ exe: "Executable File",
+};
+
+const getFileExtension = (fileName) => {
+ if (!fileName || typeof fileName !== "string") return "";
+ const parts = fileName.split(".");
+ return parts.length > 1 ? parts.pop().toLowerCase() : "";
+};
+
+const getFileTypeLabel = (fileName, t) => {
+ const ext = getFileExtension(fileName);
+ if (!ext) return t("unknownType");
+ if (FILE_TYPE_MAP[ext]) return FILE_TYPE_MAP[ext];
+ return ext.toUpperCase() + " File";
+};
+
+// ---- DetailsPanel Component ----
+
+const DetailsPanel = ({ formatDate, onFileDetails }) => {
+ const { selectedFiles } = useSelection();
+ const { currentPathFiles, currentFolder, currentPath } = useFileNavigation();
+ const { files, getChildren } = useFiles();
+ const { isDetailsPanelOpen, setDetailsPanelOpen } = useDetailsPanel();
+ const t = useTranslation();
+ const fileIcons = useFileIcons(48);
+ const prevSelectionRef = useRef(selectedFiles);
+
+ // ---- Resize state ----
+ const [panelWidth, setPanelWidth] = useState(25); // percentage of files-container
+ const isDraggingRef = useRef(false);
+ const [isDragging, setIsDragging] = useState(false);
+ const panelRef = useRef(null);
+
+ const handleResizeMouseDown = useCallback((e) => {
+ e.preventDefault();
+ isDraggingRef.current = true;
+ setIsDragging(true);
+ }, []);
+
+ useEffect(() => {
+ const handleMouseMove = (e) => {
+ if (!isDraggingRef.current) return;
+ e.preventDefault();
+
+ const container = panelRef.current?.parentElement;
+ if (!container) return;
+
+ const containerRect = container.getBoundingClientRect();
+ const mouseX = e.clientX - containerRect.left;
+ const containerWidth = containerRect.width;
+ const newWidthPercent = ((containerWidth - mouseX) / containerWidth) * 100;
+
+ // Min 200px equivalent, max 50%
+ const minPercent = (200 / containerWidth) * 100;
+ if (newWidthPercent >= minPercent && newWidthPercent <= 50) {
+ setPanelWidth(newWidthPercent);
+ }
+ };
+
+ const handleMouseUp = () => {
+ if (isDraggingRef.current) {
+ isDraggingRef.current = false;
+ setIsDragging(false);
+ }
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, []);
+
+ // Call onFileDetails when selection changes while panel is open
+ useEffect(() => {
+ if (!isDetailsPanelOpen) return;
+ if (prevSelectionRef.current !== selectedFiles) {
+ prevSelectionRef.current = selectedFiles;
+ onFileDetails?.(selectedFiles);
+ }
+ }, [isDetailsPanelOpen, selectedFiles, onFileDetails]);
+
+ // Also notify when panel first opens
+ useEffect(() => {
+ if (isDetailsPanelOpen) {
+ onFileDetails?.(selectedFiles);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isDetailsPanelOpen]);
+
+ // Stats for the current directory (no selection view)
+ const directoryStats = useMemo(() => {
+ const folders = currentPathFiles.filter((f) => f.isDirectory).length;
+ const fileCount = currentPathFiles.length - folders;
+ const totalSize = currentPathFiles.reduce((acc, f) => acc + (f.size || 0), 0);
+ return { folders, files: fileCount, totalSize, totalItems: currentPathFiles.length };
+ }, [currentPathFiles]);
+
+ // Stats for multiple selection
+ const multiSelectionStats = useMemo(() => {
+ if (selectedFiles.length <= 1) return null;
+
+ const folders = selectedFiles.filter((f) => f.isDirectory).length;
+ const fileCount = selectedFiles.length - folders;
+ const totalSize = selectedFiles.reduce((acc, f) => acc + (f.size || 0), 0);
+
+ return { folders, files: fileCount, totalSize };
+ }, [selectedFiles]);
+
+ // Folder children count for single folder selection
+ const folderChildCount = useMemo(() => {
+ if (selectedFiles.length !== 1 || !selectedFiles[0].isDirectory) return null;
+ const children = getChildren(selectedFiles[0]);
+ const folders = children.filter((c) => c.isDirectory).length;
+ const fileCount = children.length - folders;
+ return { total: children.length, folders, files: fileCount };
+ }, [selectedFiles, files, getChildren]);
+
+ if (!isDetailsPanelOpen) return null;
+
+ const selectionCount = selectedFiles.length;
+
+ // Determine the display name for the current directory
+ const currentDirName = currentFolder ? currentFolder.name : t("home");
+
+ const handleClose = () => {
+ setDetailsPanelOpen(false);
+ };
+
+ const renderFormatDate = (dateStr) => {
+ if (!dateStr) return "";
+ if (formatDate) return formatDate(dateStr);
+ return new Date(dateStr).toLocaleString();
+ };
+
+ const renderNoSelection = () => (
+
+
+
+
+
{currentDirName}
+
+
+
{t("items")}
+
+ {t("files")}
+ {directoryStats.files}
+
+
+ {t("folders")}
+ {directoryStats.folders}
+
+
+
+
+
+ {t("path")}
+
+ {currentPath || "/"}
+
+
+ {currentFolder?.updatedAt && (
+
+ {t("modified")}
+ {renderFormatDate(currentFolder.updatedAt)}
+
+ )}
+
+
+ );
+
+ const renderSingleFile = () => {
+ const file = selectedFiles[0];
+ const ext = getFileExtension(file.name);
+ const fileType = file.isDirectory ? t("folder") : getFileTypeLabel(file.name, t);
+ const icon = file.isDirectory
+ ?
+ : fileIcons[ext] ??
;
+
+ return (
+
+
{icon}
+
{file.name}
+
+
+
{t("details")}
+
+ {t("type")}
+ {fileType}
+
+
+ {!file.isDirectory && file.size != null && (
+
+ {t("size")}
+ {getDataSize(file.size)}
+
+ )}
+
+
+ {t("path")}
+ {file.path}
+
+
+ {file.isDirectory && folderChildCount && (
+
+ {t("contains")}
+
+ {folderChildCount.files} {t(folderChildCount.files === 1 ? "file" : "files")},{" "}
+ {folderChildCount.folders} {t(folderChildCount.folders === 1 ? "folder" : "folders")}
+
+
+ )}
+
+ {file.updatedAt && (
+
+ {t("modified")}
+ {renderFormatDate(file.updatedAt)}
+
+ )}
+
+
+ );
+ };
+
+ const renderMultiSelection = () => (
+
+
+
+
+
+ {selectionCount} {t("itemsSelected")}
+
+
+
+
{t("details")}
+
+ {multiSelectionStats.totalSize > 0 && (
+
+ {t("totalSize")}
+ {getDataSize(multiSelectionStats.totalSize)}
+
+ )}
+
+
+ {t("files")}
+ {multiSelectionStats.files}
+
+
+ {t("folders")}
+ {multiSelectionStats.folders}
+
+
+
+ );
+
+ const renderContent = () => {
+ if (selectionCount === 0) return renderNoSelection();
+ if (selectionCount === 1) return renderSingleFile();
+ return renderMultiSelection();
+ };
+
+ return (
+
+
+
+
+
{t("details")}
+
+
+
+
+ {renderContent()}
+
+
+ );
+};
+
+DetailsPanel.displayName = "DetailsPanel";
+
+DetailsPanel.propTypes = {
+ formatDate: PropTypes.func,
+ onFileDetails: PropTypes.func,
+};
+
+export default DetailsPanel;
diff --git a/frontend/src/FileManager/DetailsPanel/DetailsPanel.scss b/frontend/src/FileManager/DetailsPanel/DetailsPanel.scss
new file mode 100644
index 00000000..4290cb93
--- /dev/null
+++ b/frontend/src/FileManager/DetailsPanel/DetailsPanel.scss
@@ -0,0 +1,124 @@
+@import "../../styles/variables";
+
+.details-panel-wrapper {
+ position: relative;
+ height: 100%;
+ flex-shrink: 0;
+
+ .panel-resize {
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 5px;
+ cursor: col-resize;
+ z-index: 10;
+ border-left: 1px solid $border-color;
+
+ &:hover {
+ border-left: 1px solid #1e3a8a;
+ }
+ }
+
+ .panel-dragging {
+ border-left: 1px solid #1e3a8a;
+ }
+}
+
+.details-panel {
+ border-left: 1px solid $border-color;
+ background-color: white;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ height: 100%;
+ font-size: 14px;
+
+ .details-panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ border-bottom: 1px solid $border-color;
+ min-height: 40px;
+
+ h3 {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 600;
+ }
+
+ .details-close-btn {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+
+ &:hover {
+ background-color: rgba(0, 0, 0, 0.08);
+ }
+ }
+ }
+
+ .details-panel-content {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+ @include overflow-y-scroll;
+
+ .details-icon-area {
+ display: flex;
+ justify-content: center;
+ padding: 20px 0;
+ }
+
+ .details-file-name {
+ text-align: center;
+ font-size: 14px;
+ font-weight: 600;
+ word-break: break-word;
+ margin-bottom: 16px;
+ }
+ }
+
+ .details-section {
+ border-top: 1px solid $border-color;
+ padding: 12px 0;
+
+ .details-section-title {
+ font-size: 11px;
+ text-transform: uppercase;
+ color: #888;
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+ }
+ }
+
+ .details-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 4px 0;
+
+ .details-label {
+ color: #666;
+ flex-shrink: 0;
+ }
+
+ .details-value {
+ text-align: right;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ margin-left: 12px;
+ }
+ }
+
+ .details-empty {
+ text-align: center;
+ color: #888;
+ padding: 40px 0;
+ }
+}
diff --git a/frontend/src/FileManager/DetailsPanel/DetailsPanelToggle.jsx b/frontend/src/FileManager/DetailsPanel/DetailsPanelToggle.jsx
new file mode 100644
index 00000000..2c2d1deb
--- /dev/null
+++ b/frontend/src/FileManager/DetailsPanel/DetailsPanelToggle.jsx
@@ -0,0 +1,28 @@
+import PropTypes from "prop-types";
+import { MdInfoOutline } from "react-icons/md";
+import { useTranslation } from "../../contexts/TranslationProvider";
+
+const DetailsPanelToggle = ({ isOpen, onToggle }) => {
+ const t = useTranslation();
+
+ return (
+
+
+
+ );
+};
+
+DetailsPanelToggle.displayName = "DetailsPanelToggle";
+
+DetailsPanelToggle.propTypes = {
+ isOpen: PropTypes.bool.isRequired,
+ onToggle: PropTypes.func.isRequired,
+};
+
+export default DetailsPanelToggle;
diff --git a/frontend/src/FileManager/FileList/FileItem.jsx b/frontend/src/FileManager/FileList/FileItem.jsx
index eda4bb82..21282b52 100644
--- a/frontend/src/FileManager/FileList/FileItem.jsx
+++ b/frontend/src/FileManager/FileList/FileItem.jsx
@@ -1,5 +1,6 @@
-import { useEffect, useRef, useState } from "react";
+import { memo, useEffect, useMemo, useRef, useState } from "react";
import { FaRegFile, FaRegFolderOpen } from "react-icons/fa6";
+import { MdStar, MdStarBorder } from "react-icons/md";
import { useFileIcons } from "../../hooks/useFileIcons";
import CreateFolderAction from "../Actions/CreateFolder/CreateFolder.action";
import RenameAction from "../Actions/Rename/Rename.action";
@@ -8,10 +9,44 @@ import { useFileNavigation } from "../../contexts/FileNavigationContext";
import { useSelection } from "../../contexts/SelectionContext";
import { useClipBoard } from "../../contexts/ClipboardContext";
import { useLayout } from "../../contexts/LayoutContext";
+import { useFavorites } from "../../contexts/FavoritesContext";
+import { TagBadges } from "../TagMenu/TagMenu";
import Checkbox from "../../components/Checkbox/Checkbox";
const dragIconSize = 50;
+const imageExtensionsForThumb = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"];
+
+const Thumbnail = ({ file, iconSize, fallbackIcon }) => {
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(false);
+
+ if (!file.thumbnailUrl) {
+ return fallbackIcon;
+ }
+
+ if (error) {
+ return fallbackIcon;
+ }
+
+ return (
+ <>
+ {loading &&
}
+
setLoading(false)}
+ onError={() => {
+ setLoading(false);
+ setError(true);
+ }}
+ />
+ >
+ );
+};
+
const FileItem = ({
index,
file,
@@ -39,6 +74,8 @@ const FileItem = ({
const { setCurrentPath, currentPathFiles, onFolderChange } = useFileNavigation();
const { setSelectedFiles } = useSelection();
const { clipBoard, handleCutCopy, setClipBoard, handlePasting } = useClipBoard();
+ const { toggleFavorite, isFavorite, addToRecent } = useFavorites();
+ const fileFavorited = isFavorite(file.path);
const dragIconRef = useRef(null);
const dragIcons = useFileIcons(dragIconSize);
@@ -46,8 +83,15 @@ const FileItem = ({
clipBoard?.isMoving &&
clipBoard.files.find((f) => f.name === file.name && f.path === file.path);
+ const fileAriaLabel = useMemo(() => {
+ const type = file.isDirectory ? "Folder" : "File";
+ const size = file?.size > 0 ? `, Size: ${getDataSize(file.size)}` : "";
+ return `${file.name}, ${type}${size}`;
+ }, [file.name, file.isDirectory, file.size]);
+
const handleFileAccess = () => {
onFileOpen(file);
+ addToRecent(file);
if (file.isDirectory) {
setCurrentPath(file.path);
onFolderChange?.(file.path);
@@ -57,6 +101,11 @@ const FileItem = ({
}
};
+ const handleStarClick = (e) => {
+ e.stopPropagation();
+ toggleFavorite(file);
+ };
+
const handleFileRangeSelection = (shiftKey, ctrlKey) => {
if (selectedFileIndexes.length > 0 && shiftKey) {
let reverseSelection = false;
@@ -190,6 +239,9 @@ const FileItem = ({
fileSelected || !!file.isEditing ? "file-selected" : ""
} ${isFileMoving ? "file-moving" : ""}`}
tabIndex={0}
+ role={activeLayout === "list" ? "row" : "option"}
+ aria-selected={fileSelected}
+ aria-label={fileAriaLabel}
title={file.name}
onClick={handleFileSelection}
onKeyDown={handleOnKeyDown}
@@ -215,8 +267,25 @@ const FileItem = ({
onClick={(e) => e.stopPropagation()}
/>
)}
+ {!file.isEditing && (
+
+ {fileFavorited ? : }
+
+ )}
{file.isDirectory ? (
+ ) : activeLayout === "grid" && file.thumbnailUrl ? (
+
}
+ />
) : (
<>
{fileIcons[file.name?.split(".").pop()?.toLowerCase()] ??
}
@@ -244,6 +313,7 @@ const FileItem = ({
) : (
{file.name}
)}
+
{activeLayout === "list" && (
@@ -282,4 +352,12 @@ const FileItem = ({
);
};
-export default FileItem;
+export default memo(FileItem, (prevProps, nextProps) => {
+ return (
+ prevProps.file === nextProps.file &&
+ prevProps.index === nextProps.index &&
+ prevProps.selectedFileIndexes === nextProps.selectedFileIndexes &&
+ prevProps.draggable === nextProps.draggable &&
+ prevProps.formatDate === nextProps.formatDate
+ );
+});
diff --git a/frontend/src/FileManager/FileList/FileList.jsx b/frontend/src/FileManager/FileList/FileList.jsx
index 2b590087..f8be9900 100644
--- a/frontend/src/FileManager/FileList/FileList.jsx
+++ b/frontend/src/FileManager/FileList/FileList.jsx
@@ -18,6 +18,7 @@ const FileList = ({
triggerAction,
permissions,
formatDate,
+ isLoading,
}) => {
const { currentPathFiles, sortConfig, setSortConfig } = useFileNavigation();
const filesViewRef = useRef(null);
@@ -51,6 +52,10 @@ const FileList = ({
diff --git a/frontend/src/FileManager/FileList/FileList.scss b/frontend/src/FileManager/FileList/FileList.scss
index d602faf4..5a1b4783 100644
--- a/frontend/src/FileManager/FileList/FileList.scss
+++ b/frontend/src/FileManager/FileList/FileList.scss
@@ -34,6 +34,10 @@
border-radius: 5px;
margin: 5px 0;
+ &:hover .favorite-star {
+ visibility: visible;
+ }
+
.drag-icon {
position: absolute !important;
top: -1000px;
@@ -74,6 +78,28 @@
top: 8px;
}
+ .favorite-star {
+ position: absolute;
+ right: 5px;
+ top: 8px;
+ cursor: pointer;
+ color: #888;
+ transition: color 0.15s ease;
+ flex-shrink: 0;
+ visibility: hidden;
+ display: flex;
+ align-items: center;
+
+ &.favorited {
+ color: #f5a623;
+ visibility: visible;
+ }
+
+ &:hover {
+ color: #f5a623;
+ }
+ }
+
.hidden {
visibility: hidden;
}
@@ -242,6 +268,13 @@
top: 12px;
}
+ .favorite-star {
+ position: static;
+ display: flex;
+ align-items: center;
+ margin-right: 4px;
+ }
+
.file-name {
max-width: 285px;
}
@@ -270,4 +303,25 @@
align-items: center;
width: 100%;
height: 100%;
+}
+
+.file-thumbnail {
+ width: 64px;
+ height: 64px;
+ object-fit: cover;
+ border-radius: 4px;
+}
+
+.thumbnail-skeleton {
+ width: 64px;
+ height: 64px;
+ background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+ background-size: 200% 100%;
+ animation: skeleton-loading 1.5s infinite;
+ border-radius: 4px;
+}
+
+@keyframes skeleton-loading {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
}
\ No newline at end of file
diff --git a/frontend/src/FileManager/FileList/VirtualFileList.jsx b/frontend/src/FileManager/FileList/VirtualFileList.jsx
new file mode 100644
index 00000000..fe90d9e2
--- /dev/null
+++ b/frontend/src/FileManager/FileList/VirtualFileList.jsx
@@ -0,0 +1,84 @@
+import { useRef, useCallback, memo } from "react";
+import { FixedSizeList as List } from "react-window";
+import FileItem from "./FileItem";
+import { useLayout } from "../../contexts/LayoutContext";
+
+const ITEM_HEIGHT_LIST = 36;
+const ITEM_HEIGHT_GRID = 110;
+const GRID_ITEMS_PER_ROW = 6;
+const VIRTUAL_THRESHOLD = 200; // Only virtualize when > 200 items
+
+const VirtualRow = memo(({ index, style, data }) => {
+ const { files, ...rest } = data;
+ const file = files[index];
+ if (!file) return null;
+
+ return (
+
+
+
+ );
+});
+
+VirtualRow.displayName = "VirtualRow";
+
+const VirtualFileList = ({
+ files,
+ containerHeight,
+ onCreateFolder,
+ onRename,
+ onFileOpen,
+ enableFilePreview,
+ triggerAction,
+ filesViewRef,
+ selectedFileIndexes,
+ handleContextMenu,
+ setLastSelectedFile,
+ draggable,
+ formatDate,
+}) => {
+ const { activeLayout } = useLayout();
+ const listRef = useRef(null);
+
+ const itemHeight = activeLayout === "list" ? ITEM_HEIGHT_LIST : ITEM_HEIGHT_GRID;
+
+ const itemData = {
+ files,
+ onCreateFolder,
+ onRename,
+ onFileOpen,
+ enableFilePreview,
+ triggerAction,
+ filesViewRef,
+ selectedFileIndexes,
+ handleContextMenu,
+ setLastSelectedFile,
+ draggable,
+ formatDate,
+ };
+
+ // Only use virtual scrolling for large lists
+ if (files.length <= VIRTUAL_THRESHOLD) {
+ return null; // Fall back to non-virtual rendering
+ }
+
+ return (
+
+ {VirtualRow}
+
+ );
+};
+
+export default VirtualFileList;
diff --git a/frontend/src/FileManager/FileList/useFileList.jsx b/frontend/src/FileManager/FileList/useFileList.jsx
index f32dc0bc..81bf9696 100644
--- a/frontend/src/FileManager/FileList/useFileList.jsx
+++ b/frontend/src/FileManager/FileList/useFileList.jsx
@@ -5,7 +5,7 @@ import { FiRefreshCw } from "react-icons/fi";
import { MdOutlineDelete, MdOutlineFileDownload, MdOutlineFileUpload } from "react-icons/md";
import { PiFolderOpen } from "react-icons/pi";
import { useClipBoard } from "../../contexts/ClipboardContext";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { useSelection } from "../../contexts/SelectionContext";
import { useLayout } from "../../contexts/LayoutContext";
import { useFileNavigation } from "../../contexts/FileNavigationContext";
@@ -230,10 +230,10 @@ const useFileList = (onRefresh, enableFilePreview, triggerAction, permissions, o
setSelectedFiles([]);
};
- const unselectFiles = () => {
+ const unselectFiles = useCallback(() => {
setSelectedFileIndexes([]);
setSelectedFiles((prev) => (prev.length > 0 ? [] : prev));
- };
+ }, []);
const handleContextMenu = (e, isSelection = false) => {
e.preventDefault();
diff --git a/frontend/src/FileManager/FileManager.jsx b/frontend/src/FileManager/FileManager.jsx
index e6cff0ab..d40abe9d 100644
--- a/frontend/src/FileManager/FileManager.jsx
+++ b/frontend/src/FileManager/FileManager.jsx
@@ -4,21 +4,39 @@ import NavigationPane from "./NavigationPane/NavigationPane";
import BreadCrumb from "./BreadCrumb/BreadCrumb";
import FileList from "./FileList/FileList";
import Actions from "./Actions/Actions";
+import { ToastProvider } from "../components/Toast/Toast";
+import StatusBar from "./StatusBar/StatusBar";
import { FilesProvider } from "../contexts/FilesContext";
import { FileNavigationProvider } from "../contexts/FileNavigationContext";
import { SelectionProvider } from "../contexts/SelectionContext";
import { ClipBoardProvider } from "../contexts/ClipboardContext";
import { LayoutProvider } from "../contexts/LayoutContext";
+import { DetailsPanelProvider } from "../contexts/DetailsPanelContext";
+import DetailsPanel from "./DetailsPanel/DetailsPanel";
+import { FavoritesProvider } from "../contexts/FavoritesContext";
+import { UndoRedoProvider } from "../contexts/UndoRedoContext";
+import { SearchProvider } from "../contexts/SearchContext";
+import SearchBar from "./SearchBar/SearchBar";
+import { TabsProvider } from "../contexts/TabsContext";
+import TabBar from "./TabBar/TabBar";
+import { TagsProvider } from "../contexts/TagsContext";
+import { BatchOperationsProvider } from "../contexts/BatchOperationsContext";
+import BatchProgress from "./BatchProgress/BatchProgress";
+import ClipboardIndicator from "./ClipboardIndicator/ClipboardIndicator";
import { useTriggerAction } from "../hooks/useTriggerAction";
import { useColumnResize } from "../hooks/useColumnResize";
import PropTypes from "prop-types";
import { dateStringValidator, urlValidator } from "../validators/propValidators";
import { TranslationProvider } from "../contexts/TranslationProvider";
-import { useMemo, useState } from "react";
+import { AnnouncerProvider } from "../components/ScreenReaderAnnouncer/ScreenReaderAnnouncer";
+import { useMemo, useState, useCallback } from "react";
import { defaultPermissions } from "../constants";
import { formatDate as defaultFormatDate } from "../utils/formatDate";
import "./FileManager.scss";
+const MaybeToastProvider = ({ enabled, children }) =>
+ enabled ?
{children} : <>{children}>;
+
const FileManager = ({
files,
fileUploadConfig,
@@ -38,6 +56,8 @@ const FileManager = ({
onFolderChange = () => {},
onSelect,
onSelectionChange,
+ onUndo = () => {},
+ onRedo = () => {},
onError = () => {},
layout = "grid",
enableFilePreview = true,
@@ -54,19 +74,55 @@ const FileManager = ({
permissions: userPermissions = {},
collapsibleNav = false,
defaultNavExpanded = true,
+ showStatusBar = true,
+ showNotifications = true,
+ theme = "light",
+ customTokens = {},
className = "",
style = {},
formatDate = defaultFormatDate,
+ onSearch,
+ onFileDetails,
+ defaultDetailsPanelOpen = false,
+ onFavoriteToggle,
+ onRecentFiles,
+ initialFavorites,
+ // Iteration 3 props
+ enableTabs = false,
+ maxTabs = 10,
+ onTabChange,
+ onExternalDrop,
+ onOperationProgress,
+ tags: availableTags,
+ onTagChange,
+ columns: initialColumns,
+ onColumnConfigChange,
+ onClipboardChange,
}) => {
const [isNavigationPaneOpen, setNavigationPaneOpen] = useState(defaultNavExpanded);
const triggerAction = useTriggerAction();
const { containerRef, colSizes, isDragging, handleMouseMove, handleMouseUp, handleMouseDown } =
useColumnResize(20, 80);
+ // Build token overrides from customTokens prop. Keys can be provided
+ // with or without the "--fm-" prefix; bare names are auto-prefixed.
+ const tokenOverrides = useMemo(() => {
+ const overrides = {};
+ if (customTokens && typeof customTokens === "object") {
+ Object.entries(customTokens).forEach(([key, value]) => {
+ if (value == null) return;
+ const cssVar = key.startsWith("--") ? key : `--fm-${key}`;
+ overrides[cssVar] = value;
+ });
+ }
+ return overrides;
+ }, [customTokens]);
+
const customStyles = {
"--file-manager-font-family": fontFamily,
"--file-manager-primary-color": primaryColor,
height,
width,
+ ...tokenOverrides,
};
const permissions = useMemo(
@@ -74,14 +130,38 @@ const FileManager = ({
[userPermissions]
);
+ const handleExternalDrop = useCallback(
+ (e) => {
+ if (!onExternalDrop) return;
+ e.preventDefault();
+ const droppedFiles = Array.from(e.dataTransfer.files);
+ if (droppedFiles.length > 0) {
+ onExternalDrop(droppedFiles, e);
+ }
+ },
+ [onExternalDrop]
+ );
+
+ const handleExternalDragOver = useCallback(
+ (e) => {
+ if (!onExternalDrop) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "copy";
+ },
+ [onExternalDrop]
+ );
+
return (
e.preventDefault()}
+ onDrop={handleExternalDrop}
+ onDragOver={handleExternalDragOver}
style={{ ...customStyles, ...style }}
>
+
-
-
-
-
-
+
+
+
+
+
-
-
+
+
+
+ {enableTabs && }
+
-
+
+
+
+
-
-
-
-
-
-
+
+
+ {showStatusBar && }
+
+
+
+
+
+
+
+
+
+
+
);
@@ -171,6 +281,7 @@ FileManager.propTypes = {
path: PropTypes.string.isRequired,
updatedAt: dateStringValidator,
size: PropTypes.number,
+ thumbnailUrl: PropTypes.string,
})
).isRequired,
fileUploadConfig: PropTypes.shape({
@@ -194,6 +305,8 @@ FileManager.propTypes = {
onFolderChange: PropTypes.func,
onSelect: PropTypes.func,
onSelectionChange: PropTypes.func,
+ onUndo: PropTypes.func,
+ onRedo: PropTypes.func,
onError: PropTypes.func,
layout: PropTypes.oneOf(["grid", "list"]),
maxFileSize: PropTypes.number,
@@ -218,9 +331,33 @@ FileManager.propTypes = {
}),
collapsibleNav: PropTypes.bool,
defaultNavExpanded: PropTypes.bool,
+ showStatusBar: PropTypes.bool,
+ showNotifications: PropTypes.bool,
+ theme: PropTypes.oneOf(["light", "dark", "system"]),
+ customTokens: PropTypes.object,
className: PropTypes.string,
style: PropTypes.object,
formatDate: PropTypes.func,
+ onSearch: PropTypes.func,
+ onFavoriteToggle: PropTypes.func,
+ onRecentFiles: PropTypes.func,
+ initialFavorites: PropTypes.arrayOf(PropTypes.string),
+ // Iteration 3 props
+ enableTabs: PropTypes.bool,
+ maxTabs: PropTypes.number,
+ onTabChange: PropTypes.func,
+ onExternalDrop: PropTypes.func,
+ onOperationProgress: PropTypes.func,
+ tags: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ color: PropTypes.string.isRequired,
+ })
+ ),
+ onTagChange: PropTypes.func,
+ columns: PropTypes.arrayOf(PropTypes.string),
+ onColumnConfigChange: PropTypes.func,
+ onClipboardChange: PropTypes.func,
};
export default FileManager;
diff --git a/frontend/src/FileManager/FileManager.scss b/frontend/src/FileManager/FileManager.scss
index 1e4658f5..ba4f06a4 100644
--- a/frontend/src/FileManager/FileManager.scss
+++ b/frontend/src/FileManager/FileManager.scss
@@ -1,4 +1,5 @@
@import "@fontsource/nunito-sans";
+@import "../styles/tokens";
@import "../styles/variables";
.text-white {
@@ -36,6 +37,7 @@ svg {
font-family: var(--file-manager-font-family);
border: 1px solid $border-color;
border-radius: 8px;
+ overflow: hidden;
button {
font-family: var(--file-manager-font-family);
@@ -52,6 +54,7 @@ svg {
.files-container {
display: flex;
height: calc(100% - 46px); // Toolbar total height = baseHeight: 35px, padding top + bottom: 10px, border: 1px.
+ overflow: hidden;
.navigation-pane {
z-index: 1;
@@ -94,6 +97,11 @@ svg {
padding-left: 0px;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
}
}
}
diff --git a/frontend/src/FileManager/NavigationPane/FolderTree.jsx b/frontend/src/FileManager/NavigationPane/FolderTree.jsx
index 3e954f03..bcdde79c 100644
--- a/frontend/src/FileManager/NavigationPane/FolderTree.jsx
+++ b/frontend/src/FileManager/NavigationPane/FolderTree.jsx
@@ -41,6 +41,10 @@ const FolderTree = ({ folder, onFileOpen }) => {
<>
@@ -73,6 +77,9 @@ const FolderTree = ({ folder, onFileOpen }) => {
return (
diff --git a/frontend/src/FileManager/NavigationPane/NavigationPane.jsx b/frontend/src/FileManager/NavigationPane/NavigationPane.jsx
index 7e859f92..8a82fa7b 100644
--- a/frontend/src/FileManager/NavigationPane/NavigationPane.jsx
+++ b/frontend/src/FileManager/NavigationPane/NavigationPane.jsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import FolderTree from "./FolderTree";
+import QuickAccess from "./QuickAccess";
import { getParentPath } from "../../utils/getParentPath";
import { useFiles } from "../../contexts/FilesContext";
import { useTranslation } from "../../contexts/TranslationProvider";
@@ -34,7 +35,9 @@ const NavigationPane = ({ onFileOpen }) => {
}, [files]);
return (
-
+
+
+
{foldersTree?.length > 0 ? (
<>
{foldersTree?.map((folder, index) => {
diff --git a/frontend/src/FileManager/NavigationPane/NavigationPane.scss b/frontend/src/FileManager/NavigationPane/NavigationPane.scss
index c764a1dd..6aeb0026 100644
--- a/frontend/src/FileManager/NavigationPane/NavigationPane.scss
+++ b/frontend/src/FileManager/NavigationPane/NavigationPane.scss
@@ -64,6 +64,12 @@
}
}
+ .nav-pane-divider {
+ border: none;
+ border-top: 1px solid #e0e0e0;
+ margin: 4px 5px;
+ }
+
.empty-nav-pane {
display: flex;
justify-content: center;
diff --git a/frontend/src/FileManager/NavigationPane/QuickAccess.jsx b/frontend/src/FileManager/NavigationPane/QuickAccess.jsx
new file mode 100644
index 00000000..03d66f13
--- /dev/null
+++ b/frontend/src/FileManager/NavigationPane/QuickAccess.jsx
@@ -0,0 +1,191 @@
+import React, { useState, useMemo } from "react";
+import { MdStar, MdAccessTime, MdKeyboardArrowDown } from "react-icons/md";
+import { FaRegFile, FaRegFolderOpen } from "react-icons/fa6";
+import { useFavorites } from "../../contexts/FavoritesContext";
+import { useFiles } from "../../contexts/FilesContext";
+import { useFileNavigation } from "../../contexts/FileNavigationContext";
+import { useTranslation } from "../../contexts/TranslationProvider";
+import { getParentPath } from "../../utils/getParentPath";
+import "./QuickAccess.scss";
+
+const getTimeLabel = (accessedAt, t) => {
+ const now = new Date();
+ const accessed = new Date(accessedAt);
+
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const startOfYesterday = new Date(startOfToday);
+ startOfYesterday.setDate(startOfYesterday.getDate() - 1);
+ const startOfWeek = new Date(startOfToday);
+ startOfWeek.setDate(startOfWeek.getDate() - startOfToday.getDay());
+
+ if (accessed >= startOfToday) {
+ return t("today");
+ } else if (accessed >= startOfYesterday) {
+ return t("yesterday");
+ } else if (accessed >= startOfWeek) {
+ return t("thisWeek");
+ }
+ return t("earlier");
+};
+
+const QuickAccess = ({ onFileOpen }) => {
+ const [favoritesOpen, setFavoritesOpen] = useState(true);
+ const [recentsOpen, setRecentsOpen] = useState(true);
+ const { favorites, recentFiles } = useFavorites();
+ const { files } = useFiles();
+ const { setCurrentPath, onFolderChange } = useFileNavigation();
+ const t = useTranslation();
+
+ // Resolve favorite paths to file objects
+ const favoriteFiles = useMemo(() => {
+ if (!files || favorites.size === 0) return [];
+ return files.filter((file) => favorites.has(file.path));
+ }, [files, favorites]);
+
+ const handleItemClick = (file) => {
+ if (file.isDirectory) {
+ // Navigate to the folder
+ onFileOpen(file);
+ setCurrentPath(file.path);
+ onFolderChange?.(file.path);
+ } else {
+ // Navigate to parent folder and select the file
+ const parentPath = getParentPath(file.path);
+ setCurrentPath(parentPath);
+ onFolderChange?.(parentPath);
+ onFileOpen(file);
+ }
+ };
+
+ return (
+
+ {/* Favorites Section */}
+
+
setFavoritesOpen((prev) => !prev)}
+ role="button"
+ aria-expanded={favoritesOpen}
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ setFavoritesOpen((prev) => !prev);
+ }
+ }}
+ >
+
+
+
+
+ {t("quickAccess")}
+
+
+
+
+
+ {favoritesOpen && (
+
+ {favoriteFiles.length > 0 ? (
+ favoriteFiles.map((file) => (
+
handleItemClick(file)}
+ title={file.name}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleItemClick(file);
+ }
+ }}
+ >
+
+
+
+
+ {file.isDirectory ? (
+
+ ) : (
+
+ )}
+
+ {file.name}
+
+ ))
+ ) : (
+
{t("noFavorites")}
+ )}
+
+ )}
+
+
+ {/* Recent Section */}
+
+
setRecentsOpen((prev) => !prev)}
+ role="button"
+ aria-expanded={recentsOpen}
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ setRecentsOpen((prev) => !prev);
+ }
+ }}
+ >
+
+
+
+
+ {t("recent")}
+
+
+
+
+
+ {recentsOpen && (
+
+ {recentFiles.length > 0 ? (
+ recentFiles.map((file) => (
+
handleItemClick(file)}
+ title={file.name}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleItemClick(file);
+ }
+ }}
+ >
+
+ {file.isDirectory ? (
+
+ ) : (
+
+ )}
+
+ {file.name}
+
+ {getTimeLabel(file.accessedAt, t)}
+
+
+ ))
+ ) : (
+
{t("noRecentFiles")}
+ )}
+
+ )}
+
+
+ );
+};
+
+QuickAccess.displayName = "QuickAccess";
+
+export default QuickAccess;
diff --git a/frontend/src/FileManager/NavigationPane/QuickAccess.scss b/frontend/src/FileManager/NavigationPane/QuickAccess.scss
new file mode 100644
index 00000000..8711edc1
--- /dev/null
+++ b/frontend/src/FileManager/NavigationPane/QuickAccess.scss
@@ -0,0 +1,112 @@
+@import "../../styles/variables";
+
+.quick-access-container {
+ font-size: $fm-font-size;
+ padding: 4px 4px 0 4px;
+
+ .quick-access-section {
+ margin-bottom: 4px;
+
+ .section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 6px 5px;
+ cursor: pointer;
+ border-radius: 4px;
+ user-select: none;
+
+ &:hover {
+ background-color: $item-hover-color;
+ }
+
+ .section-header-left {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 600;
+ font-size: 0.85em;
+
+ .section-icon {
+ display: flex;
+ align-items: center;
+ color: #f5a623;
+ }
+
+ .section-icon.recent-icon {
+ color: #888;
+ }
+ }
+
+ .collapse-toggle {
+ display: flex;
+ align-items: center;
+ color: #888;
+ transition: transform 0.3s ease;
+
+ &.collapsed {
+ transform: rotate(-90deg);
+ }
+ }
+ }
+
+ .section-items {
+ .quick-access-item {
+ display: flex;
+ align-items: center;
+ padding: 5px 5px 5px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+ gap: 6px;
+ font-size: 0.85em;
+
+ &:hover {
+ background-color: $item-hover-color;
+ }
+
+ .item-star {
+ display: flex;
+ align-items: center;
+ color: #f5a623;
+ flex-shrink: 0;
+ }
+
+ .item-icon {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ }
+
+ .item-name {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+ min-width: 0;
+ }
+
+ .item-time-label {
+ font-size: 0.75em;
+ color: #999;
+ white-space: nowrap;
+ flex-shrink: 0;
+ margin-left: auto;
+ padding-left: 8px;
+ }
+ }
+
+ .empty-state {
+ padding: 8px 10px;
+ font-size: 0.8em;
+ color: #999;
+ font-style: italic;
+ }
+ }
+ }
+
+ .quick-access-divider {
+ border: none;
+ border-top: 1px solid #e0e0e0;
+ margin: 4px 5px;
+ }
+}
diff --git a/frontend/src/FileManager/SearchBar/FilterChips.jsx b/frontend/src/FileManager/SearchBar/FilterChips.jsx
new file mode 100644
index 00000000..2e4c1425
--- /dev/null
+++ b/frontend/src/FileManager/SearchBar/FilterChips.jsx
@@ -0,0 +1,64 @@
+import { useCallback } from "react";
+import { useSearch } from "../../contexts/SearchContext";
+import { useTranslation } from "../../contexts/TranslationProvider";
+import "./FilterChips.scss";
+
+const TYPE_CHIPS = [
+ { key: "Images", labelKey: "filterImages" },
+ { key: "Documents", labelKey: "filterDocuments" },
+ { key: "Videos", labelKey: "filterVideos" },
+ { key: "Audio", labelKey: "filterAudio" },
+ { key: "Code", labelKey: "filterCode" },
+ { key: "Archives", labelKey: "filterArchives" },
+];
+
+const FilterChips = () => {
+ const { activeFilters, toggleFilter } = useSearch();
+ const t = useTranslation();
+
+ const handleChipClick = useCallback(
+ (filterKey) => {
+ toggleFilter(filterKey);
+ },
+ [toggleFilter]
+ );
+
+ return (
+
+
+ {TYPE_CHIPS.map((chip) => (
+ handleChipClick(chip.key)}
+ aria-pressed={activeFilters.includes(chip.key)}
+ type="button"
+ >
+ {t(chip.labelKey)}
+
+ ))}
+
+
+
+
+
+ handleChipClick("This Week")}
+ aria-pressed={activeFilters.includes("This Week")}
+ type="button"
+ >
+ {t("filterThisWeek")}
+
+
+
+ );
+};
+
+FilterChips.displayName = "FilterChips";
+
+export default FilterChips;
diff --git a/frontend/src/FileManager/SearchBar/FilterChips.scss b/frontend/src/FileManager/SearchBar/FilterChips.scss
new file mode 100644
index 00000000..05e83c1a
--- /dev/null
+++ b/frontend/src/FileManager/SearchBar/FilterChips.scss
@@ -0,0 +1,76 @@
+@import "../../styles/variables";
+
+.filter-chips-container {
+ display: flex;
+ align-items: center;
+ gap: var(--fm-space-2);
+ padding: var(--fm-space-1) var(--fm-space-3);
+ overflow-x: auto;
+ font-family: var(--fm-font-family);
+ border-bottom: 1px solid $border-color;
+
+ // Hide scrollbar but keep scrollable
+ scrollbar-width: none; // Firefox
+ -ms-overflow-style: none; // IE/Edge
+
+ &::-webkit-scrollbar {
+ display: none; // Chrome/Safari
+ }
+
+ .filter-chips-group {
+ display: flex;
+ align-items: center;
+ gap: var(--fm-space-1);
+ flex-shrink: 0;
+ }
+
+ .filter-chips-separator {
+ width: 1px;
+ height: 20px;
+ background-color: var(--fm-color-border);
+ flex-shrink: 0;
+ }
+
+ .filter-chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--fm-space-1) var(--fm-space-3);
+ border: 1px solid var(--fm-color-border);
+ border-radius: var(--fm-radius-full);
+ background-color: transparent;
+ color: var(--fm-color-text-secondary);
+ font-size: var(--fm-font-size-xs);
+ font-family: var(--fm-font-family);
+ font-weight: var(--fm-font-weight-medium);
+ cursor: pointer;
+ white-space: nowrap;
+ transition: background-color var(--fm-transition-fast),
+ color var(--fm-transition-fast),
+ border-color var(--fm-transition-fast),
+ box-shadow var(--fm-transition-fast);
+
+ &:hover {
+ background-color: var(--fm-color-surface-hover);
+ border-color: var(--fm-color-border-hover);
+ color: var(--fm-color-text-primary);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--fm-color-primary);
+ outline-offset: 1px;
+ }
+
+ &.active {
+ background-color: var(--fm-color-primary);
+ color: var(--fm-color-primary-text);
+ border-color: var(--fm-color-primary);
+ box-shadow: var(--fm-shadow-sm);
+
+ &:hover {
+ background-color: var(--fm-color-primary-hover);
+ border-color: var(--fm-color-primary-hover);
+ }
+ }
+ }
+}
diff --git a/frontend/src/FileManager/SearchBar/HighlightText.jsx b/frontend/src/FileManager/SearchBar/HighlightText.jsx
new file mode 100644
index 00000000..612cb158
--- /dev/null
+++ b/frontend/src/FileManager/SearchBar/HighlightText.jsx
@@ -0,0 +1,62 @@
+import { useMemo } from "react";
+import PropTypes from "prop-types";
+
+/**
+ * HighlightText renders text with matched portions wrapped in
tags.
+ * Matching is case-insensitive.
+ *
+ * @param {string} text - The full text to display.
+ * @param {string} highlight - The query string to highlight within the text.
+ */
+const HighlightText = ({ text, highlight }) => {
+ const parts = useMemo(() => {
+ if (!highlight || !highlight.trim()) {
+ return null;
+ }
+
+ // Escape special regex characters in the highlight string
+ const escaped = highlight.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const regex = new RegExp(`(${escaped})`, "gi");
+
+ // When splitting by a capturing group, matched portions appear at odd indices
+ const splitParts = text.split(regex);
+
+ return splitParts
+ .filter((part) => part !== "")
+ .map((part, index) => ({
+ text: part,
+ isMatch: part.toLowerCase() === highlight.toLowerCase(),
+ }));
+ }, [text, highlight]);
+
+ if (!parts) {
+ return <>{text}>;
+ }
+
+ return (
+ <>
+ {parts.map((part, index) =>
+ part.isMatch ? (
+
+ {part.text}
+
+ ) : (
+ {part.text}
+ )
+ )}
+ >
+ );
+};
+
+HighlightText.displayName = "HighlightText";
+
+HighlightText.propTypes = {
+ text: PropTypes.string.isRequired,
+ highlight: PropTypes.string,
+};
+
+HighlightText.defaultProps = {
+ highlight: "",
+};
+
+export default HighlightText;
diff --git a/frontend/src/FileManager/SearchBar/SearchBar.jsx b/frontend/src/FileManager/SearchBar/SearchBar.jsx
new file mode 100644
index 00000000..4605f71e
--- /dev/null
+++ b/frontend/src/FileManager/SearchBar/SearchBar.jsx
@@ -0,0 +1,157 @@
+import { useRef, useState, useCallback, useEffect } from "react";
+import { FiSearch } from "react-icons/fi";
+import { MdClear } from "react-icons/md";
+import { useSearch } from "../../contexts/SearchContext";
+import { useTranslation } from "../../contexts/TranslationProvider";
+import FilterChips from "./FilterChips";
+import PropTypes from "prop-types";
+import "./SearchBar.scss";
+
+const SearchBar = ({ onSearch }) => {
+ const {
+ searchQuery,
+ setSearchQuery,
+ searchResults,
+ isSearchActive,
+ isSearching,
+ isRecursive,
+ setIsRecursive,
+ clearSearch,
+ } = useSearch();
+ const t = useTranslation();
+ const inputRef = useRef(null);
+ const [localQuery, setLocalQuery] = useState(searchQuery);
+ const debounceTimerRef = useRef(null);
+
+ // Sync local query with context when context resets (e.g., path change)
+ useEffect(() => {
+ setLocalQuery(searchQuery);
+ }, [searchQuery]);
+
+ // Auto-focus when search is activated (via Ctrl+F or toolbar button)
+ useEffect(() => {
+ if (isSearchActive && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [isSearchActive]);
+
+ // Debounced search update (200ms)
+ const updateSearch = useCallback(
+ (query) => {
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+ debounceTimerRef.current = setTimeout(() => {
+ if (onSearch) {
+ onSearch(query);
+ } else {
+ setSearchQuery(query);
+ }
+ }, 200);
+ },
+ [onSearch, setSearchQuery]
+ );
+
+ // Cleanup debounce timer on unmount
+ useEffect(() => {
+ return () => {
+ if (debounceTimerRef.current) {
+ clearTimeout(debounceTimerRef.current);
+ }
+ };
+ }, []);
+
+ const handleInputChange = useCallback(
+ (e) => {
+ const value = e.target.value;
+ setLocalQuery(value);
+ updateSearch(value);
+ },
+ [updateSearch]
+ );
+
+ const handleClear = useCallback(() => {
+ setLocalQuery("");
+ clearSearch();
+ if (onSearch) {
+ onSearch("");
+ }
+ inputRef.current?.focus();
+ }, [clearSearch, onSearch]);
+
+ const handleToggleRecursive = useCallback(() => {
+ setIsRecursive((prev) => !prev);
+ }, [setIsRecursive]);
+
+ const handleKeyDown = useCallback(
+ (e) => {
+ if (e.key === "Escape") {
+ if (localQuery) {
+ handleClear();
+ } else {
+ inputRef.current?.blur();
+ }
+ e.stopPropagation();
+ }
+ },
+ [localQuery, handleClear]
+ );
+
+ if (!isSearchActive) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {isSearching && (
+
+ {t("searchResults", { count: searchResults.length })}
+
+ )}
+ {localQuery && (
+
+
+
+ )}
+
+
+ {t("recursiveSearch")}
+
+
+
+
+ );
+};
+
+SearchBar.displayName = "SearchBar";
+
+SearchBar.propTypes = {
+ onSearch: PropTypes.func,
+};
+
+export default SearchBar;
diff --git a/frontend/src/FileManager/SearchBar/SearchBar.scss b/frontend/src/FileManager/SearchBar/SearchBar.scss
new file mode 100644
index 00000000..abe375b0
--- /dev/null
+++ b/frontend/src/FileManager/SearchBar/SearchBar.scss
@@ -0,0 +1,147 @@
+@import "../../styles/variables";
+
+.search-bar-wrapper {
+ animation: fm-slide-down 0.2s ease-out forwards;
+ overflow: hidden;
+ border-bottom: 1px solid $border-color;
+}
+
+.search-bar-container {
+ display: flex;
+ align-items: center;
+ gap: var(--fm-space-2);
+ padding: var(--fm-space-1) var(--fm-space-3);
+ font-family: var(--fm-font-family);
+
+ .search-input-wrapper {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ gap: var(--fm-space-2);
+ padding: var(--fm-space-1) var(--fm-space-2);
+ border-bottom: 2px solid transparent;
+ border-radius: var(--fm-radius-sm) var(--fm-radius-sm) 0 0;
+ transition: border-color var(--fm-transition-normal),
+ background-color var(--fm-transition-normal);
+ background-color: var(--fm-color-bg-secondary);
+
+ &:hover {
+ background-color: var(--fm-color-bg-tertiary);
+ }
+
+ &:focus-within,
+ &.active {
+ border-bottom-color: var(--fm-color-border-focus);
+ background-color: var(--fm-color-bg);
+ }
+
+ .search-icon {
+ color: var(--fm-color-text-tertiary);
+ flex-shrink: 0;
+ transition: color var(--fm-transition-normal);
+ }
+
+ &:focus-within .search-icon {
+ color: var(--fm-color-primary);
+ }
+
+ .search-bar-input {
+ flex: 1;
+ border: none;
+ outline: none;
+ background: transparent;
+ font-size: var(--fm-font-size-base);
+ font-family: var(--fm-font-family);
+ color: var(--fm-color-text-primary);
+ line-height: var(--fm-line-height-normal);
+ min-width: 0;
+
+ &::placeholder {
+ color: var(--fm-color-text-tertiary);
+ }
+ }
+
+ .search-result-count {
+ font-size: var(--fm-font-size-xs);
+ color: var(--fm-color-text-tertiary);
+ white-space: nowrap;
+ flex-shrink: 0;
+ }
+
+ .search-clear-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ padding: var(--fm-space-1);
+ border-radius: var(--fm-radius-full);
+ color: var(--fm-color-text-secondary);
+ transition: background-color var(--fm-transition-fast),
+ color var(--fm-transition-fast);
+ flex-shrink: 0;
+
+ &:hover {
+ background-color: var(--fm-color-surface-active);
+ color: var(--fm-color-text-primary);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--fm-color-primary);
+ outline-offset: 1px;
+ }
+ }
+ }
+
+ .search-recursive-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ border: 1px solid var(--fm-color-border);
+ border-radius: var(--fm-radius-md);
+ cursor: pointer;
+ padding: var(--fm-space-1) var(--fm-space-2);
+ font-size: var(--fm-font-size-xs);
+ font-family: var(--fm-font-family);
+ font-weight: var(--fm-font-weight-medium);
+ color: var(--fm-color-text-secondary);
+ white-space: nowrap;
+ transition: background-color var(--fm-transition-fast),
+ color var(--fm-transition-fast),
+ border-color var(--fm-transition-fast);
+
+ &:hover {
+ background-color: var(--fm-color-surface-hover);
+ border-color: var(--fm-color-border-hover);
+ }
+
+ &.active {
+ background-color: var(--fm-color-primary);
+ color: var(--fm-color-primary-text);
+ border-color: var(--fm-color-primary);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--fm-color-primary);
+ outline-offset: 1px;
+ }
+ }
+}
+
+// Responsive: full width on small screens
+@media (max-width: 480px) {
+ .search-bar-container {
+ flex-wrap: wrap;
+
+ .search-input-wrapper {
+ width: 100%;
+ flex: 1 1 100%;
+ }
+
+ .search-recursive-btn {
+ flex: 0 0 auto;
+ }
+ }
+}
diff --git a/frontend/src/FileManager/StatusBar/StatusBar.jsx b/frontend/src/FileManager/StatusBar/StatusBar.jsx
new file mode 100644
index 00000000..9beec818
--- /dev/null
+++ b/frontend/src/FileManager/StatusBar/StatusBar.jsx
@@ -0,0 +1,61 @@
+import { useMemo } from "react";
+import { useFileNavigation } from "../../contexts/FileNavigationContext";
+import { useSelection } from "../../contexts/SelectionContext";
+import { useLayout } from "../../contexts/LayoutContext";
+import { getDataSize } from "../../utils/getDataSize";
+import { useTranslation } from "../../contexts/TranslationProvider";
+import "./StatusBar.scss";
+
+const StatusBar = () => {
+ const { currentPathFiles, sortConfig } = useFileNavigation();
+ const { selectedFiles } = useSelection();
+ const { activeLayout } = useLayout();
+ const t = useTranslation();
+
+ const stats = useMemo(() => {
+ const folders = currentPathFiles.filter((f) => f.isDirectory).length;
+ const files = currentPathFiles.length - folders;
+ const totalSize = currentPathFiles.reduce((acc, f) => acc + (f.size || 0), 0);
+ return { folders, files, totalSize };
+ }, [currentPathFiles]);
+
+ const selectionInfo = useMemo(() => {
+ if (selectedFiles.length === 0) return null;
+ const totalSize = selectedFiles.reduce((acc, f) => acc + (f.size || 0), 0);
+ return {
+ count: selectedFiles.length,
+ totalSize,
+ };
+ }, [selectedFiles]);
+
+ return (
+
+
+ {selectionInfo ? (
+
+ {selectionInfo.count} {t(selectionInfo.count === 1 ? "itemSelected" : "itemsSelected")}
+ {selectionInfo.totalSize > 0 && ` (${getDataSize(selectionInfo.totalSize)})`}
+
+ ) : (
+
+ {stats.folders > 0 && `${stats.folders} ${t(stats.folders === 1 ? "folder" : "folders")}`}
+ {stats.folders > 0 && stats.files > 0 && ", "}
+ {stats.files > 0 && `${stats.files} ${t(stats.files === 1 ? "file" : "files")}`}
+ {currentPathFiles.length === 0 && t("folderEmpty")}
+
+ )}
+
+
+
+ {t("sortedBy")}: {t(sortConfig.key)} ({sortConfig.direction === "asc" ? "\u2191" : "\u2193"})
+
+ |
+ {t(activeLayout)}
+
+
+ );
+};
+
+StatusBar.displayName = "StatusBar";
+
+export default StatusBar;
diff --git a/frontend/src/FileManager/StatusBar/StatusBar.scss b/frontend/src/FileManager/StatusBar/StatusBar.scss
new file mode 100644
index 00000000..f100158f
--- /dev/null
+++ b/frontend/src/FileManager/StatusBar/StatusBar.scss
@@ -0,0 +1,30 @@
+.fm-status-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: var(--fm-statusbar-height, 28px);
+ padding: 0 var(--fm-space-3, 12px);
+ border-top: 1px solid var(--fm-color-border, #e0e0e8);
+ background: var(--fm-color-bg-secondary, #f8f9fa);
+ font-size: var(--fm-font-size-xs, 11px);
+ color: var(--fm-color-text-secondary, #5a5a7a);
+ font-family: var(--fm-font-family, "Nunito Sans", sans-serif);
+ user-select: none;
+ flex-shrink: 0;
+
+ .fm-status-left {
+ display: flex;
+ align-items: center;
+ gap: var(--fm-space-2, 8px);
+ }
+
+ .fm-status-right {
+ display: flex;
+ align-items: center;
+ gap: var(--fm-space-2, 8px);
+ }
+
+ .fm-status-divider {
+ color: var(--fm-color-border, #e0e0e8);
+ }
+}
diff --git a/frontend/src/FileManager/TabBar/TabBar.jsx b/frontend/src/FileManager/TabBar/TabBar.jsx
new file mode 100644
index 00000000..dc782d65
--- /dev/null
+++ b/frontend/src/FileManager/TabBar/TabBar.jsx
@@ -0,0 +1,102 @@
+import { useRef, useState } from "react";
+import { MdAdd, MdClose } from "react-icons/md";
+import { useTabs } from "../../contexts/TabsContext";
+import { useTranslation } from "../../contexts/TranslationProvider";
+import "./TabBar.scss";
+
+const TabBar = () => {
+ const { tabs, activeTabId, addTab, closeTab, setActiveTab, reorderTabs, canAddTab } = useTabs();
+ const t = useTranslation();
+ const dragTabRef = useRef(null);
+ const [dragOverIndex, setDragOverIndex] = useState(null);
+
+ const handleTabDragStart = (e, index) => {
+ dragTabRef.current = index;
+ e.dataTransfer.effectAllowed = "move";
+ e.dataTransfer.setData("text/plain", String(index));
+ };
+
+ const handleTabDragOver = (e, index) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ setDragOverIndex(index);
+ };
+
+ const handleTabDrop = (e, toIndex) => {
+ e.preventDefault();
+ const fromIndex = dragTabRef.current;
+ if (fromIndex != null && fromIndex !== toIndex) {
+ reorderTabs(fromIndex, toIndex);
+ }
+ dragTabRef.current = null;
+ setDragOverIndex(null);
+ };
+
+ const handleTabDragEnd = () => {
+ dragTabRef.current = null;
+ setDragOverIndex(null);
+ };
+
+ const handleCloseTab = (e, tabId) => {
+ e.stopPropagation();
+ closeTab(tabId);
+ };
+
+ const handleMiddleClick = (e, tabId) => {
+ if (e.button === 1) {
+ e.preventDefault();
+ closeTab(tabId);
+ }
+ };
+
+ return (
+
+
+ {tabs.map((tab, index) => (
+
setActiveTab(tab.id)}
+ onMouseDown={(e) => handleMiddleClick(e, tab.id)}
+ draggable
+ onDragStart={(e) => handleTabDragStart(e, index)}
+ onDragOver={(e) => handleTabDragOver(e, index)}
+ onDrop={(e) => handleTabDrop(e, index)}
+ onDragEnd={handleTabDragEnd}
+ title={tab.path || t("home")}
+ >
+ {tab.label || t("home")}
+ {tabs.length > 1 && (
+ handleCloseTab(e, tab.id)}
+ aria-label={`Close ${tab.label || "tab"}`}
+ tabIndex={-1}
+ >
+
+
+ )}
+
+ ))}
+
+ {canAddTab && (
+
addTab()}
+ aria-label="Open new tab"
+ title="New tab"
+ >
+
+
+ )}
+
+ );
+};
+
+TabBar.displayName = "TabBar";
+
+export default TabBar;
diff --git a/frontend/src/FileManager/TabBar/TabBar.scss b/frontend/src/FileManager/TabBar/TabBar.scss
new file mode 100644
index 00000000..af8a14ed
--- /dev/null
+++ b/frontend/src/FileManager/TabBar/TabBar.scss
@@ -0,0 +1,125 @@
+.fm-tab-bar {
+ display: flex;
+ align-items: center;
+ background-color: var(--fm-color-bg-secondary, #f8f9fa);
+ border-bottom: 1px solid var(--fm-color-border, #e0e0e8);
+ height: 36px;
+ padding: 0 4px;
+ overflow: hidden;
+ flex-shrink: 0;
+
+ .fm-tab-list {
+ display: flex;
+ align-items: stretch;
+ height: 100%;
+ overflow-x: auto;
+ overflow-y: hidden;
+ flex: 1;
+ min-width: 0;
+ gap: 1px;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ }
+
+ .fm-tab {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0 8px;
+ max-width: 180px;
+ min-width: 80px;
+ cursor: pointer;
+ border-radius: 6px 6px 0 0;
+ background-color: transparent;
+ border: 1px solid transparent;
+ border-bottom: none;
+ font-size: var(--fm-font-size-sm, 12px);
+ color: var(--fm-color-text-secondary, #5a5a7a);
+ transition: background-color 150ms ease, color 150ms ease;
+ user-select: none;
+ position: relative;
+
+ &:hover {
+ background-color: var(--fm-color-surface-hover, rgba(0, 0, 0, 0.04));
+ color: var(--fm-color-text-primary, #1a1a2e);
+ }
+
+ &.fm-tab-active {
+ background-color: var(--fm-color-bg, #ffffff);
+ color: var(--fm-color-text-primary, #1a1a2e);
+ border-color: var(--fm-color-border, #e0e0e8);
+ font-weight: var(--fm-font-weight-medium, 500);
+
+ &::after {
+ content: "";
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background-color: var(--fm-color-bg, #ffffff);
+ }
+ }
+
+ &.fm-tab-drag-over {
+ border-left: 2px solid var(--fm-color-primary, #6155b4);
+ }
+
+ .fm-tab-label {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1;
+ min-width: 0;
+ }
+
+ .fm-tab-close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border: none;
+ background: transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ color: inherit;
+ opacity: 0;
+ transition: opacity 100ms ease, background-color 100ms ease;
+ flex-shrink: 0;
+ padding: 0;
+
+ &:hover {
+ background-color: var(--fm-color-surface-active, rgba(0, 0, 0, 0.08));
+ }
+ }
+
+ &:hover .fm-tab-close,
+ &.fm-tab-active .fm-tab-close {
+ opacity: 1;
+ }
+ }
+
+ .fm-tab-add {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: none;
+ background: transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ color: var(--fm-color-text-secondary, #5a5a7a);
+ flex-shrink: 0;
+ padding: 0;
+ margin-left: 2px;
+
+ &:hover {
+ background-color: var(--fm-color-surface-hover, rgba(0, 0, 0, 0.04));
+ color: var(--fm-color-text-primary, #1a1a2e);
+ }
+ }
+}
diff --git a/frontend/src/FileManager/TagMenu/TagMenu.jsx b/frontend/src/FileManager/TagMenu/TagMenu.jsx
new file mode 100644
index 00000000..d0c43c38
--- /dev/null
+++ b/frontend/src/FileManager/TagMenu/TagMenu.jsx
@@ -0,0 +1,65 @@
+import { MdCheck } from "react-icons/md";
+import { useTags } from "../../contexts/TagsContext";
+import { useTranslation } from "../../contexts/TranslationProvider";
+import "./TagMenu.scss";
+
+const TagMenu = ({ file }) => {
+ const { tags, toggleTag, hasTag } = useTags();
+ const t = useTranslation();
+
+ if (!tags || tags.length === 0) return null;
+
+ return (
+
+
{t("tags") || "Tags"}
+ {tags.map((tag) => {
+ const isActive = hasTag(file.path, tag.name);
+ return (
+
{
+ e.stopPropagation();
+ toggleTag(file, tag.name);
+ }}
+ >
+
+ {tag.name}
+ {isActive && (
+
+
+
+ )}
+
+ );
+ })}
+
+ );
+};
+
+export const TagBadges = ({ file }) => {
+ const { getFileTags } = useTags();
+ const fileTags = getFileTags(file.path);
+
+ if (fileTags.length === 0) return null;
+
+ return (
+
+ {fileTags.map((tag) => (
+
+ ))}
+
+ );
+};
+
+TagMenu.displayName = "TagMenu";
+
+export default TagMenu;
diff --git a/frontend/src/FileManager/TagMenu/TagMenu.scss b/frontend/src/FileManager/TagMenu/TagMenu.scss
new file mode 100644
index 00000000..0b4df188
--- /dev/null
+++ b/frontend/src/FileManager/TagMenu/TagMenu.scss
@@ -0,0 +1,65 @@
+.fm-tag-menu {
+ padding: 4px 0;
+
+ .fm-tag-menu-title {
+ padding: 6px 12px 4px;
+ font-size: var(--fm-font-size-xs, 11px);
+ font-weight: var(--fm-font-weight-semibold, 600);
+ color: var(--fm-color-text-tertiary, #8a8aa0);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+
+ .fm-tag-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 6px 12px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ font-size: var(--fm-font-size-sm, 12px);
+ color: var(--fm-color-text-primary, #1a1a2e);
+ text-align: left;
+
+ &:hover {
+ background: var(--fm-color-surface-hover, rgba(0, 0, 0, 0.04));
+ }
+
+ &.fm-tag-active {
+ font-weight: var(--fm-font-weight-medium, 500);
+ }
+
+ .fm-tag-color-dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ }
+
+ .fm-tag-name {
+ flex: 1;
+ }
+
+ .fm-tag-check {
+ display: flex;
+ align-items: center;
+ color: var(--fm-color-primary, #6155b4);
+ }
+ }
+}
+
+.fm-tag-badges {
+ display: flex;
+ gap: 3px;
+ align-items: center;
+ flex-shrink: 0;
+
+ .fm-tag-badge {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ }
+}
diff --git a/frontend/src/FileManager/Toolbar/Toolbar.jsx b/frontend/src/FileManager/Toolbar/Toolbar.jsx
index 41475788..66df7abe 100644
--- a/frontend/src/FileManager/Toolbar/Toolbar.jsx
+++ b/frontend/src/FileManager/Toolbar/Toolbar.jsx
@@ -1,11 +1,12 @@
import { useState } from "react";
import { BsCopy, BsFolderPlus, BsGridFill, BsScissors } from "react-icons/bs";
-import { FiRefreshCw } from "react-icons/fi";
+import { FiRefreshCw, FiSearch } from "react-icons/fi";
import {
MdClear,
MdOutlineDelete,
MdOutlineFileDownload,
MdOutlineFileUpload,
+ MdOutlineInfo,
} from "react-icons/md";
import { BiRename } from "react-icons/bi";
import { FaListUl, FaRegPaste } from "react-icons/fa6";
@@ -16,6 +17,8 @@ import { useClipBoard } from "../../contexts/ClipboardContext";
import { useLayout } from "../../contexts/LayoutContext";
import { validateApiCallback } from "../../utils/validateApiCallback";
import { useTranslation } from "../../contexts/TranslationProvider";
+import { useDetailsPanel } from "../../contexts/DetailsPanelContext";
+import { useSearch } from "../../contexts/SearchContext";
import "./Toolbar.scss";
const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => {
@@ -24,6 +27,8 @@ const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => {
const { selectedFiles, setSelectedFiles, handleDownload } = useSelection();
const { clipBoard, setClipBoard, handleCutCopy, handlePasting } = useClipBoard();
const { activeLayout } = useLayout();
+ const { isDetailsPanelOpen, toggleDetailsPanel } = useDetailsPanel();
+ const { isSearchActive, setIsSearchActive } = useSearch();
const t = useTranslation();
// Toolbar Items
@@ -48,7 +53,23 @@ const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => {
},
];
+ const handleToggleSearch = () => {
+ setIsSearchActive((prev) => !prev);
+ };
+
const toolbarRightItems = [
+ {
+ icon: ,
+ title: t("search"),
+ onClick: handleToggleSearch,
+ active: isSearchActive,
+ },
+ {
+ icon: ,
+ title: t("toggleDetailsPanel"),
+ onClick: toggleDetailsPanel,
+ active: isDetailsPanelOpen,
+ },
{
icon: activeLayout === "grid" ? : ,
title: t("changeView"),
@@ -76,17 +97,17 @@ const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => {
// Selected File/Folder Actions
if (selectedFiles.length > 0) {
return (
-
+
{permissions.move && (
-
handleCutCopy(true)}>
+ handleCutCopy(true)}>
{t("cut")}
)}
{permissions.copy && (
- handleCutCopy(false)}>
+ handleCutCopy(false)}>
{t("copy")}
@@ -94,6 +115,7 @@ const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => {
{clipBoard?.files?.length > 0 && (
@@ -104,6 +126,7 @@ const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => {
{selectedFiles.length === 1 && permissions.rename && (
triggerAction.show("rename")}
>
@@ -111,7 +134,7 @@ const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => {
)}
{permissions.download && (
-
+
{t("download")}
@@ -119,6 +142,7 @@ const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => {
{permissions.delete && (
triggerAction.show("delete")}
>
@@ -129,6 +153,7 @@ const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => {
setSelectedFiles([])}
>
@@ -144,13 +169,13 @@ const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => {
//
return (
-
+
{toolbarLeftItems
.filter((item) => item.permission)
.map((item, index) => (
-
+
{item.icon}
{item.text}
@@ -159,7 +184,13 @@ const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => {
{toolbarRightItems.map((item, index) => (
-
+
{item.icon}
{index !== toolbarRightItems.length - 1 &&
}
diff --git a/frontend/src/FileManager/Toolbar/Toolbar.scss b/frontend/src/FileManager/Toolbar/Toolbar.scss
index a3d92d53..e21382da 100644
--- a/frontend/src/FileManager/Toolbar/Toolbar.scss
+++ b/frontend/src/FileManager/Toolbar/Toolbar.scss
@@ -124,6 +124,12 @@
&:hover {
color: var(--file-manager-primary-color);
}
+
+ &.active {
+ color: var(--file-manager-primary-color);
+ background-color: rgb(0 0 0 / 8%);
+ border-radius: 3px;
+ }
}
.item-separator {
diff --git a/frontend/src/components/ContextMenu/ContextMenu.jsx b/frontend/src/components/ContextMenu/ContextMenu.jsx
index 36b914f9..835d3a96 100644
--- a/frontend/src/components/ContextMenu/ContextMenu.jsx
+++ b/frontend/src/components/ContextMenu/ContextMenu.jsx
@@ -80,7 +80,7 @@ const ContextMenu = ({ filesViewRef, contextMenuRef, menuItems, visible, clickPo
}}
>
-
+
{menuItems
.filter((item) => !item.hidden)
.map((item, index) => {
@@ -89,9 +89,11 @@ const ContextMenu = ({ filesViewRef, contextMenuRef, menuItems, visible, clickPo
return (
handleMouseOver(index)}
+ {...(hasChildren ? { "aria-haspopup": "true", "aria-expanded": activeSubMenu } : {})}
>
{item.icon}
{item.title}
diff --git a/frontend/src/components/ContextMenu/SubMenu.jsx b/frontend/src/components/ContextMenu/SubMenu.jsx
index 6e9dc08d..62228ea4 100644
--- a/frontend/src/components/ContextMenu/SubMenu.jsx
+++ b/frontend/src/components/ContextMenu/SubMenu.jsx
@@ -2,9 +2,9 @@ import { FaCheck } from "react-icons/fa6";
const SubMenu = ({ subMenuRef, list, position = "right" }) => {
return (
-
+
{list?.map((item) => (
-
+
{item.selected && }
{item.icon}
{item.title}
diff --git a/frontend/src/components/Loader/Loader.jsx b/frontend/src/components/Loader/Loader.jsx
index 1afcbf22..55b9b26c 100644
--- a/frontend/src/components/Loader/Loader.jsx
+++ b/frontend/src/components/Loader/Loader.jsx
@@ -5,7 +5,7 @@ const Loader = ({ loading = false, className }) => {
if (!loading) return null;
return (
-
+
);
diff --git a/frontend/src/components/Modal/Modal.jsx b/frontend/src/components/Modal/Modal.jsx
index e612f5d0..39d62143 100644
--- a/frontend/src/components/Modal/Modal.jsx
+++ b/frontend/src/components/Modal/Modal.jsx
@@ -35,9 +35,11 @@ const Modal = ({
className={`fm-modal dialog`}
style={{ width: dialogWidth }}
onKeyDown={handleKeyDown}
+ aria-modal="true"
+ aria-labelledby="fm-modal-heading-id"
>
-
{heading}
+
{heading}
{closeButton && (
+
{!error && (
diff --git a/frontend/src/components/ScreenReaderAnnouncer/ScreenReaderAnnouncer.jsx b/frontend/src/components/ScreenReaderAnnouncer/ScreenReaderAnnouncer.jsx
new file mode 100644
index 00000000..84e95c7e
--- /dev/null
+++ b/frontend/src/components/ScreenReaderAnnouncer/ScreenReaderAnnouncer.jsx
@@ -0,0 +1,39 @@
+import { useState, useCallback, createContext, useContext } from "react";
+
+const AnnouncerContext = createContext(() => {});
+
+export const AnnouncerProvider = ({ children }) => {
+ const [message, setMessage] = useState("");
+
+ const announce = useCallback((text) => {
+ setMessage("");
+ // Force re-render by clearing then setting
+ requestAnimationFrame(() => setMessage(text));
+ }, []);
+
+ return (
+
+ {children}
+
+ {message}
+
+
+ );
+};
+
+export const useAnnounce = () => useContext(AnnouncerContext);
diff --git a/frontend/src/components/Toast/Toast.jsx b/frontend/src/components/Toast/Toast.jsx
new file mode 100644
index 00000000..c77d4a4a
--- /dev/null
+++ b/frontend/src/components/Toast/Toast.jsx
@@ -0,0 +1,99 @@
+import { useState, useCallback, useMemo, createContext, useContext, useEffect, useRef } from "react";
+import { MdClose, MdCheckCircle, MdError, MdWarning, MdInfo } from "react-icons/md";
+import "./Toast.scss";
+
+const ToastContext = createContext(null);
+
+const TOAST_ICONS = {
+ success:
,
+ error:
,
+ warning:
,
+ info:
,
+};
+
+const MAX_TOASTS = 3;
+const DEFAULT_DURATION = 4000;
+
+let toastId = 0;
+
+const ToastItem = ({ toast, onDismiss }) => {
+ const [isExiting, setIsExiting] = useState(false);
+ const timerRef = useRef(null);
+
+ useEffect(() => {
+ if (toast.duration !== 0) {
+ timerRef.current = setTimeout(() => {
+ handleDismiss();
+ }, toast.duration || DEFAULT_DURATION);
+ }
+ return () => clearTimeout(timerRef.current);
+ }, []);
+
+ const handleDismiss = () => {
+ setIsExiting(true);
+ setTimeout(() => onDismiss(toast.id), 200);
+ };
+
+ return (
+
+
{TOAST_ICONS[toast.type]}
+
+ {toast.message}
+
+ {toast.action && (
+
+ {toast.action.label}
+
+ )}
+
+
+
+
+ );
+};
+
+export const ToastProvider = ({ children, position = "bottom-right" }) => {
+ const [toasts, setToasts] = useState([]);
+
+ const addToast = useCallback(({ type = "info", message, duration = DEFAULT_DURATION, action }) => {
+ const id = ++toastId;
+ setToasts((prev) => {
+ const newToasts = [...prev, { id, type, message, duration, action }];
+ return newToasts.slice(-MAX_TOASTS);
+ });
+ return id;
+ }, []);
+
+ const removeToast = useCallback((id) => {
+ setToasts((prev) => prev.filter((t) => t.id !== id));
+ }, []);
+
+ const toast = useMemo(
+ () => ({
+ success: (message, opts) => addToast({ type: "success", message, ...opts }),
+ error: (message, opts) => addToast({ type: "error", message, ...opts }),
+ warning: (message, opts) => addToast({ type: "warning", message, ...opts }),
+ info: (message, opts) => addToast({ type: "info", message, ...opts }),
+ }),
+ [addToast]
+ );
+
+ return (
+
+ {children}
+ {toasts.length > 0 && (
+
+ {toasts.map((t) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+export const useToast = () => useContext(ToastContext);
diff --git a/frontend/src/components/Toast/Toast.scss b/frontend/src/components/Toast/Toast.scss
new file mode 100644
index 00000000..78e9f18f
--- /dev/null
+++ b/frontend/src/components/Toast/Toast.scss
@@ -0,0 +1,146 @@
+.fm-toast-container {
+ position: fixed;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ z-index: var(--fm-z-toast, 700);
+ pointer-events: none;
+ max-width: 380px;
+
+ &.fm-toast-bottom-right {
+ bottom: 16px;
+ right: 16px;
+ }
+ &.fm-toast-bottom-left {
+ bottom: 16px;
+ left: 16px;
+ }
+ &.fm-toast-top-right {
+ top: 16px;
+ right: 16px;
+ }
+ &.fm-toast-top-left {
+ top: 16px;
+ left: 16px;
+ }
+}
+
+.fm-toast {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 14px;
+ border-radius: var(--fm-radius-lg, 8px);
+ background: var(--fm-color-surface, #fff);
+ border: 1px solid var(--fm-color-border, #e0e0e8);
+ box-shadow: var(--fm-shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1));
+ pointer-events: auto;
+ font-family: var(--fm-font-family, "Nunito Sans", sans-serif);
+ font-size: var(--fm-font-size-md, 13px);
+ color: var(--fm-color-text-primary, #1a1a2e);
+ min-width: 280px;
+
+ &.fm-toast-enter {
+ animation: fm-toast-slide-in 200ms ease-out forwards;
+ }
+ &.fm-toast-exit {
+ animation: fm-toast-fade-out 200ms ease-in forwards;
+ }
+
+ .fm-toast-icon {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ }
+
+ &.fm-toast-success .fm-toast-icon {
+ color: var(--fm-color-success, #22c55e);
+ }
+ &.fm-toast-error .fm-toast-icon {
+ color: var(--fm-color-error, #ef4444);
+ }
+ &.fm-toast-warning .fm-toast-icon {
+ color: var(--fm-color-warning, #f59e0b);
+ }
+ &.fm-toast-info .fm-toast-icon {
+ color: var(--fm-color-info, #3b82f6);
+ }
+
+ &.fm-toast-success {
+ border-left: 3px solid var(--fm-color-success, #22c55e);
+ }
+ &.fm-toast-error {
+ border-left: 3px solid var(--fm-color-error, #ef4444);
+ }
+ &.fm-toast-warning {
+ border-left: 3px solid var(--fm-color-warning, #f59e0b);
+ }
+ &.fm-toast-info {
+ border-left: 3px solid var(--fm-color-info, #3b82f6);
+ }
+
+ .fm-toast-content {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .fm-toast-message {
+ line-height: 1.4;
+ }
+
+ .fm-toast-action {
+ flex-shrink: 0;
+ background: none;
+ border: none;
+ color: var(--fm-color-primary, #6155b4);
+ font-weight: 600;
+ cursor: pointer;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: inherit;
+ font-family: inherit;
+
+ &:hover {
+ background: var(--fm-color-primary-light, rgba(97, 85, 180, 0.1));
+ }
+ }
+
+ .fm-toast-close {
+ flex-shrink: 0;
+ background: none;
+ border: none;
+ color: var(--fm-color-text-tertiary, #8a8aa0);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+
+ &:hover {
+ background: var(--fm-color-surface-hover, rgba(0, 0, 0, 0.04));
+ color: var(--fm-color-text-primary, #1a1a2e);
+ }
+ }
+}
+
+@keyframes fm-toast-slide-in {
+ from {
+ opacity: 0;
+ transform: translateY(12px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes fm-toast-fade-out {
+ from {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+}
diff --git a/frontend/src/contexts/BatchOperationsContext.jsx b/frontend/src/contexts/BatchOperationsContext.jsx
new file mode 100644
index 00000000..6fd0ad0d
--- /dev/null
+++ b/frontend/src/contexts/BatchOperationsContext.jsx
@@ -0,0 +1,126 @@
+import { createContext, useCallback, useContext, useMemo, useReducer } from "react";
+
+const BatchOperationsContext = createContext();
+
+function batchReducer(state, action) {
+ switch (action.type) {
+ case "START_BATCH":
+ return {
+ ...state,
+ isActive: true,
+ operationType: action.operationType,
+ items: action.items.map((item, index) => ({
+ ...item,
+ id: index,
+ status: "pending",
+ progress: 0,
+ })),
+ overallProgress: 0,
+ completed: 0,
+ failed: 0,
+ skipped: 0,
+ cancelled: false,
+ };
+ case "UPDATE_ITEM": {
+ const items = state.items.map((item) =>
+ item.id === action.itemId ? { ...item, ...action.updates } : item
+ );
+ const completed = items.filter((i) => i.status === "completed").length;
+ const failed = items.filter((i) => i.status === "failed").length;
+ const skipped = items.filter((i) => i.status === "skipped").length;
+ const done = completed + failed + skipped;
+ const overallProgress = items.length > 0 ? Math.round((done / items.length) * 100) : 0;
+ return { ...state, items, completed, failed, skipped, overallProgress };
+ }
+ case "CANCEL_BATCH":
+ return {
+ ...state,
+ cancelled: true,
+ items: state.items.map((item) =>
+ item.status === "pending" ? { ...item, status: "skipped" } : item
+ ),
+ };
+ case "CLOSE_BATCH":
+ return {
+ ...state,
+ isActive: false,
+ operationType: null,
+ items: [],
+ overallProgress: 0,
+ completed: 0,
+ failed: 0,
+ skipped: 0,
+ cancelled: false,
+ };
+ default:
+ return state;
+ }
+}
+
+const initialState = {
+ isActive: false,
+ operationType: null,
+ items: [],
+ overallProgress: 0,
+ completed: 0,
+ failed: 0,
+ skipped: 0,
+ cancelled: false,
+};
+
+export const BatchOperationsProvider = ({ children, onOperationProgress }) => {
+ const [state, dispatch] = useReducer(batchReducer, initialState);
+
+ const startBatch = useCallback(
+ (operationType, items) => {
+ dispatch({ type: "START_BATCH", operationType, items });
+ onOperationProgress?.({
+ type: "start",
+ operationType,
+ totalItems: items.length,
+ });
+ },
+ [onOperationProgress]
+ );
+
+ const updateItem = useCallback(
+ (itemId, updates) => {
+ dispatch({ type: "UPDATE_ITEM", itemId, updates });
+ onOperationProgress?.({
+ type: "progress",
+ itemId,
+ ...updates,
+ });
+ },
+ [onOperationProgress]
+ );
+
+ const cancelBatch = useCallback(() => {
+ dispatch({ type: "CANCEL_BATCH" });
+ onOperationProgress?.({ type: "cancel" });
+ }, [onOperationProgress]);
+
+ const closeBatch = useCallback(() => {
+ dispatch({ type: "CLOSE_BATCH" });
+ onOperationProgress?.({ type: "close" });
+ }, [onOperationProgress]);
+
+ const value = useMemo(
+ () => ({
+ ...state,
+ startBatch,
+ updateItem,
+ cancelBatch,
+ closeBatch,
+ }),
+ [state, startBatch, updateItem, cancelBatch, closeBatch]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useBatchOperations = () => useContext(BatchOperationsContext);
diff --git a/frontend/src/contexts/ClipboardContext.jsx b/frontend/src/contexts/ClipboardContext.jsx
index 24e2792b..a031c46b 100644
--- a/frontend/src/contexts/ClipboardContext.jsx
+++ b/frontend/src/contexts/ClipboardContext.jsx
@@ -1,5 +1,6 @@
import { createContext, useContext, useState } from "react";
import { useSelection } from "./SelectionContext";
+import { useUndoRedo } from "./UndoRedoContext";
import { validateApiCallback } from "../utils/validateApiCallback";
const ClipBoardContext = createContext();
@@ -7,6 +8,7 @@ const ClipBoardContext = createContext();
export const ClipBoardProvider = ({ children, onPaste, onCut, onCopy }) => {
const [clipBoard, setClipBoard] = useState(null);
const { selectedFiles, setSelectedFiles } = useSelection();
+ const { pushAction } = useUndoRedo();
const handleCutCopy = (isMoving) => {
setClipBoard({
@@ -30,6 +32,11 @@ export const ClipBoardProvider = ({ children, onPaste, onCut, onCopy }) => {
validateApiCallback(onPaste, "onPaste", copiedFiles, destinationFolder, operationType);
+ pushAction({
+ type: clipBoard.isMoving ? "move" : "copy",
+ data: { files: copiedFiles, destination: destinationFolder },
+ });
+
clipBoard.isMoving && setClipBoard(null);
setSelectedFiles([]);
};
diff --git a/frontend/src/contexts/DetailsPanelContext.jsx b/frontend/src/contexts/DetailsPanelContext.jsx
new file mode 100644
index 00000000..23c09f8a
--- /dev/null
+++ b/frontend/src/contexts/DetailsPanelContext.jsx
@@ -0,0 +1,21 @@
+import { createContext, useCallback, useContext, useState } from "react";
+
+const DetailsPanelContext = createContext();
+
+export const DetailsPanelProvider = ({ children, defaultOpen = false }) => {
+ const [isDetailsPanelOpen, setDetailsPanelOpen] = useState(defaultOpen);
+
+ const toggleDetailsPanel = useCallback(() => {
+ setDetailsPanelOpen((prev) => !prev);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useDetailsPanel = () => useContext(DetailsPanelContext);
diff --git a/frontend/src/contexts/FavoritesContext.jsx b/frontend/src/contexts/FavoritesContext.jsx
new file mode 100644
index 00000000..6eda5a8f
--- /dev/null
+++ b/frontend/src/contexts/FavoritesContext.jsx
@@ -0,0 +1,85 @@
+import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
+
+const FavoritesContext = createContext();
+
+const MAX_RECENT_FILES = 20;
+
+export const FavoritesProvider = ({
+ children,
+ onFavoriteToggle,
+ onRecentFiles,
+ initialFavorites = [],
+}) => {
+ const [favorites, setFavorites] = useState(() => new Set(initialFavorites));
+ const [recentFiles, setRecentFiles] = useState([]);
+ const isMountRef = useRef(true);
+
+ // Notify parent when recents change (skip initial mount)
+ useEffect(() => {
+ if (isMountRef.current) {
+ isMountRef.current = false;
+ return;
+ }
+ onRecentFiles?.(recentFiles);
+ }, [recentFiles]);
+
+ const toggleFavorite = useCallback(
+ (file) => {
+ setFavorites((prev) => {
+ const next = new Set(prev);
+ const isFavorited = next.has(file.path);
+ if (isFavorited) {
+ next.delete(file.path);
+ } else {
+ next.add(file.path);
+ }
+ onFavoriteToggle?.(file, !isFavorited);
+ return next;
+ });
+ },
+ [onFavoriteToggle]
+ );
+
+ const isFavorite = useCallback(
+ (filePath) => favorites.has(filePath),
+ [favorites]
+ );
+
+ const addToRecent = useCallback((file) => {
+ setRecentFiles((prev) => {
+ // Remove existing entry with same path (deduplicate)
+ const filtered = prev.filter((f) => f.path !== file.path);
+ // Add to front with timestamp
+ const entry = { ...file, accessedAt: Date.now() };
+ const next = [entry, ...filtered];
+ // Cap at max
+ return next.slice(0, MAX_RECENT_FILES);
+ });
+ }, []);
+
+ const clearRecents = useCallback(() => {
+ setRecentFiles([]);
+ }, []);
+
+ const clearFavorites = useCallback(() => {
+ setFavorites(new Set());
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useFavorites = () => useContext(FavoritesContext);
diff --git a/frontend/src/contexts/FileNavigationContext.jsx b/frontend/src/contexts/FileNavigationContext.jsx
index 13546e0c..eb46e4d0 100644
--- a/frontend/src/contexts/FileNavigationContext.jsx
+++ b/frontend/src/contexts/FileNavigationContext.jsx
@@ -1,4 +1,4 @@
-import { createContext, useContext, useEffect, useRef, useState } from "react";
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useFiles } from "./FilesContext";
import sortFiles from "../utils/sortFiles";
diff --git a/frontend/src/contexts/SearchContext.jsx b/frontend/src/contexts/SearchContext.jsx
new file mode 100644
index 00000000..c24510de
--- /dev/null
+++ b/frontend/src/contexts/SearchContext.jsx
@@ -0,0 +1,171 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
+import { useFiles } from "./FilesContext";
+import { useFileNavigation } from "./FileNavigationContext";
+
+const SearchContext = createContext();
+
+// File type categories mapping
+const FILE_TYPE_CATEGORIES = {
+ Images: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp"],
+ Documents: [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".rtf"],
+ Videos: [".mp4", ".avi", ".mov", ".mkv", ".webm"],
+ Audio: [".mp3", ".wav", ".ogg", ".flac", ".aac"],
+ Code: [".js", ".jsx", ".ts", ".tsx", ".py", ".java", ".cpp", ".c", ".html", ".css", ".scss", ".json", ".xml", ".yaml", ".yml", ".md"],
+ Archives: [".zip", ".rar", ".7z", ".tar", ".gz"],
+};
+
+/**
+ * Get the file extension from a file name (lowercase, with dot).
+ * @param {string} name - The file name.
+ * @returns {string} The lowercase extension with dot, or empty string.
+ */
+const getExtension = (name) => {
+ if (!name || typeof name !== "string") return "";
+ const dotIndex = name.lastIndexOf(".");
+ return dotIndex > 0 ? name.substring(dotIndex).toLowerCase() : "";
+};
+
+/**
+ * Check if a file matches any of the given active type filter categories.
+ * @param {object} file - The file object.
+ * @param {string[]} activeFilters - Array of active filter names (e.g., ["Images", "Documents"]).
+ * @returns {boolean}
+ */
+const matchesTypeFilters = (file, activeFilters) => {
+ // Filter out "This Week" since it's handled separately
+ const typeFilters = activeFilters.filter((f) => f !== "This Week");
+ if (typeFilters.length === 0) return true;
+ if (file.isDirectory) return false;
+
+ const ext = getExtension(file.name);
+ return typeFilters.some((filterName) => {
+ const extensions = FILE_TYPE_CATEGORIES[filterName];
+ return extensions ? extensions.includes(ext) : false;
+ });
+};
+
+/**
+ * Check if a file was updated within the last 7 days.
+ * @param {object} file - The file object.
+ * @returns {boolean}
+ */
+const matchesThisWeekFilter = (file) => {
+ if (!file.updatedAt) return false;
+
+ const fileDate = new Date(file.updatedAt);
+ if (isNaN(fileDate.getTime())) return false;
+
+ const now = new Date();
+ const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+ return fileDate >= sevenDaysAgo;
+};
+
+/**
+ * Fuzzy match: case-insensitive partial name match.
+ * Also supports matching without extension.
+ * @param {string} fileName - The file name.
+ * @param {string} query - The search query.
+ * @returns {boolean}
+ */
+const fuzzyMatch = (fileName, query) => {
+ if (!query) return true;
+ const lowerName = fileName.toLowerCase();
+ const lowerQuery = query.toLowerCase();
+
+ // Match against full name
+ if (lowerName.includes(lowerQuery)) return true;
+
+ // Match against name without extension
+ const dotIndex = lowerName.lastIndexOf(".");
+ if (dotIndex > 0) {
+ const nameWithoutExt = lowerName.substring(0, dotIndex);
+ if (nameWithoutExt.includes(lowerQuery)) return true;
+ }
+
+ return false;
+};
+
+export const SearchProvider = ({ children, onSearch }) => {
+ const { files } = useFiles();
+ const { currentPath, currentPathFiles } = useFileNavigation();
+
+ const [searchQuery, setSearchQuery] = useState("");
+ const [isSearchActive, setIsSearchActive] = useState(false);
+ const [activeFilters, setActiveFilters] = useState([]);
+ const [isRecursive, setIsRecursive] = useState(false);
+
+ const isSearching = searchQuery.length > 0 || activeFilters.length > 0;
+
+ // Toggle a filter chip on/off
+ const toggleFilter = useCallback((filterName) => {
+ setActiveFilters((prev) => {
+ if (prev.includes(filterName)) {
+ return prev.filter((f) => f !== filterName);
+ }
+ return [...prev, filterName];
+ });
+ }, []);
+
+ // Clear all search state
+ const clearSearch = useCallback(() => {
+ setSearchQuery("");
+ setActiveFilters([]);
+ setIsSearchActive(false);
+ setIsRecursive(false);
+ }, []);
+
+ // Determine the source file list based on recursive flag
+ const sourceFiles = useMemo(() => {
+ if (isRecursive) {
+ return Array.isArray(files) ? files : [];
+ }
+ return Array.isArray(currentPathFiles) ? currentPathFiles : [];
+ }, [isRecursive, files, currentPathFiles]);
+
+ // Compute search results
+ const searchResults = useMemo(() => {
+ if (!isSearching) return currentPathFiles || [];
+
+ return sourceFiles.filter((file) => {
+ const matchesQuery = fuzzyMatch(file.name, searchQuery);
+ const matchesType = matchesTypeFilters(file, activeFilters);
+ const hasThisWeek = activeFilters.includes("This Week");
+ const matchesDate = hasThisWeek ? matchesThisWeekFilter(file) : true;
+ return matchesQuery && matchesType && matchesDate;
+ });
+ }, [sourceFiles, searchQuery, activeFilters, isSearching, currentPathFiles]);
+
+ // Clear search when navigating to a different path
+ useEffect(() => {
+ clearSearch();
+ }, [currentPath]);
+
+ // Notify onSearch callback when search changes
+ useEffect(() => {
+ if (onSearch && isSearching) {
+ onSearch(searchQuery, activeFilters);
+ }
+ }, [searchQuery, activeFilters]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useSearch = () => useContext(SearchContext);
diff --git a/frontend/src/contexts/TabsContext.jsx b/frontend/src/contexts/TabsContext.jsx
new file mode 100644
index 00000000..c1658884
--- /dev/null
+++ b/frontend/src/contexts/TabsContext.jsx
@@ -0,0 +1,118 @@
+import { createContext, useCallback, useContext, useMemo, useReducer } from "react";
+
+const TabsContext = createContext();
+
+let nextTabId = 1;
+
+function createTab(path = "", label = "Home") {
+ return {
+ id: nextTabId++,
+ path,
+ label: label || "Home",
+ sortConfig: { key: "name", direction: "asc" },
+ };
+}
+
+function tabsReducer(state, action) {
+ switch (action.type) {
+ case "ADD_TAB": {
+ if (state.tabs.length >= state.maxTabs) return state;
+ const newTab = createTab(action.path, action.label);
+ const tabs = [...state.tabs, newTab];
+ return { ...state, tabs, activeTabId: newTab.id };
+ }
+ case "CLOSE_TAB": {
+ if (state.tabs.length <= 1) return state;
+ const tabs = state.tabs.filter((t) => t.id !== action.tabId);
+ let activeTabId = state.activeTabId;
+ if (activeTabId === action.tabId) {
+ const closedIndex = state.tabs.findIndex((t) => t.id === action.tabId);
+ activeTabId = tabs[Math.min(closedIndex, tabs.length - 1)].id;
+ }
+ return { ...state, tabs, activeTabId };
+ }
+ case "SET_ACTIVE_TAB":
+ return { ...state, activeTabId: action.tabId };
+ case "UPDATE_TAB": {
+ const tabs = state.tabs.map((t) =>
+ t.id === action.tabId ? { ...t, ...action.updates } : t
+ );
+ return { ...state, tabs };
+ }
+ case "REORDER_TABS": {
+ const tabs = [...state.tabs];
+ const [moved] = tabs.splice(action.fromIndex, 1);
+ tabs.splice(action.toIndex, 0, moved);
+ return { ...state, tabs };
+ }
+ default:
+ return state;
+ }
+}
+
+export const TabsProvider = ({ children, maxTabs = 10, onTabChange }) => {
+ const [state, dispatch] = useReducer(tabsReducer, {
+ tabs: [createTab()],
+ activeTabId: 1,
+ maxTabs,
+ });
+
+ const activeTab = useMemo(
+ () => state.tabs.find((t) => t.id === state.activeTabId) || state.tabs[0],
+ [state.tabs, state.activeTabId]
+ );
+
+ const addTab = useCallback(
+ (path = "", label) => {
+ if (state.tabs.length >= maxTabs) return;
+ dispatch({ type: "ADD_TAB", path, label });
+ onTabChange?.({ type: "add", path });
+ },
+ [state.tabs.length, maxTabs, onTabChange]
+ );
+
+ const closeTab = useCallback(
+ (tabId) => {
+ if (state.tabs.length <= 1) return;
+ dispatch({ type: "CLOSE_TAB", tabId });
+ onTabChange?.({ type: "close", tabId });
+ },
+ [state.tabs.length, onTabChange]
+ );
+
+ const setActiveTab = useCallback(
+ (tabId) => {
+ dispatch({ type: "SET_ACTIVE_TAB", tabId });
+ const tab = state.tabs.find((t) => t.id === tabId);
+ onTabChange?.({ type: "switch", tabId, path: tab?.path });
+ },
+ [state.tabs, onTabChange]
+ );
+
+ const updateTab = useCallback((tabId, updates) => {
+ dispatch({ type: "UPDATE_TAB", tabId, updates });
+ }, []);
+
+ const reorderTabs = useCallback((fromIndex, toIndex) => {
+ dispatch({ type: "REORDER_TABS", fromIndex, toIndex });
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ tabs: state.tabs,
+ activeTab,
+ activeTabId: state.activeTabId,
+ addTab,
+ closeTab,
+ setActiveTab,
+ updateTab,
+ reorderTabs,
+ canAddTab: state.tabs.length < maxTabs,
+ }),
+ [state.tabs, activeTab, state.activeTabId, addTab, closeTab, setActiveTab, updateTab, reorderTabs, maxTabs]
+ );
+
+ return
{children} ;
+};
+
+export const useTabs = () => useContext(TabsContext);
diff --git a/frontend/src/contexts/TagsContext.jsx b/frontend/src/contexts/TagsContext.jsx
new file mode 100644
index 00000000..ddf4069c
--- /dev/null
+++ b/frontend/src/contexts/TagsContext.jsx
@@ -0,0 +1,96 @@
+import { createContext, useCallback, useContext, useMemo, useState } from "react";
+
+const TagsContext = createContext();
+
+const DEFAULT_TAG_COLORS = [
+ { name: "Red", color: "#ef4444" },
+ { name: "Orange", color: "#f97316" },
+ { name: "Yellow", color: "#eab308" },
+ { name: "Green", color: "#22c55e" },
+ { name: "Blue", color: "#3b82f6" },
+ { name: "Purple", color: "#a855f7" },
+ { name: "Gray", color: "#6b7280" },
+];
+
+export const TagsProvider = ({ children, availableTags, onTagChange }) => {
+ // fileTags: Map of filePath -> Set of tag names
+ const [fileTags, setFileTags] = useState(new Map());
+
+ const tags = useMemo(
+ () => availableTags || DEFAULT_TAG_COLORS,
+ [availableTags]
+ );
+
+ const getFileTags = useCallback(
+ (filePath) => {
+ const tagNames = fileTags.get(filePath);
+ if (!tagNames || tagNames.size === 0) return [];
+ return tags.filter((t) => tagNames.has(t.name));
+ },
+ [fileTags, tags]
+ );
+
+ const addTag = useCallback(
+ (file, tagName) => {
+ setFileTags((prev) => {
+ const next = new Map(prev);
+ const existing = next.get(file.path) || new Set();
+ const updated = new Set(existing);
+ updated.add(tagName);
+ next.set(file.path, updated);
+ return next;
+ });
+ onTagChange?.(file, tagName, "add");
+ },
+ [onTagChange]
+ );
+
+ const removeTag = useCallback(
+ (file, tagName) => {
+ setFileTags((prev) => {
+ const next = new Map(prev);
+ const existing = next.get(file.path);
+ if (!existing) return prev;
+ const updated = new Set(existing);
+ updated.delete(tagName);
+ if (updated.size === 0) {
+ next.delete(file.path);
+ } else {
+ next.set(file.path, updated);
+ }
+ return next;
+ });
+ onTagChange?.(file, tagName, "remove");
+ },
+ [onTagChange]
+ );
+
+ const toggleTag = useCallback(
+ (file, tagName) => {
+ const existing = fileTags.get(file.path);
+ if (existing && existing.has(tagName)) {
+ removeTag(file, tagName);
+ } else {
+ addTag(file, tagName);
+ }
+ },
+ [fileTags, addTag, removeTag]
+ );
+
+ const hasTag = useCallback(
+ (filePath, tagName) => {
+ const existing = fileTags.get(filePath);
+ return existing ? existing.has(tagName) : false;
+ },
+ [fileTags]
+ );
+
+ const value = useMemo(
+ () => ({ tags, fileTags, getFileTags, addTag, removeTag, toggleTag, hasTag }),
+ [tags, fileTags, getFileTags, addTag, removeTag, toggleTag, hasTag]
+ );
+
+ return
{children} ;
+};
+
+export const useTags = () => useContext(TagsContext);
diff --git a/frontend/src/contexts/UndoRedoContext.jsx b/frontend/src/contexts/UndoRedoContext.jsx
new file mode 100644
index 00000000..05e0523b
--- /dev/null
+++ b/frontend/src/contexts/UndoRedoContext.jsx
@@ -0,0 +1,99 @@
+import { createContext, useContext, useReducer, useCallback } from "react";
+
+const MAX_HISTORY = 50;
+
+const UndoRedoContext = createContext();
+
+const initialState = {
+ past: [],
+ future: [],
+};
+
+function undoRedoReducer(state, action) {
+ switch (action.type) {
+ case "PUSH_ACTION": {
+ const newPast = [...state.past, action.payload].slice(-MAX_HISTORY);
+ return {
+ past: newPast,
+ future: [], // Clear future on new action
+ };
+ }
+ case "UNDO": {
+ if (state.past.length === 0) return state;
+ const previous = state.past[state.past.length - 1];
+ const newPast = state.past.slice(0, -1);
+ return {
+ past: newPast,
+ future: [previous, ...state.future],
+ };
+ }
+ case "REDO": {
+ if (state.future.length === 0) return state;
+ const next = state.future[0];
+ const newFuture = state.future.slice(1);
+ return {
+ past: [...state.past, next],
+ future: newFuture,
+ };
+ }
+ case "CLEAR": {
+ return initialState;
+ }
+ default:
+ return state;
+ }
+}
+
+export const UndoRedoProvider = ({ children, onUndo, onRedo }) => {
+ const [state, dispatch] = useReducer(undoRedoReducer, initialState);
+
+ const pushAction = useCallback((action) => {
+ dispatch({
+ type: "PUSH_ACTION",
+ payload: {
+ ...action,
+ timestamp: Date.now(),
+ },
+ });
+ }, []);
+
+ const undo = useCallback(() => {
+ if (state.past.length === 0) return;
+ const action = state.past[state.past.length - 1];
+ dispatch({ type: "UNDO" });
+ onUndo?.(action);
+ }, [state.past, onUndo]);
+
+ const redo = useCallback(() => {
+ if (state.future.length === 0) return;
+ const action = state.future[0];
+ dispatch({ type: "REDO" });
+ onRedo?.(action);
+ }, [state.future, onRedo]);
+
+ const clear = useCallback(() => {
+ dispatch({ type: "CLEAR" });
+ }, []);
+
+ const canUndo = state.past.length > 0;
+ const canRedo = state.future.length > 0;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useUndoRedo = () => useContext(UndoRedoContext);
diff --git a/frontend/src/hooks/useShortcutHandler.js b/frontend/src/hooks/useShortcutHandler.js
index 70923a91..83a3b8ad 100644
--- a/frontend/src/hooks/useShortcutHandler.js
+++ b/frontend/src/hooks/useShortcutHandler.js
@@ -4,6 +4,9 @@ import { useClipBoard } from "../contexts/ClipboardContext";
import { useFileNavigation } from "../contexts/FileNavigationContext";
import { useSelection } from "../contexts/SelectionContext";
import { useLayout } from "../contexts/LayoutContext";
+import { useUndoRedo } from "../contexts/UndoRedoContext";
+import { useDetailsPanel } from "../contexts/DetailsPanelContext";
+import { useSearch } from "../contexts/SearchContext";
import { validateApiCallback } from "../utils/validateApiCallback";
export const useShortcutHandler = (triggerAction, onRefresh, permissions) => {
@@ -11,6 +14,9 @@ export const useShortcutHandler = (triggerAction, onRefresh, permissions) => {
const { currentFolder, currentPathFiles } = useFileNavigation();
const { selectedFiles, setSelectedFiles, handleDownload } = useSelection();
const { setActiveLayout } = useLayout();
+ const { undo, redo } = useUndoRedo();
+ const { toggleDetailsPanel } = useDetailsPanel();
+ const { setIsSearchActive } = useSearch();
const triggerCreateFolder = () => {
permissions.create && triggerAction.show("createFolder");
@@ -71,6 +77,14 @@ export const useShortcutHandler = (triggerAction, onRefresh, permissions) => {
setClipBoard(null);
};
+ const triggerUndo = () => {
+ undo();
+ };
+
+ const triggerRedo = () => {
+ redo();
+ };
+
const triggerGridLayout = () => {
setActiveLayout("grid");
};
@@ -78,6 +92,15 @@ export const useShortcutHandler = (triggerAction, onRefresh, permissions) => {
setActiveLayout("list");
};
+ const triggerSearch = () => {
+ setIsSearchActive(true);
+ // Focus happens via useEffect in SearchBar when isSearchActive becomes true
+ };
+
+ const triggerDetailsPanel = () => {
+ toggleDetailsPanel();
+ };
+
// Keypress detection will be disbaled when some Action is in active state.
useKeyPress(shortcuts.createFolder, triggerCreateFolder, triggerAction.isActive);
useKeyPress(shortcuts.uploadFiles, triggerUploadFiles, triggerAction.isActive);
@@ -91,7 +114,11 @@ export const useShortcutHandler = (triggerAction, onRefresh, permissions) => {
useKeyPress(shortcuts.jumpToLast, triggerSelectLast, triggerAction.isActive);
useKeyPress(shortcuts.selectAll, triggerSelectAll, triggerAction.isActive);
useKeyPress(shortcuts.clearSelection, triggerClearSelection, triggerAction.isActive);
+ useKeyPress(shortcuts.undo, triggerUndo, triggerAction.isActive);
+ useKeyPress(shortcuts.redo, triggerRedo, triggerAction.isActive);
useKeyPress(shortcuts.refresh, triggerRefresh, triggerAction.isActive);
useKeyPress(shortcuts.gridLayout, triggerGridLayout, triggerAction.isActive);
useKeyPress(shortcuts.listLayout, triggerListLayout, triggerAction.isActive);
+ useKeyPress(shortcuts.search, triggerSearch, triggerAction.isActive);
+ useKeyPress(shortcuts.detailsPanel, triggerDetailsPanel, triggerAction.isActive);
};
diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json
index 177ce61a..c34538f9 100644
--- a/frontend/src/locales/en-US.json
+++ b/frontend/src/locales/en-US.json
@@ -50,5 +50,75 @@
"invalidFileName": "A file name can't contain any of the following characters: \\ / : * ? \" < > |",
"folderExists": "This destination already contains a folder named \"{{renameFile}}\".",
"collapseNavigationPane": "Collapse Navigation Pane",
- "expandNavigationPane": "Expand Navigation Pane"
+ "expandNavigationPane": "Expand Navigation Pane",
+ "folder": "folder",
+ "folders": "folders",
+ "file": "file",
+ "files": "files",
+ "sortedBy": "Sorted by",
+ "zoomIn": "Zoom In",
+ "zoomOut": "Zoom Out",
+ "fitToScreen": "Fit to Screen",
+ "fullscreen": "Fullscreen",
+ "exitFullscreen": "Exit Fullscreen",
+ "previousFile": "Previous file",
+ "nextFile": "Next file",
+ "ofFiles": "of",
+ "details": "Details",
+ "detailsPanel": "Details Panel",
+ "toggleDetailsPanel": "Toggle Details Panel",
+ "type": "Type",
+ "path": "Path",
+ "contains": "Contains",
+ "items": "Items",
+ "totalItems": "Total Items",
+ "totalSize": "Total Size",
+ "location": "Location",
+ "image": "Image",
+ "document": "Document",
+ "video": "Video",
+ "audio": "Audio",
+ "codeFile": "Code File",
+ "archive": "Archive",
+ "unknownType": "File",
+ "item": "item",
+ "search": "Search",
+ "searchPlaceholder": "Search files and folders...",
+ "searchResults": "{{count}} results",
+ "noResults": "No results found",
+ "filterImages": "Images",
+ "filterDocuments": "Documents",
+ "filterVideos": "Videos",
+ "filterAudio": "Audio",
+ "filterCode": "Code",
+ "filterArchives": "Archives",
+ "filterThisWeek": "This Week",
+ "clearSearch": "Clear search",
+ "recursiveSearch": "Search all folders",
+ "quickAccess": "Quick Access",
+ "recent": "Recent",
+ "noFavorites": "No favorites yet",
+ "noRecentFiles": "No recent files",
+ "today": "Today",
+ "yesterday": "Yesterday",
+ "thisWeek": "This Week",
+ "earlier": "Earlier",
+ "addToFavorites": "Add to favorites",
+ "removeFromFavorites": "Remove from favorites",
+ "newTab": "New Tab",
+ "closeTab": "Close Tab",
+ "openInNewTab": "Open in New Tab",
+ "tags": "Tags",
+ "addTag": "Add Tag",
+ "removeTag": "Remove Tag",
+ "columns": "Columns",
+ "customizeColumns": "Customize Columns",
+ "move": "Move",
+ "operationComplete": "Operation Complete",
+ "succeeded": "succeeded",
+ "failed": "failed",
+ "skipped": "skipped",
+ "clearClipboard": "Clear Clipboard",
+ "batchProgress": "Batch Progress",
+ "externalDrop": "Drop files here"
}
\ No newline at end of file
diff --git a/frontend/src/styles/_animations.scss b/frontend/src/styles/_animations.scss
new file mode 100644
index 00000000..248c7721
--- /dev/null
+++ b/frontend/src/styles/_animations.scss
@@ -0,0 +1,99 @@
+// =============================================================
+// ANIMATIONS - React File Manager
+// =============================================================
+// Reusable keyframes, animation mixins, and reduced motion support
+// for Iteration 2 features (search, details panel, thumbnails, etc.)
+// =============================================================
+
+// ─── Slide Animations ──────────────────────────────────────
+@keyframes fm-slide-down {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ max-height: 0;
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ max-height: 200px;
+ }
+}
+
+@keyframes fm-slide-right {
+ from {
+ opacity: 0;
+ transform: translateX(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes fm-slide-left {
+ from {
+ opacity: 0;
+ transform: translateX(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+// ─── Fade Animations ───────────────────────────────────────
+@keyframes fm-fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes fm-fade-out {
+ from { opacity: 1; }
+ to { opacity: 0; }
+}
+
+// ─── Scale Animations ──────────────────────────────────────
+@keyframes fm-scale-in {
+ from {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+// ─── Skeleton Loading ──────────────────────────────────────
+@keyframes fm-skeleton-pulse {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+// ─── Animation Mixins ──────────────────────────────────────
+// Common animation patterns for consistent usage across components
+
+@mixin fm-animate-slide-down($duration: 0.2s) {
+ animation: fm-slide-down $duration ease-out forwards;
+}
+
+@mixin fm-animate-slide-right($duration: 0.2s) {
+ animation: fm-slide-right $duration ease-out forwards;
+}
+
+@mixin fm-animate-fade-in($duration: 0.2s) {
+ animation: fm-fade-in $duration ease-out forwards;
+}
+
+@mixin fm-animate-scale-in($duration: 0.15s) {
+ animation: fm-scale-in $duration ease-out forwards;
+}
+
+// ─── Reduced Motion Support ────────────────────────────────
+// Respects user preference for reduced motion
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/frontend/src/styles/_tokens.scss b/frontend/src/styles/_tokens.scss
new file mode 100644
index 00000000..95012ddb
--- /dev/null
+++ b/frontend/src/styles/_tokens.scss
@@ -0,0 +1,378 @@
+// =============================================================
+// DESIGN TOKENS - React File Manager
+// =============================================================
+// All design decisions expressed as CSS custom properties.
+// These tokens form the foundation for theming support.
+// =============================================================
+
+:root,
+.fm-theme-light {
+ // ─── Color Tokens ───────────────────────────────────────
+ // Primary
+ --fm-color-primary: var(--file-manager-primary-color, #6155b4);
+ --fm-color-primary-hover: #5248a0;
+ --fm-color-primary-active: #453d8c;
+ --fm-color-primary-light: rgba(97, 85, 180, 0.1);
+ --fm-color-primary-text: #ffffff;
+
+ // Surface & Background
+ --fm-color-bg: #ffffff;
+ --fm-color-bg-secondary: #f8f9fa;
+ --fm-color-bg-tertiary: #f0f1f3;
+ --fm-color-surface: #ffffff;
+ --fm-color-surface-hover: rgba(0, 0, 0, 0.04);
+ --fm-color-surface-active: rgba(0, 0, 0, 0.08);
+ --fm-color-surface-selected: rgba(97, 85, 180, 0.08);
+ --fm-color-surface-selected-hover: rgba(97, 85, 180, 0.12);
+
+ // Text
+ --fm-color-text-primary: #1a1a2e;
+ --fm-color-text-secondary: #5a5a7a;
+ --fm-color-text-tertiary: #8a8aa0;
+ --fm-color-text-disabled: #b0b0c0;
+ --fm-color-text-inverse: #ffffff;
+ --fm-color-text-link: var(--fm-color-primary);
+
+ // Border
+ --fm-color-border: #e0e0e8;
+ --fm-color-border-hover: #c0c0d0;
+ --fm-color-border-focus: var(--fm-color-primary);
+ --fm-color-border-selected: var(--fm-color-primary);
+
+ // Semantic Colors
+ --fm-color-success: #22c55e;
+ --fm-color-success-bg: rgba(34, 197, 94, 0.1);
+ --fm-color-warning: #f59e0b;
+ --fm-color-warning-bg: rgba(245, 158, 11, 0.1);
+ --fm-color-error: #ef4444;
+ --fm-color-error-bg: rgba(239, 68, 68, 0.1);
+ --fm-color-info: #3b82f6;
+ --fm-color-info-bg: rgba(59, 130, 246, 0.1);
+
+ // File Type Icon Colors
+ --fm-color-file-pdf: #e74c3c;
+ --fm-color-file-doc: #2b579a;
+ --fm-color-file-xls: #217346;
+ --fm-color-file-ppt: #d24726;
+ --fm-color-file-image: #8e44ad;
+ --fm-color-file-video: #e67e22;
+ --fm-color-file-audio: #1abc9c;
+ --fm-color-file-code: #3498db;
+ --fm-color-file-archive: #95a5a6;
+ --fm-color-file-default: #7f8c8d;
+
+ // ─── Spacing Tokens ────────────────────────────────────
+ --fm-space-1: 4px;
+ --fm-space-2: 8px;
+ --fm-space-3: 12px;
+ --fm-space-4: 16px;
+ --fm-space-5: 20px;
+ --fm-space-6: 24px;
+ --fm-space-8: 32px;
+ --fm-space-10: 40px;
+ --fm-space-12: 48px;
+ --fm-space-16: 64px;
+
+ // ─── Typography Tokens ──────────────────────────────────
+ --fm-font-family: var(--file-manager-font-family, 'Nunito Sans', sans-serif);
+ --fm-font-size-xs: 11px;
+ --fm-font-size-sm: 12px;
+ --fm-font-size-md: 13px;
+ --fm-font-size-base: 14px;
+ --fm-font-size-lg: 16px;
+ --fm-font-size-xl: 18px;
+ --fm-font-size-2xl: 20px;
+ --fm-font-size-3xl: 24px;
+
+ --fm-font-weight-normal: 400;
+ --fm-font-weight-medium: 500;
+ --fm-font-weight-semibold: 600;
+ --fm-font-weight-bold: 700;
+
+ --fm-line-height-tight: 1.25;
+ --fm-line-height-normal: 1.5;
+ --fm-line-height-relaxed: 1.75;
+
+ // ─── Border Radius Tokens ──────────────────────────────
+ --fm-radius-sm: 4px;
+ --fm-radius-md: 6px;
+ --fm-radius-lg: 8px;
+ --fm-radius-xl: 12px;
+ --fm-radius-full: 9999px;
+
+ // ─── Shadow / Elevation Tokens ─────────────────────────
+ --fm-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
+ --fm-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
+ --fm-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
+ --fm-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
+
+ // ─── Transition / Animation Tokens ─────────────────────
+ --fm-transition-fast: 100ms ease-in-out;
+ --fm-transition-normal: 200ms ease-in-out;
+ --fm-transition-slow: 300ms ease-in-out;
+ --fm-transition-spring: 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
+
+ --fm-duration-fast: 100ms;
+ --fm-duration-normal: 200ms;
+ --fm-duration-slow: 300ms;
+
+ // ─── Z-Index Tokens ────────────────────────────────────
+ --fm-z-dropdown: 100;
+ --fm-z-sticky: 200;
+ --fm-z-modal-backdrop: 300;
+ --fm-z-modal: 400;
+ --fm-z-popover: 500;
+ --fm-z-tooltip: 600;
+ --fm-z-toast: 700;
+
+ // ─── Sizing Tokens ─────────────────────────────────────
+ --fm-toolbar-height: 44px;
+ --fm-breadcrumb-height: 40px;
+ --fm-statusbar-height: 28px;
+ --fm-file-item-height-list: 36px;
+ --fm-file-item-height-grid: 110px;
+ --fm-icon-size-sm: 16px;
+ --fm-icon-size-md: 20px;
+ --fm-icon-size-lg: 24px;
+ --fm-icon-size-xl: 48px;
+ --fm-touch-target-min: 44px;
+
+ // ─── Scrollbar Tokens ──────────────────────────────────
+ --fm-scrollbar-width: 5px;
+ --fm-scrollbar-color: var(--fm-color-primary);
+ --fm-scrollbar-track: transparent;
+ --fm-scrollbar-radius: 8px;
+
+ // ─── Search Tokens ───────────────────────────────────
+ --fm-search-bg: #f8f9fa;
+ --fm-search-border: #dee2e6;
+ --fm-search-focus-border: var(--file-manager-primary-color);
+ --fm-search-placeholder: #999;
+ --fm-search-result-highlight: rgba(97, 85, 180, 0.15);
+
+ // ─── Filter Chip Tokens ──────────────────────────────
+ --fm-chip-bg: #f0f0f0;
+ --fm-chip-active-bg: var(--file-manager-primary-color);
+ --fm-chip-active-text: #fff;
+ --fm-chip-text: #555;
+ --fm-chip-hover-bg: #e0e0e0;
+
+ // ─── Details Panel Tokens ────────────────────────────
+ --fm-details-bg: #fafafa;
+ --fm-details-border: var(--fm-border-color, #cfcfcf);
+ --fm-details-label: #666;
+ --fm-details-value: #333;
+ --fm-details-section-border: #eee;
+ --fm-details-header-bg: #f5f5f5;
+
+ // ─── Quick Access Tokens ─────────────────────────────
+ --fm-quickaccess-bg: #f8f9fa;
+ --fm-quickaccess-hover: rgba(0, 0, 0, 0.05);
+ --fm-quickaccess-star: #f5a623;
+ --fm-quickaccess-header: #666;
+ --fm-quickaccess-divider: #eee;
+
+ // ─── Thumbnail Tokens ────────────────────────────────
+ --fm-thumbnail-bg: #f5f5f5;
+ --fm-thumbnail-border: #eee;
+ --fm-thumbnail-radius: 4px;
+
+ // ─── Preview Tokens ──────────────────────────────────
+ --fm-preview-controls-bg: #f8f9fa;
+ --fm-preview-controls-border: #dee2e6;
+}
+
+// =============================================================
+// DARK THEME TOKENS
+// =============================================================
+.fm-theme-dark {
+ // Primary
+ --fm-color-primary: var(--file-manager-primary-color, #8578d8);
+ --fm-color-primary-hover: #9a8ee5;
+ --fm-color-primary-active: #7468c4;
+ --fm-color-primary-light: rgba(133, 120, 216, 0.15);
+ --fm-color-primary-text: #ffffff;
+
+ // Surface & Background
+ --fm-color-bg: #1a1a2e;
+ --fm-color-bg-secondary: #222240;
+ --fm-color-bg-tertiary: #2a2a4a;
+ --fm-color-surface: #222240;
+ --fm-color-surface-hover: rgba(255, 255, 255, 0.06);
+ --fm-color-surface-active: rgba(255, 255, 255, 0.1);
+ --fm-color-surface-selected: rgba(133, 120, 216, 0.15);
+ --fm-color-surface-selected-hover: rgba(133, 120, 216, 0.22);
+
+ // Text
+ --fm-color-text-primary: #e8e8f0;
+ --fm-color-text-secondary: #a0a0b8;
+ --fm-color-text-tertiary: #707088;
+ --fm-color-text-disabled: #505068;
+ --fm-color-text-inverse: #1a1a2e;
+ --fm-color-text-link: var(--fm-color-primary);
+
+ // Border
+ --fm-color-border: #383858;
+ --fm-color-border-hover: #484870;
+ --fm-color-border-focus: var(--fm-color-primary);
+ --fm-color-border-selected: var(--fm-color-primary);
+
+ // Semantic Colors
+ --fm-color-success: #4ade80;
+ --fm-color-success-bg: rgba(74, 222, 128, 0.12);
+ --fm-color-warning: #fbbf24;
+ --fm-color-warning-bg: rgba(251, 191, 36, 0.12);
+ --fm-color-error: #f87171;
+ --fm-color-error-bg: rgba(248, 113, 113, 0.12);
+ --fm-color-info: #60a5fa;
+ --fm-color-info-bg: rgba(96, 165, 250, 0.12);
+
+ // Shadows (subtle on dark backgrounds)
+ --fm-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
+ --fm-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
+ --fm-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.35), 0 4px 6px -4px rgba(0, 0, 0, 0.35);
+ --fm-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.4);
+
+ // ─── Search Tokens ───────────────────────────────────
+ --fm-search-bg: #2a2a2a;
+ --fm-search-border: #444;
+ --fm-search-focus-border: var(--file-manager-primary-color);
+ --fm-search-placeholder: #888;
+ --fm-search-result-highlight: rgba(97, 85, 180, 0.25);
+
+ // ─── Filter Chip Tokens ──────────────────────────────
+ --fm-chip-bg: #333;
+ --fm-chip-active-bg: var(--file-manager-primary-color);
+ --fm-chip-active-text: #fff;
+ --fm-chip-text: #ccc;
+ --fm-chip-hover-bg: #444;
+
+ // ─── Details Panel Tokens ────────────────────────────
+ --fm-details-bg: #1e1e1e;
+ --fm-details-border: #444;
+ --fm-details-label: #999;
+ --fm-details-value: #ddd;
+ --fm-details-section-border: #333;
+ --fm-details-header-bg: #252525;
+
+ // ─── Quick Access Tokens ─────────────────────────────
+ --fm-quickaccess-bg: #1e1e1e;
+ --fm-quickaccess-hover: rgba(255, 255, 255, 0.05);
+ --fm-quickaccess-star: #f5a623;
+ --fm-quickaccess-header: #999;
+ --fm-quickaccess-divider: #333;
+
+ // ─── Thumbnail Tokens ────────────────────────────────
+ --fm-thumbnail-bg: #2a2a2a;
+ --fm-thumbnail-border: #444;
+ --fm-thumbnail-radius: 4px;
+
+ // ─── Preview Tokens ──────────────────────────────────
+ --fm-preview-controls-bg: #2a2a2a;
+ --fm-preview-controls-border: #444;
+}
+
+// System theme: follow OS preference
+@media (prefers-color-scheme: dark) {
+ .fm-theme-system {
+ // Primary
+ --fm-color-primary: var(--file-manager-primary-color, #8578d8);
+ --fm-color-primary-hover: #9a8ee5;
+ --fm-color-primary-active: #7468c4;
+ --fm-color-primary-light: rgba(133, 120, 216, 0.15);
+ --fm-color-primary-text: #ffffff;
+
+ // Surface & Background
+ --fm-color-bg: #1a1a2e;
+ --fm-color-bg-secondary: #222240;
+ --fm-color-bg-tertiary: #2a2a4a;
+ --fm-color-surface: #222240;
+ --fm-color-surface-hover: rgba(255, 255, 255, 0.06);
+ --fm-color-surface-active: rgba(255, 255, 255, 0.1);
+ --fm-color-surface-selected: rgba(133, 120, 216, 0.15);
+ --fm-color-surface-selected-hover: rgba(133, 120, 216, 0.22);
+
+ // Text
+ --fm-color-text-primary: #e8e8f0;
+ --fm-color-text-secondary: #a0a0b8;
+ --fm-color-text-tertiary: #707088;
+ --fm-color-text-disabled: #505068;
+ --fm-color-text-inverse: #1a1a2e;
+ --fm-color-text-link: var(--fm-color-primary);
+
+ // Border
+ --fm-color-border: #383858;
+ --fm-color-border-hover: #484870;
+ --fm-color-border-focus: var(--fm-color-primary);
+ --fm-color-border-selected: var(--fm-color-primary);
+
+ // Semantic
+ --fm-color-success: #4ade80;
+ --fm-color-success-bg: rgba(74, 222, 128, 0.12);
+ --fm-color-warning: #fbbf24;
+ --fm-color-warning-bg: rgba(251, 191, 36, 0.12);
+ --fm-color-error: #f87171;
+ --fm-color-error-bg: rgba(248, 113, 113, 0.12);
+ --fm-color-info: #60a5fa;
+ --fm-color-info-bg: rgba(96, 165, 250, 0.12);
+
+ // Shadows
+ --fm-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
+ --fm-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
+ --fm-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.35), 0 4px 6px -4px rgba(0, 0, 0, 0.35);
+ --fm-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.4), 0 8px 10px -6px rgba(0, 0, 0, 0.4);
+
+ // ─── Search Tokens ───────────────────────────────────
+ --fm-search-bg: #2a2a2a;
+ --fm-search-border: #444;
+ --fm-search-focus-border: var(--file-manager-primary-color);
+ --fm-search-placeholder: #888;
+ --fm-search-result-highlight: rgba(97, 85, 180, 0.25);
+
+ // ─── Filter Chip Tokens ──────────────────────────────
+ --fm-chip-bg: #333;
+ --fm-chip-active-bg: var(--file-manager-primary-color);
+ --fm-chip-active-text: #fff;
+ --fm-chip-text: #ccc;
+ --fm-chip-hover-bg: #444;
+
+ // ─── Details Panel Tokens ────────────────────────────
+ --fm-details-bg: #1e1e1e;
+ --fm-details-border: #444;
+ --fm-details-label: #999;
+ --fm-details-value: #ddd;
+ --fm-details-section-border: #333;
+ --fm-details-header-bg: #252525;
+
+ // ─── Quick Access Tokens ─────────────────────────────
+ --fm-quickaccess-bg: #1e1e1e;
+ --fm-quickaccess-hover: rgba(255, 255, 255, 0.05);
+ --fm-quickaccess-star: #f5a623;
+ --fm-quickaccess-header: #999;
+ --fm-quickaccess-divider: #333;
+
+ // ─── Thumbnail Tokens ────────────────────────────────
+ --fm-thumbnail-bg: #2a2a2a;
+ --fm-thumbnail-border: #444;
+ --fm-thumbnail-radius: 4px;
+
+ // ─── Preview Tokens ──────────────────────────────────
+ --fm-preview-controls-bg: #2a2a2a;
+ --fm-preview-controls-border: #444;
+ }
+}
+
+// =============================================================
+// Reduced Motion Support
+// =============================================================
+@media (prefers-reduced-motion: reduce) {
+ :root {
+ --fm-transition-fast: 0ms;
+ --fm-transition-normal: 0ms;
+ --fm-transition-slow: 0ms;
+ --fm-transition-spring: 0ms;
+ --fm-duration-fast: 0ms;
+ --fm-duration-normal: 0ms;
+ --fm-duration-slow: 0ms;
+ }
+}
diff --git a/frontend/src/styles/_variables.scss b/frontend/src/styles/_variables.scss
index d71ace69..14f9daa4 100644
--- a/frontend/src/styles/_variables.scss
+++ b/frontend/src/styles/_variables.scss
@@ -1,3 +1,6 @@
+@use './tokens';
+@use './animations';
+
// App Colors
$border-color: #cfcfcf;
$item-hover-color: rgb(0, 0, 0, 0.05);
@@ -26,4 +29,37 @@ $fm-font-size: 16px;
}
}
+@mixin panel-resize-handle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 5px;
+ cursor: col-resize;
+ z-index: 10;
+ transition: border-color 0.15s ease;
+
+ &:hover {
+ border-color: #1e3a8a;
+ }
+}
+
+@mixin chip-base {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 12px;
+ border-radius: 16px;
+ font-size: 12px;
+ cursor: pointer;
+ border: none;
+ transition: all 0.15s ease;
+ gap: 4px;
+}
+
+@mixin section-header {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+}
+
//
\ No newline at end of file
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
new file mode 100644
index 00000000..7b0828bf
--- /dev/null
+++ b/frontend/src/test/setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom';
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
new file mode 100644
index 00000000..9e107329
--- /dev/null
+++ b/frontend/src/types/index.ts
@@ -0,0 +1,437 @@
+import React from "react";
+
+// =============================================================================
+// File System Types
+// =============================================================================
+
+/**
+ * Represents a file or directory item in the file manager.
+ */
+export interface FileItem {
+ /** Display name of the file or directory */
+ name: string;
+ /** Whether this item is a directory */
+ isDirectory: boolean;
+ /** Full path of the file or directory */
+ path: string;
+ /** Last updated timestamp as an ISO string */
+ updatedAt?: string;
+ /** File size in bytes */
+ size?: number;
+ /** Whether the file is currently in rename/edit mode */
+ isEditing?: boolean;
+ /** Unique key identifier */
+ key?: number;
+ /** URL for the file thumbnail image */
+ thumbnailUrl?: string;
+}
+
+/**
+ * Represents a node in the folder tree navigation, extending FileItem
+ * with nested subdirectory information.
+ */
+export interface FolderTreeNode extends FileItem {
+ /** Child directories within this folder */
+ subDirectories: FolderTreeNode[];
+}
+
+// =============================================================================
+// Upload Configuration
+// =============================================================================
+
+/**
+ * Configuration for file upload behavior.
+ */
+export interface FileUploadConfig {
+ /** The URL endpoint to upload files to */
+ url: string;
+ /** Additional headers to include in the upload request */
+ headers?: Record
;
+ /** HTTP method to use for uploading */
+ method?: "POST" | "PUT";
+ /** Whether to send credentials with the upload request */
+ withCredentials?: boolean;
+}
+
+// =============================================================================
+// Permissions
+// =============================================================================
+
+/**
+ * Defines the set of actions a user is permitted to perform.
+ */
+export interface Permissions {
+ /** Whether the user can create new folders */
+ create?: boolean;
+ /** Whether the user can upload files */
+ upload?: boolean;
+ /** Whether the user can move files/folders */
+ move?: boolean;
+ /** Whether the user can copy files/folders */
+ copy?: boolean;
+ /** Whether the user can rename files/folders */
+ rename?: boolean;
+ /** Whether the user can download files */
+ download?: boolean;
+ /** Whether the user can delete files/folders */
+ delete?: boolean;
+}
+
+// =============================================================================
+// Sort Configuration
+// =============================================================================
+
+/**
+ * Configuration for sorting file listings.
+ */
+export interface SortConfig {
+ /** The field to sort by */
+ key: "name" | "size" | "modified";
+ /** Sort direction */
+ direction: "asc" | "desc";
+}
+
+// =============================================================================
+// Clipboard State
+// =============================================================================
+
+/**
+ * Represents the current state of the clipboard for cut/copy operations.
+ */
+export interface ClipboardState {
+ /** Files currently in the clipboard */
+ files: FileItem[];
+ /** Whether the clipboard operation is a move (true) or copy (false) */
+ isMoving: boolean;
+}
+
+// =============================================================================
+// Layout
+// =============================================================================
+
+/**
+ * The display layout mode for the file listing.
+ */
+export type LayoutType = "grid" | "list";
+
+// =============================================================================
+// Context Menu
+// =============================================================================
+
+/**
+ * Represents a single item in the context menu.
+ */
+export interface ContextMenuItem {
+ /** Display text for the menu item */
+ title: string;
+ /** Icon element to display alongside the title */
+ icon: React.ReactNode;
+ /** Callback invoked when the menu item is clicked */
+ onClick: () => void;
+ /** Nested submenu items */
+ children?: ContextMenuItem[];
+ /** Additional CSS class name */
+ className?: string;
+ /** Whether this item should be hidden */
+ hidden?: boolean;
+ /** Whether to render a divider after this item */
+ divider?: boolean;
+ /** Whether this item is in a selected/active state */
+ selected?: boolean;
+ /** Permission flag; if false, the item is disabled */
+ permission?: boolean;
+}
+
+// =============================================================================
+// Trigger Action
+// =============================================================================
+
+/**
+ * Represents the state and controls for a triggerable action (e.g., modals, dialogs).
+ */
+export interface TriggerAction {
+ /** Whether the action is currently active/visible */
+ isActive: boolean;
+ /** The type identifier for the current action, or null if inactive */
+ actionType: string | null;
+ /** Show/activate the action with a given type */
+ show: (type: string) => void;
+ /** Close/deactivate the action */
+ close: () => void;
+}
+
+// =============================================================================
+// Error Types
+// =============================================================================
+
+/**
+ * Represents an error encountered by the file manager.
+ */
+export interface FileManagerError {
+ /** Error type identifier */
+ type: string;
+ /** Human-readable error message */
+ message: string;
+ /** Optional HTTP response information for upload/network errors */
+ response?: {
+ status: number;
+ statusText: string;
+ data: any;
+ };
+}
+
+// =============================================================================
+// Search State
+// =============================================================================
+
+/**
+ * Represents the current state of the search feature.
+ */
+export interface SearchState {
+ /** The current search query string */
+ query: string;
+ /** Whether search mode is currently active */
+ isActive: boolean;
+ /** Array of active filter chip names (e.g., "Images", "Documents", "This Week") */
+ activeFilters: string[];
+ /** Whether to search recursively through all folders */
+ isRecursive: boolean;
+}
+
+// =============================================================================
+// Undo / Redo Types
+// =============================================================================
+
+/**
+ * The types of actions that can be undone/redone.
+ */
+export type ActionType = "rename" | "delete" | "move" | "createFolder" | "copy";
+
+/**
+ * Represents an action that can be undone or redone.
+ */
+export interface UndoableAction {
+ /** The type of action that was performed */
+ type: ActionType;
+ /** Timestamp (epoch ms) when the action was performed */
+ timestamp: number;
+ /** Data associated with the action for undo/redo purposes */
+ data: {
+ /** Files involved in the action */
+ files?: FileItem[];
+ /** Previous name (for rename actions) */
+ previousName?: string;
+ /** New name (for rename actions) */
+ newName?: string;
+ /** Source path (for move/copy actions) */
+ source?: string;
+ /** Destination path (for move/copy actions) */
+ destination?: string;
+ };
+}
+
+// =============================================================================
+// FileManager Component Props
+// =============================================================================
+
+/**
+ * Props for the main FileManager component.
+ */
+export interface FileManagerProps {
+ // ---- Required ----
+ /** Array of file and directory items to display */
+ files: FileItem[];
+
+ // ---- Upload Configuration ----
+ /** Configuration for file upload functionality */
+ fileUploadConfig?: FileUploadConfig;
+
+ // ---- Loading State ----
+ /** Whether the file manager is in a loading state */
+ isLoading?: boolean;
+
+ // ---- Event Callbacks ----
+ /** Called when a new folder is created */
+ onCreateFolder?: (name: string, parentFolder: FileItem | null) => void;
+ /** Called when a file begins uploading; may return additional form data */
+ onFileUploading?: (
+ file: File,
+ currentFolder: FileItem | null
+ ) => Record | void;
+ /** Called when a file upload completes */
+ onFileUploaded?: (response: any) => void;
+ /** Called when files are cut to clipboard */
+ onCut?: (files: FileItem[]) => void;
+ /** Called when files are copied to clipboard */
+ onCopy?: (files: FileItem[]) => void;
+ /** Called when files are pasted from clipboard */
+ onPaste?: (
+ files: FileItem[],
+ destination: FileItem | null,
+ operationType: "move" | "copy"
+ ) => void;
+ /** Called when a file or folder is renamed */
+ onRename?: (file: FileItem, newName: string) => void;
+ /** Called when files are downloaded */
+ onDownload?: (files: FileItem[]) => void;
+ /** Called when files are deleted */
+ onDelete?: (files: FileItem[]) => void;
+ /** Called when the layout mode changes */
+ onLayoutChange?: (layout: LayoutType) => void;
+ /** Called when the refresh action is triggered */
+ onRefresh?: () => void;
+ /** Called when a file is opened (e.g., double-clicked) */
+ onFileOpen?: (file: FileItem) => void;
+ /** Called when the current folder path changes */
+ onFolderChange?: (path: string) => void;
+ /** Called when file selection changes (legacy) */
+ onSelect?: (files: FileItem[]) => void;
+ /** Called when file selection changes */
+ onSelectionChange?: (files: FileItem[]) => void;
+ /** Called when an error occurs */
+ onError?: (error: FileManagerError, file?: File) => void;
+
+ // ---- Layout & Display ----
+ /** The display layout mode */
+ layout?: LayoutType;
+ /** Maximum allowed file size for uploads, in bytes */
+ maxFileSize?: number;
+ /** Whether to enable the built-in file preview feature */
+ enableFilePreview?: boolean;
+ /** Base URL path for file previews */
+ filePreviewPath?: string;
+ /** Comma-separated list of accepted file MIME types or extensions */
+ acceptedFileTypes?: string;
+ /** Height of the file manager container */
+ height?: string | number;
+ /** Width of the file manager container */
+ width?: string | number;
+ /** Initial directory path to display on mount */
+ initialPath?: string;
+ /** Custom component for rendering file previews */
+ filePreviewComponent?: (file: FileItem) => React.ReactElement;
+
+ // ---- Theming & Styling ----
+ /** Primary accent color */
+ primaryColor?: string;
+ /** Font family for the file manager */
+ fontFamily?: string;
+ /** Theme mode */
+ theme?: "light" | "dark" | "system";
+ /**
+ * Custom design token overrides applied as inline CSS custom properties.
+ * Keys can be provided with or without the "--fm-" prefix.
+ * Example: `{ "color-bg": "#1a1a1a" }` sets `--fm-color-bg: #1a1a1a`.
+ */
+ customTokens?: Record;
+ /** Additional CSS class name for the root element */
+ className?: string;
+ /** Inline styles for the root element */
+ style?: React.CSSProperties;
+
+ // ---- Localization ----
+ /** Language code for i18n (e.g., "en", "es", "fr") */
+ language?: string;
+
+ // ---- Permissions ----
+ /** Permissions configuration controlling available actions */
+ permissions?: Permissions;
+
+ // ---- Navigation ----
+ /** Whether the side navigation panel is collapsible */
+ collapsibleNav?: boolean;
+ /** Whether the side navigation is expanded by default */
+ defaultNavExpanded?: boolean;
+
+ // ---- Formatting ----
+ /** Custom date formatting function */
+ formatDate?: (date: string) => string;
+
+ // ---- Search ----
+ /** Called when the user searches; can be used for server-side search delegation */
+ onSearch?: (query: string, filters: string[]) => void;
+
+ // ---- Undo / Redo Callbacks ----
+ /** Called when an action is undone */
+ onUndo?: (action: UndoableAction) => void;
+ /** Called when an action is redone */
+ onRedo?: (action: UndoableAction) => void;
+
+ // ---- Favorites & Quick Access ----
+ /** Called when a file/folder is favorited or unfavorited */
+ onFavoriteToggle?: (file: FileItem, isFavorited: boolean) => void;
+ /** Called when recent files list changes */
+ onRecentFiles?: (recentFiles: FileItem[]) => void;
+ /** Initial favorite file paths */
+ initialFavorites?: string[];
+
+ // ---- Iteration 3: Tabs ----
+ /** Whether to enable multi-tab navigation */
+ enableTabs?: boolean;
+ /** Maximum number of tabs allowed */
+ maxTabs?: number;
+ /** Called when a tab is added, closed, or switched */
+ onTabChange?: (event: { type: "add" | "close" | "switch"; tabId?: number; path?: string }) => void;
+
+ // ---- Iteration 3: Advanced Drag & Drop ----
+ /** Called when files are dropped from the OS desktop */
+ onExternalDrop?: (files: File[], event: DragEvent) => void;
+
+ // ---- Iteration 3: Batch Operations ----
+ /** Called when batch operation progress changes */
+ onOperationProgress?: (event: BatchProgressEvent) => void;
+
+ // ---- Iteration 3: Tagging ----
+ /** Available tag definitions with name and color */
+ tags?: TagDefinition[];
+ /** Called when a tag is added or removed from a file */
+ onTagChange?: (file: FileItem, tagName: string, action: "add" | "remove") => void;
+
+ // ---- Iteration 3: Column Customization ----
+ /** Initial visible columns in list view */
+ columns?: string[];
+ /** Called when column visibility changes */
+ onColumnConfigChange?: (visibleColumns: string[]) => void;
+
+ // ---- Iteration 3: Clipboard ----
+ /** Called when the clipboard contents change */
+ onClipboardChange?: (clipboard: ClipboardState | null) => void;
+}
+
+// =============================================================================
+// Tag Types
+// =============================================================================
+
+/**
+ * Definition for a tag color option.
+ */
+export interface TagDefinition {
+ /** Display name of the tag */
+ name: string;
+ /** CSS color value for the tag */
+ color: string;
+}
+
+// =============================================================================
+// Batch Progress Types
+// =============================================================================
+
+/**
+ * Events emitted during batch operations.
+ */
+export interface BatchProgressEvent {
+ /** Event type */
+ type: "start" | "progress" | "cancel" | "close";
+ /** Operation being performed */
+ operationType?: string;
+ /** Total number of items in the batch */
+ totalItems?: number;
+ /** ID of the item being updated */
+ itemId?: number;
+ /** Item status */
+ status?: "pending" | "in_progress" | "completed" | "failed" | "skipped";
+ /** Progress percentage (0-100) */
+ progress?: number;
+ /** Error message if failed */
+ error?: string;
+}
diff --git a/frontend/src/utils/__tests__/createFolderTree.test.ts b/frontend/src/utils/__tests__/createFolderTree.test.ts
new file mode 100644
index 00000000..4da88b65
--- /dev/null
+++ b/frontend/src/utils/__tests__/createFolderTree.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect } from 'vitest';
+import { createFolderTree } from '../createFolderTree.js';
+
+describe('createFolderTree', () => {
+ it('creates a tree with children', () => {
+ const folder = { name: 'Documents', path: 'root', isDirectory: true };
+ const files = [
+ { name: 'file1.txt', path: 'root/Documents', isDirectory: false },
+ { name: 'file2.txt', path: 'root/Documents', isDirectory: false },
+ { name: 'other.txt', path: 'root/Other', isDirectory: false },
+ ];
+
+ const result = createFolderTree(folder, files);
+
+ expect(result.name).toBe('Documents');
+ expect(result.children).toHaveLength(2);
+ expect(result.children[0].name).toBe('file1.txt');
+ expect(result.children[1].name).toBe('file2.txt');
+ });
+
+ it('handles empty children (folder with no child files)', () => {
+ const folder = { name: 'EmptyFolder', path: 'root', isDirectory: true };
+ const files = [
+ { name: 'unrelated.txt', path: 'root/OtherFolder', isDirectory: false },
+ ];
+
+ const result = createFolderTree(folder, files);
+
+ expect(result.name).toBe('EmptyFolder');
+ expect(result.children).toHaveLength(0);
+ expect(result.children).toEqual([]);
+ });
+
+ it('creates nested tree structure', () => {
+ // The function computes child path as: copiedFile.path + "/" + copiedFile.name
+ // So for Root (path: "root"), children must have path "root/Root"
+ // For SubFolder (path: "root/Root"), children must have path "root/Root/SubFolder"
+ const folder = { name: 'Root', path: 'root', isDirectory: true };
+ const files = [
+ { name: 'SubFolder', path: 'root/Root', isDirectory: true },
+ { name: 'nested.txt', path: 'root/Root/SubFolder', isDirectory: false },
+ { name: 'top-level.txt', path: 'root/Root', isDirectory: false },
+ ];
+
+ const result = createFolderTree(folder, files);
+
+ expect(result.name).toBe('Root');
+ expect(result.children).toHaveLength(2);
+
+ // SubFolder should have nested.txt as child
+ const subFolder = result.children.find(
+ (c: any) => c.name === 'SubFolder'
+ );
+ expect(subFolder).toBeDefined();
+ expect(subFolder.children).toHaveLength(1);
+ expect(subFolder.children[0].name).toBe('nested.txt');
+
+ // top-level.txt should have no children (it's a file)
+ const topLevelFile = result.children.find(
+ (c: any) => c.name === 'top-level.txt'
+ );
+ expect(topLevelFile).toBeDefined();
+ expect(topLevelFile.children).toHaveLength(0);
+ });
+
+ it('preserves original properties on the copied file', () => {
+ const folder = {
+ name: 'MyFolder',
+ path: 'root',
+ isDirectory: true,
+ size: 0,
+ updatedAt: '2024-01-01',
+ };
+ const files: any[] = [];
+
+ const result = createFolderTree(folder, files);
+
+ expect(result.name).toBe('MyFolder');
+ expect(result.isDirectory).toBe(true);
+ expect(result.size).toBe(0);
+ expect(result.updatedAt).toBe('2024-01-01');
+ expect(result.children).toEqual([]);
+ });
+});
diff --git a/frontend/src/utils/__tests__/duplicateNameHandler.test.ts b/frontend/src/utils/__tests__/duplicateNameHandler.test.ts
new file mode 100644
index 00000000..3d695345
--- /dev/null
+++ b/frontend/src/utils/__tests__/duplicateNameHandler.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect } from 'vitest';
+import { duplicateNameHandler } from '../duplicateNameHandler.js';
+
+// Helper to create file objects
+const file = (name: string, isDirectory = false) => ({ name, isDirectory });
+
+describe('duplicateNameHandler', () => {
+ it('returns original name if no duplicates exist', () => {
+ const files = [file('other.txt'), file('another.txt')];
+ expect(duplicateNameHandler('unique.txt', false, files)).toBe('unique.txt');
+ });
+
+ it('appends (1) for the first duplicate of a file', () => {
+ const files = [file('report.txt')];
+ expect(duplicateNameHandler('report.txt', false, files)).toBe(
+ 'report (1).txt'
+ );
+ });
+
+ it('appends (2) when (1) already exists for a file', () => {
+ const files = [file('report.txt'), file('report (1).txt')];
+ expect(duplicateNameHandler('report.txt', false, files)).toBe(
+ 'report (2).txt'
+ );
+ });
+
+ it('handles directories (no extension)', () => {
+ const files = [file('Documents', true)];
+ expect(duplicateNameHandler('Documents', true, files)).toBe(
+ 'Documents (1)'
+ );
+ });
+
+ it('appends incrementing numbers for directory duplicates', () => {
+ const files = [
+ file('Documents', true),
+ file('Documents (1)', true),
+ file('Documents (2)', true),
+ ];
+ expect(duplicateNameHandler('Documents', true, files)).toBe(
+ 'Documents (3)'
+ );
+ });
+
+ it('handles files with extensions correctly', () => {
+ const files = [file('photo.png'), file('photo (1).png')];
+ expect(duplicateNameHandler('photo.png', false, files)).toBe(
+ 'photo (2).png'
+ );
+ });
+
+ it('returns original directory name if no duplicates exist', () => {
+ const files = [file('OtherFolder', true)];
+ expect(duplicateNameHandler('MyFolder', true, files)).toBe('MyFolder');
+ });
+});
diff --git a/frontend/src/utils/__tests__/formatDate.test.ts b/frontend/src/utils/__tests__/formatDate.test.ts
new file mode 100644
index 00000000..315fdde9
--- /dev/null
+++ b/frontend/src/utils/__tests__/formatDate.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest';
+import { formatDate } from '../formatDate.js';
+
+describe('formatDate', () => {
+ it('formats a valid date correctly', () => {
+ // Use a specific date: July 15, 2024, 2:30 PM (local time)
+ const date = new Date(2024, 6, 15, 14, 30); // month is 0-indexed
+ const result = formatDate(date.toISOString());
+ // The function uses local time via getHours(), getMinutes(), etc.
+ expect(result).toBe('7/15/2024 2:30 PM');
+ });
+
+ it('returns empty string for null', () => {
+ expect(formatDate(null)).toBe('');
+ });
+
+ it('returns empty string for undefined', () => {
+ expect(formatDate(undefined)).toBe('');
+ });
+
+ it('returns empty string for invalid date strings', () => {
+ expect(formatDate('not-a-date')).toBe('');
+ expect(formatDate('abc123')).toBe('');
+ });
+
+ it('correctly handles AM times', () => {
+ // 9:05 AM
+ const date = new Date(2024, 0, 10, 9, 5);
+ const result = formatDate(date.toISOString());
+ expect(result).toBe('1/10/2024 9:05 AM');
+ });
+
+ it('correctly handles PM times', () => {
+ // 3:45 PM
+ const date = new Date(2024, 5, 20, 15, 45);
+ const result = formatDate(date.toISOString());
+ expect(result).toBe('6/20/2024 3:45 PM');
+ });
+
+ it('correctly formats midnight (12 AM)', () => {
+ // Midnight: hour 0 => should display as 12 AM
+ const date = new Date(2024, 2, 1, 0, 0);
+ const result = formatDate(date.toISOString());
+ expect(result).toBe('3/1/2024 12:00 AM');
+ });
+
+ it('correctly formats noon (12 PM)', () => {
+ // Noon: hour 12 => should display as 12 PM
+ const date = new Date(2024, 11, 25, 12, 0);
+ const result = formatDate(date.toISOString());
+ expect(result).toBe('12/25/2024 12:00 PM');
+ });
+
+ it('pads single-digit minutes with a leading zero', () => {
+ // 8:03 AM
+ const date = new Date(2024, 3, 5, 8, 3);
+ const result = formatDate(date.toISOString());
+ expect(result).toBe('4/5/2024 8:03 AM');
+ });
+});
diff --git a/frontend/src/utils/__tests__/getDataSize.test.ts b/frontend/src/utils/__tests__/getDataSize.test.ts
new file mode 100644
index 00000000..f5edbf63
--- /dev/null
+++ b/frontend/src/utils/__tests__/getDataSize.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from 'vitest';
+import { getDataSize } from '../getDataSize.js';
+
+describe('getDataSize', () => {
+ it('returns KB for small sizes', () => {
+ // 1024 bytes = 1 KB
+ expect(getDataSize(1024)).toBe('1.00 KB');
+ // 512 bytes = 0.5 KB
+ expect(getDataSize(512)).toBe('0.50 KB');
+ // 100000 bytes = ~97.66 KB
+ expect(getDataSize(100000)).toBe('97.66 KB');
+ });
+
+ it('returns MB for medium sizes', () => {
+ // 1 MB = 1024 * 1024 = 1048576 bytes
+ expect(getDataSize(1048576)).toBe('1.00 MB');
+ // 5 MB = 5242880 bytes
+ expect(getDataSize(5242880)).toBe('5.00 MB');
+ });
+
+ it('returns GB for large sizes', () => {
+ // 1 GB = 1073741824 bytes
+ expect(getDataSize(1073741824)).toBe('1.00 GB');
+ // 2.5 GB
+ expect(getDataSize(2684354560)).toBe('2.50 GB');
+ });
+
+ it('returns empty string for NaN input', () => {
+ expect(getDataSize(NaN)).toBe('');
+ expect(getDataSize(undefined as any)).toBe('');
+ expect(getDataSize('not-a-number' as any)).toBe('');
+ });
+
+ it('respects decimal places parameter', () => {
+ // 1024 bytes = 1 KB with 0 decimal places
+ expect(getDataSize(1024, 0)).toBe('1 KB');
+ // 1024 bytes = 1 KB with 3 decimal places
+ expect(getDataSize(1024, 3)).toBe('1.000 KB');
+ // 1500 bytes with 1 decimal place
+ expect(getDataSize(1500, 1)).toBe('1.5 KB');
+ });
+});
diff --git a/frontend/src/utils/__tests__/getFileExtension.test.ts b/frontend/src/utils/__tests__/getFileExtension.test.ts
new file mode 100644
index 00000000..3bfd2743
--- /dev/null
+++ b/frontend/src/utils/__tests__/getFileExtension.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from 'vitest';
+import { getFileExtension } from '../getFileExtension.js';
+
+describe('getFileExtension', () => {
+ it('returns correct extension for a simple filename', () => {
+ expect(getFileExtension('document.pdf')).toBe('pdf');
+ });
+
+ it('returns correct extension for common file types', () => {
+ expect(getFileExtension('image.png')).toBe('png');
+ expect(getFileExtension('archive.tar.gz')).toBe('gz');
+ expect(getFileExtension('script.test.js')).toBe('js');
+ });
+
+ it('handles multiple dots in filename by returning last extension', () => {
+ expect(getFileExtension('my.file.name.txt')).toBe('txt');
+ expect(getFileExtension('backup.2024.01.tar.gz')).toBe('gz');
+ });
+
+ it('returns filename itself for no extension (no dot)', () => {
+ expect(getFileExtension('Makefile')).toBe('Makefile');
+ expect(getFileExtension('README')).toBe('README');
+ });
+});
diff --git a/frontend/src/utils/__tests__/getParentPath.test.ts b/frontend/src/utils/__tests__/getParentPath.test.ts
new file mode 100644
index 00000000..faa6a73f
--- /dev/null
+++ b/frontend/src/utils/__tests__/getParentPath.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from 'vitest';
+import { getParentPath } from '../getParentPath.js';
+
+describe('getParentPath', () => {
+ it('returns parent path for a nested path', () => {
+ expect(getParentPath('root/documents/file.txt')).toBe('root/documents');
+ });
+
+ it('returns empty string for root-level path (single segment)', () => {
+ expect(getParentPath('file.txt')).toBe('');
+ });
+
+ it('handles deeply nested paths', () => {
+ expect(getParentPath('a/b/c/d/e/f')).toBe('a/b/c/d/e');
+ });
+
+ it('returns parent for two-level path', () => {
+ expect(getParentPath('documents/report.pdf')).toBe('documents');
+ });
+
+ it('handles undefined input gracefully', () => {
+ expect(getParentPath(undefined as any)).toBeUndefined();
+ });
+});
diff --git a/frontend/src/utils/__tests__/sortFiles.test.ts b/frontend/src/utils/__tests__/sortFiles.test.ts
new file mode 100644
index 00000000..102b9d1e
--- /dev/null
+++ b/frontend/src/utils/__tests__/sortFiles.test.ts
@@ -0,0 +1,203 @@
+import { describe, it, expect } from 'vitest';
+import sortFiles from '../sortFiles.js';
+
+// Helper factory for creating file/folder objects
+const createFile = (name: string, opts: Record = {}) => ({
+ name,
+ isDirectory: false,
+ ...opts,
+});
+
+const createFolder = (name: string, opts: Record = {}) => ({
+ name,
+ isDirectory: true,
+ ...opts,
+});
+
+describe('sortFiles', () => {
+ describe('sorting by name', () => {
+ it('sorts by name ascending', () => {
+ const items = [
+ createFile('charlie.txt'),
+ createFile('alpha.txt'),
+ createFile('bravo.txt'),
+ ];
+ const result = sortFiles(items, 'name', 'asc');
+ expect(result.map((f) => f.name)).toEqual([
+ 'alpha.txt',
+ 'bravo.txt',
+ 'charlie.txt',
+ ]);
+ });
+
+ it('sorts by name descending', () => {
+ const items = [
+ createFile('alpha.txt'),
+ createFile('charlie.txt'),
+ createFile('bravo.txt'),
+ ];
+ const result = sortFiles(items, 'name', 'desc');
+ expect(result.map((f) => f.name)).toEqual([
+ 'charlie.txt',
+ 'bravo.txt',
+ 'alpha.txt',
+ ]);
+ });
+ });
+
+ describe('sorting by size', () => {
+ it('sorts by size ascending', () => {
+ const items = [
+ createFile('large.txt', { size: 3000 }),
+ createFile('small.txt', { size: 100 }),
+ createFile('medium.txt', { size: 1500 }),
+ ];
+ const result = sortFiles(items, 'size', 'asc');
+ expect(result.map((f) => f.name)).toEqual([
+ 'small.txt',
+ 'medium.txt',
+ 'large.txt',
+ ]);
+ });
+
+ it('sorts by size descending', () => {
+ const items = [
+ createFile('large.txt', { size: 3000 }),
+ createFile('small.txt', { size: 100 }),
+ createFile('medium.txt', { size: 1500 }),
+ ];
+ const result = sortFiles(items, 'size', 'desc');
+ expect(result.map((f) => f.name)).toEqual([
+ 'large.txt',
+ 'medium.txt',
+ 'small.txt',
+ ]);
+ });
+
+ it('handles missing size values by treating them as 0', () => {
+ const items = [
+ createFile('has-size.txt', { size: 500 }),
+ createFile('no-size.txt'),
+ createFile('also-has-size.txt', { size: 100 }),
+ ];
+ const result = sortFiles(items, 'size', 'asc');
+ expect(result.map((f) => f.name)).toEqual([
+ 'no-size.txt',
+ 'also-has-size.txt',
+ 'has-size.txt',
+ ]);
+ });
+ });
+
+ describe('sorting by modified date', () => {
+ it('sorts by modified date ascending', () => {
+ const items = [
+ createFile('newest.txt', { updatedAt: '2024-03-01T00:00:00Z' }),
+ createFile('oldest.txt', { updatedAt: '2024-01-01T00:00:00Z' }),
+ createFile('middle.txt', { updatedAt: '2024-02-01T00:00:00Z' }),
+ ];
+ const result = sortFiles(items, 'modified', 'asc');
+ expect(result.map((f) => f.name)).toEqual([
+ 'oldest.txt',
+ 'middle.txt',
+ 'newest.txt',
+ ]);
+ });
+
+ it('sorts by modified date descending', () => {
+ const items = [
+ createFile('newest.txt', { updatedAt: '2024-03-01T00:00:00Z' }),
+ createFile('oldest.txt', { updatedAt: '2024-01-01T00:00:00Z' }),
+ createFile('middle.txt', { updatedAt: '2024-02-01T00:00:00Z' }),
+ ];
+ const result = sortFiles(items, 'modified', 'desc');
+ expect(result.map((f) => f.name)).toEqual([
+ 'newest.txt',
+ 'middle.txt',
+ 'oldest.txt',
+ ]);
+ });
+
+ it('handles missing date values by treating them as epoch 0', () => {
+ const items = [
+ createFile('has-date.txt', { updatedAt: '2024-01-15T00:00:00Z' }),
+ createFile('no-date.txt'),
+ createFile('also-has-date.txt', { updatedAt: '2024-06-01T00:00:00Z' }),
+ ];
+ const result = sortFiles(items, 'modified', 'asc');
+ expect(result.map((f) => f.name)).toEqual([
+ 'no-date.txt',
+ 'has-date.txt',
+ 'also-has-date.txt',
+ ]);
+ });
+ });
+
+ describe('folders before files', () => {
+ it('always puts folders before files regardless of sort key', () => {
+ const items = [
+ createFile('zebra.txt'),
+ createFolder('alpha-folder'),
+ createFile('apple.txt'),
+ createFolder('zebra-folder'),
+ ];
+ const result = sortFiles(items, 'name', 'asc');
+ // Folders come first, sorted, then files, sorted
+ expect(result.map((f) => f.name)).toEqual([
+ 'alpha-folder',
+ 'zebra-folder',
+ 'apple.txt',
+ 'zebra.txt',
+ ]);
+ });
+
+ it('puts folders before files when sorting descending', () => {
+ const items = [
+ createFile('a.txt'),
+ createFolder('z-folder'),
+ createFile('z.txt'),
+ createFolder('a-folder'),
+ ];
+ const result = sortFiles(items, 'name', 'desc');
+ // Folders come first (descending), then files (descending)
+ expect(result.map((f) => f.name)).toEqual([
+ 'z-folder',
+ 'a-folder',
+ 'z.txt',
+ 'a.txt',
+ ]);
+ });
+ });
+
+ describe('fallback behavior', () => {
+ it('falls back to name sorting for unknown sort keys', () => {
+ const items = [
+ createFile('charlie.txt'),
+ createFile('alpha.txt'),
+ createFile('bravo.txt'),
+ ];
+ const result = sortFiles(items, 'unknownKey', 'asc');
+ expect(result.map((f) => f.name)).toEqual([
+ 'alpha.txt',
+ 'bravo.txt',
+ 'charlie.txt',
+ ]);
+ });
+ });
+
+ describe('default parameters', () => {
+ it('uses name ascending as defaults when no sort key or direction provided', () => {
+ const items = [
+ createFile('charlie.txt'),
+ createFile('alpha.txt'),
+ createFile('bravo.txt'),
+ ];
+ const result = sortFiles(items);
+ expect(result.map((f) => f.name)).toEqual([
+ 'alpha.txt',
+ 'bravo.txt',
+ 'charlie.txt',
+ ]);
+ });
+ });
+});
diff --git a/frontend/src/utils/__tests__/validateApiCallback.test.ts b/frontend/src/utils/__tests__/validateApiCallback.test.ts
new file mode 100644
index 00000000..9577b809
--- /dev/null
+++ b/frontend/src/utils/__tests__/validateApiCallback.test.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi } from 'vitest';
+import { validateApiCallback } from '../validateApiCallback.js';
+
+describe('validateApiCallback', () => {
+ it('calls callback when it is a function', () => {
+ const callback = vi.fn();
+ validateApiCallback(callback, 'onFileUpload');
+ expect(callback).toHaveBeenCalledOnce();
+ });
+
+ it('logs error when callback is not a function', () => {
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+
+ validateApiCallback('not-a-function', 'onFileUpload');
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('onFileUpload')
+ );
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('Missing prop')
+ );
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('logs error when callback is undefined', () => {
+ const consoleErrorSpy = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+
+ validateApiCallback(undefined, 'onDelete');
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('onDelete')
+ );
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('passes arguments correctly to the callback', () => {
+ const callback = vi.fn();
+ validateApiCallback(callback, 'onRename', 'arg1', 'arg2', 42);
+ expect(callback).toHaveBeenCalledWith('arg1', 'arg2', 42);
+ });
+
+ it('passes no arguments when none are provided', () => {
+ const callback = vi.fn();
+ validateApiCallback(callback, 'onOpen');
+ expect(callback).toHaveBeenCalledWith();
+ });
+});
diff --git a/frontend/src/utils/highlightMatch.jsx b/frontend/src/utils/highlightMatch.jsx
new file mode 100644
index 00000000..cb944485
--- /dev/null
+++ b/frontend/src/utils/highlightMatch.jsx
@@ -0,0 +1,37 @@
+/**
+ * highlightMatch takes a text string and a search query, and returns a React
+ * element with tags wrapping the matched portions. Matching is
+ * case-insensitive and highlights all occurrences.
+ *
+ * @param {string} text - The full text to display.
+ * @param {string} query - The search query to highlight within the text.
+ * @returns {React.ReactElement} The text with highlighted matches.
+ */
+const highlightMatch = (text, query) => {
+ if (!query || !query.trim() || !text) {
+ return <>{text}>;
+ }
+
+ // Escape special regex characters in the query string
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const regex = new RegExp(`(${escaped})`, "gi");
+
+ // Split by capturing group so matched portions appear in the resulting array
+ const parts = text.split(regex);
+
+ return (
+ <>
+ {parts.map((part, index) =>
+ part.toLowerCase() === query.toLowerCase() ? (
+
+ {part}
+
+ ) : (
+ {part}
+ )
+ )}
+ >
+ );
+};
+
+export default highlightMatch;
diff --git a/frontend/src/utils/shortcuts.js b/frontend/src/utils/shortcuts.js
index dbc56b4f..f7944d44 100644
--- a/frontend/src/utils/shortcuts.js
+++ b/frontend/src/utils/shortcuts.js
@@ -14,6 +14,13 @@ export const shortcuts = {
jumpToLast: ["End"],
listLayout: ["Control", "Shift", "!"], // Act as Ctrl + Shift + 1 but could cause problems for QWERTZ or DVORAK etc. keyborad layouts.
gridLayout: ["Control", "Shift", "@"], // Act as Ctrl + Shift + 2 but could cause problems for QWERTZ or DVORAK etc. keyborad layouts.
+ undo: ["Control", "Z"],
+ redo: ["Control", "Shift", "Z"],
refresh: ["F5"],
clearSelection: ["Escape"],
+ search: ["Control", "F"],
+ detailsPanel: ["Alt", "P"],
+ newTab: ["Control", "T"],
+ closeTab: ["Control", "W"],
+ nextTab: ["Control", "Tab"],
};
diff --git a/frontend/src/validators/__tests__/propValidators.test.ts b/frontend/src/validators/__tests__/propValidators.test.ts
new file mode 100644
index 00000000..4d7e7d88
--- /dev/null
+++ b/frontend/src/validators/__tests__/propValidators.test.ts
@@ -0,0 +1,77 @@
+import { describe, it, expect } from 'vitest';
+import { dateStringValidator, urlValidator } from '../propValidators.js';
+
+describe('dateStringValidator', () => {
+ it('accepts valid ISO date strings', () => {
+ const props = { createdAt: '2024-01-15T10:30:00Z' };
+ const result = dateStringValidator(props, 'createdAt', 'TestComponent');
+ expect(result).toBeUndefined();
+ });
+
+ it('accepts valid date-only strings', () => {
+ const props = { createdAt: '2024-06-15' };
+ const result = dateStringValidator(props, 'createdAt', 'TestComponent');
+ expect(result).toBeUndefined();
+ });
+
+ it('rejects invalid date strings', () => {
+ const props = { createdAt: 'not-a-date' };
+ const result = dateStringValidator(props, 'createdAt', 'TestComponent');
+ expect(result).toBeInstanceOf(Error);
+ expect(result?.message).toContain('Invalid prop');
+ expect(result?.message).toContain('createdAt');
+ expect(result?.message).toContain('TestComponent');
+ });
+
+ it('accepts undefined (optional prop)', () => {
+ const props = {};
+ const result = dateStringValidator(
+ props,
+ 'createdAt',
+ 'TestComponent'
+ );
+ expect(result).toBeUndefined();
+ });
+
+ it('rejects random strings that are not dates', () => {
+ const props = { date: 'abc123xyz' };
+ const result = dateStringValidator(props, 'date', 'MyComponent');
+ expect(result).toBeInstanceOf(Error);
+ expect(result?.message).toContain('abc123xyz');
+ });
+});
+
+describe('urlValidator', () => {
+ it('accepts valid HTTP URLs', () => {
+ const props = { endpoint: 'https://example.com/api' };
+ const result = urlValidator(props, 'endpoint', 'TestComponent');
+ expect(result).toBeUndefined();
+ });
+
+ it('accepts valid URLs with paths and query params', () => {
+ const props = { endpoint: 'https://api.example.com/v1/files?page=1' };
+ const result = urlValidator(props, 'endpoint', 'TestComponent');
+ expect(result).toBeUndefined();
+ });
+
+ it('rejects invalid URLs', () => {
+ const props = { endpoint: 'not-a-url' };
+ const result = urlValidator(props, 'endpoint', 'TestComponent');
+ expect(result).toBeInstanceOf(Error);
+ expect(result?.message).toContain('Invalid prop');
+ expect(result?.message).toContain('endpoint');
+ expect(result?.message).toContain('TestComponent');
+ });
+
+ it('rejects empty strings', () => {
+ const props = { endpoint: '' };
+ const result = urlValidator(props, 'endpoint', 'TestComponent');
+ expect(result).toBeInstanceOf(Error);
+ });
+
+ it('accepts valid URLs with different protocols', () => {
+ const props = { endpoint: 'ftp://files.example.com/docs' };
+ const result = urlValidator(props, 'endpoint', 'TestComponent');
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 00000000..6d03d173
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noFallthroughCasesInSwitch": true,
+ "allowJs": true,
+ "checkJs": false,
+ "declaration": true,
+ "declarationDir": "./dist/types",
+ "outDir": "./dist",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 00000000..42872c59
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js
new file mode 100644
index 00000000..c56f384f
--- /dev/null
+++ b/frontend/vitest.config.js
@@ -0,0 +1,24 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react-swc';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: ['./src/test/setup.ts'],
+ css: true,
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json', 'html'],
+ exclude: [
+ 'node_modules/',
+ 'src/test/',
+ 'src/locales/',
+ 'src/main.jsx',
+ 'src/App.jsx',
+ 'src/App.scss',
+ ],
+ },
+ },
+});