@djangocfg/ui-tools 2.1.160 → 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 +12 -6
- package/src/tools/Uploader/README.md +223 -0
- package/src/tools/Uploader/Uploader.story.tsx +300 -0
- package/src/tools/Uploader/components/UploadAddButton.tsx +92 -0
- package/src/tools/Uploader/components/UploadDropzone.tsx +145 -0
- package/src/tools/Uploader/components/UploadPageDropOverlay.tsx +130 -0
- package/src/tools/Uploader/components/UploadPreviewItem.tsx +182 -0
- package/src/tools/Uploader/components/UploadPreviewList.tsx +204 -0
- package/src/tools/Uploader/components/Uploader.tsx +74 -0
- package/src/tools/Uploader/components/index.ts +6 -0
- package/src/tools/Uploader/context/UploadProvider.tsx +42 -0
- package/src/tools/Uploader/context/index.ts +1 -0
- package/src/tools/Uploader/hooks/index.ts +2 -0
- package/src/tools/Uploader/hooks/useUploadEvents.ts +56 -0
- package/src/tools/Uploader/hooks/useUploadProvider.ts +8 -0
- package/src/tools/Uploader/index.ts +37 -0
- package/src/tools/Uploader/types/index.ts +100 -0
- package/src/tools/Uploader/utils/assetTypes.ts +58 -0
- package/src/tools/Uploader/utils/formatters.ts +16 -0
- package/src/tools/Uploader/utils/index.ts +17 -0
- package/src/tools/Uploader/utils/logger.ts +3 -0
- package/src/tools/Uploader/utils/transformers.ts +114 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState, useMemo } from 'react';
|
|
4
|
+
import { useUploady } from '@rpldy/uploady';
|
|
5
|
+
import { Upload } from 'lucide-react';
|
|
6
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
|
+
import { buildAcceptString, logger } from '../utils';
|
|
8
|
+
import type { UploadDropzoneProps } from '../types';
|
|
9
|
+
|
|
10
|
+
export function UploadDropzone({
|
|
11
|
+
accept = ['image', 'audio', 'video', 'document'],
|
|
12
|
+
multiple = true,
|
|
13
|
+
maxSizeMB = 100,
|
|
14
|
+
compact = false,
|
|
15
|
+
disabled = false,
|
|
16
|
+
className,
|
|
17
|
+
children,
|
|
18
|
+
onFilesSelected,
|
|
19
|
+
}: UploadDropzoneProps) {
|
|
20
|
+
const { upload } = useUploady();
|
|
21
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
22
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
23
|
+
const dragCounter = useRef(0);
|
|
24
|
+
|
|
25
|
+
const acceptString = useMemo(() => buildAcceptString(accept), [accept]);
|
|
26
|
+
|
|
27
|
+
const handleFiles = useCallback((files: FileList | File[]) => {
|
|
28
|
+
const fileArray = Array.from(files);
|
|
29
|
+
const maxBytes = maxSizeMB * 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
const validFiles = fileArray.filter(file => {
|
|
32
|
+
if (file.size > maxBytes) {
|
|
33
|
+
logger.warn(`File ${file.name} exceeds max size of ${maxSizeMB}MB`);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (validFiles.length > 0) {
|
|
40
|
+
onFilesSelected?.(validFiles);
|
|
41
|
+
upload(validFiles);
|
|
42
|
+
}
|
|
43
|
+
}, [upload, maxSizeMB, onFilesSelected]);
|
|
44
|
+
|
|
45
|
+
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
e.stopPropagation();
|
|
48
|
+
dragCounter.current++;
|
|
49
|
+
if (e.dataTransfer.items?.length) {
|
|
50
|
+
setIsDragging(true);
|
|
51
|
+
}
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
e.stopPropagation();
|
|
57
|
+
dragCounter.current--;
|
|
58
|
+
if (dragCounter.current === 0) {
|
|
59
|
+
setIsDragging(false);
|
|
60
|
+
}
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
e.stopPropagation();
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
e.stopPropagation();
|
|
71
|
+
dragCounter.current = 0;
|
|
72
|
+
setIsDragging(false);
|
|
73
|
+
|
|
74
|
+
if (!disabled && e.dataTransfer.files?.length) {
|
|
75
|
+
handleFiles(e.dataTransfer.files);
|
|
76
|
+
}
|
|
77
|
+
}, [disabled, handleFiles]);
|
|
78
|
+
|
|
79
|
+
const handleClick = useCallback(() => {
|
|
80
|
+
if (!disabled) {
|
|
81
|
+
inputRef.current?.click();
|
|
82
|
+
}
|
|
83
|
+
}, [disabled]);
|
|
84
|
+
|
|
85
|
+
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
86
|
+
if (e.target.files?.length) {
|
|
87
|
+
handleFiles(e.target.files);
|
|
88
|
+
}
|
|
89
|
+
// Reset input to allow re-selecting same file
|
|
90
|
+
e.target.value = '';
|
|
91
|
+
}, [handleFiles]);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
onClick={handleClick}
|
|
96
|
+
onDragEnter={handleDragEnter}
|
|
97
|
+
onDragLeave={handleDragLeave}
|
|
98
|
+
onDragOver={handleDragOver}
|
|
99
|
+
onDrop={handleDrop}
|
|
100
|
+
className={cn(
|
|
101
|
+
'relative flex flex-col items-center justify-center',
|
|
102
|
+
'border-2 border-dashed rounded-lg cursor-pointer',
|
|
103
|
+
'transition-colors duration-200',
|
|
104
|
+
compact ? 'p-4' : 'p-8',
|
|
105
|
+
isDragging
|
|
106
|
+
? 'border-primary bg-primary/5'
|
|
107
|
+
: 'border-muted-foreground/25 hover:border-muted-foreground/50',
|
|
108
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
109
|
+
className
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
<input
|
|
113
|
+
ref={inputRef}
|
|
114
|
+
type="file"
|
|
115
|
+
accept={acceptString}
|
|
116
|
+
multiple={multiple}
|
|
117
|
+
onChange={handleInputChange}
|
|
118
|
+
className="hidden"
|
|
119
|
+
disabled={disabled}
|
|
120
|
+
/>
|
|
121
|
+
|
|
122
|
+
{children || (
|
|
123
|
+
<>
|
|
124
|
+
<Upload className={cn(
|
|
125
|
+
'text-muted-foreground mb-2',
|
|
126
|
+
compact ? 'h-6 w-6' : 'h-10 w-10'
|
|
127
|
+
)} />
|
|
128
|
+
<p className={cn(
|
|
129
|
+
'text-muted-foreground text-center',
|
|
130
|
+
compact ? 'text-sm' : 'text-base'
|
|
131
|
+
)}>
|
|
132
|
+
{isDragging
|
|
133
|
+
? 'Drop files here'
|
|
134
|
+
: 'Drag & drop files or click to browse'}
|
|
135
|
+
</p>
|
|
136
|
+
{!compact && (
|
|
137
|
+
<p className="text-xs text-muted-foreground/60 mt-1">
|
|
138
|
+
Max {maxSizeMB}MB per file
|
|
139
|
+
</p>
|
|
140
|
+
)}
|
|
141
|
+
</>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useUploady } from '@rpldy/uploady';
|
|
5
|
+
import { Upload } from 'lucide-react';
|
|
6
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
|
+
import { logger } from '../utils';
|
|
8
|
+
import type { AssetType } from '../types';
|
|
9
|
+
|
|
10
|
+
export interface UploadPageDropOverlayProps {
|
|
11
|
+
/** Allowed asset types */
|
|
12
|
+
accept?: AssetType[];
|
|
13
|
+
/** Max file size in MB */
|
|
14
|
+
maxSizeMB?: number;
|
|
15
|
+
/** Custom overlay content */
|
|
16
|
+
children?: React.ReactNode;
|
|
17
|
+
/** Custom class for overlay */
|
|
18
|
+
className?: string;
|
|
19
|
+
/** Callback when files are dropped */
|
|
20
|
+
onFilesDropped?: (files: File[]) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function UploadPageDropOverlay({
|
|
24
|
+
accept = ['image', 'audio', 'video', 'document'],
|
|
25
|
+
maxSizeMB = 100,
|
|
26
|
+
children,
|
|
27
|
+
className,
|
|
28
|
+
onFilesDropped,
|
|
29
|
+
}: UploadPageDropOverlayProps) {
|
|
30
|
+
const { upload } = useUploady();
|
|
31
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
32
|
+
const dragCounter = useRef(0);
|
|
33
|
+
|
|
34
|
+
const handleFiles = useCallback((files: FileList | File[]) => {
|
|
35
|
+
const fileArray = Array.from(files);
|
|
36
|
+
const maxBytes = maxSizeMB * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
const validFiles = fileArray.filter(file => {
|
|
39
|
+
if (file.size > maxBytes) {
|
|
40
|
+
logger.warn(`File ${file.name} exceeds max size of ${maxSizeMB}MB`);
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (validFiles.length > 0) {
|
|
47
|
+
logger.info(`Page drop: ${validFiles.length} file(s)`);
|
|
48
|
+
onFilesDropped?.(validFiles);
|
|
49
|
+
upload(validFiles);
|
|
50
|
+
}
|
|
51
|
+
}, [upload, maxSizeMB, onFilesDropped]);
|
|
52
|
+
|
|
53
|
+
const handleDragEnter = useCallback((e: DragEvent) => {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
e.stopPropagation();
|
|
56
|
+
dragCounter.current++;
|
|
57
|
+
|
|
58
|
+
if (e.dataTransfer?.items?.length) {
|
|
59
|
+
setIsDragging(true);
|
|
60
|
+
}
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const handleDragLeave = useCallback((e: DragEvent) => {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
e.stopPropagation();
|
|
66
|
+
dragCounter.current--;
|
|
67
|
+
|
|
68
|
+
if (dragCounter.current === 0) {
|
|
69
|
+
setIsDragging(false);
|
|
70
|
+
}
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const handleDragOver = useCallback((e: DragEvent) => {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
e.stopPropagation();
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const handleDrop = useCallback((e: DragEvent) => {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
e.stopPropagation();
|
|
81
|
+
dragCounter.current = 0;
|
|
82
|
+
setIsDragging(false);
|
|
83
|
+
|
|
84
|
+
if (e.dataTransfer?.files?.length) {
|
|
85
|
+
handleFiles(e.dataTransfer.files);
|
|
86
|
+
}
|
|
87
|
+
}, [handleFiles]);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
document.addEventListener('dragenter', handleDragEnter);
|
|
91
|
+
document.addEventListener('dragleave', handleDragLeave);
|
|
92
|
+
document.addEventListener('dragover', handleDragOver);
|
|
93
|
+
document.addEventListener('drop', handleDrop);
|
|
94
|
+
|
|
95
|
+
return () => {
|
|
96
|
+
document.removeEventListener('dragenter', handleDragEnter);
|
|
97
|
+
document.removeEventListener('dragleave', handleDragLeave);
|
|
98
|
+
document.removeEventListener('dragover', handleDragOver);
|
|
99
|
+
document.removeEventListener('drop', handleDrop);
|
|
100
|
+
};
|
|
101
|
+
}, [handleDragEnter, handleDragLeave, handleDragOver, handleDrop]);
|
|
102
|
+
|
|
103
|
+
if (!isDragging) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
className={cn(
|
|
110
|
+
'fixed inset-0 z-50',
|
|
111
|
+
'bg-background/80 backdrop-blur-sm',
|
|
112
|
+
'flex items-center justify-center',
|
|
113
|
+
'pointer-events-none',
|
|
114
|
+
className
|
|
115
|
+
)}
|
|
116
|
+
>
|
|
117
|
+
{children || (
|
|
118
|
+
<div className="flex flex-col items-center gap-4 p-8 rounded-xl border-2 border-dashed border-primary bg-background/90">
|
|
119
|
+
<Upload className="h-16 w-16 text-primary" />
|
|
120
|
+
<div className="text-center">
|
|
121
|
+
<p className="text-lg font-medium">Drop files anywhere</p>
|
|
122
|
+
<p className="text-sm text-muted-foreground">
|
|
123
|
+
Release to upload
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo, useCallback } from 'react';
|
|
4
|
+
import { X, CheckCircle, AlertCircle, Loader2, FileIcon, ImageIcon, FileAudio, FileVideo, RotateCcw } from 'lucide-react';
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
import {
|
|
7
|
+
Button,
|
|
8
|
+
Progress,
|
|
9
|
+
Badge,
|
|
10
|
+
Tooltip,
|
|
11
|
+
TooltipContent,
|
|
12
|
+
TooltipTrigger,
|
|
13
|
+
Skeleton,
|
|
14
|
+
} from '@djangocfg/ui-core/components';
|
|
15
|
+
import { formatFileSize } from '../utils';
|
|
16
|
+
import type { UploadPreviewItemProps, AssetType } from '../types';
|
|
17
|
+
|
|
18
|
+
const ASSET_ICONS: Record<AssetType, typeof FileIcon> = {
|
|
19
|
+
image: ImageIcon,
|
|
20
|
+
audio: FileAudio,
|
|
21
|
+
video: FileVideo,
|
|
22
|
+
document: FileIcon,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function UploadPreviewItem({
|
|
26
|
+
item,
|
|
27
|
+
onRemove,
|
|
28
|
+
onRetry,
|
|
29
|
+
showThumbnail = true,
|
|
30
|
+
}: UploadPreviewItemProps) {
|
|
31
|
+
const { id, file, status, progress, previewUrl, error } = item;
|
|
32
|
+
|
|
33
|
+
const assetType = useMemo((): AssetType => {
|
|
34
|
+
if (file.type.startsWith('image/')) return 'image';
|
|
35
|
+
if (file.type.startsWith('audio/')) return 'audio';
|
|
36
|
+
if (file.type.startsWith('video/')) return 'video';
|
|
37
|
+
return 'document';
|
|
38
|
+
}, [file.type]);
|
|
39
|
+
|
|
40
|
+
const Icon = ASSET_ICONS[assetType];
|
|
41
|
+
const canShowPreview = showThumbnail && previewUrl && assetType === 'image';
|
|
42
|
+
|
|
43
|
+
const handleRemove = useCallback(() => {
|
|
44
|
+
onRemove?.(id);
|
|
45
|
+
}, [id, onRemove]);
|
|
46
|
+
|
|
47
|
+
const handleRetry = useCallback(() => {
|
|
48
|
+
onRetry?.(id);
|
|
49
|
+
}, [id, onRetry]);
|
|
50
|
+
|
|
51
|
+
const canRetry = (status === 'error' || status === 'aborted') && onRetry;
|
|
52
|
+
|
|
53
|
+
const progressPercent = Math.round(progress);
|
|
54
|
+
|
|
55
|
+
const statusBadge = useMemo(() => {
|
|
56
|
+
switch (status) {
|
|
57
|
+
case 'uploading':
|
|
58
|
+
return (
|
|
59
|
+
<Badge variant="secondary" className="gap-1 tabular-nums">
|
|
60
|
+
<Loader2 className="h-3 w-3 animate-spin" />
|
|
61
|
+
{progressPercent}%
|
|
62
|
+
</Badge>
|
|
63
|
+
);
|
|
64
|
+
case 'complete':
|
|
65
|
+
return (
|
|
66
|
+
<Badge variant="default" className="gap-1 bg-green-500">
|
|
67
|
+
<CheckCircle className="h-3 w-3" />
|
|
68
|
+
Done
|
|
69
|
+
</Badge>
|
|
70
|
+
);
|
|
71
|
+
case 'error':
|
|
72
|
+
return (
|
|
73
|
+
<Badge variant="destructive" className="gap-1">
|
|
74
|
+
<AlertCircle className="h-3 w-3" />
|
|
75
|
+
Error
|
|
76
|
+
</Badge>
|
|
77
|
+
);
|
|
78
|
+
case 'aborted':
|
|
79
|
+
return (
|
|
80
|
+
<Badge variant="secondary" className="gap-1">
|
|
81
|
+
Cancelled
|
|
82
|
+
</Badge>
|
|
83
|
+
);
|
|
84
|
+
default:
|
|
85
|
+
return (
|
|
86
|
+
<Badge variant="outline">
|
|
87
|
+
Pending
|
|
88
|
+
</Badge>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}, [status, progressPercent]);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className={cn(
|
|
95
|
+
'flex items-center gap-3 p-3 rounded-lg border',
|
|
96
|
+
'bg-card',
|
|
97
|
+
error && 'border-destructive/50'
|
|
98
|
+
)}>
|
|
99
|
+
{/* Thumbnail / Icon */}
|
|
100
|
+
<div className="flex-shrink-0 w-12 h-12 rounded-md overflow-hidden bg-muted flex items-center justify-center">
|
|
101
|
+
{canShowPreview ? (
|
|
102
|
+
previewUrl ? (
|
|
103
|
+
<img
|
|
104
|
+
src={previewUrl}
|
|
105
|
+
alt={file.name}
|
|
106
|
+
className="w-full h-full object-cover"
|
|
107
|
+
/>
|
|
108
|
+
) : (
|
|
109
|
+
<Skeleton className="w-full h-full" />
|
|
110
|
+
)
|
|
111
|
+
) : (
|
|
112
|
+
<Icon className="h-6 w-6 text-muted-foreground" />
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{/* Info */}
|
|
117
|
+
<div className="flex-1 min-w-0">
|
|
118
|
+
<Tooltip>
|
|
119
|
+
<TooltipTrigger asChild>
|
|
120
|
+
<p className="text-sm font-medium truncate cursor-default">{file.name}</p>
|
|
121
|
+
</TooltipTrigger>
|
|
122
|
+
<TooltipContent side="top" className="max-w-xs">
|
|
123
|
+
<p className="break-all">{file.name}</p>
|
|
124
|
+
</TooltipContent>
|
|
125
|
+
</Tooltip>
|
|
126
|
+
<p className="text-xs text-muted-foreground tabular-nums">
|
|
127
|
+
{formatFileSize(file.size)}
|
|
128
|
+
</p>
|
|
129
|
+
{status === 'uploading' && (
|
|
130
|
+
<Progress value={progressPercent} className="h-1 mt-1" />
|
|
131
|
+
)}
|
|
132
|
+
{error && (
|
|
133
|
+
<Tooltip>
|
|
134
|
+
<TooltipTrigger asChild>
|
|
135
|
+
<p className="text-xs text-destructive mt-1 truncate cursor-default">{error}</p>
|
|
136
|
+
</TooltipTrigger>
|
|
137
|
+
<TooltipContent side="bottom" className="max-w-xs">
|
|
138
|
+
<p className="break-all">{error}</p>
|
|
139
|
+
</TooltipContent>
|
|
140
|
+
</Tooltip>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{/* Status & Actions */}
|
|
145
|
+
<div className="flex items-center gap-2">
|
|
146
|
+
{statusBadge}
|
|
147
|
+
{canRetry && (
|
|
148
|
+
<Tooltip>
|
|
149
|
+
<TooltipTrigger asChild>
|
|
150
|
+
<Button
|
|
151
|
+
variant="ghost"
|
|
152
|
+
size="icon"
|
|
153
|
+
className="h-8 w-8"
|
|
154
|
+
onClick={handleRetry}
|
|
155
|
+
>
|
|
156
|
+
<RotateCcw className="h-4 w-4" />
|
|
157
|
+
</Button>
|
|
158
|
+
</TooltipTrigger>
|
|
159
|
+
<TooltipContent>Retry upload</TooltipContent>
|
|
160
|
+
</Tooltip>
|
|
161
|
+
)}
|
|
162
|
+
{(status === 'pending' || status === 'uploading' || status === 'error' || status === 'aborted') && onRemove && (
|
|
163
|
+
<Tooltip>
|
|
164
|
+
<TooltipTrigger asChild>
|
|
165
|
+
<Button
|
|
166
|
+
variant="ghost"
|
|
167
|
+
size="icon"
|
|
168
|
+
className="h-8 w-8"
|
|
169
|
+
onClick={handleRemove}
|
|
170
|
+
>
|
|
171
|
+
<X className="h-4 w-4" />
|
|
172
|
+
</Button>
|
|
173
|
+
</TooltipTrigger>
|
|
174
|
+
<TooltipContent>
|
|
175
|
+
{status === 'uploading' ? 'Cancel upload' : 'Remove'}
|
|
176
|
+
</TooltipContent>
|
|
177
|
+
</Tooltip>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
useBatchAddListener,
|
|
6
|
+
useItemProgressListener,
|
|
7
|
+
useItemFinishListener,
|
|
8
|
+
useItemErrorListener,
|
|
9
|
+
useItemAbortListener,
|
|
10
|
+
useAbortItem,
|
|
11
|
+
useUploady,
|
|
12
|
+
} from '@rpldy/uploady';
|
|
13
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
14
|
+
import { UploadPreviewItem } from './UploadPreviewItem';
|
|
15
|
+
import {
|
|
16
|
+
buildAssetFromResponse,
|
|
17
|
+
extractErrorMessage,
|
|
18
|
+
} from '../utils';
|
|
19
|
+
import type { UploadItem, UploadPreviewListProps } from '../types';
|
|
20
|
+
|
|
21
|
+
export function UploadPreviewList({
|
|
22
|
+
className,
|
|
23
|
+
onRemove,
|
|
24
|
+
onRetry,
|
|
25
|
+
showThumbnails = true,
|
|
26
|
+
}: UploadPreviewListProps) {
|
|
27
|
+
const [items, setItems] = useState<Map<string, UploadItem>>(new Map());
|
|
28
|
+
const abortItem = useAbortItem();
|
|
29
|
+
const { upload } = useUploady();
|
|
30
|
+
|
|
31
|
+
// Batch added - create initial items
|
|
32
|
+
useBatchAddListener((batch) => {
|
|
33
|
+
setItems(prev => {
|
|
34
|
+
const next = new Map(prev);
|
|
35
|
+
batch.items.forEach(item => {
|
|
36
|
+
const file = item.file as File;
|
|
37
|
+
let previewUrl: string | undefined;
|
|
38
|
+
|
|
39
|
+
// Create object URL for image preview
|
|
40
|
+
if (file.type.startsWith('image/')) {
|
|
41
|
+
previewUrl = URL.createObjectURL(file);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
next.set(item.id, {
|
|
45
|
+
id: item.id,
|
|
46
|
+
file,
|
|
47
|
+
status: 'pending',
|
|
48
|
+
progress: 0,
|
|
49
|
+
previewUrl,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
return next;
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Progress update
|
|
57
|
+
useItemProgressListener((item) => {
|
|
58
|
+
setItems(prev => {
|
|
59
|
+
const next = new Map(prev);
|
|
60
|
+
const existing = next.get(item.id);
|
|
61
|
+
if (existing) {
|
|
62
|
+
next.set(item.id, {
|
|
63
|
+
...existing,
|
|
64
|
+
status: 'uploading',
|
|
65
|
+
progress: item.completed,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return next;
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Upload complete
|
|
73
|
+
useItemFinishListener((item) => {
|
|
74
|
+
setItems(prev => {
|
|
75
|
+
const next = new Map(prev);
|
|
76
|
+
const existing = next.get(item.id);
|
|
77
|
+
if (existing) {
|
|
78
|
+
const response = item.uploadResponse?.data as Record<string, unknown> | undefined;
|
|
79
|
+
const asset = response
|
|
80
|
+
? buildAssetFromResponse(response, existing.file)
|
|
81
|
+
: undefined;
|
|
82
|
+
|
|
83
|
+
next.set(item.id, {
|
|
84
|
+
...existing,
|
|
85
|
+
status: 'complete',
|
|
86
|
+
progress: 100,
|
|
87
|
+
asset,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return next;
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Error
|
|
95
|
+
useItemErrorListener((item) => {
|
|
96
|
+
setItems(prev => {
|
|
97
|
+
const next = new Map(prev);
|
|
98
|
+
const existing = next.get(item.id);
|
|
99
|
+
if (existing) {
|
|
100
|
+
const response = item.uploadResponse?.data as Record<string, unknown> | null;
|
|
101
|
+
const status = item.uploadResponse?.status || 0;
|
|
102
|
+
const error = extractErrorMessage(response, status);
|
|
103
|
+
|
|
104
|
+
next.set(item.id, {
|
|
105
|
+
...existing,
|
|
106
|
+
status: 'error',
|
|
107
|
+
error,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return next;
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Aborted
|
|
115
|
+
useItemAbortListener((item) => {
|
|
116
|
+
setItems(prev => {
|
|
117
|
+
const next = new Map(prev);
|
|
118
|
+
const existing = next.get(item.id);
|
|
119
|
+
if (existing) {
|
|
120
|
+
next.set(item.id, {
|
|
121
|
+
...existing,
|
|
122
|
+
status: 'aborted',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return next;
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Handle remove
|
|
130
|
+
const handleRemove = useCallback((id: string) => {
|
|
131
|
+
const item = items.get(id);
|
|
132
|
+
if (item) {
|
|
133
|
+
// Abort if uploading
|
|
134
|
+
if (item.status === 'uploading') {
|
|
135
|
+
abortItem(id);
|
|
136
|
+
}
|
|
137
|
+
// Revoke object URL
|
|
138
|
+
if (item.previewUrl) {
|
|
139
|
+
URL.revokeObjectURL(item.previewUrl);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setItems(prev => {
|
|
144
|
+
const next = new Map(prev);
|
|
145
|
+
next.delete(id);
|
|
146
|
+
return next;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
onRemove?.(id);
|
|
150
|
+
}, [items, abortItem, onRemove]);
|
|
151
|
+
|
|
152
|
+
// Handle retry
|
|
153
|
+
const handleRetry = useCallback((id: string) => {
|
|
154
|
+
const item = items.get(id);
|
|
155
|
+
if (item && (item.status === 'error' || item.status === 'aborted')) {
|
|
156
|
+
// Reset status to pending
|
|
157
|
+
setItems(prev => {
|
|
158
|
+
const next = new Map(prev);
|
|
159
|
+
next.set(id, {
|
|
160
|
+
...item,
|
|
161
|
+
status: 'pending',
|
|
162
|
+
progress: 0,
|
|
163
|
+
error: undefined,
|
|
164
|
+
});
|
|
165
|
+
return next;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Re-upload the file
|
|
169
|
+
upload(item.file);
|
|
170
|
+
onRetry?.(id);
|
|
171
|
+
}
|
|
172
|
+
}, [items, upload, onRetry]);
|
|
173
|
+
|
|
174
|
+
// Cleanup object URLs on unmount
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
return () => {
|
|
177
|
+
items.forEach(item => {
|
|
178
|
+
if (item.previewUrl) {
|
|
179
|
+
URL.revokeObjectURL(item.previewUrl);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
const itemsArray = Array.from(items.values());
|
|
186
|
+
|
|
187
|
+
if (itemsArray.length === 0) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div className={cn('space-y-2', className)}>
|
|
193
|
+
{itemsArray.map(item => (
|
|
194
|
+
<UploadPreviewItem
|
|
195
|
+
key={item.id}
|
|
196
|
+
item={item}
|
|
197
|
+
onRemove={handleRemove}
|
|
198
|
+
onRetry={handleRetry}
|
|
199
|
+
showThumbnail={showThumbnails}
|
|
200
|
+
/>
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|