@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,134 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useEffect, useState, useMemo } from 'react';
4
+ import { useSelector, useDispatch } from 'react-redux';
5
+ import { setSession, clearSession } from '../state/sessionSlice';
6
+ import { AuthPage } from '../auth/AuthPage';
7
+ import { Sidebar } from './Sidebar';
8
+ import { NextMinRouter } from '../router/NextMinRouter';
9
+ import { usePathname, useRouter } from 'next/navigation';
10
+ import { AdminRouteNormalizer } from '../router/AdminRouteNormalizer';
11
+ import { SectionLoader } from './SectionLoader';
12
+ import { api } from '../lib/api';
13
+ import { systemLoading, systemLoaded, systemFailed, } from '../state/nextMinSlice';
14
+ function isJwtValid(token) {
15
+ if (!token)
16
+ return false;
17
+ const parts = token.split('.');
18
+ if (parts.length !== 3)
19
+ return true; // non-JWT token: treat as present
20
+ try {
21
+ const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
22
+ if (typeof payload?.exp === 'number') {
23
+ return payload.exp * 1000 > Date.now();
24
+ }
25
+ return true;
26
+ }
27
+ catch {
28
+ return true;
29
+ }
30
+ }
31
+ export function AdminApp() {
32
+ const dispatch = useDispatch();
33
+ const router = useRouter();
34
+ const pathname = usePathname();
35
+ const token = useSelector((s) => s?.session?.token ?? null);
36
+ const [hydrated, setHydrated] = useState(false);
37
+ // Hydrate session exactly once
38
+ useEffect(() => {
39
+ if (typeof window === 'undefined')
40
+ return;
41
+ try {
42
+ const t = localStorage.getItem('nextmin.token');
43
+ const u = localStorage.getItem('nextmin.user');
44
+ if (t && u) {
45
+ if (isJwtValid(t)) {
46
+ dispatch(setSession({ token: t, user: JSON.parse(u) }));
47
+ }
48
+ else {
49
+ // clear expired
50
+ localStorage.removeItem('nextmin.token');
51
+ localStorage.removeItem('nextmin.user');
52
+ dispatch(clearSession());
53
+ }
54
+ }
55
+ }
56
+ catch { }
57
+ setHydrated(true);
58
+ }, [dispatch]);
59
+ useEffect(() => {
60
+ if (!hydrated || !haveToken)
61
+ return;
62
+ let cancelled = false;
63
+ (async () => {
64
+ try {
65
+ dispatch(systemLoading());
66
+ // take first Settings row (singleton)
67
+ const res = await api.list('settings', 0, 1);
68
+ const first = res?.data?.[0] ?? null;
69
+ const normalized = first == null
70
+ ? null
71
+ : {
72
+ apiKey: first.apiKey ?? undefined,
73
+ siteName: first.siteName ?? undefined,
74
+ googleMapsKey: first.googleMapsKey ?? undefined,
75
+ siteLogo: Array.isArray(first.siteLogo)
76
+ ? first.siteLogo
77
+ : typeof first.siteLogo === 'string' && first.siteLogo
78
+ ? [first.siteLogo]
79
+ : undefined,
80
+ };
81
+ if (!cancelled)
82
+ dispatch(systemLoaded(normalized));
83
+ }
84
+ catch (e) {
85
+ if (!cancelled)
86
+ dispatch(systemFailed(e?.message || 'Failed to load settings'));
87
+ }
88
+ })();
89
+ return () => {
90
+ cancelled = true;
91
+ };
92
+ }, [hydrated, dispatch]);
93
+ const localToken = useMemo(() => {
94
+ if (typeof window === 'undefined')
95
+ return null;
96
+ const t = localStorage.getItem('nextmin.token');
97
+ return isJwtValid(t) ? t : null;
98
+ }, [hydrated]); // re-read after hydration
99
+ const haveToken = Boolean(token || localToken);
100
+ const onAuthRoute = pathname?.startsWith('/admin/auth') ?? false;
101
+ // Redirect logic AFTER hydration, without rendering dashboard first
102
+ useEffect(() => {
103
+ if (!hydrated)
104
+ return;
105
+ // unauth & not on auth -> go to sign-in
106
+ if (!haveToken && !onAuthRoute) {
107
+ router.replace('/admin/auth/sign-in');
108
+ return;
109
+ }
110
+ // authed & on auth -> go to dashboard
111
+ if (haveToken && onAuthRoute) {
112
+ router.replace('/admin/dashboard');
113
+ }
114
+ }, [hydrated, haveToken, onAuthRoute, router]);
115
+ // 1) While hydrating: full screen loader
116
+ if (!hydrated) {
117
+ return _jsx(SectionLoader, { label: "Please wait..." });
118
+ }
119
+ // 2) Unauthed:
120
+ // - on auth routes: show auth UI
121
+ // - elsewhere: show loader while redirecting to sign-in
122
+ if (!haveToken) {
123
+ if (onAuthRoute)
124
+ return _jsx(AuthPage, {});
125
+ return _jsx(SectionLoader, { label: "Redirecting to sign in\u2026" });
126
+ }
127
+ // 3) Authed:
128
+ // - if user manually hits /admin/auth/*, the effect will redirect; show loader to avoid flash
129
+ if (onAuthRoute) {
130
+ return _jsx(SectionLoader, { label: "Loading dashboard\u2026" });
131
+ }
132
+ // 4) Authed → Admin chrome + pages
133
+ return (_jsxs(_Fragment, { children: [_jsx(AdminRouteNormalizer, {}), _jsxs("div", { className: "grid grid-cols-[240px_1fr] h-[100dvh]", children: [_jsx(Sidebar, {}), _jsx("div", { className: "p-4 overflow-auto relative", children: _jsx(NextMinRouter, {}) })] })] }));
134
+ }
@@ -0,0 +1,12 @@
1
+ type ConfirmDialogProps = {
2
+ isOpen: boolean;
3
+ title: string;
4
+ description?: string;
5
+ confirmText?: string;
6
+ cancelText?: string;
7
+ isLoading?: boolean;
8
+ onConfirm: () => void | Promise<void>;
9
+ onClose: () => void;
10
+ };
11
+ export declare function ConfirmDialog({ isOpen, title, description, confirmText, cancelText, isLoading, onConfirm, onClose, }: ConfirmDialogProps): import("react/jsx-runtime").JSX.Element;
12
+ export {};
@@ -0,0 +1,6 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, } from '@heroui/react';
4
+ export function ConfirmDialog({ isOpen, title, description, confirmText = 'Delete', cancelText = 'Cancel', isLoading, onConfirm, onClose, }) {
5
+ return (_jsx(Modal, { isOpen: isOpen, onClose: onClose, size: "md", placement: "center", children: _jsx(ModalContent, { children: _jsxs(_Fragment, { children: [_jsx(ModalHeader, { className: "flex flex-col gap-1", children: title }), description && (_jsx(ModalBody, { children: _jsx("p", { className: "text-foreground/80", children: description }) })), _jsxs(ModalFooter, { children: [_jsx(Button, { variant: "flat", onPress: onClose, isDisabled: isLoading, children: cancelText }), _jsx(Button, { color: "danger", onPress: onConfirm, isLoading: isLoading, startContent: !isLoading ? (_jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", 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" })] })) : null, children: confirmText })] })] }) }) }));
6
+ }
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ type UploadResponseItem = {
3
+ provider: string;
4
+ bucket?: string;
5
+ key: string;
6
+ url: string;
7
+ etag?: string;
8
+ contentType?: string;
9
+ size?: number;
10
+ metadata?: Record<string, string>;
11
+ originalName?: string;
12
+ };
13
+ export type UploaderValue = string | string[] | null;
14
+ export type FileUploaderProps = {
15
+ name: string;
16
+ label?: string;
17
+ endpoint?: string;
18
+ getAuthHeaders?: () => Promise<Record<string, string>> | Record<string, string>;
19
+ accept?: string | string[];
20
+ multiple?: boolean;
21
+ maxFilesCount?: number;
22
+ maxSizeBytes?: number;
23
+ value?: UploaderValue;
24
+ onChange: (next: UploaderValue) => void;
25
+ mapResult?: (it: UploadResponseItem) => string;
26
+ description?: string;
27
+ disabled?: boolean;
28
+ required?: boolean;
29
+ className?: string;
30
+ };
31
+ export declare const FileUploader: React.FC<FileUploaderProps>;
32
+ export {};
@@ -0,0 +1,480 @@
1
+ // packages/nextmin-react/src/components/FileUploader.tsx
2
+ 'use client';
3
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
4
+ import { useEffect, useMemo, useRef, useState } from 'react';
5
+ import { Progress, Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, } from '@heroui/react';
6
+ const KB = 1024;
7
+ const MB = 1024 * KB;
8
+ const DEFAULT_MAX = 50 * MB;
9
+ const DEFAULT_ENDPOINT = ((process.env.NEXT_PUBLIC_NEXTMIN_API_URL || '') + '/files').replace(/\/+$/, '') || '/files';
10
+ const PlusTile = ({ text = 'Upload', invalid = false, }) => (_jsxs("div", { className: `flex flex-col items-center justify-center h-full${invalid ? ' text-danger' : ' text-default-700'}`, children: [_jsx("svg", { fill: "currentColor", width: "28", height: "28", viewBox: "0 0 24 24", "aria-hidden": true, className: "mb-1", children: _jsx("path", { d: "M12 5v14M5 12h14", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round" }) }), _jsx("div", { className: "text-sm", children: text })] }));
11
+ const TrashIcon = (props) => (_jsx("svg", { fill: "none", viewBox: "0 0 24 24", strokeWidth: "1.5", stroke: "currentColor", className: "size-4", ...props, "aria-hidden": true, children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" }) }));
12
+ const uid = () => Math.random().toString(36).slice(2);
13
+ const encodeKeyForPath = (key) => key.split('/').map(encodeURIComponent).join('/');
14
+ const extractKeyFromUrl = (url) => {
15
+ try {
16
+ const u = new URL(url, typeof window !== 'undefined' ? window.location.origin : 'http://x');
17
+ const idx = u.pathname.toLowerCase().indexOf('/uploads/');
18
+ if (idx >= 0)
19
+ return decodeURIComponent(u.pathname.slice(idx + 1));
20
+ const m = u.pathname.match(/\/?(uploads\/.+)$/i);
21
+ return m ? decodeURIComponent(m[1]) : null;
22
+ }
23
+ catch {
24
+ const m = url.match(/\/?(uploads\/.+)$/i);
25
+ return m ? decodeURIComponent(m[1]) : null;
26
+ }
27
+ };
28
+ const defaultAuthHeaders = () => {
29
+ if (typeof window === 'undefined')
30
+ return {};
31
+ let token;
32
+ let apiKey;
33
+ try {
34
+ const raw = localStorage.getItem('nextmin.user');
35
+ if (raw) {
36
+ const u = JSON.parse(raw);
37
+ token = u?.token ?? u?.data?.token;
38
+ }
39
+ }
40
+ catch { }
41
+ token = token ?? localStorage.getItem('nextmin.token') ?? undefined;
42
+ apiKey =
43
+ localStorage.getItem('nextmin.apiKey') ??
44
+ process.env.NEXT_PUBLIC_NEXTMIN_API_KEY;
45
+ const h = {};
46
+ if (token)
47
+ h.Authorization = `Bearer ${token}`;
48
+ if (apiKey)
49
+ h['x-api-key'] = apiKey;
50
+ return h;
51
+ };
52
+ const normalizeAccept = (input) => {
53
+ if (!input)
54
+ return undefined;
55
+ const raw = Array.isArray(input)
56
+ ? input
57
+ : String(input)
58
+ .split(',')
59
+ .map((s) => s.trim())
60
+ .filter(Boolean);
61
+ return raw.map((t) => {
62
+ const x = t.toLowerCase();
63
+ if (x === 'images/*')
64
+ return 'image/*';
65
+ if (x === 'videos/*')
66
+ return 'video/*';
67
+ if (x === 'audios/*')
68
+ return 'audio/*';
69
+ return x;
70
+ });
71
+ };
72
+ const shallowEqArr = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
73
+ // Treat only valid URLs/data/blobs as safe preview refs
74
+ function isSafeFileRef(s) {
75
+ const str = (s ?? '').trim();
76
+ if (!str)
77
+ return false;
78
+ if (/^(https?:\/\/|data:image\/|blob:)/i.test(str))
79
+ return true;
80
+ const noQuery = str.replace(/[?#].*$/, '');
81
+ return /\.(png|jpe?g|gif|webp|svg|bmp|ico|tiff?|avif)$/i.test(noQuery);
82
+ }
83
+ // Normalize incoming controlled value into an array of strings for comparison
84
+ function normalizeToArray(v) {
85
+ if (Array.isArray(v))
86
+ return v.filter((s) => typeof s === 'string');
87
+ if (typeof v === 'string' && v)
88
+ return [v];
89
+ return [];
90
+ }
91
+ export const FileUploader = ({ name, label, endpoint = DEFAULT_ENDPOINT, getAuthHeaders = defaultAuthHeaders, accept, multiple = false, maxFilesCount, maxSizeBytes = DEFAULT_MAX, value, onChange, mapResult = (it) => it.url, description, disabled, required, className, }) => {
92
+ const containerRef = useRef(null);
93
+ const pickerRef = useRef(null);
94
+ // **NO readOnly/disabled** -> participates in native validation
95
+ const validatorRef = useRef(null); // represents required / max files
96
+ const pendingRef = useRef(null); // blocks while uploading
97
+ const [items, setItems] = useState([]);
98
+ const itemsRef = useRef([]);
99
+ useEffect(() => {
100
+ itemsRef.current = items;
101
+ }, [items]);
102
+ const [error, setError] = useState(null);
103
+ const [touched, setTouched] = useState(false);
104
+ const [confirmId, setConfirmId] = useState(null);
105
+ const [confirmBusy, setConfirmBusy] = useState(false);
106
+ // === Seed from controlled value BUT ONLY IF DIFFERENT ===
107
+ useEffect(() => {
108
+ const wanted = normalizeToArray(value ?? null).filter(isSafeFileRef);
109
+ // current 'done' urls
110
+ const currentDone = itemsRef.current
111
+ .filter((i) => i.status === 'done' && i.uploaded?.url)
112
+ .map((i) => i.uploaded.url);
113
+ // if equal (same order + urls), do nothing (avoid re-render loop)
114
+ if (shallowEqArr(wanted, currentDone))
115
+ return;
116
+ if (wanted.length === 0) {
117
+ // remove only seeded/done; keep uploading/canceled/error queue intact
118
+ setItems((prev) => prev.filter((i) => i.status !== 'done'));
119
+ return;
120
+ }
121
+ // Seed only when different
122
+ setItems((prev) => {
123
+ const uploading = prev.filter((p) => p.status !== 'done');
124
+ const seeded = wanted.map((v) => ({
125
+ id: `v-${v}`,
126
+ name: v.split('/').pop() || 'file',
127
+ size: 0,
128
+ type: undefined,
129
+ progress: 100,
130
+ status: 'done',
131
+ uploaded: { provider: '', key: extractKeyFromUrl(v) ?? '', url: v },
132
+ previewUrl: v,
133
+ }));
134
+ return [...seeded, ...uploading];
135
+ });
136
+ }, [value]);
137
+ // === Derived state ===
138
+ const doneItems = items.filter((i) => i.status === 'done' && i.uploaded);
139
+ const doneCount = doneItems.length;
140
+ const hasPending = items.some((i) => i.status === 'uploading' || i.status === 'idle');
141
+ // Capacity / accept / size
142
+ const limitCount = multiple
143
+ ? typeof maxFilesCount === 'number' && maxFilesCount >= 1
144
+ ? maxFilesCount
145
+ : Infinity
146
+ : 1;
147
+ const activeCount = items.filter((i) => i.status !== 'canceled').length;
148
+ const uploadingOrIdle = items.some((i) => i.status === 'uploading' || i.status === 'idle');
149
+ const remainingCapacity = Math.max(0, limitCount - activeCount);
150
+ const showPlusPlaceholder = !disabled && remainingCapacity > 0 && !uploadingOrIdle;
151
+ const acceptTokens = useMemo(() => normalizeAccept(accept), [accept]);
152
+ const acceptAttr = useMemo(() => acceptTokens?.join(',') || undefined, [acceptTokens]);
153
+ const oversize = (f) => f.size > (maxSizeBytes || DEFAULT_MAX);
154
+ const typeAllowed = (f) => {
155
+ if (!acceptTokens || !acceptTokens.length)
156
+ return true;
157
+ return acceptTokens.some((p) => {
158
+ if (p.endsWith('/*'))
159
+ return f.type.startsWith(p.slice(0, -1));
160
+ if (p.startsWith('.'))
161
+ return f.name.toLowerCase().endsWith(p.toLowerCase());
162
+ return f.type === p;
163
+ });
164
+ };
165
+ // === Validation ===
166
+ const computeError = () => {
167
+ if (hasPending)
168
+ return 'Please wait for uploads to finish.';
169
+ if (required && doneCount < 1)
170
+ return 'Please fill out this field.';
171
+ if (Number.isFinite(limitCount) && doneCount > limitCount) {
172
+ return `You can upload up to ${limitCount} file(s).`;
173
+ }
174
+ return null;
175
+ };
176
+ // Keep native validity in sync (CRITICAL: inputs are NOT readOnly)
177
+ useEffect(() => {
178
+ const err = computeError();
179
+ if (validatorRef.current) {
180
+ const valueStr = err == null
181
+ ? multiple
182
+ ? doneItems
183
+ .map((d) => d.uploaded?.url || '')
184
+ .filter(Boolean)
185
+ .join(',')
186
+ : doneItems[0]?.uploaded?.url || 'ok'
187
+ : '';
188
+ validatorRef.current.value = valueStr;
189
+ validatorRef.current.setCustomValidity(err ?? '');
190
+ }
191
+ if (pendingRef.current) {
192
+ const pendMsg = hasPending ? 'Please wait for uploads to finish.' : '';
193
+ pendingRef.current.value = hasPending ? '' : 'ok';
194
+ pendingRef.current.setCustomValidity(pendMsg);
195
+ }
196
+ if (touched)
197
+ setError(err);
198
+ // eslint-disable-next-line react-hooks/exhaustive-deps
199
+ }, [doneCount, hasPending, required, limitCount, multiple, items, touched]);
200
+ // Show errors on submit (capture), like HeroUI Input
201
+ useEffect(() => {
202
+ const form = containerRef.current?.closest('form');
203
+ if (!form)
204
+ return;
205
+ const onSubmitCapture = () => {
206
+ const err = computeError();
207
+ setTouched(true);
208
+ setError(err);
209
+ validatorRef.current?.reportValidity();
210
+ pendingRef.current?.reportValidity();
211
+ };
212
+ form.addEventListener('submit', onSubmitCapture, true);
213
+ return () => form.removeEventListener('submit', onSubmitCapture, true);
214
+ // eslint-disable-next-line react-hooks/exhaustive-deps
215
+ }, [required, limitCount, hasPending, doneCount]);
216
+ // Sequential upload worker
217
+ const workerBusy = useRef(false);
218
+ useEffect(() => {
219
+ if (workerBusy.current)
220
+ return;
221
+ if (itemsRef.current.some((i) => i.status === 'uploading'))
222
+ return;
223
+ const nextIdle = itemsRef.current.find((i) => i.status === 'idle');
224
+ if (!nextIdle)
225
+ return;
226
+ workerBusy.current = true;
227
+ uploadOne(nextIdle.id)
228
+ .catch(() => { })
229
+ .finally(() => {
230
+ workerBusy.current = false;
231
+ setTimeout(() => {
232
+ if (itemsRef.current.some((i) => i.status === 'idle')) {
233
+ setItems((prev) => [...prev]);
234
+ }
235
+ }, 0);
236
+ });
237
+ // eslint-disable-next-line react-hooks/exhaustive-deps
238
+ }, [items]);
239
+ // Emit AFTER render with stable shape to avoid loop:
240
+ // - multiple === true → always string[]
241
+ // - multiple === false → string | null
242
+ const lastEmittedRef = useRef(null);
243
+ useEffect(() => {
244
+ const doneVals = doneItems.map((i) => mapResult(i.uploaded));
245
+ if (multiple) {
246
+ const currArr = normalizeToArray(value ?? null);
247
+ if (shallowEqArr(doneVals, currArr))
248
+ return; // already in sync
249
+ if (lastEmittedRef.current && Array.isArray(lastEmittedRef.current)) {
250
+ if (shallowEqArr(doneVals, lastEmittedRef.current))
251
+ return;
252
+ }
253
+ lastEmittedRef.current = doneVals;
254
+ onChange(doneVals);
255
+ return;
256
+ }
257
+ // single
258
+ const nextSingle = doneVals[0] ?? null;
259
+ const currSingle = typeof value === 'string' ? value : null;
260
+ if (nextSingle === currSingle)
261
+ return;
262
+ if (lastEmittedRef.current === nextSingle)
263
+ return;
264
+ lastEmittedRef.current = nextSingle;
265
+ onChange(nextSingle);
266
+ // eslint-disable-next-line react-hooks/exhaustive-deps
267
+ }, [items, multiple]);
268
+ // Stage file
269
+ const stageAndQueue = (files) => {
270
+ if (!files.length)
271
+ return;
272
+ const limitCount = multiple
273
+ ? typeof maxFilesCount === 'number' && maxFilesCount >= 1
274
+ ? maxFilesCount
275
+ : Infinity
276
+ : 1;
277
+ const activeCount = itemsRef.current.filter((i) => i.status !== 'canceled').length;
278
+ const remainingCapacity = Math.max(0, limitCount - activeCount);
279
+ if (remainingCapacity <= 0) {
280
+ setTouched(true);
281
+ setError(Number.isFinite(limitCount)
282
+ ? `You can upload up to ${limitCount} file(s).`
283
+ : 'Upload limit reached.');
284
+ return;
285
+ }
286
+ const f = files[0];
287
+ if (oversize(f)) {
288
+ setTouched(true);
289
+ setError(`File too large. Max ${((maxSizeBytes || DEFAULT_MAX) / MB).toFixed(1)} MB`);
290
+ return;
291
+ }
292
+ if (!typeAllowed(f)) {
293
+ setTouched(true);
294
+ setError(`File type not allowed${acceptAttr ? ` (${acceptAttr})` : ''}`);
295
+ return;
296
+ }
297
+ const staged = {
298
+ id: uid(),
299
+ file: f,
300
+ name: f.name,
301
+ size: f.size,
302
+ type: f.type,
303
+ progress: 0,
304
+ status: 'idle',
305
+ previewUrl: URL.createObjectURL(f),
306
+ };
307
+ setError(null);
308
+ if (!multiple) {
309
+ setItems((prev) => {
310
+ prev.forEach((p) => p.xhr?.abort());
311
+ prev.forEach((p) => {
312
+ if (p.previewUrl?.startsWith('blob:'))
313
+ URL.revokeObjectURL(p.previewUrl);
314
+ });
315
+ return [staged];
316
+ });
317
+ }
318
+ else {
319
+ setItems((prev) => [...prev, staged]);
320
+ }
321
+ };
322
+ // Upload one
323
+ const uploadOne = async (id) => {
324
+ const item = itemsRef.current.find((i) => i.id === id);
325
+ if (!item || !item.file)
326
+ return;
327
+ const headers = await Promise.resolve(getAuthHeaders());
328
+ await new Promise((resolve) => {
329
+ const xhr = new XMLHttpRequest();
330
+ xhr.open('POST', endpoint);
331
+ for (const [k, v] of Object.entries(headers))
332
+ xhr.setRequestHeader(k, v);
333
+ const form = new FormData();
334
+ // @ts-ignore backend expects 'file'
335
+ form.append('file', item.file);
336
+ xhr.upload.onprogress = (ev) => {
337
+ if (!ev.lengthComputable)
338
+ return;
339
+ const pct = Math.round((ev.loaded / ev.total) * 100);
340
+ setItems((prev) => prev.map((x) => x.id === id ? { ...x, progress: pct, status: 'uploading', xhr } : x));
341
+ };
342
+ xhr.onerror = () => {
343
+ setItems((prev) => prev.map((x) => x.id === id
344
+ ? {
345
+ ...x,
346
+ status: 'error',
347
+ error: 'Network error',
348
+ xhr: undefined,
349
+ }
350
+ : x));
351
+ resolve();
352
+ };
353
+ xhr.onabort = () => {
354
+ setItems((prev) => prev.map((x) => x.id === id ? { ...x, status: 'canceled', xhr: undefined } : x));
355
+ resolve();
356
+ };
357
+ xhr.onload = () => {
358
+ try {
359
+ const json = JSON.parse(xhr.responseText);
360
+ if (!json?.success ||
361
+ !Array.isArray(json.data) ||
362
+ json.data.length === 0) {
363
+ throw new Error(json?.message || `Upload failed (${xhr.status})`);
364
+ }
365
+ const uploaded = json.data[0];
366
+ setItems((prev) => prev.map((x) => x.id === id
367
+ ? {
368
+ ...x,
369
+ status: 'done',
370
+ progress: 100,
371
+ uploaded,
372
+ xhr: undefined,
373
+ previewUrl: uploaded.url || x.previewUrl,
374
+ }
375
+ : x));
376
+ }
377
+ catch (e) {
378
+ setItems((prev) => prev.map((x) => x.id === id
379
+ ? {
380
+ ...x,
381
+ status: 'error',
382
+ error: e?.message || 'Upload failed',
383
+ xhr: undefined,
384
+ }
385
+ : x));
386
+ }
387
+ resolve();
388
+ };
389
+ setItems((prev) => prev.map((x) => x.id === id ? { ...x, status: 'uploading', progress: 0, xhr } : x));
390
+ xhr.send(form);
391
+ });
392
+ };
393
+ // Delete
394
+ const reallyRemove = async (id) => {
395
+ const it = itemsRef.current.find((x) => x.id === id);
396
+ if (!it)
397
+ return;
398
+ const key = it.uploaded?.key ||
399
+ (it.previewUrl ? extractKeyFromUrl(it.previewUrl) : null);
400
+ if (key) {
401
+ try {
402
+ setItems((prev) => prev.map((x) => (x.id === id ? { ...x, status: 'deleting' } : x)));
403
+ const headers = await Promise.resolve(getAuthHeaders());
404
+ const resp = await fetch(`${endpoint}/${encodeKeyForPath(key)}`, {
405
+ method: 'DELETE',
406
+ headers,
407
+ });
408
+ if (!resp.ok) {
409
+ const t = await resp.text();
410
+ throw new Error(t || `Delete failed (${resp.status})`);
411
+ }
412
+ }
413
+ catch (e) {
414
+ setItems((prev) => prev.map((x) => x.id === id
415
+ ? { ...x, status: 'error', error: e?.message || 'Delete failed' }
416
+ : x));
417
+ return;
418
+ }
419
+ }
420
+ else if (it.xhr) {
421
+ it.xhr.abort();
422
+ }
423
+ if (it.previewUrl?.startsWith('blob:'))
424
+ URL.revokeObjectURL(it.previewUrl);
425
+ setItems((prev) => prev.filter((x) => x.id !== id));
426
+ if (touched)
427
+ setError(computeError());
428
+ };
429
+ const requestDelete = (id) => setConfirmId(id);
430
+ // DOM events
431
+ const onInputChange = (e) => {
432
+ const files = Array.from(e.target.files ?? []);
433
+ e.currentTarget.value = '';
434
+ stageAndQueue(files);
435
+ };
436
+ const onDrop = (e) => {
437
+ e.preventDefault();
438
+ const files = Array.from(e.dataTransfer.files ?? []);
439
+ stageAndQueue(files);
440
+ };
441
+ const isInvalid = !!error;
442
+ return (_jsxs("div", { ref: containerRef, className: className, role: "group", "aria-labelledby": label ? `${name}-label` : undefined, "aria-invalid": isInvalid || undefined, "aria-describedby": isInvalid ? `${name}-error` : undefined, "data-invalid": isInvalid ? '' : undefined, children: [label ? (_jsxs("label", { id: `${name}-label`, className: `block text-sm mb-1 ${isInvalid ? 'text-danger' : 'text-small'}`, htmlFor: `uploader-${name}`, children: [label, " ", required ? _jsx("span", { className: "text-danger", children: "*" }) : null] })) : null, _jsx("input", { ref: validatorRef, type: "text", name: `${name}__value`, value: doneItems
443
+ .map((d) => d.uploaded?.url || '')
444
+ .filter(Boolean)
445
+ .join(','), onChange: () => { }, required: !!required, className: "sr-only", "aria-hidden": true, onInvalid: (e) => {
446
+ e.preventDefault(); // suppress browser tooltip
447
+ setTouched(true);
448
+ setError(computeError());
449
+ } }), _jsx("input", { ref: pendingRef, type: "text", name: `${name}__pending`, value: hasPending ? '' : 'ok', onChange: () => { }, className: "sr-only", "aria-hidden": true, onInvalid: (e) => {
450
+ e.preventDefault();
451
+ setTouched(true);
452
+ setError('Please wait for uploads to finish.');
453
+ } }), _jsx("input", { ref: pickerRef, type: "file", accept: acceptTokens?.join(','), multiple: multiple, onChange: onInputChange, hidden: true, disabled: disabled, name: name, id: `uploader-${name}` }), _jsxs("div", { className: "grid gap-3 grid-cols-3", onDragOver: (e) => e.preventDefault(), onDrop: onDrop, children: [items.map((it) => (_jsxs("div", { className: 'relative aspect-square rounded-xl overflow-hidden border bg-default-50 ' +
454
+ (isInvalid ? 'border-danger text-danger' : 'border-default-200'), children: [it.previewUrl ? (_jsx("img", { src: it.previewUrl, alt: it.name, className: "w-full h-full object-cover" })) : (_jsx("div", { className: "w-full h-full flex items-center justify-center text-xs text-default-600", children: it.name })), it.status === 'uploading' ? (_jsx("div", { className: "absolute inset-0 bg-black/30 flex items=end p-2", children: _jsx(Progress, { "aria-label": "upload", size: "sm", value: it.progress, className: "w-full" }) })) : null, it.status === 'deleting' ? (_jsx("div", { className: "absolute inset-0 bg-black/30 flex items-center justify-center text-white text-xs", children: "Deleting\u2026" })) : null, it.status === 'error' ? (_jsx("div", { className: "absolute bottom-0 left-0 right-0 bg-danger text-white text-xs px-2 py-1", children: it.error || 'Upload failed' })) : null, _jsx("div", { className: "absolute top-1 right-1 flex gap-1", children: it.status === 'uploading' ? (_jsx(Tooltip, { content: "Cancel", children: _jsx("button", { type: "button", onClick: () => itemsRef.current.find((x) => x.id === it.id)?.xhr?.abort(), className: "bg-white/80 hover:bg-white text-xs px-2 py-1 rounded", "aria-label": "Cancel upload", children: "\u2715" }) })) : it.status === 'error' ? (_jsx(Tooltip, { content: "Retry", children: _jsx("button", { type: "button", onClick: () => uploadOne(it.id), className: "bg-white/80 hover:bg-white text-xs px-2 py-1 rounded", "aria-label": "Retry upload", children: "Retry" }) })) : (_jsx(Tooltip, { content: "Delete", children: _jsx(Button, { variant: "ghost", color: "danger", isIconOnly: true, size: "sm", onPress: () => requestDelete(it.id), isDisabled: it.status === 'deleting', "aria-label": "Delete file", children: _jsx(TrashIcon, {}) }) })) })] }, it.id))), showPlusPlaceholder ? (_jsx("button", { type: "button", onClick: () => pickerRef.current?.click(), disabled: disabled, className: 'aspect-square rounded-xl border-2 border-dashed transition-colors flex items-center justify-center ' +
455
+ (isInvalid
456
+ ? 'border-danger hover:border-danger text-danger'
457
+ : 'border-default-300 hover:border-primary hover:bg-primary-50'), "aria-label": "Upload file", children: _jsx(PlusTile, { invalid: isInvalid }) })) : null] }), _jsx("div", { className: "mt-2 text-xs", "aria-live": "polite", children: error ? (_jsx("span", { id: `${name}-error`, className: "text-danger", children: error })) : (_jsx("span", { className: "text-default-500", children: description ??
458
+ `Max ${((maxSizeBytes || DEFAULT_MAX) / MB).toFixed(1)}MB` +
459
+ (acceptAttr ? ` • ${acceptAttr}` : '') +
460
+ (multiple && Number.isFinite(limitCount)
461
+ ? ` • up to ${limitCount} file(s)`
462
+ : '') })) }), _jsx(Modal, { isOpen: !!confirmId, onOpenChange: (open) => !open && !confirmBusy && setConfirmId(null), children: _jsx(ModalContent, { children: (onClose) => {
463
+ const target = confirmId
464
+ ? items.find((x) => x.id === confirmId)
465
+ : undefined;
466
+ const hasServerKey = !!target?.uploaded?.key ||
467
+ !!(target?.previewUrl && extractKeyFromUrl(target.previewUrl));
468
+ return (_jsxs(_Fragment, { children: [_jsx(ModalHeader, { className: "flex flex-col gap-1", children: "Delete file" }), _jsxs(ModalBody, { children: [_jsx("p", { className: "text-sm", children: hasServerKey
469
+ ? 'This will permanently delete the file from storage.'
470
+ : 'This will remove the file from the list.' }), _jsx("p", { className: "text-xs text-default-500", children: target?.name ?? '' })] }), _jsxs(ModalFooter, { children: [_jsx(Button, { variant: "flat", onPress: onClose, isDisabled: confirmBusy, children: "Cancel" }), _jsx(Button, { color: "danger", onPress: async () => {
471
+ if (!confirmId)
472
+ return;
473
+ setConfirmBusy(true);
474
+ await reallyRemove(confirmId);
475
+ setConfirmBusy(false);
476
+ setConfirmId(null);
477
+ onClose();
478
+ }, isLoading: confirmBusy, children: "Delete" })] })] }));
479
+ } }) })] }));
480
+ };