@shipstatic/drop 0.1.5 → 0.1.7

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,33 +59,45 @@ 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
 
69
- <p>{drop.statusText}</p>
83
+ {/* Status - using state machine */}
84
+ {drop.state.status && (
85
+ <p>
86
+ <strong>{drop.state.status.title}:</strong> {drop.state.status.details}
87
+ </p>
88
+ )}
70
89
 
71
- {drop.files.map(file => (
90
+ {/* File list */}
91
+ {drop.state.files.map(file => (
72
92
  <div key={file.id}>
73
93
  {file.name} - {file.status}
74
94
  </div>
75
95
  ))}
76
96
 
97
+ {/* Upload button */}
77
98
  <button
78
99
  onClick={handleUpload}
79
- disabled={drop.getValidFiles().length === 0}
100
+ disabled={drop.state.value !== 'ready'}
80
101
  >
81
102
  Upload {drop.getValidFiles().length} files
82
103
  </button>
@@ -110,151 +131,56 @@ const drop = useDrop({ ship });
110
131
  - Ship SDK is the single source of truth for validation
111
132
  - Drop only provides what Ship doesn't have (ZIP, React state, folder structure)
112
133
 
113
- ## Building Your Drop Zone with Folder Support
134
+ ## Advanced: Programmatic File Picker
114
135
 
115
- For production use, you'll want to support folder drag-and-drop using modern browser APIs. Here's a complete example:
136
+ You can programmatically trigger the file picker using the `open()` method:
116
137
 
117
138
  ```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
- };
139
+ function MyUploader() {
140
+ const drop = useDrop({ ship });
133
141
 
134
- const extractFilesWithStructure = async (
135
- dataTransfer: DataTransfer
136
- ): Promise<File[]> => {
137
- const files: File[] = [];
138
- const items = dataTransfer.items;
142
+ return (
143
+ <div>
144
+ {/* Custom trigger button */}
145
+ <button onClick={drop.open}>
146
+ Select Files
147
+ </button>
139
148
 
140
- if (!items) return Array.from(dataTransfer.files);
149
+ {/* Hidden input managed by the hook */}
150
+ <input {...drop.getInputProps()} />
141
151
 
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
- }
152
+ {/* Or use the dropzone */}
153
+ <div {...drop.getDropzoneProps()}>
154
+ Drop files here
155
+ </div>
156
+ </div>
157
+ );
158
+ }
159
+ ```
148
160
 
149
- return files.length > 0 ? files : Array.from(dataTransfer.files);
150
- };
161
+ ## Advanced: Manual File Processing
151
162
 
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
- };
163
+ For advanced use cases, you can manually process files instead of using prop getters:
178
164
 
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
- };
165
+ ```tsx
166
+ function AdvancedUploader() {
167
+ const drop = useDrop({ ship });
201
168
 
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
- }
169
+ const handleManualDrop = async (e: React.DragEvent) => {
170
+ e.preventDefault();
171
+ const files = Array.from(e.dataTransfer.files);
172
+ await drop.processFiles(files);
229
173
  };
230
174
 
231
175
  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
- )}
176
+ <div onDrop={handleManualDrop}>
177
+ {/* Custom implementation */}
247
178
  </div>
248
179
  );
249
180
  }
250
181
  ```
251
182
 
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
183
+ **Note:** When using manual processing, you lose automatic folder structure preservation. The built-in `getDropzoneProps()` handles `webkitGetAsEntry` API internally to preserve folder paths.
258
184
 
259
185
  ## API
260
186
 
