@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,1091 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SerializedCollection } from '../../../core/server/utils/serialize-collection'
|
|
3
|
+
import { sortDirection, type FilterCell } from '../utils/list-query'
|
|
4
|
+
import { type ListColumn } from '../utils/list-columns'
|
|
5
|
+
import { PER_PAGE_OPTIONS } from '../../../core/app/utils/list-limits'
|
|
6
|
+
import type { BatchDeleteReport } from '../utils/collection-ops'
|
|
7
|
+
import { OPS_BY_KIND, DEFAULT_OP, type FilterKind, type FilterOp } from '../../../core/app/utils/filter-ops'
|
|
8
|
+
import { resolveLocalized } from '../../../ui/app/utils/localized'
|
|
9
|
+
import { humanizeFieldName } from '../../../ui/app/utils/humanize'
|
|
10
|
+
|
|
11
|
+
// `locale` is set only for a translatable collection: it filters the list to that content locale and
|
|
12
|
+
// shows a locale switcher. New records / row links carry it so they open in the same locale.
|
|
13
|
+
const props = defineProps<{ schema: SerializedCollection; locale?: string }>()
|
|
14
|
+
|
|
15
|
+
const { t, lang } = useT()
|
|
16
|
+
const { locales } = useContentLocales()
|
|
17
|
+
|
|
18
|
+
const collection = computed(() => props.schema.name)
|
|
19
|
+
const label = computed(() => resolveLocalized(props.schema.label?.singular, lang.value) ?? props.schema.name)
|
|
20
|
+
// Prefer the collection's complete per-locale "create" phrase; fall back to the generic template.
|
|
21
|
+
const newLabel = computed(() => resolveLocalized(props.schema.label?.new, lang.value) ?? t('common.new', { label: label.value }))
|
|
22
|
+
const translatable = computed(() => !!props.schema.translatable && props.schema.mode === 'multi')
|
|
23
|
+
const localeQuery = computed(() => (props.locale ? `?locale=${props.locale}` : ''))
|
|
24
|
+
|
|
25
|
+
// Configurable columns (persisted per-collection). The select + row-action columns are CHROME — fixed
|
|
26
|
+
// cells rendered outside this model, never sortable/filterable/hideable/persisted.
|
|
27
|
+
const { available, visibleColumns, visibleKeys, toggle: toggleColumn, reset: resetColumns, isDefault: columnsAreDefault } =
|
|
28
|
+
useListColumns(toRef(props, 'schema'))
|
|
29
|
+
const filterableColumns = computed(() => available.value.filter((c) => c.filterable))
|
|
30
|
+
|
|
31
|
+
function colLabel(col: ListColumn): string {
|
|
32
|
+
return col.type === 'field' ? humanizeFieldName(col.name ?? col.key) : t(col.labelKey ?? col.key)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The URL is the single source of truth for committed list state. `sort`/`page`/`perPage`/`filter` are
|
|
36
|
+
// derived (read-only) computeds off `route.query`; `effectiveQuery` is the sanitized `$fetch` query; the
|
|
37
|
+
// setters navigate (page → push so Back walks pages, the rest → replace). The composable also owns the
|
|
38
|
+
// per-page cookie (a density DEFAULT that seeds perPage when the URL omits it).
|
|
39
|
+
const { sort, page, perPage, filter, effectiveQuery, setSort, setPage, clampPage, setPerPage, setFilter } =
|
|
40
|
+
useListUrlState(available)
|
|
41
|
+
|
|
42
|
+
// `filter` (above, from the URL) = the committed clauses that drive the query. `draft` = the panel's pending
|
|
43
|
+
// edits, kept local and seeded per filterable column below (keyed by column key → { op, value }).
|
|
44
|
+
const draft = reactive<Record<string, FilterCell>>({})
|
|
45
|
+
|
|
46
|
+
// Overwrite each draft cell FROM the committed `filter` so deep-link / Back-Forward / chip-removal reflect
|
|
47
|
+
// back into the open panel; drop drafts for columns this collection no longer has. We key the watch on the
|
|
48
|
+
// committed filter's CONTENT (its JSON), not its object identity: `filter` recomputes to a fresh-but-EQUAL
|
|
49
|
+
// object on every `route.query` change (page/sort/perPage navigations too), and resyncing on those identity
|
|
50
|
+
// flips would wipe an in-progress panel edit. Clobber-safe on a real filter commit: a commit sends ALL
|
|
51
|
+
// drafts at once, so post-commit `filter` equals `draft` (a no-op overwrite) — the content only actually
|
|
52
|
+
// changes on external navigation (deep-link/Back-Forward/chip-removal), which is when the panel should resync.
|
|
53
|
+
watch([filterableColumns, () => JSON.stringify(filter.value)], () => {
|
|
54
|
+
const cols = filterableColumns.value
|
|
55
|
+
for (const c of cols) {
|
|
56
|
+
const cm = filter.value[c.key]
|
|
57
|
+
draft[c.key] = { op: cm?.op ?? DEFAULT_OP[c.filterKind ?? 'text'], value: cm?.value ?? '' }
|
|
58
|
+
}
|
|
59
|
+
for (const key of Object.keys(draft)) if (!cols.some((c) => c.key === key)) delete draft[key]
|
|
60
|
+
}, { immediate: true })
|
|
61
|
+
|
|
62
|
+
function opsFor(c: ListColumn): readonly FilterOp[] {
|
|
63
|
+
return OPS_BY_KIND[c.filterKind ?? 'text']
|
|
64
|
+
}
|
|
65
|
+
// Operator labels are kind-aware: a datetime comparison reads "before / on or before / …" instead of the
|
|
66
|
+
// generic "less than / less or equal / …".
|
|
67
|
+
function opLabel(kind: FilterKind, op: FilterOp): string {
|
|
68
|
+
if (kind === 'datetime' && (op === 'lt' || op === 'lte' || op === 'gt' || op === 'gte')) return t(`filter.opDate.${op}`)
|
|
69
|
+
return t(`filter.op.${op}`)
|
|
70
|
+
}
|
|
71
|
+
// Choices for an `enum` / `stringSet` value control: a fixed draft/published set for `status`, else the
|
|
72
|
+
// choice field's own (single- and multi-choice fields both carry `options.choices`).
|
|
73
|
+
function enumOptions(c: ListColumn): { label: string; value: string }[] {
|
|
74
|
+
if (c.key === 'status') return [
|
|
75
|
+
{ value: 'draft', label: t('pageSettings.statusDraft') },
|
|
76
|
+
{ value: 'published', label: t('pageSettings.statusPublished') },
|
|
77
|
+
]
|
|
78
|
+
return ((c.field?.options?.choices ?? []) as { label: string; value: string }[])
|
|
79
|
+
}
|
|
80
|
+
function displayValue(kind: FilterKind, value: string): string {
|
|
81
|
+
if (kind === 'boolean') return value === 'true' ? t('filter.bool.true') : value === 'false' ? t('filter.bool.false') : value
|
|
82
|
+
return value
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const rows = ref<Record<string, unknown>[]>([])
|
|
86
|
+
const total = ref(0)
|
|
87
|
+
// Surfaced when a (re)fetch fails — an inline error + Retry, never a silent stale list or a thrown
|
|
88
|
+
// error boundary. Distinct from the empty state (which only shows when the fetch SUCCEEDED with no rows).
|
|
89
|
+
const error = ref<string | null>(null)
|
|
90
|
+
|
|
91
|
+
const toast = useToast()
|
|
92
|
+
|
|
93
|
+
// ── Page-scoped row selection ──────────────────────────────────────────────────────────────────────
|
|
94
|
+
// A Set of selected row ids, valid only for the rows currently on screen (cleared on every fetch). The
|
|
95
|
+
// header checkbox is select-all-on-this-page with a tri-state (some-but-not-all) indeterminate marker.
|
|
96
|
+
// A reactive Set (Vue tracks add/delete/has/size), so a single-row toggle is O(1) — no full-Set copy or
|
|
97
|
+
// whole-table re-diff on each click, which mattered at the 500-row page cap.
|
|
98
|
+
const selected = reactive(new Set<number>())
|
|
99
|
+
const pageIds = computed(() => rows.value.map((r) => Number(r.id)))
|
|
100
|
+
const allSelected = computed(() => pageIds.value.length > 0 && pageIds.value.every((id) => selected.has(id)))
|
|
101
|
+
const headerIndeterminate = computed(() => pageIds.value.some((id) => selected.has(id)) && !allSelected.value)
|
|
102
|
+
function toggleRow(id: number, on: boolean) {
|
|
103
|
+
if (on) selected.add(id)
|
|
104
|
+
else selected.delete(id)
|
|
105
|
+
}
|
|
106
|
+
function toggleAll(on: boolean) {
|
|
107
|
+
selected.clear()
|
|
108
|
+
if (on) for (const id of pageIds.value) selected.add(id)
|
|
109
|
+
}
|
|
110
|
+
function clearSelection() {
|
|
111
|
+
selected.clear()
|
|
112
|
+
}
|
|
113
|
+
// A row's human label: the first visible non-sidecar cell that has a value, else the bare id — used for
|
|
114
|
+
// the per-row checkbox / action aria-labels so each control names its row.
|
|
115
|
+
function rowLabel(row: Record<string, unknown>): string {
|
|
116
|
+
for (const c of visibleColumns.value) {
|
|
117
|
+
if (c.type === 'translations' || c.type === 'deadRefs') continue
|
|
118
|
+
const d = cellDisplay(c, row)
|
|
119
|
+
if (d) return d
|
|
120
|
+
}
|
|
121
|
+
return `#${row.id}`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Batch operations (row action = 1 id, bulk bar = N ids; the server does the batching) ─────────────
|
|
125
|
+
const { busy: opsBusy, error: opsError, previewDelete, confirmDelete: runDelete, duplicate: runDuplicate, setStatus: runSetStatus } =
|
|
126
|
+
useCollectionOps(collection, () => fetchRows())
|
|
127
|
+
|
|
128
|
+
const deleteOpen = ref(false)
|
|
129
|
+
const deleteReport = ref<BatchDeleteReport | null>(null)
|
|
130
|
+
const deleteIds = ref<number[]>([])
|
|
131
|
+
|
|
132
|
+
// Destructive → confirm dialog. The referrer preview is best-effort: if it fails we still open the dialog
|
|
133
|
+
// with a bare summary (never block a delete on the warning lookup).
|
|
134
|
+
async function askDelete(ids: number[]) {
|
|
135
|
+
if (!ids.length) return
|
|
136
|
+
deleteIds.value = ids
|
|
137
|
+
deleteReport.value = null
|
|
138
|
+
deleteOpen.value = true
|
|
139
|
+
try {
|
|
140
|
+
deleteReport.value = await previewDelete(ids)
|
|
141
|
+
} catch {
|
|
142
|
+
// The referrer lookup FAILED — do not fabricate a "no inbound links" report (that would look exactly
|
|
143
|
+
// like a verified-safe delete). Flag `checked: false` so the dialog cautions the check couldn't run.
|
|
144
|
+
deleteReport.value = { count: ids.length, referencedCount: 0, referenced: [], checked: false }
|
|
145
|
+
opsError.value = null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async function confirmDelete() {
|
|
149
|
+
try {
|
|
150
|
+
await runDelete(deleteIds.value)
|
|
151
|
+
toast.success(t('toast.deleted'))
|
|
152
|
+
deleteOpen.value = false
|
|
153
|
+
} catch {
|
|
154
|
+
// error stays surfaced in the dialog via opsError
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Non-destructive / reversible → no dialog: run, toast, refetch (the composable's onChanged).
|
|
158
|
+
async function onDuplicate(id: number) {
|
|
159
|
+
try {
|
|
160
|
+
await runDuplicate([id])
|
|
161
|
+
toast.success(t('toast.duplicated'))
|
|
162
|
+
} catch {
|
|
163
|
+
toast.error(opsError.value ?? t('list.opFailed'))
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function onBulkStatus(status: 'published' | 'draft') {
|
|
167
|
+
try {
|
|
168
|
+
await runSetStatus([...selected], status)
|
|
169
|
+
toast.success(status === 'published' ? t('toast.published') : t('toast.unpublished'))
|
|
170
|
+
} catch {
|
|
171
|
+
toast.error(opsError.value ?? t('list.opFailed'))
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Monotonic request id: a slow earlier response must not overwrite a newer one.
|
|
176
|
+
let seq = 0
|
|
177
|
+
async function fetchRows() {
|
|
178
|
+
const mine = ++seq
|
|
179
|
+
try {
|
|
180
|
+
const res = await $fetch<{ data: Record<string, unknown>[]; total: number; page: number; perPage: number }>(
|
|
181
|
+
`/api/${collection.value}`,
|
|
182
|
+
{ query: { ...effectiveQuery.value, ...(props.locale ? { locale: props.locale } : {}) } },
|
|
183
|
+
)
|
|
184
|
+
if (mine !== seq) return
|
|
185
|
+
rows.value = res.data
|
|
186
|
+
total.value = res.total
|
|
187
|
+
error.value = null
|
|
188
|
+
// A page that emptied out from under us — e.g. bulk-deleting the last rows of the trailing page —
|
|
189
|
+
// would otherwise strand the user on an out-of-range page showing the "create your first" empty
|
|
190
|
+
// state. Clamp back to the last page that still has rows (REPLACE, so the dead page isn't left as a
|
|
191
|
+
// history entry that Back would bounce off; the query watch refetches).
|
|
192
|
+
if (res.data.length === 0 && res.total > 0 && page.value > 1) {
|
|
193
|
+
clampPage(Math.max(1, Math.ceil(res.total / perPage.value)))
|
|
194
|
+
}
|
|
195
|
+
// Selection is PAGE-SCOPED: any (re)fetch — page, filter, sort, per-page — replaces the visible rows,
|
|
196
|
+
// so the previous selection no longer maps to what's on screen. Clear it. (A locale switch remounts
|
|
197
|
+
// the whole component, so that case clears too.)
|
|
198
|
+
selected.clear()
|
|
199
|
+
} catch (e) {
|
|
200
|
+
if (mine !== seq) return
|
|
201
|
+
// Keep any previously-rendered rows, but surface that this fetch failed (and don't show the
|
|
202
|
+
// "create your first" empty state — that would imply the collection is empty when it isn't).
|
|
203
|
+
error.value = (e as { statusMessage?: string })?.statusMessage ?? t('list.loadError')
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)))
|
|
208
|
+
|
|
209
|
+
// Filters: edited in the Filter panel (a value change debounces; an operator change commits at once), shown
|
|
210
|
+
// as removable chips in the bar. `commitDrafts` builds a fresh clause map from the non-empty draft cells and
|
|
211
|
+
// navigates via `setFilter` (which also resets page → 1).
|
|
212
|
+
let filterTimer: ReturnType<typeof setTimeout> | undefined
|
|
213
|
+
function commitDrafts() {
|
|
214
|
+
const next: Record<string, FilterCell> = {}
|
|
215
|
+
for (const [field, cell] of Object.entries(draft)) {
|
|
216
|
+
if (cell.value !== '' && cell.value != null) next[field] = { op: cell.op, value: cell.value }
|
|
217
|
+
}
|
|
218
|
+
setFilter(next)
|
|
219
|
+
}
|
|
220
|
+
function setValue(field: string, value: string) {
|
|
221
|
+
if (draft[field]) draft[field].value = value
|
|
222
|
+
clearTimeout(filterTimer)
|
|
223
|
+
filterTimer = setTimeout(commitDrafts, 250)
|
|
224
|
+
}
|
|
225
|
+
function setOp(field: string, op: FilterOp) {
|
|
226
|
+
if (draft[field]) draft[field].op = op
|
|
227
|
+
// An operator change is deliberate — re-query immediately (no debounce) if the field has a value.
|
|
228
|
+
clearTimeout(filterTimer)
|
|
229
|
+
commitDrafts()
|
|
230
|
+
}
|
|
231
|
+
function clearFilter(field: string) {
|
|
232
|
+
if (draft[field]) draft[field].value = ''
|
|
233
|
+
commitDrafts() // re-commits the drafts with this one now empty → the clause is dropped (and page → 1)
|
|
234
|
+
}
|
|
235
|
+
function clearAllFilters() {
|
|
236
|
+
for (const cell of Object.values(draft)) cell.value = ''
|
|
237
|
+
setFilter({}) // clear the committed clauses (and page → 1); the draft-resync watch clears the panel
|
|
238
|
+
}
|
|
239
|
+
const activeFilters = computed(() => Object.entries(filter.value).map(([key, cell]) => {
|
|
240
|
+
const col = available.value.find((c) => c.key === key)
|
|
241
|
+
const kind = col?.filterKind ?? 'text'
|
|
242
|
+
return { key, op: cell.op, opLabel: opLabel(kind, cell.op), label: col ? colLabel(col) : key, display: displayValue(kind, cell.value) }
|
|
243
|
+
}))
|
|
244
|
+
onBeforeUnmount(() => clearTimeout(filterTimer))
|
|
245
|
+
|
|
246
|
+
function prev() {
|
|
247
|
+
if (page.value > 1) setPage(page.value - 1)
|
|
248
|
+
}
|
|
249
|
+
function next() {
|
|
250
|
+
if (page.value < totalPages.value) setPage(page.value + 1)
|
|
251
|
+
}
|
|
252
|
+
function arrow(field: string) {
|
|
253
|
+
const dir = sortDirection(sort.value, field)
|
|
254
|
+
return dir === 'asc' ? ' ▲' : dir === 'desc' ? ' ▼' : ''
|
|
255
|
+
}
|
|
256
|
+
// Programmatic sort state for AT (the visual arrow is aria-hidden) — WCAG 1.3.1 / 4.1.2.
|
|
257
|
+
function ariaSort(field: string): 'ascending' | 'descending' | 'none' {
|
|
258
|
+
const dir = sortDirection(sort.value, field)
|
|
259
|
+
return dir === 'asc' ? 'ascending' : dir === 'desc' ? 'descending' : 'none'
|
|
260
|
+
}
|
|
261
|
+
// Polite status announced after every (re)fetch so filter/sort/page changes report their result count
|
|
262
|
+
// to screen readers — WCAG 4.1.3 Status Messages.
|
|
263
|
+
const resultsLabel = computed(() => t(total.value === 1 ? 'list.result' : 'list.results', { total: total.value }))
|
|
264
|
+
// The ONE permanent live region. A live region only announces MUTATIONS to a node already in the tree, so
|
|
265
|
+
// the selection count is folded in here (mutating this region's text) rather than toggling a second
|
|
266
|
+
// role="status" node into the DOM — inserting an already-populated live node is typically not announced,
|
|
267
|
+
// which would silence the first selection. Selection takes precedence; with none, it reports the count.
|
|
268
|
+
const srStatus = computed(() => (selected.size ? t('list.selected', { n: selected.size }) : resultsLabel.value))
|
|
269
|
+
|
|
270
|
+
const dateFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: '2-digit' })
|
|
271
|
+
// A date-only value ('YYYY-MM-DD', from a datetime field with precision 'date') parses as UTC midnight;
|
|
272
|
+
// formatting it in the local zone would shift the calendar day, so pin it to UTC. Full ISO timestamps
|
|
273
|
+
// (createdAt/updatedAt carry a 'Z') and local datetimes keep the local-zone formatter.
|
|
274
|
+
const dateOnlyFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: '2-digit', timeZone: 'UTC' })
|
|
275
|
+
function isDateColumn(col: ListColumn): boolean {
|
|
276
|
+
return col.key === 'createdAt' || col.key === 'updatedAt' || col.field?.type === 'datetime'
|
|
277
|
+
}
|
|
278
|
+
function cellDisplay(col: ListColumn, row: Record<string, unknown>): string {
|
|
279
|
+
const value = row[col.key]
|
|
280
|
+
if (isDateColumn(col) && value != null) {
|
|
281
|
+
const dateOnly = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
|
|
282
|
+
const d = new Date(value as string)
|
|
283
|
+
if (!Number.isNaN(d.getTime())) return (dateOnly ? dateOnlyFmt : dateFmt).format(d)
|
|
284
|
+
}
|
|
285
|
+
return cellText(value)
|
|
286
|
+
}
|
|
287
|
+
function cellText(value: unknown): string {
|
|
288
|
+
if (value == null) return ''
|
|
289
|
+
if (typeof value === 'string') return value.replace(/<[^>]*>/g, '').slice(0, 80)
|
|
290
|
+
if (typeof value === 'object') return JSON.stringify(value).slice(0, 80)
|
|
291
|
+
return String(value)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Per-row translation status (A4 attaches `$translations`: locale → sibling id, or null). Present
|
|
295
|
+
// locales link to their sibling; missing ones offer create-and-link, carrying the translation group.
|
|
296
|
+
function rowTranslations(row: Record<string, unknown>) {
|
|
297
|
+
const map = (row.$translations ?? {}) as Record<string, number | null>
|
|
298
|
+
const group = row.translationGroup as string | undefined
|
|
299
|
+
return locales.map((loc) => {
|
|
300
|
+
const id = map[loc]
|
|
301
|
+
return id != null
|
|
302
|
+
? { loc, present: true, to: `/admin/${collection.value}/${id}?locale=${loc}` }
|
|
303
|
+
: { loc, present: false, to: `/admin/${collection.value}/new?locale=${loc}${group ? `&group=${group}` : ''}` }
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Toolbar popovers (filter / columns). Plain inline panels (not teleported) so they stay testable and
|
|
308
|
+
// keep their scoped styles; a document listener closes them on an outside click.
|
|
309
|
+
const toolsRef = ref<HTMLElement | null>(null)
|
|
310
|
+
const openPanel = ref<'filter' | 'columns' | null>(null)
|
|
311
|
+
// The element that opened the current panel — Escape restores focus to it (otherwise closing the panel
|
|
312
|
+
// from inside drops keyboard focus onto <body>, stranding the keyboard user at the top of the document).
|
|
313
|
+
let panelTrigger: HTMLElement | null = null
|
|
314
|
+
function togglePanel(which: 'filter' | 'columns', e: MouseEvent) {
|
|
315
|
+
if (openPanel.value === which) { openPanel.value = null; return }
|
|
316
|
+
panelTrigger = e.currentTarget as HTMLElement
|
|
317
|
+
openPanel.value = which
|
|
318
|
+
}
|
|
319
|
+
function onDocPointer(e: PointerEvent) {
|
|
320
|
+
// Outside click: close but do NOT steal focus — it belongs wherever the user just clicked.
|
|
321
|
+
if (toolsRef.value && !toolsRef.value.contains(e.target as Node)) openPanel.value = null
|
|
322
|
+
}
|
|
323
|
+
function onDocKeydown(e: KeyboardEvent) {
|
|
324
|
+
if (e.key === 'Escape' && openPanel.value) {
|
|
325
|
+
openPanel.value = null
|
|
326
|
+
panelTrigger?.focus() // return focus to the trigger, per the disclosure/APG pattern
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
onMounted(() => {
|
|
330
|
+
document.addEventListener('pointerdown', onDocPointer)
|
|
331
|
+
document.addEventListener('keydown', onDocKeydown)
|
|
332
|
+
})
|
|
333
|
+
onBeforeUnmount(() => {
|
|
334
|
+
document.removeEventListener('pointerdown', onDocPointer)
|
|
335
|
+
document.removeEventListener('keydown', onDocKeydown)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// Exactly ONE refetch per navigation: the effective (sanitized) query is stringified so an identical or
|
|
339
|
+
// junk-only URL change never refetches, and a single change fires a single fetch. Every URL-driven refetch
|
|
340
|
+
// clears the page-scoped selection inside `fetchRows`.
|
|
341
|
+
watch(() => JSON.stringify(effectiveQuery.value), fetchRows)
|
|
342
|
+
// The only host (pages/admin/[collection]/index.vue) keys this component on collection + locale, so a
|
|
343
|
+
// collection- or locale-change fully remounts it (resetting committed filter/draft/page/selection from the
|
|
344
|
+
// clean target URL) while a sort/page/perPage/filter query change does NOT remount — the derived computeds
|
|
345
|
+
// recompute in place, keeping the filter panel open and focus intact. The initial load is the
|
|
346
|
+
// `await fetchRows()` below; subsequent loads flow through the effective-query watch above.
|
|
347
|
+
await fetchRows()
|
|
348
|
+
</script>
|
|
349
|
+
|
|
350
|
+
<template>
|
|
351
|
+
<div class="list">
|
|
352
|
+
<div class="list__bar">
|
|
353
|
+
<div v-if="locale" class="list__locales" role="group" :aria-label="t('a11y.contentLocale')">
|
|
354
|
+
<NuxtLink
|
|
355
|
+
v-for="loc in locales"
|
|
356
|
+
:key="loc"
|
|
357
|
+
:to="`/admin/${collection}?locale=${loc}`"
|
|
358
|
+
class="list__locale"
|
|
359
|
+
:class="{ 'list__locale--active': loc === locale }"
|
|
360
|
+
:aria-current="loc === locale ? 'true' : undefined"
|
|
361
|
+
>{{ loc.toUpperCase() }}</NuxtLink>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<div ref="toolsRef" class="list__tools">
|
|
365
|
+
<div class="list__tool">
|
|
366
|
+
<UiButton
|
|
367
|
+
type="button"
|
|
368
|
+
size="sm"
|
|
369
|
+
icon="filter"
|
|
370
|
+
aria-controls="list-filter-panel"
|
|
371
|
+
:aria-expanded="openPanel === 'filter'"
|
|
372
|
+
class="list__tool-btn"
|
|
373
|
+
@click="togglePanel('filter', $event)"
|
|
374
|
+
>
|
|
375
|
+
{{ t('list.filter') }}<span v-if="activeFilters.length" class="list__tool-count">{{ activeFilters.length }}</span>
|
|
376
|
+
</UiButton>
|
|
377
|
+
<div v-if="openPanel === 'filter'" id="list-filter-panel" class="list__panel list__panel--filter">
|
|
378
|
+
<p v-if="!filterableColumns.length" class="list__panel-empty">{{ t('list.noFilters') }}</p>
|
|
379
|
+
<div v-for="c in filterableColumns" :key="c.key" class="list__filter-row">
|
|
380
|
+
<span class="list__filter-label">{{ colLabel(c) }}</span>
|
|
381
|
+
<div class="list__filter-controls">
|
|
382
|
+
<select
|
|
383
|
+
v-if="opsFor(c).length > 1"
|
|
384
|
+
class="list__filter-op"
|
|
385
|
+
:data-filter-op="c.key"
|
|
386
|
+
:aria-label="`${t('filter.operator')} — ${colLabel(c)}`"
|
|
387
|
+
:value="draft[c.key]?.op"
|
|
388
|
+
@change="setOp(c.key, ($event.target as HTMLSelectElement).value as FilterOp)"
|
|
389
|
+
>
|
|
390
|
+
<option v-for="op in opsFor(c)" :key="op" :value="op">{{ opLabel(c.filterKind ?? 'text', op) }}</option>
|
|
391
|
+
</select>
|
|
392
|
+
|
|
393
|
+
<input
|
|
394
|
+
v-if="c.filterKind === 'datetime'"
|
|
395
|
+
type="date"
|
|
396
|
+
class="list__filter"
|
|
397
|
+
:data-filter="c.key"
|
|
398
|
+
:aria-label="colLabel(c)"
|
|
399
|
+
:value="draft[c.key]?.value ?? ''"
|
|
400
|
+
@input="setValue(c.key, ($event.target as HTMLInputElement).value)"
|
|
401
|
+
>
|
|
402
|
+
<input
|
|
403
|
+
v-else-if="c.filterKind === 'number' || c.filterKind === 'ref' || c.filterKind === 'idSet'"
|
|
404
|
+
type="number"
|
|
405
|
+
class="list__filter"
|
|
406
|
+
:data-filter="c.key"
|
|
407
|
+
:aria-label="colLabel(c)"
|
|
408
|
+
:value="draft[c.key]?.value ?? ''"
|
|
409
|
+
@input="setValue(c.key, ($event.target as HTMLInputElement).value)"
|
|
410
|
+
>
|
|
411
|
+
<select
|
|
412
|
+
v-else-if="c.filterKind === 'boolean'"
|
|
413
|
+
class="list__filter"
|
|
414
|
+
:data-filter="c.key"
|
|
415
|
+
:aria-label="colLabel(c)"
|
|
416
|
+
:value="draft[c.key]?.value ?? ''"
|
|
417
|
+
@change="setValue(c.key, ($event.target as HTMLSelectElement).value)"
|
|
418
|
+
>
|
|
419
|
+
<option value="">—</option>
|
|
420
|
+
<option value="true">{{ t('filter.bool.true') }}</option>
|
|
421
|
+
<option value="false">{{ t('filter.bool.false') }}</option>
|
|
422
|
+
</select>
|
|
423
|
+
<!-- Single choice (`enum`) and multi choice (`stringSet`) both pick a stored VALUE from the
|
|
424
|
+
field's choices; a free-text box would let the user type the visible LABEL and match nothing. -->
|
|
425
|
+
<select
|
|
426
|
+
v-else-if="c.filterKind === 'enum' || c.filterKind === 'stringSet'"
|
|
427
|
+
class="list__filter"
|
|
428
|
+
:data-filter="c.key"
|
|
429
|
+
:aria-label="colLabel(c)"
|
|
430
|
+
:value="draft[c.key]?.value ?? ''"
|
|
431
|
+
@change="setValue(c.key, ($event.target as HTMLSelectElement).value)"
|
|
432
|
+
>
|
|
433
|
+
<option value="">—</option>
|
|
434
|
+
<option v-for="o in enumOptions(c)" :key="o.value" :value="o.value">{{ o.label }}</option>
|
|
435
|
+
</select>
|
|
436
|
+
<input
|
|
437
|
+
v-else
|
|
438
|
+
type="text"
|
|
439
|
+
class="list__filter"
|
|
440
|
+
:data-filter="c.key"
|
|
441
|
+
:aria-label="colLabel(c)"
|
|
442
|
+
:value="draft[c.key]?.value ?? ''"
|
|
443
|
+
:placeholder="t('list.filterPlaceholder', { name: colLabel(c) })"
|
|
444
|
+
@input="setValue(c.key, ($event.target as HTMLInputElement).value)"
|
|
445
|
+
>
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
<button v-if="activeFilters.length" type="button" class="list__panel-action" @click="clearAllFilters">{{ t('list.filterClearAll') }}</button>
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
<div class="list__tool">
|
|
453
|
+
<UiButton
|
|
454
|
+
type="button"
|
|
455
|
+
size="sm"
|
|
456
|
+
icon="columns"
|
|
457
|
+
aria-controls="list-columns-panel"
|
|
458
|
+
:aria-expanded="openPanel === 'columns'"
|
|
459
|
+
class="list__tool-btn"
|
|
460
|
+
@click="togglePanel('columns', $event)"
|
|
461
|
+
>{{ t('list.columns') }}</UiButton>
|
|
462
|
+
<div v-if="openPanel === 'columns'" id="list-columns-panel" class="list__panel">
|
|
463
|
+
<label v-for="c in available" :key="c.key" class="list__col-row">
|
|
464
|
+
<UiCheckbox
|
|
465
|
+
class="list__col-check"
|
|
466
|
+
:data-col="c.key"
|
|
467
|
+
:model-value="visibleKeys.includes(c.key)"
|
|
468
|
+
:disabled="visibleKeys.length === 1 && visibleKeys.includes(c.key)"
|
|
469
|
+
@update:model-value="() => toggleColumn(c.key)"
|
|
470
|
+
/>
|
|
471
|
+
<span>{{ colLabel(c) }}</span>
|
|
472
|
+
</label>
|
|
473
|
+
<button v-if="!columnsAreDefault" type="button" class="list__panel-action" @click="resetColumns">{{ t('list.columnsReset') }}</button>
|
|
474
|
+
</div>
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
<NuxtLink :to="`/admin/${collection}/new${localeQuery}`" class="list__new">{{ newLabel }}</NuxtLink>
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
<!-- Bulk action bar: chrome (flex:0 0 auto) that appears when ≥1 row is selected, shrinking the one
|
|
482
|
+
scroll region rather than overlaying it. Publish/Unpublish are schema-driven (only when status).
|
|
483
|
+
NOT a live region: the selection count is announced through the permanent .list__sr-status region
|
|
484
|
+
(see `srStatus`); a v-if-toggled role="status" here would double up and stay silent on first show. -->
|
|
485
|
+
<div v-if="selected.size" class="list__bulkbar">
|
|
486
|
+
<span class="list__bulk-count">{{ t('list.selected', { n: selected.size }) }}</span>
|
|
487
|
+
<div class="list__bulk-actions">
|
|
488
|
+
<template v-if="schema.status">
|
|
489
|
+
<UiButton type="button" size="sm" variant="secondary" :disabled="opsBusy" @click="onBulkStatus('published')">{{ t('list.bulkPublish') }}</UiButton>
|
|
490
|
+
<UiButton type="button" size="sm" variant="secondary" :disabled="opsBusy" @click="onBulkStatus('draft')">{{ t('list.bulkUnpublish') }}</UiButton>
|
|
491
|
+
</template>
|
|
492
|
+
<UiButton type="button" size="sm" variant="danger" :disabled="opsBusy" @click="askDelete([...selected])">{{ t('list.bulkDelete') }}</UiButton>
|
|
493
|
+
<UiButton type="button" size="sm" variant="ghost" @click="clearSelection">{{ t('list.clearSelection') }}</UiButton>
|
|
494
|
+
</div>
|
|
495
|
+
</div>
|
|
496
|
+
|
|
497
|
+
<div v-if="activeFilters.length" class="list__chips">
|
|
498
|
+
<span v-for="f in activeFilters" :key="f.key" class="list__chip">
|
|
499
|
+
<span class="list__chip-label">{{ f.label }} {{ f.opLabel }}:</span> {{ f.display }}
|
|
500
|
+
<button type="button" class="list__chip-x" :aria-label="t('list.removeFilter', { name: f.label })" @click="clearFilter(f.key)">
|
|
501
|
+
<UiIcon name="x" :size="12" />
|
|
502
|
+
</button>
|
|
503
|
+
</span>
|
|
504
|
+
</div>
|
|
505
|
+
|
|
506
|
+
<UiAlert v-if="error" variant="error" class="list__error">
|
|
507
|
+
{{ error }}
|
|
508
|
+
<UiButton type="button" variant="secondary" size="sm" class="list__retry" @click="fetchRows">{{ t('common.retry') }}</UiButton>
|
|
509
|
+
</UiAlert>
|
|
510
|
+
|
|
511
|
+
<!-- The one scroll region: the table + its alternate empty-states scroll here while the bar, chips,
|
|
512
|
+
error and pager stay fixed (see the admin layout's scroll-ownership convention). -->
|
|
513
|
+
<div class="list__scroll">
|
|
514
|
+
<table class="list__table">
|
|
515
|
+
<thead>
|
|
516
|
+
<tr>
|
|
517
|
+
<th class="list__select-th" scope="col">
|
|
518
|
+
<UiCheckbox
|
|
519
|
+
:model-value="allSelected"
|
|
520
|
+
:indeterminate="headerIndeterminate"
|
|
521
|
+
:aria-label="t('list.selectAll')"
|
|
522
|
+
@update:model-value="toggleAll"
|
|
523
|
+
/>
|
|
524
|
+
</th>
|
|
525
|
+
<!-- Row actions: an always-visible, left-frozen chrome column placed right after Select. Its
|
|
526
|
+
accessible name is real visually-hidden text, not aria-label on a static <th> (which several
|
|
527
|
+
screen-reader / browser pairs ignore on non-interactive table cells). -->
|
|
528
|
+
<th class="list__actions-th" scope="col">
|
|
529
|
+
<span class="list__vh">{{ t('a11y.rowActions') }}</span>
|
|
530
|
+
</th>
|
|
531
|
+
<th
|
|
532
|
+
v-for="c in visibleColumns"
|
|
533
|
+
:key="c.key"
|
|
534
|
+
scope="col"
|
|
535
|
+
:aria-sort="c.sortable ? ariaSort(c.key) : undefined"
|
|
536
|
+
:class="{ 'list__th-narrow': c.type === 'translations' || c.type === 'deadRefs' }"
|
|
537
|
+
>
|
|
538
|
+
<button v-if="c.sortable" type="button" class="list__sort" @click="setSort(c.key)">{{ colLabel(c) }}<span aria-hidden="true">{{ arrow(c.key) }}</span></button>
|
|
539
|
+
<span v-else>{{ colLabel(c) }}</span>
|
|
540
|
+
</th>
|
|
541
|
+
</tr>
|
|
542
|
+
</thead>
|
|
543
|
+
<tbody>
|
|
544
|
+
<tr v-for="row in rows" :key="String(row.id)" class="list__row">
|
|
545
|
+
<td class="list__select-cell">
|
|
546
|
+
<UiCheckbox
|
|
547
|
+
:model-value="selected.has(Number(row.id))"
|
|
548
|
+
:aria-label="t('list.selectRow', { name: rowLabel(row) })"
|
|
549
|
+
@update:model-value="(v) => toggleRow(Number(row.id), v)"
|
|
550
|
+
/>
|
|
551
|
+
</td>
|
|
552
|
+
<td class="list__actions-cell">
|
|
553
|
+
<div class="list__row-actions">
|
|
554
|
+
<NuxtLink :to="`/admin/${collection}/${row.id}${localeQuery}`" class="list__action-btn" :aria-label="t('list.rowEdit', { name: rowLabel(row) })">
|
|
555
|
+
<UiIcon name="pencil" :size="15" />
|
|
556
|
+
</NuxtLink>
|
|
557
|
+
<button type="button" class="list__action-btn" :disabled="opsBusy" :aria-label="t('list.rowDuplicate', { name: rowLabel(row) })" @click="onDuplicate(Number(row.id))">
|
|
558
|
+
<UiIcon name="copy" :size="15" />
|
|
559
|
+
</button>
|
|
560
|
+
<button type="button" class="list__action-btn list__action-btn--danger" :disabled="opsBusy" :aria-label="t('list.rowDelete', { name: rowLabel(row) })" @click="askDelete([Number(row.id)])">
|
|
561
|
+
<UiIcon name="trash" :size="15" />
|
|
562
|
+
</button>
|
|
563
|
+
</div>
|
|
564
|
+
</td>
|
|
565
|
+
<td v-for="c in visibleColumns" :key="c.key" :class="{ 'list__narrow-cell': c.type === 'translations' || c.type === 'deadRefs' }">
|
|
566
|
+
<span v-if="c.type === 'deadRefs'" class="list__deadrefs">
|
|
567
|
+
<UiIcon
|
|
568
|
+
v-if="row.$hasDeadRefs"
|
|
569
|
+
name="triangle-alert"
|
|
570
|
+
:size="15"
|
|
571
|
+
class="list__deadref-icon"
|
|
572
|
+
role="img"
|
|
573
|
+
:aria-label="t('deadRefs.listWarning')"
|
|
574
|
+
:title="t('deadRefs.listWarning')"
|
|
575
|
+
/>
|
|
576
|
+
</span>
|
|
577
|
+
<span v-else-if="c.type === 'translations'" class="list__transl">
|
|
578
|
+
<NuxtLink
|
|
579
|
+
v-for="tr in rowTranslations(row)"
|
|
580
|
+
:key="tr.loc"
|
|
581
|
+
:to="tr.to"
|
|
582
|
+
:data-loc="tr.loc"
|
|
583
|
+
class="list__badge"
|
|
584
|
+
:class="tr.present ? 'list__badge--present' : 'list__badge--missing'"
|
|
585
|
+
:title="tr.present ? t('list.editLocale', { loc: tr.loc.toUpperCase() }) : t('list.createLocale', { loc: tr.loc.toUpperCase() })"
|
|
586
|
+
:aria-label="tr.present ? t('list.editLocale', { loc: tr.loc.toUpperCase() }) : t('list.createLocale', { loc: tr.loc.toUpperCase() })"
|
|
587
|
+
>{{ tr.loc.toUpperCase() }}</NuxtLink>
|
|
588
|
+
</span>
|
|
589
|
+
<template v-else>{{ cellDisplay(c, row) }}</template>
|
|
590
|
+
</td>
|
|
591
|
+
</tr>
|
|
592
|
+
</tbody>
|
|
593
|
+
</table>
|
|
594
|
+
|
|
595
|
+
<UiEmptyState
|
|
596
|
+
v-if="!rows.length && !error && activeFilters.length"
|
|
597
|
+
icon="filter"
|
|
598
|
+
:title="t('list.noMatch.title')"
|
|
599
|
+
:description="t('list.noMatch.desc', { name: label })"
|
|
600
|
+
>
|
|
601
|
+
<template #action>
|
|
602
|
+
<button type="button" class="list__new" @click="clearAllFilters">{{ t('list.filterClearAll') }}</button>
|
|
603
|
+
</template>
|
|
604
|
+
</UiEmptyState>
|
|
605
|
+
<UiEmptyState
|
|
606
|
+
v-else-if="!rows.length && !error"
|
|
607
|
+
icon="file-text"
|
|
608
|
+
:title="t('list.empty.title')"
|
|
609
|
+
:description="t('list.empty.desc', { name: label })"
|
|
610
|
+
>
|
|
611
|
+
<template #action>
|
|
612
|
+
<NuxtLink :to="`/admin/${collection}/new${localeQuery}`" class="list__new">{{ newLabel }}</NuxtLink>
|
|
613
|
+
</template>
|
|
614
|
+
</UiEmptyState>
|
|
615
|
+
</div>
|
|
616
|
+
|
|
617
|
+
<p class="list__sr-status" role="status" aria-live="polite">{{ srStatus }}</p>
|
|
618
|
+
|
|
619
|
+
<div class="list__pager">
|
|
620
|
+
<UiButton type="button" size="sm" :disabled="page <= 1" @click="prev">{{ t('list.prev') }}</UiButton>
|
|
621
|
+
<span class="list__page">{{ t('list.page', { page, totalPages, total }) }}</span>
|
|
622
|
+
<UiButton type="button" size="sm" :disabled="page >= totalPages" @click="next">{{ t('list.next') }}</UiButton>
|
|
623
|
+
<label class="list__perpage">
|
|
624
|
+
<span class="list__perpage-label">{{ t('list.perPage') }}</span>
|
|
625
|
+
<select
|
|
626
|
+
class="list__perpage-select"
|
|
627
|
+
:value="perPage"
|
|
628
|
+
:aria-label="t('list.perPage')"
|
|
629
|
+
@change="setPerPage(Number(($event.target as HTMLSelectElement).value))"
|
|
630
|
+
>
|
|
631
|
+
<option v-for="n in PER_PAGE_OPTIONS" :key="n" :value="n">{{ n }}</option>
|
|
632
|
+
</select>
|
|
633
|
+
</label>
|
|
634
|
+
</div>
|
|
635
|
+
|
|
636
|
+
<CollectionDeleteDialog
|
|
637
|
+
:open="deleteOpen"
|
|
638
|
+
:report="deleteReport"
|
|
639
|
+
:busy="opsBusy"
|
|
640
|
+
:error="opsError"
|
|
641
|
+
@update:open="deleteOpen = $event"
|
|
642
|
+
@confirm="confirmDelete"
|
|
643
|
+
/>
|
|
644
|
+
</div>
|
|
645
|
+
</template>
|
|
646
|
+
|
|
647
|
+
<style lang="scss">
|
|
648
|
+
.list__retry {
|
|
649
|
+
margin-inline-start: var(--space-3);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Visually-hidden polite status region (mirrors the ui sr-only mixin; admin can't @use a ui-layer scss).
|
|
653
|
+
.list__sr-status {
|
|
654
|
+
position: absolute;
|
|
655
|
+
width: 1px;
|
|
656
|
+
height: 1px;
|
|
657
|
+
padding: 0;
|
|
658
|
+
margin: -1px;
|
|
659
|
+
overflow: hidden;
|
|
660
|
+
clip: rect(0, 0, 0, 0);
|
|
661
|
+
white-space: nowrap;
|
|
662
|
+
border: 0;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Visually-hidden text for a static cell that must be announced but not shown (the Actions column header).
|
|
666
|
+
// Same technique as .list__sr-status above; chosen over aria-label, which is unreliably announced on
|
|
667
|
+
// non-interactive table cells.
|
|
668
|
+
.list__vh {
|
|
669
|
+
position: absolute;
|
|
670
|
+
width: 1px;
|
|
671
|
+
height: 1px;
|
|
672
|
+
padding: 0;
|
|
673
|
+
margin: -1px;
|
|
674
|
+
overflow: hidden;
|
|
675
|
+
clip: rect(0, 0, 0, 0);
|
|
676
|
+
white-space: nowrap;
|
|
677
|
+
border: 0;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Fills the height handed down and stays fixed itself; scroll is delegated to .list__scroll. Overflow is
|
|
681
|
+
// left VISIBLE (not a scroll container) so the inline Filter/Columns popovers are never clipped.
|
|
682
|
+
.list {
|
|
683
|
+
// Frozen-column token: the actions column's sticky `left` must equal the select column's width
|
|
684
|
+
// (1.25rem checkbox + 2×var(--space-3) cell padding). Keep in sync if the checkbox size or padding change.
|
|
685
|
+
--list-col-select: 2.75rem;
|
|
686
|
+
display: flex;
|
|
687
|
+
flex-direction: column;
|
|
688
|
+
gap: var(--space-4);
|
|
689
|
+
flex: 1 1 auto;
|
|
690
|
+
min-height: 0;
|
|
691
|
+
|
|
692
|
+
// Fixed chrome around the one scroll region.
|
|
693
|
+
&__bar,
|
|
694
|
+
&__bulkbar,
|
|
695
|
+
&__chips,
|
|
696
|
+
&__error,
|
|
697
|
+
&__pager {
|
|
698
|
+
flex: 0 0 auto;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// The only scroller: the table (+ its empty-states) scroll here; the bar/chips/error/pager stay put.
|
|
702
|
+
&__scroll {
|
|
703
|
+
flex: 1 1 auto;
|
|
704
|
+
min-height: 0;
|
|
705
|
+
overflow: auto;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
&__bar {
|
|
709
|
+
display: flex;
|
|
710
|
+
align-items: center;
|
|
711
|
+
gap: var(--space-3);
|
|
712
|
+
}
|
|
713
|
+
&__locales {
|
|
714
|
+
display: flex;
|
|
715
|
+
gap: var(--space-1);
|
|
716
|
+
}
|
|
717
|
+
&__locale {
|
|
718
|
+
padding: var(--space-1) var(--space-2);
|
|
719
|
+
border: 1px solid var(--color-border);
|
|
720
|
+
border-radius: var(--radius-sm);
|
|
721
|
+
font-size: var(--text-sm);
|
|
722
|
+
font-weight: var(--weight-medium);
|
|
723
|
+
color: var(--color-text-muted);
|
|
724
|
+
text-decoration: none;
|
|
725
|
+
|
|
726
|
+
&--active {
|
|
727
|
+
background: var(--color-surface);
|
|
728
|
+
color: var(--color-text);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Filter / Columns toolbar
|
|
733
|
+
&__tools {
|
|
734
|
+
display: flex;
|
|
735
|
+
align-items: center;
|
|
736
|
+
gap: var(--space-2);
|
|
737
|
+
}
|
|
738
|
+
&__tool {
|
|
739
|
+
position: relative;
|
|
740
|
+
}
|
|
741
|
+
&__tool-count {
|
|
742
|
+
margin-left: var(--space-1);
|
|
743
|
+
padding: 0 var(--space-1);
|
|
744
|
+
border-radius: var(--radius-sm);
|
|
745
|
+
background: var(--color-primary-soft);
|
|
746
|
+
color: var(--color-primary);
|
|
747
|
+
font-size: var(--text-xs);
|
|
748
|
+
font-weight: var(--weight-medium);
|
|
749
|
+
}
|
|
750
|
+
&__panel {
|
|
751
|
+
position: absolute;
|
|
752
|
+
z-index: 20;
|
|
753
|
+
top: calc(100% + var(--space-1));
|
|
754
|
+
left: 0;
|
|
755
|
+
min-width: 16rem;
|
|
756
|
+
max-height: 60vh;
|
|
757
|
+
overflow-y: auto;
|
|
758
|
+
display: flex;
|
|
759
|
+
flex-direction: column;
|
|
760
|
+
gap: var(--space-2);
|
|
761
|
+
padding: var(--space-3);
|
|
762
|
+
border: 1px solid var(--color-border);
|
|
763
|
+
border-radius: var(--radius-md);
|
|
764
|
+
background: var(--color-surface);
|
|
765
|
+
box-shadow: var(--shadow-md, 0 6px 24px rgb(0 0 0 / 12%));
|
|
766
|
+
}
|
|
767
|
+
&__panel-empty {
|
|
768
|
+
margin: 0;
|
|
769
|
+
color: var(--color-text-muted);
|
|
770
|
+
font-size: var(--text-sm);
|
|
771
|
+
}
|
|
772
|
+
&__panel-action {
|
|
773
|
+
align-self: flex-start;
|
|
774
|
+
margin-top: var(--space-1);
|
|
775
|
+
padding: 0;
|
|
776
|
+
border: 0;
|
|
777
|
+
background: none;
|
|
778
|
+
color: var(--color-primary);
|
|
779
|
+
font: inherit;
|
|
780
|
+
font-size: var(--text-sm);
|
|
781
|
+
cursor: pointer;
|
|
782
|
+
}
|
|
783
|
+
// The inline filter rows need room for name + operator + value; the columns panel stays compact.
|
|
784
|
+
&__panel--filter {
|
|
785
|
+
min-width: 30rem;
|
|
786
|
+
}
|
|
787
|
+
// Name, operator and value on ONE line: a stacked label doubled the panel's height for no information.
|
|
788
|
+
&__filter-row {
|
|
789
|
+
display: flex;
|
|
790
|
+
align-items: center;
|
|
791
|
+
gap: var(--space-2);
|
|
792
|
+
font-size: var(--text-sm);
|
|
793
|
+
}
|
|
794
|
+
// Fixed basis so the names form an aligned column and the controls all start at the same x.
|
|
795
|
+
&__filter-label {
|
|
796
|
+
flex: 0 0 6.5rem;
|
|
797
|
+
min-width: 0;
|
|
798
|
+
overflow: hidden;
|
|
799
|
+
text-overflow: ellipsis;
|
|
800
|
+
white-space: nowrap;
|
|
801
|
+
color: var(--color-text-muted);
|
|
802
|
+
}
|
|
803
|
+
// Operator select + value control; the value control takes the remaining width.
|
|
804
|
+
&__filter-controls {
|
|
805
|
+
flex: 1 1 auto;
|
|
806
|
+
min-width: 0;
|
|
807
|
+
display: flex;
|
|
808
|
+
gap: var(--space-1);
|
|
809
|
+
align-items: center;
|
|
810
|
+
}
|
|
811
|
+
&__filter-op {
|
|
812
|
+
flex: 0 0 auto;
|
|
813
|
+
max-width: 45%;
|
|
814
|
+
padding: var(--space-1) var(--space-2);
|
|
815
|
+
border: 1px solid var(--color-border);
|
|
816
|
+
border-radius: var(--radius-sm);
|
|
817
|
+
background: var(--color-surface);
|
|
818
|
+
color: var(--color-text-muted);
|
|
819
|
+
font: inherit;
|
|
820
|
+
font-size: var(--text-sm);
|
|
821
|
+
}
|
|
822
|
+
&__filter-controls .list__filter {
|
|
823
|
+
flex: 1 1 auto;
|
|
824
|
+
min-width: 0;
|
|
825
|
+
}
|
|
826
|
+
&__col-row {
|
|
827
|
+
display: flex;
|
|
828
|
+
align-items: center;
|
|
829
|
+
gap: var(--space-2);
|
|
830
|
+
font-size: var(--text-sm);
|
|
831
|
+
cursor: pointer;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
&__new {
|
|
835
|
+
margin-left: auto;
|
|
836
|
+
padding: var(--space-2) var(--space-4);
|
|
837
|
+
border-radius: var(--radius-md);
|
|
838
|
+
background: var(--color-primary);
|
|
839
|
+
color: var(--color-on-primary);
|
|
840
|
+
text-decoration: none;
|
|
841
|
+
font-size: var(--text-sm);
|
|
842
|
+
font-weight: var(--weight-medium);
|
|
843
|
+
|
|
844
|
+
&:hover {
|
|
845
|
+
background: var(--color-primary-hover);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Active-filter chips
|
|
850
|
+
&__chips {
|
|
851
|
+
display: flex;
|
|
852
|
+
flex-wrap: wrap;
|
|
853
|
+
gap: var(--space-2);
|
|
854
|
+
}
|
|
855
|
+
&__chip {
|
|
856
|
+
display: inline-flex;
|
|
857
|
+
align-items: center;
|
|
858
|
+
gap: var(--space-1);
|
|
859
|
+
padding: var(--space-1) var(--space-2);
|
|
860
|
+
border: 1px solid var(--color-border);
|
|
861
|
+
border-radius: var(--radius-sm);
|
|
862
|
+
background: var(--color-surface);
|
|
863
|
+
font-size: var(--text-sm);
|
|
864
|
+
}
|
|
865
|
+
&__chip-label {
|
|
866
|
+
color: var(--color-text-muted);
|
|
867
|
+
}
|
|
868
|
+
&__chip-x {
|
|
869
|
+
display: inline-flex;
|
|
870
|
+
padding: 0;
|
|
871
|
+
border: 0;
|
|
872
|
+
background: none;
|
|
873
|
+
color: var(--color-text-muted);
|
|
874
|
+
cursor: pointer;
|
|
875
|
+
|
|
876
|
+
&:hover {
|
|
877
|
+
color: var(--color-text);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
&__table {
|
|
882
|
+
width: 100%;
|
|
883
|
+
border-collapse: collapse;
|
|
884
|
+
font-size: var(--text-sm);
|
|
885
|
+
|
|
886
|
+
th,
|
|
887
|
+
td {
|
|
888
|
+
text-align: start;
|
|
889
|
+
padding: var(--space-2) var(--space-3);
|
|
890
|
+
border-bottom: 1px solid var(--color-border);
|
|
891
|
+
vertical-align: top;
|
|
892
|
+
}
|
|
893
|
+
// Keep the column headers visible while the rows scroll inside .list__scroll. A solid background is
|
|
894
|
+
// required so scrolling rows don't show through. Part of the frozen-column z-index stack (see the
|
|
895
|
+
// &__select-* / &__actions-* rules): scrolling body td 0 < frozen body td 1 < scrolling header th 2 <
|
|
896
|
+
// frozen corner th 3, so nothing bleeds through on either scroll axis.
|
|
897
|
+
thead th {
|
|
898
|
+
position: sticky;
|
|
899
|
+
top: 0;
|
|
900
|
+
z-index: 2; // scrolling header: above scrolling body cells (0) and the frozen body columns (1)
|
|
901
|
+
background: var(--color-bg);
|
|
902
|
+
}
|
|
903
|
+
// Frozen corner: the select + actions headers are sticky on BOTH axes, so they must sit above every
|
|
904
|
+
// scrolling header AND every frozen body column where the two frozen edges cross.
|
|
905
|
+
thead th.list__select-th,
|
|
906
|
+
thead th.list__actions-th {
|
|
907
|
+
z-index: 3;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
&__sort {
|
|
911
|
+
background: none;
|
|
912
|
+
border: 0;
|
|
913
|
+
padding: 0;
|
|
914
|
+
font: inherit;
|
|
915
|
+
font-weight: var(--weight-medium);
|
|
916
|
+
cursor: pointer;
|
|
917
|
+
color: var(--color-text);
|
|
918
|
+
}
|
|
919
|
+
&__filter {
|
|
920
|
+
width: 100%;
|
|
921
|
+
padding: var(--space-1) var(--space-2);
|
|
922
|
+
border: 1px solid var(--color-border);
|
|
923
|
+
border-radius: var(--radius-sm);
|
|
924
|
+
font: inherit;
|
|
925
|
+
}
|
|
926
|
+
// Compact, de-emphasised info columns (translations · dead-reference warning).
|
|
927
|
+
&__th-narrow,
|
|
928
|
+
&__narrow-cell {
|
|
929
|
+
width: 1%;
|
|
930
|
+
white-space: nowrap;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Dead-reference warning: an amber triangle, shown only on rows holding a stale reference.
|
|
934
|
+
&__deadrefs {
|
|
935
|
+
display: inline-flex;
|
|
936
|
+
}
|
|
937
|
+
&__deadref-icon {
|
|
938
|
+
color: var(--color-warning);
|
|
939
|
+
}
|
|
940
|
+
&__transl {
|
|
941
|
+
display: inline-flex;
|
|
942
|
+
gap: 2px;
|
|
943
|
+
flex-wrap: wrap;
|
|
944
|
+
}
|
|
945
|
+
&__badge {
|
|
946
|
+
display: inline-flex;
|
|
947
|
+
align-items: center;
|
|
948
|
+
justify-content: center;
|
|
949
|
+
min-width: 1.4rem;
|
|
950
|
+
padding: 0 3px;
|
|
951
|
+
border: 1px solid transparent;
|
|
952
|
+
border-radius: var(--radius-sm);
|
|
953
|
+
font-size: 0.625rem;
|
|
954
|
+
font-weight: var(--weight-medium);
|
|
955
|
+
line-height: 1.5;
|
|
956
|
+
letter-spacing: 0.02em;
|
|
957
|
+
text-decoration: none;
|
|
958
|
+
|
|
959
|
+
&--present {
|
|
960
|
+
background: var(--color-primary-soft);
|
|
961
|
+
color: var(--color-primary);
|
|
962
|
+
}
|
|
963
|
+
&--missing {
|
|
964
|
+
color: var(--color-text-muted);
|
|
965
|
+
border-color: var(--color-border);
|
|
966
|
+
border-style: dashed;
|
|
967
|
+
}
|
|
968
|
+
&:focus-visible {
|
|
969
|
+
outline: 2px solid var(--color-focus);
|
|
970
|
+
outline-offset: -2px;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Selection column — the first frozen chrome column, pinned to the left with `position: sticky` inside
|
|
975
|
+
// the existing .list__scroll (no new scroll container). Opaque `var(--color-bg)` so horizontally-scrolled
|
|
976
|
+
// data can't bleed through; z-index 1 lifts the frozen body cell above the scrolling body cells.
|
|
977
|
+
&__select-th,
|
|
978
|
+
&__select-cell {
|
|
979
|
+
position: sticky;
|
|
980
|
+
left: 0;
|
|
981
|
+
z-index: 1;
|
|
982
|
+
width: var(--list-col-select);
|
|
983
|
+
white-space: nowrap;
|
|
984
|
+
vertical-align: middle;
|
|
985
|
+
background: var(--color-bg);
|
|
986
|
+
}
|
|
987
|
+
// Row-actions column — the second frozen column, sitting immediately after Select (its `left` offset is
|
|
988
|
+
// exactly the select column's width). Always visible; snug `width: 1%` so the three icon buttons never
|
|
989
|
+
// steal width from the Title column.
|
|
990
|
+
&__actions-th,
|
|
991
|
+
&__actions-cell {
|
|
992
|
+
position: sticky;
|
|
993
|
+
left: var(--list-col-select);
|
|
994
|
+
z-index: 1;
|
|
995
|
+
width: 1%;
|
|
996
|
+
white-space: nowrap;
|
|
997
|
+
vertical-align: middle;
|
|
998
|
+
background: var(--color-bg);
|
|
999
|
+
}
|
|
1000
|
+
// A plain, always-visible inline row of icon buttons (Edit → Duplicate → Delete; destructive last, never
|
|
1001
|
+
// default focus). BlockTree.vue deliberately keeps its own absolute overlay: a tree row is a single flex
|
|
1002
|
+
// row with no column grid — there is no cell to place actions in.
|
|
1003
|
+
&__row-actions {
|
|
1004
|
+
display: inline-flex;
|
|
1005
|
+
align-items: center;
|
|
1006
|
+
gap: 1px;
|
|
1007
|
+
}
|
|
1008
|
+
&__action-btn {
|
|
1009
|
+
display: flex;
|
|
1010
|
+
align-items: center;
|
|
1011
|
+
justify-content: center;
|
|
1012
|
+
width: 1.5rem;
|
|
1013
|
+
height: 1.5rem;
|
|
1014
|
+
border: 0;
|
|
1015
|
+
border-radius: var(--radius-sm);
|
|
1016
|
+
background: transparent;
|
|
1017
|
+
color: var(--color-text-muted); /* meaningful icon control at rest → ≥3:1 (SC 1.4.11) */
|
|
1018
|
+
text-decoration: none;
|
|
1019
|
+
cursor: pointer;
|
|
1020
|
+
transition:
|
|
1021
|
+
background-color var(--motion-fast) var(--ease-standard),
|
|
1022
|
+
color var(--motion-fast) var(--ease-standard);
|
|
1023
|
+
|
|
1024
|
+
&:hover {
|
|
1025
|
+
background: var(--color-hover);
|
|
1026
|
+
color: var(--color-text);
|
|
1027
|
+
}
|
|
1028
|
+
&:focus-visible {
|
|
1029
|
+
outline: 2px solid var(--color-focus);
|
|
1030
|
+
outline-offset: -2px;
|
|
1031
|
+
}
|
|
1032
|
+
&--danger:hover {
|
|
1033
|
+
color: var(--color-danger);
|
|
1034
|
+
}
|
|
1035
|
+
&:disabled {
|
|
1036
|
+
opacity: 0.5;
|
|
1037
|
+
cursor: default;
|
|
1038
|
+
pointer-events: none;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Bulk action bar — appears above the scroll region when ≥1 row is selected.
|
|
1043
|
+
&__bulkbar {
|
|
1044
|
+
display: flex;
|
|
1045
|
+
align-items: center;
|
|
1046
|
+
gap: var(--space-3);
|
|
1047
|
+
padding: var(--space-2) var(--space-3);
|
|
1048
|
+
border: 1px solid var(--color-border);
|
|
1049
|
+
border-radius: var(--radius-md);
|
|
1050
|
+
background: var(--color-surface);
|
|
1051
|
+
}
|
|
1052
|
+
&__bulk-count {
|
|
1053
|
+
font-size: var(--text-sm);
|
|
1054
|
+
font-weight: var(--weight-medium);
|
|
1055
|
+
}
|
|
1056
|
+
&__bulk-actions {
|
|
1057
|
+
display: flex;
|
|
1058
|
+
align-items: center;
|
|
1059
|
+
gap: var(--space-2);
|
|
1060
|
+
margin-inline-start: auto;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
&__pager {
|
|
1064
|
+
display: flex;
|
|
1065
|
+
align-items: center;
|
|
1066
|
+
gap: var(--space-3);
|
|
1067
|
+
}
|
|
1068
|
+
&__page {
|
|
1069
|
+
font-size: var(--text-sm);
|
|
1070
|
+
color: var(--color-text-muted);
|
|
1071
|
+
}
|
|
1072
|
+
// Per-page size selector sits at the far end of the pager.
|
|
1073
|
+
&__perpage {
|
|
1074
|
+
display: inline-flex;
|
|
1075
|
+
align-items: center;
|
|
1076
|
+
gap: var(--space-2);
|
|
1077
|
+
margin-inline-start: auto;
|
|
1078
|
+
font-size: var(--text-sm);
|
|
1079
|
+
color: var(--color-text-muted);
|
|
1080
|
+
}
|
|
1081
|
+
&__perpage-select {
|
|
1082
|
+
padding: var(--space-1) var(--space-2);
|
|
1083
|
+
border: 1px solid var(--color-border);
|
|
1084
|
+
border-radius: var(--radius-sm);
|
|
1085
|
+
background: var(--color-surface);
|
|
1086
|
+
color: var(--color-text);
|
|
1087
|
+
font: inherit;
|
|
1088
|
+
font-size: var(--text-sm);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
</style>
|