opacacms 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (399) hide show
  1. package/bun.lock +34 -0
  2. package/dist/admin/api-client.d.ts +8 -0
  3. package/dist/admin/auth-client.d.ts +940 -0
  4. package/dist/admin/custom-field.d.ts +71 -0
  5. package/dist/admin/index.d.ts +11 -0
  6. package/dist/admin/react.d.ts +3 -0
  7. package/dist/admin/router.d.ts +7 -0
  8. package/dist/admin/stores/admin-queries.d.ts +32 -0
  9. package/dist/admin/stores/auth.d.ts +33 -0
  10. package/dist/admin/stores/column-visibility.d.ts +21 -0
  11. package/dist/admin/stores/config.d.ts +7 -0
  12. package/dist/admin/stores/media.d.ts +44 -0
  13. package/dist/admin/stores/query.d.ts +4 -0
  14. package/dist/admin/stores/ui.d.ts +11 -0
  15. package/dist/admin/ui/admin-client.d.ts +7 -0
  16. package/dist/admin/ui/admin-layout.d.ts +14 -0
  17. package/dist/admin/ui/components/ColumnVisibilityToggle.d.ts +10 -0
  18. package/dist/admin/ui/components/DataDetailSheet.d.ts +13 -0
  19. package/dist/admin/ui/components/DataDetailView.d.ts +9 -0
  20. package/dist/admin/ui/components/Table.d.ts +10 -0
  21. package/dist/admin/ui/components/fields/ArrayField.d.ts +13 -0
  22. package/dist/admin/ui/components/fields/BlocksField.d.ts +17 -0
  23. package/dist/admin/ui/components/fields/BooleanField.d.ts +13 -0
  24. package/dist/admin/ui/components/fields/CollapsibleField.d.ts +16 -0
  25. package/dist/admin/ui/components/fields/DateField.d.ts +13 -0
  26. package/dist/admin/ui/components/fields/FileField.d.ts +23 -0
  27. package/dist/admin/ui/components/fields/GroupField.d.ts +13 -0
  28. package/dist/admin/ui/components/fields/JoinField.d.ts +15 -0
  29. package/dist/admin/ui/components/fields/NumberField.d.ts +14 -0
  30. package/dist/admin/ui/components/fields/RadioField.d.ts +17 -0
  31. package/dist/admin/ui/components/fields/RelationshipField.d.ts +16 -0
  32. package/dist/admin/ui/components/fields/RowField.d.ts +12 -0
  33. package/dist/admin/ui/components/fields/SelectField.d.ts +18 -0
  34. package/dist/admin/ui/components/fields/TabsField.d.ts +15 -0
  35. package/dist/admin/ui/components/fields/TextAreaField.d.ts +14 -0
  36. package/dist/admin/ui/components/fields/TextField.d.ts +14 -0
  37. package/dist/admin/ui/components/fields/VirtualField.d.ts +8 -0
  38. package/dist/admin/ui/components/fields/index.d.ts +28 -0
  39. package/dist/admin/ui/components/fields/richtext-editor/index.d.ts +10 -0
  40. package/dist/admin/ui/components/fields/richtext-editor/nodes/ImageComponent.d.ts +7 -0
  41. package/dist/admin/ui/components/fields/richtext-editor/nodes/ImageNode.d.ts +27 -0
  42. package/dist/admin/ui/components/fields/richtext-editor/plugins/ComponentPickerPlugin.d.ts +1 -0
  43. package/dist/admin/ui/components/fields/richtext-editor/plugins/EditableSyncPlugin.d.ts +5 -0
  44. package/dist/admin/ui/components/fields/richtext-editor/plugins/NotionToolbarPlugin.d.ts +1 -0
  45. package/dist/admin/ui/components/fields/richtext-editor/plugins/SimpleToolbarPlugin.d.ts +1 -0
  46. package/dist/admin/ui/components/fields/richtext-editor/plugins/ValueSyncPlugin.d.ts +5 -0
  47. package/dist/admin/ui/components/fields/utils.d.ts +1 -0
  48. package/dist/admin/ui/components/link.d.ts +8 -0
  49. package/dist/admin/ui/components/media/AssetManagerModal.d.ts +17 -0
  50. package/dist/admin/ui/components/toast.d.ts +10 -0
  51. package/dist/admin/ui/components/ui/accordion.d.ts +11 -0
  52. package/dist/admin/ui/components/ui/alert-dialog.d.ts +12 -0
  53. package/dist/admin/ui/components/ui/blocks.d.ts +5 -0
  54. package/dist/admin/ui/components/ui/breadcrumbs.d.ts +7 -0
  55. package/dist/admin/ui/components/ui/button.d.ts +7 -0
  56. package/dist/admin/ui/components/ui/collapsible.d.ts +16 -0
  57. package/dist/admin/ui/components/ui/dialog.d.ts +27 -0
  58. package/dist/admin/ui/components/ui/group.d.ts +6 -0
  59. package/dist/admin/ui/components/ui/index.d.ts +17 -0
  60. package/dist/admin/ui/components/ui/input.d.ts +5 -0
  61. package/dist/admin/ui/components/ui/join.d.ts +7 -0
  62. package/dist/admin/ui/components/ui/label.d.ts +3 -0
  63. package/dist/admin/ui/components/ui/radio-group.d.ts +13 -0
  64. package/dist/admin/ui/components/ui/relationship-detail-sheet.d.ts +9 -0
  65. package/dist/admin/ui/components/ui/relationship.d.ts +8 -0
  66. package/dist/admin/ui/components/ui/scroll-area.d.ts +7 -0
  67. package/dist/admin/ui/components/ui/select.d.ts +37 -0
  68. package/dist/admin/ui/components/ui/separator.d.ts +8 -0
  69. package/dist/admin/ui/components/ui/sheet.d.ts +28 -0
  70. package/dist/admin/ui/components/ui/tabs.d.ts +17 -0
  71. package/dist/admin/ui/components/ui/utils.d.ts +1 -0
  72. package/dist/admin/ui/hooks/use-debounce.d.ts +1 -0
  73. package/dist/admin/ui/views/collection-list-view.d.ts +5 -0
  74. package/dist/admin/ui/views/dashboard-view.d.ts +10 -0
  75. package/dist/admin/ui/views/document-edit-view.d.ts +7 -0
  76. package/dist/admin/ui/views/global-edit-view.d.ts +19 -0
  77. package/dist/admin/ui/views/init-view.d.ts +4 -0
  78. package/dist/admin/ui/views/login-view.d.ts +4 -0
  79. package/dist/admin/ui/views/media-registry-view.d.ts +7 -0
  80. package/dist/admin/ui/views/settings-view.d.ts +7 -0
  81. package/dist/admin/webcomponent.d.ts +1 -0
  82. package/dist/api.d.ts +6 -0
  83. package/dist/auth/index.d.ts +2107 -0
  84. package/dist/auth/migrations.d.ts +5 -0
  85. package/dist/auth/premissions.d.ts +6 -0
  86. package/dist/chunk-16vgcf3k.js +88 -0
  87. package/dist/chunk-2zm8cy1w.js +9482 -0
  88. package/dist/chunk-5gvbp2qa.js +167 -0
  89. package/dist/chunk-62ev8gnc.js +41 -0
  90. package/dist/chunk-6dhs73zq.js +126 -0
  91. package/dist/chunk-6ew02s0c.js +472 -0
  92. package/dist/chunk-7a9kn0np.js +116 -0
  93. package/dist/chunk-8gkhn1d4.js +309 -0
  94. package/dist/chunk-8sqjbsgt.js +42 -0
  95. package/dist/chunk-9kxpbcb1.js +85 -0
  96. package/dist/chunk-cvdd4eqh.js +110 -0
  97. package/dist/chunk-d3ffeqp9.js +87 -0
  98. package/dist/chunk-dy5t83hr.js +261 -0
  99. package/dist/chunk-f3nvxn63.js +17 -0
  100. package/dist/chunk-hmhcense.js +1352 -0
  101. package/dist/chunk-j4d50hrx.js +20 -0
  102. package/dist/chunk-jwjk85ze.js +15 -0
  103. package/dist/chunk-kwp83w8b.js +250 -0
  104. package/dist/chunk-s8mqwnm1.js +14 -0
  105. package/dist/chunk-srsac177.js +85 -0
  106. package/dist/chunk-v521d72w.js +10 -0
  107. package/dist/chunk-xa7rjsn2.js +20 -0
  108. package/dist/chunk-xg35h5a3.js +15 -0
  109. package/dist/chunk-ybbbqj63.js +130 -0
  110. package/dist/chunk-zvwb67nd.js +332 -0
  111. package/dist/cli/commands/generate-types.d.ts +1 -0
  112. package/dist/cli/commands/init.d.ts +1 -0
  113. package/dist/cli/commands/migrate-commands.d.ts +5 -0
  114. package/dist/cli/commands/seed-command.d.ts +2 -0
  115. package/dist/cli/d1-mock.d.ts +30 -0
  116. package/dist/cli/index.d.ts +5 -0
  117. package/dist/cli/index.test.d.ts +1 -0
  118. package/dist/cli/r2-mock.d.ts +46 -0
  119. package/dist/cli/seeding.d.ts +17 -0
  120. package/dist/client.d.ts +51 -0
  121. package/dist/config-utils.d.ts +6 -0
  122. package/dist/config.d.ts +10 -0
  123. package/dist/db/adapter.d.ts +34 -0
  124. package/dist/db/better-sqlite.d.ts +40 -0
  125. package/dist/db/bun-sqlite.d.ts +40 -0
  126. package/dist/db/d1.d.ts +42 -0
  127. package/dist/db/kysely/data-mapper.d.ts +6 -0
  128. package/dist/db/kysely/field-mapper.d.ts +22 -0
  129. package/dist/db/kysely/migration-generator.d.ts +9 -0
  130. package/dist/db/kysely/query-builder.d.ts +9 -0
  131. package/dist/db/kysely/schema-builder.d.ts +15 -0
  132. package/dist/db/kysely/sql-utils.d.ts +1 -0
  133. package/dist/db/postgres.d.ts +51 -0
  134. package/dist/db/sqlite.d.ts +41 -0
  135. package/dist/db/system-schema.d.ts +2 -0
  136. package/dist/index.d.ts +6 -0
  137. package/dist/runtimes/bun.d.ts +17 -0
  138. package/dist/runtimes/cloudflare-workers.d.ts +10 -0
  139. package/dist/runtimes/next.d.ts +16 -0
  140. package/dist/runtimes/node.d.ts +18 -0
  141. package/dist/schema/collection.d.ts +100 -0
  142. package/dist/schema/fields/base.d.ts +83 -0
  143. package/dist/schema/fields/index.d.ts +135 -0
  144. package/dist/schema/global.d.ts +82 -0
  145. package/dist/schema/index.d.ts +4 -0
  146. package/dist/schema/infer.d.ts +55 -0
  147. package/dist/server/admin-router.d.ts +9 -0
  148. package/dist/server/admin.d.ts +18 -0
  149. package/dist/server/assets.d.ts +47 -0
  150. package/dist/server/collection-router.d.ts +14 -0
  151. package/dist/server/handlers.d.ts +76 -0
  152. package/dist/server/middlewares/admin.d.ts +6 -0
  153. package/dist/server/middlewares/auth.d.ts +16 -0
  154. package/dist/server/middlewares/context.d.ts +9 -0
  155. package/dist/server/middlewares/cors.d.ts +3 -0
  156. package/dist/server/middlewares/database-init.d.ts +11 -0
  157. package/dist/server/middlewares/rate-limit.d.ts +3 -0
  158. package/dist/server/router.d.ts +7 -0
  159. package/dist/server/setup-middlewares.d.ts +17 -0
  160. package/dist/server/system-router.d.ts +9 -0
  161. package/dist/server.d.ts +6 -0
  162. package/dist/src/admin/index.css +47 -0
  163. package/dist/src/admin/index.js +176 -0
  164. package/dist/src/admin/webcomponent.js +19 -0
  165. package/dist/src/api.js +27 -0
  166. package/dist/src/cli/index.js +157 -0
  167. package/dist/src/client.js +9 -0
  168. package/dist/src/db/bun-sqlite.js +523 -0
  169. package/dist/src/db/d1.js +568 -0
  170. package/dist/src/db/postgres.js +520 -0
  171. package/dist/src/db/sqlite.js +534 -0
  172. package/dist/src/index.js +20 -0
  173. package/dist/src/runtimes/bun.js +36 -0
  174. package/dist/src/runtimes/cloudflare-workers.js +29 -0
  175. package/dist/src/runtimes/next.js +26 -0
  176. package/dist/src/runtimes/node.js +38 -0
  177. package/dist/src/server.js +27 -0
  178. package/dist/src/storage/index.js +355 -0
  179. package/dist/storage/adapters/cloudflare-r2.d.ts +6 -0
  180. package/dist/storage/adapters/local.d.ts +6 -0
  181. package/dist/storage/adapters/s3.d.ts +13 -0
  182. package/dist/storage/errors.d.ts +12 -0
  183. package/dist/storage/index.d.ts +5 -0
  184. package/dist/storage/types.d.ts +31 -0
  185. package/dist/types.d.ts +484 -0
  186. package/dist/utils/lexical.d.ts +5 -0
  187. package/dist/utils/logger.d.ts +35 -0
  188. package/dist/validation.d.ts +300 -0
  189. package/dist/validator.d.ts +9 -0
  190. package/global.d.ts +11 -0
  191. package/package.json +151 -0
  192. package/src/admin/api-client.ts +63 -0
  193. package/src/admin/auth-client.ts +40 -0
  194. package/src/admin/custom-field.ts +179 -0
  195. package/src/admin/index.ts +15 -0
  196. package/src/admin/react.tsx +72 -0
  197. package/src/admin/router.ts +9 -0
  198. package/src/admin/stores/admin-queries.ts +121 -0
  199. package/src/admin/stores/auth.ts +61 -0
  200. package/src/admin/stores/column-visibility.ts +67 -0
  201. package/src/admin/stores/config.ts +15 -0
  202. package/src/admin/stores/media.ts +95 -0
  203. package/src/admin/stores/query.ts +13 -0
  204. package/src/admin/stores/ui.ts +29 -0
  205. package/src/admin/ui/admin-client.tsx +283 -0
  206. package/src/admin/ui/admin-layout.tsx +276 -0
  207. package/src/admin/ui/components/ColumnVisibilityToggle.tsx +141 -0
  208. package/src/admin/ui/components/DataDetailSheet.tsx +141 -0
  209. package/src/admin/ui/components/DataDetailView.tsx +175 -0
  210. package/src/admin/ui/components/Table.tsx +67 -0
  211. package/src/admin/ui/components/fields/ArrayField.tsx +166 -0
  212. package/src/admin/ui/components/fields/BlocksField.tsx +202 -0
  213. package/src/admin/ui/components/fields/BooleanField.tsx +50 -0
  214. package/src/admin/ui/components/fields/CollapsibleField.tsx +75 -0
  215. package/src/admin/ui/components/fields/DateField.tsx +45 -0
  216. package/src/admin/ui/components/fields/FileField.tsx +322 -0
  217. package/src/admin/ui/components/fields/GroupField.tsx +50 -0
  218. package/src/admin/ui/components/fields/JoinField.tsx +23 -0
  219. package/src/admin/ui/components/fields/NumberField.tsx +46 -0
  220. package/src/admin/ui/components/fields/RadioField.tsx +62 -0
  221. package/src/admin/ui/components/fields/RelationshipField.tsx +278 -0
  222. package/src/admin/ui/components/fields/RowField.tsx +40 -0
  223. package/src/admin/ui/components/fields/SelectField.tsx +59 -0
  224. package/src/admin/ui/components/fields/TabsField.tsx +101 -0
  225. package/src/admin/ui/components/fields/TextAreaField.tsx +54 -0
  226. package/src/admin/ui/components/fields/TextField.tsx +49 -0
  227. package/src/admin/ui/components/fields/VirtualField.tsx +53 -0
  228. package/src/admin/ui/components/fields/index.tsx +371 -0
  229. package/src/admin/ui/components/fields/richtext-editor/index.tsx +211 -0
  230. package/src/admin/ui/components/fields/richtext-editor/nodes/ImageComponent.tsx +142 -0
  231. package/src/admin/ui/components/fields/richtext-editor/nodes/ImageNode.tsx +95 -0
  232. package/src/admin/ui/components/fields/richtext-editor/plugins/ComponentPickerPlugin.tsx +226 -0
  233. package/src/admin/ui/components/fields/richtext-editor/plugins/EditableSyncPlugin.tsx +16 -0
  234. package/src/admin/ui/components/fields/richtext-editor/plugins/NotionToolbarPlugin.tsx +184 -0
  235. package/src/admin/ui/components/fields/richtext-editor/plugins/SimpleToolbarPlugin.tsx +240 -0
  236. package/src/admin/ui/components/fields/richtext-editor/plugins/ValueSyncPlugin.tsx +40 -0
  237. package/src/admin/ui/components/fields/utils.ts +1 -0
  238. package/src/admin/ui/components/link.tsx +41 -0
  239. package/src/admin/ui/components/media/AssetManagerModal.tsx +334 -0
  240. package/src/admin/ui/components/toast.tsx +72 -0
  241. package/src/admin/ui/components/ui/accordion.tsx +51 -0
  242. package/src/admin/ui/components/ui/alert-dialog.tsx +98 -0
  243. package/src/admin/ui/components/ui/blocks.tsx +32 -0
  244. package/src/admin/ui/components/ui/breadcrumbs.tsx +59 -0
  245. package/src/admin/ui/components/ui/button.tsx +26 -0
  246. package/src/admin/ui/components/ui/collapsible.tsx +124 -0
  247. package/src/admin/ui/components/ui/dialog.tsx +79 -0
  248. package/src/admin/ui/components/ui/group.tsx +20 -0
  249. package/src/admin/ui/components/ui/index.ts +17 -0
  250. package/src/admin/ui/components/ui/input.tsx +12 -0
  251. package/src/admin/ui/components/ui/join.tsx +53 -0
  252. package/src/admin/ui/components/ui/label.tsx +11 -0
  253. package/src/admin/ui/components/ui/radio-group.tsx +75 -0
  254. package/src/admin/ui/components/ui/relationship-detail-sheet.tsx +122 -0
  255. package/src/admin/ui/components/ui/relationship.tsx +58 -0
  256. package/src/admin/ui/components/ui/scroll-area.tsx +19 -0
  257. package/src/admin/ui/components/ui/select.tsx +187 -0
  258. package/src/admin/ui/components/ui/separator.tsx +21 -0
  259. package/src/admin/ui/components/ui/sheet.tsx +106 -0
  260. package/src/admin/ui/components/ui/tabs.tsx +116 -0
  261. package/src/admin/ui/components/ui/utils.ts +3 -0
  262. package/src/admin/ui/hooks/use-debounce.ts +15 -0
  263. package/src/admin/ui/styles/_locale-switcher.scss +33 -0
  264. package/src/admin/ui/styles/accordion.scss +60 -0
  265. package/src/admin/ui/styles/animations.scss +41 -0
  266. package/src/admin/ui/styles/asset-manager.scss +547 -0
  267. package/src/admin/ui/styles/badge.scss +13 -0
  268. package/src/admin/ui/styles/base.scss +22 -0
  269. package/src/admin/ui/styles/button.scss +161 -0
  270. package/src/admin/ui/styles/card.scss +13 -0
  271. package/src/admin/ui/styles/collapsible.scss +75 -0
  272. package/src/admin/ui/styles/data-detail.scss +92 -0
  273. package/src/admin/ui/styles/dialog.scss +102 -0
  274. package/src/admin/ui/styles/empty-state.scss +22 -0
  275. package/src/admin/ui/styles/group.scss +19 -0
  276. package/src/admin/ui/styles/index.scss +33 -0
  277. package/src/admin/ui/styles/input.scss +80 -0
  278. package/src/admin/ui/styles/label.scss +12 -0
  279. package/src/admin/ui/styles/layout.scss +56 -0
  280. package/src/admin/ui/styles/lexical.scss +469 -0
  281. package/src/admin/ui/styles/loading.scss +102 -0
  282. package/src/admin/ui/styles/media-registry.scss +597 -0
  283. package/src/admin/ui/styles/pagination.scss +20 -0
  284. package/src/admin/ui/styles/radio-group.scss +66 -0
  285. package/src/admin/ui/styles/row.scss +17 -0
  286. package/src/admin/ui/styles/scrollbar.scss +36 -0
  287. package/src/admin/ui/styles/select.scss +121 -0
  288. package/src/admin/ui/styles/separator.scss +14 -0
  289. package/src/admin/ui/styles/sheet.scss +152 -0
  290. package/src/admin/ui/styles/sidebar.scss +148 -0
  291. package/src/admin/ui/styles/switch.scss +59 -0
  292. package/src/admin/ui/styles/table.scss +207 -0
  293. package/src/admin/ui/styles/tabs.scss +62 -0
  294. package/src/admin/ui/styles/toast.scss +45 -0
  295. package/src/admin/ui/styles/variables.scss +24 -0
  296. package/src/admin/ui/views/collection-list-view.tsx +720 -0
  297. package/src/admin/ui/views/dashboard-view.tsx +263 -0
  298. package/src/admin/ui/views/document-edit-view.tsx +384 -0
  299. package/src/admin/ui/views/global-edit-view.tsx +226 -0
  300. package/src/admin/ui/views/init-view.tsx +182 -0
  301. package/src/admin/ui/views/login-view.tsx +123 -0
  302. package/src/admin/ui/views/media-registry-view.tsx +1104 -0
  303. package/src/admin/ui/views/settings-view.tsx +729 -0
  304. package/src/admin/webcomponent.tsx +15 -0
  305. package/src/api.ts +9 -0
  306. package/src/auth/index.ts +194 -0
  307. package/src/auth/migrations.ts +87 -0
  308. package/src/auth/premissions.ts +46 -0
  309. package/src/cli/commands/generate-types.ts +116 -0
  310. package/src/cli/commands/init.ts +95 -0
  311. package/src/cli/commands/migrate-commands.ts +160 -0
  312. package/src/cli/commands/seed-command.ts +11 -0
  313. package/src/cli/d1-mock.ts +101 -0
  314. package/src/cli/index.test.ts +84 -0
  315. package/src/cli/index.ts +183 -0
  316. package/src/cli/r2-mock.ts +217 -0
  317. package/src/cli/seeding.ts +405 -0
  318. package/src/client.ts +181 -0
  319. package/src/config-utils.ts +102 -0
  320. package/src/config.ts +49 -0
  321. package/src/db/adapter.ts +53 -0
  322. package/src/db/better-sqlite.ts +630 -0
  323. package/src/db/bun-sqlite.ts +646 -0
  324. package/src/db/d1.ts +711 -0
  325. package/src/db/kysely/data-mapper.ts +142 -0
  326. package/src/db/kysely/field-mapper.ts +148 -0
  327. package/src/db/kysely/migration-generator.ts +223 -0
  328. package/src/db/kysely/query-builder.ts +92 -0
  329. package/src/db/kysely/schema-builder.ts +439 -0
  330. package/src/db/kysely/sql-utils.ts +13 -0
  331. package/src/db/postgres.ts +621 -0
  332. package/src/db/sqlite.ts +658 -0
  333. package/src/db/system-schema.ts +121 -0
  334. package/src/index.ts +13 -0
  335. package/src/runtimes/README.md +59 -0
  336. package/src/runtimes/bun.ts +49 -0
  337. package/src/runtimes/cloudflare-workers.ts +38 -0
  338. package/src/runtimes/next.ts +26 -0
  339. package/src/runtimes/node.ts +52 -0
  340. package/src/schema/collection.ts +184 -0
  341. package/src/schema/fields/base.ts +164 -0
  342. package/src/schema/fields/index.ts +427 -0
  343. package/src/schema/global.ts +145 -0
  344. package/src/schema/index.ts +4 -0
  345. package/src/schema/infer.ts +72 -0
  346. package/src/server/admin-router.ts +20 -0
  347. package/src/server/admin.ts +142 -0
  348. package/src/server/assets.ts +306 -0
  349. package/src/server/collection-router.ts +55 -0
  350. package/src/server/handlers.ts +722 -0
  351. package/src/server/middlewares/admin.ts +27 -0
  352. package/src/server/middlewares/auth.ts +89 -0
  353. package/src/server/middlewares/context.ts +17 -0
  354. package/src/server/middlewares/cors.ts +24 -0
  355. package/src/server/middlewares/database-init.ts +74 -0
  356. package/src/server/middlewares/rate-limit.ts +71 -0
  357. package/src/server/router.ts +47 -0
  358. package/src/server/setup-middlewares.ts +58 -0
  359. package/src/server/system-router.ts +35 -0
  360. package/src/server.ts +9 -0
  361. package/src/storage/adapters/cloudflare-r2.ts +136 -0
  362. package/src/storage/adapters/local.ts +146 -0
  363. package/src/storage/adapters/s3.ts +186 -0
  364. package/src/storage/errors.ts +46 -0
  365. package/src/storage/index.ts +5 -0
  366. package/src/storage/types.ts +39 -0
  367. package/src/types.ts +577 -0
  368. package/src/utils/lexical.ts +37 -0
  369. package/src/utils/logger.ts +73 -0
  370. package/src/validation.ts +429 -0
  371. package/src/validator.ts +179 -0
  372. package/test/admin-custom-field.test.ts +162 -0
  373. package/test/admin-react-field.test.tsx +134 -0
  374. package/test/api-features.test.ts +78 -0
  375. package/test/api.test.ts +178 -0
  376. package/test/auth.test.ts +62 -0
  377. package/test/cli-integration.test.ts +146 -0
  378. package/test/cli.test.ts +25 -0
  379. package/test/db/postgres.test.ts +95 -0
  380. package/test/db/sqlite-filter.test.ts +53 -0
  381. package/test/db/sqlite.test.ts +82 -0
  382. package/test/engine-features.test.ts +79 -0
  383. package/test/globals.test.ts +74 -0
  384. package/test/integration-tmp/db-app/opacacms.config.ts +15 -0
  385. package/test/integration-tmp/my-sqlite-app/opacacms.config.ts +25 -0
  386. package/test/integration-tmp/my-test-app/index.ts +8 -0
  387. package/test/integration-tmp/my-test-app/opacacms.config.ts +16 -0
  388. package/test/integration-tmp/my-test-app/package.json +12 -0
  389. package/test/populate.test.ts +79 -0
  390. package/test/runtimes.test.ts +43 -0
  391. package/test/schema-builder.test.ts +107 -0
  392. package/test/schema-features.test.ts +63 -0
  393. package/test/seeding.test.ts +68 -0
  394. package/test/storage/local.test.ts +72 -0
  395. package/test/storage/s3.test.ts +60 -0
  396. package/test/structural-data.test.ts +100 -0
  397. package/test/test-setup.ts +11 -0
  398. package/test/validation.test.ts +162 -0
  399. package/tsconfig.json +42 -0
@@ -0,0 +1,1104 @@
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
+ }