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.
- 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 +53 -31
- package/generators/client/templates/react/src/components/entities/EntityForm.tsx.ejs +1 -0
- package/generators/client/templates/react/src/components/formElements/FileField.tsx.ejs +210 -85
- package/generators/client/templates/react/src/pages/entities/EntityEdit.tsx.ejs +21 -21
- 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
|
-
<
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
{
|
|
202
|
-
{
|
|
203
|
-
{
|
|
204
|
-
{
|
|
205
|
-
{
|
|
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>
|
|
@@ -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,
|
|
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
|
-
|
|
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="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
|
-
|
|
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
|
|
126
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
191
|
-
{existingBlob?.
|
|
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
|
|
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
|
|
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
|
-
|
|
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>
|
|
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
|
-
|
|
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'}
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
65
|
+
release();
|
|
66
66
|
<%_ } _%>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 = () => {
|