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.
Files changed (69) hide show
  1. package/generators/client/templates/react/public/locales/common/am.json.ejs +7 -2
  2. package/generators/client/templates/react/public/locales/common/ar.json.ejs +7 -2
  3. package/generators/client/templates/react/public/locales/common/az.json.ejs +7 -2
  4. package/generators/client/templates/react/public/locales/common/bg.json.ejs +7 -2
  5. package/generators/client/templates/react/public/locales/common/bn.json.ejs +7 -2
  6. package/generators/client/templates/react/public/locales/common/ca.json.ejs +7 -2
  7. package/generators/client/templates/react/public/locales/common/cs.json.ejs +7 -2
  8. package/generators/client/templates/react/public/locales/common/cy.json.ejs +7 -2
  9. package/generators/client/templates/react/public/locales/common/da.json.ejs +7 -2
  10. package/generators/client/templates/react/public/locales/common/de.json.ejs +7 -2
  11. package/generators/client/templates/react/public/locales/common/el.json.ejs +7 -2
  12. package/generators/client/templates/react/public/locales/common/en.json.ejs +7 -2
  13. package/generators/client/templates/react/public/locales/common/es.json.ejs +7 -2
  14. package/generators/client/templates/react/public/locales/common/et.json.ejs +7 -2
  15. package/generators/client/templates/react/public/locales/common/eu.json.ejs +7 -2
  16. package/generators/client/templates/react/public/locales/common/fa.json.ejs +7 -2
  17. package/generators/client/templates/react/public/locales/common/fi.json.ejs +7 -2
  18. package/generators/client/templates/react/public/locales/common/fr.json.ejs +7 -2
  19. package/generators/client/templates/react/public/locales/common/fy.json.ejs +7 -2
  20. package/generators/client/templates/react/public/locales/common/ga.json.ejs +7 -2
  21. package/generators/client/templates/react/public/locales/common/gl.json.ejs +7 -2
  22. package/generators/client/templates/react/public/locales/common/gu.json.ejs +7 -2
  23. package/generators/client/templates/react/public/locales/common/ha.json.ejs +7 -2
  24. package/generators/client/templates/react/public/locales/common/he.json.ejs +7 -2
  25. package/generators/client/templates/react/public/locales/common/hi.json.ejs +7 -2
  26. package/generators/client/templates/react/public/locales/common/hr.json.ejs +7 -2
  27. package/generators/client/templates/react/public/locales/common/ht.json.ejs +7 -2
  28. package/generators/client/templates/react/public/locales/common/hu.json.ejs +7 -2
  29. package/generators/client/templates/react/public/locales/common/hy.json.ejs +7 -2
  30. package/generators/client/templates/react/public/locales/common/id.json.ejs +7 -2
  31. package/generators/client/templates/react/public/locales/common/is.json.ejs +7 -2
  32. package/generators/client/templates/react/public/locales/common/it.json.ejs +7 -2
  33. package/generators/client/templates/react/public/locales/common/ja.json.ejs +7 -2
  34. package/generators/client/templates/react/public/locales/common/kk.json.ejs +7 -2
  35. package/generators/client/templates/react/public/locales/common/ko.json.ejs +7 -2
  36. package/generators/client/templates/react/public/locales/common/lt.json.ejs +7 -2
  37. package/generators/client/templates/react/public/locales/common/lv.json.ejs +7 -2
  38. package/generators/client/templates/react/public/locales/common/mi.json.ejs +7 -2
  39. package/generators/client/templates/react/public/locales/common/ms.json.ejs +7 -2
  40. package/generators/client/templates/react/public/locales/common/mt.json.ejs +7 -2
  41. package/generators/client/templates/react/public/locales/common/nl.json.ejs +7 -2
  42. package/generators/client/templates/react/public/locales/common/no.json.ejs +7 -2
  43. package/generators/client/templates/react/public/locales/common/om.json.ejs +7 -2
  44. package/generators/client/templates/react/public/locales/common/pl.json.ejs +7 -2
  45. package/generators/client/templates/react/public/locales/common/pt.json.ejs +7 -2
  46. package/generators/client/templates/react/public/locales/common/ro.json.ejs +7 -2
  47. package/generators/client/templates/react/public/locales/common/ru.json.ejs +7 -2
  48. package/generators/client/templates/react/public/locales/common/rw.json.ejs +7 -2
  49. package/generators/client/templates/react/public/locales/common/sk.json.ejs +7 -2
  50. package/generators/client/templates/react/public/locales/common/sl.json.ejs +7 -2
  51. package/generators/client/templates/react/public/locales/common/sq.json.ejs +7 -2
  52. package/generators/client/templates/react/public/locales/common/sr.json.ejs +7 -2
  53. package/generators/client/templates/react/public/locales/common/sv.json.ejs +7 -2
  54. package/generators/client/templates/react/public/locales/common/sw.json.ejs +7 -2
  55. package/generators/client/templates/react/public/locales/common/ta.json.ejs +7 -2
  56. package/generators/client/templates/react/public/locales/common/tr.json.ejs +7 -2
  57. package/generators/client/templates/react/public/locales/common/uk.json.ejs +7 -2
  58. package/generators/client/templates/react/public/locales/common/ur.json.ejs +7 -2
  59. package/generators/client/templates/react/public/locales/common/uz.json.ejs +7 -2
  60. package/generators/client/templates/react/public/locales/common/vi.json.ejs +7 -2
  61. package/generators/client/templates/react/public/locales/common/yo.json.ejs +7 -2
  62. package/generators/client/templates/react/public/locales/common/zh.json.ejs +7 -2
  63. package/generators/client/templates/react/public/locales/common/zu.json.ejs +7 -2
  64. package/generators/client/templates/react/src/components/BlobViewer.tsx.ejs +63 -33
  65. package/generators/client/templates/react/src/components/entities/EntityForm.tsx.ejs +1 -0
  66. package/generators/client/templates/react/src/components/formElements/FileField.tsx.ejs +267 -110
  67. package/generators/client/templates/react/src/index.css.ejs +8 -0
  68. package/generators/client/templates/react/src/pages/entities/EntityEdit.tsx.ejs +21 -21
  69. 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
