@airoom/nextmin-react 0.1.0

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 (95) hide show
  1. package/LICENSE +49 -0
  2. package/README.md +133 -0
  3. package/dist/auth/AuthPage.d.ts +1 -0
  4. package/dist/auth/AuthPage.js +23 -0
  5. package/dist/auth/ForgotPasswordForm.d.ts +1 -0
  6. package/dist/auth/ForgotPasswordForm.js +28 -0
  7. package/dist/auth/SignInForm.d.ts +6 -0
  8. package/dist/auth/SignInForm.js +38 -0
  9. package/dist/auth/SignUpForm.d.ts +3 -0
  10. package/dist/auth/SignUpForm.js +30 -0
  11. package/dist/components/AddressAutocomplete.d.ts +21 -0
  12. package/dist/components/AddressAutocomplete.js +182 -0
  13. package/dist/components/AdminApp.d.ts +1 -0
  14. package/dist/components/AdminApp.js +134 -0
  15. package/dist/components/ConfirmDialog.d.ts +12 -0
  16. package/dist/components/ConfirmDialog.js +6 -0
  17. package/dist/components/FileUploader.d.ts +32 -0
  18. package/dist/components/FileUploader.js +480 -0
  19. package/dist/components/NoAccess.d.ts +3 -0
  20. package/dist/components/NoAccess.js +5 -0
  21. package/dist/components/PasswordInput.d.ts +19 -0
  22. package/dist/components/PasswordInput.js +11 -0
  23. package/dist/components/PhoneInput.d.ts +23 -0
  24. package/dist/components/PhoneInput.js +147 -0
  25. package/dist/components/RefMultiSelect.d.ts +14 -0
  26. package/dist/components/RefMultiSelect.js +76 -0
  27. package/dist/components/RefSingleSelect.d.ts +17 -0
  28. package/dist/components/RefSingleSelect.js +52 -0
  29. package/dist/components/SchemaForm.d.ts +13 -0
  30. package/dist/components/SchemaForm.js +592 -0
  31. package/dist/components/SectionLoader.d.ts +3 -0
  32. package/dist/components/SectionLoader.js +7 -0
  33. package/dist/components/Sidebar.d.ts +1 -0
  34. package/dist/components/Sidebar.js +87 -0
  35. package/dist/components/TableFilters.d.ts +16 -0
  36. package/dist/components/TableFilters.js +69 -0
  37. package/dist/components/TableSkeleton.d.ts +7 -0
  38. package/dist/components/TableSkeleton.js +5 -0
  39. package/dist/hooks/useGoogleMapsKey.d.ts +5 -0
  40. package/dist/hooks/useGoogleMapsKey.js +16 -0
  41. package/dist/index.d.ts +2 -0
  42. package/dist/index.js +2 -0
  43. package/dist/lib/api.d.ts +31 -0
  44. package/dist/lib/api.js +94 -0
  45. package/dist/lib/auth.d.ts +23 -0
  46. package/dist/lib/auth.js +51 -0
  47. package/dist/lib/googleLoader.d.ts +1 -0
  48. package/dist/lib/googleLoader.js +25 -0
  49. package/dist/lib/schemaService.d.ts +2 -0
  50. package/dist/lib/schemaService.js +39 -0
  51. package/dist/lib/schemaUtils.d.ts +4 -0
  52. package/dist/lib/schemaUtils.js +18 -0
  53. package/dist/lib/types.d.ts +50 -0
  54. package/dist/lib/types.js +1 -0
  55. package/dist/nextmin.css +1 -0
  56. package/dist/providers/NextMinProvider.d.ts +5 -0
  57. package/dist/providers/NextMinProvider.js +30 -0
  58. package/dist/router/AdminRouteNormalizer.d.ts +1 -0
  59. package/dist/router/AdminRouteNormalizer.js +32 -0
  60. package/dist/router/NextMinRouter.d.ts +1 -0
  61. package/dist/router/NextMinRouter.js +99 -0
  62. package/dist/state/nextMinSlice.d.ts +14 -0
  63. package/dist/state/nextMinSlice.js +34 -0
  64. package/dist/state/schemaLive.d.ts +2 -0
  65. package/dist/state/schemaLive.js +19 -0
  66. package/dist/state/schemasSlice.d.ts +20 -0
  67. package/dist/state/schemasSlice.js +43 -0
  68. package/dist/state/sessionSlice.d.ts +10 -0
  69. package/dist/state/sessionSlice.js +18 -0
  70. package/dist/state/store.d.ts +28 -0
  71. package/dist/state/store.js +7 -0
  72. package/dist/views/CreateEditPage.d.ts +4 -0
  73. package/dist/views/CreateEditPage.js +64 -0
  74. package/dist/views/DashboardPage.d.ts +1 -0
  75. package/dist/views/DashboardPage.js +107 -0
  76. package/dist/views/ListPage.d.ts +5 -0
  77. package/dist/views/ListPage.js +76 -0
  78. package/dist/views/NextNotFound.d.ts +1 -0
  79. package/dist/views/NextNotFound.js +6 -0
  80. package/dist/views/ProfilePage.d.ts +1 -0
  81. package/dist/views/ProfilePage.js +193 -0
  82. package/dist/views/SettingsEdit.d.ts +2 -0
  83. package/dist/views/SettingsEdit.js +87 -0
  84. package/dist/views/list/DataTableHero.d.ts +22 -0
  85. package/dist/views/list/DataTableHero.js +350 -0
  86. package/dist/views/list/ListHeader.d.ts +8 -0
  87. package/dist/views/list/ListHeader.js +7 -0
  88. package/dist/views/list/Pagination.d.ts +8 -0
  89. package/dist/views/list/Pagination.js +5 -0
  90. package/dist/views/list/formatters.d.ts +2 -0
  91. package/dist/views/list/formatters.js +62 -0
  92. package/dist/views/list/useListData.d.ts +10 -0
  93. package/dist/views/list/useListData.js +79 -0
  94. package/package.json +51 -0
  95. package/tsconfig.json +18 -0
