@shipstatic/drop 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,27 +4,36 @@
4
4
 
5
5
  A focused React hook for preparing files for deployment with [@shipstatic/ship](https://github.com/shipstatic/ship). Handles ZIP extraction, path normalization, and validation - everything needed before calling `ship.deploy()`.
6
6
 
7
+ **v0.1.6 Update:** Now includes built-in drag & drop support with prop getters! No need to manually implement drag & drop handlers - just spread `{...drop.getDropzoneProps()}` on your container and `{...drop.getInputProps()}` on the input element. Folder structure preservation is handled automatically.
8
+
7
9
  **Note:** MD5 calculation is handled by Ship SDK during deployment. Drop focuses on file processing and UI state management.
8
10
 
9
11
  ## Why Headless?
10
12
 
11
- This package provides **zero UI components**. You build the dropzone that fits your needs. Why?
13
+ This package provides **zero UI components** - just a React hook with built-in drag & drop functionality. You bring your own styling.
12
14
 
13
- 1. **Folder structure matters** - Proper folder drag-and-drop requires modern browser APIs (`File System Access API`, `webkitGetAsEntry`) that generic dropzone libraries don't support
14
- 2. **Full control** - Your UI, your styling, your UX patterns
15
- 3. **Smaller bundle** - No React components, no extra dependencies (~14KB saved vs generic libraries)
16
- 4. **Ship SDK integration** - Purpose-built for Ship deployments, not a generic file upload library
15
+ **What you get:**
16
+ 1. **Built-in drag & drop** - Proper folder support with `webkitGetAsEntry` API, all handled internally
17
+ 2. **Prop getters API** - Similar to `react-dropzone`, just spread props on your elements
18
+ 3. **Full styling control** - No imposed CSS, design system, or theming
19
+ 4. **Smaller bundle** - No UI components means less bloat
20
+ 5. **Ship SDK integration** - Purpose-built for Ship deployments, not a generic file upload library
17
21
 
18
- The package focuses on what's hard (ZIP extraction, folder structure preservation) and leaves what's easy (UI) to you.
22
+ **What's different from other libraries:**
23
+ - Generic dropzone libraries don't preserve folder structure properly
24
+ - We handle the complex parts (ZIP extraction, folder traversal, path normalization)
25
+ - You handle the simple parts (styling, layout, animations)
19
26
 
20
27
  ## Features
21
28
 
22
- - 🎯 **Headless Architecture** - Just the hook, no UI opinions
29
+ - 🎯 **Prop Getters API** - Just spread props on your elements (like `react-dropzone`)
30
+ - 🖱️ **Built-in Drag & Drop** - Automatic folder support with `webkitGetAsEntry` API
23
31
  - 📦 **ZIP Support** - Automatic ZIP file extraction and processing
24
32
  - ✅ **Validation** - Client-side file size, count, and total size validation (powered by Ship SDK)
25
33
  - 🗑️ **Junk Filtering** - Automatically filters `.DS_Store`, `Thumbs.db`, etc. (powered by Ship SDK)
26
34
  - 🔒 **Path Sanitization** - Defense-in-depth protection against directory traversal attacks
27
- - 📁 **Folder Structure Preservation** - Respects `webkitRelativePath` for proper deployment paths
35
+ - 📁 **Folder Structure Preservation** - Proper folder paths via `webkitRelativePath`
36
+ - 🎨 **Headless UI** - No visual components, just logic and state management
28
37
  - 🚀 **Focused Scope** - File processing and UI state only. MD5 calculation and deployment handled by Ship SDK
29
38
 
30
39
  ## Installation
@@ -50,30 +59,38 @@ function MyUploader() {
50
59
 
51
60
  const handleUpload = async () => {
52
61
  const validFiles = drop.getValidFiles();
53
-
54
62
  // ProcessedFile extends StaticFile - no conversion needed!
55
63
  await ship.deployments.create(validFiles.map(f => f.file));
56
64
  };
57
65
 
58
66
  return (
59
67
  <div>
60
- <input
61
- type="file"
62
- multiple
63
- onChange={(e) => {
64
- const files = Array.from(e.target.files || []);
65
- drop.processFiles(files);
68
+ {/* Drag & drop zone with built-in folder support */}
69
+ <div
70
+ {...drop.getDropzoneProps()}
71
+ style={{
72
+ border: '2px dashed',
73
+ borderColor: drop.isDragging ? 'blue' : 'gray',
74
+ padding: '40px',
75
+ textAlign: 'center',
76
+ cursor: 'pointer',
66
77
  }}
67
- />
78
+ >
79
+ <input {...drop.getInputProps()} />
80
+ {drop.isDragging ? '📂 Drop here' : '📁 Click or drag files/folders'}
81
+ </div>
68
82
 
83
+ {/* Status */}
69
84
  <p>{drop.statusText}</p>
70
85
 
86
+ {/* File list */}
71
87
  {drop.files.map(file => (
72
88
  <div key={file.id}>
73
89
  {file.name} - {file.status}
74
90
  </div>
75
91
  ))}
76
92
 
93
+ {/* Upload button */}
77
94
  <button
78
95
  onClick={handleUpload}
79
96
  disabled={drop.getValidFiles().length === 0}
@@ -110,151 +127,56 @@ const drop = useDrop({ ship });
110
127
  - Ship SDK is the single source of truth for validation
111
128
  - Drop only provides what Ship doesn't have (ZIP, React state, folder structure)
112
129
 
113
- ## Building Your Drop Zone with Folder Support
130
+ ## Advanced: Programmatic File Picker
114
131
 
115
- For production use, you'll want to support folder drag-and-drop using modern browser APIs. Here's a complete example:
132
+ You can programmatically trigger the file picker using the `open()` method:
116
133
 
117
134
  ```tsx
118
- import { useState } from 'react';
119
- import { useDrop } from '@shipstatic/drop';
120
-
121
- function MyDeployUI() {
122
- const drop = useDrop();
123
- const [isDragActive, setIsDragActive] = useState(false);
124
-
125
- const handleDrop = async (e: React.DragEvent) => {
126
- e.preventDefault();
127
- setIsDragActive(false);
128
-
129
- // Extract files with folder structure preserved
130
- const files = await extractFilesWithStructure(e.dataTransfer);
131
- drop.processFiles(files);
132
- };
135
+ function MyUploader() {
136
+ const drop = useDrop({ ship });
133
137
 
134
- const extractFilesWithStructure = async (
135
- dataTransfer: DataTransfer
136
- ): Promise<File[]> => {
137
- const files: File[] = [];
138
- const items = dataTransfer.items;
138
+ return (
139
+ <div>
140
+ {/* Custom trigger button */}
141
+ <button onClick={drop.open}>
142
+ Select Files
143
+ </button>
139
144
 
140
- if (!items) return Array.from(dataTransfer.files);
145
+ {/* Hidden input managed by the hook */}
146
+ <input {...drop.getInputProps()} />
141
147
 
142
- for (let i = 0; i < items.length; i++) {
143
- const item = items[i];
144
- if (item.kind === 'file') {
145
- await processDataTransferItem(item, files);
146
- }
147
- }
148
+ {/* Or use the dropzone */}
149
+ <div {...drop.getDropzoneProps()}>
150
+ Drop files here
151
+ </div>
152
+ </div>
153
+ );
154
+ }
155
+ ```
148
156
 
149
- return files.length > 0 ? files : Array.from(dataTransfer.files);
150
- };
157
+ ## Advanced: Manual File Processing
151
158
 
152
- const processDataTransferItem = async (
153
- item: DataTransferItem,
154
- files: File[]
155
- ): Promise<void> => {
156
- // Try modern File System Access API first (Chrome 86+)
157
- if (
158
- globalThis.isSecureContext &&
159
- typeof (item as any).getAsFileSystemHandle === 'function'
160
- ) {
161
- try {
162
- const handle = await (item as any).getAsFileSystemHandle();
163
- if (handle) {
164
- await processFileSystemHandle(handle, files, '');
165
- return;
166
- }
167
- } catch (err) {
168
- // Fall through to webkit API
169
- }
170
- }
171
-
172
- // Fallback to webkitGetAsEntry (broader browser support)
173
- const entry = (item as any).webkitGetAsEntry?.();
174
- if (entry) {
175
- await processEntry(entry, files, '');
176
- }
177
- };
159
+ For advanced use cases, you can manually process files instead of using prop getters:
178
160
 
179
- const processFileSystemHandle = async (
180
- handle: any,
181
- files: File[],
182
- basePath: string
183
- ): Promise<void> => {
184
- if (handle.kind === 'file') {
185
- const file = await handle.getFile();
186
- // Set webkitRelativePath for Ship SDK compatibility
187
- Object.defineProperty(file, 'webkitRelativePath', {
188
- value: basePath + file.name,
189
- writable: false,
190
- enumerable: true,
191
- configurable: true,
192
- });
193
- files.push(file);
194
- } else if (handle.kind === 'directory') {
195
- const dirPath = basePath + handle.name + '/';
196
- for await (const entry of handle.values()) {
197
- await processFileSystemHandle(entry, files, dirPath);
198
- }
199
- }
200
- };
161
+ ```tsx
162
+ function AdvancedUploader() {
163
+ const drop = useDrop({ ship });
201
164
 
202
- const processEntry = async (
203
- entry: any,
204
- files: File[],
205
- basePath: string
206
- ): Promise<void> => {
207
- if (entry.isFile) {
208
- const file = await new Promise<File>((resolve, reject) => {
209
- entry.file(resolve, reject);
210
- });
211
- // Set webkitRelativePath for Ship SDK compatibility
212
- Object.defineProperty(file, 'webkitRelativePath', {
213
- value: basePath + entry.name,
214
- writable: false,
215
- enumerable: true,
216
- configurable: true,
217
- });
218
- files.push(file);
219
- } else if (entry.isDirectory) {
220
- const dirReader = entry.createReader();
221
- const entries = await new Promise<any[]>((resolve, reject) => {
222
- dirReader.readEntries(resolve, reject);
223
- });
224
-
225
- for (const childEntry of entries) {
226
- await processEntry(childEntry, files, basePath + entry.name + '/');
227
- }
228
- }
165
+ const handleManualDrop = async (e: React.DragEvent) => {
166
+ e.preventDefault();
167
+ const files = Array.from(e.dataTransfer.files);
168
+ await drop.processFiles(files);
229
169
  };
230
170
 
231
171
  return (
232
- <div
233
- onDragOver={(e) => { e.preventDefault(); setIsDragActive(true); }}
234
- onDragLeave={() => setIsDragActive(false)}
235
- onDrop={handleDrop}
236
- className={isDragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}
237
- >
238
- {drop.isProcessing ? (
239
- <p>Processing {drop.files.length} files...</p>
240
- ) : (
241
- <p>Drag & drop files or folders here</p>
242
- )}
243
-
244
- {drop.validationError && (
245
- <div className="text-red-600">{drop.validationError.details}</div>
246
- )}
172
+ <div onDrop={handleManualDrop}>
173
+ {/* Custom implementation */}
247
174
  </div>
248
175
  );
249
176
  }
250
177
  ```
251
178
 
252
- ### Why This Approach?
253
-
254
- - ✅ **Preserves folder structure** via `webkitRelativePath`
255
- - ✅ **Uses modern File System Access API** (no permission prompts in Chrome 86+)
256
- - ✅ **Fallback to webkit APIs** for broader browser support (Safari, Firefox)
257
- - ✅ **You control every aspect** of the UI and UX
179
+ **Note:** When using manual processing, you lose automatic folder structure preservation. The built-in `getDropzoneProps()` handles `webkitGetAsEntry` API internally to preserve folder paths.
258
180
 
259
181
  ## API
260
182
 
@@ -281,19 +203,47 @@ interface DropOptions {
281
203
 
282
204
  ```typescript
283
205
  interface DropReturn {
206
+ // State
284
207
  /** All processed files with their status */
285
208
  files: ProcessedFile[];
209
+ /** Name of the source (file/folder/ZIP) that was dropped/selected */
210
+ sourceName: string;
286
211
  /** Current status text */
287
212
  statusText: string;
288
213
  /** Whether currently processing files (ZIP extraction, etc.) */
289
214
  isProcessing: boolean;
215
+ /** Whether user is currently dragging over the dropzone */
216
+ isDragging: boolean;
290
217
  /** Last validation error if any */
291
218
  validationError: ClientError | null;
292
219
 
293
- /** Process files from drop (resets and replaces existing files) */
220
+ // Primary API: Prop getters for easy integration
221
+ /** Get props to spread on dropzone element (handles drag & drop) */
222
+ getDropzoneProps: () => {
223
+ onDragOver: (e: React.DragEvent) => void;
224
+ onDragLeave: (e: React.DragEvent) => void;
225
+ onDrop: (e: React.DragEvent) => void;
226
+ onClick: () => void;
227
+ };
228
+ /** Get props to spread on hidden file input element */
229
+ getInputProps: () => {
230
+ ref: React.RefObject<HTMLInputElement | null>;
231
+ type: 'file';
232
+ style: { display: string };
233
+ multiple: boolean;
234
+ webkitdirectory: string;
235
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
236
+ };
237
+
238
+ // Actions
239
+ /** Programmatically trigger file picker */
240
+ open: () => void;
241
+ /** Manually process files (for advanced usage) */
294
242
  processFiles: (files: File[]) => Promise<void>;
295
243
  /** Clear all files and reset state */
296
244
  clearAll: () => void;
245
+
246
+ // Helpers
297
247
  /** Get only valid files ready for upload */
298
248
  getValidFiles: () => ProcessedFile[];
299
249
  /** Update upload state for a specific file (status, progress, message) */
@@ -514,23 +464,28 @@ This package was extracted from the `web/drop` application and is purpose-built
514
464
  **1. Focused on UI Concerns**
515
465
  - ZIP extraction for user convenience
516
466
  - File list state management for React UIs
517
- - Folder structure preservation from drag-and-drop
467
+ - Drag & drop event handling with folder support
468
+ - Folder structure preservation via `webkitGetAsEntry` API
518
469
  - Path normalization for clean URLs
519
470
  - These are UI/UX concerns, not deployment logic
520
471
 
521
- **2. Loosely Coupled Integration Pattern**
472
+ **2. Prop Getters Pattern (Like react-dropzone)**
473
+ We provide event handlers, not visual components:
474
+ - ✅ **`getDropzoneProps()`** - Returns drag & drop event handlers
475
+ - ✅ **`getInputProps()`** - Returns file input configuration
476
+ - ✅ **`isDragging`** - State for visual feedback
477
+ - ✅ **You control the DOM** - Your markup, your styling, your design system
478
+
479
+ This is the same pattern used by popular libraries like `react-dropzone`, `downshift`, and `react-table`.
480
+
481
+ **3. Loosely Coupled Integration**
522
482
  Following industry standards (Firebase hooks, Supabase utilities), we chose:
523
- - ✅ **Decoupled**: No Ship SDK dependency in this package
524
- - ✅ **Simple**: Direct `File[]` input/output
525
- - ✅ **Testable**: No mocking of Ship SDK needed
483
+ - ✅ **Ship instance as dependency**: Validates using `ship.getConfig()`
484
+ - ✅ **Simple output**: ProcessedFile[] can be passed directly to Ship SDK
485
+ - ✅ **Testable**: Easy to mock Ship SDK for testing
526
486
  - ✅ **Flexible**: Host app controls WHEN to deploy
527
487
 
528
- Instead of:
529
- - ❌ Passing Ship SDK instance to useDrop
530
- - ❌ React Context provider pattern
531
- - ❌ Global configuration singleton
532
-
533
- **3. Type System Integration**
488
+ **4. Type System Integration**
534
489
 
535
490
  ProcessedFile extends StaticFile from `@shipstatic/types` - the single source of truth for Ship SDK types:
536
491
 
@@ -540,13 +495,13 @@ File[] → ProcessedFile[] (which IS StaticFile[]) → ship.deployments.create()
540
495
 
541
496
  No conversion needed. ProcessedFile adds UI-specific properties (id, name, status, progress) to StaticFile's base properties (content, path, size, md5).
542
497
 
543
- **4. No UI Components**
498
+ **5. No Visual Components**
544
499
 
545
- We deliberately don't provide drop zone UI components because:
546
- - Generic drop zone libraries (like `react-dropzone`) don't support folder structure preservation
547
- - Proper folder drag-and-drop requires modern browser APIs that need custom implementation
548
- - Your deployment UI is unique to your application
549
- - Providing a component that "works but loses paths" would be misleading
500
+ We deliberately don't provide styled components because:
501
+ - Your design system is unique to your application
502
+ - Styling should match your brand, not our opinions
503
+ - Prop getters give you full control over DOM structure and CSS
504
+ - Similar to how `react-dropzone` works - logic without opinions
550
505
 
551
506
  ### Error Handling Philosophy
552
507
 
package/dist/index.cjs CHANGED
@@ -11834,6 +11834,38 @@ function stripCommonPrefix(files) {
11834
11834
  path: f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path
11835
11835
  }));
11836
11836
  }
11837
+ async function traverseFileTree(entry, files, currentPath = "") {
11838
+ if (entry.isFile) {
11839
+ const file = await new Promise((resolve, reject) => {
11840
+ entry.file(resolve, reject);
11841
+ });
11842
+ const relativePath = currentPath ? `${currentPath}/${file.name}` : file.name;
11843
+ Object.defineProperty(file, "webkitRelativePath", {
11844
+ value: relativePath,
11845
+ writable: false
11846
+ });
11847
+ files.push(file);
11848
+ } else if (entry.isDirectory) {
11849
+ const dirReader = entry.createReader();
11850
+ let allEntries = [];
11851
+ const readEntriesBatch = async () => {
11852
+ const batch = await new Promise(
11853
+ (resolve, reject) => {
11854
+ dirReader.readEntries(resolve, reject);
11855
+ }
11856
+ );
11857
+ if (batch.length > 0) {
11858
+ allEntries = allEntries.concat(batch);
11859
+ await readEntriesBatch();
11860
+ }
11861
+ };
11862
+ await readEntriesBatch();
11863
+ for (const childEntry of allEntries) {
11864
+ const entryPath = childEntry.isDirectory ? currentPath ? `${currentPath}/${childEntry.name}` : childEntry.name : currentPath;
11865
+ await traverseFileTree(childEntry, files, entryPath);
11866
+ }
11867
+ }
11868
+ }
11837
11869
  function useDrop(options) {
11838
11870
  const {
11839
11871
  ship: ship$1,
@@ -11845,8 +11877,10 @@ function useDrop(options) {
11845
11877
  const [sourceName, setSourceName] = react.useState("");
11846
11878
  const [statusText, setStatusText] = react.useState("");
11847
11879
  const [isProcessing, setIsProcessing] = react.useState(false);
11880
+ const [isDragging, setIsDragging] = react.useState(false);
11848
11881
  const [validationError, setValidationError] = react.useState(null);
11849
11882
  const isProcessingRef = react.useRef(false);
11883
+ const inputRef = react.useRef(null);
11850
11884
  const processFiles = react.useCallback(async (newFiles) => {
11851
11885
  if (isProcessingRef.current) {
11852
11886
  console.warn("File processing already in progress. Ignoring duplicate call.");
@@ -11938,6 +11972,7 @@ function useDrop(options) {
11938
11972
  setSourceName("");
11939
11973
  setStatusText("");
11940
11974
  setValidationError(null);
11975
+ setIsDragging(false);
11941
11976
  isProcessingRef.current = false;
11942
11977
  setIsProcessing(false);
11943
11978
  }, []);
@@ -11949,14 +11984,79 @@ function useDrop(options) {
11949
11984
  (file) => file.id === fileId ? { ...file, ...state } : file
11950
11985
  ));
11951
11986
  }, []);
11987
+ const handleDragOver = react.useCallback((e) => {
11988
+ e.preventDefault();
11989
+ setIsDragging(true);
11990
+ }, []);
11991
+ const handleDragLeave = react.useCallback((e) => {
11992
+ e.preventDefault();
11993
+ setIsDragging(false);
11994
+ }, []);
11995
+ const handleDrop = react.useCallback(async (e) => {
11996
+ e.preventDefault();
11997
+ setIsDragging(false);
11998
+ const items = Array.from(e.dataTransfer.items);
11999
+ const files2 = [];
12000
+ let hasEntries = false;
12001
+ for (const item of items) {
12002
+ if (item.kind === "file") {
12003
+ const entry = item.webkitGetAsEntry?.();
12004
+ if (entry) {
12005
+ hasEntries = true;
12006
+ await traverseFileTree(
12007
+ entry,
12008
+ files2,
12009
+ entry.isDirectory ? entry.name : ""
12010
+ );
12011
+ }
12012
+ }
12013
+ }
12014
+ if (!hasEntries && e.dataTransfer.files.length > 0) {
12015
+ files2.push(...Array.from(e.dataTransfer.files));
12016
+ }
12017
+ if (files2.length > 0) {
12018
+ await processFiles(files2);
12019
+ }
12020
+ }, [processFiles]);
12021
+ const handleInputChange = react.useCallback((e) => {
12022
+ const files2 = Array.from(e.target.files || []);
12023
+ if (files2.length > 0) {
12024
+ processFiles(files2);
12025
+ }
12026
+ }, [processFiles]);
12027
+ const open = react.useCallback(() => {
12028
+ inputRef.current?.click();
12029
+ }, []);
12030
+ const getDropzoneProps = react.useCallback(() => ({
12031
+ onDragOver: handleDragOver,
12032
+ onDragLeave: handleDragLeave,
12033
+ onDrop: handleDrop,
12034
+ onClick: open
12035
+ }), [handleDragOver, handleDragLeave, handleDrop, open]);
12036
+ const getInputProps = react.useCallback(() => ({
12037
+ ref: inputRef,
12038
+ type: "file",
12039
+ style: { display: "none" },
12040
+ multiple: true,
12041
+ webkitdirectory: "",
12042
+ onChange: handleInputChange
12043
+ }), [handleInputChange]);
11952
12044
  return {
12045
+ // State
11953
12046
  files,
11954
12047
  sourceName,
11955
12048
  statusText,
11956
12049
  isProcessing,
12050
+ isDragging,
11957
12051
  validationError,
12052
+ // Primary API: Prop getters
12053
+ getDropzoneProps,
12054
+ getInputProps,
12055
+ // Actions
12056
+ open,
11958
12057
  processFiles,
11959
12058
  clearAll,
12059
+ // Helpers
11960
12060
  getValidFiles: getValidFilesCallback,
11961
12061
  updateFileStatus
11962
12062
  };