@@ -281,19 +207,43 @@ interface DropOptions {
281
207
 
282
208
  ```typescript
283
209
  interface DropReturn {
284
- /** All processed files with their status */
285
- files: ProcessedFile[];
286
- /** Current status text */
287
- statusText: string;
210
+ // State machine
211
+ /** Current state of the drop hook */
212
+ state: DropState;
213
+
214
+ // Convenience getters (computed from state)
288
215
  /** Whether currently processing files (ZIP extraction, etc.) */
289
216
  isProcessing: boolean;
290
- /** Last validation error if any */
291
- validationError: ClientError | null;
217
+ /** Whether user is currently dragging over the dropzone */
218
+ isDragging: boolean;
219
+
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
+ };
292
237
 
293
- /** Process files from drop (resets and replaces existing files) */
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) */
@@ -303,19 +253,111 @@ interface DropReturn {
303
253
  progress?: number;
304
254
  }) => void;
305
255
  }
256
+
257
+ // State machine types
258
+ type DropStateValue =
259
+ | 'idle' // The hook is ready for files
260
+ | 'dragging' // The user is dragging files over the dropzone
261
+ | 'processing' // Files are being validated and processed
262
+ | 'ready' // Files are valid and ready for deployment
263
+ | 'error'; // An error occurred during processing
264
+
265
+ interface DropState {
266
+ value: DropStateValue;
267
+ files: ProcessedFile[];
268
+ sourceName: string;
269
+ status: DropStatus | null;
270
+ }
271
+
272
+ interface DropStatus {
273
+ title: string;
274
+ details: string;
275
+ }
276
+ ```
277
+
278
+ ## State Machine
279
+
280
+ The drop hook uses a state machine for predictable, clear state management. Instead of multiple boolean flags, you have a single `state.value` that represents exactly what's happening.
281
+
282
+ ### State Flow
283
+
284
+ ```
285
+ idle → dragging → idle (drag leave without drop)
286
+ idle → dragging → processing → ready (successful)
287
+ idle → dragging → processing → error (failed)
288
+ ready → dragging → processing → ... (new drop)
289
+ error → dragging → processing → ... (retry)
290
+ ```
291
+
292
+ ### Using the State Machine
293
+
294
+ ```tsx
295
+ function StatusIndicator({ drop }) {
296
+ const { state } = drop;
297
+
298
+ switch (state.value) {
299
+ case 'idle':
300
+ return <p>Drop files here or click to select</p>;
301
+
302
+ case 'dragging':
303
+ return <p>Drop your files now!</p>;
304
+
305
+ case 'processing':
306
+ return <p>{state.status?.details || 'Processing...'}</p>;
307
+
308
+ case 'ready':
309
+ return (
310
+ <div>
311
+ <p>✓ {state.files.length} files ready</p>
312
+ <button>Upload to Ship</button>
313
+ </div>
314
+ );
315
+
316
+ case 'error':
317
+ return (
318
+ <div>
319
+ <p>✗ {state.status?.title}</p>
320
+ <p>{state.status?.details}</p>
321
+ <button onClick={drop.clearAll}>Try Again</button>
322
+ </div>
323
+ );
324
+ }
325
+ }
306
326
  ```
307
327
 
328
+ ### Convenience Getters
329
+
330
+ For simpler use cases, boolean convenience getters are provided:
331
+
332
+ ```tsx
333
+ // These are computed from state.value (read-only projections)
334
+ drop.isProcessing // true when state.value === 'processing'
335
+ drop.isDragging // true when state.value === 'dragging'
336
+
337
+ // For error information, use the state object
338
+ drop.state.value === 'error' // Check if in error state
339
+ drop.state.status?.title // Error title
340
+ drop.state.status?.details // Error details
341
+ ```
342
+
343
+ ### Benefits
344
+
345
+ - **No impossible states** - Can't be `isProcessing=true` AND `isDragging=true`
346
+ - **Clear transitions** - State flow is explicit and predictable
347
+ - **Better TypeScript** - Discriminated unions provide type safety
348
+ - **Easier debugging** - Single source of truth for what's happening
349
+
308
350
  ## Error Handling
309
351
 
310
352
  ### Per-File Error Display
311
353
 
312
- Each file in the `files` array contains its own `status` and `statusMessage`, allowing you to display granular errors for individual files:
354
+ Each file in the `state.files` array contains its own `status` and `statusMessage`, allowing you to display granular errors for individual files:
313
355
 
314
356
  ```tsx
315
357
  function FileList({ drop }) {
316
358
  return (
317
359
  <div>
318
- {drop.files.map(file => (
360
+ {drop.state.files.map(file => (
319
361
  <div key={file.id}>
320
362
  <span>{file.path}</span>
321
363
 
@@ -332,7 +374,7 @@ function FileList({ drop }) {
332
374
  ))}
333
375
 
334
376
  {/* If validation fails, allow user to clear all and try again */}
335
- {drop.validationError && (
377
+ {drop.state.value === 'error' && (
336
378
  <button onClick={drop.clearAll}>
337
379
  Clear All & Try Again
338
380
  </button>
@@ -348,15 +390,15 @@ function FileList({ drop }) {
348
390
  - `empty_file` - File is 0 bytes
349
391
  - `ready` - File passed all validation and is ready for upload
350
392
 
351
- ### Validation Error Summary
393
+ ### Error State Summary
352
394
 
353
- The `validationError` provides a summary when any files fail validation:
395
+ When files fail validation or processing, check the error state:
354
396
 
355
397
  ```tsx
356
- {drop.validationError && (
398
+ {drop.state.value === 'error' && drop.state.status && (
357
399
  <div>
358
- <p>{drop.validationError.error}</p>
359
- <p>{drop.validationError.details}</p>
400
+ <p>{drop.state.status.title}</p>
401
+ <p>{drop.state.status.details}</p>
360
402
  </div>
361
403
  )}
362
404
  ```
@@ -386,10 +428,10 @@ Use `clearAll()` to reset and try again:
386
428
 
387
429
  ```tsx
388
430
  // If validation fails, show user which files failed
389
- {drop.validationError && (
431
+ {drop.state.value === 'error' && (
390
432
  <div>
391
433
  <p>Validation failed. Please fix the issues and try again:</p>
392
- {drop.files.map(file => (
434
+ {drop.state.files.map(file => (
393
435
  <div key={file.id}>
394
436
  {file.path}: {file.statusMessage}
395
437
  </div>
@@ -514,23 +556,28 @@ This package was extracted from the `web/drop` application and is purpose-built
514
556
  **1. Focused on UI Concerns**
515
557
  - ZIP extraction for user convenience
516
558
  - File list state management for React UIs
517
- - Folder structure preservation from drag-and-drop
559
+ - Drag & drop event handling with folder support
560
+ - Folder structure preservation via `webkitGetAsEntry` API
518
561
  - Path normalization for clean URLs
519
562
  - These are UI/UX concerns, not deployment logic
520
563
 
521
- **2. Loosely Coupled Integration Pattern**
564
+ **2. Prop Getters Pattern (Like react-dropzone)**
565
+ We provide event handlers, not visual components:
566
+ - ✅ **`getDropzoneProps()`** - Returns drag & drop event handlers
567
+ - ✅ **`getInputProps()`** - Returns file input configuration
568
+ - ✅ **`isDragging`** - State for visual feedback
569
+ - ✅ **You control the DOM** - Your markup, your styling, your design system
570
+
571
+ This is the same pattern used by popular libraries like `react-dropzone`, `downshift`, and `react-table`.
572
+
573
+ **3. Loosely Coupled Integration**
522
574
  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
575
+ - ✅ **Ship instance as dependency**: Validates using `ship.getConfig()`
576
+ - ✅ **Simple output**: ProcessedFile[] can be passed directly to Ship SDK
577
+ - ✅ **Testable**: Easy to mock Ship SDK for testing
526
578
  - ✅ **Flexible**: Host app controls WHEN to deploy
527
579
 
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**
580
+ **4. Type System Integration**
534
581
 
535
582
  ProcessedFile extends StaticFile from `@shipstatic/types` - the single source of truth for Ship SDK types:
536
583
 
@@ -540,13 +587,13 @@ File[] → ProcessedFile[] (which IS StaticFile[]) → ship.deployments.create()
540
587
 
541
588
  No conversion needed. ProcessedFile adds UI-specific properties (id, name, status, progress) to StaticFile's base properties (content, path, size, md5).
542
589
 
543
- **4. No UI Components**
590
+ **5. No Visual Components**
544
591
 
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
592
+ We deliberately don't provide styled components because:
593
+ - Your design system is unique to your application
594
+ - Styling should match your brand, not our opinions
595
+ - Prop getters give you full control over DOM structure and CSS
596
+ - Similar to how `react-dropzone` works - logic without opinions
550
597
 
551
598
  ### Error Handling Philosophy
552
599