@airoom/nextmin-react 1.4.5 → 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/README.md +29 -3
- package/dist/auth/SignInForm.js +4 -2
- package/dist/components/AdminApp.js +15 -38
- package/dist/components/ArchitectureDemo.d.ts +1 -0
- package/dist/components/ArchitectureDemo.js +45 -0
- package/dist/components/PhoneInput.d.ts +3 -0
- package/dist/components/PhoneInput.js +23 -19
- package/dist/components/RefSelect.d.ts +16 -0
- package/dist/components/RefSelect.js +225 -0
- package/dist/components/SchemaForm.js +131 -51
- package/dist/components/Sidebar.js +6 -13
- package/dist/components/TableFilters.js +2 -0
- package/dist/components/editor/TiptapEditor.js +1 -1
- package/dist/components/editor/Toolbar.js +13 -2
- package/dist/components/editor/components/DistrictGridModal.js +2 -3
- package/dist/components/editor/components/SchemaInsertionModal.js +2 -2
- package/dist/components/viewer/DynamicViewer.js +70 -9
- package/dist/hooks/useRealtime.d.ts +8 -0
- package/dist/hooks/useRealtime.js +30 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/lib/AuthClient.d.ts +15 -0
- package/dist/lib/AuthClient.js +63 -0
- package/dist/lib/QueryBuilder.d.ts +29 -0
- package/dist/lib/QueryBuilder.js +74 -0
- package/dist/lib/RealtimeClient.d.ts +16 -0
- package/dist/lib/RealtimeClient.js +56 -0
- package/dist/lib/api.d.ts +15 -3
- package/dist/lib/api.js +71 -58
- package/dist/lib/auth.js +7 -2
- package/dist/lib/types.d.ts +16 -0
- package/dist/nextmin.css +1 -1
- package/dist/providers/NextMinProvider.d.ts +8 -1
- package/dist/providers/NextMinProvider.js +40 -8
- package/dist/router/NextMinRouter.d.ts +1 -1
- package/dist/router/NextMinRouter.js +1 -1
- package/dist/state/schemasSlice.js +8 -2
- package/dist/views/DashboardPage.js +56 -42
- package/dist/views/ListPage.js +34 -4
- package/dist/views/SettingsEdit.js +25 -2
- package/dist/views/list/DataTableHero.js +103 -46
- package/dist/views/list/ListHeader.d.ts +3 -1
- package/dist/views/list/ListHeader.js +2 -2
- package/dist/views/list/jsonSummary.d.ts +3 -3
- package/dist/views/list/jsonSummary.js +47 -20
- package/dist/views/list/useListData.js +5 -1
- package/package.json +8 -4
- package/dist/components/RefMultiSelect.d.ts +0 -22
- package/dist/components/RefMultiSelect.js +0 -113
- package/dist/components/RefSingleSelect.d.ts +0 -17
- package/dist/components/RefSingleSelect.js +0 -110
- package/dist/lib/schemaService.d.ts +0 -2
- package/dist/lib/schemaService.js +0 -39
- package/dist/state/schemaLive.d.ts +0 -2
- package/dist/state/schemaLive.js +0 -19
- /package/dist/{editor.css → components/editor/editor.css} +0 -0
|
@@ -4,8 +4,7 @@ import { useEffect, useMemo, useState, useId } from 'react';
|
|
|
4
4
|
import { Form, Input, Textarea, Checkbox, RadioGroup, Radio, Select, SelectItem, Button, DatePicker, DateRangePicker, TimeInput, } from '@heroui/react';
|
|
5
5
|
import { useSelector } from 'react-redux';
|
|
6
6
|
import { inputTypeFor } from '../lib/schemaUtils';
|
|
7
|
-
import {
|
|
8
|
-
import { RefSingleSelect } from './RefSingleSelect';
|
|
7
|
+
import { RefSelect } from './RefSelect';
|
|
9
8
|
import { PasswordInput } from './PasswordInput';
|
|
10
9
|
import { PhoneInput } from './PhoneInput';
|
|
11
10
|
import { FileUploader } from './FileUploader';
|
|
@@ -259,6 +258,11 @@ export function SchemaForm({ model, schemaOverride, initialValues, submitLabel =
|
|
|
259
258
|
const sessionRole = useMemo(() => {
|
|
260
259
|
if (!effectiveUser)
|
|
261
260
|
return [];
|
|
261
|
+
// 1. Check direct roleName field if backend provided it
|
|
262
|
+
const rn = effectiveUser.roleName;
|
|
263
|
+
if (typeof rn === 'string' && rn)
|
|
264
|
+
return [rn.toLowerCase()];
|
|
265
|
+
// 2. Fallback to existing logic
|
|
262
266
|
const raw = effectiveUser.roles ?? effectiveUser.role;
|
|
263
267
|
const toName = (r) => {
|
|
264
268
|
if (!r)
|
|
@@ -270,10 +274,11 @@ export function SchemaForm({ model, schemaOverride, initialValues, submitLabel =
|
|
|
270
274
|
return null;
|
|
271
275
|
};
|
|
272
276
|
if (Array.isArray(raw)) {
|
|
273
|
-
return raw.map(toName).filter((s) => !!s);
|
|
277
|
+
return raw.map(toName).filter((s) => !!s).map(s => s.toLowerCase());
|
|
274
278
|
}
|
|
275
279
|
const single = toName(raw);
|
|
276
|
-
|
|
280
|
+
const result = single ? [single.toLowerCase()] : [];
|
|
281
|
+
return result;
|
|
277
282
|
}, [effectiveUser]);
|
|
278
283
|
const schema = useMemo(() => schemaOverride ??
|
|
279
284
|
items.find((s) => s.modelName.toLowerCase() === model.toLowerCase()), [items, model, schemaOverride]);
|
|
@@ -328,20 +333,72 @@ export function SchemaForm({ model, schemaOverride, initialValues, submitLabel =
|
|
|
328
333
|
.map(toName)
|
|
329
334
|
.filter((s) => !!s)
|
|
330
335
|
.map((s) => s.toLowerCase());
|
|
331
|
-
|
|
336
|
+
const result = configured.length > 0 ? configured : ['admin', 'superadmin'];
|
|
337
|
+
return result;
|
|
332
338
|
}, [schema]);
|
|
333
339
|
const canBypassPrivacy = useMemo(() => {
|
|
334
340
|
const userRolesLC = sessionRole.map((r) => r.toLowerCase());
|
|
335
|
-
|
|
341
|
+
const result = userRolesLC.some((r) => bypassRoles.includes(r));
|
|
342
|
+
return result;
|
|
336
343
|
}, [sessionRole, bypassRoles]);
|
|
337
|
-
const fields = useMemo(() =>
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
.filter(([name]) => name !== '
|
|
341
|
-
.filter(([, attr]) =>
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
344
|
+
const fields = useMemo(() => {
|
|
345
|
+
const allFields = Object.entries(schema.attributes);
|
|
346
|
+
const afterAudit = allFields.filter(([name]) => !AUDIT_FIELDS.has(name));
|
|
347
|
+
const afterBaseId = afterAudit.filter(([name]) => name !== 'exId');
|
|
348
|
+
const afterPrivate = afterBaseId.filter(([name, attr]) => {
|
|
349
|
+
const head = Array.isArray(attr) ? attr[0] : attr;
|
|
350
|
+
// Always show password fields in create mode for users with bypass privacy
|
|
351
|
+
if (!isEdit && isPasswordAttr(name, attr) && canBypassPrivacy) {
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
// Otherwise apply normal private field filtering
|
|
355
|
+
const keep = canBypassPrivacy ? true : !head?.private;
|
|
356
|
+
return keep;
|
|
357
|
+
});
|
|
358
|
+
const afterHidden = afterPrivate.filter(([, attr]) => !isHiddenAttr(attr, isEdit));
|
|
359
|
+
const isUserExt = schema.extends?.toLowerCase() === 'users';
|
|
360
|
+
const afterInherited = afterHidden.filter(([name, attr]) => {
|
|
361
|
+
const head = Array.isArray(attr) ? attr[0] : attr;
|
|
362
|
+
// Specifically for User extensions, hide inherited fields unless they are explicitly not marked inherited
|
|
363
|
+
if (isUserExt && name !== 'baseId') {
|
|
364
|
+
return !head?.inherited;
|
|
365
|
+
}
|
|
366
|
+
return true;
|
|
367
|
+
});
|
|
368
|
+
const afterPassword = afterInherited.filter(([name, attr]) => {
|
|
369
|
+
const isPass = isPasswordAttr(name, attr);
|
|
370
|
+
const keep = !(isEdit && isPass && !canBypassPrivacy);
|
|
371
|
+
return keep;
|
|
372
|
+
});
|
|
373
|
+
let finalFields = afterPassword.map(([name, attr]) => ({ name, attr }));
|
|
374
|
+
// If it's a User extension, move baseId to the top and inject the role filter
|
|
375
|
+
if (isUserExt) {
|
|
376
|
+
const baseIdIndex = finalFields.findIndex(f => f.name === 'baseId');
|
|
377
|
+
if (baseIdIndex !== -1) {
|
|
378
|
+
const [baseIdField] = finalFields.splice(baseIdIndex, 1);
|
|
379
|
+
// Inject the filter into baseId attribute (clone to avoid "not extensible" error)
|
|
380
|
+
const baseIdFieldClone = {
|
|
381
|
+
...baseIdField,
|
|
382
|
+
attr: { ...baseIdField.attr }
|
|
383
|
+
};
|
|
384
|
+
const baseIdAttr = baseIdFieldClone.attr;
|
|
385
|
+
if (baseIdAttr) {
|
|
386
|
+
baseIdAttr.where = {
|
|
387
|
+
role: {
|
|
388
|
+
$nestedSearch: {
|
|
389
|
+
ref: 'Roles',
|
|
390
|
+
show: 'name',
|
|
391
|
+
value: ['admin', 'superadmin', 'manager'],
|
|
392
|
+
operator: '$nin'
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
finalFields.unshift(baseIdFieldClone);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return finalFields;
|
|
401
|
+
}, [schema, canBypassPrivacy, isEdit]);
|
|
345
402
|
const gridClass = 'grid gap-4 grid-cols-2';
|
|
346
403
|
const handleChange = (name, value) => {
|
|
347
404
|
setForm((f) => {
|
|
@@ -385,7 +442,7 @@ export function SchemaForm({ model, schemaOverride, initialValues, submitLabel =
|
|
|
385
442
|
e.preventDefault();
|
|
386
443
|
setError(undefined);
|
|
387
444
|
try {
|
|
388
|
-
const { createdAt, updatedAt,
|
|
445
|
+
const { createdAt, updatedAt, __childId, ...rest } = form;
|
|
389
446
|
const payload = { ...rest };
|
|
390
447
|
if (schema) {
|
|
391
448
|
for (const [fname, fattr] of Object.entries(schema.attributes)) {
|
|
@@ -427,14 +484,17 @@ export function SchemaForm({ model, schemaOverride, initialValues, submitLabel =
|
|
|
427
484
|
const canRemove = spec.minItems == null || items.length > spec.minItems;
|
|
428
485
|
return (_jsxs("div", { className: "col-span-2", children: [_jsxs("label", { className: "text-sm font-medium", children: [attr?.label || spec.label || formatLabel(name), attr?.required ? ' *' : ''] }), _jsxs("div", { className: "flex flex-col gap-4 mt-2", children: [items.map((it, idx) => (_jsxs("div", { className: "grid grid-cols-2 gap-3 p-3 rounded-lg border border-default-200", children: [_jsxs("div", { className: "col-span-2 font-medium text-default-600", children: [(attr?.label ||
|
|
429
486
|
spec.label ||
|
|
430
|
-
formatLabel(name)).replace(/s$/, ''), ' ', idx + 1] }), Object.entries(itemSchema).map(([k, a]) =>
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
487
|
+
formatLabel(name)).replace(/s$/, ''), ' ', idx + 1] }), Object.entries(itemSchema).map(([k, a]) => {
|
|
488
|
+
const isRich = a?.rich === true;
|
|
489
|
+
return (_jsx("div", { className: isRich ? 'col-span-2' : 'col-span-1', children: _jsx(SchemaField, { uid: `${formUid}-${name}-${idx}`, name: k, attr: a, value: it?.[k], onChange: (fieldName, fieldValue) => {
|
|
490
|
+
const next = items.slice();
|
|
491
|
+
next[idx] = {
|
|
492
|
+
...(next[idx] || {}),
|
|
493
|
+
[fieldName]: fieldValue,
|
|
494
|
+
};
|
|
495
|
+
handleChange(name, next);
|
|
496
|
+
}, disabled: busy, inputClassNames: inputClassNames, selectClassNames: selectClassNames, mapsKey: mapsKey, availableSchemas: items, formState: it }) }, `${name}-${idx}-${k}`));
|
|
497
|
+
}), _jsx("div", { className: "col-span-2 flex justify-between", children: _jsx(Button, { size: "sm", variant: "flat", color: "danger", isDisabled: !canRemove, onPress: () => {
|
|
438
498
|
const next = items.slice();
|
|
439
499
|
next.splice(idx, 1);
|
|
440
500
|
handleChange(name, next);
|
|
@@ -448,23 +508,13 @@ export function SchemaForm({ model, schemaOverride, initialValues, submitLabel =
|
|
|
448
508
|
!Array.isArray(form[name])
|
|
449
509
|
? form[name]
|
|
450
510
|
: parseJsonGroupValue(form[name], 'single');
|
|
451
|
-
return (_jsxs("div", { className: "col-span-2", children: [_jsxs("label", { className: "text-sm font-medium", children: [attr?.label || spec.label || formatLabel(name), attr?.required ? ' *' : ''] }), _jsx("div", { className: "grid grid-cols-2 gap-3 p-3 rounded-lg border border-default-200 mt-2", children: Object.entries(itemSchema).map(([k, a]) =>
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
459
|
-
// --- 1) Array of references → multi select (1 column)
|
|
460
|
-
const refArray = getRefArraySpec(attr);
|
|
461
|
-
if (refArray) {
|
|
462
|
-
return (_jsx("div", { className: colClass, children: _jsx(RefMultiSelect, { name: name, label: attr[0]?.label ?? formatLabel(name), refModel: refArray.ref, showKey: refArray.show ?? 'name', description: attr?.description, value: form[name], onChange: (ids) => handleChange(name, ids), disabled: busy, required: attr[0]?.required, pageSize: attr[0]?.pageSize }) }, name));
|
|
463
|
-
}
|
|
464
|
-
// --- 2) Single reference → single select (1 column)
|
|
465
|
-
const refSingle = getRefSingleSpec(attr);
|
|
466
|
-
if (refSingle) {
|
|
467
|
-
return (_jsx("div", { className: colClass, children: _jsx(RefSingleSelect, { name: name, label: attr?.label ?? formatLabel(name), refModel: refSingle.ref, showKey: refSingle.show ?? 'name', description: attr?.description, value: normalizeId(form[name]), onChange: (id) => handleChange(name, id), disabled: busy, required: !!attr?.required, classNames: selectClassNames, pageSize: attr?.pageSize }) }, name));
|
|
511
|
+
return (_jsxs("div", { className: "col-span-2", children: [_jsxs("label", { className: "text-sm font-medium", children: [attr?.label || spec.label || formatLabel(name), attr?.required ? ' *' : ''] }), _jsx("div", { className: "grid grid-cols-2 gap-3 p-3 rounded-lg border border-default-200 mt-2", children: Object.entries(itemSchema).map(([k, a]) => {
|
|
512
|
+
const isRich = a?.rich === true;
|
|
513
|
+
return (_jsx("div", { className: isRich ? 'col-span-2' : 'col-span-1', children: _jsx(SchemaField, { uid: `${formUid}-${name}`, name: k, attr: a, value: obj?.[k], onChange: (fieldName, fieldValue) => {
|
|
514
|
+
const next = { ...(obj || {}), [fieldName]: fieldValue };
|
|
515
|
+
handleChange(name, next);
|
|
516
|
+
}, disabled: busy, inputClassNames: inputClassNames, selectClassNames: selectClassNames, mapsKey: mapsKey, availableSchemas: items, formState: obj }) }, `${name}-${k}`));
|
|
517
|
+
}) })] }, name));
|
|
468
518
|
}
|
|
469
519
|
// let password reflect local state, but never prefill
|
|
470
520
|
const baseValue = name.toLowerCase() === 'password'
|
|
@@ -500,8 +550,19 @@ function SchemaField({ uid, name, attr, value, onChange, disabled, inputClassNam
|
|
|
500
550
|
const specialitySlug = formState?.speciality?.slug || formState?.slug || '';
|
|
501
551
|
return (_jsx(TiptapEditor, { value: value, onChange: (html) => onChange(name, html), placeholder: label, availableSchemas: availableSchemas, currentSpeciality: specialitySlug }));
|
|
502
552
|
}
|
|
553
|
+
// --- 1) Array of references → multi select
|
|
554
|
+
const refArray = getRefArraySpec(attr);
|
|
555
|
+
if (refArray) {
|
|
556
|
+
return (_jsx(RefSelect, { name: name, label: attr[0]?.label ?? label, refModel: refArray.ref, showKey: refArray.show ?? 'name', description: attr?.description, value: normalizeIdsArray(value), onChange: (ids) => onChange(name, ids), multiple: true, disabled: disabled, required: attr[0]?.required, pageSize: attr[0]?.pageSize, where: attr[0]?.where || attr?.where }));
|
|
557
|
+
}
|
|
558
|
+
// --- 2) Single reference → single select
|
|
559
|
+
const refSingle = getRefSingleSpec(attr);
|
|
560
|
+
if (refSingle) {
|
|
561
|
+
return (_jsx(RefSelect, { name: name, label: label, refModel: refSingle.ref, showKey: refSingle.show ?? 'name', description: attr?.description, value: normalizeId(value), onChange: (id) => onChange(name, id), multiple: false, disabled: disabled, required: required, pageSize: attr?.pageSize, where: attr?.where }));
|
|
562
|
+
}
|
|
563
|
+
const head = Array.isArray(attr) ? (attr?.[0] ?? {}) : (attr ?? {});
|
|
503
564
|
const isPhoneField = isPhoneAttr(name, attr);
|
|
504
|
-
const rawMask = typeof
|
|
565
|
+
const rawMask = typeof head?.mask === 'string' ? head.mask : '';
|
|
505
566
|
const hasSlots = /[Xx9#_]/.test(rawMask);
|
|
506
567
|
const phoneMask = hasSlots ? rawMask : 'xxx-xxxx-xxxx';
|
|
507
568
|
const isFileField = String(attr?.format || '').toLowerCase() === 'file' ||
|
|
@@ -534,7 +595,7 @@ function SchemaField({ uid, name, attr, value, onChange, disabled, inputClassNam
|
|
|
534
595
|
const rawDigits = typeof value === 'string' || typeof value === 'number'
|
|
535
596
|
? String(value).replace(/\D/g, '')
|
|
536
597
|
: '';
|
|
537
|
-
return (_jsx(PhoneInput, { id: id, name: name, label: label, mask: phoneMask, value: rawDigits, onChange: (raw) => onChange(name, raw), disabled: disabled, required: required, description: description, className: "w-full", classNames: inputClassNames }));
|
|
598
|
+
return (_jsx(PhoneInput, { id: id, name: name, label: label, mask: phoneMask, value: rawDigits, onChange: (raw) => onChange(name, raw), disabled: disabled, required: required, description: description, className: "w-full", classNames: inputClassNames, minLength: head?.minLength, maxLength: head?.maxLength, regex: head?.pattern }));
|
|
538
599
|
}
|
|
539
600
|
const populateField = getPopulateTarget(attr);
|
|
540
601
|
// Address → Autocomplete
|
|
@@ -606,7 +667,19 @@ function SchemaField({ uid, name, attr, value, onChange, disabled, inputClassNam
|
|
|
606
667
|
if (!currentUser)
|
|
607
668
|
return false;
|
|
608
669
|
const o = normalize(opt);
|
|
609
|
-
|
|
670
|
+
// Handle roleName field (preferred for SQL databases)
|
|
671
|
+
const roleName = currentUser?.roleName;
|
|
672
|
+
if (typeof roleName === 'string') {
|
|
673
|
+
const normalized = normalize(roleName);
|
|
674
|
+
if (normalized === 'superadmin')
|
|
675
|
+
return true;
|
|
676
|
+
if (o === 'system')
|
|
677
|
+
return false;
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
// Fallback: handle role/roles arrays
|
|
681
|
+
const roleField = currentUser.role ?? currentUser.roles;
|
|
682
|
+
const roles = Array.isArray(roleField) ? roleField : [];
|
|
610
683
|
const roleNames = new Set(roles.map((r) => normalize(r.name)));
|
|
611
684
|
if (roleNames.has('superadmin'))
|
|
612
685
|
return true;
|
|
@@ -670,7 +743,7 @@ function SchemaField({ uid, name, attr, value, onChange, disabled, inputClassNam
|
|
|
670
743
|
const inputType = pickInputType(inputTypeFor(attr), name);
|
|
671
744
|
return (_jsx(Input, { variant: "bordered", classNames: inputClassNames, id: id, name: name, label: label, isReadOnly: attr.readOnly, labelPlacement: "outside-top", type: inputType, value: value ?? '', onChange: (e) => onChange(name, inputType === 'number'
|
|
672
745
|
? numberOrEmpty(e.target.value)
|
|
673
|
-
: e.target.value), isDisabled: disabled, description: description, className: "w-full", isRequired: required, autoComplete: name.toLowerCase() === 'password' ? 'new-password' : undefined }));
|
|
746
|
+
: e.target.value), isDisabled: disabled, description: description, className: "w-full", isRequired: required, minLength: head?.minLength, maxLength: head?.maxLength, pattern: head?.pattern, autoComplete: name.toLowerCase() === 'password' ? 'new-password' : undefined }));
|
|
674
747
|
}
|
|
675
748
|
function isHiddenAttr(attr, isEdit) {
|
|
676
749
|
const head = Array.isArray(attr) ? (attr?.[0] ?? {}) : (attr ?? {});
|
|
@@ -683,10 +756,15 @@ function isHiddenAttr(attr, isEdit) {
|
|
|
683
756
|
return false;
|
|
684
757
|
}
|
|
685
758
|
function isPhoneAttr(name, attr) {
|
|
759
|
+
const head = Array.isArray(attr) ? (attr?.[0] ?? {}) : (attr ?? {});
|
|
760
|
+
const fmt = String(head.format || attr?.format || '').toLowerCase();
|
|
761
|
+
// Explicit "text" format bypasses automatic phone detection
|
|
762
|
+
if (fmt === 'text')
|
|
763
|
+
return false;
|
|
686
764
|
const byName = /phone/i.test(name);
|
|
687
|
-
const byFormat =
|
|
688
|
-
const byType = String(attr?.type || '').toLowerCase() === 'phone';
|
|
689
|
-
const byMask = typeof
|
|
765
|
+
const byFormat = fmt === 'phone';
|
|
766
|
+
const byType = String(head.type || attr?.type || '').toLowerCase() === 'phone';
|
|
767
|
+
const byMask = typeof head.mask === 'string' && head.mask.includes('x');
|
|
690
768
|
return byName || byFormat || byType || byMask;
|
|
691
769
|
}
|
|
692
770
|
function isPasswordAttr(name, attr) {
|
|
@@ -721,7 +799,8 @@ function normalizeIdsArray(raw) {
|
|
|
721
799
|
if (!raw)
|
|
722
800
|
return [];
|
|
723
801
|
if (Array.isArray(raw)) {
|
|
724
|
-
return raw
|
|
802
|
+
return raw
|
|
803
|
+
.map((v) => {
|
|
725
804
|
if (typeof v === 'string')
|
|
726
805
|
return v;
|
|
727
806
|
if (v && typeof v === 'object') {
|
|
@@ -732,7 +811,8 @@ function normalizeIdsArray(raw) {
|
|
|
732
811
|
null);
|
|
733
812
|
}
|
|
734
813
|
return null;
|
|
735
|
-
})
|
|
814
|
+
})
|
|
815
|
+
.filter((v) => !!v);
|
|
736
816
|
}
|
|
737
817
|
return [];
|
|
738
818
|
}
|
|
@@ -769,7 +849,7 @@ function numberOrEmpty(v) {
|
|
|
769
849
|
}
|
|
770
850
|
function isAddressAttr(name, attr) {
|
|
771
851
|
const fname = String(name || '').toLowerCase();
|
|
772
|
-
const byName = /
|
|
852
|
+
const byName = /address/i.test(fname);
|
|
773
853
|
const byFormat = String(attr?.format || '').toLowerCase() === 'address';
|
|
774
854
|
const byPopulate = typeof attr?.populate === 'string' &&
|
|
775
855
|
attr.populate.length > 0;
|
|
@@ -797,7 +877,7 @@ async function findUniqueSlug(model, baseSlug, currentId) {
|
|
|
797
877
|
const checkSlug = attempt === 0 ? baseSlug : `${baseSlug}-${attempt}`;
|
|
798
878
|
try {
|
|
799
879
|
// Check if slug exists in the model
|
|
800
|
-
const res = await api.list(model, 0, 1, { slug: checkSlug });
|
|
880
|
+
const res = await api.list(model, { page: 0, limit: 1, where: { slug: checkSlug } });
|
|
801
881
|
const existing = res.data?.[0];
|
|
802
882
|
if (!existing ||
|
|
803
883
|
(currentId && String(existing.id || existing._id) === String(currentId))) {
|
|
@@ -5,6 +5,8 @@ import { useDispatch, useSelector } from 'react-redux';
|
|
|
5
5
|
import { useRouter } from 'next/navigation';
|
|
6
6
|
import { clearSession } from '../state/sessionSlice';
|
|
7
7
|
import { Button } from '@heroui/react';
|
|
8
|
+
import { AuthClient } from '../lib/AuthClient';
|
|
9
|
+
import { RealtimeClient } from '../lib/RealtimeClient';
|
|
8
10
|
import Link from 'next/link';
|
|
9
11
|
export function Sidebar() {
|
|
10
12
|
const router = useRouter();
|
|
@@ -13,11 +15,8 @@ export function Sidebar() {
|
|
|
13
15
|
const systemStatus = useSelector((s) => s.nextMin.status);
|
|
14
16
|
const { items, status, error } = useSelector((s) => s.schemas);
|
|
15
17
|
const logout = React.useCallback(() => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
localStorage.removeItem('nextmin.user');
|
|
19
|
-
}
|
|
20
|
-
catch { }
|
|
18
|
+
AuthClient.clearSession();
|
|
19
|
+
RealtimeClient.getInstance().disconnect();
|
|
21
20
|
dispatch(clearSession());
|
|
22
21
|
router.replace('/admin/auth/sign-in');
|
|
23
22
|
}, [dispatch, router]);
|
|
@@ -59,14 +58,8 @@ export function Sidebar() {
|
|
|
59
58
|
// Read user info from localStorage (client only)
|
|
60
59
|
const [user, setUser] = React.useState(null);
|
|
61
60
|
React.useEffect(() => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (raw)
|
|
65
|
-
setUser(JSON.parse(raw));
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
setUser(null);
|
|
69
|
-
}
|
|
61
|
+
const u = AuthClient.getUser();
|
|
62
|
+
setUser(u);
|
|
70
63
|
}, []);
|
|
71
64
|
// —— Brand (logo/name) ——
|
|
72
65
|
const siteName = React.useMemo(() => (system?.siteName && system.siteName.trim()) || 'NextMin', [system]);
|
|
@@ -51,6 +51,8 @@ export function TableFilters({ model, value, onChange, busy, columns, visibleCol
|
|
|
51
51
|
if (keys === 'all')
|
|
52
52
|
return;
|
|
53
53
|
onVisibleColumnsChange(keys);
|
|
54
|
+
}, classNames: {
|
|
55
|
+
list: 'max-h-[300px] overflow-y-auto',
|
|
54
56
|
}, children: columns.map((col) => (_jsx(DropdownItem, { className: "capitalize", children: formatLabel(col) }, col))) })] }) })] }));
|
|
55
57
|
}
|
|
56
58
|
/** tiny inline chevron (no icon pkg) */
|
|
@@ -128,7 +128,7 @@ export const TiptapEditor = ({ value, onChange, className, placeholder = 'Start
|
|
|
128
128
|
}
|
|
129
129
|
else {
|
|
130
130
|
// Fallback to latest 6
|
|
131
|
-
const res = await api.list(selectedSchema.modelName, 0, 6);
|
|
131
|
+
const res = await api.list(selectedSchema.modelName, { page: 0, limit: 6 });
|
|
132
132
|
const payload = res?.data ?? res;
|
|
133
133
|
items =
|
|
134
134
|
payload?.items ??
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useRef } from 'react';
|
|
3
|
-
import { Bold, Italic, Underline as UnderlineIcon, List, ListOrdered, Quote, Code, Undo, Redo, Image as ImageIcon, LayoutTemplate, LayoutPanelLeft, AlignLeft, AlignCenter, AlignRight, Square, Table as TableIcon, Palette, PaintBucket, Type, MapPin } from 'lucide-react';
|
|
3
|
+
import { Bold, Italic, Underline as UnderlineIcon, List, ListOrdered, Quote, Code, Undo, Redo, Image as ImageIcon, LayoutTemplate, LayoutPanelLeft, AlignLeft, AlignCenter, AlignRight, Square, Table as TableIcon, Palette, PaintBucket, Type, MapPin, AlignJustify, Link as LinkIcon } from 'lucide-react';
|
|
4
4
|
import { cn } from '../../lib/utils';
|
|
5
5
|
import { uploadFile } from '../../lib/upload';
|
|
6
6
|
export const Toolbar = ({ editor, onDistrictGridClick }) => {
|
|
@@ -32,7 +32,18 @@ export const Toolbar = ({ editor, onDistrictGridClick }) => {
|
|
|
32
32
|
fileInputRef.current?.click();
|
|
33
33
|
};
|
|
34
34
|
const ToolbarButton = ({ onClick, isActive = false, children, disabled = false }) => (_jsx("button", { onClick: onClick, disabled: disabled, className: cn("p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors", isActive && "bg-gray-200 dark:bg-gray-700 text-blue-500", disabled && "opacity-50 cursor-not-allowed"), type: "button", children: children }));
|
|
35
|
-
return (_jsxs("div", { className: "border-b border-gray-200 dark:border-gray-800 p-2 flex flex-wrap gap-1 sticky top-0 bg-white dark:bg-zinc-950 z-10 items-center", children: [_jsx(ToolbarButton, { onClick: () => editor.chain().focus().toggleBold().run(), isActive: editor.isActive('bold'), children: _jsx(Bold, { size: 18 }) }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().toggleItalic().run(), isActive: editor.isActive('italic'), children: _jsx(Italic, { size: 18 }) }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().toggleUnderline().run(), isActive: editor.isActive('underline'), children: _jsx(UnderlineIcon, { size: 18 }) }), _jsx(
|
|
35
|
+
return (_jsxs("div", { className: "border-b border-gray-200 dark:border-gray-800 p-2 flex flex-wrap gap-1 sticky top-0 bg-white dark:bg-zinc-950 z-10 items-center", children: [_jsx(ToolbarButton, { onClick: () => editor.chain().focus().toggleBold().run(), isActive: editor.isActive('bold'), children: _jsx(Bold, { size: 18 }) }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().toggleItalic().run(), isActive: editor.isActive('italic'), children: _jsx(Italic, { size: 18 }) }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().toggleUnderline().run(), isActive: editor.isActive('underline'), children: _jsx(UnderlineIcon, { size: 18 }) }), _jsx(ToolbarButton, { onClick: () => {
|
|
36
|
+
const previousUrl = editor.getAttributes('link').href;
|
|
37
|
+
const url = window.prompt('URL', previousUrl);
|
|
38
|
+
if (url === null) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (url === '') {
|
|
42
|
+
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
46
|
+
}, isActive: editor.isActive('link'), children: _jsx(LinkIcon, { size: 18 }) }), _jsx("div", { className: "w-[1px] h-6 bg-gray-300 dark:bg-gray-700 mx-1" }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().setTextAlign('left').run(), isActive: editor.isActive({ textAlign: 'left' }), children: _jsx(AlignLeft, { size: 18 }) }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().setTextAlign('center').run(), isActive: editor.isActive({ textAlign: 'center' }), children: _jsx(AlignCenter, { size: 18 }) }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().setTextAlign('right').run(), isActive: editor.isActive({ textAlign: 'right' }), children: _jsx(AlignRight, { size: 18 }) }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().setTextAlign('justify').run(), isActive: editor.isActive({ textAlign: 'justify' }), children: _jsx(AlignJustify, { size: 18 }) }), _jsx("div", { className: "w-[1px] h-6 bg-gray-300 dark:bg-gray-700 mx-1" }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), isActive: editor.isActive('heading', { level: 1 }), children: _jsx("span", { className: "font-bold text-sm", children: "H1" }) }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), isActive: editor.isActive('heading', { level: 2 }), children: _jsx("span", { className: "font-bold text-sm", children: "H2" }) }), _jsx("div", { className: "w-[1px] h-6 bg-gray-300 dark:bg-gray-700 mx-1" }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().toggleBulletList().run(), isActive: editor.isActive('bulletList'), children: _jsx(List, { size: 18 }) }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().toggleOrderedList().run(), isActive: editor.isActive('orderedList'), children: _jsx(ListOrdered, { size: 18 }) }), _jsx("div", { className: "w-[1px] h-6 bg-gray-300 dark:bg-gray-700 mx-1" }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().toggleBlockquote().run(), isActive: editor.isActive('blockquote'), children: _jsx(Quote, { size: 18 }) }), _jsx(ToolbarButton, { onClick: () => editor.chain().focus().toggleCodeBlock().run(), isActive: editor.isActive('codeBlock'), children: _jsx(Code, { size: 18 }) }), _jsx("div", { className: "w-[1px] h-6 bg-gray-300 dark:bg-gray-700 mx-1" }), _jsx(ToolbarButton, { onClick: () => {
|
|
36
47
|
editor
|
|
37
48
|
.chain()
|
|
38
49
|
.focus()
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useState, useEffect } from 'react';
|
|
4
4
|
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, } from '@heroui/react';
|
|
5
|
-
import {
|
|
6
|
-
import { RefSingleSelect } from '../../RefSingleSelect';
|
|
5
|
+
import { RefSelect } from '../../RefSelect';
|
|
7
6
|
import { cn } from '../../../lib/utils';
|
|
8
7
|
import { api } from '../../../lib/api';
|
|
9
8
|
export const DistrictGridModal = ({ isOpen, onClose, onInsert, currentSpeciality, }) => {
|
|
@@ -68,5 +67,5 @@ export const DistrictGridModal = ({ isOpen, onClose, onInsert, currentSpeciality
|
|
|
68
67
|
? "bg-primary-50 border-primary-500 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400"
|
|
69
68
|
: "bg-transparent border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900"), children: "Doctors" }), _jsx("button", { onClick: () => setBaseType('hospitals'), className: cn("px-4 py-2 rounded-lg border text-sm font-medium transition-colors", baseType === 'hospitals'
|
|
70
69
|
? "bg-primary-50 border-primary-500 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400"
|
|
71
|
-
: "bg-transparent border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900"), children: "Hospitals" })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(
|
|
70
|
+
: "bg-transparent border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900"), children: "Hospitals" })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(RefSelect, { name: "districts", label: "Search & Select Districts", refModel: "Districts", value: selectedDistricts, onChange: (ids) => setSelectedDistricts(ids), multiple: true, pageSize: 1000 }), _jsx("p", { className: "text-xs text-gray-400", children: "Select one or more districts to create grid items." })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(RefSelect, { name: "speciality", label: "Search & Select Speciality", refModel: "Specialities", showKey: "name", value: selectedSpecialityId, onChange: (id) => setSelectedSpecialityId(id || ''), multiple: false, pageSize: 10000 }), _jsx("p", { className: "text-xs text-gray-400", children: "Leave empty to create URLs without speciality." }), specialitySlug && (_jsx("div", { className: "mt-1 px-2 py-1 bg-primary-50 dark:bg-primary-900/20 rounded border border-primary-200 dark:border-primary-800", children: _jsxs("p", { className: "text-xs text-primary-700 dark:text-primary-400", children: ["Slug: ", _jsx("code", { className: "font-mono", children: specialitySlug })] }) }))] }), selectedDistricts.length > 0 && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("label", { className: "text-sm font-medium", children: "URL Preview" }), _jsxs("div", { className: "px-3 py-2 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 max-h-32 overflow-y-auto", children: [_jsx("p", { className: "text-xs text-gray-500 mb-2", children: "Example URL pattern (actual district slugs will be used):" }), _jsx("code", { className: "text-xs text-gray-700 dark:text-gray-300", children: getPreviewUrl('[district-slug]') })] })] }))] }), _jsxs(ModalFooter, { children: [_jsx(Button, { variant: "flat", onPress: onClose, children: "Cancel" }), _jsx(Button, { color: "primary", onPress: handleInsert, isDisabled: selectedDistricts.length === 0, children: "Insert Grid" })] })] }) }) }));
|
|
72
71
|
};
|
|
@@ -5,7 +5,7 @@ import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button,
|
|
|
5
5
|
// RadioGroup, // Removed
|
|
6
6
|
// Radio, // Removed
|
|
7
7
|
Input, } from '@heroui/react';
|
|
8
|
-
import {
|
|
8
|
+
import { RefSelect } from '../../RefSelect';
|
|
9
9
|
import { cn } from '../../../lib/utils'; // Assuming cn utility is available
|
|
10
10
|
// Helper to get nested keys with recursion limit
|
|
11
11
|
const getAllKeys = (attributes, allSchemas, prefix = '', depth = 0) => {
|
|
@@ -83,7 +83,7 @@ export const SchemaInsertionModal = ({ isOpen, onClose, onInsert, schema, availa
|
|
|
83
83
|
base: "bg-white dark:bg-zinc-950 border border-gray-200 dark:border-gray-800",
|
|
84
84
|
header: "border-b border-gray-200 dark:border-gray-800",
|
|
85
85
|
footer: "border-t border-gray-200 dark:border-gray-800",
|
|
86
|
-
}, children: _jsx(ModalContent, { children: _jsxs(_Fragment, { children: [_jsxs(ModalHeader, { children: ["Insert ", schema.modelName] }), _jsxs(ModalBody, { className: "py-6 flex flex-col gap-6", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(
|
|
86
|
+
}, children: _jsx(ModalContent, { children: _jsxs(_Fragment, { children: [_jsxs(ModalHeader, { children: ["Insert ", schema.modelName] }), _jsxs(ModalBody, { className: "py-6 flex flex-col gap-6", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(RefSelect, { name: "items", label: "Search & Select", refModel: schema.modelName, value: selectedIds, onChange: (ids) => setSelectedIds(ids), multiple: true, pageSize: 10000 }), _jsx("p", { className: "text-xs text-gray-400", children: "Leave empty to fetch latest items automatically." })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("label", { className: "text-sm font-medium", children: "View Type" }), _jsxs("div", { className: "flex gap-4", children: [_jsx("button", { onClick: () => setViewType('grid'), className: cn("px-4 py-2 rounded-lg border text-sm font-medium transition-colors", viewType === 'grid'
|
|
87
87
|
? "bg-primary-50 border-primary-500 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400"
|
|
88
88
|
: "bg-transparent border-gray-200 dark:border-gray-800 hover:bg-gray-50"), children: "Grid (Pills)" }), _jsx("button", { onClick: () => setViewType('list'), className: cn("px-4 py-2 rounded-lg border text-sm font-medium transition-colors", viewType === 'list'
|
|
89
89
|
? "bg-primary-50 border-primary-500 text-primary-700 dark:bg-primary-900/20 dark:text-primary-400"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import React from 'react';
|
|
4
|
+
import DOMPurify from 'dompurify';
|
|
4
5
|
import { formatCell } from '../../views/list/formatters';
|
|
5
6
|
import { summarizeObject } from '../../views/list/jsonSummary';
|
|
6
7
|
// Hide technical keys in the viewer
|
|
@@ -32,6 +33,7 @@ function isPasswordByAttr(attr) {
|
|
|
32
33
|
}
|
|
33
34
|
// --- small helpers (duplicated minimal logic from table) ---
|
|
34
35
|
const IMG_EXT_RE = /(png|jpe?g|gif|webp|bmp|svg|avif|heic|heif)$/i;
|
|
36
|
+
const HTML_TAG_RE = /<([a-z1-6]+)(?:\s+[^>]*?)?>[\s\S]*?<\/\1>|<[a-z1-6]+(?:\s+[^>]*?)?\s*\/>|&[a-z0-9#]+;/i;
|
|
35
37
|
function isUrlLike(s) {
|
|
36
38
|
return /^https?:\/\//i.test(s) || s.startsWith('/') || s.startsWith('data:');
|
|
37
39
|
}
|
|
@@ -118,6 +120,34 @@ function formatDateTimeLocal(d) {
|
|
|
118
120
|
return d.toString();
|
|
119
121
|
}
|
|
120
122
|
}
|
|
123
|
+
/** Detect HH:mm or HH:mm:ss and format to localized time */
|
|
124
|
+
function formatTimeLocal(val) {
|
|
125
|
+
if (typeof val !== 'string')
|
|
126
|
+
return null;
|
|
127
|
+
const s = val.trim();
|
|
128
|
+
// Match HH:mm or HH:mm:ss (24h format)
|
|
129
|
+
const timeMatch = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
|
130
|
+
if (!timeMatch)
|
|
131
|
+
return null;
|
|
132
|
+
try {
|
|
133
|
+
const hours = parseInt(timeMatch[1], 10);
|
|
134
|
+
const minutes = parseInt(timeMatch[2], 10);
|
|
135
|
+
const seconds = timeMatch[3] ? parseInt(timeMatch[3], 10) : 0;
|
|
136
|
+
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const d = new Date();
|
|
140
|
+
d.setHours(hours, minutes, seconds);
|
|
141
|
+
return d.toLocaleTimeString(undefined, {
|
|
142
|
+
hour: '2-digit',
|
|
143
|
+
minute: '2-digit',
|
|
144
|
+
hour12: true,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
121
151
|
function isPrivate(attr) {
|
|
122
152
|
const a = (Array.isArray(attr) ? attr[0] : attr);
|
|
123
153
|
return !!a?.private;
|
|
@@ -152,12 +182,23 @@ function toLabel(v) {
|
|
|
152
182
|
}
|
|
153
183
|
return null;
|
|
154
184
|
}
|
|
185
|
+
function isBadLabel(s) {
|
|
186
|
+
if (!s)
|
|
187
|
+
return true;
|
|
188
|
+
if (s.length > 50)
|
|
189
|
+
return true;
|
|
190
|
+
if (s.startsWith('http'))
|
|
191
|
+
return true;
|
|
192
|
+
if (/^[a-fA-F0-9]{24}$/.test(s))
|
|
193
|
+
return true;
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
155
196
|
function listLabels(arr) {
|
|
156
197
|
if (!Array.isArray(arr))
|
|
157
198
|
return null;
|
|
158
199
|
const labels = arr
|
|
159
200
|
.map((it) => toLabel(it))
|
|
160
|
-
.filter((s) => !!s && !!s.trim());
|
|
201
|
+
.filter((s) => !!s && !!s.trim() && !isBadLabel(s));
|
|
161
202
|
return labels.length ? labels : null;
|
|
162
203
|
}
|
|
163
204
|
function summarizeList(labels, max = 10) {
|
|
@@ -210,10 +251,17 @@ function maybeParseJson(val) {
|
|
|
210
251
|
return val;
|
|
211
252
|
}
|
|
212
253
|
}
|
|
213
|
-
function renderValue(input, key) {
|
|
254
|
+
function renderValue(input, key, attr) {
|
|
214
255
|
const val = maybeParseJson(input);
|
|
215
256
|
if (val == null)
|
|
216
257
|
return _jsx("span", { className: "text-foreground/50", children: "\u2014" });
|
|
258
|
+
// Detect rich text (explicit or heuristic)
|
|
259
|
+
const a = Array.isArray(attr) ? attr[0] : attr;
|
|
260
|
+
const isRich = a?.rich === true;
|
|
261
|
+
const looksLikeHtml = typeof val === 'string' && HTML_TAG_RE.test(val);
|
|
262
|
+
if ((isRich || looksLikeHtml) && typeof val === 'string' && val.length > 3) {
|
|
263
|
+
return (_jsx("div", { className: "prose prose-sm max-w-none dark:prose-invert", dangerouslySetInnerHTML: { __html: DOMPurify.sanitize(val) } }));
|
|
264
|
+
}
|
|
217
265
|
// Only parse dates for known date fields (createdAt, updatedAt, deletedAt)
|
|
218
266
|
if (isDateField(key)) {
|
|
219
267
|
const d = tryParseDate(val);
|
|
@@ -222,38 +270,51 @@ function renderValue(input, key) {
|
|
|
222
270
|
return (_jsx("time", { dateTime: iso, title: iso, className: "whitespace-pre-wrap", children: formatDateTimeLocal(d) }));
|
|
223
271
|
}
|
|
224
272
|
}
|
|
273
|
+
// Detect and format plain time strings (e.g. "14:00" -> "2:00 PM")
|
|
274
|
+
const localizedTime = formatTimeLocal(val);
|
|
275
|
+
if (localizedTime)
|
|
276
|
+
return _jsx("span", { children: localizedTime });
|
|
225
277
|
// Detect images
|
|
226
278
|
const urls = extractAllImageUrls(val);
|
|
227
279
|
if (urls.length)
|
|
228
280
|
return _jsx(ImageThumbs, { urls: urls });
|
|
229
281
|
// Primitive or simple object/array formatting via shared formatter
|
|
230
282
|
if (Array.isArray(val)) {
|
|
283
|
+
const head = Array.isArray(attr) ? attr[0] : attr;
|
|
284
|
+
const showKey = head?.show;
|
|
231
285
|
// Prefer shared summarizer for arrays
|
|
232
286
|
if (val.length && typeof val[0] === 'object') {
|
|
233
287
|
// Show each summarized object on its own line for readability
|
|
234
288
|
const lines = val
|
|
235
|
-
.map((v) => (v && typeof v === 'object' ? summarizeObject(v)?.text : null))
|
|
289
|
+
.map((v) => (v && typeof v === 'object' ? summarizeObject(v, showKey)?.text : null))
|
|
236
290
|
.filter((s) => !!s && !!s.trim());
|
|
237
291
|
if (lines.length) {
|
|
238
292
|
return (_jsx("div", { className: "grid gap-1", children: lines.map((s, i) => (_jsx("div", { className: "text-foreground/90", children: s }, i))) }));
|
|
239
293
|
}
|
|
240
294
|
}
|
|
241
295
|
// Fallback detailed rendering
|
|
242
|
-
return (_jsx("div", { className: "grid gap-2", children: val.map((v, i) =>
|
|
296
|
+
return (_jsx("div", { className: "grid gap-2", children: val.map((v, i) => {
|
|
297
|
+
const head = Array.isArray(attr) ? attr[0] : attr;
|
|
298
|
+
const subAttributes = head?.attributes;
|
|
299
|
+
const subShowKey = head?.show;
|
|
300
|
+
return (_jsx("div", { className: "rounded-medium border border-default-100 p-2", children: typeof v === 'object' && v !== null ? (_jsx(ObjectView, { obj: v, attributes: subAttributes, showKey: subShowKey })) : (_jsx("span", { children: formatCell(v, key) })) }, i));
|
|
301
|
+
}) }));
|
|
243
302
|
}
|
|
244
303
|
if (typeof val === 'object') {
|
|
245
|
-
const
|
|
304
|
+
const showKey = (Array.isArray(attr) ? attr[0] : attr)?.show;
|
|
305
|
+
const condensed = summarizeObject(val, showKey)?.text || tryCondenseEntity(val);
|
|
246
306
|
if (condensed)
|
|
247
307
|
return _jsx("span", { children: condensed });
|
|
248
|
-
|
|
308
|
+
const head = Array.isArray(attr) ? attr[0] : attr;
|
|
309
|
+
return _jsx(ObjectView, { obj: val, attributes: head?.attributes, showKey: showKey });
|
|
249
310
|
}
|
|
250
311
|
return _jsx("span", { children: formatCell(val, key) });
|
|
251
312
|
}
|
|
252
|
-
function ObjectView({ obj }) {
|
|
313
|
+
function ObjectView({ obj, attributes, showKey }) {
|
|
253
314
|
const entries = Object.entries(obj).filter(([k]) => !HIDDEN_KEYS.has(k) && !isPasswordKeyRaw(k));
|
|
254
315
|
if (!entries.length)
|
|
255
316
|
return _jsx("span", { className: "text-foreground/50", children: '{ }' });
|
|
256
|
-
return (_jsx("div", { className: "grid gap-1", children: entries.map(([k, v]) => (_jsxs("div", { className: "flex gap-2 items-start", children: [_jsxs("div", { className: "min-w-24 text-foreground/60 text-sm", children: [humanizeLabel(k), ":"] }), _jsx("div", { className: "flex-1", children: renderValue(v, k) })] }, k))) }));
|
|
317
|
+
return (_jsx("div", { className: "grid gap-1", children: entries.map(([k, v]) => (_jsxs("div", { className: "flex gap-2 items-start", children: [_jsxs("div", { className: "min-w-24 text-foreground/60 text-sm", children: [humanizeLabel(k), ":"] }), _jsx("div", { className: "flex-1", children: renderValue(v, k, attributes?.[k]) })] }, k))) }));
|
|
257
318
|
}
|
|
258
319
|
export function DynamicViewer({ schema, data }) {
|
|
259
320
|
// Use schema order if available, otherwise object keys order
|
|
@@ -270,5 +331,5 @@ export function DynamicViewer({ schema, data }) {
|
|
|
270
331
|
return base;
|
|
271
332
|
return Object.keys(data || {}).filter((k) => !HIDDEN_KEYS.has(k) && !isPasswordKeyRaw(k));
|
|
272
333
|
}, [attributes, data]);
|
|
273
|
-
return (_jsx("div", { className: "grid gap-1", children: keys.map((key) => (_jsx(FieldRow, { label: humanizeLabel(key), children: renderValue(data?.[key], key) }, key))) }));
|
|
334
|
+
return (_jsx("div", { className: "grid gap-1", children: keys.map((key) => (_jsx(FieldRow, { label: humanizeLabel(key), children: renderValue(data?.[key], key, attributes[key]) }, key))) }));
|
|
274
335
|
}
|