opacacms 0.1.12 → 0.1.13

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 (217) hide show
  1. package/dist/admin/index.js +0 -4
  2. package/dist/admin/webcomponent.d.ts +0 -1
  3. package/dist/admin/webcomponent.js +0 -4
  4. package/dist/admin.css +1 -0
  5. package/package.json +8 -2
  6. package/bun.lock +0 -34
  7. package/dist/admin/index.css +0 -47
  8. package/dist/admin/webcomponent.css +0 -47
  9. package/global.d.ts +0 -11
  10. package/src/admin/api-client.ts +0 -63
  11. package/src/admin/auth-client.ts +0 -40
  12. package/src/admin/custom-field.ts +0 -179
  13. package/src/admin/index.ts +0 -15
  14. package/src/admin/react.tsx +0 -72
  15. package/src/admin/router.ts +0 -9
  16. package/src/admin/stores/admin-queries.ts +0 -121
  17. package/src/admin/stores/auth.ts +0 -61
  18. package/src/admin/stores/column-visibility.ts +0 -67
  19. package/src/admin/stores/config.ts +0 -15
  20. package/src/admin/stores/media.ts +0 -95
  21. package/src/admin/stores/query.ts +0 -13
  22. package/src/admin/stores/ui.ts +0 -29
  23. package/src/admin/ui/admin-client.tsx +0 -283
  24. package/src/admin/ui/admin-layout.tsx +0 -276
  25. package/src/admin/ui/components/ColumnVisibilityToggle.tsx +0 -141
  26. package/src/admin/ui/components/DataDetailSheet.tsx +0 -141
  27. package/src/admin/ui/components/DataDetailView.tsx +0 -175
  28. package/src/admin/ui/components/Table.tsx +0 -67
  29. package/src/admin/ui/components/fields/ArrayField.tsx +0 -166
  30. package/src/admin/ui/components/fields/BlocksField.tsx +0 -202
  31. package/src/admin/ui/components/fields/BooleanField.tsx +0 -50
  32. package/src/admin/ui/components/fields/CollapsibleField.tsx +0 -75
  33. package/src/admin/ui/components/fields/DateField.tsx +0 -45
  34. package/src/admin/ui/components/fields/FileField.tsx +0 -322
  35. package/src/admin/ui/components/fields/GroupField.tsx +0 -50
  36. package/src/admin/ui/components/fields/JoinField.tsx +0 -23
  37. package/src/admin/ui/components/fields/NumberField.tsx +0 -46
  38. package/src/admin/ui/components/fields/RadioField.tsx +0 -62
  39. package/src/admin/ui/components/fields/RelationshipField.tsx +0 -278
  40. package/src/admin/ui/components/fields/RowField.tsx +0 -40
  41. package/src/admin/ui/components/fields/SelectField.tsx +0 -59
  42. package/src/admin/ui/components/fields/TabsField.tsx +0 -101
  43. package/src/admin/ui/components/fields/TextAreaField.tsx +0 -54
  44. package/src/admin/ui/components/fields/TextField.tsx +0 -49
  45. package/src/admin/ui/components/fields/VirtualField.tsx +0 -53
  46. package/src/admin/ui/components/fields/index.tsx +0 -371
  47. package/src/admin/ui/components/fields/richtext-editor/index.tsx +0 -211
  48. package/src/admin/ui/components/fields/richtext-editor/nodes/ImageComponent.tsx +0 -142
  49. package/src/admin/ui/components/fields/richtext-editor/nodes/ImageNode.tsx +0 -95
  50. package/src/admin/ui/components/fields/richtext-editor/plugins/ComponentPickerPlugin.tsx +0 -226
  51. package/src/admin/ui/components/fields/richtext-editor/plugins/EditableSyncPlugin.tsx +0 -16
  52. package/src/admin/ui/components/fields/richtext-editor/plugins/NotionToolbarPlugin.tsx +0 -184
  53. package/src/admin/ui/components/fields/richtext-editor/plugins/SimpleToolbarPlugin.tsx +0 -240
  54. package/src/admin/ui/components/fields/richtext-editor/plugins/ValueSyncPlugin.tsx +0 -40
  55. package/src/admin/ui/components/fields/utils.ts +0 -1
  56. package/src/admin/ui/components/link.tsx +0 -41
  57. package/src/admin/ui/components/media/AssetManagerModal.tsx +0 -334
  58. package/src/admin/ui/components/toast.tsx +0 -72
  59. package/src/admin/ui/components/ui/accordion.tsx +0 -51
  60. package/src/admin/ui/components/ui/alert-dialog.tsx +0 -98
  61. package/src/admin/ui/components/ui/blocks.tsx +0 -32
  62. package/src/admin/ui/components/ui/breadcrumbs.tsx +0 -59
  63. package/src/admin/ui/components/ui/button.tsx +0 -26
  64. package/src/admin/ui/components/ui/collapsible.tsx +0 -124
  65. package/src/admin/ui/components/ui/dialog.tsx +0 -79
  66. package/src/admin/ui/components/ui/group.tsx +0 -20
  67. package/src/admin/ui/components/ui/index.ts +0 -17
  68. package/src/admin/ui/components/ui/input.tsx +0 -12
  69. package/src/admin/ui/components/ui/join.tsx +0 -53
  70. package/src/admin/ui/components/ui/label.tsx +0 -11
  71. package/src/admin/ui/components/ui/radio-group.tsx +0 -75
  72. package/src/admin/ui/components/ui/relationship-detail-sheet.tsx +0 -122
  73. package/src/admin/ui/components/ui/relationship.tsx +0 -58
  74. package/src/admin/ui/components/ui/scroll-area.tsx +0 -19
  75. package/src/admin/ui/components/ui/select.tsx +0 -187
  76. package/src/admin/ui/components/ui/separator.tsx +0 -21
  77. package/src/admin/ui/components/ui/sheet.tsx +0 -106
  78. package/src/admin/ui/components/ui/tabs.tsx +0 -116
  79. package/src/admin/ui/components/ui/utils.ts +0 -3
  80. package/src/admin/ui/hooks/use-debounce.ts +0 -15
  81. package/src/admin/ui/styles/_locale-switcher.scss +0 -33
  82. package/src/admin/ui/styles/accordion.scss +0 -60
  83. package/src/admin/ui/styles/animations.scss +0 -41
  84. package/src/admin/ui/styles/asset-manager.scss +0 -547
  85. package/src/admin/ui/styles/badge.scss +0 -13
  86. package/src/admin/ui/styles/base.scss +0 -22
  87. package/src/admin/ui/styles/button.scss +0 -161
  88. package/src/admin/ui/styles/card.scss +0 -13
  89. package/src/admin/ui/styles/collapsible.scss +0 -75
  90. package/src/admin/ui/styles/data-detail.scss +0 -92
  91. package/src/admin/ui/styles/dialog.scss +0 -102
  92. package/src/admin/ui/styles/empty-state.scss +0 -22
  93. package/src/admin/ui/styles/group.scss +0 -19
  94. package/src/admin/ui/styles/index.scss +0 -33
  95. package/src/admin/ui/styles/input.scss +0 -80
  96. package/src/admin/ui/styles/label.scss +0 -12
  97. package/src/admin/ui/styles/layout.scss +0 -56
  98. package/src/admin/ui/styles/lexical.scss +0 -469
  99. package/src/admin/ui/styles/loading.scss +0 -102
  100. package/src/admin/ui/styles/media-registry.scss +0 -597
  101. package/src/admin/ui/styles/pagination.scss +0 -20
  102. package/src/admin/ui/styles/radio-group.scss +0 -66
  103. package/src/admin/ui/styles/row.scss +0 -17
  104. package/src/admin/ui/styles/scrollbar.scss +0 -36
  105. package/src/admin/ui/styles/select.scss +0 -121
  106. package/src/admin/ui/styles/separator.scss +0 -14
  107. package/src/admin/ui/styles/sheet.scss +0 -152
  108. package/src/admin/ui/styles/sidebar.scss +0 -148
  109. package/src/admin/ui/styles/switch.scss +0 -59
  110. package/src/admin/ui/styles/table.scss +0 -207
  111. package/src/admin/ui/styles/tabs.scss +0 -62
  112. package/src/admin/ui/styles/toast.scss +0 -45
  113. package/src/admin/ui/styles/variables.scss +0 -24
  114. package/src/admin/ui/views/collection-list-view.tsx +0 -720
  115. package/src/admin/ui/views/dashboard-view.tsx +0 -263
  116. package/src/admin/ui/views/document-edit-view.tsx +0 -384
  117. package/src/admin/ui/views/global-edit-view.tsx +0 -226
  118. package/src/admin/ui/views/init-view.tsx +0 -182
  119. package/src/admin/ui/views/login-view.tsx +0 -123
  120. package/src/admin/ui/views/media-registry-view.tsx +0 -1104
  121. package/src/admin/ui/views/settings-view.tsx +0 -729
  122. package/src/admin/webcomponent.tsx +0 -15
  123. package/src/auth/index.ts +0 -194
  124. package/src/auth/migrations.ts +0 -87
  125. package/src/auth/premissions.ts +0 -46
  126. package/src/cli/commands/generate-types.ts +0 -116
  127. package/src/cli/commands/init.ts +0 -95
  128. package/src/cli/commands/migrate-commands.ts +0 -160
  129. package/src/cli/commands/seed-command.ts +0 -11
  130. package/src/cli/d1-mock.ts +0 -101
  131. package/src/cli/index.test.ts +0 -84
  132. package/src/cli/index.ts +0 -183
  133. package/src/cli/r2-mock.ts +0 -217
  134. package/src/cli/seeding.ts +0 -409
  135. package/src/client.ts +0 -181
  136. package/src/config-utils.ts +0 -102
  137. package/src/config.ts +0 -49
  138. package/src/db/adapter.ts +0 -53
  139. package/src/db/better-sqlite.ts +0 -657
  140. package/src/db/bun-sqlite.ts +0 -666
  141. package/src/db/d1.ts +0 -721
  142. package/src/db/index.ts +0 -10
  143. package/src/db/kysely/data-mapper.ts +0 -142
  144. package/src/db/kysely/field-mapper.ts +0 -149
  145. package/src/db/kysely/migration-generator.ts +0 -223
  146. package/src/db/kysely/query-builder.ts +0 -92
  147. package/src/db/kysely/schema-builder.ts +0 -439
  148. package/src/db/kysely/sql-utils.ts +0 -13
  149. package/src/db/postgres.ts +0 -631
  150. package/src/db/sqlite.ts +0 -670
  151. package/src/db/system-schema.ts +0 -121
  152. package/src/index.ts +0 -13
  153. package/src/runtimes/README.md +0 -59
  154. package/src/runtimes/bun.ts +0 -49
  155. package/src/runtimes/cloudflare-workers.ts +0 -38
  156. package/src/runtimes/next.ts +0 -26
  157. package/src/runtimes/node.ts +0 -52
  158. package/src/schema/collection.ts +0 -184
  159. package/src/schema/fields/base.ts +0 -164
  160. package/src/schema/fields/index.ts +0 -427
  161. package/src/schema/global.ts +0 -145
  162. package/src/schema/index.ts +0 -4
  163. package/src/schema/infer.ts +0 -72
  164. package/src/server/admin-router.ts +0 -20
  165. package/src/server/admin.ts +0 -142
  166. package/src/server/assets.ts +0 -306
  167. package/src/server/collection-router.ts +0 -55
  168. package/src/server/handlers.ts +0 -722
  169. package/src/server/middlewares/admin.ts +0 -27
  170. package/src/server/middlewares/auth.ts +0 -89
  171. package/src/server/middlewares/context.ts +0 -17
  172. package/src/server/middlewares/cors.ts +0 -24
  173. package/src/server/middlewares/database-init.ts +0 -74
  174. package/src/server/middlewares/rate-limit.ts +0 -77
  175. package/src/server/router.ts +0 -47
  176. package/src/server/setup-middlewares.ts +0 -58
  177. package/src/server/system-router.ts +0 -35
  178. package/src/server.ts +0 -9
  179. package/src/storage/adapters/cloudflare-r2.ts +0 -136
  180. package/src/storage/adapters/local.ts +0 -146
  181. package/src/storage/adapters/s3.ts +0 -186
  182. package/src/storage/errors.ts +0 -46
  183. package/src/storage/index.ts +0 -5
  184. package/src/storage/types.ts +0 -39
  185. package/src/types.ts +0 -577
  186. package/src/utils/lexical.ts +0 -37
  187. package/src/utils/logger.ts +0 -73
  188. package/src/validation.ts +0 -429
  189. package/src/validator.ts +0 -179
  190. package/test/admin-custom-field.test.ts +0 -162
  191. package/test/admin-react-field.test.tsx +0 -134
  192. package/test/api-features.test.ts +0 -78
  193. package/test/api.test.ts +0 -178
  194. package/test/auth.test.ts +0 -62
  195. package/test/cli-integration.test.ts +0 -148
  196. package/test/cli.test.ts +0 -25
  197. package/test/db/postgres.test.ts +0 -95
  198. package/test/db/sqlite-filter.test.ts +0 -53
  199. package/test/db/sqlite.test.ts +0 -82
  200. package/test/engine-features.test.ts +0 -79
  201. package/test/globals.test.ts +0 -74
  202. package/test/integration-tmp/db-app/opacacms.config.ts +0 -15
  203. package/test/integration-tmp/my-sqlite-app/opacacms.config.ts +0 -25
  204. package/test/integration-tmp/my-test-app/index.ts +0 -8
  205. package/test/integration-tmp/my-test-app/opacacms.config.ts +0 -16
  206. package/test/integration-tmp/my-test-app/package.json +0 -12
  207. package/test/populate.test.ts +0 -79
  208. package/test/runtimes.test.ts +0 -43
  209. package/test/schema-builder.test.ts +0 -107
  210. package/test/schema-features.test.ts +0 -63
  211. package/test/seeding.test.ts +0 -68
  212. package/test/storage/local.test.ts +0 -72
  213. package/test/storage/s3.test.ts +0 -60
  214. package/test/structural-data.test.ts +0 -100
  215. package/test/test-setup.ts +0 -11
  216. package/test/validation.test.ts +0 -162
  217. 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
- }