@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.
Files changed (56) hide show
  1. package/README.md +29 -3
  2. package/dist/auth/SignInForm.js +4 -2
  3. package/dist/components/AdminApp.js +15 -38
  4. package/dist/components/ArchitectureDemo.d.ts +1 -0
  5. package/dist/components/ArchitectureDemo.js +45 -0
  6. package/dist/components/PhoneInput.d.ts +3 -0
  7. package/dist/components/PhoneInput.js +23 -19
  8. package/dist/components/RefSelect.d.ts +16 -0
  9. package/dist/components/RefSelect.js +225 -0
  10. package/dist/components/SchemaForm.js +131 -51
  11. package/dist/components/Sidebar.js +6 -13
  12. package/dist/components/TableFilters.js +2 -0
  13. package/dist/components/editor/TiptapEditor.js +1 -1
  14. package/dist/components/editor/Toolbar.js +13 -2
  15. package/dist/components/editor/components/DistrictGridModal.js +2 -3
  16. package/dist/components/editor/components/SchemaInsertionModal.js +2 -2
  17. package/dist/components/viewer/DynamicViewer.js +70 -9
  18. package/dist/hooks/useRealtime.d.ts +8 -0
  19. package/dist/hooks/useRealtime.js +30 -0
  20. package/dist/index.d.ts +6 -0
  21. package/dist/index.js +6 -0
  22. package/dist/lib/AuthClient.d.ts +15 -0
  23. package/dist/lib/AuthClient.js +63 -0
  24. package/dist/lib/QueryBuilder.d.ts +29 -0
  25. package/dist/lib/QueryBuilder.js +74 -0
  26. package/dist/lib/RealtimeClient.d.ts +16 -0
  27. package/dist/lib/RealtimeClient.js +56 -0
  28. package/dist/lib/api.d.ts +15 -3
  29. package/dist/lib/api.js +71 -58
  30. package/dist/lib/auth.js +7 -2
  31. package/dist/lib/types.d.ts +16 -0
  32. package/dist/nextmin.css +1 -1
  33. package/dist/providers/NextMinProvider.d.ts +8 -1
  34. package/dist/providers/NextMinProvider.js +40 -8
  35. package/dist/router/NextMinRouter.d.ts +1 -1
  36. package/dist/router/NextMinRouter.js +1 -1
  37. package/dist/state/schemasSlice.js +8 -2
  38. package/dist/views/DashboardPage.js +56 -42
  39. package/dist/views/ListPage.js +34 -4
  40. package/dist/views/SettingsEdit.js +25 -2
  41. package/dist/views/list/DataTableHero.js +103 -46
  42. package/dist/views/list/ListHeader.d.ts +3 -1
  43. package/dist/views/list/ListHeader.js +2 -2
  44. package/dist/views/list/jsonSummary.d.ts +3 -3
  45. package/dist/views/list/jsonSummary.js +47 -20
  46. package/dist/views/list/useListData.js +5 -1
  47. package/package.json +8 -4
  48. package/dist/components/RefMultiSelect.d.ts +0 -22
  49. package/dist/components/RefMultiSelect.js +0 -113
  50. package/dist/components/RefSingleSelect.d.ts +0 -17
  51. package/dist/components/RefSingleSelect.js +0 -110
  52. package/dist/lib/schemaService.d.ts +0 -2
  53. package/dist/lib/schemaService.js +0 -39
  54. package/dist/state/schemaLive.d.ts +0 -2
  55. package/dist/state/schemaLive.js +0 -19
  56. /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 { RefMultiSelect } from './RefMultiSelect';
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
- return single ? [single] : [];
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
- return configured.length > 0 ? configured : ['admin', 'superadmin'];
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
- return userRolesLC.some((r) => bypassRoles.includes(r));
341
+ const result = userRolesLC.some((r) => bypassRoles.includes(r));
342
+ return result;
336
343
  }, [sessionRole, bypassRoles]);