- <div className="space-y-2">
33
- <a href={objectUrl} target="_blank" rel="noopener noreferrer">
34
- <img
35
- src={objectUrl}
36
- alt={name}
37
- className="max-w-xs max-h-80 rounded border border-gray-200 dark:border-gray-700"
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
- <div className="space-y-2">
47
- <a href={objectUrl} target="_blank" rel="noopener noreferrer" className="inline-block relative">
48
- <video
49
- src={objectUrl}
50
- preload="metadata"
51
- className="max-w-xs max-h-80 rounded border border-gray-200 dark:border-gray-700"
52
- />
53
- <div className="absolute inset-0 flex items-center justify-center bg-black/30">
54
- <Icon icon="play_circle" size={12} className="text-white drop-shadow" />
55
- </div>
56
- </a>
57
- </div>
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
- <div className="space-y-2">
128
+ <>
124
129
  {thumbnailUrl && !pdfError && (
125
- <a href={objectUrl} target="_blank" rel="noopener noreferrer" className="block">
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
- </div>
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
- return (
200
- <div className={className}>
201
- {objectUrl && isInlineImage && <ImageViewer objectUrl={objectUrl} name={blob.name} />}
202
- {objectUrl && isVideo && <VideoViewer objectUrl={objectUrl} name={blob.name} />}
203
- {objectUrl && isPdf && <PdfViewer blob={blob} objectUrl={objectUrl} />}
204
- {objectUrl && isAudio && <AudioViewer objectUrl={objectUrl} name={blob.name} />}
205
- {objectUrl && !isInlineImage && !isVideo && !isPdf && !isAudio && (
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>
@@ -207,6 +207,7 @@ export function <%= entity.name %>Form({ <%= to.camel(entity.name)%>, onSubmit,
207
207
  await onSubmit(data);
208
208
  } catch (error) {
209
209
  console.error('Form submission error:', error);
210
+ } finally {
210
211
  setDataSaving(false);
211
212
  }
212
213
  };
@@ -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
- return <Icon icon="draft" size={6} fill />;
51
- }
52
- if (mimeType.startsWith('image/')) {
53
- return <Icon icon="imagesmode" size={6} fill />;
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
- const registerProps = register(fieldName, {
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
- trigger(fieldName);
148
- }, 0);
149
- } catch (error) {
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
- const input = document.querySelector(`[name=${fieldName}]`) as HTMLInputElement;
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
- {t('actions.crud.file.viewOrDownload')}{' '}
191
- {existingBlob?.name} ({t('common.opensInNewWindow')})
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 cursor-pointer font-medium gap-1"
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 cursor-pointer font-medium gap-1"
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
- <div className={shouldShowInput && !isMarkedForDeletion ? 'block' : 'hidden'}>
237
- {hasFile && shouldShowInput && (
238
- <div className="p-3 mb-1 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
239
- <div className="flex items-center justify-between">
240
- <div className="flex items-center text-yellow-800 dark:text-yellow-200 gap-2">
241
- <Icon icon="info" size={6} />
242
- <span className="text-sm font-medium">
243
- {t('actions.crud.file.replaceWarning')}
244
- </span>
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
- <input
258
- type="file"
259
- disabled={props.isDisabled}
260
- required={required && !hasFile && !isMarkedForDeletion}
261
- aria-invalid={!!error}
262
- aria-labelledby={labelId}
263
- aria-describedby={
264
- [validations ? descriptionId : '', error ? errorId : ''].join(' ').trim() || undefined
265
- }
266
- id={fieldId}
267
- accept={accept ?? '*/*'}
268
- {...registerProps}
269
- onChange={handleFileChange}
270
- ref={inputRef}
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
- </div>
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
  }