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.
Files changed (203) hide show
  1. package/README.md +50 -42
  2. package/dist/cli/add.js +122 -56
  3. package/dist/cli/create.d.ts +2 -0
  4. package/dist/cli/create.js +271 -95
  5. package/dist/cli/doctor.js +1 -0
  6. package/dist/cli/list.d.ts +1 -1
  7. package/dist/cli/list.js +6 -4
  8. package/dist/index.js +234 -191
  9. package/dist/lib/constants.d.ts +4 -0
  10. package/dist/lib/constants.js +4 -0
  11. package/dist/lib/discovery/module-discovery.d.ts +4 -0
  12. package/dist/lib/discovery/module-discovery.js +56 -0
  13. package/dist/lib/generation/code-generator.d.ts +11 -2
  14. package/dist/lib/generation/code-generator.js +42 -3
  15. package/dist/lib/generation/generator-utils.js +3 -1
  16. package/dist/lib/pm/package-manager.js +16 -13
  17. package/dist/lib/ui/logger.js +3 -2
  18. package/dist/lib/utils/path-resolver.d.ts +2 -0
  19. package/dist/lib/utils/path-resolver.js +8 -0
  20. package/dist/meta.json +8312 -0
  21. package/modules/auth/better-auth/files/{shared → express}/config/env.ts +48 -52
  22. package/modules/auth/better-auth/files/express/middlewares/authorize.ts +20 -1
  23. package/modules/auth/better-auth/files/express/modules/auth.controller.ts +349 -0
  24. package/modules/auth/better-auth/files/express/modules/{auth/auth.route.ts → auth.route.ts} +12 -7
  25. package/modules/auth/better-auth/files/express/modules/auth.service.ts +664 -0
  26. package/modules/auth/better-auth/files/express/modules/{auth/auth.type.ts → auth.type.ts} +22 -9
  27. package/modules/auth/better-auth/files/{shared/mongoose/auth/constants.ts → express/mongo-modules/auth.constants.ts} +0 -1
  28. package/modules/auth/better-auth/files/{shared/mongoose/auth/helper.ts → express/mongo-modules/auth.helper.ts} +11 -1
  29. package/modules/auth/better-auth/files/express/types/express.d.ts +11 -0
  30. package/modules/auth/better-auth/files/nextjs/api-route.ts +74 -0
  31. package/modules/auth/better-auth/files/nextjs/dashboard/pages/(user)/page.tsx +6 -0
  32. package/modules/auth/better-auth/files/nextjs/dashboard/pages/admin/page.tsx +6 -0
  33. package/modules/auth/better-auth/files/nextjs/dashboard/pages/layout.tsx +48 -0
  34. package/modules/auth/better-auth/files/nextjs/dashboard/pages/my-profile/page.tsx +5 -0
  35. package/modules/auth/better-auth/files/nextjs/features/services/auth.service.ts +102 -0
  36. package/modules/auth/better-auth/files/nextjs/layout/layout.tsx +13 -0
  37. package/modules/auth/better-auth/files/nextjs/lib/axios/http.ts +158 -0
  38. package/modules/auth/better-auth/files/nextjs/lib/env.ts +35 -0
  39. package/modules/auth/better-auth/files/nextjs/lib/utils/auth.ts +75 -0
  40. package/modules/auth/better-auth/files/nextjs/lib/utils/cookie.ts +29 -0
  41. package/modules/auth/better-auth/files/nextjs/lib/utils/jwt.ts +28 -0
  42. package/modules/auth/better-auth/files/nextjs/lib/utils/token.ts +49 -0
  43. package/modules/auth/better-auth/files/nextjs/pages/forgot-password/page.tsx +5 -0
  44. package/modules/auth/better-auth/files/nextjs/pages/layout.tsx +11 -0
  45. package/modules/auth/better-auth/files/nextjs/pages/login/page.tsx +9 -0
  46. package/modules/auth/better-auth/files/nextjs/pages/register/page.tsx +5 -0
  47. package/modules/auth/better-auth/files/nextjs/pages/reset-password/page.tsx +10 -0
  48. package/modules/auth/better-auth/files/nextjs/pages/verify-email/page.tsx +10 -0
  49. package/modules/auth/better-auth/files/nextjs/proxy.ts +157 -22
  50. package/modules/auth/better-auth/files/nextjs/theme/providers/theme-provider.tsx +11 -0
  51. package/modules/auth/better-auth/files/nextjs/types/api.types.ts +18 -0
  52. package/modules/auth/better-auth/files/react/components/protected-route.tsx +39 -0
  53. package/modules/auth/better-auth/files/react/components/route-guards.tsx +13 -0
  54. package/modules/auth/better-auth/files/react/dashboard/admin/pages/overview.tsx +3 -0
  55. package/modules/auth/better-auth/files/react/dashboard/pages/overview.tsx +3 -0
  56. package/modules/auth/better-auth/files/react/features/pages/forgot-password.tsx +5 -0
  57. package/modules/auth/better-auth/files/react/features/pages/login.tsx +5 -0
  58. package/modules/auth/better-auth/files/react/features/pages/my-profile.tsx +5 -0
  59. package/modules/auth/better-auth/files/react/features/pages/oauth-callback.tsx +59 -0
  60. package/modules/auth/better-auth/files/react/features/pages/register.tsx +5 -0
  61. package/modules/auth/better-auth/files/react/features/pages/reset-password.tsx +10 -0
  62. package/modules/auth/better-auth/files/react/features/pages/verify-email.tsx +10 -0
  63. package/modules/auth/better-auth/files/react/layout/dashboard-layout.tsx +54 -0
  64. package/modules/auth/better-auth/files/react/lib/axios/http.ts +68 -0
  65. package/modules/auth/better-auth/files/react/lib/env.ts +25 -0
  66. package/modules/auth/better-auth/files/react/router.tsx +73 -0
  67. package/modules/auth/better-auth/files/react/theme/components/providers/theme-provider-context.ts +13 -0
  68. package/modules/auth/better-auth/files/react/theme/components/providers/theme-provider.tsx +51 -0
  69. package/modules/auth/better-auth/files/react/theme/hooks/use-theme.ts +8 -0
  70. package/modules/auth/better-auth/files/shared/features/components/change-password-dialog.tsx +113 -0
  71. package/modules/auth/better-auth/files/shared/features/components/forgot-password-form.tsx +84 -0
  72. package/modules/auth/better-auth/files/shared/features/components/login-form.tsx +134 -0
  73. package/modules/auth/better-auth/files/shared/features/components/my-profile.tsx +147 -0
  74. package/modules/auth/better-auth/files/shared/features/components/profile-form.tsx +205 -0
  75. package/modules/auth/better-auth/files/shared/features/components/register-form.tsx +100 -0
  76. package/modules/auth/better-auth/files/shared/features/components/reset-password-form.tsx +111 -0
  77. package/modules/auth/better-auth/files/shared/features/components/social-login-buttons.tsx +47 -0
  78. package/modules/auth/better-auth/files/shared/features/components/user-profile-menu.tsx +106 -0
  79. package/modules/auth/better-auth/files/shared/features/components/verify-email-form.tsx +110 -0
  80. package/modules/auth/better-auth/files/shared/features/queries/auth.mutations.tsx +312 -0
  81. package/modules/auth/better-auth/files/shared/features/queries/auth.querie.ts +19 -0
  82. package/modules/auth/better-auth/files/shared/features/services/auth.api.ts +81 -0
  83. package/modules/auth/better-auth/files/shared/features/types/auth.type.ts +47 -0
  84. package/modules/auth/better-auth/files/shared/features/validators/change-password.validator.ts +18 -0
  85. package/modules/auth/better-auth/files/shared/features/validators/forgot.validator.ts +7 -0
  86. package/modules/auth/better-auth/files/shared/features/validators/login.validator.ts +14 -0
  87. package/modules/auth/better-auth/files/shared/features/validators/profile.validator.ts +8 -0
  88. package/modules/auth/better-auth/files/shared/features/validators/register.validator.ts +9 -0
  89. package/modules/auth/better-auth/files/shared/features/validators/reset.validator.ts +9 -0
  90. package/modules/auth/better-auth/files/shared/features/validators/verify.validator.ts +8 -0
  91. package/modules/auth/better-auth/files/shared/lib/auth-client.ts +2 -1
  92. package/modules/auth/better-auth/files/shared/lib/auth.ts +10 -29
  93. package/modules/auth/better-auth/files/shared/lib/constant/dashboard.ts +90 -0
  94. package/modules/auth/better-auth/files/shared/prisma/enums.prisma +0 -1
  95. package/modules/auth/better-auth/files/shared/theme/mode-toggle.tsx +30 -0
  96. package/modules/auth/better-auth/files/shared/ui/shadcn/components/dashboard/dashboard-header.tsx +94 -0
  97. package/modules/auth/better-auth/files/shared/ui/shadcn/components/dashboard/dashboard-sidebar.tsx +255 -0
  98. package/modules/auth/better-auth/files/shared/ui/shadcn/components/footer.tsx +35 -0
  99. package/modules/auth/better-auth/files/shared/ui/shadcn/components/navbar.tsx +145 -0
  100. package/modules/auth/better-auth/files/shared/ui/shadcn/form-field/input-field.tsx +440 -0
  101. package/modules/auth/better-auth/files/shared/utils/email.ts +20 -18
  102. package/modules/auth/better-auth/generator.json +174 -53
  103. package/modules/auth/better-auth/module.json +2 -2
  104. package/modules/components/files/shared/hooks/use-file-upload.ts +412 -0
  105. package/modules/components/files/shared/lib/utils/url-helpers.ts +110 -0
  106. package/modules/components/files/shared/shadcn/dashboard/data-table-column-selector.tsx +52 -0
  107. package/modules/components/files/shared/shadcn/dashboard/data-table-footer.tsx +156 -0
  108. package/modules/components/files/shared/shadcn/dashboard/data-table.tsx +405 -0
  109. package/modules/components/files/shared/shadcn/global/form-field/input-field.tsx +440 -0
  110. package/modules/components/files/shared/shadcn/global/form-field/media-uploader-field.tsx +745 -0
  111. package/modules/components/files/shared/shadcn/global/form-field/multi-select-field.tsx +207 -0
  112. package/modules/components/files/shared/shadcn/global/form-field/select-field.tsx +247 -0
  113. package/modules/components/files/shared/shadcn/global/form-field/textarea-field.tsx +277 -0
  114. package/modules/components/files/shared/shadcn/global/form-field/tiptap-editor-field.tsx +35 -0
  115. package/modules/components/files/shared/shadcn/global/no-results.tsx +41 -0
  116. package/modules/components/files/shared/shadcn/tiptap-editor/editor-menu-bar.tsx +217 -0
  117. package/modules/components/files/shared/shadcn/tiptap-editor/tiptap-editor.tsx +104 -0
  118. package/modules/components/files/shared/url/load-more.tsx +93 -0
  119. package/modules/components/files/shared/url/search-bar.tsx +131 -0
  120. package/modules/components/files/shared/url/sort-select.tsx +118 -0
  121. package/modules/components/files/shared/url/url-tabs.tsx +77 -0
  122. package/modules/components/generator.json +109 -0
  123. package/modules/components/module.json +11 -0
  124. package/modules/database/mongoose/generator.json +3 -14
  125. package/modules/database/mongoose/module.json +2 -2
  126. package/modules/database/prisma/generator.json +6 -12
  127. package/modules/database/prisma/module.json +2 -2
  128. package/modules/storage/cloudinary/files/express/config/env.ts +65 -0
  129. package/modules/storage/cloudinary/files/express/config/media.ts +103 -0
  130. package/modules/storage/cloudinary/files/express/modules/media/media.controller.ts +59 -0
  131. package/modules/storage/cloudinary/files/express/modules/media/media.route.ts +29 -0
  132. package/modules/storage/cloudinary/files/express/modules/media/media.service.ts +113 -0
  133. package/modules/storage/cloudinary/files/express/modules/media/media.type.ts +32 -0
  134. package/modules/storage/cloudinary/generator.json +34 -0
  135. package/modules/storage/cloudinary/module.json +11 -0
  136. package/modules/ui/shadcn/generator.json +21 -0
  137. package/modules/ui/shadcn/module.json +11 -0
  138. package/package.json +24 -26
  139. package/templates/express/README.md +11 -16
  140. package/templates/express/src/config/env.ts +7 -5
  141. package/templates/nextjs/README.md +13 -18
  142. package/templates/nextjs/app/favicon.ico +0 -0
  143. package/templates/nextjs/app/layout.tsx +6 -4
  144. package/templates/nextjs/components/providers/query-provider.tsx +3 -0
  145. package/templates/nextjs/env.example +3 -1
  146. package/templates/nextjs/lib/axios/http.ts +23 -0
  147. package/templates/nextjs/lib/env.ts +7 -5
  148. package/templates/nextjs/package.json +2 -1
  149. package/templates/nextjs/template.json +1 -2
  150. package/templates/react/README.md +9 -14
  151. package/templates/react/index.html +1 -1
  152. package/templates/react/package.json +1 -1
  153. package/templates/react/src/assets/favicon.ico +0 -0
  154. package/templates/react/src/components/providers/query-provider.tsx +38 -0
  155. package/templates/react/src/{shared/components → components}/seo.tsx +4 -8
  156. package/templates/react/src/lib/axios/http.ts +24 -0
  157. package/templates/react/src/main.tsx +8 -11
  158. package/templates/react/src/{features/about/pages → pages}/about.tsx +1 -1
  159. package/templates/react/src/{features/home/pages → pages}/home.tsx +1 -1
  160. package/templates/react/src/router.tsx +6 -6
  161. package/templates/react/src/vite-env.d.ts +2 -1
  162. package/templates/react/template.json +0 -1
  163. package/templates/react/tsconfig.app.json +6 -0
  164. package/templates/react/tsconfig.json +7 -1
  165. package/templates/react/vite.config.ts +12 -0
  166. package/modules/auth/authjs/files/nextjs/api/auth/[...nextauth]/route.ts +0 -3
  167. package/modules/auth/authjs/files/nextjs/proxy.ts +0 -1
  168. package/modules/auth/authjs/files/shared/lib/auth.ts +0 -119
  169. package/modules/auth/authjs/files/shared/prisma/schema.prisma +0 -61
  170. package/modules/auth/authjs/generator.json +0 -64
  171. package/modules/auth/authjs/module.json +0 -13
  172. package/modules/auth/better-auth/files/express/modules/auth/auth.controller.ts +0 -264
  173. package/modules/auth/better-auth/files/express/modules/auth/auth.service.ts +0 -537
  174. package/modules/auth/better-auth/files/express/templates/google-redirect.ejs +0 -24
  175. package/modules/auth/better-auth/files/nextjs/api/auth/[...all]/route.ts +0 -4
  176. package/modules/auth/better-auth/files/nextjs/lib/auth/auth-guards.ts +0 -41
  177. package/modules/auth/better-auth/files/nextjs/templates/email-otp.tsx +0 -74
  178. package/templates/express/node_modules/.bin/acorn +0 -17
  179. package/templates/express/node_modules/.bin/eslint +0 -17
  180. package/templates/express/node_modules/.bin/tsc +0 -17
  181. package/templates/express/node_modules/.bin/tsserver +0 -17
  182. package/templates/express/node_modules/.bin/tsx +0 -17
  183. package/templates/nextjs/lib/api/http.ts +0 -40
  184. package/templates/nextjs/next-env.d.ts +0 -6
  185. package/templates/react/dist/assets/index-D4AHT4dU.js +0 -193
  186. package/templates/react/dist/assets/index-rpwj5ZOX.css +0 -1
  187. package/templates/react/dist/index.html +0 -14
  188. package/templates/react/dist/vite.svg +0 -1
  189. package/templates/react/public/vite.svg +0 -1
  190. package/templates/react/src/app/layouts/dashboard-layout.tsx +0 -8
  191. package/templates/react/src/app/layouts/public-layout.tsx +0 -5
  192. package/templates/react/src/app/providers.tsx +0 -20
  193. package/templates/react/src/app/router.tsx +0 -21
  194. package/templates/react/src/assets/react.svg +0 -1
  195. package/templates/react/src/shared/api/http.ts +0 -39
  196. package/templates/react/src/shared/components/loading.tsx +0 -8
  197. package/templates/react/src/shared/lib/query-client.ts +0 -12
  198. package/templates/react/src/utils/storage.ts +0 -35
  199. package/templates/react/src/utils/utils.ts +0 -3
  200. /package/templates/nextjs/app/{page.tsx → (public)/(root)/page.tsx} +0 -0
  201. /package/templates/react/src/{shared/components → components}/error-boundary.tsx +0 -0
  202. /package/templates/react/src/{shared/components → components}/layout.tsx +0 -0
  203. /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;