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. ![React File Manager](https://github.com/user-attachments/assets/e68f750b-86bf-450d-b27e-fd3dedebf1bd) @@ -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(``); + processed.push("
    "); + inList = true; + listType = "ul"; + } + processed.push(`
  • ${ulMatch[2]}
  • `); + continue; + } else if (olMatch) { + if (!inList || listType !== "ol") { + if (inList) processed.push(``); + processed.push("
      "); + inList = true; + listType = "ol"; + } + processed.push(`
    1. ${olMatch[2]}
    2. `); + continue; + } else if (inList) { + processed.push(``); + 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(``); + } + + 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 ![alt](url) + html = html.replace( + /!\[([^\]]*)\]\(([^)]+)\)/g, + '$1' + ); + + // 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) => ( + + ))} + + + + {dataRows.map((row, rowIndex) => ( + + {headers.map((_, colIndex) => ( + + ))} + + ))} + +
      + {header} +
      + {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}% + + +
      + )} + + + + + {currentIndex + 1} {t("ofFiles")} {totalFiles} + + + +
      + ); +}; + +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)}
      - ))} - {imageExtensions.includes(extension) && ( - <> - - Preview Unavailable - - )} - {videoExtensions.includes(extension) && ( -
      + )} + {isImage && !hasError && ( + <> + +
      + Preview Unavailable +
      + + )} + {videoExtensions.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"} + )} +
      + ) : ( +
      + +
      + )} +
      +
      + ); +}; + +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}) + + +
      + ); +}; + +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 ( +
      + + {isOpen && ( +
      +
      + {t("columns") || "Columns"} +
      + {allColumns.map((col) => ( + + ))} +
      + )} +
      + ); +}; + +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 ( +
      +
      + +
      + ); +}; + +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 &&
      } + {file.name} 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) => ( + + ))} +
      + + + ); +}; + +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 && ( + + )} +
      + +
      + +
      + ); +}; + +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 && ( + + )} +
      + ))} +
      + {canAddTab && ( + + )} +
      + ); +}; + +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 ( + + ); + })} +
      + ); +}; + +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 && ( - )} {permissions.copy && ( - @@ -94,6 +115,7 @@ const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => { {clipBoard?.files?.length > 0 && ( )} {permissions.download && ( - @@ -119,6 +142,7 @@ const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => { {permissions.delete && ( @@ -159,7 +184,13 @@ const Toolbar = ({ onLayoutChange, onRefresh, triggerAction, permissions }) => {
      {toolbarRightItems.map((item, index) => (
      - {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 && ( + + )} + +
              + ); +}; + +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', + ], + }, + }, +});