generator-pninja 2.0.0 → 2.0.1

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 (68) 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 +53 -31
  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 +210 -85
  67. package/generators/client/templates/react/src/pages/entities/EntityEdit.tsx.ejs +21 -21
  68. package/package.json +1 -1
@@ -29,32 +29,33 @@ function base64ToUint8Array(base64: string): Uint8Array {
29
29
  // ─── Image viewer ────────────────────────────────────────────────────────────
30
30
 
31
31
  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>
32
+ <a href={objectUrl} target="_blank" rel="noopener noreferrer">
33
+ <img
34
+ src={objectUrl}
35
+ alt={name}
36
+ className="max-w-xs max-h-80 rounded border border-gray-200 dark:border-gray-700"
37
+ />
38
+ </a>
41
39
  );
42
40
 
43
41
  // ─── Video viewer ────────────────────────────────────────────────────────────
44
42
 
45
43
  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>
44
+ <a
45
+ href={objectUrl}
46
+ target="_blank"
47
+ rel="noopener noreferrer"
48
+ className="h-full inline-block relative"
49
+ >
50
+ <video
51
+ src={objectUrl}
52
+ preload="metadata"
53
+ className="max-w-xs max-h-80 rounded border border-gray-200 dark:border-gray-700"
54
+ />
55
+ <div className="absolute inset-0 flex items-center justify-center bg-black/30">
56
+ <Icon icon="play_circle" size={12} className="text-white drop-shadow" />
57
+ </div>
58
+ </a>
58
59
  );
59
60
 
60
61
  // ─── PDF viewer ──────────────────────────────────────────────────────────────
@@ -120,7 +121,7 @@ const PdfViewer: React.FC<{ blob: BlobValue; objectUrl: string }> = ({ blob, obj
120
121
  }, [blob.data]);
121
122
 
