@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.
- package/LICENSE +49 -0
- package/README.md +133 -0
- package/dist/auth/AuthPage.d.ts +1 -0
- package/dist/auth/AuthPage.js +23 -0
- package/dist/auth/ForgotPasswordForm.d.ts +1 -0
- package/dist/auth/ForgotPasswordForm.js +28 -0
- package/dist/auth/SignInForm.d.ts +6 -0
- package/dist/auth/SignInForm.js +38 -0
- package/dist/auth/SignUpForm.d.ts +3 -0
- package/dist/auth/SignUpForm.js +30 -0
- package/dist/components/AddressAutocomplete.d.ts +21 -0
- package/dist/components/AddressAutocomplete.js +182 -0
- package/dist/components/AdminApp.d.ts +1 -0
- package/dist/components/AdminApp.js +134 -0
- package/dist/components/ConfirmDialog.d.ts +12 -0
- package/dist/components/ConfirmDialog.js +6 -0
- package/dist/components/FileUploader.d.ts +32 -0
- package/dist/components/FileUploader.js +480 -0
- package/dist/components/NoAccess.d.ts +3 -0
- package/dist/components/NoAccess.js +5 -0
- package/dist/components/PasswordInput.d.ts +19 -0
- package/dist/components/PasswordInput.js +11 -0
- package/dist/components/PhoneInput.d.ts +23 -0
- package/dist/components/PhoneInput.js +147 -0
- package/dist/components/RefMultiSelect.d.ts +14 -0
- package/dist/components/RefMultiSelect.js +76 -0
- package/dist/components/RefSingleSelect.d.ts +17 -0
- package/dist/components/RefSingleSelect.js +52 -0
- package/dist/components/SchemaForm.d.ts +13 -0
- package/dist/components/SchemaForm.js +592 -0
- package/dist/components/SectionLoader.d.ts +3 -0
- package/dist/components/SectionLoader.js +7 -0
- package/dist/components/Sidebar.d.ts +1 -0
- package/dist/components/Sidebar.js +87 -0
- package/dist/components/TableFilters.d.ts +16 -0
- package/dist/components/TableFilters.js +69 -0
- package/dist/components/TableSkeleton.d.ts +7 -0
- package/dist/components/TableSkeleton.js +5 -0
- package/dist/hooks/useGoogleMapsKey.d.ts +5 -0
- package/dist/hooks/useGoogleMapsKey.js +16 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lib/api.d.ts +31 -0
- package/dist/lib/api.js +94 -0
- package/dist/lib/auth.d.ts +23 -0
- package/dist/lib/auth.js +51 -0
- package/dist/lib/googleLoader.d.ts +1 -0
- package/dist/lib/googleLoader.js +25 -0
- package/dist/lib/schemaService.d.ts +2 -0
- package/dist/lib/schemaService.js +39 -0
- package/dist/lib/schemaUtils.d.ts +4 -0
- package/dist/lib/schemaUtils.js +18 -0
- package/dist/lib/types.d.ts +50 -0
- package/dist/lib/types.js +1 -0
- package/dist/nextmin.css +1 -0
- package/dist/providers/NextMinProvider.d.ts +5 -0
- package/dist/providers/NextMinProvider.js +30 -0
- package/dist/router/AdminRouteNormalizer.d.ts +1 -0
- package/dist/router/AdminRouteNormalizer.js +32 -0
- package/dist/router/NextMinRouter.d.ts +1 -0
- package/dist/router/NextMinRouter.js +99 -0
- package/dist/state/nextMinSlice.d.ts +14 -0
- package/dist/state/nextMinSlice.js +34 -0
- package/dist/state/schemaLive.d.ts +2 -0
- package/dist/state/schemaLive.js +19 -0
- package/dist/state/schemasSlice.d.ts +20 -0
- package/dist/state/schemasSlice.js +43 -0
- package/dist/state/sessionSlice.d.ts +10 -0
- package/dist/state/sessionSlice.js +18 -0
- package/dist/state/store.d.ts +28 -0
- package/dist/state/store.js +7 -0
- package/dist/views/CreateEditPage.d.ts +4 -0
- package/dist/views/CreateEditPage.js +64 -0
- package/dist/views/DashboardPage.d.ts +1 -0
- package/dist/views/DashboardPage.js +107 -0
- package/dist/views/ListPage.d.ts +5 -0
- package/dist/views/ListPage.js +76 -0
- package/dist/views/NextNotFound.d.ts +1 -0
- package/dist/views/NextNotFound.js +6 -0
- package/dist/views/ProfilePage.d.ts +1 -0
- package/dist/views/ProfilePage.js +193 -0
- package/dist/views/SettingsEdit.d.ts +2 -0
- package/dist/views/SettingsEdit.js +87 -0
- package/dist/views/list/DataTableHero.d.ts +22 -0
- package/dist/views/list/DataTableHero.js +350 -0
- package/dist/views/list/ListHeader.d.ts +8 -0
- package/dist/views/list/ListHeader.js +7 -0
- package/dist/views/list/Pagination.d.ts +8 -0
- package/dist/views/list/Pagination.js +5 -0
- package/dist/views/list/formatters.d.ts +2 -0
- package/dist/views/list/formatters.js +62 -0
- package/dist/views/list/useListData.d.ts +10 -0
- package/dist/views/list/useListData.js +79 -0
- package/package.json +51 -0
- 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
|
+
};
|