From 05e1603601ecb57cbdfb2a14e3b62fc894fba68e Mon Sep 17 00:00:00 2001 From: Nasrullah M Haris Date: Thu, 19 Sep 2024 00:39:50 +0800 Subject: [PATCH 01/11] Update utils.ts --- functions/webdav/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/webdav/utils.ts b/functions/webdav/utils.ts index 1b43209..4d256ca 100644 --- a/functions/webdav/utils.ts +++ b/functions/webdav/utils.ts @@ -4,7 +4,7 @@ export interface RequestHandlerParams { request: Request; } -export const WEBDAV_ENDPOINT = "/webdav/"; +export const WEBDAV_ENDPOINT = "/api/"; export function notFound() { return new Response("Not found", { status: 404 }); From d80dcfa1ec26f029f144cbf0b5203bdb32c640c4 Mon Sep 17 00:00:00 2001 From: Nasrullah M Haris Date: Thu, 19 Sep 2024 00:45:14 +0800 Subject: [PATCH 02/11] Feat: route --- functions/{webdav => api}/[[path]].ts | 0 functions/{webdav => api}/copy.ts | 0 functions/{webdav => api}/delete.ts | 0 functions/{webdav => api}/get.ts | 0 functions/{webdav => api}/head.ts | 0 functions/{webdav => api}/mkcol.ts | 0 functions/{webdav => api}/move.ts | 0 functions/{webdav => api}/post.ts | 0 functions/{webdav => api}/propfind.ts | 0 functions/{webdav => api}/put.ts | 0 functions/{webdav => api}/utils.ts | 0 11 files changed, 0 insertions(+), 0 deletions(-) rename functions/{webdav => api}/[[path]].ts (100%) rename functions/{webdav => api}/copy.ts (100%) rename functions/{webdav => api}/delete.ts (100%) rename functions/{webdav => api}/get.ts (100%) rename functions/{webdav => api}/head.ts (100%) rename functions/{webdav => api}/mkcol.ts (100%) rename functions/{webdav => api}/move.ts (100%) rename functions/{webdav => api}/post.ts (100%) rename functions/{webdav => api}/propfind.ts (100%) rename functions/{webdav => api}/put.ts (100%) rename functions/{webdav => api}/utils.ts (100%) diff --git a/functions/webdav/[[path]].ts b/functions/api/[[path]].ts similarity index 100% rename from functions/webdav/[[path]].ts rename to functions/api/[[path]].ts diff --git a/functions/webdav/copy.ts b/functions/api/copy.ts similarity index 100% rename from functions/webdav/copy.ts rename to functions/api/copy.ts diff --git a/functions/webdav/delete.ts b/functions/api/delete.ts similarity index 100% rename from functions/webdav/delete.ts rename to functions/api/delete.ts diff --git a/functions/webdav/get.ts b/functions/api/get.ts similarity index 100% rename from functions/webdav/get.ts rename to functions/api/get.ts diff --git a/functions/webdav/head.ts b/functions/api/head.ts similarity index 100% rename from functions/webdav/head.ts rename to functions/api/head.ts diff --git a/functions/webdav/mkcol.ts b/functions/api/mkcol.ts similarity index 100% rename from functions/webdav/mkcol.ts rename to functions/api/mkcol.ts diff --git a/functions/webdav/move.ts b/functions/api/move.ts similarity index 100% rename from functions/webdav/move.ts rename to functions/api/move.ts diff --git a/functions/webdav/post.ts b/functions/api/post.ts similarity index 100% rename from functions/webdav/post.ts rename to functions/api/post.ts diff --git a/functions/webdav/propfind.ts b/functions/api/propfind.ts similarity index 100% rename from functions/webdav/propfind.ts rename to functions/api/propfind.ts diff --git a/functions/webdav/put.ts b/functions/api/put.ts similarity index 100% rename from functions/webdav/put.ts rename to functions/api/put.ts diff --git a/functions/webdav/utils.ts b/functions/api/utils.ts similarity index 100% rename from functions/webdav/utils.ts rename to functions/api/utils.ts From ce17835126e245b088b1fa0878610dbefda6dc79 Mon Sep 17 00:00:00 2001 From: Nasrullah M Haris Date: Thu, 19 Sep 2024 00:48:43 +0800 Subject: [PATCH 03/11] Update transfer.ts --- src/app/transfer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/transfer.ts b/src/app/transfer.ts index 8a044f4..bbdb75c 100644 --- a/src/app/transfer.ts +++ b/src/app/transfer.ts @@ -2,7 +2,7 @@ import pLimit from "p-limit"; import { encodeKey, FileItem } from "../FileGrid"; -const WEBDAV_ENDPOINT = "/webdav/"; +const WEBDAV_ENDPOINT = "/api/"; export async function fetchPath(path: string) { const res = await fetch(`${WEBDAV_ENDPOINT}${encodeKey(path)}`, { From 8b21ffac978b59992ac628283e596c802a316baf Mon Sep 17 00:00:00 2001 From: Nasrullah M Haris Date: Thu, 19 Sep 2024 00:51:48 +0800 Subject: [PATCH 04/11] Feat: change route --- src/FileGrid.tsx | 4 ++-- src/Main.tsx | 4 ++-- src/app/transfer.ts | 10 +++++----- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/FileGrid.tsx b/src/FileGrid.tsx index a70c545..f97c7f5 100644 --- a/src/FileGrid.tsx +++ b/src/FileGrid.tsx @@ -59,7 +59,7 @@ function FileGrid({ {file.customMetadata?.thumbnail ? ( {file.key} diff --git a/src/Main.tsx b/src/Main.tsx index dc3e034..0876c40 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -204,7 +204,7 @@ function Main({ onDownload={() => { if (multiSelected?.length !== 1) return; const a = document.createElement("a"); - a.href = `/webdav/${encodeKey(multiSelected[0])}`; + a.href = `/api/${encodeKey(multiSelected[0])}`; a.download = multiSelected[0].split("/").pop()!; a.click(); }} @@ -223,7 +223,7 @@ 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(`/api/${encodeKey(key)}`, { method: "DELETE" }); fetchFiles(); }} /> diff --git a/src/app/transfer.ts b/src/app/transfer.ts index bbdb75c..a1b8e1c 100644 --- a/src/app/transfer.ts +++ b/src/app/transfer.ts @@ -36,7 +36,7 @@ export async function fetchPath(path: string) { "thumbnail" )[0]?.textContent; return { - key: decodeURI(href).replace(/^\/webdav\//, ""), + key: decodeURI(href).replace(/^\/api\//, ""), size: size ? Number(size) : 0, uploaded: lastModified!, httpMetadata: { contentType: contentType! }, @@ -161,7 +161,7 @@ export async function multipartUpload( const headers = options?.headers || {}; headers["content-type"] = file.type; - const uploadResponse = await fetch(`/webdav/${encodeKey(key)}?uploads`, { + const uploadResponse = await fetch(`/api/${encodeKey(key)}?uploads`, { headers, method: "POST", }); @@ -177,7 +177,7 @@ export async function multipartUpload( partNumber: i.toString(), uploadId, }); - const res = await xhrFetch(`/webdav/${encodeKey(key)}?${searchParams}`, { + const res = await xhrFetch(`/api/${encodeKey(key)}?${searchParams}`, { method: "PUT", headers, body: chunk, @@ -194,7 +194,7 @@ export async function multipartUpload( ); const uploadedParts = await Promise.all(promises); const completeParams = new URLSearchParams({ uploadId }); - await fetch(`/webdav/${encodeKey(key)}?${completeParams}`, { + await fetch(`/api/${encodeKey(key)}?${completeParams}`, { method: "POST", body: JSON.stringify({ parts: uploadedParts }), }); @@ -250,7 +250,7 @@ export async function processUploadQueue() { const thumbnailBlob = await generateThumbnail(file); const digestHex = await blobDigest(thumbnailBlob); - const thumbnailUploadUrl = `/webdav/_$flaredrive$/thumbnails/${digestHex}.png`; + const thumbnailUploadUrl = `/api/_$flaredrive$/thumbnails/${digestHex}.png`; try { await fetch(thumbnailUploadUrl, { method: "PUT", From c5e26042b9bf988f4f30fb3423782809ae81cd49 Mon Sep 17 00:00:00 2001 From: Nasrullah M Haris Date: Thu, 19 Sep 2024 00:57:16 +0800 Subject: [PATCH 05/11] Feat: change route --- functions/api/utils.ts | 2 +- src/FileGrid.tsx | 4 ++-- src/Main.tsx | 4 ++-- src/app/transfer.ts | 12 ++++++------ 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/functions/api/utils.ts b/functions/api/utils.ts index 4d256ca..441386f 100644 --- a/functions/api/utils.ts +++ b/functions/api/utils.ts @@ -4,7 +4,7 @@ export interface RequestHandlerParams { request: Request; } -export const WEBDAV_ENDPOINT = "/api/"; +export const WEBDAV_ENDPOINT = "/file/"; export function notFound() { return new Response("Not found", { status: 404 }); diff --git a/src/FileGrid.tsx b/src/FileGrid.tsx index f97c7f5..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/Main.tsx b/src/Main.tsx index 0876c40..54628eb 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -204,7 +204,7 @@ function Main({ onDownload={() => { if (multiSelected?.length !== 1) return; const a = document.createElement("a"); - a.href = `/api/${encodeKey(multiSelected[0])}`; + a.href = `/file/${encodeKey(multiSelected[0])}`; a.download = multiSelected[0].split("/").pop()!; a.click(); }} @@ -223,7 +223,7 @@ function Main({ const confirmMessage = "Delete the following file(s) permanently?"; if (!window.confirm(`${confirmMessage}\n${filenames}`)) return; for (const key of multiSelected) - await fetch(`/api/${encodeKey(key)}`, { method: "DELETE" }); + await fetch(`/file/${encodeKey(key)}`, { method: "DELETE" }); fetchFiles(); }} /> diff --git a/src/app/transfer.ts b/src/app/transfer.ts index a1b8e1c..acd95a1 100644 --- a/src/app/transfer.ts +++ b/src/app/transfer.ts @@ -2,7 +2,7 @@ import pLimit from "p-limit"; import { encodeKey, FileItem } from "../FileGrid"; -const WEBDAV_ENDPOINT = "/api/"; +const WEBDAV_ENDPOINT = "/file/"; export async function fetchPath(path: string) { const res = await fetch(`${WEBDAV_ENDPOINT}${encodeKey(path)}`, { @@ -36,7 +36,7 @@ export async function fetchPath(path: string) { "thumbnail" )[0]?.textContent; return { - key: decodeURI(href).replace(/^\/api\//, ""), + key: decodeURI(href).replace(/^\/file\//, ""), size: size ? Number(size) : 0, uploaded: lastModified!, httpMetadata: { contentType: contentType! }, @@ -161,7 +161,7 @@ export async function multipartUpload( const headers = options?.headers || {}; headers["content-type"] = file.type; - const uploadResponse = await fetch(`/api/${encodeKey(key)}?uploads`, { + const uploadResponse = await fetch(`/file/${encodeKey(key)}?uploads`, { headers, method: "POST", }); @@ -177,7 +177,7 @@ export async function multipartUpload( partNumber: i.toString(), uploadId, }); - const res = await xhrFetch(`/api/${encodeKey(key)}?${searchParams}`, { + const res = await xhrFetch(`/file/${encodeKey(key)}?${searchParams}`, { method: "PUT", headers, body: chunk, @@ -194,7 +194,7 @@ export async function multipartUpload( ); const uploadedParts = await Promise.all(promises); const completeParams = new URLSearchParams({ uploadId }); - await fetch(`/api/${encodeKey(key)}?${completeParams}`, { + await fetch(`/file/${encodeKey(key)}?${completeParams}`, { method: "POST", body: JSON.stringify({ parts: uploadedParts }), }); @@ -250,7 +250,7 @@ export async function processUploadQueue() { const thumbnailBlob = await generateThumbnail(file); const digestHex = await blobDigest(thumbnailBlob); - const thumbnailUploadUrl = `/api/_$flaredrive$/thumbnails/${digestHex}.png`; + const thumbnailUploadUrl = `/file/_$flaredrive$/thumbnails/${digestHex}.png`; try { await fetch(thumbnailUploadUrl, { method: "PUT", From b42fc43c3f00325784882c5ff96165cce0ecef15 Mon Sep 17 00:00:00 2001 From: Nasrullah M Haris Date: Thu, 19 Sep 2024 01:09:01 +0800 Subject: [PATCH 06/11] Feat: change route --- functions/{api => file}/[[path]].ts | 0 functions/{api => file}/copy.ts | 0 functions/{api => file}/delete.ts | 0 functions/{api => file}/get.ts | 0 functions/{api => file}/head.ts | 0 functions/{api => file}/mkcol.ts | 0 functions/{api => file}/move.ts | 0 functions/{api => file}/post.ts | 0 functions/{api => file}/propfind.ts | 0 functions/{api => file}/put.ts | 0 functions/{api => file}/utils.ts | 0 11 files changed, 0 insertions(+), 0 deletions(-) rename functions/{api => file}/[[path]].ts (100%) rename functions/{api => file}/copy.ts (100%) rename functions/{api => file}/delete.ts (100%) rename functions/{api => file}/get.ts (100%) rename functions/{api => file}/head.ts (100%) rename functions/{api => file}/mkcol.ts (100%) rename functions/{api => file}/move.ts (100%) rename functions/{api => file}/post.ts (100%) rename functions/{api => file}/propfind.ts (100%) rename functions/{api => file}/put.ts (100%) rename functions/{api => file}/utils.ts (100%) diff --git a/functions/api/[[path]].ts b/functions/file/[[path]].ts similarity index 100% rename from functions/api/[[path]].ts rename to functions/file/[[path]].ts diff --git a/functions/api/copy.ts b/functions/file/copy.ts similarity index 100% rename from functions/api/copy.ts rename to functions/file/copy.ts diff --git a/functions/api/delete.ts b/functions/file/delete.ts similarity index 100% rename from functions/api/delete.ts rename to functions/file/delete.ts diff --git a/functions/api/get.ts b/functions/file/get.ts similarity index 100% rename from functions/api/get.ts rename to functions/file/get.ts diff --git a/functions/api/head.ts b/functions/file/head.ts similarity index 100% rename from functions/api/head.ts rename to functions/file/head.ts diff --git a/functions/api/mkcol.ts b/functions/file/mkcol.ts similarity index 100% rename from functions/api/mkcol.ts rename to functions/file/mkcol.ts diff --git a/functions/api/move.ts b/functions/file/move.ts similarity index 100% rename from functions/api/move.ts rename to functions/file/move.ts diff --git a/functions/api/post.ts b/functions/file/post.ts similarity index 100% rename from functions/api/post.ts rename to functions/file/post.ts diff --git a/functions/api/propfind.ts b/functions/file/propfind.ts similarity index 100% rename from functions/api/propfind.ts rename to functions/file/propfind.ts diff --git a/functions/api/put.ts b/functions/file/put.ts similarity index 100% rename from functions/api/put.ts rename to functions/file/put.ts diff --git a/functions/api/utils.ts b/functions/file/utils.ts similarity index 100% rename from functions/api/utils.ts rename to functions/file/utils.ts From 9fbc6bce13515da203e88c70d0af998690ee4cd2 Mon Sep 17 00:00:00 2001 From: Nasrullah M Haris Date: Thu, 19 Sep 2024 01:17:42 +0800 Subject: [PATCH 07/11] Feat: assets update --- public/favicon.png | Bin 992 -> 3151 bytes public/index.html | 2 +- public/logo144.png | Bin 2680 -> 12157 bytes public/manifest.json | 4 ++-- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/public/favicon.png b/public/favicon.png index 8119e1c9afe14907780441b7a841f9f3d19824d5..f3b0a307cec3989ddf9e87f2c41ef4dc61a4f83e 100644 GIT binary patch delta 3131 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 V%}t%v|4sk^002ovPDHLkV1h;e9tZ#c delta 970 zcmX>v@qm4TNt>~G#PkT$cWb26n?K)4d-iu< zWu5$e^TXVlAn@Ke>xzrmqp2_c?r941UUTFHtCgTy@|h#GhxIIX9^5Iv?a#`&hx=zM z)gQRuK7IRHP1}3DKlo*qls>2X z_o78ji+iWBPq-1bNAAmUIq~H?1k!#_n47nyM)a^!eR{U_p^M$T{2xACc+_{srG)w8 z#U9oQv80?k^CK>8Ezj9Attfw2`_g7xZguT@;pcLu`nt?l6g#uRrXk|N^k+5PGHciV zF?}@EbG>|y@QKx_i`YIN*#1Z9>5Q7-hu-ZwB);XXoU%9jz_XSojJvv0!mkGXaB6Up zcwIJ)U;t-$F6i5Lkb^K>QyTel9eBCn1A{`bAjaX zUpH0w6FxlNpI9c_(A}dyNvx}P!iGBTk8^sKdj7s5nUr}k)!f1{y#A8j*CesE#cqGp z7DWm~JrwvpS!|{J>oT_Oldr9~AO9?6)y~Qct0T78ClxYB?6KRDX~4Vl+TZQ(IR4Jt z;yrgxF@s((pCOm$?IVZV{=PfM$bMnh|97^|(KB7=hfMyKaXet>tqWV8yPZpZGmBS| z#r@XdKMa4?D`%g6T5ou{E$yCPQ@wJr?*GeEUQd_g7cX@$vh;~pj+!*Bu8nv0mghfD z`Aa{rT>9bhye{qa+dZ;XHYM09{FmhvcPuyLWqkezXy022E%j}6;1uV3GC1MP+pdf& z)1LqnziNqVL`h0wNvc(HQ7VvPFfuSQ(lxNqH8cw`G_W!?vNABxHZZVqHZTzX-W!Rc fBR4-KGp!Pr4g-U - Flare Drive + UndanganTa by NAS diff --git a/public/logo144.png b/public/logo144.png index 6582a336b5d145cec1509b83e69719e1b0c02747..4989b84dc03f2b8dc1ec7180da9f28d902b4b7df 100644 GIT binary patch literal 12157 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 Date: Thu, 19 Sep 2024 01:18:27 +0800 Subject: [PATCH 08/11] Update index.html --- public/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/index.html b/public/index.html index dddc61c..8414954 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ - + UndanganTa by NAS From d886096a4b3add4ad7c912204f19a4a1c2dd425c Mon Sep 17 00:00:00 2001 From: NasroelLah Date: Sat, 13 Sep 2025 22:02:32 +0800 Subject: [PATCH 09/11] feat: add auth page --- WARP.md | 191 +++++++++++++++++++++++++++++++++++++ functions/file/[[path]].ts | 2 +- src/App.tsx | 27 +++++- src/AuthContext.tsx | 80 ++++++++++++++++ src/Header.tsx | 8 +- src/Login.tsx | 136 ++++++++++++++++++++++++++ src/LogoutButton.tsx | 38 ++++++++ src/Main.tsx | 12 ++- src/app/transfer.ts | 29 ++++-- 9 files changed, 508 insertions(+), 15 deletions(-) create mode 100644 WARP.md create mode 100644 src/AuthContext.tsx create mode 100644 src/Login.tsx create mode 100644 src/LogoutButton.tsx diff --git a/WARP.md b/WARP.md new file mode 100644 index 0000000..11ae1fa --- /dev/null +++ b/WARP.md @@ -0,0 +1,191 @@ +# 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, rename, delete) +- **app/transfer.ts**: File operations with authentication headers + +### 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**: Client-side filtering by filename (case-insensitive) +- **Operations**: Copy, move, rename, delete via WebDAV methods + +## 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) + +### 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 + +### 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/file/[[path]].ts b/functions/file/[[path]].ts index f9077e8..ee4cdca 100644 --- a/functions/file/[[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/src/App.tsx b/src/App.tsx index c9e1311..fcf1ea5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,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,15 +22,18 @@ 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); + if (!isAuthenticated) { + return ; + } + return ( - - - {globalStyles} +

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/Header.tsx b/src/Header.tsx index c4aab78..44a2fa4 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -1,6 +1,7 @@ -import { IconButton, InputBase, Menu, MenuItem, Toolbar } from "@mui/material"; +import { IconButton, InputBase, Menu, MenuItem, Toolbar, Box } from "@mui/material"; import { useState } from "react"; import { MoreHoriz as MoreHorizIcon } from "@mui/icons-material"; +import LogoutButton from "./LogoutButton"; function Header({ search, @@ -27,7 +28,9 @@ function Header({ padding: "8px 16px", }} /> - + + + ); } 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 54628eb..87c2955 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -19,6 +19,16 @@ import { uploadQueue, } from "./app/transfer"; +function getAuthHeaders(): Record { + const credentials = localStorage.getItem('flaredrive_auth'); + if (credentials) { + return { + 'Authorization': `Basic ${credentials}` + }; + } + return {}; +} + function Centered({ children }: { children: React.ReactNode }) { return ( diff --git a/src/app/transfer.ts b/src/app/transfer.ts index acd95a1..70164b3 100644 --- a/src/app/transfer.ts +++ b/src/app/transfer.ts @@ -4,10 +4,25 @@ import { encodeKey, FileItem } from "../FileGrid"; const WEBDAV_ENDPOINT = "/file/"; +function getAuthHeaders(): Record { + const credentials = localStorage.getItem('flaredrive_auth'); + if (credentials) { + return { + 'Authorization': `Basic ${credentials}` + }; + } + return {}; +} + export async function fetchPath(path: string) { + const headers: Record = { + Depth: "1", + ...getAuthHeaders() + }; + const res = await fetch(`${WEBDAV_ENDPOINT}${encodeKey(path)}`, { method: "PROPFIND", - headers: { Depth: "1" }, + headers, }); if (!res.ok) throw new Error("Failed to fetch"); @@ -158,7 +173,7 @@ export async function multipartUpload( }) => void; } ) { - const headers = options?.headers || {}; + const headers = { ...getAuthHeaders(), ...(options?.headers || {}) }; headers["content-type"] = file.type; const uploadResponse = await fetch(`/file/${encodeKey(key)}?uploads`, { @@ -179,7 +194,7 @@ export async function multipartUpload( }); const res = await xhrFetch(`/file/${encodeKey(key)}?${searchParams}`, { method: "PUT", - headers, + headers: { ...headers, ...getAuthHeaders() }, body: chunk, onUploadProgress: (progressEvent) => { if (typeof options?.onUploadProgress !== "function") return; @@ -196,6 +211,7 @@ export async function multipartUpload( const completeParams = new URLSearchParams({ uploadId }); await fetch(`/file/${encodeKey(key)}?${completeParams}`, { method: "POST", + headers: getAuthHeaders(), body: JSON.stringify({ parts: uploadedParts }), }); } @@ -208,7 +224,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 +238,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`); } @@ -254,6 +270,7 @@ export async function processUploadQueue() { try { await fetch(thumbnailUploadUrl, { method: "PUT", + headers: getAuthHeaders(), body: thumbnailBlob, }); thumbnailDigest = digestHex; @@ -272,7 +289,7 @@ export async function processUploadQueue() { await multipartUpload(basedir + file.name, file, { headers }); } 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 }); } } catch (error) { console.log(`Upload ${file.name} failed`, error); From 624cea8bbb22ae763d89e0a60812171825d515b1 Mon Sep 17 00:00:00 2001 From: NasroelLah Date: Sun, 14 Sep 2025 17:14:31 +0800 Subject: [PATCH 10/11] feat: Enhanced UI with advanced features and improved upload system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## New Features - ✨ Dual view modes: Grid and List view with toggle - 🔍 Advanced fuzzy search with exact match option - 📊 Flexible sorting: by name, size, or date modified - 🎯 Floating upload progress with real-time tracking - 🖱️ Enhanced drag & drop with visual feedback - 🚫 Upload cancellation and queue management - 📱 Responsive toolbar design ## UI/UX Improvements - 🎨 Modern toolbar with intuitive controls - 📋 Detailed list view with file metadata - 🔄 Auto-show floating progress for new uploads - 🎭 Improved visual feedback for drag operations - 📊 Real-time search statistics display - 🧹 Auto-cleanup completed uploads ## Technical Enhancements - 🧠 Smart fuzzy search algorithms - 🗂️ Robust XML parsing with fallbacks - 🔧 Comprehensive upload manager system - 📝 Enhanced logging and debugging - ⚡ Optimized performance and error handling - 🏗️ Modular component architecture ## Bug Fixes - 🐛 Fixed floating progress visibility issues - 🔧 Resolved upload state management - 📡 Improved XHR error handling - 🧹 Memory leak prevention in uploads Closes multiple UI/UX improvement requests and enhances overall user experience. --- README.md | 114 +++++++++- UPLOAD_DEBUG.md | 126 ++++++++++ WARP.md | 22 +- src/App.tsx | 28 ++- src/FileList.tsx | 149 ++++++++++++ src/FloatingUploadProgress.tsx | 404 +++++++++++++++++++++++++++++++++ src/Header.tsx | 206 +++++++++++++---- src/Main.tsx | 333 +++++++++++++++++++++++---- src/MultiSelectToolbar.tsx | 75 ++++-- src/UploadDrawer.tsx | 28 ++- src/app/transfer.ts | 315 +++++++++++++++++++++---- src/utils/fuzzySearch.ts | 223 ++++++++++++++++++ src/utils/uploadManager.ts | 190 ++++++++++++++++ src/utils/xmlParser.ts | 166 ++++++++++++++ 14 files changed, 2216 insertions(+), 163 deletions(-) create mode 100644 UPLOAD_DEBUG.md create mode 100644 src/FileList.tsx create mode 100644 src/FloatingUploadProgress.tsx create mode 100644 src/utils/fuzzySearch.ts create mode 100644 src/utils/uploadManager.ts create mode 100644 src/utils/xmlParser.ts 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 index 11ae1fa..2d1a116 100644 --- a/WARP.md +++ b/WARP.md @@ -65,8 +65,9 @@ FlareDrive uses a dual architecture: - **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, rename, delete) -- **app/transfer.ts**: File operations with authentication headers +- **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: @@ -103,8 +104,14 @@ WebDAV + REST API implementation: - **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**: Client-side filtering by filename (case-insensitive) +- **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 @@ -130,6 +137,14 @@ tsconfig.json # TypeScript configuration 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) @@ -172,6 +187,7 @@ npx wrangler pages deploy build # Deploy to Cloudflare Pages - **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) diff --git a/src/App.tsx b/src/App.tsx index fcf1ea5..520c37f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,6 @@ 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"; @@ -25,8 +24,11 @@ const theme = createTheme({ 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 ; @@ -38,9 +40,23 @@ function AppContent() {
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)} - /> ); } 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 44a2fa4..23e117a 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -1,59 +1,183 @@ -import { IconButton, InputBase, Menu, MenuItem, Toolbar, Box } 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)} - sx={{ - backgroundColor: "whitesmoke", - borderRadius: "999px", - padding: "8px 16px", - }} - /> - - + + + 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 */} + setAnchorEl(e.currentTarget)} - > - - - setAnchorEl(null)} - > - View as - Sort by - { - setAnchorEl(null); - setShowProgressDialog(true); + size="small" + onClick={() => onFuzzySearchChange(!useFuzzySearch)} + sx={{ + color: useFuzzySearch ? 'primary.main' : 'text.secondary', + backgroundColor: useFuzzySearch ? 'primary.light' : 'transparent', + '&:hover': { + backgroundColor: useFuzzySearch ? 'primary.light' : 'action.hover' + } }} > - Progress - - + + + + + {/* View Mode Toggle */} + newMode && onViewModeChange(newMode)} + size="small" + sx={{ height: 36 }} + > + + + + + + + + + + + + + {/* Sort button with menu */} + + + setSortMenuAnchor(e.currentTarget)} + sx={{ + color: 'text.secondary', + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + {getSortIcon()} + + + setSortMenuAnchor(null)} + > + { + onSortByChange('name'); + setSortMenuAnchor(null); + }} + > + + Name + + { + onSortByChange('size'); + setSortMenuAnchor(null); + }} + > + + Size + + { + onSortByChange('date'); + setSortMenuAnchor(null); + }} + > + + Date Modified + + + + + + ); diff --git a/src/Main.tsx b/src/Main.tsx index 87c2955..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,9 @@ 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'); @@ -93,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 + + + )} ); } @@ -125,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) => { @@ -183,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 || 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 70164b3..0005610 100644 --- a/src/app/transfer.ts +++ b/src/app/transfer.ts @@ -1,9 +1,104 @@ import pLimit from "p-limit"; import { encodeKey, FileItem } from "../FileGrid"; +import { parseXmlSafely, extractFileInfoFromXml, debugXmlIssues } from "../utils/xmlParser"; 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) { @@ -15,50 +110,85 @@ function getAuthHeaders(): Record { } export async function fetchPath(path: string) { + console.log(`Fetching path: "${path}"`); + const headers: Record = { Depth: "1", ...getAuthHeaders() }; - const res = await fetch(`${WEBDAV_ENDPOINT}${encodeKey(path)}`, { - method: "PROPFIND", - headers, - }); + 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(/^\/file\//, ""), - 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; @@ -129,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 @@ -141,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() @@ -150,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" @@ -171,6 +326,7 @@ export async function multipartUpload( loaded: number; total: number; }) => void; + abortSignal?: AbortSignal; } ) { const headers = { ...getAuthHeaders(), ...(options?.headers || {}) }; @@ -196,6 +352,7 @@ export async function multipartUpload( method: "PUT", headers: { ...headers, ...getAuthHeaders() }, body: chunk, + abortSignal: options?.abortSignal, onUploadProgress: (progressEvent) => { if (typeof options?.onUploadProgress !== "function") return; options.onUploadProgress({ @@ -247,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" || @@ -281,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: { ...headers, ...getAuthHeaders() }, 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 From b4cd846826df52f128a1b5b60f72d0ff5caf88f4 Mon Sep 17 00:00:00 2001 From: NasroelLah Date: Thu, 9 Oct 2025 22:51:49 +0800 Subject: [PATCH 11/11] feat: auto sanitize filename --- src/app/transfer.ts | 52 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/src/app/transfer.ts b/src/app/transfer.ts index 0005610..25783f9 100644 --- a/src/app/transfer.ts +++ b/src/app/transfer.ts @@ -255,6 +255,39 @@ export async function blobDigest(blob: Blob) { export const SIZE_LIMIT = 100 * 1000 * 1000; // 100MB +/** + * Sanitize filename to lowercase and replace spaces/symbols with dash + * Example: "Kahitna - Menikahimu.mp3" -> "kahitna---menikahimu.mp3" + * Keeps only alphanumeric, dots (for extension), and dashes + */ +export function sanitizeFileName(fileName: string): string { + // Split filename and extension + const lastDotIndex = fileName.lastIndexOf('.'); + let name = fileName; + let extension = ''; + + if (lastDotIndex > 0) { + name = fileName.substring(0, lastDotIndex); + extension = fileName.substring(lastDotIndex); // includes the dot + } + + // Convert to lowercase + name = name.toLowerCase(); + extension = extension.toLowerCase(); + + // Replace any character that is not alphanumeric or dash with dash + // This will replace spaces, special characters, etc. with dash + name = name.replace(/[^a-z0-9-]+/g, '-'); + + // Remove leading/trailing dashes + name = name.replace(/^-+|-+$/g, ''); + + // Replace multiple consecutive dashes with single dash + name = name.replace(/-+/g, '-'); + + return name + extension; +} + function xhrFetch( url: RequestInfo | URL, requestInit: RequestInit & { @@ -457,9 +490,14 @@ export async function processUploadQueue( try { console.log('Starting actual upload for:', file.name); + + // Sanitize the filename + const sanitizedFileName = sanitizeFileName(file.name); + console.log('Sanitized filename:', file.name, '->', sanitizedFileName); + 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); @@ -467,9 +505,9 @@ export async function processUploadQueue( } else { console.log('No uploadManager or uploadId, proceeding without tracking'); } - + if (file.size >= SIZE_LIMIT) { - await multipartUpload(basedir + file.name, file, { + await multipartUpload(basedir + sanitizedFileName, file, { headers, abortSignal: abortController?.signal, onUploadProgress: (progressEvent) => { @@ -483,10 +521,10 @@ export async function processUploadQueue( } }); } else { - const uploadUrl = `${WEBDAV_ENDPOINT}${encodeKey(basedir + file.name)}`; - await xhrFetch(uploadUrl, { - method: "PUT", - headers: { ...headers, ...getAuthHeaders() }, + const uploadUrl = `${WEBDAV_ENDPOINT}${encodeKey(basedir + sanitizedFileName)}`; + await xhrFetch(uploadUrl, { + method: "PUT", + headers: { ...headers, ...getAuthHeaders() }, body: file, abortSignal: abortController?.signal, onUploadProgress: (progressEvent) => {