@michaelthielemann/kestrel 1.2.1

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 (395) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +12 -0
  3. package/README.md +234 -0
  4. package/kestrel.config.ts +28 -0
  5. package/layers/access/nuxt.config.ts +5 -0
  6. package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
  7. package/layers/access/server/middleware/access-guard.ts +30 -0
  8. package/layers/access/server/types.d.ts +8 -0
  9. package/layers/access/server/utils/csrf.ts +39 -0
  10. package/layers/access/server/utils/grant-registry.ts +36 -0
  11. package/layers/access/server/utils/guard.ts +44 -0
  12. package/layers/access/server/utils/ip-allowlist.ts +76 -0
  13. package/layers/access/server/utils/policy.ts +98 -0
  14. package/layers/access/server/utils/public-resources.ts +11 -0
  15. package/layers/access/server/utils/render-context.ts +41 -0
  16. package/layers/access/server/utils/require-admin.ts +15 -0
  17. package/layers/admin/app/components/AdminAccount.vue +212 -0
  18. package/layers/admin/app/components/AdminNav.vue +44 -0
  19. package/layers/admin/app/components/BlockFields.vue +68 -0
  20. package/layers/admin/app/components/BlockPreview.vue +401 -0
  21. package/layers/admin/app/components/BlockTree.vue +433 -0
  22. package/layers/admin/app/components/BlocksBody.vue +162 -0
  23. package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
  24. package/layers/admin/app/components/CollectionEditor.vue +203 -0
  25. package/layers/admin/app/components/CollectionList.vue +1091 -0
  26. package/layers/admin/app/components/EditorStatus.vue +199 -0
  27. package/layers/admin/app/components/EditorUnsupported.vue +28 -0
  28. package/layers/admin/app/components/FieldsBody.vue +23 -0
  29. package/layers/admin/app/components/LocaleBar.vue +105 -0
  30. package/layers/admin/app/components/PageFields.vue +101 -0
  31. package/layers/admin/app/components/PageFieldsPane.vue +39 -0
  32. package/layers/admin/app/components/SeoFields.vue +169 -0
  33. package/layers/admin/app/components/SingletonEditor.vue +92 -0
  34. package/layers/admin/app/composables/useAuth.ts +68 -0
  35. package/layers/admin/app/composables/useBlockTree.ts +95 -0
  36. package/layers/admin/app/composables/useBlocks.ts +7 -0
  37. package/layers/admin/app/composables/useCachedResource.ts +35 -0
  38. package/layers/admin/app/composables/useCollectionOps.ts +53 -0
  39. package/layers/admin/app/composables/useCollections.ts +7 -0
  40. package/layers/admin/app/composables/useContentLocales.ts +23 -0
  41. package/layers/admin/app/composables/useEditForm.ts +370 -0
  42. package/layers/admin/app/composables/useLinkResolver.ts +40 -0
  43. package/layers/admin/app/composables/useListColumns.ts +53 -0
  44. package/layers/admin/app/composables/useListUrlState.ts +53 -0
  45. package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
  46. package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
  47. package/layers/admin/app/composables/usePublishStatus.ts +98 -0
  48. package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
  49. package/layers/admin/app/composables/useTheme.ts +36 -0
  50. package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
  51. package/layers/admin/app/layouts/admin.vue +267 -0
  52. package/layers/admin/app/middleware/admin-auth.ts +4 -0
  53. package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
  54. package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
  55. package/layers/admin/app/pages/admin/index.vue +118 -0
  56. package/layers/admin/app/pages/admin/login.vue +68 -0
  57. package/layers/admin/app/pages/admin/media.vue +28 -0
  58. package/layers/admin/app/pages/admin/references.vue +146 -0
  59. package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
  60. package/layers/admin/app/plugins/reauth.client.ts +20 -0
  61. package/layers/admin/app/utils/admin-style-guard.ts +20 -0
  62. package/layers/admin/app/utils/block-tree.ts +214 -0
  63. package/layers/admin/app/utils/collection-ops.ts +44 -0
  64. package/layers/admin/app/utils/dead-refs.ts +30 -0
  65. package/layers/admin/app/utils/edit-form.ts +223 -0
  66. package/layers/admin/app/utils/editor-expose.ts +26 -0
  67. package/layers/admin/app/utils/editor-form-context.ts +83 -0
  68. package/layers/admin/app/utils/editor-registry.ts +20 -0
  69. package/layers/admin/app/utils/field-keys.ts +11 -0
  70. package/layers/admin/app/utils/list-columns.ts +141 -0
  71. package/layers/admin/app/utils/list-query.ts +96 -0
  72. package/layers/admin/app/utils/nav-active.ts +9 -0
  73. package/layers/admin/app/utils/populate-blocks.ts +288 -0
  74. package/layers/admin/app/utils/preview-channel.ts +62 -0
  75. package/layers/admin/app/utils/preview-viewport.ts +80 -0
  76. package/layers/admin/app/utils/reauth.ts +68 -0
  77. package/layers/admin/app/utils/record-title.ts +15 -0
  78. package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
  79. package/layers/admin/app/utils/safe-redirect.ts +12 -0
  80. package/layers/admin/nuxt.config.ts +17 -0
  81. package/layers/auth/nuxt.config.ts +1 -0
  82. package/layers/auth/server/api/auth/login.post.ts +38 -0
  83. package/layers/auth/server/api/auth/logout.post.ts +9 -0
  84. package/layers/auth/server/api/auth/session.get.ts +1 -0
  85. package/layers/auth/server/utils/admin-credential.ts +42 -0
  86. package/layers/auth/server/utils/client-ip.ts +88 -0
  87. package/layers/auth/server/utils/login-throttle.ts +126 -0
  88. package/layers/auth/server/utils/password.ts +52 -0
  89. package/layers/auth/server/utils/session-cookie.ts +44 -0
  90. package/layers/auth/server/utils/session-epoch.ts +96 -0
  91. package/layers/auth/server/utils/session.ts +85 -0
  92. package/layers/collections/nuxt.config.ts +1 -0
  93. package/layers/collections/server/collections/pages.ts +19 -0
  94. package/layers/collections/server/plugins/01.register.ts +32 -0
  95. package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
  96. package/layers/collections/server/utils/populate-relations.ts +66 -0
  97. package/layers/core/app/utils/bulk-actions.ts +9 -0
  98. package/layers/core/app/utils/filter-ops.ts +98 -0
  99. package/layers/core/app/utils/list-limits.ts +27 -0
  100. package/layers/core/app/utils/locale-path.ts +12 -0
  101. package/layers/core/app/utils/slugify.ts +16 -0
  102. package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
  103. package/layers/core/modules/auto-discovery/index.ts +44 -0
  104. package/layers/core/modules/auto-discovery/scan.ts +78 -0
  105. package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
  106. package/layers/core/modules/kestrel/index.ts +86 -0
  107. package/layers/core/nuxt.config.ts +8 -0
  108. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
  109. package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
  110. package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
  111. package/layers/core/server/api/[collection]/[id].get.ts +8 -0
  112. package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
  113. package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
  114. package/layers/core/server/api/[collection]/index.get.ts +19 -0
  115. package/layers/core/server/api/[collection]/index.post.ts +8 -0
  116. package/layers/core/server/api/[collection]/index.put.ts +7 -0
  117. package/layers/core/server/api/[collection]/options.get.ts +18 -0
  118. package/layers/core/server/api/[collection]/translations.get.ts +25 -0
  119. package/layers/core/server/api/collections/[name].get.ts +4 -0
  120. package/layers/core/server/api/collections/index.get.ts +3 -0
  121. package/layers/core/server/api/references/broken.get.ts +3 -0
  122. package/layers/core/server/api/references/referrers.get.ts +24 -0
  123. package/layers/core/server/database/record-refs.ts +20 -0
  124. package/layers/core/server/plugins/00.migrate.ts +11 -0
  125. package/layers/core/server/plugins/02.schema-sync.ts +63 -0
  126. package/layers/core/server/plugins/03.record-refs.ts +12 -0
  127. package/layers/core/server/schema/bootstrap.ts +41 -0
  128. package/layers/core/server/schema/desired.ts +99 -0
  129. package/layers/core/server/schema/dev-sync.ts +23 -0
  130. package/layers/core/server/schema/dialect.ts +59 -0
  131. package/layers/core/server/schema/diff.ts +110 -0
  132. package/layers/core/server/schema/introspect.ts +67 -0
  133. package/layers/core/server/schema/model.ts +61 -0
  134. package/layers/core/server/schema/render-sqlite.ts +89 -0
  135. package/layers/core/server/schema/sync.ts +201 -0
  136. package/layers/core/server/tasks/db/migrate.ts +54 -0
  137. package/layers/core/server/utils/blocks.ts +6 -0
  138. package/layers/core/server/utils/collection-types.ts +27 -0
  139. package/layers/core/server/utils/crud.ts +577 -0
  140. package/layers/core/server/utils/db.ts +26 -0
  141. package/layers/core/server/utils/defineCollection.ts +207 -0
  142. package/layers/core/server/utils/duplicate.ts +162 -0
  143. package/layers/core/server/utils/field-layout.ts +125 -0
  144. package/layers/core/server/utils/filter-predicate.ts +101 -0
  145. package/layers/core/server/utils/http.ts +50 -0
  146. package/layers/core/server/utils/if-unmodified.ts +13 -0
  147. package/layers/core/server/utils/kestrel-config.ts +410 -0
  148. package/layers/core/server/utils/key-lock.ts +33 -0
  149. package/layers/core/server/utils/locale.ts +53 -0
  150. package/layers/core/server/utils/page-route.ts +58 -0
  151. package/layers/core/server/utils/page-slug.ts +127 -0
  152. package/layers/core/server/utils/picker.ts +82 -0
  153. package/layers/core/server/utils/populate.ts +48 -0
  154. package/layers/core/server/utils/prerender-memo.ts +23 -0
  155. package/layers/core/server/utils/read-capture.ts +37 -0
  156. package/layers/core/server/utils/record-ref-index.ts +200 -0
  157. package/layers/core/server/utils/registry.ts +24 -0
  158. package/layers/core/server/utils/resolve-scope.ts +79 -0
  159. package/layers/core/server/utils/seo.ts +24 -0
  160. package/layers/core/server/utils/serialize-collection.ts +149 -0
  161. package/layers/core/server/utils/server-config.ts +18 -0
  162. package/layers/core/server/utils/sql.ts +2 -0
  163. package/layers/core/server/utils/storage.local.ts +117 -0
  164. package/layers/core/server/utils/storage.s3.ts +197 -0
  165. package/layers/core/server/utils/storage.ts +90 -0
  166. package/layers/core/server/utils/write-events.ts +31 -0
  167. package/layers/fields/app/utils/condition.ts +71 -0
  168. package/layers/fields/app/utils/define-block.ts +13 -0
  169. package/layers/fields/app/utils/field-constraints.ts +17 -0
  170. package/layers/fields/app/utils/field-factories.ts +129 -0
  171. package/layers/fields/app/utils/field-meta.ts +33 -0
  172. package/layers/fields/app/utils/field-validate.ts +75 -0
  173. package/layers/fields/app/utils/field-value.ts +7 -0
  174. package/layers/fields/app/utils/richtext-links.ts +48 -0
  175. package/layers/fields/nuxt.config.ts +1 -0
  176. package/layers/fields/server/api/blocks/index.get.ts +9 -0
  177. package/layers/fields/server/field-registry/index.ts +248 -0
  178. package/layers/fields/server/field-registry/naming.ts +10 -0
  179. package/layers/fields/server/field-registry/sanitize.ts +40 -0
  180. package/layers/fields/server/field-registry/types.ts +19 -0
  181. package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
  182. package/layers/fields/server/utils/block-ids.ts +43 -0
  183. package/layers/fields/server/utils/block-populate.ts +41 -0
  184. package/layers/fields/server/utils/buildCollection.ts +88 -0
  185. package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
  186. package/layers/fields/server/utils/buildTable.ts +107 -0
  187. package/layers/fields/server/utils/conditional-required.ts +25 -0
  188. package/layers/fields/server/utils/defineBlock.ts +86 -0
  189. package/layers/fields/server/utils/defineFieldType.ts +33 -0
  190. package/layers/fields/server/utils/extract-refs.ts +152 -0
  191. package/layers/fields/server/utils/field-populate.ts +68 -0
  192. package/layers/media/app/components/KestrelImg.vue +65 -0
  193. package/layers/media/app/components/MediaConflictDialog.vue +35 -0
  194. package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
  195. package/layers/media/app/components/MediaGrid.vue +113 -0
  196. package/layers/media/app/components/MediaImage.vue +70 -0
  197. package/layers/media/app/components/MediaLibrary.vue +451 -0
  198. package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
  199. package/layers/media/app/components/MediaPathBar.vue +163 -0
  200. package/layers/media/app/components/MediaPicker.vue +19 -0
  201. package/layers/media/app/components/MediaRenameDialog.vue +32 -0
  202. package/layers/media/app/components/MediaTable.vue +75 -0
  203. package/layers/media/app/components/MediaThumb.vue +25 -0
  204. package/layers/media/app/components/MediaToolbar.vue +90 -0
  205. package/layers/media/app/components/MediaUploadDialog.vue +87 -0
  206. package/layers/media/app/components/MediaViewer.vue +88 -0
  207. package/layers/media/app/components/field/Media.vue +261 -0
  208. package/layers/media/app/composables/useBlurUp.ts +55 -0
  209. package/layers/media/app/composables/useMediaClipboard.ts +14 -0
  210. package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
  211. package/layers/media/app/composables/useMediaDnd.ts +56 -0
  212. package/layers/media/app/composables/useMediaLibrary.ts +151 -0
  213. package/layers/media/app/composables/useMediaOps.ts +50 -0
  214. package/layers/media/app/composables/useMediaRelocate.ts +39 -0
  215. package/layers/media/app/composables/useMediaResolver.ts +50 -0
  216. package/layers/media/app/composables/useMediaUpload.ts +93 -0
  217. package/layers/media/app/composables/useMediaVariant.ts +34 -0
  218. package/layers/media/app/plugins/field-media.client.ts +6 -0
  219. package/layers/media/app/utils/dnd.ts +69 -0
  220. package/layers/media/app/utils/library.ts +79 -0
  221. package/layers/media/app/utils/ops.ts +62 -0
  222. package/layers/media/app/utils/picture-model.ts +125 -0
  223. package/layers/media/app/utils/thumbhash.ts +19 -0
  224. package/layers/media/app/utils/variant-name.ts +20 -0
  225. package/layers/media/nuxt.config.ts +3 -0
  226. package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
  227. package/layers/media/server/api/media/[id].delete.ts +18 -0
  228. package/layers/media/server/api/media/[id].get.ts +10 -0
  229. package/layers/media/server/api/media/[id].patch.ts +41 -0
  230. package/layers/media/server/api/media/backfill.post.ts +18 -0
  231. package/layers/media/server/api/media/copy.post.ts +7 -0
  232. package/layers/media/server/api/media/delete.post.ts +13 -0
  233. package/layers/media/server/api/media/folders.post.ts +18 -0
  234. package/layers/media/server/api/media/index.get.ts +15 -0
  235. package/layers/media/server/api/media/index.post.ts +157 -0
  236. package/layers/media/server/api/media/library.get.ts +17 -0
  237. package/layers/media/server/api/media/move.post.ts +7 -0
  238. package/layers/media/server/api/media/rename.post.ts +7 -0
  239. package/layers/media/server/api/media/resolve.get.ts +13 -0
  240. package/layers/media/server/collections/media-settings.ts +25 -0
  241. package/layers/media/server/collections/media.ts +29 -0
  242. package/layers/media/server/database/folders.ts +10 -0
  243. package/layers/media/server/middleware/ondemand-variants.ts +37 -0
  244. package/layers/media/server/plugins/02.register-media.ts +19 -0
  245. package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
  246. package/layers/media/server/plugins/04.variant-capture.ts +23 -0
  247. package/layers/media/server/schema-tables/folders.ts +4 -0
  248. package/layers/media/server/tasks/media/backfill.ts +18 -0
  249. package/layers/media/server/utils/backfill.ts +132 -0
  250. package/layers/media/server/utils/derive.ts +59 -0
  251. package/layers/media/server/utils/folder-paths.ts +42 -0
  252. package/layers/media/server/utils/folders.ts +12 -0
  253. package/layers/media/server/utils/library.ts +90 -0
  254. package/layers/media/server/utils/media-cleanup.ts +27 -0
  255. package/layers/media/server/utils/media-enabled.ts +23 -0
  256. package/layers/media/server/utils/media-ops.ts +118 -0
  257. package/layers/media/server/utils/media-write.ts +13 -0
  258. package/layers/media/server/utils/naming.ts +52 -0
  259. package/layers/media/server/utils/ondemand.ts +69 -0
  260. package/layers/media/server/utils/persist-upload.ts +62 -0
  261. package/layers/media/server/utils/populate.ts +47 -0
  262. package/layers/media/server/utils/record.ts +63 -0
  263. package/layers/media/server/utils/relocate-ops.ts +341 -0
  264. package/layers/media/server/utils/relocate-plan.ts +63 -0
  265. package/layers/media/server/utils/resolve.ts +86 -0
  266. package/layers/media/server/utils/sanitize-svg.ts +12 -0
  267. package/layers/media/server/utils/sniff.ts +68 -0
  268. package/layers/media/server/utils/storage-relocate.ts +139 -0
  269. package/layers/media/server/utils/translations.ts +19 -0
  270. package/layers/media/server/utils/usages.ts +66 -0
  271. package/layers/media/server/utils/variant-capture.ts +70 -0
  272. package/layers/media/server/utils/variants.ts +108 -0
  273. package/layers/public/app/app.vue +5 -0
  274. package/layers/public/app/components/BlockRenderer.vue +56 -0
  275. package/layers/public/app/components/KestrelLink.vue +26 -0
  276. package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
  277. package/layers/public/app/composables/public-page.ts +24 -0
  278. package/layers/public/app/error.vue +12 -0
  279. package/layers/public/app/layouts/default.vue +5 -0
  280. package/layers/public/app/pages/[...slug].vue +157 -0
  281. package/layers/public/app/pages/__kestrel/preview.vue +34 -0
  282. package/layers/public/app/utils/block-edit-context.ts +16 -0
  283. package/layers/public/app/utils/link-href.ts +46 -0
  284. package/layers/public/app/utils/page-head.ts +84 -0
  285. package/layers/public/app/utils/preview-protocol.ts +81 -0
  286. package/layers/public/app/utils/public-route.ts +46 -0
  287. package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
  288. package/layers/public/modules/deploy-output/index.ts +82 -0
  289. package/layers/public/modules/prerender-routes/discover.ts +37 -0
  290. package/layers/public/modules/prerender-routes/index.ts +85 -0
  291. package/layers/public/modules/prerender-routes/routes.ts +16 -0
  292. package/layers/public/modules/prune-media/index.ts +48 -0
  293. package/layers/public/modules/prune-media/prune-media.ts +81 -0
  294. package/layers/public/nuxt.config.ts +11 -0
  295. package/layers/public/server/api/links/resolve.get.ts +19 -0
  296. package/layers/public/server/api/publish-status.get.ts +44 -0
  297. package/layers/public/server/api/route.get.ts +18 -0
  298. package/layers/public/server/database/publish-deps.ts +12 -0
  299. package/layers/public/server/database/publish-status.ts +19 -0
  300. package/layers/public/server/plugins/02.register-links.ts +17 -0
  301. package/layers/public/server/plugins/zz.publish.ts +63 -0
  302. package/layers/public/server/routes/llms.txt.get.ts +72 -0
  303. package/layers/public/server/routes/robots.txt.get.ts +8 -0
  304. package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
  305. package/layers/public/server/schema-tables/publish-deps.ts +4 -0
  306. package/layers/public/server/schema-tables/publish-status.ts +4 -0
  307. package/layers/public/server/tasks/publish/run.ts +26 -0
  308. package/layers/public/server/utils/link-resolve.ts +35 -0
  309. package/layers/public/server/utils/llms.ts +39 -0
  310. package/layers/public/server/utils/page-resolve.ts +86 -0
  311. package/layers/public/server/utils/populate-links.ts +66 -0
  312. package/layers/public/server/utils/publish/coalesce.ts +22 -0
  313. package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
  314. package/layers/public/server/utils/publish/deps.ts +87 -0
  315. package/layers/public/server/utils/publish/invalidation.ts +141 -0
  316. package/layers/public/server/utils/publish/publish-status.ts +54 -0
  317. package/layers/public/server/utils/publish/publisher.ts +291 -0
  318. package/layers/public/server/utils/publish/queue.ts +67 -0
  319. package/layers/public/server/utils/publish/route-for-record.ts +16 -0
  320. package/layers/public/server/utils/publish/route-keys.ts +13 -0
  321. package/layers/public/server/utils/site-url.ts +32 -0
  322. package/layers/public/server/utils/sitemap.ts +97 -0
  323. package/layers/ui/app/assets/scss/_base.scss +16 -0
  324. package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
  325. package/layers/ui/app/assets/scss/_mixins.scss +50 -0
  326. package/layers/ui/app/assets/scss/_reset.scss +65 -0
  327. package/layers/ui/app/assets/scss/_tokens.scss +183 -0
  328. package/layers/ui/app/assets/scss/main.scss +4 -0
  329. package/layers/ui/app/components/field/Boolean.vue +30 -0
  330. package/layers/ui/app/components/field/Choice.vue +68 -0
  331. package/layers/ui/app/components/field/Datetime.vue +96 -0
  332. package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
  333. package/layers/ui/app/components/field/Json.vue +63 -0
  334. package/layers/ui/app/components/field/Layout.vue +140 -0
  335. package/layers/ui/app/components/field/Link.vue +112 -0
  336. package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
  337. package/layers/ui/app/components/field/Number.vue +30 -0
  338. package/layers/ui/app/components/field/Relation.vue +49 -0
  339. package/layers/ui/app/components/field/Renderer.vue +30 -0
  340. package/layers/ui/app/components/field/Repeater.vue +460 -0
  341. package/layers/ui/app/components/field/Richtext.vue +26 -0
  342. package/layers/ui/app/components/field/Slug.vue +66 -0
  343. package/layers/ui/app/components/field/Text.vue +35 -0
  344. package/layers/ui/app/components/field/Unsupported.vue +34 -0
  345. package/layers/ui/app/components/ui/Alert.vue +50 -0
  346. package/layers/ui/app/components/ui/Brand.vue +22 -0
  347. package/layers/ui/app/components/ui/Button.vue +116 -0
  348. package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
  349. package/layers/ui/app/components/ui/Checkbox.vue +52 -0
  350. package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
  351. package/layers/ui/app/components/ui/Combobox.vue +343 -0
  352. package/layers/ui/app/components/ui/DatePicker.vue +92 -0
  353. package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
  354. package/layers/ui/app/components/ui/Dialog.vue +107 -0
  355. package/layers/ui/app/components/ui/EmptyState.vue +46 -0
  356. package/layers/ui/app/components/ui/Field.vue +57 -0
  357. package/layers/ui/app/components/ui/Fieldset.vue +64 -0
  358. package/layers/ui/app/components/ui/Icon.vue +44 -0
  359. package/layers/ui/app/components/ui/Menu.vue +54 -0
  360. package/layers/ui/app/components/ui/NumberInput.vue +173 -0
  361. package/layers/ui/app/components/ui/Richtext.vue +119 -0
  362. package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
  363. package/layers/ui/app/components/ui/Select.vue +51 -0
  364. package/layers/ui/app/components/ui/TextInput.vue +110 -0
  365. package/layers/ui/app/components/ui/Textarea.vue +38 -0
  366. package/layers/ui/app/components/ui/TimeInput.vue +17 -0
  367. package/layers/ui/app/components/ui/Toasts.vue +103 -0
  368. package/layers/ui/app/components/ui/Tooltip.vue +45 -0
  369. package/layers/ui/app/composables/useAdminLang.ts +8 -0
  370. package/layers/ui/app/composables/useDragReorder.ts +41 -0
  371. package/layers/ui/app/composables/useEchoGuard.ts +28 -0
  372. package/layers/ui/app/composables/useLinkField.ts +70 -0
  373. package/layers/ui/app/composables/useRecordOptions.ts +84 -0
  374. package/layers/ui/app/composables/useRepeater.ts +99 -0
  375. package/layers/ui/app/composables/useT.ts +32 -0
  376. package/layers/ui/app/composables/useToast.ts +46 -0
  377. package/layers/ui/app/i18n/de.ts +406 -0
  378. package/layers/ui/app/i18n/en.ts +440 -0
  379. package/layers/ui/app/utils/boolean-field.ts +10 -0
  380. package/layers/ui/app/utils/field-component.ts +22 -0
  381. package/layers/ui/app/utils/field-empty.ts +21 -0
  382. package/layers/ui/app/utils/field-registry.ts +36 -0
  383. package/layers/ui/app/utils/humanize.ts +14 -0
  384. package/layers/ui/app/utils/icons.ts +147 -0
  385. package/layers/ui/app/utils/iso-date.ts +18 -0
  386. package/layers/ui/app/utils/localized.ts +13 -0
  387. package/layers/ui/app/utils/record-context.ts +15 -0
  388. package/layers/ui/app/utils/reorder.ts +8 -0
  389. package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
  390. package/layers/ui/app/utils/useFieldA11y.ts +40 -0
  391. package/layers/ui/nuxt.config.ts +5 -0
  392. package/nuxt.config.ts +105 -0
  393. package/package.json +122 -0
  394. package/scripts/hash-password.mjs +26 -0
  395. package/scripts/typecheck.mjs +51 -0
