generator-pninja 2.0.1 → 2.0.3
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/src/components/BlobViewer.tsx.ejs +16 -8
- package/generators/client/templates/react/src/components/formElements/FileField.tsx.ejs +71 -39
- package/generators/client/templates/react/src/contexts/AuthorizationContext.tsx.ejs +1 -1
- package/generators/client/templates/react/src/index.css.ejs +8 -0
- package/generators/client/templates/react/src/main.tsx.ejs +1 -1
- package/package.json +1 -1
|
@@ -26,14 +26,17 @@ 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
|
-
<a href={objectUrl} target="_blank" rel="noopener noreferrer">
|
|
35
|
+
<a href={objectUrl} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
|
33
36
|
<img
|
|
34
37
|
src={objectUrl}
|
|
35
38
|
alt={name}
|
|
36
|
-
className="max-w-xs max-h-80 rounded border border-gray-200 dark:border-gray-700"
|
|
39
|
+
className="max-w-xs max-h-80 rounded-sm border border-gray-200 dark:border-gray-700"
|
|
37
40
|
/>
|
|
38
41
|
</a>
|
|
39
42
|
);
|
|
@@ -45,12 +48,13 @@ const VideoViewer: React.FC<{ objectUrl: string; name: string }> = ({ objectUrl,
|
|
|
45
48
|
href={objectUrl}
|
|
46
49
|
target="_blank"
|
|
47
50
|
rel="noopener noreferrer"
|
|
48
|
-
className="h-full inline-block relative"
|
|
51
|
+
// className="h-full inline-block relative"
|
|
52
|
+
className={`relative ${linkClass}`}
|
|
49
53
|
>
|
|
50
54
|
<video
|
|
51
55
|
src={objectUrl}
|
|
52
56
|
preload="metadata"
|
|
53
|
-
className="max-w-xs max-h-80 rounded border border-gray-200 dark:border-gray-700"
|
|
57
|
+
className="max-w-xs max-h-80 rounded-sm border border-gray-200 dark:border-gray-700"
|
|
54
58
|
/>
|
|
55
59
|
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
|
56
60
|
<Icon icon="play_circle" size={12} className="text-white drop-shadow" />
|
|
@@ -123,11 +127,11 @@ const PdfViewer: React.FC<{ blob: BlobValue; objectUrl: string }> = ({ blob, obj
|
|
|
123
127
|
return (
|
|
124
128
|
<>
|
|
125
129
|
{thumbnailUrl && !pdfError && (
|
|
126
|
-
<a href={objectUrl} target="_blank" rel="noopener noreferrer" className=
|
|
130
|
+
<a href={objectUrl} target="_blank" rel="noopener noreferrer" className={linkClass}>
|
|
127
131
|
<img
|
|
128
132
|
src={thumbnailUrl}
|
|
129
133
|
alt={blob.name}
|
|
130
|
-
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"
|
|
131
135
|
/>
|
|
132
136
|
</a>
|
|
133
137
|
)}
|
|
@@ -211,13 +215,17 @@ const BlobViewer: React.FC<BlobViewerProps> = ({ blob, className, compact = fals
|
|
|
211
215
|
</>
|
|
212
216
|
);
|
|
213
217
|
|
|
218
|
+
if (compact && !isInlineImage && !isVideo && !isPdf) return null;
|
|
219
|
+
|
|
214
220
|
if (compact) {
|
|
215
221
|
return (
|
|
216
222
|
<div
|
|
217
223
|
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',
|
|
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',
|
|
219
225
|
className,
|
|
220
|
-
]
|
|
226
|
+
]
|
|
227
|
+
.filter(Boolean)
|
|
228
|
+
.join(' ')}
|
|
221
229
|
>
|
|
222
230
|
{preview}
|
|
223
231
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useTranslation } from 'react-i18next';
|
|
2
2
|
import prettyBytes from 'pretty-bytes';
|
|
3
3
|
import { Icon } from '../Icon';
|
|
4
|
-
import { useState, useEffect } 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';
|
|
@@ -114,7 +114,7 @@ const FilePreview: React.FC<{
|
|
|
114
114
|
<button
|
|
115
115
|
type="button"
|
|
116
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"
|
|
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
118
|
aria-label={t('actions.crud.file.removeSelection')}
|
|
119
119
|
>
|
|
120
120
|
<Icon icon="close" size={5} />
|
|
@@ -132,6 +132,19 @@ const DropZone: React.FC<{
|
|
|
132
132
|
hasError: boolean;
|
|
133
133
|
}> = ({ accept, isDisabled, onFileSelected, hasError }) => {
|
|
134
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
|
+
}, []);
|
|
135
148
|
|
|
136
149
|
const handleDrop = async (e: import('react-aria-components').DropEvent) => {
|
|
137
150
|
const droppedItem = e.items.find((item) => item.kind === 'file');
|
|
@@ -142,13 +155,17 @@ const DropZone: React.FC<{
|
|
|
142
155
|
|
|
143
156
|
return (
|
|
144
157
|
<AriaDropZone
|
|
158
|
+
ref={dropZoneRef}
|
|
145
159
|
onDrop={handleDrop}
|
|
146
160
|
isDisabled={isDisabled}
|
|
161
|
+
tabIndex={-1}
|
|
147
162
|
className={({ isDropTarget }) =>
|
|
148
163
|
[
|
|
149
|
-
'
|
|
150
|
-
isDropTarget
|
|
151
|
-
?
|
|
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'
|
|
152
169
|
: hasError
|
|
153
170
|
? 'border-red-400 bg-red-50/30 dark:bg-red-900/10'
|
|
154
171
|
: 'border-slate-300 dark:border-slate-600',
|
|
@@ -167,14 +184,20 @@ const DropZone: React.FC<{
|
|
|
167
184
|
>
|
|
168
185
|
<Button
|
|
169
186
|
isDisabled={isDisabled}
|
|
170
|
-
|
|
187
|
+
isInvalid={hasError}
|
|
188
|
+
className="drop-zone-button flex flex-col items-center justify-center gap-2 p-6"
|
|
189
|
+
onFocusChange={setIsFocused}
|
|
171
190
|
>
|
|
172
191
|
<Icon
|
|
173
192
|
icon="cloud_upload"
|
|
174
193
|
size={8}
|
|
175
|
-
className={isDropTarget
|
|
194
|
+
className={isDropTarget || isFocused
|
|
195
|
+
? hasError ? 'text-red-400' : 'text-blue-500'
|
|
196
|
+
: 'text-slate-400'}
|
|
176
197
|
/>
|
|
177
|
-
<span className=
|
|
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'}`}>
|
|
178
201
|
{isDropTarget
|
|
179
202
|
? t('actions.crud.file.dropHere')
|
|
180
203
|
: t('actions.crud.file.dragOrClick')}
|
|
@@ -207,18 +230,35 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
207
230
|
const { required, minbytes, maxbytes } = validations || {};
|
|
208
231
|
const {
|
|
209
232
|
control,
|
|
210
|
-
register,
|
|
211
233
|
setValue,
|
|
212
234
|
trigger,
|
|
213
|
-
formState: { errors },
|
|
214
235
|
} = useFormContext();
|
|
215
236
|
|
|
216
237
|
const {
|
|
238
|
+
field: { ref: fieldRef },
|
|
217
239
|
fieldState: { error },
|
|
218
240
|
} = useController({
|
|
219
241
|
name: fieldName,
|
|
220
242
|
control,
|
|
221
|
-
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
|
+
},
|
|
222
262
|
});
|
|
223
263
|
|
|
224
264
|
const markForDeletion = (mark: boolean) => {
|
|
@@ -226,28 +266,6 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
226
266
|
setValue(fieldName, mark ? null : existingBlob);
|
|
227
267
|
setTimeout(() => trigger(fieldName), 0);
|
|
228
268
|
};
|
|
229
|
-
|
|
230
|
-
// Register the field in RHF for validation — not bound to a native input
|
|
231
|
-
register(fieldName, {
|
|
232
|
-
validate: (value: unknown | undefined) => {
|
|
233
|
-
const fileData = value as BlobValue | null;
|
|
234
|
-
|
|
235
|
-
if (fileData?.type && !fileData.type.startsWith(accept?.replace(/\/\*$/, '') ?? '')) {
|
|
236
|
-
return `errors.validations.file.invalidType:${accept}:${fileData.type}`;
|
|
237
|
-
}
|
|
238
|
-
if (typeof fileData?.size === 'number' && minbytes && fileData?.size < minbytes) {
|
|
239
|
-
return `errors.validations.file.minSize:${prettyBytes(minbytes)}:${prettyBytes(fileData?.size)}`;
|
|
240
|
-
}
|
|
241
|
-
if (typeof fileData?.size === 'number' && maxbytes && fileData?.size > maxbytes) {
|
|
242
|
-
return `errors.validations.file.maxSize:${prettyBytes(maxbytes)}:${prettyBytes(fileData?.size)}`;
|
|
243
|
-
}
|
|
244
|
-
if (required && (isMarkedForDeletion || (!fileData && !existingBlob?.name))) {
|
|
245
|
-
return 'errors.validations.file.required';
|
|
246
|
-
}
|
|
247
|
-
return true;
|
|
248
|
-
},
|
|
249
|
-
});
|
|
250
|
-
|
|
251
269
|
const processFile = async (file: File) => {
|
|
252
270
|
setSelectedFile(file);
|
|
253
271
|
try {
|
|
@@ -281,6 +299,7 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
281
299
|
setTimeout(() => trigger(fieldName), 0);
|
|
282
300
|
};
|
|
283
301
|
|
|
302
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
284
303
|
const fieldId = useId();
|
|
285
304
|
const labelId = useId();
|
|
286
305
|
const errorId = useId();
|
|
@@ -289,11 +308,24 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
289
308
|
const showDropZone = shouldShowInput && !isMarkedForDeletion;
|
|
290
309
|
|
|
291
310
|
return (
|
|
292
|
-
<div className={className}>
|
|
311
|
+
<div className={className} ref={containerRef}>
|
|
293
312
|
<FormLabel id={labelId} validations={validations}>
|
|
294
313
|
{label}
|
|
295
314
|
</FormLabel>
|
|
296
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
|
+
/>
|
|
297
329
|
|
|
298
330
|
{/* File esistente */}
|
|
299
331
|
{hasFile && !shouldShowInput && !isMarkedForDeletion && (
|
|
@@ -301,7 +333,7 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
301
333
|
<div className="flex items-center justify-between">
|
|
302
334
|
<a
|
|
303
335
|
href={`${location.pathname.replace('entities', 'api').replace('edit/', '')}/${fieldName}/${existingBlob?.name}`}
|
|
304
|
-
className="hover:underline flex items-center gap-2 text-sm"
|
|
336
|
+
className="icon-text-button hover:underline flex items-center gap-2 text-sm"
|
|
305
337
|
target="_blank"
|
|
306
338
|
rel="noopener noreferrer"
|
|
307
339
|
aria-label={`${t('actions.crud.file.viewOrDownload')} ${existingBlob?.name} (${t('common.opensInNewWindow')})`}
|
|
@@ -316,7 +348,7 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
316
348
|
<button
|
|
317
349
|
type="button"
|
|
318
350
|
onClick={() => setShouldShowInput(true)}
|
|
319
|
-
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"
|
|
320
352
|
>
|
|
321
353
|
<Icon icon="swap_horiz" size={5} />
|
|
322
354
|
{t('actions.crud.edit')}
|
|
@@ -324,7 +356,7 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
324
356
|
<button
|
|
325
357
|
type="button"
|
|
326
358
|
onClick={() => markForDeletion(true)}
|
|
327
|
-
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"
|
|
328
360
|
>
|
|
329
361
|
<Icon icon="delete" size={5} />
|
|
330
362
|
{t('actions.crud.delete')}
|
|
@@ -350,7 +382,7 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
350
382
|
<button
|
|
351
383
|
type="button"
|
|
352
384
|
onClick={() => markForDeletion(false)}
|
|
353
|
-
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"
|
|
354
386
|
>
|
|
355
387
|
<Icon icon="undo" size={5} />
|
|
356
388
|
{t('common.undo')}
|
|
@@ -372,7 +404,7 @@ const FileField: React.FC<FileFieldProps> = ({
|
|
|
372
404
|
<button
|
|
373
405
|
type="button"
|
|
374
406
|
onClick={undoEdit}
|
|
375
|
-
className="flex items-center text-sm cursor-pointer font-medium gap-1"
|
|
407
|
+
className="icon-text-button flex items-center text-sm cursor-pointer font-medium gap-1"
|
|
376
408
|
>
|
|
377
409
|
<Icon icon="undo" size={5} />
|
|
378
410
|
{t('common.cancel')}
|
|
@@ -20,7 +20,7 @@ interface AuthorizationProviderProps {
|
|
|
20
20
|
|
|
21
21
|
export const AuthorizationProvider: React.FC<AuthorizationProviderProps> = ({
|
|
22
22
|
children,
|
|
23
|
-
cacheDuration =
|
|
23
|
+
cacheDuration = 60 * 1000,
|
|
24
24
|
}) => {
|
|
25
25
|
const [cache, setCache] = useState<Map<string, CacheEntry>>(new Map());
|
|
26
26
|
const pendingRequestsRef = useRef<Map<string, Promise<boolean>>>(new Map());
|
|
@@ -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
|
}
|