@shipstatic/drop 0.1.7 → 0.1.9

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,10 +4,26 @@
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.
7
+ 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
8
 
9
9
  **Note:** MD5 calculation is handled by Ship SDK during deployment. Drop focuses on file processing and UI state management.
10
10
 
11
+ ## Table of Contents
12
+
13
+ - [Why Headless?](#why-headless)
14
+ - [Features](#features)
15
+ - [Installation](#installation)
16
+ - [Requirements](#requirements)
17
+ - [Quick Start](#quick-start)
18
+ - [Configuration Architecture](#️-configuration-architecture)
19
+ - [Advanced Usage](#advanced-programmatic-file-picker)
20
+ - [API Reference](#api)
21
+ - [State Machine](#state-machine)
22
+ - [Error Handling](#error-handling)
23
+ - [Types](#types)
24
+ - [Ship SDK Integration](#direct-ship-sdk-integration)
25
+ - [Architecture Decisions](#architecture-decisions)
26
+
11
27
  ## Why Headless?
12
28
 
13
29
  This package provides **zero UI components** - just a React hook with built-in drag & drop functionality. You bring your own styling.
@@ -16,8 +32,7 @@ This package provides **zero UI components** - just a React hook with built-in d
16
32
  1. **Built-in drag & drop** - Proper folder support with `webkitGetAsEntry` API, all handled internally
17
33
  2. **Prop getters API** - Similar to `react-dropzone`, just spread props on your elements
18
34
  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
35
+ 4. **Ship SDK integration** - Purpose-built for Ship deployments, not a generic file upload library
21
36
 
22
37
  **What's different from other libraries:**
23
38
  - Generic dropzone libraries don't preserve folder structure properly
@@ -34,6 +49,7 @@ This package provides **zero UI components** - just a React hook with built-in d
34
49
  - 🔒 **Path Sanitization** - Defense-in-depth protection against directory traversal attacks
35
50
  - 📁 **Folder Structure Preservation** - Proper folder paths via `webkitRelativePath`
36
51
  - 🎨 **Headless UI** - No visual components, just logic and state management
52
+ - 📘 **Full TypeScript Support** - Complete type definitions with discriminated unions for state machine
37
53
  - 🚀 **Focused Scope** - File processing and UI state only. MD5 calculation and deployment handled by Ship SDK
38
54
 
39
55
  ## Installation
@@ -44,6 +60,17 @@ npm install @shipstatic/drop
44
60
  pnpm add @shipstatic/drop
45
61
  ```
46
62
 
63
+ ## Requirements
64
+
65
+ - **React**: ^18.0.0 or ^19.0.0
66
+ - **TypeScript**: Full TypeScript support with exported types
67
+ - **Browsers**: Modern browsers with support for:
68
+ - File API (universal support)
69
+ - DataTransfer API for drag & drop (universal support)
70
+ - `webkitGetAsEntry` for folder uploads (Chrome, Edge, Safari 11.1+, Firefox 50+)
71
+
72
+ **Note on folder uploads**: The folder drag & drop feature uses the `webkitGetAsEntry` API. While widely supported, older browsers may only support file-by-file selection. ZIP extraction works universally as a fallback.
73
+
47
74
  ## Quick Start
48
75
 
49
76
  ```tsx
@@ -80,15 +107,15 @@ function MyUploader() {
80
107
  {drop.isDragging ? '📂 Drop here' : '📁 Click or drag files/folders'}
81
108
  </div>
82
109
 
83
- {/* Status - using state machine */}
84
- {drop.state.status && (
110
+ {/* Status - using state machine phase */}
111
+ {drop.status && (
85
112
  <p>
86
- <strong>{drop.state.status.title}:</strong> {drop.state.status.details}
113
+ <strong>{drop.status.title}:</strong> {drop.status.details}
87
114
  </p>
88
115
  )}
89
116
 
90
117
  {/* File list */}
91
- {drop.state.files.map(file => (
118
+ {drop.files.map(file => (
92
119
  <div key={file.id}>
93
120
  {file.name} - {file.status}
94
121
  </div>
@@ -97,7 +124,7 @@ function MyUploader() {
97
124
  {/* Upload button */}
98
125
  <button
99
126
  onClick={handleUpload}
100
- disabled={drop.state.value !== 'ready'}
127
+ disabled={drop.phase !== 'ready'}
101
128
  >
102
129
  Upload {drop.getValidFiles().length} files
103
130
  </button>
@@ -205,17 +232,22 @@ interface DropOptions {
205
232
 
206
233
  **Returns:**
207
234
 
235
+ ```typescript
208
236
  ```typescript
209
237
  interface DropReturn {
210
- // State machine
211
- /** Current state of the drop hook */
212
- state: DropState;
213
-
214
238
  // Convenience getters (computed from state)
239
+ /** Current phase of the state machine */
240
+ phase: DropStateValue;
215
241
  /** Whether currently processing files (ZIP extraction, etc.) */
216
242
  isProcessing: boolean;
217
243
  /** Whether user is currently dragging over the dropzone */
218
244
  isDragging: boolean;
245
+ /** Flattened access to files */
246
+ files: ProcessedFile[];
247
+ /** Flattened access to source name */
248
+ sourceName: string;
249
+ /** Flattened access to status */
250
+ status: DropStatus | null;
219
251
 
220
252
  // Primary API: Prop getters for easy integration
221
253
  /** Get props to spread on dropzone element (handles drag & drop) */
@@ -231,7 +263,7 @@ interface DropReturn {
231
263
  type: 'file';
232
264
  style: { display: string };
233
265
  multiple: boolean;
234
- webkitdirectory: string;
266
+ webkitdirectory: string; // Note: React expects string ('') for boolean attributes
235
267
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
236
268
  };
237
269
 
@@ -262,22 +294,16 @@ type DropStateValue =
262
294
  | 'ready' // Files are valid and ready for deployment
263
295
  | 'error'; // An error occurred during processing
264
296
 
265
- interface DropState {
266
- value: DropStateValue;
267
- files: ProcessedFile[];
268
- sourceName: string;
269
- status: DropStatus | null;
270
- }
271
-
272
297
  interface DropStatus {
273
298
  title: string;
274
299
  details: string;
300
+ errors?: string[];
275
301
  }
276
302
  ```
277
303
 
278
304
  ## State Machine
279
305
 
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.
306
+ The drop hook uses a state machine for predictable, clear state management. Instead of multiple boolean flags, you have a single `drop.phase` that represents exactly what's happening.
281
307
 
282
308
  ### State Flow
283
309
 
@@ -293,9 +319,8 @@ error → dragging → processing → ... (retry)
293
319
 
294
320
  ```tsx
295
321
  function StatusIndicator({ drop }) {
296
- const { state } = drop;
297
-
298
- switch (state.value) {
322
+ // Use drop.phase to switch-case on the state
323
+ switch (drop.phase) {
299
324
  case 'idle':
300
325
  return <p>Drop files here or click to select</p>;
301
326
 
@@ -303,12 +328,12 @@ function StatusIndicator({ drop }) {
303
328
  return <p>Drop your files now!</p>;
304
329
 
305
330
  case 'processing':
306
- return <p>{state.status?.details || 'Processing...'}</p>;
331
+ return <p>{drop.status?.details || 'Processing...'}</p>;
307
332
 
308
333
  case 'ready':
309
334
  return (
310
335
  <div>
311
- <p>✓ {state.files.length} files ready</p>
336
+ <p>✓ {drop.files.length} files ready</p>
312
337
  <button>Upload to Ship</button>
313
338
  </div>
314
339
  );
@@ -316,8 +341,8 @@ function StatusIndicator({ drop }) {
316
341
  case 'error':
317
342
  return (
318
343
  <div>
319
- <p>✗ {state.status?.title}</p>
320
- <p>{state.status?.details}</p>
344
+ <p>✗ {drop.status?.title}</p>
345
+ <p>{drop.status?.details}</p>
321
346
  <button onClick={drop.clearAll}>Try Again</button>
322
347
  </div>
323
348
  );
@@ -330,14 +355,14 @@ function StatusIndicator({ drop }) {
330
355
  For simpler use cases, boolean convenience getters are provided:
331
356
 
332
357
  ```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
358
+ // These are computed from drop.phase (read-only projections)
359
+ drop.isProcessing // true when phase === 'processing'
360
+ drop.isDragging // true when phase === 'dragging'
361
+
362
+ // For error information, use the flattened status object
363
+ drop.phase === 'error' // Check if in error state
364
+ drop.status?.title // Error title
365
+ drop.status?.details // Error details
341
366
  ```
342
367
 
343
368
  ### Benefits
@@ -357,7 +382,8 @@ Each file in the `state.files` array contains its own `status` and `statusMessag
357
382
  function FileList({ drop }) {
358
383
  return (
359
384
  <div>
360
- {drop.state.files.map(file => (
385
+ {/* Flattened access to files array */}
386
+ {drop.files.map(file => (
361
387
  <div key={file.id}>
362
388
  <span>{file.path}</span>
363
389
 
@@ -374,7 +400,7 @@ function FileList({ drop }) {
374
400
  ))}
375
401
 
376
402
  {/* If validation fails, allow user to clear all and try again */}
377
- {drop.state.value === 'error' && (
403
+ {drop.phase === 'error' && (
378
404
  <button onClick={drop.clearAll}>
379
405
  Clear All & Try Again
380
406
  </button>
@@ -395,10 +421,10 @@ function FileList({ drop }) {
395
421
  When files fail validation or processing, check the error state:
396
422
 
397
423
  ```tsx
398
- {drop.state.value === 'error' && drop.state.status && (
424
+ {drop.phase === 'error' && drop.status && (
399
425
  <div>
400
- <p>{drop.state.status.title}</p>
401
- <p>{drop.state.status.details}</p>
426
+ <p>{drop.status.title}</p>
427
+ <p>{drop.status.details}</p>
402
428
  </div>
403
429
  )}
404
430
  ```
@@ -428,10 +454,10 @@ Use `clearAll()` to reset and try again:
428
454
 
429
455
  ```tsx
430
456
  // If validation fails, show user which files failed
431
- {drop.state.value === 'error' && (
457
+ {drop.phase === 'error' && (
432
458
  <div>
433
459
  <p>Validation failed. Please fix the issues and try again:</p>
434
- {drop.state.files.map(file => (
460
+ {drop.files.map(file => (
435
461
  <div key={file.id}>
436
462
  {file.path}: {file.statusMessage}
437
463
  </div>
package/dist/index.cjs CHANGED
@@ -37,9 +37,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
37
37
 
38
38
  // node_modules/.pnpm/jszip@3.10.1/node_modules/jszip/dist/jszip.min.js
39
39
  var require_jszip_min = __commonJS({
40
- "node_modules/.pnpm/jszip@3.10.1/node_modules/jszip/dist/jszip.min.js"(exports, module) {
40
+ "node_modules/.pnpm/jszip@3.10.1/node_modules/jszip/dist/jszip.min.js"(exports$1, module) {
41
41
  !(function(e) {
42
- if ("object" == typeof exports && "undefined" != typeof module) module.exports = e();
42
+ if ("object" == typeof exports$1 && "undefined" != typeof module) module.exports = e();
43
43
  else if ("function" == typeof define && define.amd) define([], e);
44
44
  else {
45
45
  ("undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof self ? self : this).JSZip = e();
@@ -2349,7 +2349,7 @@ var require_jszip_min = __commonJS({
2349
2349
 
2350
2350
  // node_modules/.pnpm/mime-db@1.54.0/node_modules/mime-db/db.json
2351
2351
  var require_db = __commonJS({
2352
- "node_modules/.pnpm/mime-db@1.54.0/node_modules/mime-db/db.json"(exports, module) {
2352
+ "node_modules/.pnpm/mime-db@1.54.0/node_modules/mime-db/db.json"(exports$1, module) {
2353
2353
  module.exports = {
2354
2354
  "application/1d-interleaved-parityfec": {
2355
2355
  source: "iana"
@@ -11697,7 +11697,7 @@ var require_db = __commonJS({
11697
11697
 
11698
11698
  // node_modules/.pnpm/mime-db@1.54.0/node_modules/mime-db/index.js
11699
11699
  var require_mime_db = __commonJS({
11700
- "node_modules/.pnpm/mime-db@1.54.0/node_modules/mime-db/index.js"(exports, module) {
11700
+ "node_modules/.pnpm/mime-db@1.54.0/node_modules/mime-db/index.js"(exports$1, module) {
11701
11701
  module.exports = require_db();
11702
11702
  }
11703
11703
  });
@@ -11813,7 +11813,6 @@ async function createProcessedFile(file, options) {
11813
11813
  status: FILE_STATUSES.PENDING
11814
11814
  };
11815
11815
  }
11816
- var getValidFiles = ship.getValidFiles;
11817
11816
  function stripCommonPrefix(files) {
11818
11817
  if (files.length === 0) return files;
11819
11818
  const paths = files.map((f) => f.path);
@@ -11835,35 +11834,39 @@ function stripCommonPrefix(files) {
11835
11834
  }));
11836
11835
  }
11837
11836
  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);
11837
+ try {
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();
11855
11860
  }
11856
- );
11857
- if (batch.length > 0) {
11858
- allEntries = allEntries.concat(batch);
11859
- await readEntriesBatch();
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);
11860
11866
  }
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
+ } catch (error) {
11869
+ console.warn(`Error traversing file tree for entry ${entry.name}:`, error);
11867
11870
  }
11868
11871
  }
11869
11872
  function useDrop(options) {
@@ -11884,6 +11887,7 @@ function useDrop(options) {
11884
11887
  const inputRef = react.useRef(null);
11885
11888
  const isProcessing = react.useMemo(() => state.value === "processing", [state.value]);
11886
11889
  const isDragging = react.useMemo(() => state.value === "dragging", [state.value]);
11890
+ const validFiles = react.useMemo(() => ship.getValidFiles(state.files), [state.files]);
11887
11891
  const processFiles = react.useCallback(async (newFiles) => {
11888
11892
  if (isProcessingRef.current) {
11889
11893
  console.warn("File processing already in progress. Ignoring duplicate call.");
@@ -11949,7 +11953,11 @@ function useDrop(options) {
11949
11953
  value: "error",
11950
11954
  files: validation.files,
11951
11955
  sourceName: detectedSourceName,
11952
- status: { title: validation.error.error, details: validation.error.details }
11956
+ status: {
11957
+ title: validation.error.error,
11958
+ details: validation.error.details,
11959
+ errors: validation.error.errors
11960
+ }
11953
11961
  });
11954
11962
  onValidationError?.(validation.error);
11955
11963
  } else if (validation.validFiles.length > 0) {
@@ -11994,9 +12002,6 @@ function useDrop(options) {
11994
12002
  setState(initialState);
11995
12003
  isProcessingRef.current = false;
11996
12004
  }, []);
11997
- const getValidFilesCallback = react.useCallback(() => {
11998
- return getValidFiles(state.files);
11999
- }, [state.files]);
12000
12005
  const updateFileStatus = react.useCallback((fileId, fileState) => {
12001
12006
  setState((prev) => ({
12002
12007
  ...prev,
@@ -12029,14 +12034,27 @@ function useDrop(options) {
12029
12034
  let hasEntries = false;
12030
12035
  for (const item of items) {
12031
12036
  if (item.kind === "file") {
12032
- const entry = item.webkitGetAsEntry?.();
12033
- if (entry) {
12034
- hasEntries = true;
12035
- await traverseFileTree(
12036
- entry,
12037
- files,
12038
- entry.isDirectory ? entry.name : ""
12039
- );
12037
+ try {
12038
+ const entry = item.webkitGetAsEntry?.();
12039
+ if (entry) {
12040
+ hasEntries = true;
12041
+ await traverseFileTree(
12042
+ entry,
12043
+ files,
12044
+ entry.isDirectory ? entry.name : ""
12045
+ );
12046
+ } else {
12047
+ const file = item.getAsFile();
12048
+ if (file) {
12049
+ files.push(file);
12050
+ }
12051
+ }
12052
+ } catch (error) {
12053
+ console.warn("Error processing drop item:", error);
12054
+ const file = item.getAsFile();
12055
+ if (file) {
12056
+ files.push(file);
12057
+ }
12040
12058
  }
12041
12059
  }
12042
12060
  }
@@ -12074,10 +12092,14 @@ function useDrop(options) {
12074
12092
  }), [handleInputChange]);
12075
12093
  return {
12076
12094
  // State machine
12077
- state,
12095
+ // state, // REMOVED
12078
12096
  // Convenience getters (computed from state)
12097
+ phase: state.value,
12079
12098
  isProcessing,
12080
12099
  isDragging,
12100
+ files: state.files,
12101
+ sourceName: state.sourceName,
12102
+ status: state.status,
12081
12103
  // Primary API: Prop getters
12082
12104
  getDropzoneProps,
12083
12105
  getInputProps,
@@ -12086,7 +12108,7 @@ function useDrop(options) {
12086
12108
  processFiles,
12087
12109
  clearAll,
12088
12110
  // Helpers
12089
- getValidFiles: getValidFilesCallback,
12111
+ validFiles,
12090
12112
  updateFileStatus
12091
12113
  };
12092
12114
  }
@@ -12118,10 +12140,10 @@ exports.FILE_STATUSES = FILE_STATUSES;
12118
12140
  exports.createProcessedFile = createProcessedFile;
12119
12141
  exports.extractZipToFiles = extractZipToFiles;
12120
12142
  exports.formatFileSize = formatFileSize;
12121
- exports.getValidFiles = getValidFiles;
12122
12143
  exports.isZipFile = isZipFile;
12123
12144
  exports.normalizePath = normalizePath;
12124
12145
  exports.stripCommonPrefix = stripCommonPrefix;
12146
+ exports.traverseFileTree = traverseFileTree;
12125
12147
  exports.useDrop = useDrop;
12126
12148
  //# sourceMappingURL=index.cjs.map
12127
12149
  //# sourceMappingURL=index.cjs.map