@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.
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +234 -0
- package/kestrel.config.ts +28 -0
- package/layers/access/nuxt.config.ts +5 -0
- package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
- package/layers/access/server/middleware/access-guard.ts +30 -0
- package/layers/access/server/types.d.ts +8 -0
- package/layers/access/server/utils/csrf.ts +39 -0
- package/layers/access/server/utils/grant-registry.ts +36 -0
- package/layers/access/server/utils/guard.ts +44 -0
- package/layers/access/server/utils/ip-allowlist.ts +76 -0
- package/layers/access/server/utils/policy.ts +98 -0
- package/layers/access/server/utils/public-resources.ts +11 -0
- package/layers/access/server/utils/render-context.ts +41 -0
- package/layers/access/server/utils/require-admin.ts +15 -0
- package/layers/admin/app/components/AdminAccount.vue +212 -0
- package/layers/admin/app/components/AdminNav.vue +44 -0
- package/layers/admin/app/components/BlockFields.vue +68 -0
- package/layers/admin/app/components/BlockPreview.vue +401 -0
- package/layers/admin/app/components/BlockTree.vue +433 -0
- package/layers/admin/app/components/BlocksBody.vue +162 -0
- package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
- package/layers/admin/app/components/CollectionEditor.vue +203 -0
- package/layers/admin/app/components/CollectionList.vue +1091 -0
- package/layers/admin/app/components/EditorStatus.vue +199 -0
- package/layers/admin/app/components/EditorUnsupported.vue +28 -0
- package/layers/admin/app/components/FieldsBody.vue +23 -0
- package/layers/admin/app/components/LocaleBar.vue +105 -0
- package/layers/admin/app/components/PageFields.vue +101 -0
- package/layers/admin/app/components/PageFieldsPane.vue +39 -0
- package/layers/admin/app/components/SeoFields.vue +169 -0
- package/layers/admin/app/components/SingletonEditor.vue +92 -0
- package/layers/admin/app/composables/useAuth.ts +68 -0
- package/layers/admin/app/composables/useBlockTree.ts +95 -0
- package/layers/admin/app/composables/useBlocks.ts +7 -0
- package/layers/admin/app/composables/useCachedResource.ts +35 -0
- package/layers/admin/app/composables/useCollectionOps.ts +53 -0
- package/layers/admin/app/composables/useCollections.ts +7 -0
- package/layers/admin/app/composables/useContentLocales.ts +23 -0
- package/layers/admin/app/composables/useEditForm.ts +370 -0
- package/layers/admin/app/composables/useLinkResolver.ts +40 -0
- package/layers/admin/app/composables/useListColumns.ts +53 -0
- package/layers/admin/app/composables/useListUrlState.ts +53 -0
- package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
- package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
- package/layers/admin/app/composables/usePublishStatus.ts +98 -0
- package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
- package/layers/admin/app/composables/useTheme.ts +36 -0
- package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
- package/layers/admin/app/layouts/admin.vue +267 -0
- package/layers/admin/app/middleware/admin-auth.ts +4 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
- package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
- package/layers/admin/app/pages/admin/index.vue +118 -0
- package/layers/admin/app/pages/admin/login.vue +68 -0
- package/layers/admin/app/pages/admin/media.vue +28 -0
- package/layers/admin/app/pages/admin/references.vue +146 -0
- package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
- package/layers/admin/app/plugins/reauth.client.ts +20 -0
- package/layers/admin/app/utils/admin-style-guard.ts +20 -0
- package/layers/admin/app/utils/block-tree.ts +214 -0
- package/layers/admin/app/utils/collection-ops.ts +44 -0
- package/layers/admin/app/utils/dead-refs.ts +30 -0
- package/layers/admin/app/utils/edit-form.ts +223 -0
- package/layers/admin/app/utils/editor-expose.ts +26 -0
- package/layers/admin/app/utils/editor-form-context.ts +83 -0
- package/layers/admin/app/utils/editor-registry.ts +20 -0
- package/layers/admin/app/utils/field-keys.ts +11 -0
- package/layers/admin/app/utils/list-columns.ts +141 -0
- package/layers/admin/app/utils/list-query.ts +96 -0
- package/layers/admin/app/utils/nav-active.ts +9 -0
- package/layers/admin/app/utils/populate-blocks.ts +288 -0
- package/layers/admin/app/utils/preview-channel.ts +62 -0
- package/layers/admin/app/utils/preview-viewport.ts +80 -0
- package/layers/admin/app/utils/reauth.ts +68 -0
- package/layers/admin/app/utils/record-title.ts +15 -0
- package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
- package/layers/admin/app/utils/safe-redirect.ts +12 -0
- package/layers/admin/nuxt.config.ts +17 -0
- package/layers/auth/nuxt.config.ts +1 -0
- package/layers/auth/server/api/auth/login.post.ts +38 -0
- package/layers/auth/server/api/auth/logout.post.ts +9 -0
- package/layers/auth/server/api/auth/session.get.ts +1 -0
- package/layers/auth/server/utils/admin-credential.ts +42 -0
- package/layers/auth/server/utils/client-ip.ts +88 -0
- package/layers/auth/server/utils/login-throttle.ts +126 -0
- package/layers/auth/server/utils/password.ts +52 -0
- package/layers/auth/server/utils/session-cookie.ts +44 -0
- package/layers/auth/server/utils/session-epoch.ts +96 -0
- package/layers/auth/server/utils/session.ts +85 -0
- package/layers/collections/nuxt.config.ts +1 -0
- package/layers/collections/server/collections/pages.ts +19 -0
- package/layers/collections/server/plugins/01.register.ts +32 -0
- package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
- package/layers/collections/server/utils/populate-relations.ts +66 -0
- package/layers/core/app/utils/bulk-actions.ts +9 -0
- package/layers/core/app/utils/filter-ops.ts +98 -0
- package/layers/core/app/utils/list-limits.ts +27 -0
- package/layers/core/app/utils/locale-path.ts +12 -0
- package/layers/core/app/utils/slugify.ts +16 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
- package/layers/core/modules/auto-discovery/index.ts +44 -0
- package/layers/core/modules/auto-discovery/scan.ts +78 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
- package/layers/core/modules/kestrel/index.ts +86 -0
- package/layers/core/nuxt.config.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
- package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
- package/layers/core/server/api/[collection]/[id].get.ts +8 -0
- package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
- package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
- package/layers/core/server/api/[collection]/index.get.ts +19 -0
- package/layers/core/server/api/[collection]/index.post.ts +8 -0
- package/layers/core/server/api/[collection]/index.put.ts +7 -0
- package/layers/core/server/api/[collection]/options.get.ts +18 -0
- package/layers/core/server/api/[collection]/translations.get.ts +25 -0
- package/layers/core/server/api/collections/[name].get.ts +4 -0
- package/layers/core/server/api/collections/index.get.ts +3 -0
- package/layers/core/server/api/references/broken.get.ts +3 -0
- package/layers/core/server/api/references/referrers.get.ts +24 -0
- package/layers/core/server/database/record-refs.ts +20 -0
- package/layers/core/server/plugins/00.migrate.ts +11 -0
- package/layers/core/server/plugins/02.schema-sync.ts +63 -0
- package/layers/core/server/plugins/03.record-refs.ts +12 -0
- package/layers/core/server/schema/bootstrap.ts +41 -0
- package/layers/core/server/schema/desired.ts +99 -0
- package/layers/core/server/schema/dev-sync.ts +23 -0
- package/layers/core/server/schema/dialect.ts +59 -0
- package/layers/core/server/schema/diff.ts +110 -0
- package/layers/core/server/schema/introspect.ts +67 -0
- package/layers/core/server/schema/model.ts +61 -0
- package/layers/core/server/schema/render-sqlite.ts +89 -0
- package/layers/core/server/schema/sync.ts +201 -0
- package/layers/core/server/tasks/db/migrate.ts +54 -0
- package/layers/core/server/utils/blocks.ts +6 -0
- package/layers/core/server/utils/collection-types.ts +27 -0
- package/layers/core/server/utils/crud.ts +577 -0
- package/layers/core/server/utils/db.ts +26 -0
- package/layers/core/server/utils/defineCollection.ts +207 -0
- package/layers/core/server/utils/duplicate.ts +162 -0
- package/layers/core/server/utils/field-layout.ts +125 -0
- package/layers/core/server/utils/filter-predicate.ts +101 -0
- package/layers/core/server/utils/http.ts +50 -0
- package/layers/core/server/utils/if-unmodified.ts +13 -0
- package/layers/core/server/utils/kestrel-config.ts +410 -0
- package/layers/core/server/utils/key-lock.ts +33 -0
- package/layers/core/server/utils/locale.ts +53 -0
- package/layers/core/server/utils/page-route.ts +58 -0
- package/layers/core/server/utils/page-slug.ts +127 -0
- package/layers/core/server/utils/picker.ts +82 -0
- package/layers/core/server/utils/populate.ts +48 -0
- package/layers/core/server/utils/prerender-memo.ts +23 -0
- package/layers/core/server/utils/read-capture.ts +37 -0
- package/layers/core/server/utils/record-ref-index.ts +200 -0
- package/layers/core/server/utils/registry.ts +24 -0
- package/layers/core/server/utils/resolve-scope.ts +79 -0
- package/layers/core/server/utils/seo.ts +24 -0
- package/layers/core/server/utils/serialize-collection.ts +149 -0
- package/layers/core/server/utils/server-config.ts +18 -0
- package/layers/core/server/utils/sql.ts +2 -0
- package/layers/core/server/utils/storage.local.ts +117 -0
- package/layers/core/server/utils/storage.s3.ts +197 -0
- package/layers/core/server/utils/storage.ts +90 -0
- package/layers/core/server/utils/write-events.ts +31 -0
- package/layers/fields/app/utils/condition.ts +71 -0
- package/layers/fields/app/utils/define-block.ts +13 -0
- package/layers/fields/app/utils/field-constraints.ts +17 -0
- package/layers/fields/app/utils/field-factories.ts +129 -0
- package/layers/fields/app/utils/field-meta.ts +33 -0
- package/layers/fields/app/utils/field-validate.ts +75 -0
- package/layers/fields/app/utils/field-value.ts +7 -0
- package/layers/fields/app/utils/richtext-links.ts +48 -0
- package/layers/fields/nuxt.config.ts +1 -0
- package/layers/fields/server/api/blocks/index.get.ts +9 -0
- package/layers/fields/server/field-registry/index.ts +248 -0
- package/layers/fields/server/field-registry/naming.ts +10 -0
- package/layers/fields/server/field-registry/sanitize.ts +40 -0
- package/layers/fields/server/field-registry/types.ts +19 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
- package/layers/fields/server/utils/block-ids.ts +43 -0
- package/layers/fields/server/utils/block-populate.ts +41 -0
- package/layers/fields/server/utils/buildCollection.ts +88 -0
- package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
- package/layers/fields/server/utils/buildTable.ts +107 -0
- package/layers/fields/server/utils/conditional-required.ts +25 -0
- package/layers/fields/server/utils/defineBlock.ts +86 -0
- package/layers/fields/server/utils/defineFieldType.ts +33 -0
- package/layers/fields/server/utils/extract-refs.ts +152 -0
- package/layers/fields/server/utils/field-populate.ts +68 -0
- package/layers/media/app/components/KestrelImg.vue +65 -0
- package/layers/media/app/components/MediaConflictDialog.vue +35 -0
- package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
- package/layers/media/app/components/MediaGrid.vue +113 -0
- package/layers/media/app/components/MediaImage.vue +70 -0
- package/layers/media/app/components/MediaLibrary.vue +451 -0
- package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
- package/layers/media/app/components/MediaPathBar.vue +163 -0
- package/layers/media/app/components/MediaPicker.vue +19 -0
- package/layers/media/app/components/MediaRenameDialog.vue +32 -0
- package/layers/media/app/components/MediaTable.vue +75 -0
- package/layers/media/app/components/MediaThumb.vue +25 -0
- package/layers/media/app/components/MediaToolbar.vue +90 -0
- package/layers/media/app/components/MediaUploadDialog.vue +87 -0
- package/layers/media/app/components/MediaViewer.vue +88 -0
- package/layers/media/app/components/field/Media.vue +261 -0
- package/layers/media/app/composables/useBlurUp.ts +55 -0
- package/layers/media/app/composables/useMediaClipboard.ts +14 -0
- package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
- package/layers/media/app/composables/useMediaDnd.ts +56 -0
- package/layers/media/app/composables/useMediaLibrary.ts +151 -0
- package/layers/media/app/composables/useMediaOps.ts +50 -0
- package/layers/media/app/composables/useMediaRelocate.ts +39 -0
- package/layers/media/app/composables/useMediaResolver.ts +50 -0
- package/layers/media/app/composables/useMediaUpload.ts +93 -0
- package/layers/media/app/composables/useMediaVariant.ts +34 -0
- package/layers/media/app/plugins/field-media.client.ts +6 -0
- package/layers/media/app/utils/dnd.ts +69 -0
- package/layers/media/app/utils/library.ts +79 -0
- package/layers/media/app/utils/ops.ts +62 -0
- package/layers/media/app/utils/picture-model.ts +125 -0
- package/layers/media/app/utils/thumbhash.ts +19 -0
- package/layers/media/app/utils/variant-name.ts +20 -0
- package/layers/media/nuxt.config.ts +3 -0
- package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
- package/layers/media/server/api/media/[id].delete.ts +18 -0
- package/layers/media/server/api/media/[id].get.ts +10 -0
- package/layers/media/server/api/media/[id].patch.ts +41 -0
- package/layers/media/server/api/media/backfill.post.ts +18 -0
- package/layers/media/server/api/media/copy.post.ts +7 -0
- package/layers/media/server/api/media/delete.post.ts +13 -0
- package/layers/media/server/api/media/folders.post.ts +18 -0
- package/layers/media/server/api/media/index.get.ts +15 -0
- package/layers/media/server/api/media/index.post.ts +157 -0
- package/layers/media/server/api/media/library.get.ts +17 -0
- package/layers/media/server/api/media/move.post.ts +7 -0
- package/layers/media/server/api/media/rename.post.ts +7 -0
- package/layers/media/server/api/media/resolve.get.ts +13 -0
- package/layers/media/server/collections/media-settings.ts +25 -0
- package/layers/media/server/collections/media.ts +29 -0
- package/layers/media/server/database/folders.ts +10 -0
- package/layers/media/server/middleware/ondemand-variants.ts +37 -0
- package/layers/media/server/plugins/02.register-media.ts +19 -0
- package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
- package/layers/media/server/plugins/04.variant-capture.ts +23 -0
- package/layers/media/server/schema-tables/folders.ts +4 -0
- package/layers/media/server/tasks/media/backfill.ts +18 -0
- package/layers/media/server/utils/backfill.ts +132 -0
- package/layers/media/server/utils/derive.ts +59 -0
- package/layers/media/server/utils/folder-paths.ts +42 -0
- package/layers/media/server/utils/folders.ts +12 -0
- package/layers/media/server/utils/library.ts +90 -0
- package/layers/media/server/utils/media-cleanup.ts +27 -0
- package/layers/media/server/utils/media-enabled.ts +23 -0
- package/layers/media/server/utils/media-ops.ts +118 -0
- package/layers/media/server/utils/media-write.ts +13 -0
- package/layers/media/server/utils/naming.ts +52 -0
- package/layers/media/server/utils/ondemand.ts +69 -0
- package/layers/media/server/utils/persist-upload.ts +62 -0
- package/layers/media/server/utils/populate.ts +47 -0
- package/layers/media/server/utils/record.ts +63 -0
- package/layers/media/server/utils/relocate-ops.ts +341 -0
- package/layers/media/server/utils/relocate-plan.ts +63 -0
- package/layers/media/server/utils/resolve.ts +86 -0
- package/layers/media/server/utils/sanitize-svg.ts +12 -0
- package/layers/media/server/utils/sniff.ts +68 -0
- package/layers/media/server/utils/storage-relocate.ts +139 -0
- package/layers/media/server/utils/translations.ts +19 -0
- package/layers/media/server/utils/usages.ts +66 -0
- package/layers/media/server/utils/variant-capture.ts +70 -0
- package/layers/media/server/utils/variants.ts +108 -0
- package/layers/public/app/app.vue +5 -0
- package/layers/public/app/components/BlockRenderer.vue +56 -0
- package/layers/public/app/components/KestrelLink.vue +26 -0
- package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
- package/layers/public/app/composables/public-page.ts +24 -0
- package/layers/public/app/error.vue +12 -0
- package/layers/public/app/layouts/default.vue +5 -0
- package/layers/public/app/pages/[...slug].vue +157 -0
- package/layers/public/app/pages/__kestrel/preview.vue +34 -0
- package/layers/public/app/utils/block-edit-context.ts +16 -0
- package/layers/public/app/utils/link-href.ts +46 -0
- package/layers/public/app/utils/page-head.ts +84 -0
- package/layers/public/app/utils/preview-protocol.ts +81 -0
- package/layers/public/app/utils/public-route.ts +46 -0
- package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
- package/layers/public/modules/deploy-output/index.ts +82 -0
- package/layers/public/modules/prerender-routes/discover.ts +37 -0
- package/layers/public/modules/prerender-routes/index.ts +85 -0
- package/layers/public/modules/prerender-routes/routes.ts +16 -0
- package/layers/public/modules/prune-media/index.ts +48 -0
- package/layers/public/modules/prune-media/prune-media.ts +81 -0
- package/layers/public/nuxt.config.ts +11 -0
- package/layers/public/server/api/links/resolve.get.ts +19 -0
- package/layers/public/server/api/publish-status.get.ts +44 -0
- package/layers/public/server/api/route.get.ts +18 -0
- package/layers/public/server/database/publish-deps.ts +12 -0
- package/layers/public/server/database/publish-status.ts +19 -0
- package/layers/public/server/plugins/02.register-links.ts +17 -0
- package/layers/public/server/plugins/zz.publish.ts +63 -0
- package/layers/public/server/routes/llms.txt.get.ts +72 -0
- package/layers/public/server/routes/robots.txt.get.ts +8 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
- package/layers/public/server/schema-tables/publish-deps.ts +4 -0
- package/layers/public/server/schema-tables/publish-status.ts +4 -0
- package/layers/public/server/tasks/publish/run.ts +26 -0
- package/layers/public/server/utils/link-resolve.ts +35 -0
- package/layers/public/server/utils/llms.ts +39 -0
- package/layers/public/server/utils/page-resolve.ts +86 -0
- package/layers/public/server/utils/populate-links.ts +66 -0
- package/layers/public/server/utils/publish/coalesce.ts +22 -0
- package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
- package/layers/public/server/utils/publish/deps.ts +87 -0
- package/layers/public/server/utils/publish/invalidation.ts +141 -0
- package/layers/public/server/utils/publish/publish-status.ts +54 -0
- package/layers/public/server/utils/publish/publisher.ts +291 -0
- package/layers/public/server/utils/publish/queue.ts +67 -0
- package/layers/public/server/utils/publish/route-for-record.ts +16 -0
- package/layers/public/server/utils/publish/route-keys.ts +13 -0
- package/layers/public/server/utils/site-url.ts +32 -0
- package/layers/public/server/utils/sitemap.ts +97 -0
- package/layers/ui/app/assets/scss/_base.scss +16 -0
- package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
- package/layers/ui/app/assets/scss/_mixins.scss +50 -0
- package/layers/ui/app/assets/scss/_reset.scss +65 -0
- package/layers/ui/app/assets/scss/_tokens.scss +183 -0
- package/layers/ui/app/assets/scss/main.scss +4 -0
- package/layers/ui/app/components/field/Boolean.vue +30 -0
- package/layers/ui/app/components/field/Choice.vue +68 -0
- package/layers/ui/app/components/field/Datetime.vue +96 -0
- package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
- package/layers/ui/app/components/field/Json.vue +63 -0
- package/layers/ui/app/components/field/Layout.vue +140 -0
- package/layers/ui/app/components/field/Link.vue +112 -0
- package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
- package/layers/ui/app/components/field/Number.vue +30 -0
- package/layers/ui/app/components/field/Relation.vue +49 -0
- package/layers/ui/app/components/field/Renderer.vue +30 -0
- package/layers/ui/app/components/field/Repeater.vue +460 -0
- package/layers/ui/app/components/field/Richtext.vue +26 -0
- package/layers/ui/app/components/field/Slug.vue +66 -0
- package/layers/ui/app/components/field/Text.vue +35 -0
- package/layers/ui/app/components/field/Unsupported.vue +34 -0
- package/layers/ui/app/components/ui/Alert.vue +50 -0
- package/layers/ui/app/components/ui/Brand.vue +22 -0
- package/layers/ui/app/components/ui/Button.vue +116 -0
- package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
- package/layers/ui/app/components/ui/Checkbox.vue +52 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
- package/layers/ui/app/components/ui/Combobox.vue +343 -0
- package/layers/ui/app/components/ui/DatePicker.vue +92 -0
- package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
- package/layers/ui/app/components/ui/Dialog.vue +107 -0
- package/layers/ui/app/components/ui/EmptyState.vue +46 -0
- package/layers/ui/app/components/ui/Field.vue +57 -0
- package/layers/ui/app/components/ui/Fieldset.vue +64 -0
- package/layers/ui/app/components/ui/Icon.vue +44 -0
- package/layers/ui/app/components/ui/Menu.vue +54 -0
- package/layers/ui/app/components/ui/NumberInput.vue +173 -0
- package/layers/ui/app/components/ui/Richtext.vue +119 -0
- package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
- package/layers/ui/app/components/ui/Select.vue +51 -0
- package/layers/ui/app/components/ui/TextInput.vue +110 -0
- package/layers/ui/app/components/ui/Textarea.vue +38 -0
- package/layers/ui/app/components/ui/TimeInput.vue +17 -0
- package/layers/ui/app/components/ui/Toasts.vue +103 -0
- package/layers/ui/app/components/ui/Tooltip.vue +45 -0
- package/layers/ui/app/composables/useAdminLang.ts +8 -0
- package/layers/ui/app/composables/useDragReorder.ts +41 -0
- package/layers/ui/app/composables/useEchoGuard.ts +28 -0
- package/layers/ui/app/composables/useLinkField.ts +70 -0
- package/layers/ui/app/composables/useRecordOptions.ts +84 -0
- package/layers/ui/app/composables/useRepeater.ts +99 -0
- package/layers/ui/app/composables/useT.ts +32 -0
- package/layers/ui/app/composables/useToast.ts +46 -0
- package/layers/ui/app/i18n/de.ts +406 -0
- package/layers/ui/app/i18n/en.ts +440 -0
- package/layers/ui/app/utils/boolean-field.ts +10 -0
- package/layers/ui/app/utils/field-component.ts +22 -0
- package/layers/ui/app/utils/field-empty.ts +21 -0
- package/layers/ui/app/utils/field-registry.ts +36 -0
- package/layers/ui/app/utils/humanize.ts +14 -0
- package/layers/ui/app/utils/icons.ts +147 -0
- package/layers/ui/app/utils/iso-date.ts +18 -0
- package/layers/ui/app/utils/localized.ts +13 -0
- package/layers/ui/app/utils/record-context.ts +15 -0
- package/layers/ui/app/utils/reorder.ts +8 -0
- package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
- package/layers/ui/app/utils/useFieldA11y.ts +40 -0
- package/layers/ui/nuxt.config.ts +5 -0
- package/nuxt.config.ts +105 -0
- package/package.json +122 -0
- package/scripts/hash-password.mjs +26 -0
- package/scripts/typecheck.mjs +51 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
// `$attrs` (id / aria-* from UiField, autocomplete, …) is bound to the inner <input>, not the wrapper,
|
|
5
|
+
// so the field-label association + error wiring keep working with the reveal button in place.
|
|
6
|
+
defineOptions({ inheritAttrs: false })
|
|
7
|
+
|
|
8
|
+
const props = withDefaults(
|
|
9
|
+
defineProps<{
|
|
10
|
+
type?: 'text' | 'password' | 'email' | 'url' | 'search' | 'tel'
|
|
11
|
+
placeholder?: string
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
/** Force the show/hide reveal affordance on a non-password input. Password inputs get it automatically. */
|
|
14
|
+
reveal?: boolean
|
|
15
|
+
/** Tighter padding for dense chrome (toolbars). Still meets the AA minimum target size. */
|
|
16
|
+
slim?: boolean
|
|
17
|
+
}>(),
|
|
18
|
+
{ type: 'text', disabled: false, reveal: false, slim: false },
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const model = defineModel<string | null>()
|
|
22
|
+
const { t } = useT()
|
|
23
|
+
|
|
24
|
+
// Reveal is automatic for password inputs (the only consumer today), opt-in elsewhere via `reveal`.
|
|
25
|
+
const revealable = computed(() => props.type === 'password' || props.reveal)
|
|
26
|
+
const revealed = ref(false)
|
|
27
|
+
// While revealed, a password input becomes a text input so the typed value is legible; other types
|
|
28
|
+
// are unaffected. The control is a switch — the rendered type tracks `revealed` exactly.
|
|
29
|
+
const inputType = computed(() => (revealable.value && revealed.value ? 'text' : props.type))
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<div class="ui-input-wrap">
|
|
34
|
+
<input
|
|
35
|
+
v-model="model"
|
|
36
|
+
:type="inputType"
|
|
37
|
+
:placeholder="placeholder"
|
|
38
|
+
:disabled="disabled"
|
|
39
|
+
class="ui-input"
|
|
40
|
+
:class="{ 'ui-input--revealable': revealable, 'ui-input--slim': slim }"
|
|
41
|
+
v-bind="$attrs"
|
|
42
|
+
>
|
|
43
|
+
<button
|
|
44
|
+
v-if="revealable"
|
|
45
|
+
type="button"
|
|
46
|
+
class="ui-input__reveal"
|
|
47
|
+
:disabled="disabled"
|
|
48
|
+
:aria-label="revealed ? t('input.hidePassword') : t('input.showPassword')"
|
|
49
|
+
@click="revealed = !revealed"
|
|
50
|
+
>
|
|
51
|
+
<UiIcon :name="revealed ? 'eye-off' : 'eye'" size="1.125rem" />
|
|
52
|
+
</button>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<style lang="scss">
|
|
57
|
+
@use '../../assets/scss/mixins' as *;
|
|
58
|
+
|
|
59
|
+
.ui-input-wrap {
|
|
60
|
+
position: relative;
|
|
61
|
+
display: block;
|
|
62
|
+
width: 100%;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.ui-input {
|
|
66
|
+
@include input-base;
|
|
67
|
+
width: 100%;
|
|
68
|
+
|
|
69
|
+
&::placeholder {
|
|
70
|
+
color: var(--color-text-muted);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
&--slim {
|
|
74
|
+
@include input-slim;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Leave room for the inset reveal button so the value never sits under it. Declared after `--slim` so a
|
|
78
|
+
// slim revealable input keeps the reveal gutter (equal specificity → source order wins).
|
|
79
|
+
&--revealable {
|
|
80
|
+
padding-inline-end: 2.5rem;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.ui-input__reveal {
|
|
85
|
+
@include focus-ring;
|
|
86
|
+
position: absolute;
|
|
87
|
+
inset-block: 1px;
|
|
88
|
+
inset-inline-end: 1px;
|
|
89
|
+
display: inline-flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: center;
|
|
92
|
+
// ≥24×24 target (WCAG 2.5.8): full input height, comfortable hit width.
|
|
93
|
+
min-inline-size: 2.25rem;
|
|
94
|
+
padding-inline: var(--space-2);
|
|
95
|
+
border: 0;
|
|
96
|
+
border-start-end-radius: var(--radius-md);
|
|
97
|
+
border-end-end-radius: var(--radius-md);
|
|
98
|
+
background: transparent;
|
|
99
|
+
color: var(--color-text-muted);
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
|
|
102
|
+
&:hover:not(:disabled) {
|
|
103
|
+
color: var(--color-text);
|
|
104
|
+
}
|
|
105
|
+
&:disabled {
|
|
106
|
+
cursor: not-allowed;
|
|
107
|
+
opacity: 0.6;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
withDefaults(
|
|
3
|
+
defineProps<{
|
|
4
|
+
rows?: number
|
|
5
|
+
placeholder?: string
|
|
6
|
+
disabled?: boolean
|
|
7
|
+
}>(),
|
|
8
|
+
{ rows: 4, disabled: false },
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
const model = defineModel<string | null>()
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<textarea
|
|
16
|
+
v-model="model"
|
|
17
|
+
:rows="rows"
|
|
18
|
+
:placeholder="placeholder"
|
|
19
|
+
:disabled="disabled"
|
|
20
|
+
class="ui-textarea"
|
|
21
|
+
/>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<style lang="scss">
|
|
25
|
+
@use '../../assets/scss/mixins' as *;
|
|
26
|
+
|
|
27
|
+
.ui-textarea {
|
|
28
|
+
@include input-base;
|
|
29
|
+
width: 100%;
|
|
30
|
+
font-family: inherit;
|
|
31
|
+
line-height: var(--leading-normal);
|
|
32
|
+
resize: vertical;
|
|
33
|
+
|
|
34
|
+
&::placeholder {
|
|
35
|
+
color: var(--color-text-muted);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
withDefaults(defineProps<{ disabled?: boolean }>(), { disabled: false })
|
|
3
|
+
|
|
4
|
+
const model = defineModel<string | null>()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<input v-model="model" type="time" :disabled="disabled" class="ui-time">
|
|
9
|
+
</template>
|
|
10
|
+
|
|
11
|
+
<style lang="scss">
|
|
12
|
+
@use '../../assets/scss/mixins' as *;
|
|
13
|
+
|
|
14
|
+
.ui-time {
|
|
15
|
+
@include input-base;
|
|
16
|
+
}
|
|
17
|
+
</style>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// App-wide transient notifications. Teleported to <body> so it floats above any layout/dialog;
|
|
3
|
+
// the queue lives in `useToast` (unit-tested), this is the thin render of it.
|
|
4
|
+
const toasts = useToast()
|
|
5
|
+
const { t } = useT()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<Teleport to="body">
|
|
10
|
+
<!-- Two PERSISTENT live regions that exist before any toast: a screen reader only announces changes to
|
|
11
|
+
a live region already in the DOM, so a role placed on the freshly-inserted toast node is missed.
|
|
12
|
+
The visual cards below carry no live role; these mirror their text (errors → assertive). -->
|
|
13
|
+
<div class="ui-toasts__live" aria-live="polite" role="status">
|
|
14
|
+
<template v-for="toast in toasts.items" :key="`p-${toast.id}`">
|
|
15
|
+
<div v-if="toast.type !== 'error'">{{ toast.message }}</div>
|
|
16
|
+
</template>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="ui-toasts__live" aria-live="assertive" role="alert">
|
|
19
|
+
<template v-for="toast in toasts.items" :key="`a-${toast.id}`">
|
|
20
|
+
<div v-if="toast.type === 'error'">{{ toast.message }}</div>
|
|
21
|
+
</template>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="ui-toasts" role="region" :aria-label="t('toast.region')">
|
|
24
|
+
<TransitionGroup name="ui-toast">
|
|
25
|
+
<div
|
|
26
|
+
v-for="toast in toasts.items"
|
|
27
|
+
:key="toast.id"
|
|
28
|
+
class="ui-toast"
|
|
29
|
+
:class="`ui-toast--${toast.type}`"
|
|
30
|
+
>
|
|
31
|
+
<span class="ui-toast__msg">{{ toast.message }}</span>
|
|
32
|
+
<button type="button" class="ui-toast__close" :aria-label="t('toast.dismiss')" @click="toasts.dismiss(toast.id)">
|
|
33
|
+
<UiIcon name="x" size="0.875rem" />
|
|
34
|
+
</button>
|
|
35
|
+
</div>
|
|
36
|
+
</TransitionGroup>
|
|
37
|
+
</div>
|
|
38
|
+
</Teleport>
|
|
39
|
+
</template>
|
|
40
|
+
|
|
41
|
+
<style lang="scss">
|
|
42
|
+
@use '../../assets/scss/mixins' as *;
|
|
43
|
+
|
|
44
|
+
.ui-toasts__live { @include sr-only; }
|
|
45
|
+
|
|
46
|
+
.ui-toasts {
|
|
47
|
+
position: fixed;
|
|
48
|
+
inset-block-end: var(--space-5);
|
|
49
|
+
inset-inline-end: var(--space-5);
|
|
50
|
+
z-index: var(--z-toast);
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
gap: var(--space-2);
|
|
54
|
+
width: min(22rem, calc(100vw - var(--space-8)));
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.ui-toast {
|
|
59
|
+
pointer-events: auto;
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: var(--space-3);
|
|
63
|
+
padding: var(--space-3) var(--space-3) var(--space-3) var(--space-4);
|
|
64
|
+
background: var(--color-surface);
|
|
65
|
+
color: var(--color-text);
|
|
66
|
+
border: 1px solid var(--color-border);
|
|
67
|
+
border-inline-start: 4px solid var(--color-border-strong);
|
|
68
|
+
border-radius: var(--radius-md);
|
|
69
|
+
box-shadow: var(--shadow-md);
|
|
70
|
+
font-size: var(--text-sm);
|
|
71
|
+
|
|
72
|
+
&--success { border-inline-start-color: var(--color-success); }
|
|
73
|
+
&--error { border-inline-start-color: var(--color-danger); }
|
|
74
|
+
&--info { border-inline-start-color: var(--color-primary); }
|
|
75
|
+
|
|
76
|
+
&__msg { flex: 1 1 auto; min-width: 0; }
|
|
77
|
+
|
|
78
|
+
&__close {
|
|
79
|
+
@include focus-ring;
|
|
80
|
+
flex-shrink: 0;
|
|
81
|
+
display: inline-flex;
|
|
82
|
+
padding: var(--space-1);
|
|
83
|
+
border: 0;
|
|
84
|
+
border-radius: var(--radius-sm);
|
|
85
|
+
background: transparent;
|
|
86
|
+
color: var(--color-text-muted);
|
|
87
|
+
|
|
88
|
+
&:hover { background: var(--color-hover); color: var(--color-text); }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.ui-toast-enter-active,
|
|
93
|
+
.ui-toast-leave-active {
|
|
94
|
+
transition:
|
|
95
|
+
opacity var(--motion-base) var(--ease-standard),
|
|
96
|
+
transform var(--motion-base) var(--ease-standard);
|
|
97
|
+
}
|
|
98
|
+
.ui-toast-enter-from,
|
|
99
|
+
.ui-toast-leave-to {
|
|
100
|
+
opacity: 0;
|
|
101
|
+
transform: translateY(var(--space-3));
|
|
102
|
+
}
|
|
103
|
+
</style>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { TooltipProvider, TooltipRoot, TooltipTrigger, TooltipPortal, TooltipContent } from 'reka-ui'
|
|
3
|
+
|
|
4
|
+
// A hover/focus tooltip primitive (auto-imported as `UiTooltip`). The default slot is the trigger
|
|
5
|
+
// (`as-child`, so it must be a single element); the `content` slot is the floating panel. Self-contained
|
|
6
|
+
// `TooltipProvider` so it works anywhere without an app-root provider. PORTALED (escapes the action-bar's
|
|
7
|
+
// `overflow`/stacking context), so its styles are global — see below.
|
|
8
|
+
withDefaults(defineProps<{ side?: 'top' | 'right' | 'bottom' | 'left'; delay?: number }>(), {
|
|
9
|
+
side: 'top',
|
|
10
|
+
delay: 150,
|
|
11
|
+
})
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<TooltipProvider :delay-duration="delay">
|
|
16
|
+
<TooltipRoot>
|
|
17
|
+
<TooltipTrigger as-child>
|
|
18
|
+
<slot />
|
|
19
|
+
</TooltipTrigger>
|
|
20
|
+
<TooltipPortal>
|
|
21
|
+
<TooltipContent class="ui-tooltip" :side="side" :side-offset="6" :collision-padding="8">
|
|
22
|
+
<slot name="content" />
|
|
23
|
+
</TooltipContent>
|
|
24
|
+
</TooltipPortal>
|
|
25
|
+
</TooltipRoot>
|
|
26
|
+
</TooltipProvider>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<style lang="scss">
|
|
30
|
+
// Not scoped: Reka teleports the content to <body>, where a scoped data-v selector no longer matches
|
|
31
|
+
// (the same reason UiMenu and the other portaled overlays keep global styles — see the
|
|
32
|
+
// `reka-portal-scoped-styles` note). `--z-toast` keeps it above dropdowns/dialogs it may overlap.
|
|
33
|
+
.ui-tooltip {
|
|
34
|
+
max-width: 22rem;
|
|
35
|
+
padding: var(--space-2) var(--space-3);
|
|
36
|
+
background: var(--color-surface);
|
|
37
|
+
color: var(--color-text);
|
|
38
|
+
border: 1px solid var(--color-border);
|
|
39
|
+
border-radius: var(--radius-md);
|
|
40
|
+
box-shadow: var(--shadow-md);
|
|
41
|
+
font-size: var(--text-sm);
|
|
42
|
+
line-height: var(--leading-normal);
|
|
43
|
+
z-index: var(--z-toast);
|
|
44
|
+
}
|
|
45
|
+
</style>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The admin-UI language preference (chrome strings), cookie-backed so it persists and is SSR-safe.
|
|
3
|
+
* Deliberately distinct from the content locale (`useContentLocales` / the editor LocaleBar): one is
|
|
4
|
+
* the language of the dashboard, the other is the language of the content being edited.
|
|
5
|
+
*/
|
|
6
|
+
export function useAdminLang() {
|
|
7
|
+
return useCookie<string>('kestrel-admin-lang', { default: () => 'en' })
|
|
8
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shared HTML drag-and-drop REORDER interaction (chip lists, media tiles, …): owns the `dragIndex` /
|
|
5
|
+
* `overIndex` state and the five dnd handlers, so a widget only supplies `disabled` and a `commit(from,
|
|
6
|
+
* to)` that actually reorders its model (and announces the move). The index passed to the handlers is
|
|
7
|
+
* whatever the widget renders by (a model index for the combobox, a resolved/subsequence index for the
|
|
8
|
+
* media field) — `commit` maps it.
|
|
9
|
+
*/
|
|
10
|
+
export function useDragReorder(opts: { disabled: () => boolean; commit: (from: number, to: number) => void }) {
|
|
11
|
+
const dragIndex = ref<number | null>(null)
|
|
12
|
+
const overIndex = ref<number | null>(null)
|
|
13
|
+
|
|
14
|
+
function onDragStart(index: number, event: DragEvent) {
|
|
15
|
+
if (opts.disabled()) return
|
|
16
|
+
dragIndex.value = index
|
|
17
|
+
if (event.dataTransfer) {
|
|
18
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
19
|
+
event.dataTransfer.setData('text/plain', String(index)) // Firefox needs data set to start the drag
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function onDragEnter(index: number) {
|
|
23
|
+
if (dragIndex.value !== null) overIndex.value = index
|
|
24
|
+
}
|
|
25
|
+
// Clear the drop highlight only when the cursor leaves the list entirely (not on item-to-item moves).
|
|
26
|
+
function onDragLeave(event: DragEvent) {
|
|
27
|
+
if (!(event.currentTarget as Element).contains(event.relatedTarget as Node | null)) overIndex.value = null
|
|
28
|
+
}
|
|
29
|
+
function onDrop(index: number) {
|
|
30
|
+
if (opts.disabled() || dragIndex.value === null) return
|
|
31
|
+
if (dragIndex.value !== index) opts.commit(dragIndex.value, index)
|
|
32
|
+
dragIndex.value = null
|
|
33
|
+
overIndex.value = null
|
|
34
|
+
}
|
|
35
|
+
function onDragEnd() {
|
|
36
|
+
dragIndex.value = null
|
|
37
|
+
overIndex.value = null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { dragIndex, overIndex, onDragStart, onDragEnter, onDragLeave, onDrop, onDragEnd }
|
|
41
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { watch } from 'vue'
|
|
2
|
+
import type { Ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The shared "mirror a v-model into local reactive state, skip the echo of our own emit, reseed on a
|
|
6
|
+
* genuine external change" guard — used by the block tree, the repeater and the link field.
|
|
7
|
+
*
|
|
8
|
+
* It compares the incoming model against the CURRENT local snapshot, NOT a one-shot "last emitted" token.
|
|
9
|
+
* A captured token goes stale: an external undo→redo (or copy-from-locale) can land back on a
|
|
10
|
+
* previously-emitted value and be wrongly skipped, stranding local state at the pre-undo content. The
|
|
11
|
+
* own-emit echo still short-circuits (the model we just wrote equals our snapshot), while any genuine
|
|
12
|
+
* difference reseeds.
|
|
13
|
+
*
|
|
14
|
+
* @param current the local serializable snapshot (what we emit / compare against)
|
|
15
|
+
* @param reseed apply an external model value into local state
|
|
16
|
+
* @param empty the normalized form of a null/undefined model for the comparison (e.g. `[]` for a list)
|
|
17
|
+
*/
|
|
18
|
+
export function useEchoGuard<T>(model: Ref<T>, current: () => unknown, reseed: (v: T) => void, empty: unknown = null): void {
|
|
19
|
+
watch(model, (v) => {
|
|
20
|
+
// Own-emit fast path: writing `model.value` to our own reactive snapshot (e.g. `model.value =
|
|
21
|
+
// blocks.value`) resolves both sides to the SAME proxy, so a reference check catches it for free —
|
|
22
|
+
// skipping a full JSON.stringify of a potentially large tree on every keystroke. Excluded from
|
|
23
|
+
// null/undefined so the `empty` normalization below still runs for that case.
|
|
24
|
+
if (v !== null && v !== undefined && v === current()) return
|
|
25
|
+
if (JSON.stringify(v ?? empty) === JSON.stringify(current())) return
|
|
26
|
+
reseed(v)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ref, computed, watch } from 'vue'
|
|
2
|
+
import type { Ref } from 'vue'
|
|
3
|
+
import { useEchoGuard } from './useEchoGuard'
|
|
4
|
+
import type { LinkValue, LinkType } from '../../../core/server/utils/defineCollection'
|
|
5
|
+
|
|
6
|
+
export function useLinkField(
|
|
7
|
+
model: Ref<LinkValue | null | undefined>,
|
|
8
|
+
allowed: Ref<LinkType[]>,
|
|
9
|
+
) {
|
|
10
|
+
const initial = model.value
|
|
11
|
+
|
|
12
|
+
const currentType = ref<LinkType>(
|
|
13
|
+
initial?.type ?? allowed.value[0] ?? 'external',
|
|
14
|
+
)
|
|
15
|
+
const label = ref(initial?.label ?? '')
|
|
16
|
+
const url = ref(initial?.type === 'external' ? initial.url : '')
|
|
17
|
+
const email = ref(initial?.type === 'email' ? initial.email : '')
|
|
18
|
+
const tel = ref(initial?.type === 'tel' ? initial.tel : '')
|
|
19
|
+
const collection = ref<string | null>(initial?.type === 'internal' ? initial.collection : null)
|
|
20
|
+
const recordId = ref<number | null>(initial?.type === 'internal' ? initial.id : null)
|
|
21
|
+
const hash = ref(initial?.type === 'internal' ? (initial.hash ?? '') : '')
|
|
22
|
+
|
|
23
|
+
function build(): LinkValue | null {
|
|
24
|
+
const lbl = label.value.trim() || undefined
|
|
25
|
+
const type = currentType.value
|
|
26
|
+
if (type === 'external') {
|
|
27
|
+
const u = url.value.trim()
|
|
28
|
+
return u ? { type: 'external', url: u, ...(lbl ? { label: lbl } : {}) } : null
|
|
29
|
+
}
|
|
30
|
+
if (type === 'email') {
|
|
31
|
+
const e = email.value.trim()
|
|
32
|
+
return e ? { type: 'email', email: e, ...(lbl ? { label: lbl } : {}) } : null
|
|
33
|
+
}
|
|
34
|
+
if (type === 'tel') {
|
|
35
|
+
const t = tel.value.trim()
|
|
36
|
+
return t ? { type: 'tel', tel: t, ...(lbl ? { label: lbl } : {}) } : null
|
|
37
|
+
}
|
|
38
|
+
if (collection.value && recordId.value != null) {
|
|
39
|
+
const h = hash.value.trim().replace(/^#+/, '')
|
|
40
|
+
return { type: 'internal', collection: collection.value, id: recordId.value, ...(h ? { hash: h } : {}), ...(lbl ? { label: lbl } : {}) }
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function reseed(m: LinkValue | null | undefined) {
|
|
46
|
+
const v = m ?? null
|
|
47
|
+
if (v) currentType.value = v.type
|
|
48
|
+
label.value = v?.label ?? ''
|
|
49
|
+
url.value = v?.type === 'external' ? v.url : ''
|
|
50
|
+
email.value = v?.type === 'email' ? v.email : ''
|
|
51
|
+
tel.value = v?.type === 'tel' ? v.tel : ''
|
|
52
|
+
collection.value = v?.type === 'internal' ? v.collection : null
|
|
53
|
+
recordId.value = v?.type === 'internal' ? v.id : null
|
|
54
|
+
hash.value = v?.type === 'internal' ? (v.hash ?? '') : ''
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Non-immediate so setup never reassigns an equivalent model (no spurious mount emit).
|
|
58
|
+
watch([currentType, label, url, email, tel, collection, recordId, hash], () => { model.value = build() })
|
|
59
|
+
|
|
60
|
+
// Echo-guarded reseed via the shared primitive: drafts follow an external model change, but the
|
|
61
|
+
// build() write above is recognised as self-originated (build() === model) so it never loops.
|
|
62
|
+
useEchoGuard(model, () => build(), reseed)
|
|
63
|
+
|
|
64
|
+
const typeModel = computed<LinkType>({
|
|
65
|
+
get: () => currentType.value,
|
|
66
|
+
set: (v) => { if (v) currentType.value = v as LinkType },
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
return { currentType, label, url, email, tel, collection, recordId, hash, typeModel }
|
|
70
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ref, computed, watch, onUnmounted } from 'vue'
|
|
2
|
+
import type { Ref } from 'vue'
|
|
3
|
+
import type { FieldOption } from '../utils/field-component'
|
|
4
|
+
|
|
5
|
+
export function useRecordOptions(
|
|
6
|
+
collection: Ref<string>,
|
|
7
|
+
ids: Ref<number[]>,
|
|
8
|
+
locale: Ref<string>,
|
|
9
|
+
labelField?: Ref<string | undefined>,
|
|
10
|
+
) {
|
|
11
|
+
const cache = ref<Map<number, string>>(new Map())
|
|
12
|
+
const options = ref<FieldOption[]>([])
|
|
13
|
+
const loading = ref(false)
|
|
14
|
+
|
|
15
|
+
const selected = computed<FieldOption[]>(() =>
|
|
16
|
+
ids.value.map((value) => ({ value, label: cache.value.get(value) ?? `#${value}` })),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
function cacheOptions(opts: FieldOption[]) {
|
|
20
|
+
const next = new Map(cache.value)
|
|
21
|
+
for (const o of opts) next.set(o.value, o.label)
|
|
22
|
+
cache.value = next
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The endpoint returns { id, label }; map it to the combobox's { value, label }.
|
|
26
|
+
async function fetchOptions(query: Record<string, unknown>): Promise<FieldOption[]> {
|
|
27
|
+
const res = await $fetch<{ data: { id: number; label: string }[] }>(`/api/${collection.value}/options`, {
|
|
28
|
+
query: { label: labelField?.value, locale: locale.value, ...query },
|
|
29
|
+
})
|
|
30
|
+
return res.data.map((r) => ({ value: r.id, label: r.label }))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Labels are per-collection; drop stale options/cache on switch (runs before the resolve below).
|
|
34
|
+
watch(collection, () => {
|
|
35
|
+
cache.value = new Map()
|
|
36
|
+
options.value = []
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Resolve labels for any current ids we don't yet have cached. Depends on collection too, so it
|
|
40
|
+
// (re)resolves once the collection becomes available (a v-model prop can arrive after setup) and
|
|
41
|
+
// after a switch — not only when ids change.
|
|
42
|
+
watch([ids, collection], async () => {
|
|
43
|
+
const missing = ids.value.filter((value) => !cache.value.has(value))
|
|
44
|
+
const forCollection = collection.value
|
|
45
|
+
if (!missing.length || !forCollection) return
|
|
46
|
+
try {
|
|
47
|
+
const data = await fetchOptions({ ids: missing.join(',') })
|
|
48
|
+
if (collection.value === forCollection) cacheOptions(data) // discard if the collection switched mid-fetch
|
|
49
|
+
} catch {
|
|
50
|
+
// leave the unresolved ids as `#id` placeholders rather than throwing
|
|
51
|
+
}
|
|
52
|
+
}, { immediate: true })
|
|
53
|
+
|
|
54
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
55
|
+
let searchSeq = 0
|
|
56
|
+
|
|
57
|
+
function onSearch(term: string) {
|
|
58
|
+
if (timer) clearTimeout(timer)
|
|
59
|
+
// No collection → nothing to list. Bump the token so any in-flight fetch can't repopulate.
|
|
60
|
+
if (!collection.value) { ++searchSeq; options.value = []; loading.value = false; return }
|
|
61
|
+
timer = setTimeout(async () => {
|
|
62
|
+
const seq = ++searchSeq
|
|
63
|
+
const forCollection = collection.value
|
|
64
|
+
loading.value = true
|
|
65
|
+
try {
|
|
66
|
+
// An empty term lists the first page (records appear on focus, no typing needed).
|
|
67
|
+
const data = await fetchOptions(term.trim() ? { search: term } : {})
|
|
68
|
+
// ignore if a newer search superseded this one, or the collection switched mid-fetch
|
|
69
|
+
if (seq === searchSeq && collection.value === forCollection) {
|
|
70
|
+
options.value = data
|
|
71
|
+
cacheOptions(data)
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
if (seq === searchSeq) options.value = [] // failed search clears rather than showing stale matches
|
|
75
|
+
} finally {
|
|
76
|
+
if (seq === searchSeq) loading.value = false
|
|
77
|
+
}
|
|
78
|
+
}, 250)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
onUnmounted(() => { if (timer) clearTimeout(timer) })
|
|
82
|
+
|
|
83
|
+
return { options, selected, loading, onSearch }
|
|
84
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import type { Ref } from 'vue'
|
|
3
|
+
import type { FieldDef } from '../../../core/server/utils/defineCollection'
|
|
4
|
+
import { reorder } from '../utils/reorder'
|
|
5
|
+
import { useEchoGuard } from './useEchoGuard'
|
|
6
|
+
|
|
7
|
+
export interface UseRepeater {
|
|
8
|
+
rows: Ref<Record<string, unknown>[]>
|
|
9
|
+
keys: Ref<number[]>
|
|
10
|
+
addRow: () => void
|
|
11
|
+
removeRow: (i: number) => void
|
|
12
|
+
move: (from: number, to: number) => void
|
|
13
|
+
setCell: (i: number, key: string, val: unknown) => void
|
|
14
|
+
insertRow: (at: number) => void
|
|
15
|
+
duplicateRow: (i: number) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useRepeater(
|
|
19
|
+
model: Ref<Record<string, unknown>[] | null | undefined>,
|
|
20
|
+
subFields: Ref<Record<string, FieldDef>>,
|
|
21
|
+
): UseRepeater {
|
|
22
|
+
let seq = 0
|
|
23
|
+
|
|
24
|
+
function cloneRows(v: unknown): Record<string, unknown>[] {
|
|
25
|
+
if (!Array.isArray(v)) return []
|
|
26
|
+
return (v as Record<string, unknown>[]).map(r => ({ ...r }))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const rows = ref<Record<string, unknown>[]>(cloneRows(model.value))
|
|
30
|
+
const keys = ref<number[]>(rows.value.map(() => seq++))
|
|
31
|
+
|
|
32
|
+
function emit(): void {
|
|
33
|
+
model.value = rows.value.map(r => ({ ...r }))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function reseed(v: Record<string, unknown>[] | null | undefined): void {
|
|
37
|
+
rows.value = cloneRows(v)
|
|
38
|
+
keys.value = rows.value.map(() => seq++)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Echo-guard via the shared primitive: skip our own emit, reseed on a genuine external change. Compares
|
|
42
|
+
// against the CURRENT rows (not a one-shot token) so an undo→redo back to a prior value still reseeds.
|
|
43
|
+
useEchoGuard(model, () => rows.value, reseed, [])
|
|
44
|
+
|
|
45
|
+
function blankRow(): Record<string, unknown> {
|
|
46
|
+
return Object.fromEntries(
|
|
47
|
+
Object.keys(subFields.value).map(k => [k, subFields.value[k]!.default ?? null]),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function addRow(): void {
|
|
52
|
+
rows.value = [...rows.value, blankRow()]
|
|
53
|
+
keys.value = [...keys.value, seq++]
|
|
54
|
+
emit()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function insertRow(at: number): void {
|
|
58
|
+
const clamped = Math.max(0, Math.min(at, rows.value.length))
|
|
59
|
+
const newRows = [...rows.value]
|
|
60
|
+
const newKeys = [...keys.value]
|
|
61
|
+
newRows.splice(clamped, 0, blankRow())
|
|
62
|
+
newKeys.splice(clamped, 0, seq++)
|
|
63
|
+
rows.value = newRows
|
|
64
|
+
keys.value = newKeys
|
|
65
|
+
emit()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function duplicateRow(i: number): void {
|
|
69
|
+
if (i < 0 || i >= rows.value.length) return
|
|
70
|
+
const clone = JSON.parse(JSON.stringify(rows.value[i])) as Record<string, unknown>
|
|
71
|
+
const newRows = [...rows.value]
|
|
72
|
+
const newKeys = [...keys.value]
|
|
73
|
+
newRows.splice(i + 1, 0, clone)
|
|
74
|
+
newKeys.splice(i + 1, 0, seq++)
|
|
75
|
+
rows.value = newRows
|
|
76
|
+
keys.value = newKeys
|
|
77
|
+
emit()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function removeRow(i: number): void {
|
|
81
|
+
rows.value = rows.value.filter((_, idx) => idx !== i)
|
|
82
|
+
keys.value = keys.value.filter((_, idx) => idx !== i)
|
|
83
|
+
emit()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function move(from: number, to: number): void {
|
|
87
|
+
if (from === to || from < 0 || to < 0 || from >= rows.value.length || to >= rows.value.length) return
|
|
88
|
+
rows.value = reorder(rows.value, from, to)
|
|
89
|
+
keys.value = reorder(keys.value, from, to)
|
|
90
|
+
emit()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function setCell(i: number, key: string, val: unknown): void {
|
|
94
|
+
rows.value = rows.value.map((r, idx) => idx === i ? { ...r, [key]: val } : r)
|
|
95
|
+
emit()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { rows, keys, addRow, removeRow, move, setCell, insertRow, duplicateRow }
|
|
99
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { en } from '../i18n/en'
|
|
2
|
+
import { de } from '../i18n/de'
|
|
3
|
+
|
|
4
|
+
export type Catalog = Record<string, string>
|
|
5
|
+
|
|
6
|
+
/** Admin-UI languages that ship a catalog. The first is the source + fallback language. */
|
|
7
|
+
export const ADMIN_LANGS = ['en', 'de'] as const
|
|
8
|
+
export type AdminLang = (typeof ADMIN_LANGS)[number]
|
|
9
|
+
|
|
10
|
+
const catalogs: Record<string, Catalog> = { en, de }
|
|
11
|
+
|
|
12
|
+
/** Replace `{name}` placeholders from `params`; an absent param leaves its placeholder untouched. */
|
|
13
|
+
export function interpolate(template: string, params?: Record<string, unknown>): string {
|
|
14
|
+
if (!params) return template
|
|
15
|
+
return template.replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolve `key` in the active catalog, then the fallback (en), then the key itself; interpolate. Pure. */
|
|
19
|
+
export function translate(catalog: Catalog, fallback: Catalog, key: string, params?: Record<string, unknown>): string {
|
|
20
|
+
return interpolate(catalog[key] ?? fallback[key] ?? key, params)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Admin-UI translation. `t(key, params?)` reads the active admin language (a cookie-backed preference,
|
|
25
|
+
* separate from the content locale the editor edits) and is reactive — switching the language re-renders.
|
|
26
|
+
* Missing keys fall back to English, then to the key, so a partial `de` catalog degrades gracefully.
|
|
27
|
+
*/
|
|
28
|
+
export function useT() {
|
|
29
|
+
const lang = useAdminLang()
|
|
30
|
+
const t = (key: string, params?: Record<string, unknown>) => translate(catalogs[lang.value] ?? en, en, key, params)
|
|
31
|
+
return { t, lang }
|
|
32
|
+
}
|