opacacms 0.1.1 → 0.1.2

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 (212) hide show
  1. package/package.json +36 -1
  2. package/bun.lock +0 -34
  3. package/global.d.ts +0 -11
  4. package/src/admin/api-client.ts +0 -63
  5. package/src/admin/auth-client.ts +0 -40
  6. package/src/admin/custom-field.ts +0 -179
  7. package/src/admin/index.ts +0 -15
  8. package/src/admin/react.tsx +0 -72
  9. package/src/admin/router.ts +0 -9
  10. package/src/admin/stores/admin-queries.ts +0 -121
  11. package/src/admin/stores/auth.ts +0 -61
  12. package/src/admin/stores/column-visibility.ts +0 -67
  13. package/src/admin/stores/config.ts +0 -15
  14. package/src/admin/stores/media.ts +0 -95
  15. package/src/admin/stores/query.ts +0 -13
  16. package/src/admin/stores/ui.ts +0 -29
  17. package/src/admin/ui/admin-client.tsx +0 -283
  18. package/src/admin/ui/admin-layout.tsx +0 -276
  19. package/src/admin/ui/components/ColumnVisibilityToggle.tsx +0 -141
  20. package/src/admin/ui/components/DataDetailSheet.tsx +0 -141
  21. package/src/admin/ui/components/DataDetailView.tsx +0 -175
  22. package/src/admin/ui/components/Table.tsx +0 -67
  23. package/src/admin/ui/components/fields/ArrayField.tsx +0 -166
  24. package/src/admin/ui/components/fields/BlocksField.tsx +0 -202
  25. package/src/admin/ui/components/fields/BooleanField.tsx +0 -50
  26. package/src/admin/ui/components/fields/CollapsibleField.tsx +0 -75
  27. package/src/admin/ui/components/fields/DateField.tsx +0 -45
  28. package/src/admin/ui/components/fields/FileField.tsx +0 -322
  29. package/src/admin/ui/components/fields/GroupField.tsx +0 -50
  30. package/src/admin/ui/components/fields/JoinField.tsx +0 -23
  31. package/src/admin/ui/components/fields/NumberField.tsx +0 -46
  32. package/src/admin/ui/components/fields/RadioField.tsx +0 -62
  33. package/src/admin/ui/components/fields/RelationshipField.tsx +0 -278
  34. package/src/admin/ui/components/fields/RowField.tsx +0 -40
  35. package/src/admin/ui/components/fields/SelectField.tsx +0 -59
  36. package/src/admin/ui/components/fields/TabsField.tsx +0 -101
  37. package/src/admin/ui/components/fields/TextAreaField.tsx +0 -54
  38. package/src/admin/ui/components/fields/TextField.tsx +0 -49
  39. package/src/admin/ui/components/fields/VirtualField.tsx +0 -53
  40. package/src/admin/ui/components/fields/index.tsx +0 -371
  41. package/src/admin/ui/components/fields/richtext-editor/index.tsx +0 -211
  42. package/src/admin/ui/components/fields/richtext-editor/nodes/ImageComponent.tsx +0 -142
  43. package/src/admin/ui/components/fields/richtext-editor/nodes/ImageNode.tsx +0 -95
  44. package/src/admin/ui/components/fields/richtext-editor/plugins/ComponentPickerPlugin.tsx +0 -226
  45. package/src/admin/ui/components/fields/richtext-editor/plugins/EditableSyncPlugin.tsx +0 -16
  46. package/src/admin/ui/components/fields/richtext-editor/plugins/NotionToolbarPlugin.tsx +0 -184
  47. package/src/admin/ui/components/fields/richtext-editor/plugins/SimpleToolbarPlugin.tsx +0 -240
  48. package/src/admin/ui/components/fields/richtext-editor/plugins/ValueSyncPlugin.tsx +0 -40
  49. package/src/admin/ui/components/fields/utils.ts +0 -1
  50. package/src/admin/ui/components/link.tsx +0 -41
  51. package/src/admin/ui/components/media/AssetManagerModal.tsx +0 -334
  52. package/src/admin/ui/components/toast.tsx +0 -72
  53. package/src/admin/ui/components/ui/accordion.tsx +0 -51
  54. package/src/admin/ui/components/ui/alert-dialog.tsx +0 -98
  55. package/src/admin/ui/components/ui/blocks.tsx +0 -32
  56. package/src/admin/ui/components/ui/breadcrumbs.tsx +0 -59
  57. package/src/admin/ui/components/ui/button.tsx +0 -26
  58. package/src/admin/ui/components/ui/collapsible.tsx +0 -124
  59. package/src/admin/ui/components/ui/dialog.tsx +0 -79
  60. package/src/admin/ui/components/ui/group.tsx +0 -20
  61. package/src/admin/ui/components/ui/index.ts +0 -17
  62. package/src/admin/ui/components/ui/input.tsx +0 -12
  63. package/src/admin/ui/components/ui/join.tsx +0 -53
  64. package/src/admin/ui/components/ui/label.tsx +0 -11
  65. package/src/admin/ui/components/ui/radio-group.tsx +0 -75
  66. package/src/admin/ui/components/ui/relationship-detail-sheet.tsx +0 -122
  67. package/src/admin/ui/components/ui/relationship.tsx +0 -58
  68. package/src/admin/ui/components/ui/scroll-area.tsx +0 -19
  69. package/src/admin/ui/components/ui/select.tsx +0 -187
  70. package/src/admin/ui/components/ui/separator.tsx +0 -21
  71. package/src/admin/ui/components/ui/sheet.tsx +0 -106
  72. package/src/admin/ui/components/ui/tabs.tsx +0 -116
  73. package/src/admin/ui/components/ui/utils.ts +0 -3
  74. package/src/admin/ui/hooks/use-debounce.ts +0 -15
  75. package/src/admin/ui/styles/_locale-switcher.scss +0 -33
  76. package/src/admin/ui/styles/accordion.scss +0 -60
  77. package/src/admin/ui/styles/animations.scss +0 -41
  78. package/src/admin/ui/styles/asset-manager.scss +0 -547
  79. package/src/admin/ui/styles/badge.scss +0 -13
  80. package/src/admin/ui/styles/base.scss +0 -22
  81. package/src/admin/ui/styles/button.scss +0 -161
  82. package/src/admin/ui/styles/card.scss +0 -13
  83. package/src/admin/ui/styles/collapsible.scss +0 -75
  84. package/src/admin/ui/styles/data-detail.scss +0 -92
  85. package/src/admin/ui/styles/dialog.scss +0 -102
  86. package/src/admin/ui/styles/empty-state.scss +0 -22
  87. package/src/admin/ui/styles/group.scss +0 -19
  88. package/src/admin/ui/styles/index.scss +0 -33
  89. package/src/admin/ui/styles/input.scss +0 -80
  90. package/src/admin/ui/styles/label.scss +0 -12
  91. package/src/admin/ui/styles/layout.scss +0 -56
  92. package/src/admin/ui/styles/lexical.scss +0 -469
  93. package/src/admin/ui/styles/loading.scss +0 -102
  94. package/src/admin/ui/styles/media-registry.scss +0 -597
  95. package/src/admin/ui/styles/pagination.scss +0 -20
  96. package/src/admin/ui/styles/radio-group.scss +0 -66
  97. package/src/admin/ui/styles/row.scss +0 -17
  98. package/src/admin/ui/styles/scrollbar.scss +0 -36
  99. package/src/admin/ui/styles/select.scss +0 -121
  100. package/src/admin/ui/styles/separator.scss +0 -14
  101. package/src/admin/ui/styles/sheet.scss +0 -152
  102. package/src/admin/ui/styles/sidebar.scss +0 -148
  103. package/src/admin/ui/styles/switch.scss +0 -59
  104. package/src/admin/ui/styles/table.scss +0 -207
  105. package/src/admin/ui/styles/tabs.scss +0 -62
  106. package/src/admin/ui/styles/toast.scss +0 -45
  107. package/src/admin/ui/styles/variables.scss +0 -24
  108. package/src/admin/ui/views/collection-list-view.tsx +0 -720
  109. package/src/admin/ui/views/dashboard-view.tsx +0 -263
  110. package/src/admin/ui/views/document-edit-view.tsx +0 -384
  111. package/src/admin/ui/views/global-edit-view.tsx +0 -226
  112. package/src/admin/ui/views/init-view.tsx +0 -182
  113. package/src/admin/ui/views/login-view.tsx +0 -123
  114. package/src/admin/ui/views/media-registry-view.tsx +0 -1104
  115. package/src/admin/ui/views/settings-view.tsx +0 -729
  116. package/src/admin/webcomponent.tsx +0 -15
  117. package/src/auth/index.ts +0 -194
  118. package/src/auth/migrations.ts +0 -87
  119. package/src/auth/premissions.ts +0 -46
  120. package/src/cli/commands/generate-types.ts +0 -116
  121. package/src/cli/commands/init.ts +0 -95
  122. package/src/cli/commands/migrate-commands.ts +0 -160
  123. package/src/cli/commands/seed-command.ts +0 -11
  124. package/src/cli/d1-mock.ts +0 -101
  125. package/src/cli/index.test.ts +0 -84
  126. package/src/cli/index.ts +0 -183
  127. package/src/cli/r2-mock.ts +0 -217
  128. package/src/cli/seeding.ts +0 -409
  129. package/src/client.ts +0 -181
  130. package/src/config-utils.ts +0 -102
  131. package/src/config.ts +0 -49
  132. package/src/db/adapter.ts +0 -53
  133. package/src/db/better-sqlite.ts +0 -630
  134. package/src/db/bun-sqlite.ts +0 -646
  135. package/src/db/d1.ts +0 -711
  136. package/src/db/index.ts +0 -2
  137. package/src/db/kysely/data-mapper.ts +0 -142
  138. package/src/db/kysely/field-mapper.ts +0 -148
  139. package/src/db/kysely/migration-generator.ts +0 -223
  140. package/src/db/kysely/query-builder.ts +0 -92
  141. package/src/db/kysely/schema-builder.ts +0 -439
  142. package/src/db/kysely/sql-utils.ts +0 -13
  143. package/src/db/migration.ts +0 -40
  144. package/src/db/postgres.ts +0 -621
  145. package/src/db/sqlite.ts +0 -658
  146. package/src/db/system-schema.ts +0 -121
  147. package/src/index.ts +0 -11
  148. package/src/runtimes/README.md +0 -59
  149. package/src/runtimes/bun.ts +0 -49
  150. package/src/runtimes/cloudflare-workers.ts +0 -38
  151. package/src/runtimes/next.ts +0 -26
  152. package/src/runtimes/node.ts +0 -52
  153. package/src/schema/collection.ts +0 -184
  154. package/src/schema/fields/base.ts +0 -164
  155. package/src/schema/fields/index.ts +0 -427
  156. package/src/schema/global.ts +0 -145
  157. package/src/schema/index.ts +0 -4
  158. package/src/schema/infer.ts +0 -72
  159. package/src/server/admin-router.ts +0 -20
  160. package/src/server/admin.ts +0 -142
  161. package/src/server/assets.ts +0 -306
  162. package/src/server/collection-router.ts +0 -55
  163. package/src/server/handlers.ts +0 -722
  164. package/src/server/middlewares/admin.ts +0 -27
  165. package/src/server/middlewares/auth.ts +0 -89
  166. package/src/server/middlewares/context.ts +0 -17
  167. package/src/server/middlewares/cors.ts +0 -24
  168. package/src/server/middlewares/database-init.ts +0 -74
  169. package/src/server/middlewares/rate-limit.ts +0 -71
  170. package/src/server/router.ts +0 -47
  171. package/src/server/setup-middlewares.ts +0 -58
  172. package/src/server/system-router.ts +0 -35
  173. package/src/server.ts +0 -9
  174. package/src/storage/adapters/cloudflare-r2.ts +0 -136
  175. package/src/storage/adapters/local.ts +0 -146
  176. package/src/storage/adapters/s3.ts +0 -186
  177. package/src/storage/errors.ts +0 -46
  178. package/src/storage/index.ts +0 -6
  179. package/src/storage/types.ts +0 -39
  180. package/src/types.ts +0 -605
  181. package/src/utils/lexical.ts +0 -37
  182. package/src/utils/logger.ts +0 -73
  183. package/src/validation.ts +0 -429
  184. package/src/validator.ts +0 -179
  185. package/test/admin-custom-field.test.ts +0 -162
  186. package/test/admin-react-field.test.tsx +0 -134
  187. package/test/api-features.test.ts +0 -78
  188. package/test/api.test.ts +0 -178
  189. package/test/auth.test.ts +0 -62
  190. package/test/cli-integration.test.ts +0 -148
  191. package/test/cli.test.ts +0 -25
  192. package/test/db/postgres.test.ts +0 -95
  193. package/test/db/sqlite-filter.test.ts +0 -53
  194. package/test/db/sqlite.test.ts +0 -82
  195. package/test/engine-features.test.ts +0 -79
  196. package/test/globals.test.ts +0 -74
  197. package/test/integration-tmp/db-app/opacacms.config.ts +0 -15
  198. package/test/integration-tmp/my-sqlite-app/opacacms.config.ts +0 -25
  199. package/test/integration-tmp/my-test-app/index.ts +0 -8
  200. package/test/integration-tmp/my-test-app/opacacms.config.ts +0 -16
  201. package/test/integration-tmp/my-test-app/package.json +0 -12
  202. package/test/populate.test.ts +0 -79
  203. package/test/runtimes.test.ts +0 -43
  204. package/test/schema-builder.test.ts +0 -107
  205. package/test/schema-features.test.ts +0 -63
  206. package/test/seeding.test.ts +0 -68
  207. package/test/storage/local.test.ts +0 -72
  208. package/test/storage/s3.test.ts +0 -60
  209. package/test/structural-data.test.ts +0 -100
  210. package/test/test-setup.ts +0 -11
  211. package/test/validation.test.ts +0 -162
  212. package/tsconfig.json +0 -42
