@djangocfg/ui-tools 2.1.161 → 2.1.162

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.161",
3
+ "version": "2.1.162",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -52,6 +52,11 @@
52
52
  "import": "./src/tools/Mermaid/index.tsx",
53
53
  "require": "./src/tools/Mermaid/index.tsx"
54
54
  },
55
+ "./upload": {
56
+ "types": "./src/tools/Uploader/index.ts",
57
+ "import": "./src/tools/Uploader/index.ts",
58
+ "require": "./src/tools/Uploader/index.ts"
59
+ },
55
60
  "./styles": "./src/styles/index.css"
56
61
  },
57
62
  "files": [
@@ -68,8 +73,8 @@
68
73
  "check": "tsc --noEmit"
69
74
  },
70
75
  "peerDependencies": {
71
- "@djangocfg/i18n": "^2.1.161",
72
- "@djangocfg/ui-core": "^2.1.161",
76
+ "@djangocfg/i18n": "^2.1.162",
77
+ "@djangocfg/ui-core": "^2.1.162",
73
78
  "lucide-react": "^0.545.0",
74
79
  "react": "^19.0.0",
75
80
  "react-dom": "^19.0.0",
@@ -78,6 +83,7 @@
78
83
  "consola": "^3.4.2"
79
84
  },
80
85
  "dependencies": {
86
+ "@rpldy/uploady": "^1.8.5",
81
87
  "@rjsf/core": "^6.1.2",
82
88
  "@rjsf/utils": "^6.1.2",
83
89
  "@rjsf/validator-ajv8": "^6.1.2",
@@ -101,10 +107,10 @@
101
107
  "@maplibre/maplibre-gl-geocoder": "^1.7.0"
102
108
  },
