stackkit 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -42
- package/dist/cli/add.js +122 -56
- package/dist/cli/create.d.ts +2 -0
- package/dist/cli/create.js +271 -95
- package/dist/cli/doctor.js +1 -0
- package/dist/cli/list.d.ts +1 -1
- package/dist/cli/list.js +6 -4
- package/dist/index.js +234 -191
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/discovery/module-discovery.d.ts +4 -0
- package/dist/lib/discovery/module-discovery.js +56 -0
- package/dist/lib/generation/code-generator.d.ts +11 -2
- package/dist/lib/generation/code-generator.js +42 -3
- package/dist/lib/generation/generator-utils.js +3 -1
- package/dist/lib/pm/package-manager.js +16 -13
- package/dist/lib/ui/logger.js +3 -2
- package/dist/lib/utils/path-resolver.d.ts +2 -0
- package/dist/lib/utils/path-resolver.js +8 -0
- package/dist/meta.json +8312 -0
- package/modules/auth/better-auth/files/{shared → express}/config/env.ts +48 -52
- package/modules/auth/better-auth/files/express/middlewares/authorize.ts +20 -1
- package/modules/auth/better-auth/files/express/modules/auth.controller.ts +349 -0
- package/modules/auth/better-auth/files/express/modules/{auth/auth.route.ts → auth.route.ts} +12 -7
- package/modules/auth/better-auth/files/express/modules/auth.service.ts +664 -0
- package/modules/auth/better-auth/files/express/modules/{auth/auth.type.ts → auth.type.ts} +22 -9
- package/modules/auth/better-auth/files/{shared/mongoose/auth/constants.ts → express/mongo-modules/auth.constants.ts} +0 -1
- package/modules/auth/better-auth/files/{shared/mongoose/auth/helper.ts → express/mongo-modules/auth.helper.ts} +11 -1
- package/modules/auth/better-auth/files/express/types/express.d.ts +11 -0
- package/modules/auth/better-auth/files/nextjs/api-route.ts +74 -0
- package/modules/auth/better-auth/files/nextjs/dashboard/pages/(user)/page.tsx +6 -0
- package/modules/auth/better-auth/files/nextjs/dashboard/pages/admin/page.tsx +6 -0
- package/modules/auth/better-auth/files/nextjs/dashboard/pages/layout.tsx +48 -0
- package/modules/auth/better-auth/files/nextjs/dashboard/pages/my-profile/page.tsx +5 -0
- package/modules/auth/better-auth/files/nextjs/features/services/auth.service.ts +102 -0
- package/modules/auth/better-auth/files/nextjs/layout/layout.tsx +13 -0
- package/modules/auth/better-auth/files/nextjs/lib/axios/http.ts +158 -0
- package/modules/auth/better-auth/files/nextjs/lib/env.ts +35 -0
- package/modules/auth/better-auth/files/nextjs/lib/utils/auth.ts +75 -0
- package/modules/auth/better-auth/files/nextjs/lib/utils/cookie.ts +29 -0
- package/modules/auth/better-auth/files/nextjs/lib/utils/jwt.ts +28 -0
- package/modules/auth/better-auth/files/nextjs/lib/utils/token.ts +49 -0
- package/modules/auth/better-auth/files/nextjs/pages/forgot-password/page.tsx +5 -0
- package/modules/auth/better-auth/files/nextjs/pages/layout.tsx +11 -0
- package/modules/auth/better-auth/files/nextjs/pages/login/page.tsx +9 -0
- package/modules/auth/better-auth/files/nextjs/pages/register/page.tsx +5 -0
- package/modules/auth/better-auth/files/nextjs/pages/reset-password/page.tsx +10 -0
- package/modules/auth/better-auth/files/nextjs/pages/verify-email/page.tsx +10 -0
- package/modules/auth/better-auth/files/nextjs/proxy.ts +157 -22
- package/modules/auth/better-auth/files/nextjs/theme/providers/theme-provider.tsx +11 -0
- package/modules/auth/better-auth/files/nextjs/types/api.types.ts +18 -0
- package/modules/auth/better-auth/files/react/components/protected-route.tsx +39 -0
- package/modules/auth/better-auth/files/react/components/route-guards.tsx +13 -0
- package/modules/auth/better-auth/files/react/dashboard/admin/pages/overview.tsx +3 -0
- package/modules/auth/better-auth/files/react/dashboard/pages/overview.tsx +3 -0
- package/modules/auth/better-auth/files/react/features/pages/forgot-password.tsx +5 -0
- package/modules/auth/better-auth/files/react/features/pages/login.tsx +5 -0
- package/modules/auth/better-auth/files/react/features/pages/my-profile.tsx +5 -0
- package/modules/auth/better-auth/files/react/features/pages/oauth-callback.tsx +59 -0
- package/modules/auth/better-auth/files/react/features/pages/register.tsx +5 -0
- package/modules/auth/better-auth/files/react/features/pages/reset-password.tsx +10 -0
- package/modules/auth/better-auth/files/react/features/pages/verify-email.tsx +10 -0
- package/modules/auth/better-auth/files/react/layout/dashboard-layout.tsx +54 -0
- package/modules/auth/better-auth/files/react/lib/axios/http.ts +68 -0
- package/modules/auth/better-auth/files/react/lib/env.ts +25 -0
- package/modules/auth/better-auth/files/react/router.tsx +73 -0
- package/modules/auth/better-auth/files/react/theme/components/providers/theme-provider-context.ts +13 -0
- package/modules/auth/better-auth/files/react/theme/components/providers/theme-provider.tsx +51 -0
- package/modules/auth/better-auth/files/react/theme/hooks/use-theme.ts +8 -0
- package/modules/auth/better-auth/files/shared/features/components/change-password-dialog.tsx +113 -0
- package/modules/auth/better-auth/files/shared/features/components/forgot-password-form.tsx +84 -0
- package/modules/auth/better-auth/files/shared/features/components/login-form.tsx +134 -0
- package/modules/auth/better-auth/files/shared/features/components/my-profile.tsx +147 -0
- package/modules/auth/better-auth/files/shared/features/components/profile-form.tsx +205 -0
- package/modules/auth/better-auth/files/shared/features/components/register-form.tsx +100 -0
- package/modules/auth/better-auth/files/shared/features/components/reset-password-form.tsx +111 -0
- package/modules/auth/better-auth/files/shared/features/components/social-login-buttons.tsx +47 -0
- package/modules/auth/better-auth/files/shared/features/components/user-profile-menu.tsx +106 -0
- package/modules/auth/better-auth/files/shared/features/components/verify-email-form.tsx +110 -0
- package/modules/auth/better-auth/files/shared/features/queries/auth.mutations.tsx +312 -0
- package/modules/auth/better-auth/files/shared/features/queries/auth.querie.ts +19 -0
- package/modules/auth/better-auth/files/shared/features/services/auth.api.ts +81 -0
- package/modules/auth/better-auth/files/shared/features/types/auth.type.ts +47 -0
- package/modules/auth/better-auth/files/shared/features/validators/change-password.validator.ts +18 -0
- package/modules/auth/better-auth/files/shared/features/validators/forgot.validator.ts +7 -0
- package/modules/auth/better-auth/files/shared/features/validators/login.validator.ts +14 -0
- package/modules/auth/better-auth/files/shared/features/validators/profile.validator.ts +8 -0
- package/modules/auth/better-auth/files/shared/features/validators/register.validator.ts +9 -0
- package/modules/auth/better-auth/files/shared/features/validators/reset.validator.ts +9 -0
- package/modules/auth/better-auth/files/shared/features/validators/verify.validator.ts +8 -0
- package/modules/auth/better-auth/files/shared/lib/auth-client.ts +2 -1
- package/modules/auth/better-auth/files/shared/lib/auth.ts +10 -29
- package/modules/auth/better-auth/files/shared/lib/constant/dashboard.ts +90 -0
- package/modules/auth/better-auth/files/shared/prisma/enums.prisma +0 -1
- package/modules/auth/better-auth/files/shared/theme/mode-toggle.tsx +30 -0
- package/modules/auth/better-auth/files/shared/ui/shadcn/components/dashboard/dashboard-header.tsx +94 -0
- package/modules/auth/better-auth/files/shared/ui/shadcn/components/dashboard/dashboard-sidebar.tsx +255 -0
- package/modules/auth/better-auth/files/shared/ui/shadcn/components/footer.tsx +35 -0
- package/modules/auth/better-auth/files/shared/ui/shadcn/components/navbar.tsx +145 -0
- package/modules/auth/better-auth/files/shared/ui/shadcn/form-field/input-field.tsx +440 -0
- package/modules/auth/better-auth/files/shared/utils/email.ts +20 -18
- package/modules/auth/better-auth/generator.json +174 -53
- package/modules/auth/better-auth/module.json +2 -2
- package/modules/components/files/shared/hooks/use-file-upload.ts +412 -0
- package/modules/components/files/shared/lib/utils/url-helpers.ts +110 -0
- package/modules/components/files/shared/shadcn/dashboard/data-table-column-selector.tsx +52 -0
- package/modules/components/files/shared/shadcn/dashboard/data-table-footer.tsx +156 -0
- package/modules/components/files/shared/shadcn/dashboard/data-table.tsx +405 -0
- package/modules/components/files/shared/shadcn/global/form-field/input-field.tsx +440 -0
- package/modules/components/files/shared/shadcn/global/form-field/media-uploader-field.tsx +745 -0
- package/modules/components/files/shared/shadcn/global/form-field/multi-select-field.tsx +207 -0
- package/modules/components/files/shared/shadcn/global/form-field/select-field.tsx +247 -0
- package/modules/components/files/shared/shadcn/global/form-field/textarea-field.tsx +277 -0
- package/modules/components/files/shared/shadcn/global/form-field/tiptap-editor-field.tsx +35 -0
- package/modules/components/files/shared/shadcn/global/no-results.tsx +41 -0
- package/modules/components/files/shared/shadcn/tiptap-editor/editor-menu-bar.tsx +217 -0
- package/modules/components/files/shared/shadcn/tiptap-editor/tiptap-editor.tsx +104 -0
- package/modules/components/files/shared/url/load-more.tsx +93 -0
- package/modules/components/files/shared/url/search-bar.tsx +131 -0
- package/modules/components/files/shared/url/sort-select.tsx +118 -0
- package/modules/components/files/shared/url/url-tabs.tsx +77 -0
- package/modules/components/generator.json +109 -0
- package/modules/components/module.json +11 -0
- package/modules/database/mongoose/generator.json +3 -14
- package/modules/database/mongoose/module.json +2 -2
- package/modules/database/prisma/generator.json +6 -12
- package/modules/database/prisma/module.json +2 -2
- package/modules/storage/cloudinary/files/express/config/env.ts +65 -0
- package/modules/storage/cloudinary/files/express/config/media.ts +103 -0
- package/modules/storage/cloudinary/files/express/modules/media/media.controller.ts +59 -0
- package/modules/storage/cloudinary/files/express/modules/media/media.route.ts +29 -0
- package/modules/storage/cloudinary/files/express/modules/media/media.service.ts +113 -0
- package/modules/storage/cloudinary/files/express/modules/media/media.type.ts +32 -0
- package/modules/storage/cloudinary/generator.json +34 -0
- package/modules/storage/cloudinary/module.json +11 -0
- package/modules/ui/shadcn/generator.json +21 -0
- package/modules/ui/shadcn/module.json +11 -0
- package/package.json +24 -26
- package/templates/express/README.md +11 -16
- package/templates/express/src/config/env.ts +7 -5
- package/templates/nextjs/README.md +13 -18
- package/templates/nextjs/app/favicon.ico +0 -0
- package/templates/nextjs/app/layout.tsx +6 -4
- package/templates/nextjs/components/providers/query-provider.tsx +3 -0
- package/templates/nextjs/env.example +3 -1
- package/templates/nextjs/lib/axios/http.ts +23 -0
- package/templates/nextjs/lib/env.ts +7 -5
- package/templates/nextjs/package.json +2 -1
- package/templates/nextjs/template.json +1 -2
- package/templates/react/README.md +9 -14
- package/templates/react/index.html +1 -1
- package/templates/react/package.json +1 -1
- package/templates/react/src/assets/favicon.ico +0 -0
- package/templates/react/src/components/providers/query-provider.tsx +38 -0
- package/templates/react/src/{shared/components → components}/seo.tsx +4 -8
- package/templates/react/src/lib/axios/http.ts +24 -0
- package/templates/react/src/main.tsx +8 -11
- package/templates/react/src/{features/about/pages → pages}/about.tsx +1 -1
- package/templates/react/src/{features/home/pages → pages}/home.tsx +1 -1
- package/templates/react/src/router.tsx +6 -6
- package/templates/react/src/vite-env.d.ts +2 -1
- package/templates/react/template.json +0 -1
- package/templates/react/tsconfig.app.json +6 -0
- package/templates/react/tsconfig.json +7 -1
- package/templates/react/vite.config.ts +12 -0
- package/modules/auth/authjs/files/nextjs/api/auth/[...nextauth]/route.ts +0 -3
- package/modules/auth/authjs/files/nextjs/proxy.ts +0 -1
- package/modules/auth/authjs/files/shared/lib/auth.ts +0 -119
- package/modules/auth/authjs/files/shared/prisma/schema.prisma +0 -61
- package/modules/auth/authjs/generator.json +0 -64
- package/modules/auth/authjs/module.json +0 -13
- package/modules/auth/better-auth/files/express/modules/auth/auth.controller.ts +0 -264
- package/modules/auth/better-auth/files/express/modules/auth/auth.service.ts +0 -537
- package/modules/auth/better-auth/files/express/templates/google-redirect.ejs +0 -24
- package/modules/auth/better-auth/files/nextjs/api/auth/[...all]/route.ts +0 -4
- package/modules/auth/better-auth/files/nextjs/lib/auth/auth-guards.ts +0 -41
- package/modules/auth/better-auth/files/nextjs/templates/email-otp.tsx +0 -74
- package/templates/express/node_modules/.bin/acorn +0 -17
- package/templates/express/node_modules/.bin/eslint +0 -17
- package/templates/express/node_modules/.bin/tsc +0 -17
- package/templates/express/node_modules/.bin/tsserver +0 -17
- package/templates/express/node_modules/.bin/tsx +0 -17
- package/templates/nextjs/lib/api/http.ts +0 -40
- package/templates/nextjs/next-env.d.ts +0 -6
- package/templates/react/dist/assets/index-D4AHT4dU.js +0 -193
- package/templates/react/dist/assets/index-rpwj5ZOX.css +0 -1
- package/templates/react/dist/index.html +0 -14
- package/templates/react/dist/vite.svg +0 -1
- package/templates/react/public/vite.svg +0 -1
- package/templates/react/src/app/layouts/dashboard-layout.tsx +0 -8
- package/templates/react/src/app/layouts/public-layout.tsx +0 -5
- package/templates/react/src/app/providers.tsx +0 -20
- package/templates/react/src/app/router.tsx +0 -21
- package/templates/react/src/assets/react.svg +0 -1
- package/templates/react/src/shared/api/http.ts +0 -39
- package/templates/react/src/shared/components/loading.tsx +0 -8
- package/templates/react/src/shared/lib/query-client.ts +0 -12
- package/templates/react/src/utils/storage.ts +0 -35
- package/templates/react/src/utils/utils.ts +0 -3
- /package/templates/nextjs/app/{page.tsx → (public)/(root)/page.tsx} +0 -0
- /package/templates/react/src/{shared/components → components}/error-boundary.tsx +0 -0
- /package/templates/react/src/{shared/components → components}/layout.tsx +0 -0
- /package/templates/react/src/{shared/pages → pages}/not-found.tsx +0 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@/components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
Field,
|
|
6
|
+
FieldContent,
|
|
7
|
+
FieldError,
|
|
8
|
+
FieldLabel,
|
|
9
|
+
} from "@/components/ui/field";
|
|
10
|
+
import { type FileWithPreview, useFileUpload } from "@/hooks/use-file-upload";
|
|
11
|
+
import { api } from "@/lib/axios/http";
|
|
12
|
+
import { cn } from "@/lib/utils";
|
|
13
|
+
import {
|
|
14
|
+
closestCenter,
|
|
15
|
+
DndContext,
|
|
16
|
+
type DragEndEvent,
|
|
17
|
+
PointerSensor,
|
|
18
|
+
useSensor,
|
|
19
|
+
useSensors,
|
|
20
|
+
} from "@dnd-kit/core";
|
|
21
|
+
import {
|
|
22
|
+
arrayMove,
|
|
23
|
+
rectSortingStrategy,
|
|
24
|
+
SortableContext,
|
|
25
|
+
useSortable,
|
|
26
|
+
} from "@dnd-kit/sortable";
|
|
27
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
28
|
+
import {
|
|
29
|
+
AlertCircleIcon,
|
|
30
|
+
GripVerticalIcon,
|
|
31
|
+
ImageIcon,
|
|
32
|
+
UploadIcon,
|
|
33
|
+
XIcon,
|
|
34
|
+
} from "lucide-react";
|
|
35
|
+
import {
|
|
36
|
+
forwardRef,
|
|
37
|
+
useCallback,
|
|
38
|
+
useEffect,
|
|
39
|
+
useImperativeHandle,
|
|
40
|
+
useMemo,
|
|
41
|
+
useRef,
|
|
42
|
+
useState,
|
|
43
|
+
} from "react";
|
|
44
|
+
import { Controller, useFormContext } from "react-hook-form";
|
|
45
|
+
|
|
46
|
+
type SelectableMedia = {
|
|
47
|
+
id: string;
|
|
48
|
+
url: string;
|
|
49
|
+
alt: string | null;
|
|
50
|
+
sort: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type MediaKind = "image" | "video" | "pdf";
|
|
54
|
+
|
|
55
|
+
type Props = {
|
|
56
|
+
name: string;
|
|
57
|
+
label: string;
|
|
58
|
+
multiple?: boolean;
|
|
59
|
+
className?: string;
|
|
60
|
+
viewClass?: string;
|
|
61
|
+
selectableMedia?: SelectableMedia[];
|
|
62
|
+
keyPrefix?: string;
|
|
63
|
+
acceptTypes?: MediaKind[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type MediaUploaderFieldRef = {
|
|
67
|
+
uploadPendingFiles: () => Promise<{ urls: string[]; uploadedKeys: string[] }>;
|
|
68
|
+
revertUncommittedUploads: () => void;
|
|
69
|
+
getCurrentUrls: () => string[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type InitialFileMeta = {
|
|
73
|
+
name: string;
|
|
74
|
+
size: number;
|
|
75
|
+
type: string;
|
|
76
|
+
url: string;
|
|
77
|
+
id: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
async function uploadToCloudinaryViaPresign(
|
|
81
|
+
file: File,
|
|
82
|
+
keyPrefix = "products",
|
|
83
|
+
): Promise<{ key: string; publicUrl: string; secureUrl: string }> {
|
|
84
|
+
const resourceType = file.type.startsWith("image/")
|
|
85
|
+
? "image"
|
|
86
|
+
: file.type.startsWith("video/")
|
|
87
|
+
? "video"
|
|
88
|
+
: file.type === "application/pdf"
|
|
89
|
+
? "raw"
|
|
90
|
+
: "auto";
|
|
91
|
+
|
|
92
|
+
const res = await api.post<{ data: Record<string, unknown> }>(
|
|
93
|
+
"/v1/images/upload/presign",
|
|
94
|
+
{
|
|
95
|
+
folder: keyPrefix,
|
|
96
|
+
resourceType,
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
const payload = (res.data?.data as Record<string, unknown>) || {};
|
|
100
|
+
|
|
101
|
+
const form = new FormData();
|
|
102
|
+
|
|
103
|
+
if (payload.unsigned && payload.upload_preset) {
|
|
104
|
+
form.append("upload_preset", payload.upload_preset as string);
|
|
105
|
+
if (payload.folder) form.append("folder", payload.folder as string);
|
|
106
|
+
form.append("file", file);
|
|
107
|
+
const { data: upJson } = await api.post<Record<string, unknown>>(
|
|
108
|
+
payload.uploadUrl as string,
|
|
109
|
+
form,
|
|
110
|
+
);
|
|
111
|
+
return {
|
|
112
|
+
key: upJson.public_id as string,
|
|
113
|
+
publicUrl: upJson.secure_url as string,
|
|
114
|
+
secureUrl: upJson.secure_url as string,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!payload.unsigned) {
|
|
119
|
+
if (payload.api_key) form.append("api_key", payload.api_key as string);
|
|
120
|
+
if (payload.timestamp) form.append("timestamp", String(payload.timestamp));
|
|
121
|
+
if (payload.signature) form.append("signature", payload.signature as string);
|
|
122
|
+
if (payload.publicId) form.append("public_id", payload.publicId as string);
|
|
123
|
+
if (payload.folder) form.append("folder", payload.folder as string);
|
|
124
|
+
if (payload.resourceType) form.append("resource_type", payload.resourceType as string);
|
|
125
|
+
form.append("file", file);
|
|
126
|
+
|
|
127
|
+
const { data: upJson } = await api.post<Record<string, unknown>>(
|
|
128
|
+
payload.uploadUrl as string,
|
|
129
|
+
form,
|
|
130
|
+
);
|
|
131
|
+
return {
|
|
132
|
+
key: upJson.public_id as string,
|
|
133
|
+
publicUrl: upJson.url as string,
|
|
134
|
+
secureUrl: upJson.secure_url as string,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error("Invalid presign response");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function SortableMediaItem({
|
|
142
|
+
file,
|
|
143
|
+
onDelete,
|
|
144
|
+
isBusy,
|
|
145
|
+
}: {
|
|
146
|
+
file: FileWithPreview;
|
|
147
|
+
onDelete: () => void;
|
|
148
|
+
isBusy: boolean;
|
|
149
|
+
}) {
|
|
150
|
+
const {
|
|
151
|
+
attributes,
|
|
152
|
+
listeners,
|
|
153
|
+
setNodeRef,
|
|
154
|
+
transform,
|
|
155
|
+
transition,
|
|
156
|
+
isDragging,
|
|
157
|
+
} = useSortable({
|
|
158
|
+
id: file.id,
|
|
159
|
+
disabled: isBusy,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const style = {
|
|
163
|
+
transform: CSS.Transform.toString(transform),
|
|
164
|
+
transition,
|
|
165
|
+
zIndex: isDragging ? 50 : undefined,
|
|
166
|
+
boxShadow: isDragging ? "0 10px 25px rgba(0,0,0,0.12)" : undefined,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const src = file.preview || "";
|
|
170
|
+
|
|
171
|
+
const isVideo = file.file instanceof File && file.file.type.startsWith("video/");
|
|
172
|
+
const isPdf = file.file instanceof File && file.file.type === "application/pdf";
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div
|
|
176
|
+
ref={setNodeRef}
|
|
177
|
+
style={style}
|
|
178
|
+
className={cn(
|
|
179
|
+
"bg-accent relative aspect-square rounded-md transition-all duration-200 group",
|
|
180
|
+
isDragging && "opacity-75",
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
{src ? (
|
|
184
|
+
isVideo ? (
|
|
185
|
+
<video
|
|
186
|
+
src={src}
|
|
187
|
+
controls
|
|
188
|
+
className="rounded-[inherit] object-cover w-full h-full"
|
|
189
|
+
/>
|
|
190
|
+
) : isPdf ? (
|
|
191
|
+
<div className="flex items-center justify-center h-full text-xs">
|
|
192
|
+
PDF
|
|
193
|
+
</div>
|
|
194
|
+
) : (
|
|
195
|
+
<img
|
|
196
|
+
src={src}
|
|
197
|
+
alt={file.file?.name ?? "media"}
|
|
198
|
+
className="absolute inset-0 w-full h-full rounded-[inherit] object-cover"
|
|
199
|
+
/>
|
|
200
|
+
)
|
|
201
|
+
) : (
|
|
202
|
+
<div className="text-muted-foreground flex h-full w-full items-center justify-center text-xs">
|
|
203
|
+
Preview not available
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{!isBusy && (
|
|
208
|
+
<Button
|
|
209
|
+
{...attributes}
|
|
210
|
+
{...listeners}
|
|
211
|
+
type="button"
|
|
212
|
+
variant="ghost"
|
|
213
|
+
size="icon"
|
|
214
|
+
className="absolute top-1 left-1 z-20 size-8 bg-foreground/20 backdrop-blur-sm opacity-0 transition-opacity group-hover:opacity-100 hover:bg-foreground/30"
|
|
215
|
+
aria-label="Drag to reorder"
|
|
216
|
+
>
|
|
217
|
+
<GripVerticalIcon className="size-3 text-primary-foreground" />
|
|
218
|
+
</Button>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
<Button
|
|
222
|
+
onClick={onDelete}
|
|
223
|
+
size="icon"
|
|
224
|
+
className="border-background focus-visible:border-background absolute -top-2 -right-2 size-6 rounded-full border-2 shadow-none z-30"
|
|
225
|
+
aria-label="Remove media"
|
|
226
|
+
disabled={isBusy}
|
|
227
|
+
>
|
|
228
|
+
<XIcon className="size-3.5" />
|
|
229
|
+
</Button>
|
|
230
|
+
|
|
231
|
+
{isBusy && (
|
|
232
|
+
<div className="absolute inset-0 grid place-items-center rounded-[inherit] bg-foreground/30 text-[11px] text-primary-foreground z-20">
|
|
233
|
+
Uploading…
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const MediaUploaderField = forwardRef<MediaUploaderFieldRef, Props>(
|
|
241
|
+
function MediaUploaderField(
|
|
242
|
+
{
|
|
243
|
+
name,
|
|
244
|
+
label,
|
|
245
|
+
multiple = false,
|
|
246
|
+
className,
|
|
247
|
+
viewClass,
|
|
248
|
+
selectableMedia = [],
|
|
249
|
+
keyPrefix,
|
|
250
|
+
acceptTypes = ["image"],
|
|
251
|
+
},
|
|
252
|
+
ref,
|
|
253
|
+
) {
|
|
254
|
+
const acceptString = useMemo(() => {
|
|
255
|
+
const parts: string[] = [];
|
|
256
|
+
if (acceptTypes.includes("image")) parts.push("image/*");
|
|
257
|
+
if (acceptTypes.includes("video")) parts.push("video/*");
|
|
258
|
+
if (acceptTypes.includes("pdf")) parts.push("application/pdf");
|
|
259
|
+
return parts.join(",");
|
|
260
|
+
}, [acceptTypes]);
|
|
261
|
+
const sortedSelectableMedia = useMemo(() => {
|
|
262
|
+
return [...selectableMedia].sort((a, b) => a.sort - b.sort);
|
|
263
|
+
}, [selectableMedia]);
|
|
264
|
+
const { setValue, getValues } = useFormContext();
|
|
265
|
+
|
|
266
|
+
const getList = useCallback((): string[] => {
|
|
267
|
+
const v = getValues(name);
|
|
268
|
+
if (Array.isArray(v)) return (v as string[]).filter(Boolean);
|
|
269
|
+
if (typeof v === "string" && v.trim()) return [v.trim()];
|
|
270
|
+
return [];
|
|
271
|
+
}, [getValues, name]);
|
|
272
|
+
|
|
273
|
+
const setList = useCallback(
|
|
274
|
+
(urls: string[]) => {
|
|
275
|
+
if (multiple) {
|
|
276
|
+
setValue(name, urls, { shouldDirty: true, shouldValidate: true });
|
|
277
|
+
} else {
|
|
278
|
+
setValue(name, urls[0] ?? "", {
|
|
279
|
+
shouldDirty: true,
|
|
280
|
+
shouldValidate: true,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
[multiple, name, setValue],
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const initialFiles: InitialFileMeta[] = useMemo(() => {
|
|
288
|
+
const urls = getList();
|
|
289
|
+
return urls.map((url, i) => ({
|
|
290
|
+
name: `media-${i + 1}`,
|
|
291
|
+
size: 0,
|
|
292
|
+
type: "image/jpeg",
|
|
293
|
+
url,
|
|
294
|
+
id: `init-${i}-${url}`,
|
|
295
|
+
}));
|
|
296
|
+
}, [getList]);
|
|
297
|
+
|
|
298
|
+
const maxSizeMB = 50;
|
|
299
|
+
const maxSize = maxSizeMB * 1024 * 1024;
|
|
300
|
+
const maxFiles = multiple ? 20 : 1;
|
|
301
|
+
|
|
302
|
+
const _fileUpload = useFileUpload({
|
|
303
|
+
accept: acceptString,
|
|
304
|
+
maxSize,
|
|
305
|
+
multiple,
|
|
306
|
+
maxFiles,
|
|
307
|
+
initialFiles,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const files = _fileUpload[0].files;
|
|
311
|
+
const isDragging = _fileUpload[0].isDragging;
|
|
312
|
+
const errors = _fileUpload[0].errors;
|
|
313
|
+
|
|
314
|
+
const handleDragEnter = _fileUpload[1].handleDragEnter;
|
|
315
|
+
const handleDragLeave = _fileUpload[1].handleDragLeave;
|
|
316
|
+
const handleDragOver = _fileUpload[1].handleDragOver;
|
|
317
|
+
const handleDrop = _fileUpload[1].handleDrop;
|
|
318
|
+
const openFileDialog = _fileUpload[1].openFileDialog;
|
|
319
|
+
const removeFile = _fileUpload[1].removeFile;
|
|
320
|
+
const getInputProps = _fileUpload[1].getInputProps;
|
|
321
|
+
const clearErrors = _fileUpload[1].clearErrors;
|
|
322
|
+
|
|
323
|
+
const idToUrl = useRef<Record<string, string>>(Object.fromEntries(initialFiles.map((f) => [f.id, f.url])));
|
|
324
|
+
const initialPreviewById = useRef<Record<string, string>>(Object.fromEntries(initialFiles.map((f) => [f.id, f.url])));
|
|
325
|
+
const uploadedKeyById = useRef<Record<string, string>>({});
|
|
326
|
+
|
|
327
|
+
const [busyIds, setBusyIds] = useState<Record<string, boolean>>({});
|
|
328
|
+
const [topError, setTopError] = useState<string | null>(null);
|
|
329
|
+
const [showSelectableMedia, setShowSelectableMedia] = useState(false);
|
|
330
|
+
|
|
331
|
+
const [fileOrder, setFileOrder] = useState<string[]>([]);
|
|
332
|
+
|
|
333
|
+
const sensors = useSensors(
|
|
334
|
+
useSensor(PointerSensor, {
|
|
335
|
+
activationConstraint: {
|
|
336
|
+
distance: 8,
|
|
337
|
+
},
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
const currentFileIds = files.map((f) => f.id as string);
|
|
343
|
+
setFileOrder((prevOrder) => {
|
|
344
|
+
if (prevOrder.length === 0) {
|
|
345
|
+
return currentFileIds;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const prevSet = new Set(prevOrder);
|
|
349
|
+
const currentSet = new Set(currentFileIds);
|
|
350
|
+
|
|
351
|
+
if (
|
|
352
|
+
prevSet.size !== currentSet.size ||
|
|
353
|
+
![...prevSet].every((id) => currentSet.has(id))
|
|
354
|
+
) {
|
|
355
|
+
const existingIds = prevOrder.filter((id) => currentSet.has(id));
|
|
356
|
+
const newIds = currentFileIds.filter((id) => !prevSet.has(id));
|
|
357
|
+
return [...existingIds, ...newIds];
|
|
358
|
+
}
|
|
359
|
+
return prevOrder;
|
|
360
|
+
});
|
|
361
|
+
}, [files]);
|
|
362
|
+
|
|
363
|
+
const orderedFiles = useMemo(() => {
|
|
364
|
+
if (fileOrder.length === 0) return files;
|
|
365
|
+
|
|
366
|
+
const fileMap = new Map(files.map((file) => [file.id as string, file]));
|
|
367
|
+
return fileOrder
|
|
368
|
+
.map((id) => fileMap.get(id))
|
|
369
|
+
.filter(Boolean) as FileWithPreview[];
|
|
370
|
+
}, [files, fileOrder]);
|
|
371
|
+
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
const current = getList();
|
|
374
|
+
if (current.length === 0 && initialFiles.length > 0) {
|
|
375
|
+
setList(initialFiles.map((f) => f.url));
|
|
376
|
+
}
|
|
377
|
+
}, [getList, initialFiles, setList]);
|
|
378
|
+
|
|
379
|
+
useEffect(() => {
|
|
380
|
+
setTopError(null);
|
|
381
|
+
clearErrors?.();
|
|
382
|
+
|
|
383
|
+
for (const f of files) {
|
|
384
|
+
const id = f.id as string;
|
|
385
|
+
if (idToUrl.current[id]) continue;
|
|
386
|
+
|
|
387
|
+
const fallbackValue =
|
|
388
|
+
f.preview ||
|
|
389
|
+
(f.file instanceof File ? "" : ((f.file as InitialFileMeta).url ?? ""));
|
|
390
|
+
|
|
391
|
+
if (!fallbackValue) continue;
|
|
392
|
+
|
|
393
|
+
idToUrl.current[id] = fallbackValue;
|
|
394
|
+
initialPreviewById.current[id] = fallbackValue;
|
|
395
|
+
|
|
396
|
+
const currentUrls = getList();
|
|
397
|
+
const fileIndex = files.findIndex((file) => file.id === id);
|
|
398
|
+
if (fileIndex !== -1) {
|
|
399
|
+
const newUrls = [...currentUrls];
|
|
400
|
+
newUrls[fileIndex] = fallbackValue;
|
|
401
|
+
setList(newUrls.filter(Boolean));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}, [files, clearErrors, getList, setList]);
|
|
405
|
+
|
|
406
|
+
useImperativeHandle(
|
|
407
|
+
ref,
|
|
408
|
+
() => ({
|
|
409
|
+
uploadPendingFiles: async () => {
|
|
410
|
+
const filesToProcess = multiple ? orderedFiles : files;
|
|
411
|
+
const uploadedKeys: string[] = [];
|
|
412
|
+
const resolvedUrls: string[] = [];
|
|
413
|
+
|
|
414
|
+
setTopError(null);
|
|
415
|
+
|
|
416
|
+
for (const fileEntry of filesToProcess) {
|
|
417
|
+
const id = fileEntry.id as string;
|
|
418
|
+
|
|
419
|
+
if (fileEntry.file instanceof File) {
|
|
420
|
+
const hasRemoteUrl = !!idToUrl.current[id] && /^(https?:\/\/)/.test(idToUrl.current[id]);
|
|
421
|
+
|
|
422
|
+
if (!hasRemoteUrl) {
|
|
423
|
+
try {
|
|
424
|
+
setBusyIds((p) => ({ ...p, [id]: true }));
|
|
425
|
+
const { key, secureUrl } = await uploadToCloudinaryViaPresign(
|
|
426
|
+
fileEntry.file,
|
|
427
|
+
keyPrefix ?? "uploads",
|
|
428
|
+
);
|
|
429
|
+
idToUrl.current[id] = secureUrl;
|
|
430
|
+
uploadedKeyById.current[id] = key;
|
|
431
|
+
} catch (e: unknown) {
|
|
432
|
+
const errorMessage =
|
|
433
|
+
typeof e === "object" && e !== null && "message" in e
|
|
434
|
+
? (e as { message?: string }).message
|
|
435
|
+
: undefined;
|
|
436
|
+
setTopError(errorMessage ?? "Failed to upload media.");
|
|
437
|
+
throw e;
|
|
438
|
+
} finally {
|
|
439
|
+
setBusyIds((p) => {
|
|
440
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
441
|
+
const { [id]: _omit, ...rest } = p;
|
|
442
|
+
return rest;
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (uploadedKeyById.current[id]) {
|
|
448
|
+
uploadedKeys.push(uploadedKeyById.current[id]);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const resolvedUrl =
|
|
453
|
+
idToUrl.current[id] || (fileEntry.file instanceof File ? fileEntry.preview || "" : ((fileEntry.file as InitialFileMeta).url ?? ""));
|
|
454
|
+
|
|
455
|
+
if (resolvedUrl) {
|
|
456
|
+
resolvedUrls.push(resolvedUrl);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
setList(resolvedUrls);
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
urls: resolvedUrls,
|
|
464
|
+
uploadedKeys: Array.from(new Set(uploadedKeys)),
|
|
465
|
+
};
|
|
466
|
+
},
|
|
467
|
+
revertUncommittedUploads: () => {
|
|
468
|
+
const filesToProcess = multiple ? orderedFiles : files;
|
|
469
|
+
const revertedUrls = filesToProcess
|
|
470
|
+
.map((fileEntry) => {
|
|
471
|
+
const id = fileEntry.id as string;
|
|
472
|
+
if (fileEntry.file instanceof File) {
|
|
473
|
+
const fallback = initialPreviewById.current[id] || fileEntry.preview || "";
|
|
474
|
+
if (fallback) {
|
|
475
|
+
idToUrl.current[id] = fallback;
|
|
476
|
+
}
|
|
477
|
+
delete uploadedKeyById.current[id];
|
|
478
|
+
return fallback;
|
|
479
|
+
}
|
|
480
|
+
return idToUrl.current[id] || (fileEntry.file as InitialFileMeta).url || "";
|
|
481
|
+
})
|
|
482
|
+
.filter(Boolean);
|
|
483
|
+
|
|
484
|
+
setList(revertedUrls);
|
|
485
|
+
},
|
|
486
|
+
getCurrentUrls: () => getList(),
|
|
487
|
+
}),
|
|
488
|
+
[files, getList, keyPrefix, multiple, orderedFiles, setList],
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
const handleRemove = useCallback(
|
|
492
|
+
(id: string) => {
|
|
493
|
+
const url = idToUrl.current[id];
|
|
494
|
+
if (url) {
|
|
495
|
+
setList(getList().filter((u) => u !== url));
|
|
496
|
+
delete idToUrl.current[id];
|
|
497
|
+
delete initialPreviewById.current[id];
|
|
498
|
+
delete uploadedKeyById.current[id];
|
|
499
|
+
}
|
|
500
|
+
removeFile(id);
|
|
501
|
+
},
|
|
502
|
+
[getList, removeFile, setList],
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const handleSelectMedia = useCallback(
|
|
506
|
+
(media: SelectableMedia) => {
|
|
507
|
+
const currentUrls = getList();
|
|
508
|
+
|
|
509
|
+
if (multiple) {
|
|
510
|
+
if (currentUrls.includes(media.url)) {
|
|
511
|
+
const newUrls = currentUrls.filter((url) => url !== media.url);
|
|
512
|
+
setList(newUrls);
|
|
513
|
+
|
|
514
|
+
Object.keys(idToUrl.current).forEach((id) => {
|
|
515
|
+
if (idToUrl.current[id] === media.url) {
|
|
516
|
+
delete idToUrl.current[id];
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
} else {
|
|
520
|
+
const newUrls = [...currentUrls, media.url];
|
|
521
|
+
setList(newUrls);
|
|
522
|
+
|
|
523
|
+
const selectedId = `selected-${media.id}-${Date.now()}`;
|
|
524
|
+
idToUrl.current[selectedId] = media.url;
|
|
525
|
+
}
|
|
526
|
+
} else {
|
|
527
|
+
setList([media.url]);
|
|
528
|
+
setShowSelectableMedia(false);
|
|
529
|
+
|
|
530
|
+
idToUrl.current = {};
|
|
531
|
+
const selectedId = `selected-${media.id}-${Date.now()}`;
|
|
532
|
+
idToUrl.current[selectedId] = media.url;
|
|
533
|
+
}
|
|
534
|
+
},
|
|
535
|
+
[getList, setList, multiple],
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
const handleDragEnd = useCallback(
|
|
539
|
+
(event: DragEndEvent) => {
|
|
540
|
+
const { active, over } = event;
|
|
541
|
+
|
|
542
|
+
const activeIndex = orderedFiles.findIndex((f) => f.id === active.id);
|
|
543
|
+
const overIndex = orderedFiles.findIndex((f) => f.id === over?.id);
|
|
544
|
+
|
|
545
|
+
const currentUrls = getList();
|
|
546
|
+
|
|
547
|
+
if (currentUrls.length !== files.length) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const idToUrlMap: Record<string, string> = {};
|
|
552
|
+
files.forEach((file, index) => {
|
|
553
|
+
const fileId = file.id as string;
|
|
554
|
+
const url = currentUrls[index];
|
|
555
|
+
if (url) {
|
|
556
|
+
idToUrlMap[fileId] = url;
|
|
557
|
+
idToUrl.current[fileId] = url;
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const newFileOrder = arrayMove([...fileOrder], activeIndex, overIndex);
|
|
562
|
+
setFileOrder(newFileOrder);
|
|
563
|
+
|
|
564
|
+
const reorderedUrls = newFileOrder.map((fileId) => idToUrlMap[fileId]).filter(Boolean);
|
|
565
|
+
|
|
566
|
+
setList(reorderedUrls);
|
|
567
|
+
},
|
|
568
|
+
[orderedFiles, getList, setList, fileOrder, files],
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
return (
|
|
572
|
+
<Controller
|
|
573
|
+
name={name}
|
|
574
|
+
render={({ fieldState }) => (
|
|
575
|
+
<Field className={className} data-invalid={fieldState.invalid}>
|
|
576
|
+
<FieldLabel>{label}</FieldLabel>
|
|
577
|
+
<FieldContent>
|
|
578
|
+
<div className={cn("flex flex-col gap-2", viewClass)}>
|
|
579
|
+
<div
|
|
580
|
+
onDragEnter={handleDragEnter}
|
|
581
|
+
onDragLeave={handleDragLeave}
|
|
582
|
+
onDragOver={handleDragOver}
|
|
583
|
+
onDrop={handleDrop}
|
|
584
|
+
data-dragging={isDragging || undefined}
|
|
585
|
+
data-files={files.length > 0 || undefined}
|
|
586
|
+
className={cn(
|
|
587
|
+
"border-input data-[dragging=true]:bg-accent/50 has-[input:focus]:border-ring has-[input:focus]:ring-ring/50 relative flex min-h-52 flex-col items-center overflow-hidden rounded-xl border border-dashed p-4 transition-colors not-data-files:justify-center has-[input:focus]:ring-[3px]",
|
|
588
|
+
)}
|
|
589
|
+
>
|
|
590
|
+
<input {...getInputProps()} className="sr-only" aria-label="Upload media file" />
|
|
591
|
+
|
|
592
|
+
{files.length > 0 ? (
|
|
593
|
+
<div className="flex w-full flex-col gap-3">
|
|
594
|
+
<div className="flex items-center justify-between gap-2">
|
|
595
|
+
<h3 className="truncate text-sm font-medium">Uploaded Files ({files.length})</h3>
|
|
596
|
+
<div className="flex gap-2">
|
|
597
|
+
{sortedSelectableMedia.length > 0 && (
|
|
598
|
+
<Button
|
|
599
|
+
type="button"
|
|
600
|
+
variant="outline"
|
|
601
|
+
size="sm"
|
|
602
|
+
onClick={() => setShowSelectableMedia(!showSelectableMedia)}
|
|
603
|
+
disabled={files.length >= maxFiles}
|
|
604
|
+
>
|
|
605
|
+
<ImageIcon className="-ms-0.5 size-3.5 opacity-60" aria-hidden="true" />
|
|
606
|
+
Select Media
|
|
607
|
+
</Button>
|
|
608
|
+
)}
|
|
609
|
+
<Button type="button" variant="outline" size="sm" onClick={openFileDialog} disabled={files.length >= maxFiles}>
|
|
610
|
+
<UploadIcon className="-ms-0.5 size-3.5 opacity-60" aria-hidden="true" />
|
|
611
|
+
{multiple ? "Add more" : "Replace"}
|
|
612
|
+
</Button>
|
|
613
|
+
</div>
|
|
614
|
+
</div>
|
|
615
|
+
|
|
616
|
+
{showSelectableMedia && sortedSelectableMedia.length > 0 && (
|
|
617
|
+
<div className="border-t pt-3">
|
|
618
|
+
<h4 className="text-sm font-medium mb-2">Select from available media:</h4>
|
|
619
|
+
<div className="grid grid-cols-4 gap-2 md:grid-cols-6 lg:grid-cols-8 max-h-40 overflow-y-auto">
|
|
620
|
+
{sortedSelectableMedia.map((m) => {
|
|
621
|
+
const isSelected = getList().includes(m.url);
|
|
622
|
+
return (
|
|
623
|
+
<button
|
|
624
|
+
key={m.id}
|
|
625
|
+
type="button"
|
|
626
|
+
className={cn(
|
|
627
|
+
"relative aspect-square rounded-md border-2 transition-all hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 h-16 w-16",
|
|
628
|
+
isSelected ? "border-primary bg-primary/10" : "border-border hover:border-primary/50",
|
|
629
|
+
)}
|
|
630
|
+
onClick={() => handleSelectMedia(m)}
|
|
631
|
+
>
|
|
632
|
+
<img src={m.url} alt={m.alt || "Selectable media"} className="absolute inset-0 w-full h-full rounded-[inherit] object-cover" />
|
|
633
|
+
</button>
|
|
634
|
+
);
|
|
635
|
+
})}
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
)}
|
|
639
|
+
|
|
640
|
+
{multiple ? (
|
|
641
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
642
|
+
<SortableContext items={orderedFiles.map((f) => f.id as string)} strategy={rectSortingStrategy}>
|
|
643
|
+
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
|
644
|
+
{orderedFiles.map((file) => {
|
|
645
|
+
const id = file.id as string;
|
|
646
|
+
const isBusy = !!busyIds[id];
|
|
647
|
+
|
|
648
|
+
return <SortableMediaItem key={id} file={file} onDelete={() => handleRemove(id)} isBusy={isBusy} />;
|
|
649
|
+
})}
|
|
650
|
+
</div>
|
|
651
|
+
</SortableContext>
|
|
652
|
+
</DndContext>
|
|
653
|
+
) : (
|
|
654
|
+
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
|
655
|
+
{files.map((file) => {
|
|
656
|
+
const id = file.id as string;
|
|
657
|
+
const isBusy = !!busyIds[id];
|
|
658
|
+
const src = file.preview || "";
|
|
659
|
+
|
|
660
|
+
return (
|
|
661
|
+
<div key={id} className="bg-accent relative aspect-square rounded-md">
|
|
662
|
+
{src ? (
|
|
663
|
+
file.file instanceof File && file.file.type.startsWith("video/") ? (
|
|
664
|
+
<video src={src} controls className="rounded-[inherit] object-cover w-full h-full" />
|
|
665
|
+
) : (
|
|
666
|
+
<img src={src} alt={file.file?.name ?? "media"} className="absolute inset-0 w-full h-full rounded-[inherit] object-cover" />
|
|
667
|
+
)
|
|
668
|
+
) : (
|
|
669
|
+
<div className="text-muted-foreground flex h-full w-full items-center justify-center text-xs">Preview not available</div>
|
|
670
|
+
)}
|
|
671
|
+
|
|
672
|
+
<Button onClick={() => handleRemove(id)} size="icon" className="border-background focus-visible:border-background absolute -top-2 -right-2 size-6 rounded-full border-2 shadow-none z-30" aria-label="Remove media" disabled={isBusy}>
|
|
673
|
+
<XIcon className="size-3.5" />
|
|
674
|
+
</Button>
|
|
675
|
+
|
|
676
|
+
{isBusy && (
|
|
677
|
+
<div className="absolute inset-0 grid place-items-center rounded-[inherit] bg-foreground/30 text-[11px] text-primary-foreground z-20">Uploading…</div>
|
|
678
|
+
)}
|
|
679
|
+
</div>
|
|
680
|
+
);
|
|
681
|
+
})}
|
|
682
|
+
</div>
|
|
683
|
+
)}
|
|
684
|
+
</div>
|
|
685
|
+
) : (
|
|
686
|
+
<div className="flex flex-col items-center justify-center px-4 py-3 text-center">
|
|
687
|
+
<div className="bg-background mb-2 flex size-11 shrink-0 items-center justify-center rounded-full border" aria-hidden="true">
|
|
688
|
+
<ImageIcon className="size-4 opacity-60" />
|
|
689
|
+
</div>
|
|
690
|
+
<p className="mb-1.5 text-sm font-medium">Drop your media here</p>
|
|
691
|
+
<p className="text-muted-foreground text-xs">Accept: {acceptTypes.join(", ")}</p>
|
|
692
|
+
<div className="flex gap-2 mt-4">
|
|
693
|
+
<Button type="button" variant="outline" onClick={openFileDialog}>
|
|
694
|
+
<UploadIcon className="-ms-1 opacity-60" aria-hidden="true" />
|
|
695
|
+
{multiple ? "Upload files" : "Upload file"}
|
|
696
|
+
</Button>
|
|
697
|
+
{sortedSelectableMedia.length > 0 && (
|
|
698
|
+
<Button type="button" variant="outline" onClick={() => setShowSelectableMedia(true)}>
|
|
699
|
+
<ImageIcon className="-ms-1 opacity-60" aria-hidden="true" />
|
|
700
|
+
Select Media
|
|
701
|
+
</Button>
|
|
702
|
+
)}
|
|
703
|
+
</div>
|
|
704
|
+
</div>
|
|
705
|
+
)}
|
|
706
|
+
</div>
|
|
707
|
+
|
|
708
|
+
{files.length === 0 && showSelectableMedia && sortedSelectableMedia.length > 0 && (
|
|
709
|
+
<div className="border border-dashed rounded-xl p-4 mt-2">
|
|
710
|
+
<div className="flex items-center justify-between mb-3">
|
|
711
|
+
<h4 className="text-sm font-medium">Select from available media:</h4>
|
|
712
|
+
<Button type="button" variant="ghost" size="sm" onClick={() => setShowSelectableMedia(false)}>
|
|
713
|
+
<XIcon className="size-4" />
|
|
714
|
+
</Button>
|
|
715
|
+
</div>
|
|
716
|
+
<div className="grid grid-cols-4 gap-2 md:grid-cols-6 lg:grid-cols-8 max-h-40 overflow-y-auto">
|
|
717
|
+
{sortedSelectableMedia.map((m) => {
|
|
718
|
+
const isSelected = getList().includes(m.url);
|
|
719
|
+
return (
|
|
720
|
+
<button key={m.id} type="button" className={cn("relative aspect-square rounded-md border-2 transition-all hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 h-16 w-16", isSelected ? "border-primary bg-primary/10" : "border-border hover:border-primary/50")} onClick={() => handleSelectMedia(m)}>
|
|
721
|
+
<img src={m.url} alt={m.alt || "Selectable media"} className="absolute inset-0 w-full h-full rounded-[inherit] object-cover" />
|
|
722
|
+
</button>
|
|
723
|
+
);
|
|
724
|
+
})}
|
|
725
|
+
</div>
|
|
726
|
+
</div>
|
|
727
|
+
)}
|
|
728
|
+
|
|
729
|
+
{(errors.length > 0 || topError) && (
|
|
730
|
+
<div className="text-destructive flex items-center gap-1 text-xs" role="alert">
|
|
731
|
+
<AlertCircleIcon className="size-3 shrink-0" />
|
|
732
|
+
<span>{topError ?? errors[0]}</span>
|
|
733
|
+
</div>
|
|
734
|
+
)}
|
|
735
|
+
</div>
|
|
736
|
+
</FieldContent>
|
|
737
|
+
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
|
738
|
+
</Field>
|
|
739
|
+
)}
|
|
740
|
+
/>
|
|
741
|
+
);
|
|
742
|
+
},
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
export default MediaUploaderField;
|