@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.
@@ -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
+ }