@@ -0,0 +1,163 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onUnmounted } from 'vue'
3
+ import { splitPathInput, joinFolder, displayFolderPath, type LibraryFolder } from '../utils/library'
4
+
5
+ const props = defineProps<{ folder: string }>()
6
+ const emit = defineEmits<{ navigate: [string] }>()
7
+
8
+ const { t } = useT()
9
+
10
+ const editing = ref(false)
11
+ const draft = ref('')
12
+ const suggestions = ref<string[]>([])
13
+ let acToken = 0
14
+ let acTimer: ReturnType<typeof setTimeout> | undefined
15
+
16
+ // Non-root path segments, each with its accumulated internal path (for click-to-navigate). The root
17
+ // is rendered separately as "/", so the whole bar reads as a real path: "/" · "/a/" · "/a/b/".
18
+ const segments = computed(() => {
19
+ const out: { label: string; path: string }[] = []
20
+ let acc = ''
21
+ for (const s of props.folder.split('/').filter(Boolean)) { acc = acc ? `${acc}/${s}` : s; out.push({ label: s, path: acc }) }
22
+ return out
23
+ })
24
+
25
+ function startEdit() { draft.value = displayFolderPath(props.folder); editing.value = true; suggestions.value = [] }
26
+ function commit(path: string) { editing.value = false; suggestions.value = []; emit('navigate', path) }
27
+ function cancel() { editing.value = false; suggestions.value = [] }
28
+
29
+ function onInput() { clearTimeout(acTimer); acTimer = setTimeout(fetchSuggestions, 150) }
30
+
31
+ async function fetchSuggestions() {
32
+ // The typed value is a display path ("/a/su"); resolve its parent to the internal form for the query.
33
+ const { parent, fragment } = splitPathInput(draft.value)
34
+ const my = ++acToken
35
+ try {
36
+ const res = await $fetch<{ folders: LibraryFolder[] }>('/api/media/library', { query: { folder: joinFolder(parent), perPage: 200 } })
37
+ if (my !== acToken) return
38
+ suggestions.value = res.folders
39
+ .filter((f) => f.name.toLowerCase().includes(fragment.toLowerCase()))
40
+ .map((f) => f.path)
41
+ } catch {
42
+ if (my === acToken) suggestions.value = []
43
+ }
44
+ }
45
+
46
+ onUnmounted(() => clearTimeout(acTimer))
47
+ </script>
48
+
49
+ <template>
50
+ <div class="media-pathbar">
51
+ <template v-if="!editing">
52
+ <nav class="media-pathbar__segments" :aria-label="t('mediaPath.navAriaLabel')">
53
+ <button type="button" class="media-pathbar__seg media-pathbar__seg--root" @click="emit('navigate', '')">/</button>
54
+ <template v-for="seg in segments" :key="seg.path">
55
+ <button type="button" class="media-pathbar__seg" @click="emit('navigate', seg.path)">{{ seg.label }}</button>
56
+ <span class="media-pathbar__sep" aria-hidden="true">/</span>
57
+ </template>
58
+ </nav>
59
+ <button type="button" data-test="path-edit" class="media-pathbar__edit" :aria-label="t('mediaPath.editAriaLabel')" @click="startEdit"><UiIcon name="pencil" :size="14" /></button>
60
+ </template>
61
+ <div v-else class="media-pathbar__editor">
62
+ <input
63
+ v-model="draft"
64
+ class="media-pathbar__input"
65
+ :aria-label="t('mediaPath.inputAriaLabel')"
66
+ @input="onInput"
67
+ @keydown.enter="commit(joinFolder(draft))"
68
+ @keydown.esc="cancel"
69
+ />
70
+ <ul v-if="suggestions.length" class="media-pathbar__suggestions">
71
+ <li v-for="s in suggestions" :key="s">
72
+ <button type="button" @click="commit(s)">{{ displayFolderPath(s) }}</button>
73
+ </li>
74
+ </ul>
75
+ </div>
76
+ </div>
77
+ </template>
78
+
79
+ <style lang="scss" scoped>
80
+ .media-pathbar {
81
+ display: flex;
82
+ align-items: center;
83
+ gap: var(--space-2);
84
+ position: relative;
85
+
86
+ &__segments {
87
+ display: flex;
88
+ align-items: center;
89
+ gap: 0;
90
+ flex-wrap: wrap;
91
+ }
92
+
93
+ &__seg,
94
+ &__edit {
95
+ background: none;
96
+ border: 0;
97
+ padding: var(--space-1);
98
+ border-radius: var(--radius-sm);
99
+ color: var(--color-text);
100
+ cursor: pointer;
101
+ }
102
+ &__edit {
103
+ display: inline-flex;
104
+ color: var(--color-text-muted);
105
+ margin-inline-start: var(--space-1);
106
+ }
107
+ &__seg--root {
108
+ color: var(--color-text-muted);
109
+ }
110
+
111
+ &__seg:hover {
112
+ background: var(--color-hover);
113
+ }
114
+
115
+ &__seg:focus-visible,
116
+ &__edit:focus-visible,
117
+ &__input:focus-visible {
118
+ outline: 2px solid var(--color-focus);
119
+ outline-offset: -2px;
120
+ }
121
+
122
+ &__sep {
123
+ color: var(--color-text-muted);
124
+ }
125
+
126
+ &__input {
127
+ padding: var(--space-1) var(--space-2);
128
+ border: 1px solid var(--color-border);
129
+ border-radius: var(--radius-sm);
130
+ min-width: 18rem;
131
+ }
132
+
133
+ &__suggestions {
134
+ position: absolute;
135
+ top: 100%;
136
+ left: 0;
137
+ z-index: var(--z-dropdown);
138
+ list-style: none;
139
+ margin: var(--space-1) 0 0;
140
+ padding: var(--space-1);
141
+ background: var(--color-surface);
142
+ border: 1px solid var(--color-border);
143
+ border-radius: var(--radius-md);
144
+ box-shadow: var(--shadow-md);
145
+ min-width: 18rem;
146
+
147
+ button {
148
+ display: block;
149
+ width: 100%;
150
+ text-align: left;
151
+ background: none;
152
+ border: 0;
153
+ padding: var(--space-1) var(--space-2);
154
+ border-radius: var(--radius-sm);
155
+ cursor: pointer;
156
+
157
+ &:hover {
158
+ background: var(--color-hover);
159
+ }
160
+ }
161
+ }
162
+ }
163
+ </style>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ defineProps<{ open: boolean; multiple?: boolean; accept?: 'image' | 'any'; initialFolder?: string; initialSelected?: number[] }>()
3
+ const emit = defineEmits<{ confirm: [number[]]; 'update:open': [boolean] }>()
4
+ const { t } = useT()
5
+ </script>
6
+
7
+ <template>
8
+ <UiDialog :open="open" size="lg" :title="multiple ? t('media.picker.titleMultiple') : t('media.picker.titleSingle')" @update:open="(v) => emit('update:open', v)">
9
+ <MediaLibrary
10
+ pick
11
+ :multiple="multiple"
12
+ :accept="accept ?? 'any'"
13
+ :initial-folder="initialFolder"
14
+ :initial-selected="initialSelected"
15
+ @confirm="(ids) => { emit('confirm', ids); emit('update:open', false) }"
16
+ @cancel="emit('update:open', false)"
17
+ />
18
+ </UiDialog>
19
+ </template>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch } from 'vue'
3
+
4
+ const props = defineProps<{ open: boolean; name: string; busy?: boolean; error?: string | null }>()
5
+ const emit = defineEmits<{ rename: [string]; 'update:open': [boolean] }>()
6
+
7
+ const draft = ref(props.name)
8
+ watch(() => props.open, (o) => { if (o) draft.value = props.name })
9
+
10
+ const { t } = useT()
11
+
12
+ const canRename = computed(() => {
13
+ const v = draft.value.trim()
14
+ return !!v && v !== props.name
15
+ })
16
+ function submit() { if (canRename.value && !props.busy) emit('rename', draft.value.trim()) }
17
+ </script>
18
+
19
+ <template>
20
+ <UiDialog :open="open" :title="t('media.rename')" @update:open="(v) => emit('update:open', v)">
21
+ <UiField :label="t('media.newName')">
22
+ <template #default="f">
23
+ <UiTextInput v-model="draft" v-bind="f" @keydown.enter="submit" />
24
+ </template>
25
+ </UiField>
26
+ <UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
27
+ <template #footer>
28
+ <UiButton :disabled="busy" @click="emit('update:open', false)">{{ t('common.cancel') }}</UiButton>
29
+ <UiButton variant="primary" :disabled="busy || !canRename" @click="submit">{{ t('media.rename') }}</UiButton>
30
+ </template>
31
+ </UiDialog>
32
+ </template>
@@ -0,0 +1,75 @@
1
+ <script setup lang="ts">
2
+ import { humanizeSize, itemKey, type LibraryItem } from '../utils/library'
3
+ const { t } = useT()
4
+ const props = defineProps<{ items: LibraryItem[]; isSelected: (item: LibraryItem) => boolean; dropTargetPath?: string | null; sort?: string; parentPath?: string | null; upLabel?: string }>()
5
+ const emit = defineEmits<{ navigate: [string]; select: [LibraryItem, { toggle: boolean; range: boolean }]; open: [LibraryItem]; dragstart: [LibraryItem, DragEvent]; dragend: []; sort: [string] }>()
6
+ const arrow = (field: string) => (props.sort === field ? ' ▲' : props.sort === `-${field}` ? ' ▼' : '')
7
+ const ariaSort = (field: string): 'ascending' | 'descending' | 'none' => (props.sort === field ? 'ascending' : props.sort === `-${field}` ? 'descending' : 'none')
8
+ const mods = (e: MouseEvent) => ({ toggle: e.ctrlKey || e.metaKey, range: e.shiftKey })
9
+ // Plain click opens the folder; modifier-click selects it so folders can join the selection.
10
+ function onFolder(item: LibraryItem, path: string, e: MouseEvent | KeyboardEvent) {
11
+ if (e.ctrlKey || e.metaKey || e.shiftKey) emit('select', item, mods(e as MouseEvent))
12
+ else emit('navigate', path)
13
+ }
14
+ function onFile(item: LibraryItem, e: MouseEvent | KeyboardEvent) { emit('select', item, mods(e as MouseEvent)) }
15
+ const dims = (f: { width?: number; height?: number }) => (f.width && f.height ? `${f.width}×${f.height}` : '—')
16
+ </script>
17
+
18
+ <template>
19
+ <table class="media-table">
20
+ <thead>
21
+ <tr>
22
+ <th scope="col" :aria-sort="ariaSort('name')"><button type="button" class="media-table__sort" @click="emit('sort', 'name')">{{ t('media.colName') }}<span aria-hidden="true">{{ arrow('name') }}</span></button></th>
23
+ <th scope="col" :aria-sort="ariaSort('type')"><button type="button" class="media-table__sort" @click="emit('sort', 'type')">{{ t('media.colType') }}<span aria-hidden="true">{{ arrow('type') }}</span></button></th>
24
+ <th scope="col" :aria-sort="ariaSort('size')"><button type="button" class="media-table__sort" @click="emit('sort', 'size')">{{ t('media.colSize') }}<span aria-hidden="true">{{ arrow('size') }}</span></button></th>
25
+ <th scope="col">{{ t('media.colDimensions') }}</th>
26
+ </tr>
27
+ </thead>
28
+ <tbody>
29
+ <tr v-if="parentPath != null" class="media-table__row media-table__row--up" data-test="row-folder-up"
30
+ tabindex="0" :aria-label="upLabel"
31
+ @click="emit('navigate', parentPath)" @keydown.enter.space.prevent="emit('navigate', parentPath)">
32
+ <td><span aria-hidden="true"><UiIcon name="folder" :size="15" /></span> ..</td>
33
+ <td>{{ t('media.typeFolder') }}</td><td>—</td><td>—</td>
34
+ </tr>
35
+ <tr v-for="item in items" :key="itemKey(item)" class="media-table__row"
36
+ :class="{ 'is-selected': isSelected(item), 'is-drop-target': item.type === 'folder' && item.folder.path === dropTargetPath }"
37
+ :aria-selected="isSelected(item)" tabindex="0"
38
+ :data-drop-folder="item.type === 'folder' ? item.folder.path : undefined"
39
+ :data-file-id="item.type === 'file' ? item.file.id : undefined"
40
+ :data-test="item.type === 'folder' ? `row-folder-${item.folder.path}` : `row-file-${item.file.id}`"
41
+ draggable="true"
42
+ @click="item.type === 'folder' ? onFolder(item, item.folder.path, $event) : onFile(item, $event)"
43
+ @dblclick="item.type === 'file' && emit('open', item)"
44
+ @keydown.enter.prevent="item.type === 'folder' ? onFolder(item, item.folder.path, $event) : emit('open', item)"
45
+ @keydown.space.prevent="item.type === 'folder' ? emit('select', item, { toggle: true, range: false }) : onFile(item, $event)"
46
+ @dragstart="(e) => emit('dragstart', item, e)"
47
+ @dragend="emit('dragend')">
48
+ <template v-if="item.type === 'folder'">
49
+ <td><span aria-hidden="true"><UiIcon name="folder" :size="15" /></span> {{ item.folder.name }}</td>
50
+ <td>{{ t('media.typeFolder') }}</td><td>{{ item.folder.size != null ? humanizeSize(item.folder.size) : '—' }}</td><td>—</td>
51
+ </template>
52
+ <template v-else>
53
+ <td><slot name="file-badge" :file="item.file" />{{ item.file.filename }}</td>
54
+ <td>{{ item.file.mime }}</td>
55
+ <td>{{ humanizeSize(item.file.size) }}</td>
56
+ <td>{{ dims(item.file) }}</td>
57
+ </template>
58
+ </tr>
59
+ </tbody>
60
+ </table>
61
+ </template>
62
+
63
+ <style lang="scss" scoped>
64
+ .media-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
65
+ .media-table th, .media-table td { text-align: left; padding: var(--space-2); border-bottom: 1px solid var(--color-border); }
66
+ .media-table th { color: var(--color-text-muted); font-weight: var(--weight-medium); }
67
+ .media-table__sort { display: inline-flex; align-items: center; gap: var(--space-1); background: none; border: 0; padding: 0; font: inherit; font-weight: var(--weight-medium); color: var(--color-text-muted); cursor: pointer; }
68
+ .media-table__sort:hover { color: var(--color-text); }
69
+ .media-table__sort:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; }
70
+ .media-table__row { cursor: pointer; }
71
+ .media-table__row:hover { background: var(--color-hover); }
72
+ .media-table__row.is-selected { background: var(--color-active); box-shadow: inset 2px 0 0 var(--color-primary); }
73
+ .media-table__row.is-drop-target { outline: 2px dashed var(--color-primary); outline-offset: -2px; background: var(--color-surface); }
74
+ .media-table__row:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; }
75
+ </style>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import { useBlurUp } from '../composables/useBlurUp'
3
+
4
+ // An admin media-library thumbnail: a plain <img> with a thumbhash blur-up placeholder so the library
5
+ // grid + the media field picker fill with soft previews instead of blank tiles that pop in. Product
6
+ // (AdminUI) — distinct from the demo public `MediaImage`. Takes ready-made src/srcset strings (the
7
+ // library API and the media resolver already build them). Placeholder-only on purpose: no sharpen
8
+ // animation, so adjacent tiles in the grid never overlap with a filter-blur halo.
9
+ const props = defineProps<{ src: string; srcset?: string; sizes?: string; alt?: string; thumbhash?: string | null }>()
10
+ const { imgEl, placeholderStyle, onLoad } = useBlurUp(() => props.thumbhash ?? null)
11
+ </script>
12
+
13
+ <template>
14
+ <img
15
+ ref="imgEl"
16
+ :src="src"
17
+ :srcset="srcset"
18
+ :sizes="sizes"
19
+ :alt="alt ?? ''"
20
+ :style="placeholderStyle"
21
+ loading="lazy"
22
+ decoding="async"
23
+ @load="onLoad"
24
+ >
25
+ </template>
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import type { IconName } from '../../../ui/app/utils/icons'
4
+
5
+ type View = 'grid' | 'table'
6
+
7
+ const props = defineProps<{ view: View; search: string; disabled?: boolean }>()
8
+ const emit = defineEmits<{
9
+ 'update:view': [View]
10
+ 'update:search': [string]
11
+ upload: [File[]]
12
+ 'new-folder': []
13
+ }>()
14
+
15
+ const { t } = useT()
16
+
17
+ const VIEW_OPTIONS = computed<{ label: string; value: string; icon: IconName }[]>(() => [
18
+ { label: t('mediaToolbar.viewGrid'), value: 'grid', icon: 'layout-grid' },
19
+ { label: t('mediaToolbar.viewTable'), value: 'table', icon: 'table' },
20
+ ])
21
+
22
+ const viewModel = computed<string | string[] | null>({
23
+ get: () => props.view,
24
+ set: (v) => { if (typeof v === 'string') emit('update:view', v as View) },
25
+ })
26
+
27
+ const fileInput = ref<HTMLInputElement | null>(null)
28
+
29
+ function onFiles(e: Event) {
30
+ const input = e.target as HTMLInputElement
31
+ emit('upload', Array.from(input.files ?? []))
32
+ input.value = ''
33
+ }
34
+ </script>
35
+
36
+ <template>
37
+ <div class="media-toolbar">
38
+ <UiTextInput
39
+ class="media-toolbar__search"
40
+ :model-value="search"
41
+ type="search"
42
+ :placeholder="t('mediaToolbar.searchPlaceholder')"
43
+ :aria-label="t('mediaToolbar.searchAriaLabel')"
44
+ @update:model-value="(v) => emit('update:search', v ?? '')"
45
+ />
46
+ <!-- View toggle + actions sit together on the right so they read as one control cluster. -->
47
+ <div class="media-toolbar__actions">
48
+ <UiButtonGroup v-model="viewModel" :options="VIEW_OPTIONS" :aria-label="t('mediaToolbar.viewAriaLabel')" />
49
+ <span class="media-toolbar__divider" aria-hidden="true"></span>
50
+ <input ref="fileInput" type="file" multiple class="media-toolbar__file" @change="onFiles" />
51
+ <UiButton :disabled="disabled" @click="fileInput?.click()"><UiIcon name="upload" :size="16" /> {{ t('mediaToolbar.upload') }}</UiButton>
52
+ <UiButton :disabled="disabled" @click="emit('new-folder')"><UiIcon name="folder-plus" :size="16" /> {{ t('mediaToolbar.newFolder') }}</UiButton>
53
+ </div>
54
+ </div>
55
+ </template>
56
+
57
+ <style lang="scss" scoped>
58
+ .media-toolbar {
59
+ display: flex;
60
+ flex-wrap: wrap;
61
+ gap: var(--space-3);
62
+ row-gap: var(--space-2);
63
+ align-items: center;
64
+
65
+ &__search {
66
+ flex: 1 1 16rem;
67
+ min-width: 0;
68
+ max-width: 24rem;
69
+ margin-right: auto;
70
+ }
71
+
72
+ &__actions {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: var(--space-2);
76
+ }
77
+
78
+ &__divider {
79
+ align-self: stretch;
80
+ width: 1px;
81
+ min-height: 1.5rem;
82
+ background: var(--color-border);
83
+ margin: 0 var(--space-1);
84
+ }
85
+
86
+ &__file {
87
+ display: none;
88
+ }
89
+ }
90
+ </style>
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ import { reactive, watch } from 'vue'
3
+ import type { UploadItem } from '../composables/useMediaUpload'
4
+
5
+ const { t } = useT()
6
+
7
+ const props = defineProps<{ conflicts: UploadItem[]; open: boolean }>()
8
+ const emit = defineEmits<{
9
+ resolve: [string, 'overwrite' | 'rename' | 'skip', string?]
10
+ 'resolve-all': ['overwrite' | 'rename' | 'skip']
11
+ 'update:open': [boolean]
12
+ }>()
13
+
14
+ // Per-row editable rename, seeded from each item's server suggestion.
15
+ const names = reactive<Record<string, string>>({})
16
+ watch(
17
+ () => props.conflicts,
18
+ (list) => {
19
+ for (const c of list) if (!(c.id in names)) names[c.id] = c.suggestion ?? c.filename
20
+ },
21
+ { immediate: true, deep: true },
22
+ )
23
+
24
+ function rename(c: UploadItem) {
25
+ emit('resolve', c.id, 'rename', names[c.id] ?? c.suggestion ?? c.filename)
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <UiDialog
31
+ :open="open"
32
+ :title="t('media.conflicts.title')"
33
+ :description="t('media.conflicts.desc', { count: conflicts.length })"
34
+ @update:open="(v) => emit('update:open', v)"
35
+ >
36
+ <div class="media-conflicts__all">
37
+ <span>{{ t('media.conflicts.applyAll') }}</span>
38
+ <UiButton @click="emit('resolve-all', 'overwrite')">{{ t('media.conflicts.overwriteAll') }}</UiButton>
39
+ <UiButton @click="emit('resolve-all', 'rename')">{{ t('media.conflicts.renameAll') }}</UiButton>
40
+ <UiButton @click="emit('resolve-all', 'skip')">{{ t('media.conflicts.skipAll') }}</UiButton>
41
+ </div>
42
+ <ul class="media-conflicts__list">
43
+ <li v-for="c in conflicts" :key="c.id" class="media-conflicts__row">
44
+ <span class="media-conflicts__name">{{ c.filename }}</span>
45
+ <UiTextInput v-model="names[c.id]" :aria-label="t('media.conflicts.newName', { name: c.filename })" />
46
+ <div class="media-conflicts__actions">
47
+ <UiButton @click="emit('resolve', c.id, 'overwrite', undefined)">{{ t('media.conflicts.overwrite') }}</UiButton>
48
+ <UiButton variant="primary" @click="rename(c)">{{ t('media.conflicts.rename') }}</UiButton>
49
+ <UiButton @click="emit('resolve', c.id, 'skip', undefined)">{{ t('media.conflicts.skip') }}</UiButton>
50
+ </div>
51
+ </li>
52
+ </ul>
53
+ </UiDialog>
54
+ </template>
55
+
56
+ <style lang="scss" scoped>
57
+ .media-conflicts__all {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: var(--space-2);
61
+ flex-wrap: wrap;
62
+ margin-bottom: var(--space-3);
63
+ }
64
+ .media-conflicts__list {
65
+ list-style: none;
66
+ margin: 0;
67
+ padding: 0;
68
+ display: flex;
69
+ flex-direction: column;
70
+ gap: var(--space-2);
71
+ }
72
+ .media-conflicts__row {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: var(--space-2);
76
+ flex-wrap: wrap;
77
+ }
78
+ .media-conflicts__name {
79
+ font-weight: var(--weight-medium);
80
+ min-width: 8rem;
81
+ }
82
+ .media-conflicts__actions {
83
+ display: flex;
84
+ gap: var(--space-2);
85
+ margin-left: auto;
86
+ }
87
+ </style>
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch } from 'vue'
3
+ import { humanizeSize, type LibraryFile } from '../utils/library'
4
+
5
+ // Fullscreen-ish preview + general info for a single file. Images additionally expose an editable alt
6
+ // text (the only field worth maintaining from the library); everything else is read-only metadata.
7
+ const props = defineProps<{ open: boolean; file: LibraryFile | null; busy?: boolean; error?: string | null }>()
8
+ const emit = defineEmits<{ 'update:open': [boolean]; save: [string] }>()
9
+ const { t, lang } = useT()
10
+
11
+ const isImage = computed(() => props.file?.mime.startsWith('image/') ?? false)
12
+ const ext = computed(() => (props.file?.filename.split('.').pop() ?? '').toUpperCase())
13
+ const dims = computed(() => (props.file?.width && props.file?.height ? `${props.file.width}×${props.file.height}` : '—'))
14
+ const uploaded = computed(() => {
15
+ const c = props.file?.createdAt
16
+ if (!c) return '—'
17
+ const d = new Date(c)
18
+ return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString(lang.value)
19
+ })
20
+
21
+ const alt = ref('')
22
+ // Seed (and re-seed) the draft whenever the dialog opens — immediate so a viewer mounted already-open
23
+ // (or re-opened on a different file) shows the current alt rather than a stale/empty value.
24
+ watch(() => props.open, (o) => { if (o) alt.value = props.file?.alt ?? '' }, { immediate: true })
25
+ const dirty = computed(() => isImage.value && alt.value !== (props.file?.alt ?? ''))
26
+ function save() { if (dirty.value && !props.busy) emit('save', alt.value) }
27
+ </script>
28
+
29
+ <template>
30
+ <UiDialog :open="open" size="xl" :title="file?.filename ?? ''" @update:open="(v) => emit('update:open', v)">
31
+ <div v-if="file" class="media-viewer">
32
+ <div class="media-viewer__preview">
33
+ <img v-if="isImage" :src="file.src" :srcset="file.srcset" sizes="(max-width: 60rem) 100vw, 60rem" :alt="file.alt ?? file.filename" />
34
+ <span v-else class="media-viewer__ext" aria-hidden="true">{{ ext }}</span>
35
+ </div>
36
+ <aside class="media-viewer__details">
37
+ <dl class="media-viewer__info">
38
+ <div><dt>{{ t('media.colType') }}</dt><dd>{{ file.mime }}</dd></div>
39
+ <div><dt>{{ t('media.colSize') }}</dt><dd>{{ humanizeSize(file.size) }}</dd></div>
40
+ <div v-if="isImage"><dt>{{ t('media.colDimensions') }}</dt><dd>{{ dims }}</dd></div>
41
+ <div><dt>{{ t('mediaViewer.folder') }}</dt><dd>{{ file.folder || '/' }}</dd></div>
42
+ <div><dt>{{ t('mediaViewer.uploaded') }}</dt><dd>{{ uploaded }}</dd></div>
43
+ </dl>
44
+ <UiField v-if="isImage" :label="t('mediaViewer.alt')" :hint="t('mediaViewer.altHint')">
45
+ <template #default="f">
46
+ <UiTextInput v-model="alt" v-bind="f" @keydown.enter="save" />
47
+ </template>
48
+ </UiField>
49
+ <UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
50
+ <!-- Optional per-file extra panel (e.g. proofing comments). Empty by default. -->
51
+ <slot name="extra" :file="file" />
52
+ </aside>
53
+ </div>
54
+ <template v-if="file" #footer>
55
+ <UiButton :disabled="busy" @click="emit('update:open', false)">{{ t('common.close') }}</UiButton>
56
+ <UiButton v-if="isImage" variant="primary" :disabled="busy || !dirty" @click="save">{{ t('common.save') }}</UiButton>
57
+ </template>
58
+ </UiDialog>
59
+ </template>
60
+
61
+ <style lang="scss" scoped>
62
+ .media-viewer {
63
+ display: grid;
64
+ grid-template-columns: minmax(0, 1fr) minmax(0, 18rem);
65
+ gap: var(--space-4);
66
+ align-items: start;
67
+ }
68
+ @media (max-width: 48rem) {
69
+ .media-viewer { grid-template-columns: 1fr; }
70
+ }
71
+ .media-viewer__preview {
72
+ display: grid;
73
+ place-items: center;
74
+ min-height: 16rem;
75
+ max-height: 70svh;
76
+ overflow: hidden;
77
+ background: var(--color-bg);
78
+ border: 1px solid var(--color-border);
79
+ border-radius: var(--radius-md);
80
+ }
81
+ .media-viewer__preview img { max-width: 100%; max-height: 70svh; object-fit: contain; }
82
+ .media-viewer__ext { padding: var(--space-7); font-size: var(--text-xl); font-weight: var(--weight-bold); color: var(--color-text-muted); }
83
+ .media-viewer__details { display: flex; flex-direction: column; gap: var(--space-4); }
84
+ .media-viewer__info { display: flex; flex-direction: column; gap: var(--space-2); margin: 0; }
85
+ .media-viewer__info > div { display: flex; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
86
+ .media-viewer__info dt { color: var(--color-text-muted); }
87
+ .media-viewer__info dd { margin: 0; text-align: right; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
88
+ </style>