generator-pninja 2.0.0 → 2.0.2
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/generators/client/templates/react/public/locales/common/am.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/ar.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/az.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/bg.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/bn.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/ca.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/cs.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/cy.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/da.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/de.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/el.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/en.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/es.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/et.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/eu.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/fa.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/fi.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/fr.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/fy.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/ga.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/gl.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/gu.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/ha.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/he.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/hi.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/hr.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/ht.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/hu.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/hy.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/id.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/is.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/it.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/ja.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/kk.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/ko.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/lt.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/lv.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/mi.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/ms.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/mt.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/nl.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/no.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/om.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/pl.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/pt.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/ro.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/ru.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/rw.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/sk.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/sl.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/sq.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/sr.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/sv.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/sw.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/ta.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/tr.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/uk.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/ur.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/uz.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/vi.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/yo.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/zh.json.ejs +7 -2
- package/generators/client/templates/react/public/locales/common/zu.json.ejs +7 -2
- package/generators/client/templates/react/src/components/BlobViewer.tsx.ejs +63 -33
- package/generators/client/templates/react/src/components/entities/EntityForm.tsx.ejs +1 -0
- package/generators/client/templates/react/src/components/formElements/FileField.tsx.ejs +267 -110
- package/generators/client/templates/react/src/index.css.ejs +8 -0
- package/generators/client/templates/react/src/pages/entities/EntityEdit.tsx.ejs +21 -21
- package/package.json +1 -1
|
@@ -26,35 +26,40 @@ function base64ToUint8Array(base64: string): Uint8Array {
|
|
|
26
26
|
return bytes;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
const linkClass =
|
|
30
|
+
'inline-block rounded-sm hover:outline-2 hover:outline-offset-2 hover:outline-blue-600 hover:dark:outline-sky-300 focus:outline-2 focus:outline-offset-2 focus:outline-blue-600 focus:dark:outline-sky-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 focus-visible:dark:outline-sky-300';
|
|
31
|
+
|
|
29
32
|
// ─── Image viewer ────────────────────────────────────────────────────────────
|
|
30
33
|
|
|
31
34
|
const ImageViewer: React.FC<{ objectUrl: string; name: string }> = ({ objectUrl, name }) => (
|
|
32
|
-
<
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
</a>
|
|
40
|
-
</div>
|
|
35
|
+
<a href={objectUrl} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
|
36
|
+
<img
|
|
37
|
+
src={objectUrl}
|
|
38
|
+
alt={name}
|
|
39
|
+
className="max-w-xs max-h-80 rounded-sm border border-gray-200 dark:border-gray-700"
|
|
40
|
+
/>
|
|
41
|
+
</a>
|
|
41
42
|
);
|
|
42
43
|
|
|
43
44
|
// ─── Video viewer ────────────────────────────────────────────────────────────
|
|
44
45
|
|
|
45
46
|
const VideoViewer: React.FC<{ objectUrl: string; name: string }> = ({ objectUrl, name }) => (
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
47
|
+
<a
|
|
48
|
+
href={objectUrl}
|
|
49
|
+
target="_blank"
|
|
50
|
+
rel="noopener noreferrer"
|
|
51
|
+
// className="h-full inline-block relative"
|
|
52
|
+
className={`relative ${linkClass}`}
|
|
53
|
+
>
|
|
54
|
+
<video
|
|
55
|
+
src={objectUrl}
|
|
56
|
+
preload="metadata"
|
|
57
|
+
className="max-w-xs max-h-80 rounded-sm border border-gray-200 dark:border-gray-700"
|
|
58
|
+
/>
|
|
59
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
|
60
|
+
<Icon icon="play_circle" size={12} className="text-white drop-shadow" />
|
|
61
|
+
</div>
|
|
62
|
+
</a>
|
|
58
63
|
);
|
|
59
64
|
|
|
60
65
|
// ─── PDF viewer ──────────────────────────────────────────────────────────────
|
|
@@ -120,20 +125,20 @@ const PdfViewer: React.FC<{ blob: BlobValue; objectUrl: string }> = ({ blob, obj
|
|
|
120
125
|
}, [blob.data]);
|
|
121
126
|
|
|
122
127
|
return (
|
|
123
|
-
|
|
128
|
+
<>
|
|
124
129
|
{thumbnailUrl && !pdfError && (
|
|
125
|
-
<a href={objectUrl} target="_blank" rel="noopener noreferrer" className=
|
|
130
|
+
<a href={objectUrl} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
|
126
131
|
<img
|
|
127
132
|
src={thumbnailUrl}
|
|
128
133
|
alt={blob.name}
|
|
129
|
-
className="max-w-xs rounded border border-gray-200 dark:border-gray-700"
|
|
134
|
+
className="max-w-xs rounded-sm border border-gray-200 dark:border-gray-700"
|
|
130
135
|
/>
|
|
131
136
|
</a>
|
|
132
137
|
)}
|
|
133
138
|
{(pdfError || !thumbnailUrl) && (
|
|
134
139
|
<Icon icon="picture_as_pdf" size={10} className="text-red-500" />
|
|
135
140
|
)}
|
|
136
|
-
|
|
141
|
+
</>
|
|
137
142
|
);
|
|
138
143
|
};
|
|
139
144
|
|
|
@@ -174,9 +179,11 @@ const AudioViewer: React.FC<{ objectUrl: string; name: string }> = ({ objectUrl,
|
|
|
174
179
|
interface BlobViewerProps {
|
|
175
180
|
blob: BlobValue | null | undefined;
|
|
176
181
|
className?: string;
|
|
182
|
+
/** When true, suppresses the filename/size/link bar below the preview */
|
|
183
|
+
compact?: boolean;
|
|
177
184
|
}
|
|
178
185
|
|
|
179
|
-
const BlobViewer: React.FC<BlobViewerProps> = ({ blob, className }) => {
|
|
186
|
+
const BlobViewer: React.FC<BlobViewerProps> = ({ blob, className, compact = false }) => {
|
|
180
187
|
const { t } = useTranslation();
|
|
181
188
|
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
|
182
189
|
|
|
@@ -196,15 +203,38 @@ const BlobViewer: React.FC<BlobViewerProps> = ({ blob, className }) => {
|
|
|
196
203
|
const isAudio = blob.type.startsWith('audio/');
|
|
197
204
|
const isPdf = blob.type === 'application/pdf';
|
|
198
205
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
{
|
|
202
|
-
{
|
|
203
|
-
{
|
|
204
|
-
{
|
|
205
|
-
{
|
|
206
|
+
const preview = objectUrl && (
|
|
207
|
+
<>
|
|
208
|
+
{isInlineImage && <ImageViewer objectUrl={objectUrl} name={blob.name} />}
|
|
209
|
+
{isVideo && <VideoViewer objectUrl={objectUrl} name={blob.name} />}
|
|
210
|
+
{isPdf && <PdfViewer blob={blob} objectUrl={objectUrl} />}
|
|
211
|
+
{isAudio && <AudioViewer objectUrl={objectUrl} name={blob.name} />}
|
|
212
|
+
{!isInlineImage && !isVideo && !isPdf && !isAudio && (
|
|
206
213
|
<GenericViewer objectUrl={objectUrl} name={blob.name} />
|
|
207
214
|
)}
|
|
215
|
+
</>
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (compact && !isInlineImage && !isVideo && !isPdf) return null;
|
|
219
|
+
|
|
220
|
+
if (compact) {
|
|
221
|
+
return (
|
|
222
|
+
<div
|
|
223
|
+
className={[
|
|
224
|
+
'w-20 h-20 p-1 outline-2 -outline-offset-2 focus-within:outline-0 hover:outline-0 flex-shrink-0 rounded-md overflow-hidden bg-slate-200 dark:bg-slate-600 flex items-center justify-center [&_img]:w-full [&_img]:h-full [&_img]:object-cover [&_video]:w-full [&_video]:h-full [&_video]:object-cover [&_a]:w-full [&_a]:h-full',
|
|
225
|
+
className,
|
|
226
|
+
]
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
.join(' ')}
|
|
229
|
+
>
|
|
230
|
+
{preview}
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div className={className}>
|
|
237
|
+
{preview}
|
|
208
238
|
{objectUrl && (
|
|
209
239
|
<div className="flex items-center gap-3 mt-1">
|
|
210
240
|
<span className="text-xs text-gray-500 truncate max-w-xs">{blob.name}</span>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useTranslation } from 'react-i18next';
|
|
2
2
|
import prettyBytes from 'pretty-bytes';
|
|
3
3
|
import { Icon } from '../Icon';
|
|
4
|
-
import { useState, useRef } from 'react';
|
|
4
|
+
import { useState, useEffect, useRef } from 'react';
|
|
5
5
|
import { useFormContext, useController, FieldError } from 'react-hook-form';
|
|
6
6
|
import { FormLabel, FormFieldInfo } from './index';
|
|
7
7
|
import { useId } from 'react-aria';
|
|
8
|
+
import { DropZone as AriaDropZone, FileTrigger, Button } from 'react-aria-components';
|
|
9
|
+
import { BlobViewer } from '../BlobViewer';
|
|
8
10
|
|
|
9
11
|
type BlobValue = {
|
|
10
12
|
data: string;
|
|
@@ -45,22 +47,170 @@ function fileToBase64(file: File): Promise<string> {
|
|
|
45
47
|
});
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
const INLINE_IMAGE_TYPES = [
|
|
51
|
+
'image/jpeg',
|
|
52
|
+
'image/png',
|
|
53
|
+
'image/gif',
|
|
54
|
+
'image/webp',
|
|
55
|
+
'image/svg+xml',
|
|
56
|
+
'image/avif',
|
|
57
|
+
'image/apng',
|
|
58
|
+
];
|
|
59
|
+
|
|
48
60
|
const getFileIcon = (mimeType: string | undefined): JSX.Element => {
|
|
49
|
-
if (!mimeType) {
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
if (mimeType.startsWith('
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
if (mimeType.startsWith('video/')) {
|
|
56
|
-
return <Icon icon="video_file" size={6} fill />;
|
|
57
|
-
}
|
|
58
|
-
if (mimeType.startsWith('audio/')) {
|
|
59
|
-
return <Icon icon="audio_file" size={6} fill />;
|
|
60
|
-
}
|
|
61
|
+
if (!mimeType) return <Icon icon="draft" size={6} fill />;
|
|
62
|
+
if (mimeType.startsWith('image/')) return <Icon icon="imagesmode" size={6} fill />;
|
|
63
|
+
if (mimeType.startsWith('video/')) return <Icon icon="video_file" size={6} fill />;
|
|
64
|
+
if (mimeType.startsWith('audio/')) return <Icon icon="audio_file" size={6} fill />;
|
|
65
|
+
if (mimeType === 'application/pdf') return <Icon icon="picture_as_pdf" size={6} fill />;
|
|
61
66
|
return <Icon icon="draft" size={6} fill />;
|
|
62
67
|
};
|
|
63
68
|
|
|
69
|
+
// ─── FilePreview ──────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const FilePreview: React.FC<{
|
|
72
|
+
file: File;
|
|
73
|
+
onRemove: () => void;
|
|
74
|
+
}> = ({ file, onRemove }) => {
|
|
75
|
+
const { t } = useTranslation();
|
|
76
|
+
const [blobValue, setBlobValue] = useState<{ data: string; type: string; name: string; size: number } | null>(null);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
let cancelled = false;
|
|
80
|
+
const url = URL.createObjectURL(file);
|
|
81
|
+
// Convert to base64 for BlobViewer
|
|
82
|
+
fetch(url)
|
|
83
|
+
.then((r) => r.arrayBuffer())
|
|
84
|
+
.then((buf) => {
|
|
85
|
+
if (cancelled) return;
|
|
86
|
+
const bytes = new Uint8Array(buf);
|
|
87
|
+
let binary = '';
|
|
88
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
89
|
+
setBlobValue({
|
|
90
|
+
data: btoa(binary),
|
|
91
|
+
type: file.type,
|
|
92
|
+
name: file.name,
|
|
93
|
+
size: file.size,
|
|
94
|
+
});
|
|
95
|
+
})
|
|
96
|
+
.catch(() => { if (!cancelled) setBlobValue(null); });
|
|
97
|
+
URL.revokeObjectURL(url);
|
|
98
|
+
return () => { cancelled = true; };
|
|
99
|
+
}, [file]);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div className="flex items-center gap-3 p-3 bg-slate-100 dark:bg-slate-700 rounded-md border border-slate-300 dark:border-slate-600">
|
|
103
|
+
{blobValue ? (
|
|
104
|
+
<BlobViewer blob={blobValue} compact />
|
|
105
|
+
) : (
|
|
106
|
+
<div className="w-20 h-20 flex-shrink-0 rounded bg-slate-200 dark:bg-slate-600 flex items-center justify-center">
|
|
107
|
+
{getFileIcon(file.type)}
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
<div className="flex-1 min-w-0">
|
|
111
|
+
<p className="text-sm font-medium truncate">{file.name}</p>
|
|
112
|
+
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{prettyBytes(file.size)}</p>
|
|
113
|
+
</div>
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={onRemove}
|
|
117
|
+
className="icon-text-button flex-shrink-0 flex items-center text-sm cursor-pointer font-medium gap-1 text-slate-600 dark:text-slate-300"
|
|
118
|
+
aria-label={t('actions.crud.file.removeSelection')}
|
|
119
|
+
>
|
|
120
|
+
<Icon icon="close" size={5} />
|
|
121
|
+
</button>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// ─── DropZone ─────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
const DropZone: React.FC<{
|
|
129
|
+
accept?: string;
|
|
130
|
+
isDisabled?: boolean;
|
|
131
|
+
onFileSelected: (file: File) => void;
|
|
132
|
+
hasError: boolean;
|
|
133
|
+
}> = ({ accept, isDisabled, onFileSelected, hasError }) => {
|
|
134
|
+
const { t } = useTranslation();
|
|
135
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
136
|
+
const dropZoneRef = useRef<HTMLDivElement>(null);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!dropZoneRef.current) return;
|
|
140
|
+
// RAC renders an internal button with aria-label for screen reader accessibility.
|
|
141
|
+
// We remove it from the tab order since our FileTrigger Button handles keyboard interaction.
|
|
142
|
+
// Screen readers can still reach it via arrow key navigation.
|
|
143
|
+
const internalButton = dropZoneRef.current.querySelector<HTMLElement>(
|
|
144
|
+
'button[data-react-aria-pressable][aria-label]'
|
|
145
|
+
);
|
|
146
|
+
if (internalButton) internalButton.tabIndex = -1;
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
const handleDrop = async (e: import('react-aria-components').DropEvent) => {
|
|
150
|
+
const droppedItem = e.items.find((item) => item.kind === 'file');
|
|
151
|
+
if (!droppedItem || droppedItem.kind !== 'file') return;
|
|
152
|
+
const file = await droppedItem.getFile();
|
|
153
|
+
onFileSelected(file);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<AriaDropZone
|
|
158
|
+
ref={dropZoneRef}
|
|
159
|
+
onDrop={handleDrop}
|
|
160
|
+
isDisabled={isDisabled}
|
|
161
|
+
tabIndex={-1}
|
|
162
|
+
className={({ isDropTarget }) =>
|
|
163
|
+
[
|
|
164
|
+
'rounded-md border-2 border-dashed transition-colors',
|
|
165
|
+
isDropTarget || isFocused
|
|
166
|
+
? hasError
|
|
167
|
+
? 'border-red-400 bg-red-50 dark:bg-red-900/20'
|
|
168
|
+
: 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
|
169
|
+
: hasError
|
|
170
|
+
? 'border-red-400 bg-red-50/30 dark:bg-red-900/10'
|
|
171
|
+
: 'border-slate-300 dark:border-slate-600',
|
|
172
|
+
isDisabled ? 'opacity-50' : '',
|
|
173
|
+
].join(' ')
|
|
174
|
+
}
|
|
175
|
+
>
|
|
176
|
+
{({ isDropTarget }) => (
|
|
177
|
+
<FileTrigger
|
|
178
|
+
acceptedFileTypes={accept ? [accept] : undefined}
|
|
179
|
+
isDisabled={isDisabled}
|
|
180
|
+
onSelect={(files) => {
|
|
181
|
+
const file = files?.[0];
|
|
182
|
+
if (file) onFileSelected(file);
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
<Button
|
|
186
|
+
isDisabled={isDisabled}
|
|
187
|
+
isInvalid={hasError}
|
|
188
|
+
className="drop-zone-button flex flex-col items-center justify-center gap-2 p-6"
|
|
189
|
+
onFocusChange={setIsFocused}
|
|
190
|
+
>
|
|
191
|
+
<Icon
|
|
192
|
+
icon="cloud_upload"
|
|
193
|
+
size={8}
|
|
194
|
+
className={isDropTarget || isFocused
|
|
195
|
+
? hasError ? 'text-red-400' : 'text-blue-500'
|
|
196
|
+
: 'text-slate-400'}
|
|
197
|
+
/>
|
|
198
|
+
<span className={`text-sm text-center ${isDropTarget || isFocused
|
|
199
|
+
? hasError ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'
|
|
200
|
+
: 'text-slate-600 dark:text-slate-400'}`}>
|
|
201
|
+
{isDropTarget
|
|
202
|
+
? t('actions.crud.file.dropHere')
|
|
203
|
+
: t('actions.crud.file.dragOrClick')}
|
|
204
|
+
</span>
|
|
205
|
+
</Button>
|
|
206
|
+
</FileTrigger>
|
|
207
|
+
)}
|
|
208
|
+
</AriaDropZone>
|
|
209
|
+
);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// ─── FileField ────────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
64
214
|
const FileField: React.FC<FileFieldProps> = ({
|
|
65
215
|
accept,
|
|
66
216
|
className,
|
|
@@ -76,63 +226,48 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
76
226
|
const hasFile = !!existingBlob?.name;
|
|
77
227
|
const [shouldShowInput, setShouldShowInput] = useState(!hasFile);
|
|
78
228
|
const [isMarkedForDeletion, setIsMarkedForDeletion] = useState(false);
|
|
229
|
+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
79
230
|
const { required, minbytes, maxbytes } = validations || {};
|
|
80
|
-
const inputRef = useRef<HTMLInputElement>(null);
|
|
81
231
|
const {
|
|
82
232
|
control,
|
|
83
|
-
register,
|
|
84
233
|
setValue,
|
|
85
234
|
trigger,
|
|
86
|
-
formState: { errors },
|
|
87
235
|
} = useFormContext();
|
|
88
236
|
|
|
89
237
|
const {
|
|
238
|
+
field: { ref: fieldRef },
|
|
90
239
|
fieldState: { error },
|
|
91
240
|
} = useController({
|
|
92
241
|
name: fieldName,
|
|
93
242
|
control,
|
|
94
|
-
rules: {
|
|
243
|
+
rules: {
|
|
244
|
+
validate: (value: unknown | undefined) => {
|
|
245
|
+
const fileData = value as BlobValue | null;
|
|
246
|
+
|
|
247
|
+
if (fileData?.type && !fileData.type.startsWith(accept?.replace(/\/\*$/, '') ?? '')) {
|
|
248
|
+
return `errors.validations.file.invalidType:${accept}:${fileData.type}`;
|
|
249
|
+
}
|
|
250
|
+
if (typeof fileData?.size === 'number' && minbytes && fileData?.size < minbytes) {
|
|
251
|
+
return `errors.validations.file.minSize:${prettyBytes(minbytes)}:${prettyBytes(fileData?.size)}`;
|
|
252
|
+
}
|
|
253
|
+
if (typeof fileData?.size === 'number' && maxbytes && fileData?.size > maxbytes) {
|
|
254
|
+
return `errors.validations.file.maxSize:${prettyBytes(maxbytes)}:${prettyBytes(fileData?.size)}`;
|
|
255
|
+
}
|
|
256
|
+
if (required && (isMarkedForDeletion || (!fileData && !existingBlob?.name))) {
|
|
257
|
+
return 'errors.validations.file.required';
|
|
258
|
+
}
|
|
259
|
+
return true;
|
|
260
|
+
},
|
|
261
|
+
},
|
|
95
262
|
});
|
|
96
263
|
|
|
97
264
|
const markForDeletion = (mark: boolean) => {
|
|
98
265
|
setIsMarkedForDeletion(mark);
|
|
99
266
|
setValue(fieldName, mark ? null : existingBlob);
|
|
100
|
-
setTimeout(() =>
|
|
101
|
-
trigger(fieldName);
|
|
102
|
-
}, 0);
|
|
267
|
+
setTimeout(() => trigger(fieldName), 0);
|
|
103
268
|
};
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
validate: (value: unknown | undefined) => {
|
|
107
|
-
const fileData = value as BlobValue | null;
|
|
108
|
-
|
|
109
|
-
if (fileData?.type && !fileData.type.startsWith(accept?.replace(/\/\*$/, '') ?? '')) {
|
|
110
|
-
return `errors.validations.file.invalidType:${accept}:${fileData.type}`;
|
|
111
|
-
}
|
|
112
|
-
if (typeof fileData?.size === 'number' && minbytes && fileData?.size < minbytes) {
|
|
113
|
-
return `errors.validations.file.minSize:${prettyBytes(minbytes)}:${prettyBytes(fileData?.size)}`;
|
|
114
|
-
}
|
|
115
|
-
if (typeof fileData?.size === 'number' && maxbytes && fileData?.size > maxbytes) {
|
|
116
|
-
return `errors.validations.file.maxSize:${prettyBytes(maxbytes)}:${prettyBytes(fileData?.size)}`;
|
|
117
|
-
}
|
|
118
|
-
if (required && (isMarkedForDeletion || (!fileData && !existingBlob?.name))) {
|
|
119
|
-
return 'errors.validations.file.required';
|
|
120
|
-
}
|
|
121
|
-
return true;
|
|
122
|
-
},
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
126
|
-
const file = e.target.files?.[0] || null;
|
|
127
|
-
|
|
128
|
-
if (!file) {
|
|
129
|
-
setValue(fieldName, null);
|
|
130
|
-
setTimeout(() => {
|
|
131
|
-
trigger(fieldName);
|
|
132
|
-
}, 0);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
|
|
269
|
+
const processFile = async (file: File) => {
|
|
270
|
+
setSelectedFile(file);
|
|
136
271
|
try {
|
|
137
272
|
const fileData = await fileToBase64(file);
|
|
138
273
|
const blobValue: BlobValue = {
|
|
@@ -141,60 +276,79 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
141
276
|
name: file.name,
|
|
142
277
|
size: file.size,
|
|
143
278
|
};
|
|
144
|
-
|
|
145
279
|
setValue(fieldName, blobValue);
|
|
146
|
-
setTimeout(() =>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
console.error('Error converting file to base64:', error);
|
|
280
|
+
setTimeout(() => trigger(fieldName), 0);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error('Error converting file to base64:', err);
|
|
283
|
+
setSelectedFile(null);
|
|
151
284
|
setValue(fieldName, null);
|
|
152
|
-
setTimeout(() =>
|
|
153
|
-
trigger(fieldName);
|
|
154
|
-
}, 0);
|
|
285
|
+
setTimeout(() => trigger(fieldName), 0);
|
|
155
286
|
}
|
|
156
287
|
};
|
|
157
288
|
|
|
289
|
+
const removeSelection = () => {
|
|
290
|
+
setSelectedFile(null);
|
|
291
|
+
setValue(fieldName, hasFile ? existingBlob : null);
|
|
292
|
+
setTimeout(() => trigger(fieldName), 0);
|
|
293
|
+
};
|
|
294
|
+
|
|
158
295
|
const undoEdit = () => {
|
|
159
296
|
setShouldShowInput(false);
|
|
297
|
+
setSelectedFile(null);
|
|
160
298
|
setValue(fieldName, existingBlob);
|
|
161
|
-
|
|
162
|
-
if (input) input.value = '';
|
|
163
|
-
setTimeout(() => {
|
|
164
|
-
trigger(fieldName);
|
|
165
|
-
}, 0);
|
|
299
|
+
setTimeout(() => trigger(fieldName), 0);
|
|
166
300
|
};
|
|
167
301
|
|
|
302
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
168
303
|
const fieldId = useId();
|
|
169
304
|
const labelId = useId();
|
|
170
305
|
const errorId = useId();
|
|
171
306
|
const descriptionId = useId();
|
|
172
307
|
|
|
308
|
+
const showDropZone = shouldShowInput && !isMarkedForDeletion;
|
|
309
|
+
|
|
173
310
|
return (
|
|
174
|
-
<div className={className}>
|
|
311
|
+
<div className={className} ref={containerRef}>
|
|
175
312
|
<FormLabel id={labelId} validations={validations}>
|
|
176
313
|
{label}
|
|
177
314
|
</FormLabel>
|
|
178
|
-
<div>
|
|
315
|
+
<div className="space-y-2">
|
|
316
|
+
{/* Focus anchor for RHF scroll-to-error */}
|
|
317
|
+
<span
|
|
318
|
+
ref={fieldRef}
|
|
319
|
+
tabIndex={-1}
|
|
320
|
+
className="sr-only"
|
|
321
|
+
aria-hidden="true"
|
|
322
|
+
onFocus={() => {
|
|
323
|
+
const firstButton = containerRef.current?.querySelector<HTMLElement>(
|
|
324
|
+
'button:not([tabindex="-1"]), [tabindex="0"]'
|
|
325
|
+
);
|
|
326
|
+
firstButton?.focus();
|
|
327
|
+
}}
|
|
328
|
+
/>
|
|
329
|
+
|
|
330
|
+
{/* File esistente */}
|
|
179
331
|
{hasFile && !shouldShowInput && !isMarkedForDeletion && (
|
|
180
|
-
<div className="p-3 bg-slate-200 text-slate-800 dark:bg-slate-600 dark:text-slate-200 rounded-md">
|
|
332
|
+
<div className="p-3 bg-slate-200 text-slate-800 dark:bg-slate-600 dark:text-slate-200 rounded-md space-y-2">
|
|
181
333
|
<div className="flex items-center justify-between">
|
|
182
334
|
<a
|
|
183
335
|
href={`${location.pathname.replace('entities', 'api').replace('edit/', '')}/${fieldName}/${existingBlob?.name}`}
|
|
184
|
-
className="hover:underline flex items-center gap-2"
|
|
336
|
+
className="icon-text-button hover:underline flex items-center gap-2 text-sm"
|
|
185
337
|
target="_blank"
|
|
186
338
|
rel="noopener noreferrer"
|
|
187
339
|
aria-label={`${t('actions.crud.file.viewOrDownload')} ${existingBlob?.name} (${t('common.opensInNewWindow')})`}
|
|
188
340
|
>
|
|
189
341
|
{getFileIcon(existingBlob?.type)}
|
|
190
|
-
{
|
|
191
|
-
{existingBlob?.
|
|
342
|
+
{existingBlob?.name}
|
|
343
|
+
{existingBlob?.size != null && (
|
|
344
|
+
<span className="text-xs opacity-70">({prettyBytes(existingBlob.size)})</span>
|
|
345
|
+
)}
|
|
192
346
|
</a>
|
|
193
347
|
<div className="flex items-center gap-2">
|
|
194
348
|
<button
|
|
195
349
|
type="button"
|
|
196
350
|
onClick={() => setShouldShowInput(true)}
|
|
197
|
-
className="flex items-center text-sm
|
|
351
|
+
className="icon-text-button flex items-center text-sm cursor-pointer font-medium gap-1"
|
|
198
352
|
>
|
|
199
353
|
<Icon icon="swap_horiz" size={5} />
|
|
200
354
|
{t('actions.crud.edit')}
|
|
@@ -202,16 +356,20 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
202
356
|
<button
|
|
203
357
|
type="button"
|
|
204
358
|
onClick={() => markForDeletion(true)}
|
|
205
|
-
className="flex items-center text-sm
|
|
359
|
+
className="icon-text-button flex items-center text-sm cursor-pointer font-medium gap-1"
|
|
206
360
|
>
|
|
207
361
|
<Icon icon="delete" size={5} />
|
|
208
362
|
{t('actions.crud.delete')}
|
|
209
363
|
</button>
|
|
210
364
|
</div>
|
|
211
365
|
</div>
|
|
366
|
+
{existingBlob && (
|
|
367
|
+
<BlobViewer blob={existingBlob} compact />
|
|
368
|
+
)}
|
|
212
369
|
</div>
|
|
213
370
|
)}
|
|
214
371
|
|
|
372
|
+
{/* Marcato per eliminazione */}
|
|
215
373
|
{isMarkedForDeletion && (
|
|
216
374
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
|
217
375
|
<div className="flex items-center justify-between">
|
|
@@ -224,7 +382,7 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
224
382
|
<button
|
|
225
383
|
type="button"
|
|
226
384
|
onClick={() => markForDeletion(false)}
|
|
227
|
-
className="flex items-center text-sm cursor-pointer font-medium gap-1"
|
|
385
|
+
className="icon-text-button flex items-center text-sm cursor-pointer font-medium gap-1"
|
|
228
386
|
>
|
|
229
387
|
<Icon icon="undo" size={5} />
|
|
230
388
|
{t('common.undo')}
|
|
@@ -233,45 +391,44 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
233
391
|
</div>
|
|
234
392
|
)}
|
|
235
393
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
</div>
|
|
246
|
-
<button
|
|
247
|
-
type="button"
|
|
248
|
-
onClick={() => undoEdit()}
|
|
249
|
-
className="flex items-center text-sm cursor-pointer font-medium gap-1"
|
|
250
|
-
>
|
|
251
|
-
<Icon icon="undo" size={5} />
|
|
252
|
-
{t('common.cancel')}
|
|
253
|
-
</button>
|
|
394
|
+
{/* Warning sostituzione */}
|
|
395
|
+
{hasFile && shouldShowInput && (
|
|
396
|
+
<div className="p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
|
|
397
|
+
<div className="flex items-center justify-between">
|
|
398
|
+
<div className="flex items-center text-yellow-800 dark:text-yellow-200 gap-2">
|
|
399
|
+
<Icon icon="info" size={6} />
|
|
400
|
+
<span className="text-sm font-medium">
|
|
401
|
+
{t('actions.crud.file.replaceWarning')}
|
|
402
|
+
</span>
|
|
254
403
|
</div>
|
|
404
|
+
<button
|
|
405
|
+
type="button"
|
|
406
|
+
onClick={undoEdit}
|
|
407
|
+
className="icon-text-button flex items-center text-sm cursor-pointer font-medium gap-1"
|
|
408
|
+
>
|
|
409
|
+
<Icon icon="undo" size={5} />
|
|
410
|
+
{t('common.cancel')}
|
|
411
|
+
</button>
|
|
255
412
|
</div>
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
accept={accept
|
|
268
|
-
{
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
className={errors?.[fieldName]?.message ? 'form-field-error' : 'form-field'}
|
|
413
|
+
</div>
|
|
414
|
+
)}
|
|
415
|
+
|
|
416
|
+
{/* Preview file selezionato */}
|
|
417
|
+
{showDropZone && selectedFile && (
|
|
418
|
+
<FilePreview file={selectedFile} onRemove={removeSelection} />
|
|
419
|
+
)}
|
|
420
|
+
|
|
421
|
+
{/* Drop zone — nascosta se file già selezionato */}
|
|
422
|
+
{showDropZone && !selectedFile && (
|
|
423
|
+
<DropZone
|
|
424
|
+
accept={accept}
|
|
425
|
+
isDisabled={props.isDisabled}
|
|
426
|
+
onFileSelected={processFile}
|
|
427
|
+
hasError={!!error}
|
|
272
428
|
/>
|
|
273
|
-
|
|
429
|
+
)}
|
|
274
430
|
</div>
|
|
431
|
+
|
|
275
432
|
{!error && <FormFieldInfo id={descriptionId} validations={validations || {}} />}
|
|
276
433
|
{error && <Error id={errorId} error={error} />}
|
|
277
434
|
</div>
|
|
@@ -75,6 +75,14 @@
|
|
|
75
75
|
focus:inset-ring-0 focus:outline-2 focus:outline-offset-2 focus:outline-red-600 dark:focus:outline-red-500
|
|
76
76
|
inset-ring-2 inset-ring-red-600 dark:inset-ring-red-500;
|
|
77
77
|
}
|
|
78
|
+
.drop-zone-button {
|
|
79
|
+
@apply outline-0 rounded-md w-full cursor-pointer transition-colors;
|
|
80
|
+
}
|
|
81
|
+
.icon-text-button {
|
|
82
|
+
@apply rounded-full cursor-pointer
|
|
83
|
+
focus:outline-2 focus:outline-offset-2 focus:outline-blue-600 focus:dark:outline-sky-300
|
|
84
|
+
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 focus-visible:dark:outline-sky-300;
|
|
85
|
+
}
|
|
78
86
|
.kdb-shortcut {
|
|
79
87
|
@apply font-sans text-xs h-5 min-w-5 items-center justify-center rounded border px-1 hidden xl:inline-flex;
|
|
80
88
|
}
|