337
- const fields = useMemo(() => Object.entries(schema.attributes)
338
- .filter(([name]) => !AUDIT_FIELDS.has(name))
339
- // Force-hide any linkage field named "baseId"
340
- .filter(([name]) => name !== 'baseId' && name !== 'exId')
341
- .filter(([, attr]) => canBypassPrivacy ? true : !attr?.private)
342
- .filter(([, attr]) => !isHiddenAttr(attr, isEdit))
343
- .filter(([name, attr]) => !(isEdit && isPasswordAttr(name, attr)))
344
- .map(([name, attr]) => ({ name, attr })), [schema, canBypassPrivacy, isEdit]);
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, baseId, exId, __childId, ...rest } = form;
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]) => (_jsx(Input, { variant: "bordered", classNames: inputClassNames, id: `${name}-${idx}-${k}`, name: `${name}.${idx}.${k}`, label: a?.label ?? formatLabel(k), labelPlacement: "outside-top", type: "text", value: typeof it?.[k] === 'string' ? it[k] : '', onChange: (e) => {
431
- const next = items.slice();
432
- next[idx] = {
433
- ...(next[idx] || {}),
434
- [k]: e.target.value,
435
- };
436
- handleChange(name, next);
437
- }, isDisabled: busy, description: a?.description, className: "w-full", isRequired: !!a?.required }, `${name}-${idx}-${k}`))), _jsx("div", { className: "col-span-2 flex justify-between", children: _jsx(Button, { size: "sm", variant: "flat", color: "danger", isDisabled: !canRemove, onPress: () => {
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]) => a?.format === 'textarea' ? (_jsx(Textarea, { variant: "bordered", classNames: inputClassNames, id: `${name}-${k}`, name: `${name}.${k}`, label: a?.label ?? formatLabel(k), labelPlacement: "outside", value: typeof obj?.[k] === 'string' ? obj[k] : '', onChange: (e) => {
452
- const next = { ...(obj || {}), [k]: e.target.value };
453
- handleChange(name, next);
454
- }, minRows: 3, maxRows: 20, isDisabled: busy, description: a?.description, className: "w-full col-span-2", isRequired: !!a?.required }, `${name}-${k}`)) : (_jsx(Input, { variant: "bordered", classNames: inputClassNames, id: `${name}-${k}`, name: `${name}.${k}`, label: a?.label ?? formatLabel(k), labelPlacement: "outside-top", type: "text", value: typeof obj?.[k] === 'string' ? obj[k] : '', onChange: (e) => {
455
- const next = { ...(obj || {}), [k]: e.target.value };
456
- handleChange(name, next);
457
- }, isDisabled: busy, description: a?.description, className: "w-full", isRequired: !!a?.required }, `${name}-${k}`))) })] }, name));
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 attr?.mask === 'string' ? attr.mask : '';
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
- const roles = currentUser.role ?? currentUser.roles ?? [];
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 = String(attr?.format || '').toLowerCase() === 'phone';
688
- const byType = String(attr?.type || '').toLowerCase() === 'phone';
689
- const byMask = typeof attr?.mask === 'string' && attr.mask.includes('x');
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.map((v) => {
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 = /(address|location)/i.test(fname);
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
- try {
17
- localStorage.removeItem('nextmin.token');
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
- try {
63
- const raw = localStorage.getItem('nextmin.user');
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("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("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: () => {
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 { RefMultiSelect } from '../../RefMultiSelect';
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(RefMultiSelect, { name: "districts", label: "Search & Select Districts", refModel: "Districts", value: selectedDistricts, onChange: (ids) => setSelectedDistricts(ids), 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(RefSingleSelect, { name: "speciality", label: "Search & Select Speciality", refModel: "Specialities", showKey: "name", value: selectedSpecialityId, onChange: (id) => setSelectedSpecialityId(id || ''), 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" })] })] }) }) }));
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 { RefMultiSelect } from '../../RefMultiSelect';
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(RefMultiSelect, { name: "items", label: "Search & Select", refModel: schema.modelName, value: selectedIds, onChange: (ids) => setSelectedIds(ids), 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'
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) => (_jsx("div", { className: "rounded-medium border border-default-100 p-2", children: typeof v === 'object' && v !== null ? (_jsx(ObjectView, { obj: v })) : (_jsx("span", { children: formatCell(v, key) })) }, 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 condensed = summarizeObject(val)?.text || tryCondenseEntity(val);
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
- return _jsx(ObjectView, { obj: val });
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
  }
@@ -0,0 +1,8 @@
1
+ export interface RealtimeEvent {
2
+ event: string;
3
+ payload: any;
4
+ }
5
+ export declare function useRealtime(): {
6
+ isConnected: boolean;
7
+ lastEvent: RealtimeEvent | null;
8
+ };