122
123
  return (
123
- <div className="space-y-2">
124
+ <>
124
125
  {thumbnailUrl && !pdfError && (
125
126
  <a href={objectUrl} target="_blank" rel="noopener noreferrer" className="block">
126
127
  <img
@@ -133,7 +134,7 @@ const PdfViewer: React.FC<{ blob: BlobValue; objectUrl: string }> = ({ blob, obj
133
134
  {(pdfError || !thumbnailUrl) && (
134
135
  <Icon icon="picture_as_pdf" size={10} className="text-red-500" />
135
136
  )}
136
- </div>
137
+ </>
137
138
  );
138
139
  };
139
140
 
@@ -174,9 +175,11 @@ const AudioViewer: React.FC<{ objectUrl: string; name: string }> = ({ objectUrl,
174
175
  interface BlobViewerProps {
175
176
  blob: BlobValue | null | undefined;
176
177
  className?: string;
178
+ /** When true, suppresses the filename/size/link bar below the preview */
179
+ compact?: boolean;
177
180
  }
178
181
 
179
- const BlobViewer: React.FC<BlobViewerProps> = ({ blob, className }) => {
182
+ const BlobViewer: React.FC<BlobViewerProps> = ({ blob, className, compact = false }) => {
180
183
  const { t } = useTranslation();
181
184
  const [objectUrl, setObjectUrl] = useState<string | null>(null);
182
185
 
@@ -196,15 +199,34 @@ const BlobViewer: React.FC<BlobViewerProps> = ({ blob, className }) => {
196
199
  const isAudio = blob.type.startsWith('audio/');
197
200
  const isPdf = blob.type === 'application/pdf';
198
201
 
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 && (
202
+ const preview = objectUrl && (
203
+ <>
204
+ {isInlineImage && <ImageViewer objectUrl={objectUrl} name={blob.name} />}
205
+ {isVideo && <VideoViewer objectUrl={objectUrl} name={blob.name} />}
206
+ {isPdf && <PdfViewer blob={blob} objectUrl={objectUrl} />}
207
+ {isAudio && <AudioViewer objectUrl={objectUrl} name={blob.name} />}
208
+ {!isInlineImage && !isVideo && !isPdf && !isAudio && (
206
209
  <GenericViewer objectUrl={objectUrl} name={blob.name} />
207
210
  )}
211
+ </>
212
+ );
213
+
214
+ if (compact) {
215
+ return (
216
+ <div
217
+ className={[
218
+ 'w-20 h-20 flex-shrink-0 rounded 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',
219
+ className,
220
+ ].filter(Boolean).join(' ')}
221
+ >
222
+ {preview}
223
+ </div>
224
+ );
225
+ }
226
+
227
+ return (
228
+ <div className={className}>
229
+ {preview}
208
230
  {objectUrl && (
209
231
  <div className="flex items-center gap-3 mt-1">
210
232
  <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 } 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,147 @@ 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="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
+
136
+ const handleDrop = async (e: import('react-aria-components').DropEvent) => {
137
+ const droppedItem = e.items.find((item) => item.kind === 'file');
138
+ if (!droppedItem || droppedItem.kind !== 'file') return;
139
+ const file = await droppedItem.getFile();
140
+ onFileSelected(file);
141
+ };
142
+
143
+ return (
144
+ <AriaDropZone
145
+ onDrop={handleDrop}
146
+ isDisabled={isDisabled}
147
+ className={({ isDropTarget }) =>
148
+ [
149
+ 'flex flex-col items-center justify-center gap-2 p-6 rounded-md border-2 border-dashed transition-colors outline-none',
150
+ isDropTarget
151
+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
152
+ : hasError
153
+ ? 'border-red-400 bg-red-50/30 dark:bg-red-900/10'
154
+ : 'border-slate-300 dark:border-slate-600',
155
+ isDisabled ? 'opacity-50' : '',
156
+ ].join(' ')
157
+ }
158
+ >
159
+ {({ isDropTarget }) => (
160
+ <FileTrigger
161
+ acceptedFileTypes={accept ? [accept] : undefined}
162
+ isDisabled={isDisabled}
163
+ onSelect={(files) => {
164
+ const file = files?.[0];
165
+ if (file) onFileSelected(file);
166
+ }}
167
+ >
168
+ <Button
169
+ isDisabled={isDisabled}
170
+ className="flex flex-col items-center gap-2 cursor-pointer w-full focus:outline-none"
171
+ >
172
+ <Icon
173
+ icon="cloud_upload"
174
+ size={8}
175
+ className={isDropTarget ? 'text-blue-500' : 'text-slate-400'}
176
+ />
177
+ <span className="text-sm text-slate-600 dark:text-slate-400 text-center">
178
+ {isDropTarget
179
+ ? t('actions.crud.file.dropHere')
180
+ : t('actions.crud.file.dragOrClick')}
181
+ </span>
182
+ </Button>
183
+ </FileTrigger>
184
+ )}
185
+ </AriaDropZone>
186
+ );
187
+ };
188
+
189
+ // ─── FileField ────────────────────────────────────────────────────────────────
190
+
64
191
  const FileField: React.FC<FileFieldProps> = ({
65
192
  accept,
66
193
  className,
@@ -76,8 +203,8 @@ const FileField: React.FC<FileFieldProps> = ({
76
203
  const hasFile = !!existingBlob?.name;
77
204
  const [shouldShowInput, setShouldShowInput] = useState(!hasFile);
78
205
  const [isMarkedForDeletion, setIsMarkedForDeletion] = useState(false);
206
+ const [selectedFile, setSelectedFile] = useState<File | null>(null);
79
207
  const { required, minbytes, maxbytes } = validations || {};
80
- const inputRef = useRef<HTMLInputElement>(null);
81
208
  const {
82
209
  control,
83
210
  register,
@@ -97,12 +224,11 @@ const FileField: React.FC<FileFieldProps> = ({
97
224
  const markForDeletion = (mark: boolean) => {
98
225
  setIsMarkedForDeletion(mark);
99
226
  setValue(fieldName, mark ? null : existingBlob);
100
- setTimeout(() => {
101
- trigger(fieldName);
102
- }, 0);
227
+ setTimeout(() => trigger(fieldName), 0);
103
228
  };
104
229
 
105
- const registerProps = register(fieldName, {
230
+ // Register the field in RHF for validation — not bound to a native input
231
+ register(fieldName, {
106
232
  validate: (value: unknown | undefined) => {
107
233
  const fileData = value as BlobValue | null;
108
234
 
@@ -122,17 +248,8 @@ const FileField: React.FC<FileFieldProps> = ({
122
248
  },
123
249
  });
124
250
 
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
-
251
+ const processFile = async (file: File) => {
252
+ setSelectedFile(file);
136
253
  try {
137
254
  const fileData = await fileToBase64(file);
138
255
  const blobValue: BlobValue = {
@@ -141,28 +258,27 @@ const FileField: React.FC<FileFieldProps> = ({
141
258
  name: file.name,
142
259
  size: file.size,
143
260
  };
144
-
145
261
  setValue(fieldName, blobValue);
146
- setTimeout(() => {
147
- trigger(fieldName);
148
- }, 0);
149
- } catch (error) {
150
- console.error('Error converting file to base64:', error);
262
+ setTimeout(() => trigger(fieldName), 0);
263
+ } catch (err) {
264
+ console.error('Error converting file to base64:', err);
265
+ setSelectedFile(null);
151
266
  setValue(fieldName, null);
152
- setTimeout(() => {
153
- trigger(fieldName);
154
- }, 0);
267
+ setTimeout(() => trigger(fieldName), 0);
155
268
  }
156
269
  };
157
270
 
271
+ const removeSelection = () => {
272
+ setSelectedFile(null);
273
+ setValue(fieldName, hasFile ? existingBlob : null);
274
+ setTimeout(() => trigger(fieldName), 0);
275
+ };
276
+
158
277
  const undoEdit = () => {
159
278
  setShouldShowInput(false);
279
+ setSelectedFile(null);
160
280
  setValue(fieldName, existingBlob);
161
- const input = document.querySelector(`[name=${fieldName}]`) as HTMLInputElement;
162
- if (input) input.value = '';
163
- setTimeout(() => {
164
- trigger(fieldName);
165
- }, 0);
281
+ setTimeout(() => trigger(fieldName), 0);
166
282
  };
167
283
 
168
284
  const fieldId = useId();
@@ -170,31 +286,37 @@ const FileField: React.FC<FileFieldProps> = ({
170
286
  const errorId = useId();
171
287
  const descriptionId = useId();
172
288
 
289
+ const showDropZone = shouldShowInput && !isMarkedForDeletion;
290
+
173
291
  return (
174
292
  <div className={className}>
175
293
  <FormLabel id={labelId} validations={validations}>
176
294
  {label}
177
295
  </FormLabel>
178
- <div>
296
+ <div className="space-y-2">
297
+
298
+ {/* File esistente */}
179
299
  {hasFile && !shouldShowInput && !isMarkedForDeletion && (
180
- <div className="p-3 bg-slate-200 text-slate-800 dark:bg-slate-600 dark:text-slate-200 rounded-md">
300
+ <div className="p-3 bg-slate-200 text-slate-800 dark:bg-slate-600 dark:text-slate-200 rounded-md space-y-2">
181
301
  <div className="flex items-center justify-between">
182
302
  <a
183
303
  href={`${location.pathname.replace('entities', 'api').replace('edit/', '')}/${fieldName}/${existingBlob?.name}`}
184
- className="hover:underline flex items-center gap-2"
304
+ className="hover:underline flex items-center gap-2 text-sm"
185
305
  target="_blank"
186
306
  rel="noopener noreferrer"
187
307
  aria-label={`${t('actions.crud.file.viewOrDownload')} ${existingBlob?.name} (${t('common.opensInNewWindow')})`}
188
308
  >
189
309
  {getFileIcon(existingBlob?.type)}
190
- {t('actions.crud.file.viewOrDownload')}{' '}
191
- {existingBlob?.name} ({t('common.opensInNewWindow')})
310
+ {existingBlob?.name}
311
+ {existingBlob?.size != null && (
312
+ <span className="text-xs opacity-70">({prettyBytes(existingBlob.size)})</span>
313
+ )}
192
314
  </a>
193
315
  <div className="flex items-center gap-2">
194
316
  <button
195
317
  type="button"
196
318
  onClick={() => setShouldShowInput(true)}
197
- className="flex items-center text-sm cursor-pointer font-medium gap-1"
319
+ className="flex items-center text-sm cursor-pointer font-medium gap-1"
198
320
  >
199
321
  <Icon icon="swap_horiz" size={5} />
200
322
  {t('actions.crud.edit')}
@@ -202,16 +324,20 @@ const FileField: React.FC<FileFieldProps> = ({
202
324
  <button
203
325
  type="button"
204
326
  onClick={() => markForDeletion(true)}
205
- className="flex items-center text-sm cursor-pointer font-medium gap-1"
327
+ className="flex items-center text-sm cursor-pointer font-medium gap-1"
206
328
  >
207
329
  <Icon icon="delete" size={5} />
208
330
  {t('actions.crud.delete')}
209
331
  </button>
210
332
  </div>
211
333
  </div>
334
+ {existingBlob && (
335
+ <BlobViewer blob={existingBlob} compact />
336
+ )}
212
337
  </div>
213
338
  )}
214
339
 
340
+ {/* Marcato per eliminazione */}
215
341
  {isMarkedForDeletion && (
216
342
  <div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
217
343
  <div className="flex items-center justify-between">
@@ -233,45 +359,44 @@ const FileField: React.FC<FileFieldProps> = ({
233
359
  </div>
234
360
  )}
235
361
 
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>
362
+ {/* Warning sostituzione */}
363
+ {hasFile && shouldShowInput && (
364
+ <div className="p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
365
+ <div className="flex items-center justify-between">
366
+ <div className="flex items-center text-yellow-800 dark:text-yellow-200 gap-2">
367
+ <Icon icon="info" size={6} />
368
+ <span className="text-sm font-medium">
369
+ {t('actions.crud.file.replaceWarning')}
370
+ </span>
254
371
  </div>
372
+ <button
373
+ type="button"
374
+ onClick={undoEdit}
375
+ className="flex items-center text-sm cursor-pointer font-medium gap-1"
376
+ >
377
+ <Icon icon="undo" size={5} />
378
+ {t('common.cancel')}
379
+ </button>
255
380
  </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'}
381
+ </div>
382
+ )}
383
+
384
+ {/* Preview file selezionato */}
385
+ {showDropZone && selectedFile && (
386
+ <FilePreview file={selectedFile} onRemove={removeSelection} />
387
+ )}
388
+
389
+ {/* Drop zone nascosta se file già selezionato */}
390
+ {showDropZone && !selectedFile && (
391
+ <DropZone
392
+ accept={accept}
393
+ isDisabled={props.isDisabled}
394
+ onFileSelected={processFile}
395
+ hasError={!!error}
272
396
  />
273
- </div>
397
+ )}
274
398
  </div>
399
+
275
400
  {!error && <FormFieldInfo id={descriptionId} validations={validations || {}} />}
276
401
  {error && <Error id={errorId} error={error} />}
277
402
  </div>
@@ -55,30 +55,30 @@ export default function <%= entity.name %>Edit() {
55
55
  }
56
56
  }, [id, addNotification, navigate, t]);
57
57
 
58
- const handleSubmit = (data: I<%= entity.name %>) => {
59
- (id
60
- ? <%= entity.name %>Service.update(Number(id), data)
61
- : <%= entity.name %>Service.create(data)
62
- )
63
- .then((data: I<%= entity.name %>) => {
58
+ const handleSubmit = async (data: I<%= entity.name %>) => {
59
+ try {
60
+ const result = await (id
61
+ ? <%= entity.name %>Service.update(Number(id), data)
62
+ : <%= entity.name %>Service.create(data)
63
+ );
64
64
  <%_ if (entity.pessimisticLock) { _%>
65
- release();
65
+ release();
66
66
  <%_ } _%>
67
- addNotification({
68
- type: 'success',
69
- message: t(`actions.crud.successfully${id ? 'Updated' : 'Created'}`, {
70
- id: id || data.id,
71
- }),
72
- });
73
- navigate('/<%- entity.name === 'AcRule' ? 'admin' : 'entities' %>/<%=to.slug(pluralize(entity.name))%>');
74
- })
75
- .catch((error: AxiosError<ErrorResponseData>) => {
76
- addNotification({
77
- type: 'error',
78
- message: `${t(id ? 'errors.entities.update' : 'errors.entities.create')} ${t('entities:<%-to.snake(entity.name)%>.<%= entity.name %>')}${id ? ` ID ${id}` : ''}`,
79
- error,
80
- });
67
+ addNotification({
68
+ type: 'success',
69
+ message: t(`actions.crud.successfully${id ? 'Updated' : 'Created'}`, {
70
+ id: id || result.id,
71
+ }),
81
72
  });
73
+ navigate('/<%- entity.name === 'AcRule' ? 'admin' : 'entities' %>/<%=to.slug(pluralize(entity.name))%>');
74
+ } catch (error) {
75
+ addNotification({
76
+ type: 'error',
77
+ message: `${t(id ? 'errors.entities.update' : 'errors.entities.create')} ${t('entities:<%-to.snake(entity.name)%>.<%= entity.name %>')}${id ? ` ID ${id}` : ''}`,
78
+ error: error as AxiosError<ErrorResponseData>,
79
+ });
80
+ throw error;
81
+ }
82
82
  };
83
83
 
84
84
  const handleCancel = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "generator-pninja",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Generator for PHP/Laravel + SQL + Vite + React applications",
5
5
  "type": "module",
6
6
  "files": [