@@ -0,0 +1,193 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useEffect, useState, useCallback } from 'react';
4
+ import { Input, Button, Card, CardBody, CardHeader, Divider, } from '@heroui/react';
5
+ import { request, api } from '../lib/api';
6
+ import { useDispatch } from 'react-redux';
7
+ import { setSession } from '../state/sessionSlice';
8
+ import { FileUploader } from '../components/FileUploader';
9
+ import { PasswordInput } from '../components/PasswordInput';
10
+ // Small helpers for current user endpoints (plural 'users' per nextmin-node)
11
+ async function getMe() {
12
+ return request('/auth/users/me');
13
+ }
14
+ async function changePassword(payload) {
15
+ return request('/auth/users/change-password', { method: 'POST', body: payload });
16
+ }
17
+ export default function ProfilePage() {
18
+ const dispatch = useDispatch();
19
+ const [loading, setLoading] = useState(true);
20
+ const [saving, setSaving] = useState(false);
21
+ const [pwdBusy, setPwdBusy] = useState(false);
22
+ const [error, setError] = useState(null);
23
+ const [success, setSuccess] = useState(null);
24
+ const [me, setMe] = useState(null);
25
+ // Editable fields (keep minimal and generic)
26
+ const [name, setName] = useState('');
27
+ const [firstName, setFirstName] = useState('');
28
+ const [lastName, setLastName] = useState('');
29
+ const [email, setEmail] = useState('');
30
+ const [picture, setPicture] = useState([]);
31
+ const [pictureKey, setPictureKey] = useState(null);
32
+ // Password fields
33
+ const [oldPassword, setOldPassword] = useState('');
34
+ const [newPassword, setNewPassword] = useState('');
35
+ useEffect(() => {
36
+ let cancelled = false;
37
+ const extractUrl = (v) => {
38
+ if (!v)
39
+ return null;
40
+ if (typeof v === 'string')
41
+ return v;
42
+ if (typeof v === 'object') {
43
+ const u = (typeof v.url === 'string' && v.url) ||
44
+ (typeof v.src === 'string' && v.src) ||
45
+ (typeof v.value === 'string' && v.value) ||
46
+ null;
47
+ return u;
48
+ }
49
+ return null;
50
+ };
51
+ (async () => {
52
+ try {
53
+ setLoading(true);
54
+ const res = await getMe();
55
+ const user = res?.data ?? null;
56
+ if (!cancelled) {
57
+ setMe(user);
58
+ const baseName = user && user.name != null && String(user.name).trim() !== ''
59
+ ? String(user.name)
60
+ : `${user?.firstName ?? ''} ${user?.lastName ?? ''}`;
61
+ setName(String(baseName || '').trim());
62
+ setFirstName(String(user?.firstName ?? ''));
63
+ setLastName(String(user?.lastName ?? ''));
64
+ setEmail(String(user?.email ?? ''));
65
+ // Detect existing picture field and normalize to [string]
66
+ const picKeys = [
67
+ 'avatar',
68
+ 'photo',
69
+ 'profilePicture',
70
+ 'picture',
71
+ 'image',
72
+ ];
73
+ let foundKey = null;
74
+ let urls = [];
75
+ for (const k of picKeys) {
76
+ if (user && user[k] != null) {
77
+ foundKey = k;
78
+ const val = user[k];
79
+ if (Array.isArray(val)) {
80
+ urls = val.map(extractUrl).filter(Boolean);
81
+ }
82
+ else {
83
+ const one = extractUrl(val);
84
+ urls = one ? [one] : [];
85
+ }
86
+ break;
87
+ }
88
+ }
89
+ setPictureKey(foundKey);
90
+ setPicture(urls);
91
+ }
92
+ }
93
+ catch (e) {
94
+ if (!cancelled)
95
+ setError(e?.message || 'Failed to load profile');
96
+ }
97
+ finally {
98
+ if (!cancelled)
99
+ setLoading(false);
100
+ }
101
+ })();
102
+ return () => {
103
+ cancelled = true;
104
+ };
105
+ }, []);
106
+ const handleSave = useCallback(async () => {
107
+ if (!me?.id)
108
+ return;
109
+ try {
110
+ setSaving(true);
111
+ setError(null);
112
+ setSuccess(null);
113
+ const payload = {};
114
+ if (name.trim())
115
+ payload.name = name.trim();
116
+ if (firstName.trim())
117
+ payload.firstName = firstName.trim();
118
+ if (lastName.trim())
119
+ payload.lastName = lastName.trim();
120
+ if (email.trim())
121
+ payload.email = email.trim().toLowerCase();
122
+ // Picture handling: preserve existing key and shape
123
+ const key = pictureKey;
124
+ if (key) {
125
+ const wasArray = Array.isArray(me?.[key]);
126
+ if (picture.length === 0) {
127
+ payload[key] = wasArray ? [] : '';
128
+ }
129
+ else {
130
+ payload[key] = wasArray ? picture : picture[0];
131
+ }
132
+ }
133
+ else if (picture.length > 0) {
134
+ // default to 'avatar' if none existed
135
+ payload['avatar'] = picture[0];
136
+ }
137
+ const res = await api.update('users', me.id, payload);
138
+ const updated = res?.data ?? { ...me, ...payload };
139
+ // Update local copies
140
+ try {
141
+ const token = (typeof window !== 'undefined' &&
142
+ localStorage.getItem('nextmin.token')) ||
143
+ null;
144
+ if (typeof window !== 'undefined') {
145
+ localStorage.setItem('nextmin.user', JSON.stringify(updated));
146
+ }
147
+ if (token)
148
+ dispatch(setSession({ token, user: updated }));
149
+ }
150
+ catch { }
151
+ setMe(updated);
152
+ setSuccess('Profile updated.');
153
+ }
154
+ catch (e) {
155
+ setError(e?.message || 'Failed to save profile');
156
+ }
157
+ finally {
158
+ setSaving(false);
159
+ }
160
+ }, [me, name, firstName, lastName, email, picture, pictureKey, dispatch]);
161
+ const handleChangePassword = useCallback(async () => {
162
+ try {
163
+ setPwdBusy(true);
164
+ setError(null);
165
+ setSuccess(null);
166
+ if (!oldPassword || !newPassword) {
167
+ setError('Please provide both old and new passwords.');
168
+ return;
169
+ }
170
+ await changePassword({ oldPassword, newPassword });
171
+ setSuccess('Password changed successfully.');
172
+ setOldPassword('');
173
+ setNewPassword('');
174
+ }
175
+ catch (e) {
176
+ setError(e?.message || 'Failed to change password');
177
+ }
178
+ finally {
179
+ setPwdBusy(false);
180
+ }
181
+ }, [oldPassword, newPassword]);
182
+ return (_jsxs("div", { className: "grid gap-4 px-4", children: [_jsx("div", { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", children: _jsx("h2", { className: "m-0 text-xl font-semibold", children: "My Profile" }) }), _jsx(Divider, { className: "my-3" }), loading ? (_jsx("div", { children: "Loading\u2026" })) : (_jsxs(_Fragment, { children: [(error || success) && (_jsx("div", { className: 'rounded-md px-3 py-2 ' +
183
+ (error
184
+ ? 'bg-red-100 text-red-700'
185
+ : 'bg-green-100 text-green-700'), children: error || success })), _jsxs("div", { className: "grid grid-cols-1 md:grid-cols-2 gap-4", children: [_jsxs(Card, { children: [_jsx(CardHeader, { className: "font-medium", children: "Profile info" }), _jsxs(CardBody, { className: "space-y-8", children: [_jsxs("div", { className: "grid grid-cols-1 md:grid-cols-2 gap-4", children: [_jsx(Input, { label: "First name", labelPlacement: "outside", variant: "bordered", value: firstName, onChange: (e) => setFirstName(e.target.value), classNames: { inputWrapper: 'bg-transparent shadow-none' } }), _jsx(Input, { label: "Last name", labelPlacement: "outside", variant: "bordered", value: lastName, onChange: (e) => setLastName(e.target.value), classNames: { inputWrapper: 'bg-transparent shadow-none' } })] }), _jsx(Input, { label: "Display name", labelPlacement: "outside", variant: "bordered", description: "Shown in some places if provided; otherwise composed from first/last name", value: name, onChange: (e) => setName(e.target.value), classNames: { inputWrapper: 'bg-transparent shadow-none' } }), _jsx(Input, { type: "email", label: "Email", labelPlacement: "outside", variant: "bordered", value: email, onChange: (e) => setEmail(e.target.value), classNames: { inputWrapper: 'bg-transparent shadow-none' } }), _jsx("div", { children: _jsx(FileUploader, { name: "profilePicture", label: "Profile picture", multiple: false, value: picture.length ? picture[0] : null, onChange: (v) => {
186
+ if (Array.isArray(v))
187
+ setPicture(v);
188
+ else if (typeof v === 'string' && v)
189
+ setPicture([v]);
190
+ else
191
+ setPicture([]);
192
+ }, accept: ['image/*'], description: "Upload a square image for best results" }) }), _jsx("div", { children: _jsx(Button, { color: "primary", isLoading: saving, onPress: handleSave, children: "Save changes" }) })] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { className: "font-medium", children: "Change password" }), _jsxs(CardBody, { className: "space-y-8", children: [_jsx(PasswordInput, { name: "oldPassword", label: "Current password", value: oldPassword, onChange: (v) => setOldPassword(v), className: "w-full", classNames: { inputWrapper: 'bg-transparent shadow-none' } }), _jsx(PasswordInput, { name: "newPassword", label: "New password", value: newPassword, onChange: (v) => setNewPassword(v), description: "At least 8 characters", className: "w-full", classNames: { inputWrapper: 'bg-transparent shadow-none' } }), _jsx("div", { children: _jsx(Button, { color: "secondary", isLoading: pwdBusy, onPress: handleChangePassword, children: "Update password" }) })] })] })] })] }))] }));
193
+ }
@@ -0,0 +1,2 @@
1
+ export declare function SettingsEdit(): import("react/jsx-runtime").JSX.Element;
2
+ export default SettingsEdit;
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
+ import { useDispatch, useSelector } from 'react-redux';
5
+ import { ListHeader } from './list/ListHeader';
6
+ import { NoAccess } from '../components/NoAccess';
7
+ import { SchemaForm } from '../components/SchemaForm';
8
+ import { api, ApiError } from '../lib/api';
9
+ import { systemLoaded } from '../state/nextMinSlice';
10
+ const MODEL = 'settings'; // use lowercase to match router/api paths
11
+ const getId = (doc) => doc && (doc.id || doc._id) ? String(doc.id ?? doc._id) : undefined;
12
+ export function SettingsEdit() {
13
+ const dispatch = useDispatch();
14
+ const { items } = useSelector((s) => s.schemas);
15
+ const schema = useMemo(() => items.find((s) => s.modelName.toLowerCase() === MODEL.toLowerCase()), [items]);
16
+ const [doc, setDoc] = useState(null);
17
+ const [loading, setLoading] = useState(true);
18
+ const [saving, setSaving] = useState(false);
19
+ const [forbidden, setForbidden] = useState(null);
20
+ const [error, setError] = useState(null);
21
+ const fetchSingleton = useCallback(async () => {
22
+ setLoading(true);
23
+ setForbidden(null);
24
+ setError(null);
25
+ try {
26
+ // Only one Settings row exists; fetch first page with 1 item
27
+ const resp = await api.list(MODEL, 0, 1);
28
+ const first = resp?.data?.[0] ?? null;
29
+ setDoc(first);
30
+ const normalized = first == null
31
+ ? null
32
+ : {
33
+ apiKey: first.apiKey ?? undefined,
34
+ siteName: first.siteName ?? undefined,
35
+ googleMapsKey: first.googleMapsKey ?? undefined,
36
+ siteLogo: Array.isArray(first.siteLogo)
37
+ ? first.siteLogo
38
+ : typeof first.siteLogo === 'string' && first.siteLogo
39
+ ? [first.siteLogo]
40
+ : undefined,
41
+ };
42
+ dispatch(systemLoaded(normalized));
43
+ }
44
+ catch (e) {
45
+ if (e instanceof ApiError && e.status === 403) {
46
+ setForbidden('You are not permitted to view Settings.');
47
+ }
48
+ else {
49
+ setError(e?.message || 'Failed to fetch settings.');
50
+ }
51
+ }
52
+ finally {
53
+ setLoading(false);
54
+ }
55
+ }, []);
56
+ useEffect(() => {
57
+ void fetchSingleton();
58
+ }, [fetchSingleton]);
59
+ const handleSubmit = useCallback(async (payload) => {
60
+ setError(null);
61
+ const id = getId(doc);
62
+ if (!id) {
63
+ throw new Error('Settings document not found. Please run the initializer.');
64
+ }
65
+ setSaving(true);
66
+ try {
67
+ await api.update(MODEL, id, payload);
68
+ // Re-fetch to reflect any server-side normalization (e.g., processed file URLs)
69
+ await fetchSingleton();
70
+ }
71
+ catch (e) {
72
+ setError(e?.message || 'Save failed');
73
+ }
74
+ finally {
75
+ setSaving(false);
76
+ }
77
+ }, [doc, fetchSingleton]);
78
+ const title = schema?.modelName ?? 'Settings';
79
+ if (!schema) {
80
+ return (_jsx("div", { className: "p-4 text-danger text-sm", children: "Settings schema not found." }));
81
+ }
82
+ if (forbidden) {
83
+ return (_jsxs("div", { className: "px-4", children: [_jsx(ListHeader, { title: title, createHref: "", loading: false }), _jsx(NoAccess, { message: forbidden })] }));
84
+ }
85
+ return (_jsxs("div", { className: "grid gap-3 px-4", children: [_jsx(ListHeader, { title: title, hideCreate: true, createHref: "", loading: loading || saving }), error ? (_jsx("div", { className: "text-danger text-sm", children: error })) : (_jsx(SchemaForm, { model: MODEL, schemaOverride: schema, initialValues: doc ?? undefined, submitLabel: "Save", busy: loading || saving, onSubmit: handleSubmit, showReset: false }))] }));
86
+ }
87
+ export default SettingsEdit;
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ type Props = {
3
+ modelName: string;
4
+ columns: string[];
5
+ topContent?: React.ReactNode;
6
+ rows: any[];
7
+ total: number;
8
+ page: number;
9
+ pageSize: number;
10
+ onPageChange: (p: number) => void;
11
+ onPageSizeChange: (n: number) => void;
12
+ baseHref: string;
13
+ loading?: boolean;
14
+ error?: string | null;
15
+ /** optional hook so parent can refetch or optimistically remove */
16
+ onDeleted?: (id: string) => void;
17
+ };
18
+ /** ---------- truncation helper ---------- */
19
+ export declare function truncateText(text: unknown, maxLength?: number): string;
20
+ /** ---------- component ---------- */
21
+ export declare function DataTableHero({ modelName, columns, rows, total, page, pageSize, onPageChange, onPageSizeChange, baseHref, loading, error, onDeleted, topContent, }: Props): import("react/jsx-runtime").JSX.Element;
22
+ export {};
@@ -0,0 +1,350 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import React from 'react';
4
+ import Link from 'next/link';
5
+ import { Table, TableHeader, TableBody, TableColumn, TableRow, TableCell, Pagination, Select, SelectItem, Skeleton, Button, Tooltip, Spinner, } from '@heroui/react';
6
+ import { formatCell } from './formatters';
7
+ import { api } from '../../lib/api';
8
+ import { ConfirmDialog } from '../../components/ConfirmDialog';
9
+ /** ---------- helpers: image detection/rendering ---------- */
10
+ const IMG_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|avif|heic|heif)$/i;
11
+ const IMG_COLUMN_RE = /(image|photo|avatar|logo|picture|thumbnail|icon)/i;
12
+ function isUrlLike(s) {
13
+ return /^https?:\/\//i.test(s) || s.startsWith('/') || s.startsWith('data:');
14
+ }
15
+ function isLikelyImageUrl(s) {
16
+ if (!isUrlLike(s))
17
+ return false;
18
+ if (s.startsWith('data:image/'))
19
+ return true;
20
+ try {
21
+ const url = new URL(s, 'http://x'); // base for relative paths
22
+ const pathname = url.pathname || '';
23
+ if (IMG_EXT_RE.test(pathname))
24
+ return true;
25
+ }
26
+ catch {
27
+ if (IMG_EXT_RE.test(s.split('?')[0]))
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+ function extractUrl(v) {
33
+ if (!v)
34
+ return null;
35
+ if (typeof v === 'string' && isLikelyImageUrl(v))
36
+ return v;
37
+ if (typeof v === 'object') {
38
+ const u = (typeof v.url === 'string' && v.url) ||
39
+ (typeof v.src === 'string' && v.src) ||
40
+ (typeof v.value === 'string' && v.value) ||
41
+ null;
42
+ return u && isLikelyImageUrl(u) ? u : null;
43
+ }
44
+ return null;
45
+ }
46
+ function extractAllImageUrls(val) {
47
+ if (Array.isArray(val)) {
48
+ const list = val.map(extractUrl).filter(Boolean);
49
+ if (!list.length) {
50
+ return (val.filter((s) => typeof s === 'string' && isLikelyImageUrl(s)) ??
51
+ []);
52
+ }
53
+ return list;
54
+ }
55
+ const one = extractUrl(val);
56
+ return one ? [one] : [];
57
+ }
58
+ function ImageCell({ urls, alt, size = 40, }) {
59
+ const shown = urls.slice(0, 1);
60
+ const rest = urls.length - shown.length;
61
+ return (_jsxs("div", { className: "flex items-center justify-center gap-1", children: [shown.map((u, i) => (_jsx("img", { src: u, alt: alt, width: size, height: size, className: "h-10 w-10 rounded-md object-cover border border-default-200 bg-default-100", loading: "lazy" }, `${u}-${i}`))), rest > 0 ? (_jsxs("div", { className: "h-10 w-10 rounded-md border border-default-200 bg-default-50 text-xs flex items-center justify-center", children: ["+", rest] })) : null] }));
62
+ }
63
+ /** decide if a column should be treated as image by header hint or value */
64
+ function maybeRenderImageCell(item, columnKey) {
65
+ const val = item?.[columnKey];
66
+ const urls = extractAllImageUrls(val);
67
+ if (urls.length || IMG_COLUMN_RE.test(String(columnKey))) {
68
+ if (!urls.length)
69
+ return null;
70
+ return _jsx(ImageCell, { urls: urls, alt: `${columnKey}` });
71
+ }
72
+ return null;
73
+ }
74
+ /** ---------- truncation helper ---------- */
75
+ export function truncateText(text, maxLength = 35) {
76
+ if (typeof text !== 'string')
77
+ return String(text ?? '');
78
+ if (text.length <= maxLength)
79
+ return text;
80
+ return text.slice(0, maxLength) + '…';
81
+ }
82
+ /** ---------- JSON-ish string -> object ---------- */
83
+ function maybeParseJson(val) {
84
+ if (typeof val !== 'string')
85
+ return val;
86
+ const s = val.trim();
87
+ if (!s)
88
+ return val;
89
+ // quick gate to avoid parsing normal text
90
+ if (!(s.startsWith('{') || s.startsWith('[')))
91
+ return val;
92
+ try {
93
+ const parsed = JSON.parse(s);
94
+ // only treat as object/array; don't convert primitives
95
+ if (parsed && (typeof parsed === 'object' || Array.isArray(parsed))) {
96
+ return parsed;
97
+ }
98
+ return val;
99
+ }
100
+ catch {
101
+ return val;
102
+ }
103
+ }
104
+ /** ---------- time-only range formatting (24h -> am/pm) ---------- */
105
+ const TIME_24_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
106
+ function to12h(hhmm) {
107
+ const m = TIME_24_RE.exec(hhmm);
108
+ if (!m)
109
+ return hhmm;
110
+ let h = parseInt(m[1], 10);
111
+ const mm = m[2];
112
+ const ampm = h >= 12 ? 'pm' : 'am';
113
+ h = h % 12 || 12;
114
+ const hh = String(h).padStart(2, '0');
115
+ return `${hh}:${mm}${ampm}`;
116
+ }
117
+ function parseTimeRangeToken(tok) {
118
+ const [s, e] = tok.split('..');
119
+ if (s == null && e == null)
120
+ return null;
121
+ const startOk = typeof s === 'string' && TIME_24_RE.test(s.trim());
122
+ const endOk = typeof e === 'string' && TIME_24_RE.test(e.trim());
123
+ if (!startOk && !endOk)
124
+ return null;
125
+ const start = startOk ? s.trim() : undefined;
126
+ const end = endOk ? e.trim() : undefined;
127
+ return { start, end };
128
+ }
129
+ function extractTimeRanges24(val) {
130
+ if (val == null)
131
+ return null;
132
+ if (typeof val === 'string') {
133
+ const tokens = val
134
+ .split(/[,\s]+/)
135
+ .map((t) => t.trim())
136
+ .filter(Boolean);
137
+ const ranges = tokens
138
+ .map(parseTimeRangeToken)
139
+ .filter((r) => !!r);
140
+ return ranges.length ? ranges : null;
141
+ }
142
+ if (typeof val === 'object' && !Array.isArray(val)) {
143
+ const anyv = val;
144
+ const r = parseTimeRangeToken(`${String(anyv?.start ?? '').trim()}..${String(anyv?.end ?? '').trim()}`);
145
+ return r ? [r] : null;
146
+ }
147
+ if (Array.isArray(val)) {
148
+ const ranges = [];
149
+ for (const x of val) {
150
+ if (typeof x === 'string') {
151
+ const r = parseTimeRangeToken(x.trim());
152
+ if (r)
153
+ ranges.push(r);
154
+ }
155
+ else if (typeof x === 'object' && x) {
156
+ const xx = x;
157
+ const r = parseTimeRangeToken(`${String(xx?.start ?? '').trim()}..${String(xx?.end ?? '').trim()}`);
158
+ if (r)
159
+ ranges.push(r);
160
+ }
161
+ }
162
+ return ranges.length ? ranges : null;
163
+ }
164
+ return null;
165
+ }
166
+ function formatTimeOnlyRanges(val) {
167
+ const ranges = extractTimeRanges24(val);
168
+ if (!ranges)
169
+ return null;
170
+ const parts = ranges
171
+ .map(({ start, end }) => {
172
+ const left = start ? to12h(start) : '';
173
+ const right = end ? to12h(end) : '';
174
+ if (left && right)
175
+ return `${left}-${right}`;
176
+ if (left)
177
+ return `${left}-`;
178
+ if (right)
179
+ return `-${right}`;
180
+ return '';
181
+ })
182
+ .filter(Boolean);
183
+ return parts.length ? parts.join(', ') : null;
184
+ }
185
+ /** ---------- object "show key" formatting ---------- */
186
+ function getByPath(obj, path) {
187
+ if (!obj || typeof obj !== 'object' || !path)
188
+ return undefined;
189
+ const parts = path
190
+ .replace(/\[(\d+)\]/g, '.$1')
191
+ .split('.')
192
+ .filter(Boolean);
193
+ let cur = obj;
194
+ for (const p of parts) {
195
+ if (cur == null)
196
+ return undefined;
197
+ cur = cur[p];
198
+ }
199
+ return cur;
200
+ }
201
+ function extractShowFromObject(o) {
202
+ if (!o || typeof o !== 'object')
203
+ return null;
204
+ // explicit hints
205
+ const showKey = (typeof o.show === 'string' && o.show) ||
206
+ (typeof o.showKey === 'string' && o.showKey) ||
207
+ null;
208
+ if (showKey) {
209
+ const v = getByPath(o, showKey);
210
+ if (v != null && (typeof v === 'string' || typeof v === 'number')) {
211
+ return String(v);
212
+ }
213
+ const vv = o[showKey];
214
+ if (vv != null && (typeof vv === 'string' || typeof vv === 'number')) {
215
+ return String(vv);
216
+ }
217
+ }
218
+ // smart fallbacks (domain-aware)
219
+ const first = o.firstName;
220
+ const last = o.lastName;
221
+ if (typeof first === 'string' && typeof last === 'string') {
222
+ const full = `${first} ${last}`.trim();
223
+ if (full)
224
+ return full;
225
+ }
226
+ const candidates = [
227
+ 'display',
228
+ 'name',
229
+ 'title',
230
+ 'label',
231
+ 'value',
232
+ 'fullName',
233
+ 'appointmentName',
234
+ 'email',
235
+ 'username',
236
+ ];
237
+ for (const k of candidates) {
238
+ const v = o[k];
239
+ if (v != null && (typeof v === 'string' || typeof v === 'number')) {
240
+ return String(v);
241
+ }
242
+ }
243
+ return null;
244
+ }
245
+ function formatShowValue(val) {
246
+ if (val == null)
247
+ return null;
248
+ // If it's a JSON-like string, parse first
249
+ const parsed = maybeParseJson(val);
250
+ if (Array.isArray(parsed)) {
251
+ const parts = parsed
252
+ .map((x) => (typeof x === 'object' ? extractShowFromObject(x) : null))
253
+ .filter((s) => !!s);
254
+ return parts.length ? parts.join(', ') : null;
255
+ }
256
+ if (typeof parsed === 'object') {
257
+ return extractShowFromObject(parsed);
258
+ }
259
+ return null;
260
+ }
261
+ /** ---------- component ---------- */
262
+ export function DataTableHero({ modelName, columns, rows, total, page, pageSize, onPageChange, onPageSizeChange, baseHref, loading, error, onDeleted, topContent, }) {
263
+ const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
264
+ const [pendingDeleteId, setPendingDeleteId] = React.useState(null);
265
+ const [isDeleting, setIsDeleting] = React.useState(false);
266
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false);
267
+ const headerItems = React.useMemo(() => [
268
+ ...columns.map((c) => ({ key: c, label: c })),
269
+ { key: 'actions', label: 'Actions' },
270
+ ], [columns]);
271
+ const skeletonItems = React.useMemo(() => Array.from({ length: Math.min(pageSize, 10) }, (_, i) => ({
272
+ id: `s-${i}`,
273
+ })), [pageSize]);
274
+ const bodyItems = loading ? skeletonItems : rows;
275
+ const requestDelete = (id) => {
276
+ setPendingDeleteId(id);
277
+ setIsDialogOpen(true);
278
+ };
279
+ const handleConfirmDelete = async () => {
280
+ if (!pendingDeleteId) {
281
+ setIsDialogOpen(false);
282
+ return;
283
+ }
284
+ try {
285
+ setIsDeleting(true);
286
+ await api.remove(modelName, pendingDeleteId);
287
+ onDeleted?.(pendingDeleteId);
288
+ }
289
+ finally {
290
+ setIsDeleting(false);
291
+ setPendingDeleteId(null);
292
+ setIsDialogOpen(false);
293
+ }
294
+ };
295
+ return (_jsxs(_Fragment, { children: [_jsxs(Table, { "aria-label": "Model table", removeWrapper: true, bottomContent: _jsxs("div", { className: "flex items-center justify-between px-2 py-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-sm text-foreground/70", children: "Rows per page:" }), _jsx(Select, { size: "sm", "aria-label": "Rows per page", selectedKeys: new Set([String(pageSize)]), onSelectionChange: (keys) => {
296
+ const val = Array.from(keys)[0];
297
+ if (val)
298
+ onPageSizeChange(Number(val));
299
+ }, className: "w-24", children: [10, 20, 50, 100].map((n) => (_jsx(SelectItem, { textValue: String(n), children: n }, String(n)))) })] }), _jsx(Pagination, { size: "sm", total: pageCount, page: Math.min(page, pageCount), onChange: onPageChange, showControls: true })] }), topContent: topContent, children: [_jsx(TableHeader, { columns: headerItems, children: (col) => (_jsx(TableColumn, { className: "text-xs uppercase tracking-wide", children: col.label }, col.key)) }), _jsx(TableBody, { items: bodyItems, emptyContent: loading ? undefined : error || 'No data', children: (item) => (_jsx(TableRow, { children: (columnKey) => {
300
+ const key = String(columnKey);
301
+ if (loading) {
302
+ return (_jsx(TableCell, { children: _jsx(Skeleton, { className: "h-3 w-3/4 rounded-md" }) }, `${item.id}-${key}`));
303
+ }
304
+ if (key === 'actions') {
305
+ const typeCol = columns.find((c) => c.toLowerCase() === 'type');
306
+ const typeVal = typeCol ? item?.[typeCol] : undefined;
307
+ const typeString = typeof typeVal === 'string'
308
+ ? typeVal
309
+ : typeof typeVal?.value === 'string'
310
+ ? typeVal.value
311
+ : typeof typeVal?.name === 'string'
312
+ ? typeVal.name
313
+ : '';
314
+ const isSystemRow = String(typeString ?? '').toLowerCase() === 'system';
315
+ return (_jsx(TableCell, { children: _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Tooltip, { content: isSystemRow
316
+ ? 'System record — editing disabled'
317
+ : 'Edit', children: _jsx(Button, { as: isSystemRow ? undefined : Link, href: isSystemRow ? undefined : `${baseHref}/${item.id}`, isIconOnly: true, size: "sm", variant: "light", isDisabled: isSystemRow, "aria-disabled": isSystemRow, children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("path", { d: "M12 20h9" }), _jsx("path", { d: "M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" })] }) }) }), _jsx(Tooltip, { content: isSystemRow
318
+ ? 'System record — deletion disabled'
319
+ : 'Delete', children: _jsx(Button, { isIconOnly: true, size: "sm", variant: "light", color: "danger", isDisabled: isSystemRow, "aria-disabled": isSystemRow, onPress: () => {
320
+ if (!isSystemRow)
321
+ requestDelete(item.id);
322
+ }, children: isDeleting && pendingDeleteId === item.id ? (_jsx(Spinner, { size: "sm" })) : (
323
+ // Trash icon
324
+ _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", focusable: "false", children: [_jsx("polyline", { points: "3 6 5 6 21 6" }), _jsx("path", { d: "M19 6l-1 14H6L5 6" }), _jsx("path", { d: "M10 11v6" }), _jsx("path", { d: "M14 11v6" })] })) }) })] }) }, `${item.id}-actions`));
325
+ }
326
+ // Image-aware cell; fallback to formatter
327
+ const maybeImg = maybeRenderImageCell(item, key);
328
+ // Prepare raw value (parse JSON-like strings first)
329
+ const rawRawVal = item[key];
330
+ const rawVal = maybeParseJson(rawRawVal);
331
+ // 1) Time-only ranges like "15:00..00:00 17:00..23:00"
332
+ const timeRangePretty = formatTimeOnlyRanges(rawVal);
333
+ if (timeRangePretty) {
334
+ return (_jsx(TableCell, { children: timeRangePretty }, `${item.id ?? item._id}-${key}`));
335
+ }
336
+ // 2) Object/array with "show"/"showKey" or common label fields
337
+ const showPretty = formatShowValue(rawVal);
338
+ if (showPretty) {
339
+ return (_jsx(TableCell, { children: truncateText(showPretty, 35) }, `${item.id ?? item._id}-${key}`));
340
+ }
341
+ // 3) Existing formatter pipeline
342
+ const formatted = formatCell(rawVal, key);
343
+ const isSimple = typeof formatted === 'string' ||
344
+ typeof formatted === 'number';
345
+ const displayValue = isSimple
346
+ ? truncateText(String(formatted), 35)
347
+ : formatted;
348
+ return (_jsx(TableCell, { children: maybeImg ?? displayValue }, `${item.id ?? item._id}-${key}`));
349
+ } }, item.id)) })] }), _jsx(ConfirmDialog, { isOpen: isDialogOpen, onClose: () => setIsDialogOpen(false), onConfirm: handleConfirmDelete, isLoading: isDeleting, title: "Delete this record?", description: "This action cannot be undone. The record will be permanently removed.", confirmText: "Delete", cancelText: "Cancel" })] }));
350
+ }
@@ -0,0 +1,8 @@
1
+ type Props = {
2
+ title: string;
3
+ createHref: string;
4
+ hideCreate?: boolean;
5
+ loading?: boolean;
6
+ };
7
+ export declare function ListHeader({ title, createHref, hideCreate, loading, }: Props): import("react/jsx-runtime").JSX.Element;
8
+ export {};