103
109
  "devDependencies": {
104
- "@djangocfg/i18n": "^2.1.161",
110
+ "@djangocfg/i18n": "^2.1.162",
105
111
  "@djangocfg/playground": "workspace:*",
106
- "@djangocfg/typescript-config": "^2.1.161",
107
- "@djangocfg/ui-core": "^2.1.161",
112
+ "@djangocfg/typescript-config": "^2.1.162",
113
+ "@djangocfg/ui-core": "^2.1.162",
108
114
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
109
115
  "@types/node": "^24.7.2",
110
116
  "@types/react": "^19.1.0",
@@ -0,0 +1,223 @@
1
+ # Uploader
2
+
3
+ Drag-drop file uploader built on `@rpldy/uploady` with shadcn/ui styling.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @djangocfg/ui-tools
9
+ ```
10
+
11
+ ## Basic Usage
12
+
13
+ ```tsx
14
+ import { Uploader } from '@djangocfg/ui-tools/upload';
15
+
16
+ function MyPage() {
17
+ return (
18
+ <Uploader
19
+ destination="/api/upload"
20
+ accept={['image', 'document']}
21
+ maxSizeMB={10}
22
+ onUploadComplete={(asset) => console.log('Uploaded:', asset)}
23
+ />
24
+ );
25
+ }
26
+ ```
27
+
28
+ ## Components
29
+
30
+ ### Uploader
31
+
32
+ All-in-one component with dropzone and preview list.
33
+
34
+ ```tsx
35
+ <Uploader
36
+ destination="/api/upload" // Upload URL
37
+ accept={['image', 'video']} // Asset types: image, audio, video, document
38
+ maxSizeMB={50} // Max file size
39
+ multiple={true} // Allow multiple files
40
+ autoUpload={true} // Auto-upload on drop
41
+ showPreview={true} // Show upload queue
42
+ compact={false} // Compact dropzone mode
43
+ concurrent={3} // Max concurrent uploads
44
+ headers={{ 'X-Token': '...' }}
45
+ params={{ folder: 'uploads' }}
46
+ onUploadComplete={(asset, rawResponse) => {}}
47
+ onUploadError={(error, file, rawResponse) => {}}
48
+ onBatchComplete={(assets) => {}}
49
+ />
50
+ ```
51
+
52
+ ### Custom Composition
53
+
54
+ ```tsx
55
+ import {
56
+ UploadProvider,
57
+ UploadDropzone,
58
+ UploadPreviewList,
59
+ UploadAddButton,
60
+ useUploadEvents,
61
+ } from '@djangocfg/ui-tools/upload';
62
+
63
+ function CustomUploader() {
64
+ useUploadEvents({
65
+ onFileComplete: (asset, rawResponse) => saveToState(asset),
66
+ onError: (error, fileName, rawResponse) => toast.error(error),
67
+ });
68
+
69
+ return (
70
+ <div className="grid grid-cols-2 gap-4">
71
+ <UploadDropzone accept={['image']} />
72
+ <UploadPreviewList />
73
+ </div>
74
+ );
75
+ }
76
+
77
+ <UploadProvider destination={{ url: '/api/upload' }}>
78
+ <CustomUploader />
79
+ </UploadProvider>
80
+ ```
81
+
82
+ ### Page-Level Drop
83
+
84
+ Enable drag-drop anywhere on the page:
85
+
86
+ ```tsx
87
+ <UploadProvider
88
+ destination={{ url: '/api/upload' }}
89
+ pageDropEnabled
90
+ pageDropProps={{
91
+ accept: ['image'],
92
+ maxSizeMB: 10,
93
+ }}
94
+ >
95
+ <YourApp />
96
+ </UploadProvider>
97
+ ```
98
+
99
+ Custom overlay:
100
+
101
+ ```tsx
102
+ <UploadProvider
103
+ destination={{ url: '/api/upload' }}
104
+ pageDropEnabled
105
+ pageDropOverlay={
106
+ <div className="p-12 bg-primary/10 rounded-xl border-dashed border-2">
107
+ <p className="text-xl">Drop files here!</p>
108
+ </div>
109
+ }
110
+ >
111
+ <YourApp />
112
+ </UploadProvider>
113
+ ```
114
+
115
+ ## Exports
116
+
117
+ ### Components
118
+
119
+ | Component | Description |
120
+ |-----------|-------------|
121
+ | `Uploader` | All-in-one (Provider + Dropzone + Preview) |
122
+ | `UploadProvider` | Context provider wrapping @rpldy/uploady |
123
+ | `UploadDropzone` | Drag-drop zone with file input |
124
+ | `UploadPreviewList` | List of upload items with progress |
125
+ | `UploadPreviewItem` | Single item (thumbnail, status, actions) |
126
+ | `UploadAddButton` | Button to add files |
127
+ | `UploadPageDropOverlay` | Full-page drop overlay |
128
+
129
+ ### Hooks
130
+
131
+ | Hook | Description |
132
+ |------|-------------|
133
+ | `useUploadEvents` | Subscribe to upload lifecycle events |
134
+ | `useUploadProvider` | Access uploady context |
135
+ | `useAbortAll` | Abort all uploads |
136
+ | `useAbortBatch` | Abort batch by ID |
137
+ | `useAbortItem` | Abort single item |
138
+
139
+ ### Utils
140
+
141
+ | Function | Description |
142
+ |----------|-------------|
143
+ | `getAssetTypeFromMime(mime)` | Get asset type from MIME |
144
+ | `buildAcceptString(types)` | Build accept string for input |
145
+ | `formatFileSize(bytes)` | Format bytes to human readable |
146
+ | `formatDuration(seconds)` | Format seconds to mm:ss |
147
+
148
+ ### Types
149
+
150
+ ```ts
151
+ type AssetType = 'image' | 'audio' | 'video' | 'document';
152
+
153
+ type UploadStatus = 'pending' | 'uploading' | 'complete' | 'error' | 'aborted';
154
+
155
+ interface UploadedAsset {
156
+ id: string;
157
+ name: string;
158
+ type: AssetType;
159
+ url: string;
160
+ thumbnailUrl?: string;
161
+ size: number;
162
+ mimeType: string;
163
+ duration?: number;
164
+ }
165
+
166
+ interface UploadItem {
167
+ id: string;
168
+ file: File;
169
+ status: UploadStatus;
170
+ progress: number;
171
+ previewUrl?: string;
172
+ asset?: UploadedAsset;
173
+ error?: string;
174
+ }
175
+ ```
176
+
177
+ ## Server Response
178
+
179
+ Expected response format:
180
+
181
+ ```json
182
+ {
183
+ "id": "abc123",
184
+ "url": "https://cdn.example.com/file.jpg",
185
+ "thumbnail_url": "https://cdn.example.com/file_thumb.jpg",
186
+ "duration": 120
187
+ }
188
+ ```
189
+
190
+ Also supports: `uuid`, `file`, `file_url`, `thumbnail`, `thumb_url`, `preview_url`.
191
+
192
+ ### Custom Response Handling
193
+
194
+ Use `onUploadComplete` callback to access raw response:
195
+
196
+ ```tsx
197
+ <Uploader
198
+ destination="/api/upload"
199
+ onUploadComplete={(asset, rawResponse) => {
200
+ // asset - parsed asset with defaults
201
+ // rawResponse - original API response for custom handling
202
+ console.log('Custom field:', rawResponse.my_custom_field);
203
+ }}
204
+ onUploadError={(error, file, rawResponse) => {
205
+ // error - parsed error message
206
+ // rawResponse - original error response for custom handling
207
+ }}
208
+ />
209
+ ```
210
+
211
+ ## Features
212
+
213
+ - Drag & drop with visual feedback
214
+ - Multiple file upload
215
+ - Concurrent uploads (configurable)
216
+ - Progress tracking per file
217
+ - Image preview thumbnails
218
+ - File type validation
219
+ - Size validation
220
+ - Abort/cancel uploads
221
+ - Page-level drop zone
222
+ - Tooltips for long filenames
223
+ - Error handling with retry
@@ -0,0 +1,300 @@
1
+ import { useState } from 'react';
2
+ import { defineStory, useBoolean, useNumber } from '@djangocfg/playground';
3
+ import { Card, CardContent, CardHeader, CardTitle } from '@djangocfg/ui-core/components';
4
+ import { ImageIcon } from 'lucide-react';
5
+ import { Uploader } from './components/Uploader';
6
+ import { logger } from './utils';
7
+ import { UploadProvider } from './context';
8
+ import { UploadDropzone } from './components/UploadDropzone';
9
+ import { UploadPreviewList } from './components/UploadPreviewList';
10
+ import { UploadAddButton } from './components/UploadAddButton';
11
+ import { useUploadEvents } from './hooks/useUploadEvents';
12
+ import type { UploadedAsset, AssetType } from './types';
13
+
14
+ export default defineStory({
15
+ title: 'Tools/Uploader',
16
+ component: Uploader,
17
+ description: 'Drag-drop file uploader with progress tracking and preview.',
18
+ });
19
+
20
+ // Mock upload endpoint that simulates server response
21
+ const MOCK_DESTINATION = 'https://httpbin.org/post';
22
+
23
+ export const Interactive = () => {
24
+ const [compact] = useBoolean('compact', {
25
+ defaultValue: false,
26
+ label: 'Compact Mode',
27
+ });
28
+
29
+ const [showPreview] = useBoolean('showPreview', {
30
+ defaultValue: true,
31
+ label: 'Show Preview',
32
+ });
33
+
34
+ const [multiple] = useBoolean('multiple', {
35
+ defaultValue: true,
36
+ label: 'Multiple Files',
37
+ });
38
+
39
+ const [maxSizeMB] = useNumber('maxSizeMB', {
40
+ defaultValue: 10,
41
+ min: 1,
42
+ max: 100,
43
+ label: 'Max Size (MB)',
44
+ });
45
+
46
+ const [concurrent] = useNumber('concurrent', {
47
+ defaultValue: 3,
48
+ min: 1,
49
+ max: 10,
50
+ label: 'Concurrent Uploads',
51
+ });
52
+
53
+ const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
54
+
55
+ return (
56
+ <div className="max-w-2xl space-y-4">
57
+ <Uploader
58
+ destination={MOCK_DESTINATION}
59
+ compact={compact}
60
+ showPreview={showPreview}
61
+ multiple={multiple}
62
+ maxSizeMB={maxSizeMB}
63
+ concurrent={concurrent}
64
+ accept={['image', 'document']}
65
+ onUploadComplete={(asset) => {
66
+ setUploadedFiles((prev) => [...prev, asset.name]);
67
+ }}
68
+ />
69
+
70
+ {uploadedFiles.length > 0 && (
71
+ <Card>
72
+ <CardHeader>
73
+ <CardTitle className="text-sm">Uploaded Files</CardTitle>
74
+ </CardHeader>
75
+ <CardContent>
76
+ <ul className="text-sm space-y-1">
77
+ {uploadedFiles.map((name, i) => (
78
+ <li key={i} className="text-muted-foreground">
79
+ {name}
80
+ </li>
81
+ ))}
82
+ </ul>
83
+ </CardContent>
84
+ </Card>
85
+ )}
86
+ </div>
87
+ );
88
+ };
89
+
90
+ export const Default = () => (
91
+ <div className="max-w-2xl">
92
+ <Uploader
93
+ destination={MOCK_DESTINATION}
94
+ accept={['image', 'video', 'document']}
95
+ />
96
+ </div>
97
+ );
98
+
99
+ export const ImagesOnly = () => (
100
+ <div className="max-w-2xl">
101
+ <Uploader
102
+ destination={MOCK_DESTINATION}
103
+ accept={['image']}
104
+ maxSizeMB={5}
105
+ />
106
+ </div>
107
+ );
108
+
109
+ export const Compact = () => (
110
+ <div className="max-w-md">
111
+ <Uploader
112
+ destination={MOCK_DESTINATION}
113
+ compact
114
+ accept={['image', 'document']}
115
+ />
116
+ </div>
117
+ );
118
+
119
+ export const NoPreview = () => (
120
+ <div className="max-w-2xl">
121
+ <Uploader
122
+ destination={MOCK_DESTINATION}
123
+ showPreview={false}
124
+ accept={['image', 'document']}
125
+ />
126
+ </div>
127
+ );
128
+
129
+ export const SingleFile = () => (
130
+ <div className="max-w-2xl">
131
+ <Uploader
132
+ destination={MOCK_DESTINATION}
133
+ multiple={false}
134
+ accept={['image']}
135
+ />
136
+ </div>
137
+ );
138
+
139
+ // Custom composition example
140
+ function CustomUploaderContent() {
141
+ const [assets, setAssets] = useState<UploadedAsset[]>([]);
142
+
143
+ useUploadEvents({
144
+ onFileComplete: (asset) => {
145
+ setAssets((prev) => [...prev, asset]);
146
+ },
147
+ onError: (error, fileName) => {
148
+ logger.error(`Error uploading ${fileName}: ${error}`);
149
+ },
150
+ });
151
+
152
+ return (
153
+ <div className="grid grid-cols-2 gap-4">
154
+ <div>
155
+ <h3 className="text-sm font-medium mb-2">Drop Zone</h3>
156
+ <UploadDropzone accept={['image']} />
157
+ </div>
158
+ <div>
159
+ <h3 className="text-sm font-medium mb-2">Upload Queue</h3>
160
+ <UploadPreviewList />
161
+ </div>
162
+ </div>
163
+ );
164
+ }
165
+
166
+ export const CustomComposition = () => (
167
+ <div className="max-w-3xl">
168
+ <UploadProvider destination={{ url: MOCK_DESTINATION }}>
169
+ <CustomUploaderContent />
170
+ </UploadProvider>
171
+ </div>
172
+ );
173
+
174
+ // With add button
175
+ function WithAddButtonContent() {
176
+ return (
177
+ <div className="space-y-4">
178
+ <div className="flex items-center gap-2">
179
+ <UploadAddButton accept={['image', 'document']} />
180
+ <span className="text-sm text-muted-foreground">
181
+ Click to add files
182
+ </span>
183
+ </div>
184
+ <UploadPreviewList />
185
+ </div>
186
+ );
187
+ }
188
+
189
+ export const WithAddButton = () => (
190
+ <div className="max-w-2xl">
191
+ <UploadProvider destination={{ url: MOCK_DESTINATION }}>
192
+ <WithAddButtonContent />
193
+ </UploadProvider>
194
+ </div>
195
+ );
196
+
197
+ export const DocumentsOnly = () => (
198
+ <div className="max-w-2xl">
199
+ <Uploader
200
+ destination={MOCK_DESTINATION}
201
+ accept={['document']}
202
+ maxSizeMB={20}
203
+ >
204
+ <div className="text-center">
205
+ <p className="text-muted-foreground">Drop PDF, DOC, or XLS files</p>
206
+ <p className="text-xs text-muted-foreground/60 mt-1">
207
+ Max 20MB per file
208
+ </p>
209
+ </div>
210
+ </Uploader>
211
+ </div>
212
+ );
213
+
214
+ export const CustomContent = () => (
215
+ <div className="max-w-2xl">
216
+ <Uploader
217
+ destination={MOCK_DESTINATION}
218
+ accept={['image']}
219
+ compact
220
+ >
221
+ <div className="flex items-center gap-2">
222
+ <span className="text-2xl">+</span>
223
+ <span className="text-sm">Add images</span>
224
+ </div>
225
+ </Uploader>
226
+ </div>
227
+ );
228
+
229
+ // Page-level drop zone
230
+ function PageDropContent() {
231
+ return (
232
+ <div className="space-y-4">
233
+ <Card>
234
+ <CardHeader>
235
+ <CardTitle className="text-sm">Page Drop Enabled</CardTitle>
236
+ </CardHeader>
237
+ <CardContent>
238
+ <p className="text-sm text-muted-foreground mb-4">
239
+ Drag files anywhere on the page to upload. An overlay will appear when dragging.
240
+ </p>
241
+ <UploadPreviewList />
242
+ </CardContent>
243
+ </Card>
244
+ </div>
245
+ );
246
+ }
247
+
248
+ export const PageDrop = () => (
249
+ <div className="max-w-2xl">
250
+ <UploadProvider
251
+ destination={{ url: MOCK_DESTINATION }}
252
+ pageDropEnabled
253
+ pageDropProps={{
254
+ accept: ['image', 'document'],
255
+ maxSizeMB: 10,
256
+ }}
257
+ >
258
+ <PageDropContent />
259
+ </UploadProvider>
260
+ </div>
261
+ );
262
+
263
+ // Page drop with custom overlay
264
+ function PageDropCustomOverlayContent() {
265
+ return (
266
+ <div className="space-y-4">
267
+ <Card>
268
+ <CardHeader>
269
+ <CardTitle className="text-sm">Custom Page Drop Overlay</CardTitle>
270
+ </CardHeader>
271
+ <CardContent>
272
+ <p className="text-sm text-muted-foreground mb-4">
273
+ This example uses a custom overlay design.
274
+ </p>
275
+ <UploadPreviewList />
276
+ </CardContent>
277
+ </Card>
278
+ </div>
279
+ );
280
+ }
281
+
282
+ export const PageDropCustomOverlay = () => (
283
+ <div className="max-w-2xl">
284
+ <UploadProvider
285
+ destination={{ url: MOCK_DESTINATION }}
286
+ pageDropEnabled
287
+ pageDropProps={{
288
+ accept: ['image'],
289
+ }}
290
+ pageDropOverlay={
291
+ <div className="text-center p-12 bg-primary/10 rounded-2xl border-4 border-dashed border-primary">
292
+ <ImageIcon className="h-16 w-16 text-primary mx-auto mb-4" />
293
+ <p className="text-xl font-bold text-primary">Drop your images here!</p>
294
+ </div>
295
+ }
296
+ >
297
+ <PageDropCustomOverlayContent />
298
+ </UploadProvider>
299
+ </div>
300
+ );
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useRef, useMemo } from 'react';
4
+ import { useUploady } from '@rpldy/uploady';
5
+ import { Plus } from 'lucide-react';
6
+ import { Button } from '@djangocfg/ui-core/components';
7
+ import { cn } from '@djangocfg/ui-core/lib';
8
+ import { buildAcceptString, logger } from '../utils';
9
+ import type { AssetType } from '../types';
10
+
11
+ interface UploadAddButtonProps {
12
+ accept?: AssetType[];
13
+ multiple?: boolean;
14
+ maxSizeMB?: number;
15
+ disabled?: boolean;
16
+ className?: string;
17
+ variant?: 'default' | 'outline' | 'ghost' | 'secondary';
18
+ size?: 'default' | 'sm' | 'lg' | 'icon';
19
+ children?: React.ReactNode;
20
+ }
21
+
22
+ export function UploadAddButton({
23
+ accept = ['image', 'audio', 'video', 'document'],
24
+ multiple = true,
25
+ maxSizeMB = 100,
26
+ disabled = false,
27
+ className,
28
+ variant = 'outline',
29
+ size = 'default',
30
+ children,
31
+ }: UploadAddButtonProps) {
32
+ const { upload } = useUploady();
33
+ const inputRef = useRef<HTMLInputElement>(null);
34
+
35
+ const acceptString = useMemo(() => buildAcceptString(accept), [accept]);
36
+
37
+ const handleFiles = useCallback((files: FileList | File[]) => {
38
+ const fileArray = Array.from(files);
39
+ const maxBytes = maxSizeMB * 1024 * 1024;
40
+
41
+ const validFiles = fileArray.filter(file => {
42
+ if (file.size > maxBytes) {
43
+ logger.warn(`File ${file.name} exceeds max size of ${maxSizeMB}MB`);
44
+ return false;
45
+ }
46
+ return true;
47
+ });
48
+
49
+ if (validFiles.length > 0) {
50
+ upload(validFiles);
51
+ }
52
+ }, [upload, maxSizeMB]);
53
+
54
+ const handleClick = useCallback(() => {
55
+ inputRef.current?.click();
56
+ }, []);
57
+
58
+ const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
59
+ if (e.target.files?.length) {
60
+ handleFiles(e.target.files);
61
+ }
62
+ e.target.value = '';
63
+ }, [handleFiles]);
64
+
65
+ return (
66
+ <>
67
+ <input
68
+ ref={inputRef}
69
+ type="file"
70
+ accept={acceptString}
71
+ multiple={multiple}
72
+ onChange={handleInputChange}
73
+ className="hidden"
74
+ disabled={disabled}
75
+ />
76
+ <Button
77
+ variant={variant}
78
+ size={size}
79
+ disabled={disabled}
80
+ onClick={handleClick}
81
+ className={cn(className)}
82
+ >
83
+ {children || (
84
+ <>
85
+ <Plus className="h-4 w-4 mr-2" />
86
+ Add Files
87
+ </>
88
+ )}
89
+ </Button>
90
+ </>
91
+ );
92
+ }