diff --git a/README.md b/README.md index 4ce92b5..6674a96 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,53 @@ Free serverless backend with a limit of 100,000 invocation requests per day. ## Features -- Upload large files -- Create folders -- Search files -- Image/video/PDF thumbnails -- WebDAV endpoint -- Drag and drop upload +### File Management +- **Upload large files** - Support for files of any size with chunked uploads +- **Create folders** - Organize your files with folder structure +- **Drag and drop upload** - Enhanced drag & drop interface with visual feedback +- **Multi-file operations** - Select multiple files for bulk operations +- **File operations** - Download, rename, delete, copy link + +### User Interface +- **Dual view modes** - Switch between grid and list view +- **Advanced search** - Smart fuzzy search with exact match toggle +- **Flexible sorting** - Sort by name, size, or date modified +- **Floating upload progress** - Real-time upload tracking with cancel option +- **Responsive design** - Works seamlessly on desktop and mobile + +### Media & Preview +- **Image/video/PDF thumbnails** - Visual preview for supported file types +- **File type icons** - Clear visual indicators for different file types +- **Breadcrumb navigation** - Easy folder navigation + +### Integration & Access +- **WebDAV endpoint** - Standard protocol support for third-party clients +- **Authentication system** - Secure login with customizable credentials +- **Public read option** - Optional public access to files +- **Direct file links** - Share files with copyable URLs ## Usage +### User Interface Guide + +#### File Upload +- **Drag & Drop**: Simply drag files from your computer into the browser window +- **Upload Button**: Click the floating upload button (bottom-right) to select files +- **Upload Progress**: Monitor uploads with the floating progress indicator +- **Cancel Uploads**: Cancel in-progress uploads if needed + +#### File Management +- **View Modes**: Switch between grid view (thumbnails) and list view (detailed) +- **Sorting**: Sort files by name, size, or modification date +- **Search**: Use fuzzy search for flexible file finding, or exact search for precise matching +- **Multi-select**: Right-click or long-press to select multiple files +- **File Operations**: Download, rename, delete files, or copy shareable links + +#### Navigation +- **Folders**: Click on folders to navigate, use breadcrumbs to go back +- **Search Results**: View how many files match your search query +- **File Statistics**: See total files and filtered results in real-time + ### Installation Before starting, you should make sure that @@ -49,6 +87,70 @@ Fill the endpoint URL as `https:///webdav` and use the username However, the standard WebDAV protocol does not support large file (≥128MB) uploads due to the limitation of Cloudflare Workers. You must upload large files through the web interface which supports chunked uploads. +## Technical Features + +### Upload System +- **Chunked uploads** for files ≥100MB with progress tracking +- **Concurrent upload** support with queue management +- **Upload cancellation** with proper cleanup +- **Retry mechanism** for failed uploads +- **Thumbnail generation** for images, videos, and PDFs + +### Search & Filter +- **Fuzzy search algorithm** with Levenshtein distance and n-gram similarity +- **Real-time filtering** with instant results +- **Search statistics** showing matched/total files +- **Case-insensitive** search with accent support + +### Performance +- **Optimized file parsing** with XML sanitization and fallback +- **Efficient rendering** with virtual scrolling for large file lists +- **Responsive UI** with smooth transitions and loading states +- **Memory management** with proper cleanup of upload resources + +### Security +- **Authentication system** with secure credential storage +- **CORS handling** for cross-origin requests +- **Input sanitization** for file names and metadata +- **Error handling** with user-friendly messages + +## Development + +### Local Development +```bash +# Install dependencies +npm install + +# Start development server +npm start + +# Build for production +npm run build +``` + +### Debug Features +- **Comprehensive logging** throughout upload and file operations +- **Upload debugging** with detailed progress and error tracking +- **Test upload button** (localhost only) for UI component testing +- **Console debugging** with step-by-step operation logs + +### Project Structure +``` +src/ +├── components/ # UI components +│ ├── FileGrid.tsx # Grid view component +│ ├── FileList.tsx # List view component +│ ├── Header.tsx # Main header with toolbar +│ └── ... # Other components +├── utils/ # Utility modules +│ ├── fuzzySearch.ts # Advanced search algorithms +│ ├── uploadManager.ts # Upload queue management +│ └── xmlParser.ts # XML parsing utilities +├── app/ # Core application logic +│ └── transfer.ts # File operations and API calls +└── ... # Other files +``` + ## Acknowledgments WebDAV related code is based on [r2-webdav]( diff --git a/UPLOAD_DEBUG.md b/UPLOAD_DEBUG.md new file mode 100644 index 0000000..79d26fe --- /dev/null +++ b/UPLOAD_DEBUG.md @@ -0,0 +1,126 @@ +# FlareDrive Upload Debug Guide + +## Debug Improvements Added + +### 1. Enhanced Logging Throughout Upload Flow + +#### Upload Manager (uploadManager.ts) +- Logs when uploads are added with their IDs +- Logs status changes for each upload +- Logs progress updates with detailed upload info + +#### Transfer Module (transfer.ts) +- Logs when `processUploadQueue` is called with queue length +- Logs each file being processed with uploadId and manager status +- Logs actual upload start +- Logs XHR requests with URLs +- Logs upload progress events in XHR +- Logs response status and errors +- Enhanced error messages for failed uploads + +#### Main Component (Main.tsx) +- Logs upload manager creation +- Detailed logging of upload progress updates with: + - Upload count + - Each upload's ID, filename, status, and progress +- Logs when component mounts with manager status + +#### FloatingUploadProgress Component +- Logs render events with upload count and visibility +- Detailed logging of each upload's state +- Logs when component is hidden/shown + +#### UploadDrawer Component +- Logs when files are added from drawer with IDs +- Logs upload manager availability + +### 2. Test Upload Button + +When running on localhost, a green "Test Upload UI" button appears in the bottom-left corner. This button: +- Creates a test file +- Adds it to the upload manager +- Should trigger the FloatingUploadProgress to appear +- Helps verify if the UI component is working independently of actual uploads + +### 3. How to Debug Upload Issues + +Open browser Developer Tools (F12) and check the Console tab for: + +1. **Upload Manager Initialization** + - Look for: "Creating upload manager instance" + - Confirms manager is ready + +2. **File Selection** + - Look for: "Added upload from drawer: [filename] with ID: [id]" + - Confirms files are being added to manager + +3. **Queue Processing** + - Look for: "processUploadQueue called, queue length: [n]" + - Should show non-zero queue length + - Look for: "Processing upload: [filename]" + +4. **Upload Progress** + - Look for: "XHR upload progress: [loaded] / [total]" + - Should show increasing loaded values + - Look for: "Upload progress update - count: [n]" + - Should show uploads with changing progress + +5. **UI Visibility** + - Look for: "FloatingUploadProgress render - uploads: [n]" + - Should show non-zero upload count + - Look for: "Uploads in FloatingProgress:" followed by upload details + +6. **Network Activity** + - Check Network tab for PUT requests to `/webdav/[filename]` + - Check response status (should be 200-299 for success) + +### 4. Common Issues and Solutions + +#### Issue: FloatingProgress doesn't appear +**Check:** +- Console for "Upload progress update - count: 0" +- If count is 0, uploads aren't being added to manager +- Test with the green "Test Upload UI" button + +#### Issue: Upload starts but no progress +**Check:** +- Network tab for stalled requests +- Console for "XHR request error" or status errors +- Authentication headers in requests + +#### Issue: Upload completes but UI doesn't update +**Check:** +- Console for "Upload completed: [id]" +- Console for status transition logs +- FloatingProgress visibility logs + +### 5. Quick Test Procedure + +1. Open the app in browser +2. Open Developer Tools (F12) > Console +3. Click the green "Test Upload UI" button (localhost only) +4. Check if FloatingUploadProgress appears +5. If it appears, try uploading a real file +6. If it doesn't appear, check console for upload manager logs + +### 6. Authentication Issues + +If uploads fail with 401/403 errors: +- Check `getAuthHeaders()` is returning correct headers +- Verify authentication token in localStorage +- Check Network tab for authorization headers in requests + +### 7. File Size Issues + +- Files < 100MB use single PUT request +- Files >= 100MB use multipart upload +- Check console for "multipart" vs regular upload logs + +## Next Steps if Issues Persist + +1. Check server logs for upload endpoints +2. Verify CORS settings if cross-origin +3. Test with small text files first +4. Check browser console for any uncaught errors +5. Verify WebDAV endpoint is accessible +6. Test upload endpoints manually with curl/Postman \ No newline at end of file diff --git a/WARP.md b/WARP.md new file mode 100644 index 0000000..2d1a116 --- /dev/null +++ b/WARP.md @@ -0,0 +1,207 @@ +# WARP.md + +This file provides guidance to WARP (warp.dev) when working with code in this repository. + +## Project Overview + +FlareDrive is a Cloudflare R2 storage manager built with React and Cloudflare Pages Functions. It provides a dual interface: a modern web UI and WebDAV protocol support for client compatibility. Key features include: + +- Upload large files with chunked multipart upload (≥100MB files via web interface) +- Create folders and manage file hierarchies +- Search files and navigate directories +- Generate and serve image/video/PDF thumbnails (144x144px) +- WebDAV endpoint for client applications (file managers, etc.) +- Drag and drop upload with progress tracking + +## Environment Setup Requirements + +### Prerequisites +- Cloudflare account with payment method added +- R2 service activated with at least one bucket created +- Node.js environment for local development +- Wrangler CLI for deployment + +### Environment Variables +Set the following in Cloudflare Pages environment variables: +- `WEBDAV_USERNAME` - Username for WebDAV authentication +- `WEBDAV_PASSWORD` - Password for WebDAV authentication +- `WEBDAV_PUBLIC_READ` (optional) - Set to `1` to enable public read access +- `BUCKET` - R2 bucket binding for file storage + +## Common Development Commands + +```bash +npm start # Start React development server +npm run build # Build production bundle for deployment +npm test # Run test suite +npm run eject # Eject from Create React App (not recommended) +``` + +### Deployment Commands +```bash +npm run build # Build the React app +npx wrangler pages deploy build # Deploy to Cloudflare Pages +``` + +## High-Level Architecture + +### Dual Architecture Pattern +FlareDrive uses a dual architecture: +1. **React SPA** (`src/`) - Modern web UI for file management +2. **Cloudflare Pages Functions** (`functions/`) - ServerlessWebDAV + REST API backend + +### Data Flow +- **UI → Functions**: React app calls Cloudflare Functions via `/file/*` routes +- **WebDAV Clients → Functions**: External clients use WebDAV protocol endpoints +- **Functions → R2**: All file operations go through Cloudflare R2 storage +- **Thumbnails**: Generated client-side and stored in R2 at `_$flaredrive$/thumbnails/` + +### React Frontend Structure (`src/`) +- **App.tsx**: Main app component with AuthProvider, theme, conditional rendering +- **AuthContext.tsx**: Authentication state management and login logic +- **Login.tsx**: Login form component for web interface authentication +- **LogoutButton.tsx**: Logout button component for header +- **Main.tsx**: Core file browser with breadcrumbs, drag-drop, multi-select +- **FileGrid.tsx**: File/folder grid display with thumbnails and metadata +- **Header.tsx**: Top navigation with search and logout functionality +- **UploadDrawer.tsx**: File upload interface and progress tracking +- **MultiSelectToolbar.tsx**: Actions for selected files (download, copy link, rename, delete) +- **app/transfer.ts**: File operations with authentication headers and debug logging +- **utils/fuzzySearch.ts**: Advanced search algorithms (fuzzy, n-gram, Levenshtein) + +### Cloudflare Functions Backend (`functions/file/`) +WebDAV + REST API implementation: +- **[[path]].ts**: Main request router and authentication handler +- **propfind.ts**: WebDAV PROPFIND (list directories/files) +- **get.ts**: Download files and serve content +- **put.ts**: Upload files (single part) +- **post.ts**: Multipart upload initiation and completion +- **copy.ts**, **move.ts**: File copy and move operations +- **delete.ts**: File deletion +- **mkcol.ts**: Create directories +- **head.ts**: File metadata requests +- **utils.ts**: Shared utilities (bucket parsing, listing, authentication) + +### WebDAV Implementation Details +- **Supported Methods**: PROPFIND, MKCOL, HEAD, GET, POST, PUT, COPY, MOVE, DELETE, OPTIONS +- **Authentication**: Dual authentication system: + - HTTP Basic Auth for WebDAV clients + - Session-based auth for web interface (requires login) + - Direct file access (GET) remains public when WEBDAV_PUBLIC_READ is enabled +- **XML Responses**: Custom XML generation for 207 Multi-Status responses +- **Path Mapping**: WebDAV URLs map directly to R2 object keys +- **Client Compatibility**: Works with standard WebDAV clients (file managers, etc.) + +### Multipart Upload System +- **Chunk Size**: 100MB per part (`SIZE_LIMIT` in `transfer.ts`) +- **Concurrency**: 2 concurrent uploads using `p-limit` +- **Initiation**: `POST /file/{path}?uploads` creates multipart upload +- **Part Upload**: `PUT /file/{path}?partNumber=N&uploadId=X` uploads chunks +- **Completion**: `POST /file/{path}?uploadId=X` with parts list finalizes upload +- **Progress Tracking**: XHR-based uploads with progress events + +### File Management and Thumbnails +- **Thumbnail Generation**: Client-side canvas rendering for images, videos, PDFs +- **Thumbnail Storage**: R2 at `/_$flaredrive$/thumbnails/{sha1}.png` +- **Metadata**: Custom metadata `fd-thumbnail` header links files to thumbnails +- **Search**: Advanced fuzzy search with multiple algorithms: + - Exact matching, prefix matching, substring matching + - N-gram similarity for partial matches + - Levenshtein distance for typo tolerance + - Word boundary matching and acronym matching + - Toggle between fuzzy and exact search modes +- **Operations**: Copy, move, rename, delete via WebDAV methods +- **Link Sharing**: Copy direct file URLs to clipboard for easy sharing + +## Key Directories + +``` +/src/ # React frontend source code + /app/ # Application logic (transfer, API calls) +/functions/ # Cloudflare Pages Functions (WebDAV + REST API) + /file/ # File operation handlers +/public/ # Static assets (favicon, manifest, etc.) +/utils/ # Shared utilities (S3 client for R2) +package.json # Dependencies and scripts +tsconfig.json # TypeScript configuration +.gitignore # Git ignore rules +``` + +## Development Workflow + +### Local Development +1. Set up Cloudflare account and R2 bucket +2. Configure environment variables in Cloudflare Pages dashboard +3. Run `npm install` to install dependencies +4. Run `npm start` to start React development server +5. Pages Functions run automatically in development mode +6. Access WebDAV at `http://localhost:3000/file/` (when running locally) + +### Debugging File Issues +If files are not showing up or search is not working: +1. Open browser developer console to see debug logs +2. Check `fetchPath()` logs for PROPFIND request/response details +3. Verify authentication headers are being sent +4. Check XML parsing and filtering logic +5. Use fuzzy search toggle to switch between search modes + +### Testing WebDAV Connectivity +Use WebDAV clients like: +- **BD File Manager** (Android) +- **Finder** (macOS) - Connect to Server +- **File Explorer** (Windows) - Map Network Drive +- **Cyberduck** or similar desktop clients + +Endpoint: `https:///file/` (not `/webdav/` as mentioned in README) + +## Deployment + +### Cloudflare Pages Deployment +1. Fork repository and connect to Cloudflare Pages +2. Set framework preset to "Create React App" +3. Configure environment variables (`WEBDAV_USERNAME`, `WEBDAV_PASSWORD`, etc.) +4. Bind R2 bucket to `BUCKET` variable in Pages dashboard +5. Deploy triggers automatic build and deployment + +### Manual Deployment +```bash +npm run build # Build React app +npx wrangler pages deploy build # Deploy to Cloudflare Pages +``` + +## Important Technical Details and Tradeoffs + +### Architecture Tradeoffs +- **Client-side thumbnails**: Generated in browser (good: no server load, bad: slower uploads) +- **WebDAV + SPA**: Dual interface increases complexity but maximizes compatibility +- **R2 direct**: No database for metadata, relies on R2 object metadata and listing + +### Performance Considerations +- **Large directories**: Performance may degrade with thousands of files (R2 list operations) +- **Thumbnail caching**: Thumbnails stored permanently in R2, no cleanup mechanism +- **Upload limits**: WebDAV uploads limited to <128MB due to Cloudflare Workers request limits + +### Authentication and Access Control +- **Web Interface**: Requires login with WebDAV credentials before accessing file browser +- **Direct File Access**: GET requests remain public (no authentication required) +- **WebDAV Clients**: Use HTTP Basic Auth with username/password +- **Session Management**: Web sessions stored in localStorage +- **Directory Listing**: PROPFIND requests always require authentication +- **Link Sharing**: Direct file URLs can be shared publicly (no authentication needed for access) + +### WebDAV Limitations +- **Large file uploads**: Must use web interface for files ≥128MB (Workers request size limit) +- **Path encoding**: Special characters in filenames may cause issues with some clients +- **Concurrent operations**: No locking mechanism for concurrent WebDAV operations + +### Storage Layout +- **Files**: Stored directly in R2 bucket with original paths as keys +- **Folders**: Represented as objects with `application/x-directory` content-type +- **Thumbnails**: Stored under `_$flaredrive$/thumbnails/` prefix with SHA-1 hash names +- **Metadata**: File thumbnails linked via `fd-thumbnail` custom metadata + +### Error Handling +- **Upload failures**: Client-side retry logic with visual feedback +- **WebDAV errors**: Standard HTTP status codes (404, 401, 207, etc.) +- **Authentication errors**: Login form shows error messages, logout on auth failure +- **Session expiry**: Automatic redirect to login when credentials become invalid diff --git a/functions/webdav/[[path]].ts b/functions/file/[[path]].ts similarity index 97% rename from functions/webdav/[[path]].ts rename to functions/file/[[path]].ts index f9077e8..ee4cdca 100644 --- a/functions/webdav/[[path]].ts +++ b/functions/file/[[path]].ts @@ -46,7 +46,7 @@ export const onRequest: PagesFunction<{ const skipAuth = env.WEBDAV_PUBLIC_READ && - ["GET", "HEAD", "PROPFIND"].includes(request.method); + ["GET", "HEAD"].includes(request.method); if (!skipAuth) { if (!env.WEBDAV_USERNAME || !env.WEBDAV_PASSWORD) diff --git a/functions/webdav/copy.ts b/functions/file/copy.ts similarity index 100% rename from functions/webdav/copy.ts rename to functions/file/copy.ts diff --git a/functions/webdav/delete.ts b/functions/file/delete.ts similarity index 100% rename from functions/webdav/delete.ts rename to functions/file/delete.ts diff --git a/functions/webdav/get.ts b/functions/file/get.ts similarity index 100% rename from functions/webdav/get.ts rename to functions/file/get.ts diff --git a/functions/webdav/head.ts b/functions/file/head.ts similarity index 100% rename from functions/webdav/head.ts rename to functions/file/head.ts diff --git a/functions/webdav/mkcol.ts b/functions/file/mkcol.ts similarity index 100% rename from functions/webdav/mkcol.ts rename to functions/file/mkcol.ts diff --git a/functions/webdav/move.ts b/functions/file/move.ts similarity index 100% rename from functions/webdav/move.ts rename to functions/file/move.ts diff --git a/functions/webdav/post.ts b/functions/file/post.ts similarity index 100% rename from functions/webdav/post.ts rename to functions/file/post.ts diff --git a/functions/webdav/propfind.ts b/functions/file/propfind.ts similarity index 100% rename from functions/webdav/propfind.ts rename to functions/file/propfind.ts diff --git a/functions/webdav/put.ts b/functions/file/put.ts similarity index 100% rename from functions/webdav/put.ts rename to functions/file/put.ts diff --git a/functions/webdav/utils.ts b/functions/file/utils.ts similarity index 96% rename from functions/webdav/utils.ts rename to functions/file/utils.ts index 1b43209..441386f 100644 --- a/functions/webdav/utils.ts +++ b/functions/file/utils.ts @@ -4,7 +4,7 @@ export interface RequestHandlerParams { request: Request; } -export const WEBDAV_ENDPOINT = "/webdav/"; +export const WEBDAV_ENDPOINT = "/file/"; export function notFound() { return new Response("Not found", { status: 404 }); diff --git a/public/favicon.png b/public/favicon.png index 8119e1c..f3b0a30 100644 Binary files a/public/favicon.png and b/public/favicon.png differ diff --git a/public/index.html b/public/index.html index dbc77ce..8414954 100644 --- a/public/index.html +++ b/public/index.html @@ -5,10 +5,10 @@ - + - Flare Drive + UndanganTa by NAS diff --git a/public/logo144.png b/public/logo144.png index 6582a33..4989b84 100644 Binary files a/public/logo144.png and b/public/logo144.png differ diff --git a/public/manifest.json b/public/manifest.json index df607bd..9b53f13 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,6 +1,6 @@ { - "short_name": "Flare Drive", - "name": "Flare Drive", + "short_name": "UndanganTa by NAS", + "name": "UndanganTa by NAS", "icons": [ { "src": "favicon.ico", diff --git a/src/App.tsx b/src/App.tsx index c9e1311..520c37f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,8 @@ import React, { useState } from "react"; import Header from "./Header"; import Main from "./Main"; -import ProgressDialog from "./ProgressDialog"; +import Login from "./Login"; +import { AuthProvider, useAuth } from "./AuthContext"; const globalStyles = ( @@ -20,22 +21,42 @@ const theme = createTheme({ palette: { primary: { main: "#f38020" } }, }); -function App() { +function AppContent() { + const { isAuthenticated } = useAuth(); const [search, setSearch] = useState(""); - const [showProgressDialog, setShowProgressDialog] = React.useState(false); const [error, setError] = useState(null); + const [fileStats, setFileStats] = useState<{ total: number; filtered: number }>({ total: 0, filtered: 0 }); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + const [sortBy, setSortBy] = useState<'name' | 'size' | 'date'>('name'); + const [useFuzzySearch, setUseFuzzySearch] = useState(true); + + if (!isAuthenticated) { + return ; + } return ( - - - {globalStyles} +
setSearch(newSearch)} - setShowProgressDialog={setShowProgressDialog} + totalFiles={fileStats.total} + filteredCount={fileStats.filtered} + viewMode={viewMode} + onViewModeChange={setViewMode} + sortBy={sortBy} + onSortByChange={setSortBy} + useFuzzySearch={useFuzzySearch} + onFuzzySearchChange={setUseFuzzySearch} + /> +
-
setError(null)} /> - setShowProgressDialog(false)} - /> - + + ); +} + +function App() { + return ( + + + + {globalStyles} + + + ); } diff --git a/src/AuthContext.tsx b/src/AuthContext.tsx new file mode 100644 index 0000000..5ce035e --- /dev/null +++ b/src/AuthContext.tsx @@ -0,0 +1,80 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; + +interface AuthContextType { + isAuthenticated: boolean; + credentials: string | null; + login: (username: string, password: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +export const useAuth = () => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +interface AuthProviderProps { + children: ReactNode; +} + +export const AuthProvider: React.FC = ({ children }) => { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [credentials, setCredentials] = useState(null); + + useEffect(() => { + // Check for stored credentials on app load + const storedCredentials = localStorage.getItem('flaredrive_auth'); + if (storedCredentials) { + setCredentials(storedCredentials); + setIsAuthenticated(true); + } + }, []); + + const login = async (username: string, password: string): Promise => { + try { + // Create Basic Auth credentials + const basicAuth = btoa(`${username}:${password}`); + + // Test the credentials by making a PROPFIND request + const response = await fetch('/file/', { + method: 'PROPFIND', + headers: { + 'Authorization': `Basic ${basicAuth}`, + 'Depth': '1' + } + }); + + if (response.ok) { + // Store credentials and update state + localStorage.setItem('flaredrive_auth', basicAuth); + setCredentials(basicAuth); + setIsAuthenticated(true); + return true; + } else { + return false; + } + } catch (error) { + console.error('Login error:', error); + return false; + } + }; + + const logout = () => { + localStorage.removeItem('flaredrive_auth'); + setCredentials(null); + setIsAuthenticated(false); + }; + + const value = { + isAuthenticated, + credentials, + login, + logout + }; + + return {children}; +}; \ No newline at end of file diff --git a/src/FileGrid.tsx b/src/FileGrid.tsx index a70c545..da8693a 100644 --- a/src/FileGrid.tsx +++ b/src/FileGrid.tsx @@ -59,7 +59,7 @@ function FileGrid({ {file.customMetadata?.thumbnail ? ( {file.key} diff --git a/src/FileList.tsx b/src/FileList.tsx new file mode 100644 index 0000000..43bed3d --- /dev/null +++ b/src/FileList.tsx @@ -0,0 +1,149 @@ +import React from "react"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Box, + Typography, +} from "@mui/material"; +import MimeIcon from "./MimeIcon"; +import { FileItem, encodeKey, isDirectory } from "./FileGrid"; + +function humanReadableSize(size: number) { + const units = ["B", "KB", "MB", "GB", "TB"]; + let i = 0; + while (size >= 1024) { + size /= 1024; + i++; + } + return `${size.toFixed(1)} ${units[i]}`; +} + +function extractFilename(key: string) { + return key.split("/").pop() || ""; +} + +function FileList({ + files, + onCwdChange, + multiSelected, + onMultiSelect, + emptyMessage, +}: { + files: FileItem[]; + onCwdChange: (newCwd: string) => void; + multiSelected: string[] | null; + onMultiSelect: (key: string) => void; + emptyMessage?: React.ReactNode; +}) { + if (files.length === 0) { + return <>{emptyMessage}; + } + + const handleRowClick = (event: React.MouseEvent, file: FileItem) => { + if (multiSelected !== null) { + onMultiSelect(file.key); + event.preventDefault(); + } else if (isDirectory(file)) { + onCwdChange(file.key + "/"); + event.preventDefault(); + } else { + // Open file in new tab + window.open(`/file/${encodeKey(file.key)}`, "_blank"); + event.preventDefault(); + } + }; + + return ( + + + + + + Name + Size + Modified + Type + + + + {files.map((file) => { + const isDir = isDirectory(file); + const isSelected = multiSelected?.includes(file.key) || false; + + return ( + handleRowClick(e, file)} + onContextMenu={(e) => { + e.preventDefault(); + onMultiSelect(file.key); + }} + sx={{ + cursor: "pointer", + "&:hover": { + backgroundColor: "action.hover", + } + }} + > + + + {file.customMetadata?.thumbnail ? ( + {file.key} + ) : ( + + )} + + + + + + {extractFilename(file.key)} + + + + + {!isDir ? humanReadableSize(file.size) : "-"} + + + {new Date(file.uploaded).toLocaleDateString()} + + + + {isDir ? "Folder" : file.httpMetadata.contentType.split("/")[1]?.toUpperCase() || "File"} + + + + ); + })} + +
+
+ ); +} + +export default FileList; \ No newline at end of file diff --git a/src/FloatingUploadProgress.tsx b/src/FloatingUploadProgress.tsx new file mode 100644 index 0000000..c3dad7a --- /dev/null +++ b/src/FloatingUploadProgress.tsx @@ -0,0 +1,404 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + IconButton, + Typography, + LinearProgress, + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + Collapse, + Fade, + Paper, +} from '@mui/material'; +import { + Close as CloseIcon, + Cancel as CancelIcon, + ExpandLess as ExpandLessIcon, + ExpandMore as ExpandMoreIcon, + CloudUpload as CloudUploadIcon, + CheckCircle as CheckCircleIcon, + Error as ErrorIcon, +} from '@mui/icons-material'; + +export interface UploadItem { + id: string; + fileName: string; + fileSize: number; + progress: number; + status: 'pending' | 'uploading' | 'completed' | 'error' | 'cancelled'; + error?: string; + abortController?: AbortController; +} + +interface FloatingUploadProgressProps { + uploads: UploadItem[]; + onCancelUpload: (id: string) => void; + onClearCompleted: () => void; + onClose: () => void; +} + +const FloatingUploadProgress: React.FC = ({ + uploads, + onCancelUpload, + onClearCompleted, + onClose, +}) => { + const [isExpanded, setIsExpanded] = useState(true); + const [isVisible, setIsVisible] = useState(true); + + // Auto-show the component when new uploads are added + useEffect(() => { + const hasActiveUploads = uploads.some(u => u.status === 'uploading' || u.status === 'pending'); + if (hasActiveUploads && !isVisible) { + console.log('New active uploads detected, showing FloatingUploadProgress'); + setIsVisible(true); + } + }, [uploads, isVisible]); + + console.log('FloatingUploadProgress render - uploads:', uploads.length, 'visible:', isVisible); + console.log('Uploads in FloatingProgress:', uploads.map(u => ({ + id: u.id, + fileName: u.fileName, + status: u.status, + progress: u.progress + }))); + + // Show the component if there are ANY uploads (even completed ones initially) + const shouldShow = isVisible && uploads.length > 0; + + if (!shouldShow) { + console.log('FloatingUploadProgress not showing - visible:', isVisible, 'uploads:', uploads.length); + return null; + } + + const activeUploads = uploads.filter(u => u.status === 'uploading' || u.status === 'pending'); + const completedUploads = uploads.filter(u => u.status === 'completed'); + const errorUploads = uploads.filter(u => u.status === 'error'); + const cancelledUploads = uploads.filter(u => u.status === 'cancelled'); + + const totalProgress = activeUploads.length > 0 + ? activeUploads.reduce((sum, u) => sum + u.progress, 0) / activeUploads.length + : 100; + + const formatFileSize = (bytes: number): string => { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(1)} ${units[unitIndex]}`; + }; + + const getStatusIcon = (status: UploadItem['status']) => { + switch (status) { + case 'completed': + return ; + case 'error': + return ; + case 'cancelled': + return ; + default: + return null; + } + }; + + const getUploadSummary = () => { + if (activeUploads.length > 0) { + return `Uploading ${activeUploads.length} file${activeUploads.length > 1 ? 's' : ''}`; + } + if (completedUploads.length > 0 && errorUploads.length === 0) { + return `${completedUploads.length} upload${completedUploads.length > 1 ? 's' : ''} completed`; + } + if (errorUploads.length > 0) { + return `${errorUploads.length} upload${errorUploads.length > 1 ? 's' : ''} failed`; + } + return 'No active uploads'; + }; + + return ( + + + {/* Header */} + setIsExpanded(!isExpanded)} + > + + + + {getUploadSummary()} + + + + {activeUploads.length > 0 && ( + + {Math.round(totalProgress)}% + + )} + { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }} + > + {isExpanded ? : } + + { + e.stopPropagation(); + // Only allow closing if no active uploads + const hasActiveUploads = uploads.some(u => u.status === 'uploading' || u.status === 'pending'); + if (!hasActiveUploads) { + setIsVisible(false); + // Clear completed uploads when closing + onClearCompleted(); + onClose(); + } else { + console.log('Cannot close while uploads are in progress'); + // Optionally minimize instead of closing + setIsExpanded(false); + } + }} + title={activeUploads.length > 0 ? 'Minimize (uploads in progress)' : 'Close'} + > + + + + + + {/* Overall Progress Bar */} + {activeUploads.length > 0 && ( + + )} + + {/* Upload List */} + + + + {/* Active and Pending Uploads */} + {activeUploads.map((upload) => ( + + + + {upload.fileName} + + + {formatFileSize(upload.fileSize)} + + + } + secondary={ + + + + + {upload.status === 'pending' ? 'Waiting...' : `${upload.progress}%`} + + {upload.status === 'uploading' && ( + + Uploading... + + )} + + + } + /> + + onCancelUpload(upload.id)} + title="Cancel upload" + > + + + + + ))} + + {/* Completed Uploads */} + {completedUploads.map((upload) => ( + + + {getStatusIcon(upload.status)} + + {upload.fileName} + + + {formatFileSize(upload.fileSize)} + + + } + /> + + ))} + + {/* Error Uploads */} + {errorUploads.map((upload) => ( + + + {getStatusIcon(upload.status)} + + {upload.fileName} + + + } + secondary={ + + {upload.error || 'Upload failed'} + + } + /> + + onCancelUpload(upload.id)} + title="Remove" + > + + + + + ))} + + {/* Cancelled Uploads */} + {cancelledUploads.map((upload) => ( + + + {getStatusIcon(upload.status)} + + {upload.fileName} + + + Cancelled + + + } + /> + + ))} + + + {/* Clear Completed Button */} + {(completedUploads.length > 0 || errorUploads.length > 0 || cancelledUploads.length > 0) && ( + + + Clear completed + + + )} + + + + + ); +}; + +export default FloatingUploadProgress; \ No newline at end of file diff --git a/src/Header.tsx b/src/Header.tsx index c4aab78..23e117a 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -1,56 +1,184 @@ -import { IconButton, InputBase, Menu, MenuItem, Toolbar } from "@mui/material"; +import { IconButton, InputBase, Menu, MenuItem, Toolbar, Box, Tooltip, Chip, ToggleButton, ToggleButtonGroup } from "@mui/material"; import { useState } from "react"; -import { MoreHoriz as MoreHorizIcon } from "@mui/icons-material"; +import { + Search as SearchIcon, + Tune as TuneIcon, + GridView as GridViewIcon, + ViewList as ListViewIcon, + Sort as SortIcon, + SortByAlpha as SortByAlphaIcon, + Storage as StorageIcon, + Schedule as ScheduleIcon +} from "@mui/icons-material"; +import LogoutButton from "./LogoutButton"; function Header({ search, onSearchChange, - setShowProgressDialog, + totalFiles, + filteredCount, + viewMode, + onViewModeChange, + sortBy, + onSortByChange, + useFuzzySearch, + onFuzzySearchChange, }: { search: string; onSearchChange: (newSearch: string) => void; - setShowProgressDialog: (show: boolean) => void; + totalFiles?: number; + filteredCount?: number; + viewMode: 'grid' | 'list'; + onViewModeChange: (mode: 'grid' | 'list') => void; + sortBy: 'name' | 'size' | 'date'; + onSortByChange: (sort: 'name' | 'size' | 'date') => void; + useFuzzySearch: boolean; + onFuzzySearchChange: (fuzzy: boolean) => void; }) { - const [anchorEl, setAnchorEl] = useState(null); + const [sortMenuAnchor, setSortMenuAnchor] = useState(null); + + const getSortIcon = () => { + switch(sortBy) { + case 'name': return ; + case 'size': return ; + case 'date': return ; + default: return ; + } + }; return ( - - + + onSearchChange(e.target.value)} + startAdornment={ + + } + sx={{ + backgroundColor: "whitesmoke", + borderRadius: "999px", + padding: "8px 16px", + paddingLeft: "12px", + }} + /> + + {/* Search results info */} + {search && totalFiles !== undefined && filteredCount !== undefined && ( + + )} + + + {/* Fuzzy search toggle */} + + onFuzzySearchChange(!useFuzzySearch)} + sx={{ + color: useFuzzySearch ? 'primary.main' : 'text.secondary', + backgroundColor: useFuzzySearch ? 'primary.light' : 'transparent', + '&:hover': { + backgroundColor: useFuzzySearch ? 'primary.light' : 'action.hover' + } + }} + > + + + + + {/* View Mode Toggle */} + newMode && onViewModeChange(newMode)} size="small" - fullWidth - placeholder="Search…" - value={search} - onChange={(e) => onSearchChange(e.target.value)} - sx={{ - backgroundColor: "whitesmoke", - borderRadius: "999px", - padding: "8px 16px", - }} - /> - setAnchorEl(e.currentTarget)} + sx={{ height: 36 }} > - - - setAnchorEl(null)} - > - View as - Sort by - { - setAnchorEl(null); - setShowProgressDialog(true); - }} + + + + + + + + + + + + + {/* Sort button with menu */} + + + setSortMenuAnchor(e.currentTarget)} + sx={{ + color: 'text.secondary', + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + {getSortIcon()} + + + setSortMenuAnchor(null)} > - Progress - - + { + onSortByChange('name'); + setSortMenuAnchor(null); + }} + > + + Name + + { + onSortByChange('size'); + setSortMenuAnchor(null); + }} + > + + Size + + { + onSortByChange('date'); + setSortMenuAnchor(null); + }} + > + + Date Modified + + + + + + + ); } diff --git a/src/Login.tsx b/src/Login.tsx new file mode 100644 index 0000000..7ceed00 --- /dev/null +++ b/src/Login.tsx @@ -0,0 +1,136 @@ +import React, { useState } from 'react'; +import { + Box, + Card, + CardContent, + TextField, + Button, + Typography, + Alert, + Stack, + IconButton, + InputAdornment, +} from '@mui/material'; +import { Visibility, VisibilityOff } from '@mui/icons-material'; +import { useAuth } from './AuthContext'; + +const Login: React.FC = () => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const { login } = useAuth(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + const success = await login(username, password); + if (!success) { + setError('Username atau password salah'); + } + } catch (error) { + setError('Terjadi kesalahan saat login'); + } finally { + setIsLoading(false); + } + }; + + const handleTogglePasswordVisibility = () => { + setShowPassword(!showPassword); + }; + + return ( + + + + + + FlareDrive + + + Masuk untuk mengakses file Anda + + + +
+ + {error && ( + + {error} + + )} + + setUsername(e.target.value)} + required + disabled={isLoading} + autoComplete="username" + /> + + setPassword(e.target.value)} + required + disabled={isLoading} + autoComplete="current-password" + InputProps={{ + endAdornment: ( + + + {showPassword ? : } + + + ), + }} + /> + + + +
+ + + + Gunakan kredensial WebDAV yang sama untuk masuk + + +
+
+
+ ); +}; + +export default Login; \ No newline at end of file diff --git a/src/LogoutButton.tsx b/src/LogoutButton.tsx new file mode 100644 index 0000000..4a4920d --- /dev/null +++ b/src/LogoutButton.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Button, IconButton, Tooltip } from '@mui/material'; +import { Logout as LogoutIcon } from '@mui/icons-material'; +import { useAuth } from './AuthContext'; + +interface LogoutButtonProps { + variant?: 'icon' | 'button'; +} + +const LogoutButton: React.FC = ({ variant = 'button' }) => { + const { logout } = useAuth(); + + const handleLogout = () => { + logout(); + }; + + if (variant === 'icon') { + return ( + + + + + + ); + } + + return ( + + ); +}; + +export default LogoutButton; \ No newline at end of file diff --git a/src/Main.tsx b/src/Main.tsx index dc3e034..3b11781 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -5,11 +5,13 @@ import { Button, CircularProgress, Link, + Snackbar, Typography, } from "@mui/material"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState, useRef } from "react"; import FileGrid, { encodeKey, FileItem, isDirectory } from "./FileGrid"; +import FileList from "./FileList"; import MultiSelectToolbar from "./MultiSelectToolbar"; import UploadDrawer, { UploadFab } from "./UploadDrawer"; import { @@ -18,6 +20,19 @@ import { processUploadQueue, uploadQueue, } from "./app/transfer"; +import { enhancedSearch } from "./utils/fuzzySearch"; +import FloatingUploadProgress, { UploadItem } from "./FloatingUploadProgress"; +import UploadManager from "./utils/uploadManager"; + +function getAuthHeaders(): Record { + const credentials = localStorage.getItem('flaredrive_auth'); + if (credentials) { + return { + 'Authorization': `Basic ${credentials}` + }; + } + return {}; +} function Centered({ children }: { children: React.ReactNode }) { return ( @@ -83,6 +98,38 @@ function DropZone({ onDrop: (files: FileList) => void; }) { const [dragging, setDragging] = useState(false); + const dragCounter = useRef(0); + + const handleDragEnter = (event: React.DragEvent) => { + event.preventDefault(); + dragCounter.current++; + if (event.dataTransfer.items && event.dataTransfer.items.length > 0) { + setDragging(true); + } + }; + + const handleDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + dragCounter.current--; + if (dragCounter.current === 0) { + setDragging(false); + } + }; + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + setDragging(false); + dragCounter.current = 0; + + if (event.dataTransfer.files && event.dataTransfer.files.length > 0) { + onDrop(event.dataTransfer.files); + } + }; return ( theme.palette.background.default, - filter: dragging ? "brightness(0.9)" : "none", - transition: "filter 0.2s", - }} - onDragEnter={(event) => { - event.preventDefault(); - setDragging(true); - }} - onDragOver={(event) => { - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - }} - onDragLeave={() => setDragging(false)} - onDrop={(event) => { - event.preventDefault(); - onDrop(event.dataTransfer.files); + position: "relative", + transition: "all 0.2s", }} + onDragEnter={handleDragEnter} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} > {children} + {dragging && ( + + + Drop files here to upload + + + )} ); } @@ -115,40 +178,124 @@ function DropZone({ function Main({ search, onError, + onFileStatsChange, + viewMode, + sortBy, + useFuzzySearch, }: { search: string; onError: (error: Error) => void; + onFileStatsChange?: (stats: { total: number; filtered: number }) => void; + viewMode: 'grid' | 'list'; + sortBy: 'name' | 'size' | 'date'; + useFuzzySearch: boolean; }) { const [cwd, setCwd] = React.useState(""); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); const [multiSelected, setMultiSelected] = useState(null); const [showUploadDrawer, setShowUploadDrawer] = useState(false); + const [copyLinkSnackbar, setCopyLinkSnackbar] = useState(null); + const [floatingUploads, setFloatingUploads] = useState([]); + const uploadManagerRef = useRef(null); + + // Create upload manager instance immediately + if (!uploadManagerRef.current) { + console.log('Creating upload manager instance'); + uploadManagerRef.current = new UploadManager({ + onProgressUpdate: (uploads) => { + console.log('Upload progress update - count:', uploads.length); + console.log('Uploads details:', uploads.map(u => ({ + id: u.id, + fileName: u.fileName, + status: u.status, + progress: u.progress + }))); + setFloatingUploads([...uploads]); // Force re-render with new array + }, + onUploadComplete: (id) => { + console.log(`Upload completed: ${id}`); + }, + onUploadError: (id, error) => { + console.error(`Upload error for ${id}: ${error}`); + }, + onUploadCancelled: (id) => { + console.log(`Upload cancelled: ${id}`); + }, + }); + } const fetchFiles = useCallback(() => { setLoading(true); + console.log(`Fetching files for directory: "${cwd}"`); + fetchPath(cwd) .then((files) => { + console.log(`Successfully fetched ${files.length} files for "${cwd}"`); setFiles(files); setMultiSelected(null); }) - .catch(onError) + .catch((error) => { + console.error(`Failed to fetch files for "${cwd}":`, error); + onError(error); + }) .finally(() => setLoading(false)); }, [cwd, onError]); + // Refresh files when upload completes + useEffect(() => { + // Setup is done above, just log for debugging + console.log('Main component mounted, upload manager ready:', !!uploadManagerRef.current); + }, []); + useEffect(() => { fetchFiles(); }, [fetchFiles]); const filteredFiles = useMemo( - () => - (search - ? files.filter((file) => - file.key.toLowerCase().includes(search.toLowerCase()) - ) - : files - ).sort((a, b) => (isDirectory(a) ? -1 : isDirectory(b) ? 1 : 0)), - [files, search] + () => { + console.log(`Filtering ${files.length} files with search query: "${search}"`); + + let searchResults: FileItem[]; + + if (search && search.trim().length > 0) { + // Use enhanced search with fuzzy matching toggle + searchResults = enhancedSearch(files, search.trim(), useFuzzySearch); + console.log(`Search found ${searchResults.length} matches (fuzzy: ${useFuzzySearch})`); + } else { + searchResults = files; + } + + // Sort: directories first, then apply selected sort + const sorted = searchResults.sort((a, b) => { + const aIsDir = isDirectory(a); + const bIsDir = isDirectory(b); + + // Directories always come first + if (aIsDir && !bIsDir) return -1; + if (!aIsDir && bIsDir) return 1; + + // If both are same type, apply selected sort + switch(sortBy) { + case 'name': + return a.key.localeCompare(b.key); + case 'size': + return a.size - b.size; + case 'date': + return new Date(b.uploaded).getTime() - new Date(a.uploaded).getTime(); + default: + return a.key.localeCompare(b.key); + } + }); + + // Update file statistics + if (onFileStatsChange) { + onFileStatsChange({ total: files.length, filtered: sorted.length }); + } + + return sorted; + }, + [files, search, onFileStatsChange, sortBy, useFuzzySearch] ); const handleMultiSelect = useCallback((key: string) => { @@ -173,30 +320,85 @@ function Main({ ) : ( { - uploadQueue.push( - ...Array.from(files).map((file) => ({ file, basedir: cwd })) - ); - await processUploadQueue(); - fetchFiles(); + // Add files to queue and upload manager + const uploadManager = uploadManagerRef.current; + if (!uploadManager) { + console.error('Upload manager not initialized'); + return; + } + + Array.from(files).forEach((file) => { + const uploadId = uploadManager.addUpload(file); + const upload = uploadManager.getUpload(uploadId); + console.log('Added upload:', file.name, 'with ID:', uploadId); + + uploadQueue.push({ + file, + basedir: cwd, + uploadId, + abortController: upload?.abortController + }); + }); + + // Start processing uploads + processUploadQueue(uploadManager); }} > - setCwd(newCwd)} - multiSelected={multiSelected} - onMultiSelect={handleMultiSelect} - emptyMessage={No files or folders} - /> + {viewMode === 'list' ? ( + setCwd(newCwd)} + multiSelected={multiSelected} + onMultiSelect={handleMultiSelect} + emptyMessage={No files or folders} + /> + ) : ( + setCwd(newCwd)} + multiSelected={multiSelected} + onMultiSelect={handleMultiSelect} + emptyMessage={No files or folders} + /> + )} )} {multiSelected === null && ( - setShowUploadDrawer(true)} /> + <> + setShowUploadDrawer(true)} /> + {/* Debug button to test upload visibility */} + {window.location.hostname === 'localhost' && ( + + )} + )} { if (multiSelected?.length !== 1) return; const a = document.createElement("a"); - a.href = `/webdav/${encodeKey(multiSelected[0])}`; + a.href = `/file/${encodeKey(multiSelected[0])}`; a.download = multiSelected[0].split("/").pop()!; a.click(); }} @@ -223,9 +425,74 @@ function Main({ const confirmMessage = "Delete the following file(s) permanently?"; if (!window.confirm(`${confirmMessage}\n${filenames}`)) return; for (const key of multiSelected) - await fetch(`/webdav/${encodeKey(key)}`, { method: "DELETE" }); + await fetch(`/file/${encodeKey(key)}`, { method: "DELETE", headers: getAuthHeaders() }); fetchFiles(); }} + onCopyLink={async () => { + if (multiSelected?.length !== 1 || multiSelected[0].endsWith("/")) return; + + const filePath = multiSelected[0]; + const fullUrl = `${window.location.origin}/file/${encodeKey(filePath)}`; + + try { + if (navigator.clipboard && window.isSecureContext) { + // Use modern clipboard API if available + await navigator.clipboard.writeText(fullUrl); + console.log(`Copied to clipboard: ${fullUrl}`); + } else { + // Fallback for older browsers or non-secure contexts + const textArea = document.createElement('textarea'); + textArea.value = fullUrl; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + textArea.style.top = '-999999px'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + document.execCommand('copy'); + console.log(`Copied to clipboard (fallback): ${fullUrl}`); + } catch (err) { + console.error('Failed to copy to clipboard:', err); + // Show user the URL in a prompt as last resort + window.prompt('Copy this URL:', fullUrl); + } + + document.body.removeChild(textArea); + } + + // Show user feedback that link was copied + const fileName = filePath.split('/').pop() || 'file'; + setCopyLinkSnackbar(`Link copied for: ${fileName}`); + console.log(`Link copied for: ${fileName}`); + + } catch (err) { + console.error('Error copying link:', err); + // Show user the URL in a prompt as fallback + window.prompt('Copy this URL:', fullUrl); + } + }} + /> + setCopyLinkSnackbar(null)} + message={copyLinkSnackbar} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + sx={{ bottom: 80 }} // Position above the toolbar + /> + { + uploadManagerRef.current?.cancelUpload(id); + }} + onClearCompleted={() => { + uploadManagerRef.current?.clearCompleted(); + }} + onClose={() => { + // Optionally handle close + }} /> ); diff --git a/src/MultiSelectToolbar.tsx b/src/MultiSelectToolbar.tsx index 186419a..125a0d2 100644 --- a/src/MultiSelectToolbar.tsx +++ b/src/MultiSelectToolbar.tsx @@ -1,9 +1,10 @@ import React, { useState } from "react"; -import { IconButton, Menu, MenuItem, Slide, Toolbar } from "@mui/material"; +import { IconButton, Menu, MenuItem, Slide, Toolbar, Tooltip } from "@mui/material"; import { Close as CloseIcon, Delete as DeleteIcon, Download as DownloadIcon, + Link as LinkIcon, MoreHoriz as MoreHorizIcon, } from "@mui/icons-material"; @@ -13,12 +14,14 @@ function MultiSelectToolbar({ onDownload, onRename, onDelete, + onCopyLink, }: { multiSelected: string[] | null; onClose: () => void; onDownload: () => void; onRename: () => void; onDelete: () => void; + onCopyLink: () => void; }) { const [anchorEl, setAnchorEl] = useState(null); @@ -36,21 +39,42 @@ function MultiSelectToolbar({ justifyContent: "space-evenly", }} > - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + {multiSelected.length === 1 && ( - Rename - Share + { + onRename(); + setAnchorEl(null); + }} + > + Rename + + { + onCopyLink(); + setAnchorEl(null); + }} + disabled={multiSelected[0].endsWith("/")} + > + Copy Link + )} diff --git a/src/UploadDrawer.tsx b/src/UploadDrawer.tsx index 7708e44..3399ba0 100644 --- a/src/UploadDrawer.tsx +++ b/src/UploadDrawer.tsx @@ -58,11 +58,13 @@ function UploadDrawer({ setOpen, cwd, onUpload, + uploadManager, }: { open: boolean; setOpen: (open: boolean) => void; cwd: string; onUpload: () => void; + uploadManager?: any; }) { const handleUpload = useCallback( (action: string) => () => { @@ -83,15 +85,33 @@ function UploadDrawer({ input.multiple = true; input.onchange = async () => { if (!input.files) return; + + if (!uploadManager) { + console.error('Upload manager not available'); + return; + } + const files = Array.from(input.files); - uploadQueue.push(...files.map((file) => ({ file, basedir: cwd }))); - await processUploadQueue(); + files.forEach((file) => { + const uploadId = uploadManager.addUpload(file); + const upload = uploadManager.getUpload(uploadId); + console.log('Added upload from drawer:', file.name, 'with ID:', uploadId); + + uploadQueue.push({ + file, + basedir: cwd, + uploadId, + abortController: upload?.abortController + }); + }); + + // Start processing uploads + processUploadQueue(uploadManager); setOpen(false); - onUpload(); }; input.click(); }, - [cwd, onUpload, setOpen] + [cwd, setOpen, uploadManager] ); const takePhoto = useMemo(() => handleUpload("photo"), [handleUpload]); diff --git a/src/app/transfer.ts b/src/app/transfer.ts index 8a044f4..0005610 100644 --- a/src/app/transfer.ts +++ b/src/app/transfer.ts @@ -1,49 +1,194 @@ import pLimit from "p-limit"; import { encodeKey, FileItem } from "../FileGrid"; +import { parseXmlSafely, extractFileInfoFromXml, debugXmlIssues } from "../utils/xmlParser"; -const WEBDAV_ENDPOINT = "/webdav/"; +const WEBDAV_ENDPOINT = "/file/"; + +// Helper function to parse responses from DOM elements +async function parseResponsesFromDOM(responses: Element[], path: string): Promise { + const currentPath = path.replace(/\/$/, ""); + console.log(`Current path for filtering: "${currentPath}"`); + + const items: FileItem[] = responses + .filter((response) => { + const href = response.querySelector("href")?.textContent; + if (!href) return false; + + const decodedPath = decodeURIComponent(href).slice(WEBDAV_ENDPOINT.length); + const shouldInclude = decodedPath !== currentPath; + + if (!shouldInclude) { + console.log(`Filtering out current directory: "${decodedPath}"`); + } + + return shouldInclude; + }) + .map((response) => { + try { + const href = response.querySelector("href")?.textContent; + if (!href) throw new Error("Missing href in response"); + + const contentType = response.querySelector("getcontenttype")?.textContent || "application/octet-stream"; + const size = response.querySelector("getcontentlength")?.textContent; + const lastModified = response.querySelector("getlastmodified")?.textContent; + const thumbnail = response.getElementsByTagNameNS("flaredrive", "thumbnail")[0]?.textContent; + + const fileItem = { + key: decodeURI(href).replace(/^\/file\//, ""), + size: size ? Number(size) : 0, + uploaded: lastModified || new Date().toISOString(), + httpMetadata: { contentType }, + customMetadata: { thumbnail }, + } as FileItem; + + return fileItem; + } catch (error) { + console.error("Error parsing response item:", error, response); + return null; + } + }) + .filter((item): item is FileItem => item !== null); + + return items; +} + +// Helper function to parse responses from regex-extracted data +async function parseResponsesFromRegex( + fileInfos: Array<{ + href: string; + contentType?: string; + size?: string; + lastModified?: string; + thumbnail?: string; + }>, + path: string +): Promise { + const currentPath = path.replace(/\/$/, ""); + console.log(`Current path for filtering: "${currentPath}"`); + + const items: FileItem[] = fileInfos + .filter((fileInfo) => { + const decodedPath = decodeURIComponent(fileInfo.href).slice(WEBDAV_ENDPOINT.length); + const shouldInclude = decodedPath !== currentPath; + + if (!shouldInclude) { + console.log(`Filtering out current directory: "${decodedPath}"`); + } + + return shouldInclude; + }) + .map((fileInfo) => { + try { + const fileItem = { + key: decodeURI(fileInfo.href).replace(/^\/file\//, ""), + size: fileInfo.size ? Number(fileInfo.size) : 0, + uploaded: fileInfo.lastModified || new Date().toISOString(), + httpMetadata: { contentType: fileInfo.contentType || "application/octet-stream" }, + customMetadata: { thumbnail: fileInfo.thumbnail }, + } as FileItem; + + return fileItem; + } catch (error) { + console.error("Error parsing file info:", error, fileInfo); + return null; + } + }) + .filter((item): item is FileItem => item !== null); + + return items; +} + +function getAuthHeaders(): Record { + const credentials = localStorage.getItem('flaredrive_auth'); + if (credentials) { + return { + 'Authorization': `Basic ${credentials}` + }; + } + return {}; +} export async function fetchPath(path: string) { - const res = await fetch(`${WEBDAV_ENDPOINT}${encodeKey(path)}`, { - method: "PROPFIND", - headers: { Depth: "1" }, - }); + console.log(`Fetching path: "${path}"`); + + const headers: Record = { + Depth: "1", + ...getAuthHeaders() + }; + + const url = `${WEBDAV_ENDPOINT}${encodeKey(path)}`; + console.log(`PROPFIND URL: ${url}`); + + try { + const res = await fetch(url, { + method: "PROPFIND", + headers, + }); - if (!res.ok) throw new Error("Failed to fetch"); - if (!res.headers.get("Content-Type")?.includes("application/xml")) - throw new Error("Invalid response"); + console.log(`Response status: ${res.status}`); + console.log(`Content-Type: ${res.headers.get('Content-Type')}`); + console.log(`Content-Length: ${res.headers.get('Content-Length')}`); + + // Log all headers manually + const headersObj: Record = {}; + res.headers.forEach((value, key) => { + headersObj[key] = value; + }); + console.log(`Response headers:`, headersObj); - const parser = new DOMParser(); - const text = await res.text(); - const document = parser.parseFromString(text, "application/xml"); - const items: FileItem[] = Array.from(document.querySelectorAll("response")) - .filter( - (response) => - decodeURIComponent( - response.querySelector("href")?.textContent ?? "" - ).slice(WEBDAV_ENDPOINT.length) !== path.replace(/\/$/, "") - ) - .map((response) => { - const href = response.querySelector("href")?.textContent; - if (!href) throw new Error("Invalid response"); - const contentType = response.querySelector("getcontenttype")?.textContent; - const size = response.querySelector("getcontentlength")?.textContent; - const lastModified = - response.querySelector("getlastmodified")?.textContent; - const thumbnail = response.getElementsByTagNameNS( - "flaredrive", - "thumbnail" - )[0]?.textContent; - return { - key: decodeURI(href).replace(/^\/webdav\//, ""), - size: size ? Number(size) : 0, - uploaded: lastModified!, - httpMetadata: { contentType: contentType! }, - customMetadata: { thumbnail }, - } as FileItem; + if (!res.ok) { + const errorText = await res.text(); + console.error(`PROPFIND failed with status ${res.status}:`, errorText); + throw new Error(`Failed to fetch: ${res.status} ${res.statusText}`); + } + + const contentType = res.headers.get("Content-Type"); + if (!contentType?.includes("application/xml")) { + console.error(`Invalid content type: ${contentType}`); + throw new Error(`Invalid response content type: ${contentType}`); + } + + const text = await res.text(); + console.log(`XML Response length: ${text.length}`); + console.log(`XML Response preview:`, text.substring(0, 500)); + + // Try robust XML parsing first + let document = parseXmlSafely(text); + let items: FileItem[] = []; + + if (document) { + // XML parsing succeeded, use DOM approach + console.log('Using DOM-based XML parsing'); + const responses = Array.from(document.querySelectorAll("response")); + console.log(`Found ${responses.length} response elements`); + + items = await parseResponsesFromDOM(responses, path); + } else { + // XML parsing failed, use regex fallback + console.log('DOM parsing failed, using regex fallback'); + debugXmlIssues(text); + + const fileInfos = extractFileInfoFromXml(text); + items = await parseResponsesFromRegex(fileInfos, path); + } + + console.log(`Parsed ${items.length} files/folders:`); + items.forEach((item, index) => { + if (index < 10) { // Only log first 10 items + console.log(` ${index + 1}. "${item.key}" (${item.httpMetadata.contentType})`); + } }); - return items; + + if (items.length > 10) { + console.log(` ... and ${items.length - 10} more items`); + } + + return items; + } catch (error) { + console.error("Error in fetchPath:", error); + throw error; + } } const THUMBNAIL_SIZE = 144; @@ -114,11 +259,26 @@ function xhrFetch( url: RequestInfo | URL, requestInit: RequestInit & { onUploadProgress?: (progressEvent: ProgressEvent) => void; + abortSignal?: AbortSignal; } ) { + console.log('xhrFetch called with URL:', url); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); - xhr.upload.onprogress = requestInit.onUploadProgress ?? null; + + // Handle abort signal + if (requestInit.abortSignal) { + requestInit.abortSignal.addEventListener('abort', () => { + xhr.abort(); + reject(new DOMException('Aborted', 'AbortError')); + }); + } + xhr.upload.onprogress = (event) => { + console.log('XHR upload progress:', event.loaded, '/', event.total); + if (requestInit.onUploadProgress) { + requestInit.onUploadProgress(event); + } + }; xhr.open( requestInit.method ?? "GET", url instanceof Request ? url.url : url @@ -126,6 +286,7 @@ function xhrFetch( const headers = new Headers(requestInit.headers); headers.forEach((value, key) => xhr.setRequestHeader(key, value)); xhr.onload = () => { + console.log('XHR request completed with status:', xhr.status); const headers = xhr .getAllResponseHeaders() .trim() @@ -135,9 +296,18 @@ function xhrFetch( acc[key] = value; return acc; }, {} as Record); - resolve(new Response(xhr.responseText, { status: xhr.status, headers })); + + if (xhr.status >= 200 && xhr.status < 300) { + resolve(new Response(xhr.responseText, { status: xhr.status, headers })); + } else { + console.error('XHR request failed with status:', xhr.status, 'response:', xhr.responseText); + reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.responseText}`)); + } + }; + xhr.onerror = (event) => { + console.error('XHR request error:', event); + reject(new Error('Network error during upload')); }; - xhr.onerror = reject; if ( requestInit.body instanceof Blob || typeof requestInit.body === "string" @@ -156,12 +326,13 @@ export async function multipartUpload( loaded: number; total: number; }) => void; + abortSignal?: AbortSignal; } ) { - const headers = options?.headers || {}; + const headers = { ...getAuthHeaders(), ...(options?.headers || {}) }; headers["content-type"] = file.type; - const uploadResponse = await fetch(`/webdav/${encodeKey(key)}?uploads`, { + const uploadResponse = await fetch(`/file/${encodeKey(key)}?uploads`, { headers, method: "POST", }); @@ -177,10 +348,11 @@ export async function multipartUpload( partNumber: i.toString(), uploadId, }); - const res = await xhrFetch(`/webdav/${encodeKey(key)}?${searchParams}`, { + const res = await xhrFetch(`/file/${encodeKey(key)}?${searchParams}`, { method: "PUT", - headers, + headers: { ...headers, ...getAuthHeaders() }, body: chunk, + abortSignal: options?.abortSignal, onUploadProgress: (progressEvent) => { if (typeof options?.onUploadProgress !== "function") return; options.onUploadProgress({ @@ -194,8 +366,9 @@ export async function multipartUpload( ); const uploadedParts = await Promise.all(promises); const completeParams = new URLSearchParams({ uploadId }); - await fetch(`/webdav/${encodeKey(key)}?${completeParams}`, { + await fetch(`/file/${encodeKey(key)}?${completeParams}`, { method: "POST", + headers: getAuthHeaders(), body: JSON.stringify({ parts: uploadedParts }), }); } @@ -208,7 +381,7 @@ export async function copyPaste(source: string, target: string, move = false) { ); await fetch(uploadUrl, { method: move ? "MOVE" : "COPY", - headers: { Destination: destinationUrl.href }, + headers: { Destination: destinationUrl.href, ...getAuthHeaders() }, }); } @@ -222,7 +395,7 @@ export async function createFolder(cwd: string) { } const folderKey = `${cwd}${folderName}`; const uploadUrl = `${WEBDAV_ENDPOINT}${encodeKey(folderKey)}`; - await fetch(uploadUrl, { method: "MKCOL" }); + await fetch(uploadUrl, { method: "MKCOL", headers: getAuthHeaders() }); } catch (error) { console.log(`Create folder failed`); } @@ -231,16 +404,31 @@ export async function createFolder(cwd: string) { export const uploadQueue: { basedir: string; file: File; + uploadId?: string; + abortController?: AbortController; }[] = []; -export async function processUploadQueue() { +export async function processUploadQueue( + uploadManager?: any, + onProgress?: (loaded: number, total: number, uploadId: string) => void +) { + console.log('processUploadQueue called, queue length:', uploadQueue.length); + if (!uploadQueue.length) { + console.log('Upload queue is empty, returning'); return; } - const { basedir, file } = uploadQueue.shift()!; + const item = uploadQueue.shift()!; + const { basedir, file, uploadId, abortController } = item; + + console.log('Processing upload:', file.name, 'uploadId:', uploadId, 'hasManager:', !!uploadManager); let thumbnailDigest = null; + // Skip thumbnail generation for now to debug upload issue + console.log('Skipping thumbnail generation for debugging'); + + /* Temporarily disabled for debugging if ( file.type.startsWith("image/") || file.type === "video/mp4" || @@ -250,10 +438,11 @@ export async function processUploadQueue() { const thumbnailBlob = await generateThumbnail(file); const digestHex = await blobDigest(thumbnailBlob); - const thumbnailUploadUrl = `/webdav/_$flaredrive$/thumbnails/${digestHex}.png`; + const thumbnailUploadUrl = `/file/_$flaredrive$/thumbnails/${digestHex}.png`; try { await fetch(thumbnailUploadUrl, { method: "PUT", + headers: getAuthHeaders(), body: thumbnailBlob, }); thumbnailDigest = digestHex; @@ -264,18 +453,71 @@ export async function processUploadQueue() { console.log(`Generate thumbnail failed`); } } + */ try { + console.log('Starting actual upload for:', file.name); const headers: { "fd-thumbnail"?: string } = {}; if (thumbnailDigest) headers["fd-thumbnail"] = thumbnailDigest; + + // Update status to uploading + if (uploadManager && uploadId) { + console.log('Setting upload status to uploading for:', uploadId); + uploadManager.setUploadStatus(uploadId, 'uploading'); + } else { + console.log('No uploadManager or uploadId, proceeding without tracking'); + } + if (file.size >= SIZE_LIMIT) { - await multipartUpload(basedir + file.name, file, { headers }); + await multipartUpload(basedir + file.name, file, { + headers, + abortSignal: abortController?.signal, + onUploadProgress: (progressEvent) => { + if (uploadManager && uploadId) { + const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); + uploadManager.updateProgress(uploadId, percentCompleted); + } + if (onProgress && uploadId) { + onProgress(progressEvent.loaded, progressEvent.total, uploadId); + } + } + }); } else { const uploadUrl = `${WEBDAV_ENDPOINT}${encodeKey(basedir + file.name)}`; - await xhrFetch(uploadUrl, { method: "PUT", headers, body: file }); + await xhrFetch(uploadUrl, { + method: "PUT", + headers: { ...headers, ...getAuthHeaders() }, + body: file, + abortSignal: abortController?.signal, + onUploadProgress: (progressEvent) => { + if (uploadManager && uploadId) { + const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total); + uploadManager.updateProgress(uploadId, percentCompleted); + } + } + }); } - } catch (error) { + + // Mark as completed + if (uploadManager && uploadId) { + uploadManager.completeUpload(uploadId); + } + } catch (error: any) { console.log(`Upload ${file.name} failed`, error); + + // Check if it was cancelled + if (error.name === 'AbortError') { + if (uploadManager && uploadId) { + // Status already set by cancelUpload method + } + } else { + // Mark as error + if (uploadManager && uploadId) { + uploadManager.errorUpload(uploadId, error.message || 'Upload failed'); + } + } } - setTimeout(processUploadQueue); + + // Process next upload + setTimeout(() => processUploadQueue(uploadManager, onProgress)); } diff --git a/src/utils/fuzzySearch.ts b/src/utils/fuzzySearch.ts new file mode 100644 index 0000000..546f1e8 --- /dev/null +++ b/src/utils/fuzzySearch.ts @@ -0,0 +1,223 @@ +import { FileItem } from '../FileGrid'; + +// Levenshtein distance algorithm +function levenshteinDistance(str1: string, str2: string): number { + const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null)); + + for (let i = 0; i <= str1.length; i += 1) { + matrix[0][i] = i; + } + + for (let j = 0; j <= str2.length; j += 1) { + matrix[j][0] = j; + } + + for (let j = 1; j <= str2.length; j += 1) { + for (let i = 1; i <= str1.length; i += 1) { + const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1; + matrix[j][i] = Math.min( + matrix[j][i - 1] + 1, // deletion + matrix[j - 1][i] + 1, // insertion + matrix[j - 1][i - 1] + indicator // substitution + ); + } + } + + return matrix[str2.length][str1.length]; +} + +// N-gram matching +function getNgrams(text: string, n: number = 2): Set { + const ngrams = new Set(); + const normalizedText = text.toLowerCase(); + + for (let i = 0; i <= normalizedText.length - n; i++) { + ngrams.add(normalizedText.slice(i, i + n)); + } + + return ngrams; +} + +function ngramSimilarity(str1: string, str2: string, n: number = 2): number { + const ngrams1 = getNgrams(str1, n); + const ngrams2 = getNgrams(str2, n); + + if (ngrams1.size === 0 && ngrams2.size === 0) return 1; + if (ngrams1.size === 0 || ngrams2.size === 0) return 0; + + const intersection = new Set([...ngrams1].filter(x => ngrams2.has(x))); + const union = new Set([...ngrams1, ...ngrams2]); + + return intersection.size / union.size; +} + +// Extract filename from path +function getFileName(path: string): string { + return path.split('/').pop() || path; +} + +// Extract file extension +function getFileExtension(path: string): string { + const fileName = getFileName(path); + const dotIndex = fileName.lastIndexOf('.'); + return dotIndex > 0 ? fileName.slice(dotIndex + 1).toLowerCase() : ''; +} + +// Fuzzy match scoring interface +interface FuzzyScore { + item: FileItem; + score: number; + matchType: string[]; +} + +// Main fuzzy search function +export function fuzzySearchFiles(files: FileItem[], query: string, threshold: number = 0.3): FileItem[] { + if (!query || query.trim().length === 0) { + return files; + } + + const normalizedQuery = query.toLowerCase().trim(); + const scores: FuzzyScore[] = []; + + for (const file of files) { + const fileName = getFileName(file.key).toLowerCase(); + const fileExtension = getFileExtension(file.key); + const fullPath = file.key.toLowerCase(); + + let score = 0; + const matchTypes: string[] = []; + + // 1. Exact match gets highest score + if (fileName === normalizedQuery) { + score += 1.0; + matchTypes.push('exact'); + } + + // 2. Starts with match + if (fileName.startsWith(normalizedQuery)) { + score += 0.9; + matchTypes.push('prefix'); + } + + // 3. Contains match (substring) + if (fileName.includes(normalizedQuery)) { + score += 0.8; + matchTypes.push('substring'); + } + + // 4. Extension match + if (fileExtension === normalizedQuery) { + score += 0.7; + matchTypes.push('extension'); + } + + // 5. Path contains query + if (fullPath.includes(normalizedQuery)) { + score += 0.6; + matchTypes.push('path'); + } + + // 6. N-gram similarity + const ngramScore = ngramSimilarity(fileName, normalizedQuery); + if (ngramScore > threshold) { + score += ngramScore * 0.5; + matchTypes.push('ngram'); + } + + // 7. Levenshtein distance (for typo tolerance) + const maxDistance = Math.max(fileName.length, normalizedQuery.length); + if (maxDistance > 0) { + const distance = levenshteinDistance(fileName, normalizedQuery); + const similarity = 1 - (distance / maxDistance); + if (similarity > threshold) { + score += similarity * 0.4; + matchTypes.push('levenshtein'); + } + } + + // 8. Word boundary matches (split by spaces, dots, underscores, hyphens) + const fileWords = fileName.split(/[\s._-]+/).filter(word => word.length > 0); + const queryWords = normalizedQuery.split(/\s+/).filter(word => word.length > 0); + + let wordMatches = 0; + for (const queryWord of queryWords) { + for (const fileWord of fileWords) { + if (fileWord.includes(queryWord) || queryWord.includes(fileWord)) { + wordMatches++; + break; + } + } + } + + if (wordMatches > 0 && queryWords.length > 0) { + const wordScore = wordMatches / queryWords.length; + score += wordScore * 0.3; + matchTypes.push('words'); + } + + // 9. Acronym matching (first letters of words) + const fileAcronym = fileWords.map(word => word[0]).join(''); + if (fileAcronym.includes(normalizedQuery) || normalizedQuery.includes(fileAcronym)) { + score += 0.2; + matchTypes.push('acronym'); + } + + // Only include files with some match + if (score > 0 || matchTypes.length > 0) { + scores.push({ + item: file, + score, + matchType: matchTypes + }); + } + } + + // Sort by score (highest first) and return files + scores.sort((a, b) => b.score - a.score); + + // Debug logging + if (scores.length > 0) { + console.log(`Fuzzy search for "${query}" found ${scores.length} matches:`); + scores.slice(0, 5).forEach(({ item, score, matchType }) => { + console.log(` ${getFileName(item.key)} (score: ${score.toFixed(3)}, types: ${matchType.join(', ')})`); + }); + } + + return scores.map(s => s.item); +} + +// Simple search fallback (for performance) +export function simpleSearch(files: FileItem[], query: string): FileItem[] { + if (!query || query.trim().length === 0) { + return files; + } + + const normalizedQuery = query.toLowerCase().trim(); + + return files.filter(file => { + const fileName = getFileName(file.key).toLowerCase(); + const fullPath = file.key.toLowerCase(); + + return fileName.includes(normalizedQuery) || + fullPath.includes(normalizedQuery) || + getFileExtension(file.key) === normalizedQuery; + }); +} + +// Enhanced search that combines both approaches +export function enhancedSearch(files: FileItem[], query: string, useFuzzy: boolean = true): FileItem[] { + if (!query || query.trim().length === 0) { + return files; + } + + // For very long queries or many files, use simple search for performance + if (query.length > 50 || files.length > 1000) { + return simpleSearch(files, query); + } + + if (useFuzzy) { + return fuzzySearchFiles(files, query); + } else { + return simpleSearch(files, query); + } +} \ No newline at end of file diff --git a/src/utils/uploadManager.ts b/src/utils/uploadManager.ts new file mode 100644 index 0000000..bd4e19a --- /dev/null +++ b/src/utils/uploadManager.ts @@ -0,0 +1,190 @@ +import { UploadItem } from '../FloatingUploadProgress'; + +export interface UploadManagerOptions { + onProgressUpdate: (uploads: UploadItem[]) => void; + onUploadComplete: (id: string) => void; + onUploadError: (id: string, error: string) => void; + onUploadCancelled: (id: string) => void; +} + +class UploadManager { + private uploads: Map = new Map(); + private options: UploadManagerOptions; + private uploadQueue: string[] = []; + private activeUploads = 0; + private maxConcurrentUploads = 2; + + constructor(options: UploadManagerOptions) { + this.options = options; + } + + generateUploadId(): string { + return `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + } + + addUpload(file: File): string { + const id = this.generateUploadId(); + const abortController = new AbortController(); + + const uploadItem: UploadItem = { + id, + fileName: file.name, + fileSize: file.size, + progress: 0, + status: 'pending', + abortController, + }; + + console.log('Adding upload to manager:', file.name, 'with id:', id); + this.uploads.set(id, uploadItem); + this.notifyProgressUpdate(); + + // Don't add to queue here, let the caller handle it + // The queue processing will be triggered by processUploadQueue + + return id; + } + + async processQueue() { + while (this.uploadQueue.length > 0 && this.activeUploads < this.maxConcurrentUploads) { + const uploadId = this.uploadQueue.shift(); + if (uploadId) { + const upload = this.uploads.get(uploadId); + if (upload && upload.status === 'pending') { + // Don't increment here, let startUpload handle it + this.startUpload(uploadId); + } + } + } + } + + private async startUpload(uploadId: string) { + const upload = this.uploads.get(uploadId); + if (!upload) return; + + // Update status to uploading + upload.status = 'uploading'; + this.notifyProgressUpdate(); + + // This will be called from the actual upload implementation + // The upload function will use the abortController.signal + } + + updateProgress(uploadId: string, progress: number) { + const upload = this.uploads.get(uploadId); + if (upload) { + upload.progress = Math.min(Math.max(0, progress), 100); + this.notifyProgressUpdate(); + } + } + + completeUpload(uploadId: string) { + const upload = this.uploads.get(uploadId); + if (upload) { + upload.status = 'completed'; + upload.progress = 100; + this.activeUploads--; + this.notifyProgressUpdate(); + this.options.onUploadComplete(uploadId); + this.processQueue(); // Process next in queue + } + } + + errorUpload(uploadId: string, error: string) { + const upload = this.uploads.get(uploadId); + if (upload) { + upload.status = 'error'; + upload.error = error; + this.activeUploads--; + this.notifyProgressUpdate(); + this.options.onUploadError(uploadId, error); + this.processQueue(); // Process next in queue + } + } + + cancelUpload(uploadId: string) { + const upload = this.uploads.get(uploadId); + if (upload) { + // Abort the upload if it's in progress + if (upload.abortController && (upload.status === 'uploading' || upload.status === 'pending')) { + // Store the original status before changing it + const wasUploading = upload.status === 'uploading'; + + upload.abortController.abort(); + upload.status = 'cancelled'; + + // Remove from queue if pending + const queueIndex = this.uploadQueue.indexOf(uploadId); + if (queueIndex > -1) { + this.uploadQueue.splice(queueIndex, 1); + } + + // Decrease active uploads if it was uploading + if (wasUploading) { + this.activeUploads--; + } + + this.notifyProgressUpdate(); + this.options.onUploadCancelled(uploadId); + this.processQueue(); // Process next in queue + } else if (upload.status === 'completed' || upload.status === 'error' || upload.status === 'cancelled') { + // Just remove from list if already finished + this.uploads.delete(uploadId); + this.notifyProgressUpdate(); + } + } + } + + clearCompleted() { + const uploadsArray = Array.from(this.uploads.values()); + uploadsArray.forEach(upload => { + if (upload.status === 'completed' || upload.status === 'error' || upload.status === 'cancelled') { + this.uploads.delete(upload.id); + } + }); + this.notifyProgressUpdate(); + } + + getUpload(uploadId: string): UploadItem | undefined { + return this.uploads.get(uploadId); + } + + getAllUploads(): UploadItem[] { + return Array.from(this.uploads.values()); + } + + private notifyProgressUpdate() { + this.options.onProgressUpdate(this.getAllUploads()); + } + + setUploadStatus(uploadId: string, status: UploadItem['status']) { + const upload = this.uploads.get(uploadId); + if (upload) { + const previousStatus = upload.status; + upload.status = status; + + // Only increment if transitioning from pending to uploading + if (status === 'uploading' && previousStatus === 'pending') { + this.activeUploads++; + } + + this.notifyProgressUpdate(); + } + } + + reset() { + // Cancel all active uploads + this.uploads.forEach(upload => { + if (upload.abortController && upload.status === 'uploading') { + upload.abortController.abort(); + } + }); + + this.uploads.clear(); + this.uploadQueue = []; + this.activeUploads = 0; + this.notifyProgressUpdate(); + } +} + +export default UploadManager; \ No newline at end of file diff --git a/src/utils/xmlParser.ts b/src/utils/xmlParser.ts new file mode 100644 index 0000000..dd0859f --- /dev/null +++ b/src/utils/xmlParser.ts @@ -0,0 +1,166 @@ +// Utility for robust XML parsing and sanitization + +export function sanitizeXmlText(text: string): string { + // Replace invalid XML characters with their entity references + return text + .replace(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + // Remove control characters except tab, newline, and carriage return + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); +} + +export function fixBrokenXml(xmlText: string): string { + // Fix common XML parsing issues + let fixed = xmlText; + + // Fix unclosed tags and malformed entities + fixed = fixed.replace(/&(?![a-zA-Z0-9#]+;)/g, '&'); + + // Fix < and > that are not part of tags + fixed = fixed.replace(/<(?![/\w])/g, '<'); + fixed = fixed.replace(/(?])>/g, '>'); + + // Remove invalid characters + // eslint-disable-next-line no-control-regex + fixed = fixed.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + + return fixed; +} + +export function parseXmlSafely(xmlText: string): Document | null { + const parser = new DOMParser(); + + try { + // First try with original XML + let document = parser.parseFromString(xmlText, 'application/xml'); + let parserError = document.querySelector('parsererror'); + + if (!parserError) { + console.log('XML parsed successfully on first attempt'); + return document; + } + + console.log('First XML parse attempt failed, trying with fixes...'); + + // Try with sanitized XML + const fixedXml = fixBrokenXml(xmlText); + document = parser.parseFromString(fixedXml, 'application/xml'); + parserError = document.querySelector('parsererror'); + + if (!parserError) { + console.log('XML parsed successfully after sanitization'); + return document; + } + + console.error('XML parsing failed even after sanitization'); + console.error('Parser error:', parserError?.textContent); + + return null; + } catch (error) { + console.error('Exception during XML parsing:', error); + return null; + } +} + +// Fallback: Extract file information using regex when XML parsing fails +export function extractFileInfoFromXml(xmlText: string): Array<{ + href: string; + contentType?: string; + size?: string; + lastModified?: string; + thumbnail?: string; +}> { + console.log('Using fallback regex extraction for file information'); + + const files: Array<{ + href: string; + contentType?: string; + size?: string; + lastModified?: string; + thumbnail?: string; + }> = []; + + // Regex to match response blocks + const responseRegex = /(.*?)<\/response>/gs; + let responseMatch; + + while ((responseMatch = responseRegex.exec(xmlText)) !== null) { + const responseContent = responseMatch[1]; + + // Extract href + const hrefMatch = /(.*?)<\/href>/s.exec(responseContent); + if (!hrefMatch) continue; + + const href = hrefMatch[1].trim(); + + // Extract other properties + const contentTypeMatch = /(.*?)<\/getcontenttype>/s.exec(responseContent); + const sizeMatch = /(.*?)<\/getcontentlength>/s.exec(responseContent); + const lastModifiedMatch = /(.*?)<\/getlastmodified>/s.exec(responseContent); + const thumbnailMatch = /(.*?)<\/fd:thumbnail>/s.exec(responseContent); + + files.push({ + href, + contentType: contentTypeMatch ? contentTypeMatch[1].trim() : undefined, + size: sizeMatch ? sizeMatch[1].trim() : undefined, + lastModified: lastModifiedMatch ? lastModifiedMatch[1].trim() : undefined, + thumbnail: thumbnailMatch ? thumbnailMatch[1].trim() : undefined, + }); + } + + console.log(`Extracted ${files.length} files using regex fallback`); + return files; +} + +// Debug function to find problematic lines in XML +export function debugXmlIssues(xmlText: string): void { + console.log('=== XML DEBUGGING ==='); + console.log(`XML length: ${xmlText.length} characters`); + + // Find lines around error line 802 + const lines = xmlText.split('\n'); + console.log(`Total lines: ${lines.length}`); + + if (lines.length > 800) { + console.log('Lines around 800-805:'); + for (let i = 798; i <= 804 && i < lines.length; i++) { + const line = lines[i]; + console.log(`Line ${i + 1}: "${line}"`); + + // Check for problematic characters + const problematicChars = line.match(/[&<>"']/g); + if (problematicChars) { + console.log(` Found potentially problematic characters: ${problematicChars.join(', ')}`); + } + + // Check for unescaped ampersands + const badAmpersands = line.match(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g); + if (badAmpersands) { + console.log(` Found unescaped ampersands: ${badAmpersands.join(', ')}`); + } + } + } + + // Look for overall patterns that might cause issues + const unescapedAmpersands = (xmlText.match(/&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g) || []).length; + const unescapedLessThan = (xmlText.match(/<(?![/\w!?])/g) || []).length; + + console.log(`Total unescaped ampersands: ${unescapedAmpersands}`); + console.log(`Total unescaped < characters: ${unescapedLessThan}`); + + // Sample some file names that might be problematic + const hrefMatches = xmlText.match(/([^<]+)<\/href>/g); + if (hrefMatches) { + console.log('Sample file paths (first 10):'); + hrefMatches.slice(0, 10).forEach((match, index) => { + const path = match.replace(/<\/?href>/g, ''); + console.log(` ${index + 1}. "${path}"`); + }); + } + + console.log('=== END XML DEBUGGING ==='); +} \ No newline at end of file