@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,173 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
// Attrs (id, aria-*) must land on the inner <input>, not the suffix wrapper, so the field label's
|
|
5
|
+
// `for` keeps pointing at the control. With no suffix the input still receives them via v-bind="$attrs".
|
|
6
|
+
defineOptions({ inheritAttrs: false })
|
|
7
|
+
|
|
8
|
+
withDefaults(
|
|
9
|
+
defineProps<{
|
|
10
|
+
min?: number
|
|
11
|
+
max?: number
|
|
12
|
+
step?: number | 'any'
|
|
13
|
+
placeholder?: string
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
// Display-only suffix (e.g. 'rem'). When set, the input gains a trailing adornment; the emitted
|
|
16
|
+
// model value is still a bare number. When absent, the plain `<input class="ui-number">` renders.
|
|
17
|
+
suffix?: string
|
|
18
|
+
/** Tighter padding for dense chrome (toolbars). Still meets the AA minimum target size. */
|
|
19
|
+
slim?: boolean
|
|
20
|
+
}>(),
|
|
21
|
+
{ disabled: false, slim: false },
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const model = defineModel<number | null>()
|
|
25
|
+
|
|
26
|
+
// Bound with `v-model` rather than :value/@input on purpose: mid-edit text like '1.0' parses to the
|
|
27
|
+
// number 1, and a plain :value binding would write that back over the text, deleting the trailing digit
|
|
28
|
+
// and moving the caret ('1.05' would end up as 15). v-model's directive skips the DOM write while the
|
|
29
|
+
// field is focused and its text still parses to the same number, so typing stays intact.
|
|
30
|
+
// The proxy only maps the empty field to null; v-model already hands over a number for a number input.
|
|
31
|
+
const text = computed<number | string>({
|
|
32
|
+
get: () => model.value ?? '',
|
|
33
|
+
set: (v) => { model.value = v === '' ? null : Number(v) },
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// A number input can hold editing text the browser can't parse ('-', '1e'); its DOM value is then ''
|
|
37
|
+
// and the model already null, so a save would silently drop what the user still sees in the field.
|
|
38
|
+
// Snap the text back to the model so the field never shows a value that wouldn't be saved.
|
|
39
|
+
// (Assigning `value` clears the unparsable editing text even though the raw value is already ''.)
|
|
40
|
+
function snapBack(el: HTMLInputElement) {
|
|
41
|
+
if (el.validity?.badInput) el.value = model.value == null ? '' : String(model.value)
|
|
42
|
+
}
|
|
43
|
+
function onBlur(e: Event) {
|
|
44
|
+
snapBack(e.target as HTMLInputElement)
|
|
45
|
+
}
|
|
46
|
+
// Enter fires the ancestor form's implicit submission WITHOUT a preceding blur, so the blur guard
|
|
47
|
+
// can't run — with the form's native validation off, a null would be saved while unparsable text
|
|
48
|
+
// still shows. Suppress that submit and snap back so the mismatch is corrected and visible instead.
|
|
49
|
+
function onKeydown(e: KeyboardEvent) {
|
|
50
|
+
if (e.key !== 'Enter' || e.isComposing) return
|
|
51
|
+
const el = e.target as HTMLInputElement
|
|
52
|
+
if (el.validity?.badInput) { e.preventDefault(); snapBack(el) }
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<div v-if="suffix" class="ui-number-wrap" :class="{ 'ui-number-wrap--slim': slim }" :data-disabled="disabled || undefined">
|
|
58
|
+
<input
|
|
59
|
+
v-model="text"
|
|
60
|
+
type="number"
|
|
61
|
+
:min="min"
|
|
62
|
+
:max="max"
|
|
63
|
+
:step="step"
|
|
64
|
+
:placeholder="placeholder"
|
|
65
|
+
:disabled="disabled"
|
|
66
|
+
class="ui-number-wrap__input"
|
|
67
|
+
v-bind="$attrs"
|
|
68
|
+
@blur="onBlur"
|
|
69
|
+
@keydown="onKeydown"
|
|
70
|
+
>
|
|
71
|
+
<span class="ui-number-wrap__suffix" aria-hidden="true">{{ suffix }}</span>
|
|
72
|
+
</div>
|
|
73
|
+
<input
|
|
74
|
+
v-else
|
|
75
|
+
v-model="text"
|
|
76
|
+
type="number"
|
|
77
|
+
:min="min"
|
|
78
|
+
:max="max"
|
|
79
|
+
:step="step"
|
|
80
|
+
:placeholder="placeholder"
|
|
81
|
+
:disabled="disabled"
|
|
82
|
+
class="ui-number"
|
|
83
|
+
:class="{ 'ui-number--slim': slim }"
|
|
84
|
+
v-bind="$attrs"
|
|
85
|
+
@blur="onBlur"
|
|
86
|
+
@keydown="onKeydown"
|
|
87
|
+
>
|
|
88
|
+
</template>
|
|
89
|
+
|
|
90
|
+
<style lang="scss">
|
|
91
|
+
@use '../../assets/scss/mixins' as *;
|
|
92
|
+
|
|
93
|
+
.ui-number {
|
|
94
|
+
@include input-base;
|
|
95
|
+
width: 100%;
|
|
96
|
+
|
|
97
|
+
&::placeholder {
|
|
98
|
+
color: var(--color-text-muted);
|
|
99
|
+
}
|
|
100
|
+
&--slim {
|
|
101
|
+
@include input-slim;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Suffix variant (opt-in via the `suffix` prop). The border/radius live on the wrapper; the inner input
|
|
106
|
+
// is borderless + transparent so it reads as one control, mirroring the slug prefix adornment.
|
|
107
|
+
.ui-number-wrap {
|
|
108
|
+
display: flex;
|
|
109
|
+
align-items: stretch;
|
|
110
|
+
width: 100%;
|
|
111
|
+
min-width: 0;
|
|
112
|
+
border: 1px solid var(--color-control-border, var(--color-border));
|
|
113
|
+
border-radius: var(--radius-md);
|
|
114
|
+
background: var(--color-surface);
|
|
115
|
+
overflow: hidden; // clip the square-cornered suffix background to the wrapper's rounded border
|
|
116
|
+
|
|
117
|
+
&:focus-within {
|
|
118
|
+
outline: 2px solid var(--color-focus);
|
|
119
|
+
outline-offset: -2px;
|
|
120
|
+
}
|
|
121
|
+
&[data-disabled] {
|
|
122
|
+
opacity: 0.6;
|
|
123
|
+
cursor: not-allowed;
|
|
124
|
+
}
|
|
125
|
+
// input-base flags the invalid state on the input's own border; here the border lives on the wrapper
|
|
126
|
+
// while the inner input is borderless, so mirror it so a unit field shows the same red invalid border.
|
|
127
|
+
&:has(.ui-number-wrap__input[aria-invalid='true']) {
|
|
128
|
+
border-color: var(--color-danger);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
&__input {
|
|
132
|
+
flex: 1 1 auto;
|
|
133
|
+
min-width: 0;
|
|
134
|
+
padding: var(--space-2) var(--space-3);
|
|
135
|
+
border: 0;
|
|
136
|
+
background: transparent;
|
|
137
|
+
color: var(--color-text);
|
|
138
|
+
font-size: var(--text-base);
|
|
139
|
+
|
|
140
|
+
&:focus-visible {
|
|
141
|
+
outline: none;
|
|
142
|
+
}
|
|
143
|
+
&::placeholder {
|
|
144
|
+
color: var(--color-text-muted);
|
|
145
|
+
}
|
|
146
|
+
&:disabled {
|
|
147
|
+
cursor: not-allowed;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
&__suffix {
|
|
152
|
+
display: inline-flex;
|
|
153
|
+
align-items: center;
|
|
154
|
+
padding-inline: var(--space-3);
|
|
155
|
+
background: var(--color-surface-2);
|
|
156
|
+
color: var(--color-text-muted);
|
|
157
|
+
border-left: 1px solid var(--color-control-border, var(--color-border));
|
|
158
|
+
font-size: var(--text-sm);
|
|
159
|
+
white-space: nowrap;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Slim adornment variant: the wrapper keeps its border/radius, only the inner box tightens. Written as a
|
|
164
|
+
// separate rule (not nested) so `.ui-number-wrap--slim .ui-number-wrap__input` outranks the base element rule.
|
|
165
|
+
.ui-number-wrap--slim {
|
|
166
|
+
.ui-number-wrap__input {
|
|
167
|
+
@include input-slim;
|
|
168
|
+
}
|
|
169
|
+
.ui-number-wrap__suffix {
|
|
170
|
+
padding-inline: var(--space-2);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
</style>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { watch, watchEffect } from 'vue'
|
|
3
|
+
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
|
4
|
+
import { StarterKit } from '@tiptap/starter-kit'
|
|
5
|
+
import { Highlight } from '@tiptap/extension-highlight'
|
|
6
|
+
import { Subscript } from '@tiptap/extension-subscript'
|
|
7
|
+
import { Superscript } from '@tiptap/extension-superscript'
|
|
8
|
+
import { TextAlign } from '@tiptap/extension-text-align'
|
|
9
|
+
import RichtextToolbar from './RichtextToolbar.vue'
|
|
10
|
+
import { RichtextSpanClass, RichtextBlockClass } from '../../utils/richtext-preserve-class'
|
|
11
|
+
|
|
12
|
+
const props = withDefaults(
|
|
13
|
+
defineProps<{
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
locale?: string
|
|
16
|
+
// a11y: the editing surface is a contenteditable with no native label; the field name + error/state
|
|
17
|
+
// are set on the ProseMirror DOM node (editor.view.dom) so AT announces a named multiline textbox.
|
|
18
|
+
ariaLabel?: string
|
|
19
|
+
describedby?: string
|
|
20
|
+
invalid?: boolean
|
|
21
|
+
required?: boolean
|
|
22
|
+
}>(),
|
|
23
|
+
{ disabled: false },
|
|
24
|
+
)
|
|
25
|
+
const model = defineModel<string | null>()
|
|
26
|
+
|
|
27
|
+
// StarterKit (v3) already bundles Link + Underline; only the rest are added here.
|
|
28
|
+
const editor = useEditor({
|
|
29
|
+
content: model.value ?? '',
|
|
30
|
+
editable: !props.disabled,
|
|
31
|
+
// (no `immediatelyRender` — not in tiptap v3's EditorOptions type; the admin editor is SPA-only so the
|
|
32
|
+
// browser default applies.)
|
|
33
|
+
extensions: [
|
|
34
|
+
StarterKit.configure({
|
|
35
|
+
heading: { levels: [1, 2, 3, 4, 5, 6] },
|
|
36
|
+
link: { openOnClick: false },
|
|
37
|
+
}),
|
|
38
|
+
Highlight,
|
|
39
|
+
Subscript,
|
|
40
|
+
Superscript,
|
|
41
|
+
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
42
|
+
RichtextSpanClass, RichtextBlockClass, // preserve consumer presentational classes on round-trip
|
|
43
|
+
],
|
|
44
|
+
onUpdate: ({ editor }) => {
|
|
45
|
+
model.value = editor.isEmpty ? '' : editor.getHTML()
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
watch(model, (value) => {
|
|
50
|
+
const e = editor.value
|
|
51
|
+
if (!e) return
|
|
52
|
+
const next = value ?? ''
|
|
53
|
+
const current = e.isEmpty ? '' : e.getHTML()
|
|
54
|
+
// `emitUpdate: false`: this value came FROM the model, so re-emitting it would reach the edit form as
|
|
55
|
+
// a user edit — clearing the redo stack and turning a restored null back into ''.
|
|
56
|
+
if (next !== current) e.commands.setContent(next, { emitUpdate: false })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
watch(() => props.disabled, (d) => editor.value?.setEditable(!d))
|
|
60
|
+
|
|
61
|
+
// Apply the accessible name + textbox semantics directly to the ProseMirror surface, reactively (the
|
|
62
|
+
// error-driven describedby/invalid arrive after mount). `editor.view.dom` is the contenteditable element.
|
|
63
|
+
function setAttr(el: HTMLElement, name: string, value?: string) {
|
|
64
|
+
if (value) el.setAttribute(name, value)
|
|
65
|
+
else el.removeAttribute(name)
|
|
66
|
+
}
|
|
67
|
+
watchEffect(() => {
|
|
68
|
+
const dom = editor.value?.view?.dom as HTMLElement | undefined
|
|
69
|
+
if (!dom) return
|
|
70
|
+
dom.setAttribute('role', 'textbox')
|
|
71
|
+
dom.setAttribute('aria-multiline', 'true')
|
|
72
|
+
setAttr(dom, 'aria-label', props.ariaLabel)
|
|
73
|
+
setAttr(dom, 'aria-describedby', props.describedby)
|
|
74
|
+
setAttr(dom, 'aria-invalid', props.invalid ? 'true' : undefined)
|
|
75
|
+
setAttr(dom, 'aria-required', props.required ? 'true' : undefined)
|
|
76
|
+
})
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<template>
|
|
80
|
+
<div class="ui-richtext">
|
|
81
|
+
<RichtextToolbar :editor="editor" :locale="locale" :disabled="disabled" />
|
|
82
|
+
<EditorContent :editor="editor" class="ui-richtext__content" />
|
|
83
|
+
</div>
|
|
84
|
+
</template>
|
|
85
|
+
|
|
86
|
+
<style lang="scss">
|
|
87
|
+
.ui-richtext {
|
|
88
|
+
border: 1px solid var(--color-control-border, var(--color-border));
|
|
89
|
+
border-radius: var(--radius-md);
|
|
90
|
+
background: var(--color-surface);
|
|
91
|
+
|
|
92
|
+
&__content {
|
|
93
|
+
.tiptap {
|
|
94
|
+
padding: var(--space-3);
|
|
95
|
+
min-height: 8rem;
|
|
96
|
+
outline: none;
|
|
97
|
+
|
|
98
|
+
:where(p, ul, ol, blockquote, pre, h1, h2, h3, h4, h5, h6) { margin: 0 0 var(--space-3); }
|
|
99
|
+
:where(p, ul, ol, blockquote, pre, h1, h2, h3, h4, h5, h6):last-child { margin-bottom: 0; }
|
|
100
|
+
ul, ol { padding-inline-start: var(--space-5); }
|
|
101
|
+
blockquote {
|
|
102
|
+
border-inline-start: 3px solid var(--color-border);
|
|
103
|
+
padding-inline-start: var(--space-3);
|
|
104
|
+
color: var(--color-text-muted);
|
|
105
|
+
}
|
|
106
|
+
pre {
|
|
107
|
+
background: var(--color-bg);
|
|
108
|
+
padding: var(--space-3);
|
|
109
|
+
border-radius: var(--radius-sm);
|
|
110
|
+
overflow-x: auto;
|
|
111
|
+
}
|
|
112
|
+
code { font-family: monospace; }
|
|
113
|
+
a { color: var(--color-primary); }
|
|
114
|
+
mark { background: var(--color-highlight); color: var(--color-on-highlight); }
|
|
115
|
+
hr { border: 0; border-top: 1px solid var(--color-border); margin: var(--space-4) 0; }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
</style>
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, watch } from 'vue'
|
|
3
|
+
import type { Editor } from '@tiptap/vue-3'
|
|
4
|
+
import UiButton from './Button.vue'
|
|
5
|
+
import UiIcon from './Icon.vue'
|
|
6
|
+
import UiSelect from './Select.vue'
|
|
7
|
+
import UiTextInput from './TextInput.vue'
|
|
8
|
+
import LinkInternalPicker from '../field/LinkInternalPicker.vue'
|
|
9
|
+
import type { IconName } from '../../utils/icons'
|
|
10
|
+
import { richtextLinkHref, parseRichtextLinkHref } from '../../../../fields/app/utils/richtext-links'
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{ editor: Editor | undefined; locale?: string; disabled?: boolean }>()
|
|
13
|
+
|
|
14
|
+
interface Item {
|
|
15
|
+
name: string
|
|
16
|
+
label: string
|
|
17
|
+
icon: IconName
|
|
18
|
+
run: (e: Editor) => void
|
|
19
|
+
active?: (e: Editor) => boolean
|
|
20
|
+
disabled?: (e: Editor) => boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { t } = useT()
|
|
24
|
+
|
|
25
|
+
const MARKS = computed((): Item[] => [
|
|
26
|
+
{ name: 'bold', label: t('richtext.bold'), icon: 'bold', run: (e) => e.chain().focus().toggleBold().run(), active: (e) => e.isActive('bold') },
|
|
27
|
+
{ name: 'italic', label: t('richtext.italic'), icon: 'italic', run: (e) => e.chain().focus().toggleItalic().run(), active: (e) => e.isActive('italic') },
|
|
28
|
+
{ name: 'underline', label: t('richtext.underline'), icon: 'underline', run: (e) => e.chain().focus().toggleUnderline().run(), active: (e) => e.isActive('underline') },
|
|
29
|
+
{ name: 'strike', label: t('richtext.strikethrough'), icon: 'strikethrough', run: (e) => e.chain().focus().toggleStrike().run(), active: (e) => e.isActive('strike') },
|
|
30
|
+
{ name: 'code', label: t('richtext.inlineCode'), icon: 'code', run: (e) => e.chain().focus().toggleCode().run(), active: (e) => e.isActive('code') },
|
|
31
|
+
{ name: 'highlight', label: t('richtext.highlight'), icon: 'highlighter', run: (e) => e.chain().focus().toggleHighlight().run(), active: (e) => e.isActive('highlight') },
|
|
32
|
+
{ name: 'subscript', label: t('richtext.subscript'), icon: 'subscript', run: (e) => e.chain().focus().toggleSubscript().run(), active: (e) => e.isActive('subscript') },
|
|
33
|
+
{ name: 'superscript', label: t('richtext.superscript'), icon: 'superscript', run: (e) => e.chain().focus().toggleSuperscript().run(), active: (e) => e.isActive('superscript') },
|
|
34
|
+
])
|
|
35
|
+
const BLOCKS = computed((): Item[] => [
|
|
36
|
+
{ name: 'bulletList', label: t('richtext.bulletList'), icon: 'list', run: (e) => e.chain().focus().toggleBulletList().run(), active: (e) => e.isActive('bulletList') },
|
|
37
|
+
{ name: 'orderedList', label: t('richtext.orderedList'), icon: 'list-ordered', run: (e) => e.chain().focus().toggleOrderedList().run(), active: (e) => e.isActive('orderedList') },
|
|
38
|
+
{ name: 'blockquote', label: t('richtext.blockquote'), icon: 'quote', run: (e) => e.chain().focus().toggleBlockquote().run(), active: (e) => e.isActive('blockquote') },
|
|
39
|
+
{ name: 'codeBlock', label: t('richtext.codeBlock'), icon: 'square-code', run: (e) => e.chain().focus().toggleCodeBlock().run(), active: (e) => e.isActive('codeBlock') },
|
|
40
|
+
{ name: 'horizontalRule', label: t('richtext.horizontalRule'), icon: 'minus', run: (e) => e.chain().focus().setHorizontalRule().run() },
|
|
41
|
+
])
|
|
42
|
+
const ALIGN = computed((): Item[] => [
|
|
43
|
+
{ name: 'left', label: t('richtext.alignLeft'), icon: 'align-left', run: (e) => e.chain().focus().setTextAlign('left').run(), active: (e) => e.isActive({ textAlign: 'left' }) },
|
|
44
|
+
{ name: 'center', label: t('richtext.alignCenter'), icon: 'align-center', run: (e) => e.chain().focus().setTextAlign('center').run(), active: (e) => e.isActive({ textAlign: 'center' }) },
|
|
45
|
+
{ name: 'right', label: t('richtext.alignRight'), icon: 'align-right', run: (e) => e.chain().focus().setTextAlign('right').run(), active: (e) => e.isActive({ textAlign: 'right' }) },
|
|
46
|
+
{ name: 'justify', label: t('richtext.justify'), icon: 'align-justify', run: (e) => e.chain().focus().setTextAlign('justify').run(), active: (e) => e.isActive({ textAlign: 'justify' }) },
|
|
47
|
+
])
|
|
48
|
+
const EXTRA = computed((): Item[] => [
|
|
49
|
+
{ name: 'clear', label: t('richtext.clearFormatting'), icon: 'eraser', run: (e) => e.chain().focus().unsetAllMarks().clearNodes().run() },
|
|
50
|
+
{ name: 'undo', label: t('richtext.undo'), icon: 'undo', run: (e) => e.chain().focus().undo().run(), disabled: (e) => !e.can().undo() },
|
|
51
|
+
{ name: 'redo', label: t('richtext.redo'), icon: 'redo', run: (e) => e.chain().focus().redo().run(), disabled: (e) => !e.can().redo() },
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
const BLOCK_OPTIONS = computed(() => [
|
|
55
|
+
{ label: t('richtext.paragraph'), value: 'p' },
|
|
56
|
+
{ label: t('richtext.heading1'), value: 'h1' }, { label: t('richtext.heading2'), value: 'h2' }, { label: t('richtext.heading3'), value: 'h3' },
|
|
57
|
+
{ label: t('richtext.heading4'), value: 'h4' }, { label: t('richtext.heading5'), value: 'h5' }, { label: t('richtext.heading6'), value: 'h6' },
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
// Re-render the toolbar on every editor transaction so active/disabled states stay live.
|
|
61
|
+
const tick = ref(0)
|
|
62
|
+
watch(() => props.editor, (editor, _old, onCleanup) => {
|
|
63
|
+
if (!editor) return
|
|
64
|
+
const bump = () => { tick.value++ }
|
|
65
|
+
editor.on('transaction', bump)
|
|
66
|
+
onCleanup(() => { editor.off('transaction', bump) })
|
|
67
|
+
}, { immediate: true })
|
|
68
|
+
|
|
69
|
+
const blockValue = computed(() => {
|
|
70
|
+
void tick.value
|
|
71
|
+
const e = props.editor
|
|
72
|
+
if (!e) return 'p'
|
|
73
|
+
for (let l = 1; l <= 6; l++) if (e.isActive('heading', { level: l })) return `h${l}`
|
|
74
|
+
return 'p'
|
|
75
|
+
})
|
|
76
|
+
function onBlock(v: string | null | undefined) {
|
|
77
|
+
const e = props.editor
|
|
78
|
+
if (!e || !v) return
|
|
79
|
+
if (v === 'p') e.chain().focus().setParagraph().run()
|
|
80
|
+
else e.chain().focus().toggleHeading({ level: Number(v[1]) as 1 | 2 | 3 | 4 | 5 | 6 }).run()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const linkOpen = ref(false)
|
|
84
|
+
const linkUrl = ref('')
|
|
85
|
+
function toggleLink() {
|
|
86
|
+
const e = props.editor!
|
|
87
|
+
if (e.isActive('link')) { e.chain().focus().unsetLink().run(); return }
|
|
88
|
+
linkInternalOpen.value = false
|
|
89
|
+
linkUrl.value = (e.getAttributes('link').href as string) ?? ''
|
|
90
|
+
linkOpen.value = true
|
|
91
|
+
}
|
|
92
|
+
function applyLink() {
|
|
93
|
+
const e = props.editor!
|
|
94
|
+
const href = linkUrl.value.trim()
|
|
95
|
+
if (href) e.chain().focus().extendMarkRange('link').setLink({ href }).run()
|
|
96
|
+
linkOpen.value = false
|
|
97
|
+
linkUrl.value = ''
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Internal links: pick a record, store it as a `kestrel:<collection>:<id>` marker on a normal link
|
|
101
|
+
// mark (resolved to the real localized path at read time / in the preview).
|
|
102
|
+
const linkInternalOpen = ref(false)
|
|
103
|
+
const pickCollection = ref<string | null>(null)
|
|
104
|
+
const pickRecordId = ref<number | null>(null)
|
|
105
|
+
function toggleInternalLink() {
|
|
106
|
+
linkOpen.value = false
|
|
107
|
+
const opening = !linkInternalOpen.value
|
|
108
|
+
linkInternalOpen.value = opening
|
|
109
|
+
if (!opening) return
|
|
110
|
+
// Prefill from the link under the cursor: an existing internal marker re-selects its record (the
|
|
111
|
+
// picker loads + highlights it); an external/absent link opens the picker blank.
|
|
112
|
+
const parsed = parseRichtextLinkHref(props.editor!.getAttributes('link').href as string | undefined)
|
|
113
|
+
pickCollection.value = parsed?.collection ?? null
|
|
114
|
+
pickRecordId.value = parsed?.id ?? null
|
|
115
|
+
}
|
|
116
|
+
function applyInternalLink() {
|
|
117
|
+
const e = props.editor!
|
|
118
|
+
if (pickCollection.value && pickRecordId.value != null) {
|
|
119
|
+
e.chain().focus().extendMarkRange('link').setLink({ href: richtextLinkHref(pickCollection.value, pickRecordId.value) }).run()
|
|
120
|
+
}
|
|
121
|
+
linkInternalOpen.value = false
|
|
122
|
+
pickCollection.value = null
|
|
123
|
+
pickRecordId.value = null
|
|
124
|
+
}
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<template>
|
|
128
|
+
<div v-if="editor" class="ui-rt-toolbar" :data-rev="tick">
|
|
129
|
+
<UiSelect
|
|
130
|
+
class="ui-rt-toolbar__block"
|
|
131
|
+
:model-value="blockValue"
|
|
132
|
+
:options="BLOCK_OPTIONS"
|
|
133
|
+
:aria-label="t('richtext.blockType')"
|
|
134
|
+
:disabled="disabled"
|
|
135
|
+
@update:model-value="onBlock"
|
|
136
|
+
/>
|
|
137
|
+
|
|
138
|
+
<div class="ui-rt-toolbar__group">
|
|
139
|
+
<UiButton
|
|
140
|
+
v-for="item in MARKS" :key="item.name"
|
|
141
|
+
type="button" size="sm" variant="ghost"
|
|
142
|
+
:class="{ 'rt-on': item.active!(editor) }"
|
|
143
|
+
:aria-label="item.label"
|
|
144
|
+
:aria-pressed="item.active!(editor) ? 'true' : 'false'"
|
|
145
|
+
:disabled="disabled"
|
|
146
|
+
@click="item.run(editor)"
|
|
147
|
+
><UiIcon :name="item.icon" :size="16" /></UiButton>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div class="ui-rt-toolbar__group">
|
|
151
|
+
<UiButton
|
|
152
|
+
type="button" size="sm" variant="ghost"
|
|
153
|
+
:class="{ 'rt-on': editor.isActive('link') }"
|
|
154
|
+
:aria-label="t('richtext.link')"
|
|
155
|
+
:aria-pressed="editor.isActive('link') ? 'true' : 'false'"
|
|
156
|
+
:disabled="disabled"
|
|
157
|
+
@click="toggleLink"
|
|
158
|
+
><UiIcon name="link" :size="16" /></UiButton>
|
|
159
|
+
<UiButton
|
|
160
|
+
type="button" size="sm" variant="ghost"
|
|
161
|
+
:class="{ 'rt-on': linkInternalOpen }"
|
|
162
|
+
:aria-label="t('richtext.linkInternal')"
|
|
163
|
+
:aria-pressed="linkInternalOpen ? 'true' : 'false'"
|
|
164
|
+
:disabled="disabled"
|
|
165
|
+
@click="toggleInternalLink"
|
|
166
|
+
><UiIcon name="link-2" :size="16" /></UiButton>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div class="ui-rt-toolbar__group">
|
|
170
|
+
<UiButton
|
|
171
|
+
v-for="item in BLOCKS" :key="item.name"
|
|
172
|
+
type="button" size="sm" variant="ghost"
|
|
173
|
+
:class="{ 'rt-on': item.active && item.active(editor) }"
|
|
174
|
+
:aria-label="item.label"
|
|
175
|
+
:aria-pressed="item.active ? (item.active(editor) ? 'true' : 'false') : undefined"
|
|
176
|
+
:disabled="disabled"
|
|
177
|
+
@click="item.run(editor)"
|
|
178
|
+
><UiIcon :name="item.icon" :size="16" /></UiButton>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div class="ui-rt-toolbar__group">
|
|
182
|
+
<UiButton
|
|
183
|
+
v-for="item in ALIGN" :key="item.name"
|
|
184
|
+
type="button" size="sm" variant="ghost"
|
|
185
|
+
:class="{ 'rt-on': item.active!(editor) }"
|
|
186
|
+
:aria-label="item.label"
|
|
187
|
+
:aria-pressed="item.active!(editor) ? 'true' : 'false'"
|
|
188
|
+
:disabled="disabled"
|
|
189
|
+
@click="item.run(editor)"
|
|
190
|
+
><UiIcon :name="item.icon" :size="16" /></UiButton>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div class="ui-rt-toolbar__group">
|
|
194
|
+
<UiButton
|
|
195
|
+
v-for="item in EXTRA" :key="item.name"
|
|
196
|
+
type="button" size="sm" variant="ghost"
|
|
197
|
+
:aria-label="item.label"
|
|
198
|
+
:disabled="disabled || (item.disabled ? item.disabled(editor) : false)"
|
|
199
|
+
@click="item.run(editor)"
|
|
200
|
+
><UiIcon :name="item.icon" :size="16" /></UiButton>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<div v-if="linkOpen" class="ui-rt-toolbar__link">
|
|
204
|
+
<UiTextInput v-model="linkUrl" type="url" :placeholder="t('richtext.urlPlaceholder')" :aria-label="t('richtext.linkUrl')" @keydown.enter.prevent="applyLink" />
|
|
205
|
+
<UiButton type="button" size="sm" variant="primary" @click="applyLink">{{ t('richtext.apply') }}</UiButton>
|
|
206
|
+
</div>
|
|
207
|
+
|
|
208
|
+
<div v-if="linkInternalOpen" class="ui-rt-toolbar__link">
|
|
209
|
+
<LinkInternalPicker v-model:collection="pickCollection" v-model:record-id="pickRecordId" :locale="locale ?? 'en'" />
|
|
210
|
+
<UiButton type="button" size="sm" variant="primary" :disabled="pickRecordId == null" @click="applyInternalLink">{{ t('richtext.apply') }}</UiButton>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</template>
|
|
214
|
+
|
|
215
|
+
<style lang="scss">
|
|
216
|
+
.ui-rt-toolbar {
|
|
217
|
+
display: flex;
|
|
218
|
+
flex-wrap: wrap;
|
|
219
|
+
align-items: center;
|
|
220
|
+
gap: var(--space-2);
|
|
221
|
+
padding: var(--space-2);
|
|
222
|
+
border-bottom: 1px solid var(--color-border);
|
|
223
|
+
|
|
224
|
+
&__group {
|
|
225
|
+
display: inline-flex;
|
|
226
|
+
gap: var(--space-1);
|
|
227
|
+
}
|
|
228
|
+
&__block {
|
|
229
|
+
width: auto;
|
|
230
|
+
min-width: 8rem;
|
|
231
|
+
}
|
|
232
|
+
&__link {
|
|
233
|
+
display: flex;
|
|
234
|
+
gap: var(--space-2);
|
|
235
|
+
flex-basis: 100%;
|
|
236
|
+
align-items: center;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Active toggle: a calm neutral fill with the accent carried only by the icon — matching the
|
|
240
|
+
// calmed active-nav treatment, instead of a solid-indigo button per active mark.
|
|
241
|
+
&__group .ui-button.rt-on {
|
|
242
|
+
background: var(--color-active, var(--color-surface-2));
|
|
243
|
+
color: var(--color-text);
|
|
244
|
+
|
|
245
|
+
&:hover:not(:disabled) {
|
|
246
|
+
background: var(--color-active, var(--color-surface-2));
|
|
247
|
+
}
|
|
248
|
+
.ui-icon {
|
|
249
|
+
color: var(--color-primary);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
</style>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import UiIcon from './Icon.vue'
|
|
3
|
+
|
|
4
|
+
defineOptions({ inheritAttrs: false })
|
|
5
|
+
|
|
6
|
+
withDefaults(
|
|
7
|
+
defineProps<{
|
|
8
|
+
options: { label: string; value: string }[]
|
|
9
|
+
placeholder?: string
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
}>(),
|
|
12
|
+
{ disabled: false },
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const model = defineModel<string | null>()
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<span class="ui-select">
|
|
20
|
+
<select v-model="model" :disabled="disabled" class="ui-select__field" v-bind="$attrs">
|
|
21
|
+
<option v-if="placeholder" value="" disabled>{{ placeholder }}</option>
|
|
22
|
+
<option v-for="o in options" :key="o.value" :value="o.value">{{ o.label }}</option>
|
|
23
|
+
</select>
|
|
24
|
+
<UiIcon name="chevron-down" :size="14" class="ui-select__icon" />
|
|
25
|
+
</span>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<style lang="scss">
|
|
29
|
+
@use '../../assets/scss/mixins' as *;
|
|
30
|
+
|
|
31
|
+
.ui-select {
|
|
32
|
+
position: relative;
|
|
33
|
+
display: block;
|
|
34
|
+
width: 100%;
|
|
35
|
+
}
|
|
36
|
+
.ui-select__field {
|
|
37
|
+
@include input-base;
|
|
38
|
+
width: 100%;
|
|
39
|
+
appearance: none;
|
|
40
|
+
// leave room for the overlaid chevron so long values don't run under it
|
|
41
|
+
padding-right: calc(var(--space-3) + 1.25rem);
|
|
42
|
+
}
|
|
43
|
+
.ui-select__icon {
|
|
44
|
+
position: absolute;
|
|
45
|
+
top: 50%;
|
|
46
|
+
right: var(--space-3);
|
|
47
|
+
transform: translateY(-50%);
|
|
48
|
+
pointer-events: none; // clicks fall through to the native select
|
|
49
|
+
color: var(--color-text-muted);
|
|
50
|
+
}
|
|
51
|
+
</style>
|