@@ -1,1104 +0,0 @@
1
- import { useStore } from "@nanostores/react";
2
- import {
3
- Check,
4
- ChevronLeft,
5
- ChevronRight,
6
- Download,
7
- Eye,
8
- File,
9
- FileText,
10
- FolderPlus,
11
- Grid,
12
- Image as ImageIcon,
13
- LayoutList,
14
- Loader2,
15
- Plus,
16
- Save,
17
- Search,
18
- Settings2,
19
- Trash2,
20
- X,
21
- } from "lucide-react";
22
- import { useMemo, useRef, useState } from "react";
23
- import { createPortal } from "react-dom";
24
- import type { SerializableCollection, SerializableConfig } from "../../../types";
25
- import { api } from "../../api-client";
26
- import {
27
- $assets,
28
- $bucketColors,
29
- $mediaCurrentFolder,
30
- $mediaPage,
31
- $mediaSearch,
32
- $mediaSelectedBucket,
33
- $mediaViewMode,
34
- type AssetDoc,
35
- setBucketColor,
36
- setMediaBucket,
37
- setMediaFolder,
38
- setMediaPage,
39
- setMediaSearch,
40
- } from "../../stores/media";
41
- import { notify } from "../../stores/ui";
42
- import {
43
- Button,
44
- Dialog,
45
- DialogContent,
46
- DialogDescription,
47
- DialogFooter,
48
- DialogHeader,
49
- DialogTitle,
50
- Input,
51
- Label,
52
- Select,
53
- SelectContent,
54
- SelectItem,
55
- SelectLabel,
56
- SelectSeparator,
57
- SelectTrigger,
58
- SelectValue,
59
- Sheet,
60
- SheetContent,
61
- SheetDescription,
62
- SheetFooter,
63
- SheetHeader,
64
- SheetTitle,
65
- } from "../components/ui";
66
- import { AlertDialog } from "../components/ui/alert-dialog";
67
- import "../styles/media-registry.scss";
68
-
69
- const formatMediaDate = (date: string | number | Date | undefined | null) => {
70
- if (!date) return "-";
71
- const d = new Date(date);
72
- return Number.isNaN(d.getTime()) ? "-" : d.toLocaleDateString();
73
- };
74
-
75
- export interface MediaRegistryViewProps {
76
- collection: SerializableCollection;
77
- config: SerializableConfig;
78
- }
79
-
80
- // Utility for deterministic bucket colors
81
- const PRESET_COLORS = [
82
- "#7c3aed", // Purple
83
- "#047857", // Emerald
84
- "#b91c1c", // Red
85
- "#0369a1", // Sky
86
- "#a21caf", // Fuchsia
87
- "#c2410c", // Orange
88
- "#eab308", // Yellow
89
- "#10b981", // Green
90
- "#ec4899", // Pink
91
- "#6366f1", // Indigo
92
- ];
93
-
94
- const getBucketColor = (bucket: string, customColors: Record<string, string>) => {
95
- if (customColors && customColors[bucket]) return customColors[bucket];
96
-
97
- let hash = 0;
98
- for (let i = 0; i < bucket.length; i++) {
99
- hash = bucket.charCodeAt(i) + ((hash << 5) - hash);
100
- }
101
- return PRESET_COLORS[Math.abs(hash) % PRESET_COLORS.length];
102
- };
103
-
104
- export function MediaRegistryView({ collection, config }: MediaRegistryViewProps) {
105
- const { data, loading } = useStore($assets);
106
- const viewMode = useStore($mediaViewMode);
107
- const selectedBucket = useStore($mediaSelectedBucket);
108
- const currentFolder = useStore($mediaCurrentFolder);
109
- const search = useStore($mediaSearch);
110
- const page = useStore($mediaPage);
111
- const customColors = useStore($bucketColors);
112
-
113
- const [isUploading, setIsUploading] = useState(false);
114
- const [selectedAsset, setSelectedAsset] = useState<AssetDoc | null>(null);
115
- const [isPreviewOpen, setIsPreviewOpen] = useState(false);
116
- const [newFolderName, setNewFolderName] = useState("");
117
- const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
118
- const [isBucketSettingsOpen, setIsBucketSettingsOpen] = useState(false);
119
-
120
- const fileInputRef = useRef<HTMLInputElement>(null);
121
-
122
- const buckets = useMemo(() => {
123
- const storageKeys = Object.keys(config.storages || {});
124
- return [...storageKeys];
125
- }, [config.storages]);
126
-
127
- const fetchData = () => {
128
- // Now handled by $assets store automatically when dependencies change
129
- // but we can call it manually to refresh if needed (e.g. after upload/delete)
130
- // Actually, $assets is a fetcher store, so it has .revalidate()
131
- $assets.revalidate();
132
- };
133
-
134
- const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
135
- const file = e.target.files?.[0];
136
- if (!file) return;
137
-
138
- setIsUploading(true);
139
- const formData = new FormData();
140
- formData.append("file", file);
141
-
142
- try {
143
- const bucket = selectedBucket === "all" ? "default" : selectedBucket;
144
- await api.post(`api/__system/assets/upload?bucket=${bucket}&folder=${currentFolder}`, {
145
- body: formData,
146
- });
147
- notify("File uploaded successfully", "success");
148
- fetchData();
149
- } catch (err) {
150
- console.error("Upload failed", err);
151
- notify("Upload failed", "error");
152
- } finally {
153
- setIsUploading(false);
154
- if (fileInputRef.current) fileInputRef.current.value = "";
155
- }
156
- };
157
-
158
- const handleCreateFolder = async () => {
159
- if (!newFolderName) return;
160
- try {
161
- const bucket = selectedBucket === "all" ? "default" : selectedBucket;
162
- // We upload a .keep file to "create" the folder in typical object storage
163
- const blob = new Blob([""], { type: "text/plain" });
164
- const formData = new FormData();
165
- const folderPath = currentFolder ? `${currentFolder}/${newFolderName}` : newFolderName;
166
- formData.append("file", blob, ".keep");
167
-
168
- await api.post(`api/__system/assets/upload?bucket=${bucket}&folder=${folderPath}`, {
169
- body: formData,
170
- });
171
-
172
- notify("Folder created", "success");
173
- setNewFolderName("");
174
- setIsCreateFolderOpen(false);
175
- fetchData();
176
- } catch (err) {
177
- console.error("Failed to create folder", err);
178
- notify("Failed to create folder", "error");
179
- }
180
- };
181
-
182
- const handleUpdateMetadata = async () => {
183
- if (!selectedAsset) return;
184
- try {
185
- await api.patch(`api/${collection.slug}/${selectedAsset.id}`, {
186
- json: {
187
- filename: selectedAsset.filename,
188
- altText: selectedAsset.altText || selectedAsset.alt_text,
189
- caption: selectedAsset.caption,
190
- },
191
- });
192
- notify("Metadata updated", "success");
193
- fetchData();
194
- } catch (err) {
195
- console.error("Failed to update metadata", err);
196
- notify("Failed to update metadata", "error");
197
- }
198
- };
199
-
200
- const handleDelete = async (id: string) => {
201
- try {
202
- await api.delete(`api/${collection.slug}/${id}`);
203
- notify("Asset deleted", "success");
204
- if (selectedAsset?.id === id) setSelectedAsset(null);
205
- fetchData();
206
- } catch (err) {
207
- console.error("Delete failed", err);
208
- notify("Failed to delete asset", "error");
209
- }
210
- };
211
-
212
- const docs = data?.docs || [];
213
- const folders = data?.folders || [];
214
-
215
- const formatSize = (bytes: number) => {
216
- if (bytes === 0) return "0 Bytes";
217
- const k = 1024;
218
- const sizes = ["Bytes", "KB", "MB", "GB"];
219
- const i = Math.floor(Math.log(bytes) / Math.log(k));
220
- return parseFloat((bytes / k ** i).toFixed(1)) + " " + sizes[i];
221
- };
222
-
223
- const getFileIcon = (mime: string) => {
224
- if (!mime) return <File size={viewMode === "grid" ? 48 : 20} className="text-gray-400" />;
225
- if (mime.startsWith("image/"))
226
- return <ImageIcon size={viewMode === "grid" ? 48 : 20} className="text-blue-400" />;
227
- if (mime.startsWith("video/"))
228
- return <FileText size={viewMode === "grid" ? 48 : 20} className="text-purple-400" />;
229
- if (mime.includes("pdf"))
230
- return <FileText size={viewMode === "grid" ? 48 : 20} className="text-red-400" />;
231
- return <File size={viewMode === "grid" ? 48 : 20} className="text-gray-400" />;
232
- };
233
-
234
- const filteredDocs = docs.filter((doc) => {
235
- const originalFilename = doc.originalFilename || doc.original_filename;
236
- return (
237
- originalFilename?.toLowerCase().includes(search.toLowerCase()) ||
238
- doc.filename.toLowerCase().includes(search.toLowerCase())
239
- );
240
- });
241
-
242
- const filteredFolders = folders.filter((f) =>
243
- f.name.toLowerCase().includes(search.toLowerCase()),
244
- );
245
-
246
- return (
247
- <div className={`opaca-view-container media-registry-view ${loading ? "loading" : ""}`}>
248
- <input type="file" ref={fileInputRef} style={{ display: "none" }} onChange={handleUpload} />
249
- <div className="opaca-header media-registry-header">
250
- <div>
251
- <h1 className="opaca-title">Media Library</h1>
252
- <p className="opaca-subtitle">Manage your global asset registry across all buckets.</p>
253
- </div>
254
- <div className="media-registry-header-actions">
255
- <Button
256
- variant="outline"
257
- onClick={() => setIsCreateFolderOpen(true)}
258
- disabled={selectedBucket === "all"}
259
- title={selectedBucket === "all" ? "Select a specific bucket to create folders" : ""}
260
- >
261
- <FolderPlus size={16} className="media-registry-icon-mr" />
262
- New Folder
263
- </Button>
264
- <Button
265
- variant="default"
266
- disabled={isUploading || selectedBucket === "all"}
267
- onClick={() => fileInputRef.current?.click()}
268
- title={selectedBucket === "all" ? "Select a specific bucket to upload files" : ""}
269
- >
270
- {isUploading ? (
271
- <Loader2 size={16} className="opaca-spin media-registry-icon-mr" />
272
- ) : (
273
- <Plus size={16} className="media-registry-icon-mr" />
274
- )}
275
- {isUploading ? "Uploading..." : "Upload New"}
276
- </Button>
277
- </div>
278
- </div>
279
-
280
- <div
281
- style={{
282
- display: "flex",
283
- flex: 1,
284
- minHeight: 0,
285
- gap: "1.5rem",
286
- marginBottom: "1.5rem",
287
- }}
288
- >
289
- <div
290
- style={{
291
- flex: 1,
292
- display: "flex",
293
- flexDirection: "column",
294
- minWidth: 0,
295
- }}
296
- >
297
- {/* Breadcrumbs */}
298
- <div
299
- style={{
300
- display: "flex",
301
- alignItems: "center",
302
- gap: "0.5rem",
303
- marginBottom: "1rem",
304
- color: "var(--opaca-text-dim)",
305
- fontSize: "0.875rem",
306
- }}
307
- >
308
- <button
309
- type="button"
310
- onClick={() => setMediaFolder("")}
311
- style={{
312
- background: "none",
313
- border: "none",
314
- color: currentFolder === "" ? "var(--opaca-text)" : "inherit",
315
- cursor: "pointer",
316
- fontWeight: currentFolder === "" ? 600 : 400,
317
- }}
318
- >
319
- Home
320
- </button>
321
- {currentFolder
322
- .split("/")
323
- .filter(Boolean)
324
- .map((part, i, arr) => (
325
- <div
326
- key={part}
327
- style={{
328
- display: "flex",
329
- alignItems: "center",
330
- gap: "0.5rem",
331
- }}
332
- >
333
- <ChevronRight size={14} />
334
- <button
335
- type="button"
336
- onClick={() => setMediaFolder(arr.slice(0, i + 1).join("/"))}
337
- style={{
338
- background: "none",
339
- border: "none",
340
- color: i === arr.length - 1 ? "var(--opaca-text)" : "inherit",
341
- cursor: "pointer",
342
- fontWeight: i === arr.length - 1 ? 600 : 400,
343
- }}
344
- >
345
- {part}
346
- </button>
347
- </div>
348
- ))}
349
- </div>
350
-
351
- <div className="opaca-card media-registry-body">
352
- {/* Toolbar */}
353
- <div className="media-registry-toolbar">
354
- <div className="media-registry-filters">
355
- <div className="media-registry-search">
356
- <Search size={16} className="search-icon" />
357
- <Input
358
- type="text"
359
- placeholder="Search assets..."
360
- value={search}
361
- onChange={(e) => setMediaSearch(e.target.value)}
362
- />
363
- </div>
364
-
365
- <Select value={selectedBucket} onValueChange={(val: string) => setMediaBucket(val)}>
366
- <SelectTrigger className="media-registry-select-trigger">
367
- <div
368
- style={{
369
- display: "flex",
370
- alignItems: "center",
371
- gap: "8px",
372
- }}
373
- >
374
- {selectedBucket !== "all" && (
375
- <div
376
- style={{
377
- width: "8px",
378
- height: "8px",
379
- borderRadius: "50%",
380
- backgroundColor: getBucketColor(selectedBucket, customColors),
381
- }}
382
- />
383
- )}
384
- <SelectValue placeholder="All Buckets" />
385
- </div>
386
- </SelectTrigger>
387
- <SelectContent>
388
- <SelectItem value="all">
389
- <div
390
- style={{
391
- display: "flex",
392
- alignItems: "center",
393
- gap: "8px",
394
- }}
395
- >
396
- <div
397
- style={{
398
- width: "8px",
399
- height: "8px",
400
- borderRadius: "50%",
401
- border: "1px dashed var(--opaca-text-dim)",
402
- }}
403
- />
404
- All Buckets
405
- </div>
406
- </SelectItem>
407
- <SelectSeparator />
408
- <SelectLabel>Storage Buckets</SelectLabel>
409
- {buckets.map((b) => (
410
- <SelectItem key={b} value={b.toLowerCase()}>
411
- <div
412
- style={{
413
- display: "flex",
414
- alignItems: "center",
415
- gap: "8px",
416
- }}
417
- >
418
- <div
419
- style={{
420
- width: "8px",
421
- height: "8px",
422
- borderRadius: "50%",
423
- backgroundColor: getBucketColor(b, customColors),
424
- }}
425
- />
426
- {b.toUpperCase()}
427
- </div>
428
- </SelectItem>
429
- ))}
430
- </SelectContent>
431
- </Select>
432
- </div>
433
-
434
- <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
435
- <Button
436
- variant="ghost"
437
- size="icon"
438
- onClick={() => setIsBucketSettingsOpen(true)}
439
- title="Bucket Settings"
440
- >
441
- <Settings2 size={16} />
442
- </Button>
443
-
444
- <div className="media-registry-view-toggles">
445
- <button
446
- type="button"
447
- onClick={() => $mediaViewMode.set("grid")}
448
- className={`media-registry-toggle-btn ${viewMode === "grid" ? "active" : ""}`}
449
- >
450
- <Grid size={16} />
451
- </button>
452
- <button
453
- type="button"
454
- onClick={() => $mediaViewMode.set("list")}
455
- className={`media-registry-toggle-btn ${viewMode === "list" ? "active" : ""}`}
456
- >
457
- <LayoutList size={16} />
458
- </button>
459
- </div>
460
- </div>
461
- </div>
462
-
463
- {loading && (
464
- <div
465
- style={{
466
- display: "flex",
467
- justifyContent: "center",
468
- padding: "4rem",
469
- }}
470
- >
471
- <Loader2
472
- size={32}
473
- className="opaca-spin"
474
- style={{ color: "var(--opaca-accent)" }}
475
- />
476
- </div>
477
- )}
478
-
479
- {!loading && filteredDocs.length === 0 && (
480
- <div
481
- style={{
482
- display: "flex",
483
- flexDirection: "column",
484
- alignItems: "center",
485
- justifyContent: "center",
486
- textAlign: "center",
487
- padding: "4rem",
488
- background: "rgba(255,255,255,0.01)",
489
- borderRadius: "12px",
490
- border: "1px dashed var(--opaca-border)",
491
- }}
492
- >
493
- <ImageIcon
494
- size={48}
495
- style={{
496
- color: "var(--opaca-text-dim)",
497
- marginBottom: "1rem",
498
- opacity: 0.3,
499
- }}
500
- />
501
- <h3 style={{ fontSize: "1.25rem", fontWeight: 600 }}>No assets found</h3>
502
- <p style={{ color: "var(--opaca-text-muted)" }}>
503
- Upload some files to get started.
504
- </p>
505
- </div>
506
- )}
507
-
508
- {/* Grid View */}
509
- {!loading &&
510
- viewMode === "grid" &&
511
- (filteredFolders.length > 0 || filteredDocs.length > 0) && (
512
- <div className="media-registry-grid">
513
- {/* Folders first */}
514
- {filteredFolders.map((folder) => (
515
- <div
516
- key={`folder-${folder.name}`}
517
- className="media-registry-card"
518
- onClick={() =>
519
- setMediaFolder(
520
- currentFolder ? `${currentFolder}/${folder.name}` : folder.name,
521
- )
522
- }
523
- >
524
- <div className="media-registry-card-thumb" style={{ position: "relative" }}>
525
- <FolderPlus size={54} className="folder-icon" />
526
- {selectedBucket === "all" && (
527
- <div
528
- style={{
529
- position: "absolute",
530
- bottom: "8px",
531
- right: "8px",
532
- display: "flex",
533
- gap: "2px",
534
- }}
535
- >
536
- {folder.buckets.map((b) => (
537
- <div
538
- key={b}
539
- title={b}
540
- style={{
541
- width: "8px",
542
- height: "8px",
543
- borderRadius: "50%",
544
- backgroundColor: getBucketColor(b, customColors),
545
- }}
546
- />
547
- ))}
548
- </div>
549
- )}
550
- </div>
551
- <div className="media-registry-card-body">
552
- <div className="media-registry-card-title">{folder.name}</div>
553
- <div className="media-registry-card-meta">Folder</div>
554
- </div>
555
- </div>
556
- ))}
557
-
558
- {/* Assets */}
559
- {filteredDocs.map((doc) => (
560
- <div
561
- key={doc.id}
562
- className={`media-registry-card ${selectedAsset?.id === doc.id ? "active" : ""}`}
563
- onClick={() => setSelectedAsset(doc)}
564
- >
565
- <div className="media-registry-card-thumb" style={{ position: "relative" }}>
566
- {(() => {
567
- const mime = doc.mimeType || doc.mime_type;
568
- return mime?.startsWith("image/") ? (
569
- <img
570
- src={`${config.serverURL || ""}/api/assets/${doc.id}/view`}
571
- alt={doc.originalFilename || doc.original_filename || ""}
572
- onError={(e) => (e.currentTarget.style.display = "none")}
573
- />
574
- ) : (
575
- getFileIcon(mime || "")
576
- );
577
- })()}
578
-
579
- {selectedBucket === "all" && (
580
- <div
581
- title={`Bucket: ${doc.bucket}`}
582
- style={{
583
- position: "absolute",
584
- top: "8px",
585
- left: "8px",
586
- width: "12px",
587
- height: "12px",
588
- borderRadius: "50%",
589
- backgroundColor: getBucketColor(doc.bucket, customColors),
590
- border: "2px solid rgba(0,0,0,0.5)",
591
- zIndex: 2,
592
- }}
593
- />
594
- )}
595
-
596
- <div className="media-registry-overlay">
597
- <button
598
- className="media-registry-overlay-btn"
599
- onClick={(e) => {
600
- e.stopPropagation();
601
- setSelectedAsset(doc);
602
- setIsPreviewOpen(true);
603
- }}
604
- >
605
- <Eye size={20} />
606
- </button>
607
- </div>
608
- </div>
609
- <div className="media-registry-card-body">
610
- <div className="media-registry-card-title" title={doc.original_filename}>
611
- {doc.original_filename}
612
- </div>
613
- <div className="media-registry-card-meta">
614
- <span className="meta-type">
615
- {(doc.mimeType || doc.mime_type)?.split("/")[1] || "FILE"}
616
- </span>
617
- <span>{formatSize(doc.filesize)}</span>
618
- </div>
619
- </div>
620
- </div>
621
- ))}
622
- </div>
623
- )}
624
-
625
- {/* List View */}
626
- {!loading &&
627
- viewMode === "list" &&
628
- (filteredFolders.length > 0 || filteredDocs.length > 0) && (
629
- <div className="opaca-table-container">
630
- <table className="opaca-table">
631
- <thead>
632
- <tr>
633
- <th style={{ width: "40px" }}></th>
634
- <th>Name</th>
635
- <th>Type</th>
636
- <th>Size</th>
637
- <th>Date</th>
638
- <th style={{ width: "40px" }}></th>
639
- </tr>
640
- </thead>
641
- <tbody>
642
- {/* Folders first */}
643
- {filteredFolders.map((folder) => (
644
- <tr
645
- key={`folder-${folder.name}`}
646
- onClick={() =>
647
- setMediaFolder(
648
- currentFolder ? `${currentFolder}/${folder.name}` : folder.name,
649
- )
650
- }
651
- style={{ cursor: "pointer" }}
652
- >
653
- <td>
654
- <div
655
- style={{
656
- position: "relative",
657
- display: "inline-block",
658
- }}
659
- >
660
- <FolderPlus size={20} style={{ color: "#eab308" }} />
661
- {selectedBucket === "all" && (
662
- <div
663
- style={{
664
- position: "absolute",
665
- bottom: "-2px",
666
- right: "-2px",
667
- display: "flex",
668
- gap: "1px",
669
- }}
670
- >
671
- {folder.buckets.map((b) => (
672
- <div
673
- key={b}
674
- style={{
675
- width: "6px",
676
- height: "6px",
677
- borderRadius: "50%",
678
- backgroundColor: getBucketColor(b, customColors),
679
- }}
680
- />
681
- ))}
682
- </div>
683
- )}
684
- </div>
685
- </td>
686
- <td style={{ fontWeight: 600 }}>{folder.name}</td>
687
- <td>Folder</td>
688
- <td>-</td>
689
- <td>-</td>
690
- <td></td>
691
- </tr>
692
- ))}
693
-
694
- {filteredDocs.map((doc) => (
695
- <tr
696
- key={doc.id}
697
- onClick={() => setSelectedAsset(doc)}
698
- style={{
699
- cursor: "pointer",
700
- background:
701
- selectedAsset?.id === doc.id
702
- ? "rgba(var(--opaca-accent-rgb), 0.1)"
703
- : "inherit",
704
- }}
705
- >
706
- <td>
707
- <div
708
- style={{
709
- width: "32px",
710
- height: "32px",
711
- borderRadius: "4px",
712
- background: "var(--opaca-bg-alt)",
713
- display: "flex",
714
- alignItems: "center",
715
- justifyContent: "center",
716
- overflow: "hidden",
717
- }}
718
- >
719
- {(() => {
720
- const mime = doc.mimeType || doc.mime_type || "";
721
- return mime.startsWith("image/") ? (
722
- <img
723
- src={`${config.serverURL || ""}/api/assets/${doc.id}/view`}
724
- style={{
725
- width: "100%",
726
- height: "100%",
727
- objectFit: "cover",
728
- }}
729
- alt=""
730
- />
731
- ) : (
732
- getFileIcon(mime)
733
- );
734
- })()}
735
- </div>
736
- </td>
737
- <td>
738
- <div style={{ fontWeight: 500 }}>{doc.original_filename}</div>
739
- <div
740
- style={{
741
- fontSize: "0.75rem",
742
- color: "var(--opaca-text-dim)",
743
- }}
744
- >
745
- {doc.filename}
746
- </div>
747
- </td>
748
- <td style={{ fontSize: "0.875rem" }}>
749
- {doc.mimeType || doc.mime_type || ""}
750
- </td>
751
- <td style={{ fontSize: "0.875rem" }}>{formatSize(doc.filesize)}</td>
752
- <td
753
- style={{
754
- fontSize: "0.75rem",
755
- color: "var(--opaca-text-muted)",
756
- }}
757
- >
758
- {formatMediaDate(doc.createdAt || doc.created_at)}
759
- </td>
760
- <td>
761
- <button
762
- onClick={(e) => {
763
- e.stopPropagation();
764
- setSelectedAsset(doc);
765
- setIsPreviewOpen(true);
766
- }}
767
- style={{
768
- background: "none",
769
- border: "none",
770
- color: "var(--opaca-text-dim)",
771
- cursor: "pointer",
772
- }}
773
- >
774
- <Eye size={18} />
775
- </button>
776
- </td>
777
- </tr>
778
- ))}
779
- </tbody>
780
- </table>
781
- </div>
782
- )}
783
-
784
- {/* Pagination */}
785
- {data && data.totalPages > 1 && (
786
- <div className="media-registry-pagination">
787
- <div className="info">
788
- Showing {(data.page - 1) * data.limit + 1} to{" "}
789
- {Math.min(data.page * data.limit, data.totalDocs)} of {data.totalDocs}
790
- </div>
791
- <div className="actions">
792
- <Button
793
- variant="outline"
794
- size="sm"
795
- disabled={data.page === 1 || loading}
796
- onClick={() => setMediaPage(page - 1)}
797
- >
798
- <ChevronLeft size={16} />
799
- </Button>
800
- <Button
801
- variant="outline"
802
- size="sm"
803
- disabled={data.page === data.totalPages || loading}
804
- onClick={() => setMediaPage(page + 1)}
805
- >
806
- <ChevronRight size={16} />
807
- </Button>
808
- </div>
809
- </div>
810
- )}
811
- </div>
812
- </div>
813
- </div>
814
-
815
- {/* Asset Details Sheet */}
816
- <Sheet
817
- open={!!selectedAsset && !isPreviewOpen}
818
- onOpenChange={(open) => !open && setSelectedAsset(null)}
819
- >
820
- <SheetContent onClose={() => setSelectedAsset(null)}>
821
- <SheetHeader>
822
- <SheetTitle>Asset Details</SheetTitle>
823
- <SheetDescription>View and manage asset metadata.</SheetDescription>
824
- </SheetHeader>
825
-
826
- {selectedAsset && (
827
- <div className="media-sheet-flex">
828
- <div className="media-sheet-body opaca-scrollbar-hidden">
829
- <div className="media-sheet-preview">
830
- {(() => {
831
- const mime = selectedAsset.mimeType || selectedAsset.mime_type || "";
832
- return mime.startsWith("image/") ? (
833
- <img
834
- src={`${config.serverURL || ""}/api/assets/${selectedAsset.id}/view`}
835
- alt={selectedAsset.filename}
836
- />
837
- ) : (
838
- getFileIcon(mime)
839
- );
840
- })()}
841
- <div className="overlay">
842
- <Button variant="secondary" onClick={() => setIsPreviewOpen(true)}>
843
- <Eye size={16} className="media-registry-icon-mr" />
844
- Preview Full
845
- </Button>
846
- </div>
847
- </div>
848
-
849
- <div className="media-sheet-form">
850
- <div className="media-sheet-group">
851
- <Label htmlFor="filename">Display Name</Label>
852
- <Input
853
- id="filename"
854
- value={selectedAsset.filename}
855
- onChange={(e) =>
856
- setSelectedAsset({
857
- ...selectedAsset,
858
- filename: e.target.value,
859
- })
860
- }
861
- />
862
- </div>
863
- <div className="media-sheet-group">
864
- <Label htmlFor="alt_text">Alt Text</Label>
865
- <Input
866
- id="alt_text"
867
- value={selectedAsset.alt_text || ""}
868
- onChange={(e) =>
869
- setSelectedAsset({
870
- ...selectedAsset,
871
- alt_text: e.target.value,
872
- })
873
- }
874
- placeholder="SEO friendly description"
875
- />
876
- </div>
877
- <div className="media-sheet-group">
878
- <Label htmlFor="caption">Caption</Label>
879
- <textarea
880
- id="caption"
881
- value={selectedAsset.caption || ""}
882
- onChange={(e) =>
883
- setSelectedAsset({
884
- ...selectedAsset,
885
- caption: e.target.value,
886
- })
887
- }
888
- className="media-sheet-textarea"
889
- placeholder="Write a short caption..."
890
- />
891
- </div>
892
-
893
- <div className="media-sheet-meta-box">
894
- <div className="media-sheet-meta-row">
895
- <span className="label">File Size</span>
896
- <span className="value">{formatSize(selectedAsset.filesize)}</span>
897
- </div>
898
- <div className="media-sheet-meta-row">
899
- <span className="label">Internal Key</span>
900
- <span className="value value-mono" title={selectedAsset.key}>
901
- {selectedAsset.key}
902
- </span>
903
- </div>
904
- <div className="media-sheet-meta-row">
905
- <span className="label">Bucket</span>
906
- <span className="value">{selectedAsset.bucket}</span>
907
- </div>
908
- </div>
909
- </div>
910
- </div>
911
- </div>
912
- )}
913
-
914
- {selectedAsset && (
915
- <SheetFooter className="media-sheet-actions">
916
- <AlertDialog
917
- title="Delete Asset"
918
- description="Are you sure you want to delete this asset? This action cannot be undone and may break links in your content."
919
- onConfirm={() => handleDelete(selectedAsset.id)}
920
- variant="destructive"
921
- confirmText="Delete Asset"
922
- >
923
- <Button variant="destructive">
924
- <Trash2 size={16} className="media-registry-icon-mr" />
925
- Delete
926
- </Button>
927
- </AlertDialog>
928
- <Button variant="default" onClick={handleUpdateMetadata}>
929
- <Save size={16} className="media-registry-icon-mr" />
930
- Save Changes
931
- </Button>
932
- </SheetFooter>
933
- )}
934
- </SheetContent>
935
- </Sheet>
936
-
937
- {/* CREATE FOLDER DIALOG */}
938
- <Dialog open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
939
- <DialogContent className="opaca-dialog-max-w">
940
- <DialogHeader>
941
- <DialogTitle>Create New Folder</DialogTitle>
942
- <DialogDescription>Organize your assets into folders.</DialogDescription>
943
- </DialogHeader>
944
- <div className="media-dialog-create-body">
945
- <Label htmlFor="newFolderName">Folder Name</Label>
946
- <Input
947
- id="newFolderName"
948
- placeholder="e.g. products"
949
- value={newFolderName}
950
- onChange={(e) => setNewFolderName(e.target.value)}
951
- style={{ marginTop: "0.5rem" }}
952
- onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
953
- />
954
- </div>
955
- <DialogFooter>
956
- <Button variant="outline" onClick={() => setIsCreateFolderOpen(false)}>
957
- Cancel
958
- </Button>
959
- <Button onClick={handleCreateFolder}>Create</Button>
960
- </DialogFooter>
961
- </DialogContent>
962
- </Dialog>
963
-
964
- {/* ASSET PREVIEW FULL SCREEN PORTAL */}
965
- {isPreviewOpen &&
966
- selectedAsset &&
967
- typeof document !== "undefined" &&
968
- createPortal(
969
- <div className="opaca-ui-dialog-portal">
970
- <div
971
- className="opaca-ui-dialog-overlay"
972
- onClick={() => {
973
- setIsPreviewOpen(false);
974
- setSelectedAsset(null);
975
- }}
976
- />
977
- <div className="opaca-ui-dialog-wrapper">
978
- <div className="media-preview-container">
979
- <div className="media-preview-header">
980
- <div className="media-preview-title-group">
981
- <div
982
- style={{
983
- display: "flex",
984
- alignItems: "center",
985
- gap: "0.75rem",
986
- }}
987
- >
988
- {selectedBucket === "all" && (
989
- <div
990
- title={`Bucket: ${selectedAsset.bucket}`}
991
- style={{
992
- width: "12px",
993
- height: "12px",
994
- borderRadius: "50%",
995
- backgroundColor: getBucketColor(selectedAsset.bucket, customColors),
996
- }}
997
- />
998
- )}
999
- <h2>{selectedAsset.original_filename}</h2>
1000
- </div>
1001
- <span className="badge">
1002
- {selectedAsset.mimeType || selectedAsset.mime_type || ""}
1003
- </span>
1004
- </div>
1005
- <button
1006
- className="media-preview-close-btn"
1007
- onClick={() => {
1008
- setIsPreviewOpen(false);
1009
- setSelectedAsset(null);
1010
- }}
1011
- >
1012
- <X size={24} />
1013
- </button>
1014
- </div>
1015
-
1016
- <div className="media-preview-body">
1017
- {(() => {
1018
- const mime = selectedAsset.mimeType || selectedAsset.mime_type || "";
1019
- return mime.startsWith("image/") ? (
1020
- <img
1021
- src={`${config.serverURL || ""}/api/assets/${selectedAsset.id}/view`}
1022
- alt={
1023
- selectedAsset.altText ||
1024
- selectedAsset.alt_text ||
1025
- selectedAsset.originalFilename ||
1026
- selectedAsset.original_filename ||
1027
- ""
1028
- }
1029
- />
1030
- ) : (
1031
- <div className="media-preview-no-rich">
1032
- <div className="icon-wrapper">{getFileIcon(mime)}</div>
1033
- <p>No rich preview available for this file type.</p>
1034
- <Button
1035
- variant="default"
1036
- onClick={() =>
1037
- (window.location.href = `${config.serverURL || ""}/api/assets/${selectedAsset.id}/view`)
1038
- }
1039
- >
1040
- <Download size={16} className="media-registry-icon-mr" />
1041
- Download Original
1042
- </Button>
1043
- </div>
1044
- );
1045
- })()}
1046
- </div>
1047
-
1048
- <div className="media-preview-footer">
1049
- <div className="media-preview-stat">
1050
- <div className="label">Size</div>
1051
- <div className="value">{formatSize(selectedAsset.filesize)}</div>
1052
- </div>
1053
- <div className="media-preview-stat">
1054
- <div className="label">Created</div>
1055
- <div className="value">
1056
- {formatMediaDate(selectedAsset.createdAt || selectedAsset.created_at)}
1057
- </div>
1058
- </div>
1059
- <div className="media-preview-stat">
1060
- <div className="label">Bucket</div>
1061
- <div className="value">{selectedAsset.bucket}</div>
1062
- </div>
1063
- </div>
1064
- </div>
1065
- </div>
1066
- </div>,
1067
- document.body,
1068
- )}
1069
- {/* Bucket Color Settings Dialog */}
1070
- <Dialog open={isBucketSettingsOpen} onOpenChange={setIsBucketSettingsOpen}>
1071
- <DialogContent className="media-registry-dialog-sm">
1072
- <DialogHeader>
1073
- <DialogTitle>Bucket Colors</DialogTitle>
1074
- </DialogHeader>
1075
- <div className="media-bucket-settings">
1076
- {buckets.map((bucketName) => (
1077
- <div key={bucketName} className="bucket-setting-row">
1078
- <span className="bucket-name">{bucketName}</span>
1079
- <div className="color-presets">
1080
- {PRESET_COLORS.map((color) => {
1081
- const isActive = getBucketColor(bucketName, customColors) === color;
1082
- return (
1083
- <button
1084
- key={color}
1085
- className={`color-bubble ${isActive ? "active" : ""}`}
1086
- style={{ backgroundColor: color }}
1087
- onClick={() => setBucketColor(bucketName, color)}
1088
- >
1089
- {isActive && <Check size={12} color="white" />}
1090
- </button>
1091
- );
1092
- })}
1093
- </div>
1094
- </div>
1095
- ))}
1096
- </div>
1097
- <DialogFooter>
1098
- <Button onClick={() => setIsBucketSettingsOpen(false)}>Close</Button>
1099
- </DialogFooter>
1100
- </DialogContent>
1101
- </Dialog>
1102
- </div>
1103
- );
1104
- }