@xleddyl/nuxt-cms 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +10 -0
- package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +2 -0
- package/dist/runtime/app/components/cms/EntryForm.vue +34 -3
- package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +2 -0
- package/dist/runtime/app/components/cms/MediaDetail.vue +25 -13
- package/dist/runtime/app/components/cms/Search.d.vue.ts +7 -0
- package/dist/runtime/app/components/cms/Search.vue +347 -0
- package/dist/runtime/app/components/cms/Search.vue.d.ts +7 -0
- package/dist/runtime/app/components/cms/VisualEditor.d.vue.ts +2 -0
- package/dist/runtime/app/components/cms/VisualEditor.vue +21 -1
- package/dist/runtime/app/components/cms/VisualEditor.vue.d.ts +2 -0
- package/dist/runtime/app/composables/cms-field-focus.d.ts +4 -0
- package/dist/runtime/app/composables/cms-field-focus.js +20 -0
- package/dist/runtime/app/layouts/cms-admin.d.vue.ts +2 -2
- package/dist/runtime/app/layouts/cms-admin.vue +2 -0
- package/dist/runtime/app/layouts/cms-admin.vue.d.ts +2 -2
- package/dist/runtime/app/pages/admin-collection.vue +3 -0
- package/dist/runtime/app/pages/admin-content.vue +3 -0
- package/dist/runtime/app/pages/admin-entry.vue +3 -0
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/server/api/collection.get.js +2 -4
- package/dist/runtime/server/api/media-download.get.d.ts +2 -0
- package/dist/runtime/server/api/media-download.get.js +90 -0
- package/dist/runtime/server/api/search.get.d.ts +3 -0
- package/dist/runtime/server/api/search.get.js +24 -0
- package/dist/runtime/server/utils/media-download.d.ts +1 -0
- package/dist/runtime/server/utils/media-download.js +8 -0
- package/dist/runtime/server/utils/media.d.ts +1 -0
- package/dist/runtime/server/utils/media.js +18 -3
- package/dist/runtime/server/utils/search.d.ts +20 -0
- package/dist/runtime/server/utils/search.js +233 -0
- package/dist/runtime/shared/search.d.ts +52 -0
- package/dist/runtime/shared/search.js +207 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ nuxt-cms is a Nuxt module that leverages the Nitro server to ship a lightweight
|
|
|
10
10
|
- **Content types in code**: a `cms.config.ts` with `defineCmsConfig()` declares collections, single documents, relations, blocks and translatable fields; database schema, migrations and TypeScript types are generated from it.
|
|
11
11
|
- **Content with blocks**: news, blog and other content types with title, slug, cover, SEO fields and a body built from your own site components, drafts and scheduled publishing; `<CmsBlocks>` renders the body with typed props.
|
|
12
12
|
- **Visual editor**: content items (and any blocks field built from site components) are edited on a live preview of your own site, with drag and drop, a block palette, per-locale and mobile previews, scheduling and publishing.
|
|
13
|
-
- **Admin panel at `/cms`**: entry editing with validation, drafts, media library (S3-compatible storage, a directory on the server's disk, or a local mode backed directly by your `public/` folder), single-admin auth from env credentials.
|
|
13
|
+
- **Admin panel at `/cms`**: entry editing with validation, drafts, media library with one-click download of the original file (S3-compatible storage, a directory on the server's disk, or a local mode backed directly by your `public/` folder), a global search (⌘K / Ctrl K) over labels and every stored value in all locales, single-admin auth from env credentials.
|
|
14
14
|
- **Public GraphQL API**: read-only, typed end-to-end via gql.tada, with filtering, sorting and pagination; fields marked `private` stay out of it.
|
|
15
15
|
- **SQLite, Postgres, libSQL/Turso or Cloudflare D1**: a local file database by default, one config line to switch (including remote SQLite over the network).
|
|
16
16
|
- **Runs on serverless too**: migrations are baked into the server bundle, uploads are presigned straight to your bucket, sessions are sealed cookies. Nothing on the request path needs a local disk or sticky instances.
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -1871,6 +1871,11 @@ const module$1 = defineNuxtModule({
|
|
|
1871
1871
|
method: "post",
|
|
1872
1872
|
handler: resolver.resolve("./runtime/server/api/media-usage.post")
|
|
1873
1873
|
});
|
|
1874
|
+
addServerHandler({
|
|
1875
|
+
route: "/api/cms/admin/media/download",
|
|
1876
|
+
method: "get",
|
|
1877
|
+
handler: resolver.resolve("./runtime/server/api/media-download.get")
|
|
1878
|
+
});
|
|
1874
1879
|
if (mediaFilesystemBase) {
|
|
1875
1880
|
addServerHandler({
|
|
1876
1881
|
route: "/api/cms/admin/media/upload",
|
|
@@ -1882,6 +1887,11 @@ const module$1 = defineNuxtModule({
|
|
|
1882
1887
|
handler: resolver.resolve("./runtime/server/routes/media-file")
|
|
1883
1888
|
});
|
|
1884
1889
|
}
|
|
1890
|
+
addServerHandler({
|
|
1891
|
+
route: "/api/cms/search",
|
|
1892
|
+
method: "get",
|
|
1893
|
+
handler: resolver.resolve("./runtime/server/api/search.get")
|
|
1894
|
+
});
|
|
1885
1895
|
addServerHandler({
|
|
1886
1896
|
route: "/api/cms/users",
|
|
1887
1897
|
method: "get",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CmsEntry, CmsTab } from '#nuxt-cms';
|
|
2
|
+
import type { CmsSearchFocus } from '../../../shared/search.js';
|
|
2
3
|
type __VLS_Props = {
|
|
3
4
|
fields: CmsEntry['fields'];
|
|
4
5
|
tabs?: CmsTab[];
|
|
@@ -7,6 +8,7 @@ type __VLS_Props = {
|
|
|
7
8
|
loading?: boolean;
|
|
8
9
|
formId?: string;
|
|
9
10
|
footer?: boolean;
|
|
11
|
+
focus?: CmsSearchFocus | null;
|
|
10
12
|
};
|
|
11
13
|
type __VLS_ModelProps = {
|
|
12
14
|
modelValue: Record<string, unknown>;
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<CmsForm
|
|
2
|
+
<CmsForm
|
|
3
|
+
:id="formId"
|
|
4
|
+
ref="form"
|
|
5
|
+
:state="state"
|
|
6
|
+
:schema="schema"
|
|
7
|
+
@submit="emit('submit')"
|
|
8
|
+
@error="onError"
|
|
9
|
+
>
|
|
3
10
|
<div v-if="tabList.length > 1" class="cms-tabs" role="tablist">
|
|
4
11
|
<button
|
|
5
12
|
v-for="tab in tabList"
|
|
@@ -53,6 +60,8 @@
|
|
|
53
60
|
:icon="fieldIcon(fields[key])"
|
|
54
61
|
:name="key"
|
|
55
62
|
:required="fields[key].required"
|
|
63
|
+
:data-cms-field="key"
|
|
64
|
+
:class="{ 'is-search-focus': highlighted === key }"
|
|
56
65
|
>
|
|
57
66
|
<template
|
|
58
67
|
v-if="hasLocaleSwitch(fields[key]) && i18n.locales.length > 1"
|
|
@@ -102,8 +111,9 @@ import {
|
|
|
102
111
|
resolveFormLayout,
|
|
103
112
|
slugSourceValue
|
|
104
113
|
} from "#nuxt-cms";
|
|
105
|
-
import { computed, nextTick, ref, watch } from "#imports";
|
|
114
|
+
import { computed, nextTick, onMounted, ref, watch } from "#imports";
|
|
106
115
|
import { buildEntrySchema } from "../../../shared/validation";
|
|
116
|
+
import { useCmsFieldFocus } from "../../composables/cms-field-focus";
|
|
107
117
|
import { useCmsRuntime } from "../../composables/cms-runtime";
|
|
108
118
|
import { entryFormLayout, useCmsSettingsState } from "../../composables/cms-settings";
|
|
109
119
|
import { fieldIcon } from "../../utils/ui";
|
|
@@ -114,7 +124,8 @@ const props = defineProps({
|
|
|
114
124
|
drafts: { type: Boolean, required: false },
|
|
115
125
|
loading: { type: Boolean, required: false },
|
|
116
126
|
formId: { type: String, required: false },
|
|
117
|
-
footer: { type: Boolean, required: false, default: true }
|
|
127
|
+
footer: { type: Boolean, required: false, default: true },
|
|
128
|
+
focus: { type: [Object, null], required: false }
|
|
118
129
|
});
|
|
119
130
|
const state = defineModel({ type: Object, ...{ required: true } });
|
|
120
131
|
const emit = defineEmits(["submit", "error"]);
|
|
@@ -194,4 +205,24 @@ const published = computed({
|
|
|
194
205
|
function togglePublished() {
|
|
195
206
|
published.value = !published.value;
|
|
196
207
|
}
|
|
208
|
+
const form = ref(null);
|
|
209
|
+
const { highlighted, reveal } = useCmsFieldFocus();
|
|
210
|
+
function applyFocus(focus) {
|
|
211
|
+
if (focus.tab && tabList.value.some((tab2) => tab2.id === focus.tab)) activeTab.value = focus.tab;
|
|
212
|
+
const key = focus.field;
|
|
213
|
+
const field = key ? props.fields[key] : void 0;
|
|
214
|
+
if (!key || !field) return;
|
|
215
|
+
const tab = tabList.value.length ? fieldTab(field, tabList.value) : void 0;
|
|
216
|
+
if (tab) activeTab.value = tab;
|
|
217
|
+
if (focus.locale && hasLocaleSwitch(field) && i18n.locales.includes(focus.locale)) {
|
|
218
|
+
setLocale(key, focus.locale);
|
|
219
|
+
}
|
|
220
|
+
void nextTick(() => {
|
|
221
|
+
revealField(key);
|
|
222
|
+
reveal(() => form.value?.$el, key);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
onMounted(() => {
|
|
226
|
+
if (props.focus) applyFocus(props.focus);
|
|
227
|
+
});
|
|
197
228
|
</script>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CmsEntry, CmsTab } from '#nuxt-cms';
|
|
2
|
+
import type { CmsSearchFocus } from '../../../shared/search.js';
|
|
2
3
|
type __VLS_Props = {
|
|
3
4
|
fields: CmsEntry['fields'];
|
|
4
5
|
tabs?: CmsTab[];
|
|
@@ -7,6 +8,7 @@ type __VLS_Props = {
|
|
|
7
8
|
loading?: boolean;
|
|
8
9
|
formId?: string;
|
|
9
10
|
footer?: boolean;
|
|
11
|
+
focus?: CmsSearchFocus | null;
|
|
10
12
|
};
|
|
11
13
|
type __VLS_ModelProps = {
|
|
12
14
|
modelValue: Record<string, unknown>;
|
|
@@ -13,22 +13,31 @@
|
|
|
13
13
|
<CmsIcon v-else :name="mediaIconFor(item.type)" class="size-12" />
|
|
14
14
|
</div>
|
|
15
15
|
|
|
16
|
-
<div
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
<div class="cms-actions">
|
|
17
|
+
<template v-if="item.url">
|
|
18
|
+
<CmsButton
|
|
19
|
+
:label="copied ? 'Copied' : 'Copy URL'"
|
|
20
|
+
:icon="copied ? 'check' : 'clipboard-document'"
|
|
21
|
+
variant="soft"
|
|
22
|
+
color="neutral"
|
|
23
|
+
size="sm"
|
|
24
|
+
@click="copyUrl"
|
|
25
|
+
/>
|
|
26
|
+
<a
|
|
27
|
+
:href="item.url"
|
|
28
|
+
target="_blank"
|
|
29
|
+
rel="noopener"
|
|
30
|
+
class="cms-btn cms-btn-sm cms-btn-neutral cms-btn-ghost"
|
|
31
|
+
>
|
|
32
|
+
<CmsIcon name="arrow-top-right-on-square" class="size-4 shrink-0" />Open
|
|
33
|
+
</a>
|
|
34
|
+
</template>
|
|
25
35
|
<a
|
|
26
|
-
:href="
|
|
27
|
-
|
|
28
|
-
rel="noopener"
|
|
36
|
+
:href="downloadHref"
|
|
37
|
+
download
|
|
29
38
|
class="cms-btn cms-btn-sm cms-btn-neutral cms-btn-ghost"
|
|
30
39
|
>
|
|
31
|
-
<CmsIcon name="arrow-
|
|
40
|
+
<CmsIcon name="arrow-down-tray" class="size-4 shrink-0" />Download
|
|
32
41
|
</a>
|
|
33
42
|
</div>
|
|
34
43
|
|
|
@@ -128,6 +137,9 @@ const dirty = computed(() => {
|
|
|
128
137
|
if (!props.item) return false;
|
|
129
138
|
return (alt.value.trim() || null) !== (props.item.alt ?? null) || !props.readOnly && folder.value !== (props.item.folder ?? null);
|
|
130
139
|
});
|
|
140
|
+
const downloadHref = computed(
|
|
141
|
+
() => props.item ? `/api/cms/admin/media/download?key=${encodeURIComponent(props.item.key)}` : ""
|
|
142
|
+
);
|
|
131
143
|
const facts = computed(() => {
|
|
132
144
|
const item = props.item;
|
|
133
145
|
if (!item) return [];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
2
|
+
open: () => any;
|
|
3
|
+
}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{
|
|
4
|
+
onOpen?: (() => any) | undefined;
|
|
5
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
6
|
+
declare const _default: typeof __VLS_export;
|
|
7
|
+
export default _default;
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<button
|
|
3
|
+
ref="trigger"
|
|
4
|
+
type="button"
|
|
5
|
+
class="cms-search-trigger"
|
|
6
|
+
aria-haspopup="dialog"
|
|
7
|
+
:aria-expanded="open"
|
|
8
|
+
aria-keyshortcuts="Meta+K Control+K"
|
|
9
|
+
@click="show"
|
|
10
|
+
>
|
|
11
|
+
<CmsIcon name="magnifying-glass" class="cms-search-trigger-icon size-4 shrink-0" />
|
|
12
|
+
<span class="cms-search-trigger-text">Search</span>
|
|
13
|
+
<kbd class="cms-kbd">{{ shortcut }}</kbd>
|
|
14
|
+
</button>
|
|
15
|
+
|
|
16
|
+
<Teleport to="body">
|
|
17
|
+
<Transition name="cms-modal">
|
|
18
|
+
<div v-if="open" class="cms-scope cms-overlay cms-search-overlay" @click.self="close">
|
|
19
|
+
<div
|
|
20
|
+
class="cms-modal cms-search"
|
|
21
|
+
role="dialog"
|
|
22
|
+
aria-modal="true"
|
|
23
|
+
aria-labelledby="cms-search-label"
|
|
24
|
+
>
|
|
25
|
+
<div class="cms-search-bar">
|
|
26
|
+
<label id="cms-search-label" for="cms-search-input" class="sr-only">
|
|
27
|
+
Search the CMS
|
|
28
|
+
</label>
|
|
29
|
+
<CmsIcon name="magnifying-glass" class="cms-search-bar-icon size-5 shrink-0" />
|
|
30
|
+
<input
|
|
31
|
+
id="cms-search-input"
|
|
32
|
+
ref="input"
|
|
33
|
+
v-model="query"
|
|
34
|
+
type="text"
|
|
35
|
+
role="combobox"
|
|
36
|
+
class="cms-search-input"
|
|
37
|
+
placeholder="Search content, fields and pages"
|
|
38
|
+
autocomplete="off"
|
|
39
|
+
spellcheck="false"
|
|
40
|
+
aria-autocomplete="list"
|
|
41
|
+
aria-controls="cms-search-results"
|
|
42
|
+
:aria-expanded="hits.length > 0"
|
|
43
|
+
:aria-activedescendant="activeId"
|
|
44
|
+
@keydown="onKeydown"
|
|
45
|
+
/>
|
|
46
|
+
<CmsSpinner v-if="loading" />
|
|
47
|
+
<kbd class="cms-kbd">Esc</kbd>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="cms-search-body">
|
|
51
|
+
<p v-if="hint" class="cms-search-hint">{{ hint }}</p>
|
|
52
|
+
<div
|
|
53
|
+
id="cms-search-results"
|
|
54
|
+
role="listbox"
|
|
55
|
+
aria-labelledby="cms-search-label"
|
|
56
|
+
class="cms-search-results"
|
|
57
|
+
>
|
|
58
|
+
<div
|
|
59
|
+
v-for="(group, groupIndex) in groups"
|
|
60
|
+
:key="group.key"
|
|
61
|
+
role="group"
|
|
62
|
+
:aria-labelledby="`cms-search-group-${groupIndex}`"
|
|
63
|
+
class="cms-search-group"
|
|
64
|
+
>
|
|
65
|
+
<div :id="`cms-search-group-${groupIndex}`" class="cms-search-group-title">
|
|
66
|
+
<span>{{ group.label }}</span>
|
|
67
|
+
<span class="cms-search-group-kind">{{ KIND_LABELS[group.kind] }}</span>
|
|
68
|
+
</div>
|
|
69
|
+
<div
|
|
70
|
+
v-for="item in group.items"
|
|
71
|
+
:id="optionId(item.index)"
|
|
72
|
+
:key="item.index"
|
|
73
|
+
role="option"
|
|
74
|
+
class="cms-search-option"
|
|
75
|
+
:class="{ 'is-active': item.index === active }"
|
|
76
|
+
:aria-selected="item.index === active"
|
|
77
|
+
@mousemove="active = item.index"
|
|
78
|
+
@click="go(item.hit)"
|
|
79
|
+
>
|
|
80
|
+
<CmsIcon
|
|
81
|
+
:name="iconOf(item.hit)"
|
|
82
|
+
class="cms-search-option-icon size-4 shrink-0"
|
|
83
|
+
/>
|
|
84
|
+
<span class="cms-search-option-text">
|
|
85
|
+
<template v-if="isValueHit(item.hit)">
|
|
86
|
+
<span class="cms-search-option-head">
|
|
87
|
+
<span class="cms-search-option-title">
|
|
88
|
+
{{ item.hit.title ?? item.hit.entryLabel }}
|
|
89
|
+
</span>
|
|
90
|
+
<span v-if="item.hit.fieldLabel" class="cms-search-option-meta">
|
|
91
|
+
{{ item.hit.fieldLabel }}
|
|
92
|
+
</span>
|
|
93
|
+
<span v-if="item.hit.locale" class="cms-badge is-muted">
|
|
94
|
+
{{ item.hit.locale }}
|
|
95
|
+
</span>
|
|
96
|
+
</span>
|
|
97
|
+
<span class="cms-search-snippet">
|
|
98
|
+
{{ item.hit.snippet.before
|
|
99
|
+
}}<mark class="cms-search-mark">{{
|
|
100
|
+
item.hit.snippet.match
|
|
101
|
+
}}</mark
|
|
102
|
+
>{{ item.hit.snippet.after }}
|
|
103
|
+
</span>
|
|
104
|
+
</template>
|
|
105
|
+
<template v-else>
|
|
106
|
+
<span class="cms-search-option-title">
|
|
107
|
+
{{ item.hit.snippet.before
|
|
108
|
+
}}<mark class="cms-search-mark">{{
|
|
109
|
+
item.hit.snippet.match
|
|
110
|
+
}}</mark
|
|
111
|
+
>{{ item.hit.snippet.after }}
|
|
112
|
+
</span>
|
|
113
|
+
<span class="cms-search-option-meta">{{
|
|
114
|
+
labelMeta(item.hit)
|
|
115
|
+
}}</span>
|
|
116
|
+
</template>
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
<p v-if="truncated" class="cms-search-hint">
|
|
122
|
+
Showing the first results. Type more to narrow them down.
|
|
123
|
+
</p>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div class="cms-search-footer" aria-hidden="true">
|
|
127
|
+
<span><kbd class="cms-kbd">↑</kbd><kbd class="cms-kbd">↓</kbd> to move</span>
|
|
128
|
+
<span><kbd class="cms-kbd">↵</kbd> to open</span>
|
|
129
|
+
<span><kbd class="cms-kbd">Esc</kbd> to close</span>
|
|
130
|
+
</div>
|
|
131
|
+
<p class="sr-only" aria-live="polite">{{ status }}</p>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</Transition>
|
|
135
|
+
</Teleport>
|
|
136
|
+
</template>
|
|
137
|
+
|
|
138
|
+
<script setup>
|
|
139
|
+
import { computed, navigateTo, nextTick, onBeforeUnmount, onMounted, ref, watch } from "#imports";
|
|
140
|
+
import { isSearchableQuery, normalizeSearchQuery } from "../../../shared/search";
|
|
141
|
+
import { useCmsOverlay } from "../../composables/cms-overlay";
|
|
142
|
+
import { cmsApi } from "../../utils/api";
|
|
143
|
+
const DEBOUNCE_MS = 180;
|
|
144
|
+
const KIND_LABELS = {
|
|
145
|
+
collection: "Collection",
|
|
146
|
+
single: "Single",
|
|
147
|
+
content: "Content",
|
|
148
|
+
page: "Pages",
|
|
149
|
+
media: "Library"
|
|
150
|
+
};
|
|
151
|
+
const KIND_ICONS = {
|
|
152
|
+
collection: "square-3-stack-3d",
|
|
153
|
+
single: "document-text",
|
|
154
|
+
content: "newspaper",
|
|
155
|
+
page: "window",
|
|
156
|
+
media: "photo"
|
|
157
|
+
};
|
|
158
|
+
const TYPE_LABELS = {
|
|
159
|
+
entry: "Section",
|
|
160
|
+
page: "Page",
|
|
161
|
+
tab: "Tab",
|
|
162
|
+
field: "Field",
|
|
163
|
+
block: "Block",
|
|
164
|
+
value: "Value",
|
|
165
|
+
media: "Media"
|
|
166
|
+
};
|
|
167
|
+
const TYPE_ICONS = {
|
|
168
|
+
page: "window",
|
|
169
|
+
tab: "rectangle-group",
|
|
170
|
+
field: "tag",
|
|
171
|
+
block: "squares-2x2",
|
|
172
|
+
value: "bars-3-bottom-left",
|
|
173
|
+
media: "photo"
|
|
174
|
+
};
|
|
175
|
+
const emit = defineEmits(["open"]);
|
|
176
|
+
const open = ref(false);
|
|
177
|
+
const query = ref("");
|
|
178
|
+
const hits = ref([]);
|
|
179
|
+
const truncated = ref(false);
|
|
180
|
+
const loading = ref(false);
|
|
181
|
+
const failed = ref(false);
|
|
182
|
+
const searched = ref("");
|
|
183
|
+
const active = ref(0);
|
|
184
|
+
const shortcut = ref("Ctrl K");
|
|
185
|
+
const trigger = ref(null);
|
|
186
|
+
const input = ref(null);
|
|
187
|
+
let timer;
|
|
188
|
+
let request = 0;
|
|
189
|
+
let returnFocus = null;
|
|
190
|
+
const groups = computed(() => {
|
|
191
|
+
const map = /* @__PURE__ */ new Map();
|
|
192
|
+
hits.value.forEach((hit, index2) => {
|
|
193
|
+
const key = hit.collection ?? hit.kind;
|
|
194
|
+
let group = map.get(key);
|
|
195
|
+
if (!group) {
|
|
196
|
+
group = { key, label: hit.entryLabel, kind: hit.kind, items: [] };
|
|
197
|
+
map.set(key, group);
|
|
198
|
+
}
|
|
199
|
+
group.items.push({ hit, index: index2 });
|
|
200
|
+
});
|
|
201
|
+
const ordered = [...map.values()];
|
|
202
|
+
let index = 0;
|
|
203
|
+
for (const group of ordered) {
|
|
204
|
+
for (const item of group.items) item.index = index++;
|
|
205
|
+
}
|
|
206
|
+
return ordered;
|
|
207
|
+
});
|
|
208
|
+
const flat = computed(() => groups.value.flatMap((group) => group.items.map((item) => item.hit)));
|
|
209
|
+
const activeId = computed(() => flat.value.length ? optionId(active.value) : void 0);
|
|
210
|
+
const hint = computed(() => {
|
|
211
|
+
if (!isSearchableQuery(query.value)) return "Type at least 2 characters to search.";
|
|
212
|
+
if (failed.value) return "Search failed. Try again.";
|
|
213
|
+
if (!loading.value && searched.value && !flat.value.length) {
|
|
214
|
+
return `No results for "${searched.value}".`;
|
|
215
|
+
}
|
|
216
|
+
return "";
|
|
217
|
+
});
|
|
218
|
+
const status = computed(() => {
|
|
219
|
+
if (hint.value) return hint.value;
|
|
220
|
+
if (loading.value) return "Searching";
|
|
221
|
+
return `${flat.value.length} results`;
|
|
222
|
+
});
|
|
223
|
+
function optionId(index) {
|
|
224
|
+
return `cms-search-option-${index}`;
|
|
225
|
+
}
|
|
226
|
+
function isValueHit(hit) {
|
|
227
|
+
return hit.type === "value" || hit.type === "media";
|
|
228
|
+
}
|
|
229
|
+
function iconOf(hit) {
|
|
230
|
+
return hit.type === "entry" ? KIND_ICONS[hit.kind] : TYPE_ICONS[hit.type] ?? "tag";
|
|
231
|
+
}
|
|
232
|
+
function labelMeta(hit) {
|
|
233
|
+
const parts = [TYPE_LABELS[hit.type]];
|
|
234
|
+
if (hit.title) parts.push(hit.title);
|
|
235
|
+
if (hit.fieldLabel && hit.type !== "tab") parts.push(hit.fieldLabel);
|
|
236
|
+
if (hit.type === "page" && hit.id) parts.push(hit.to);
|
|
237
|
+
return parts.join(" \xB7 ");
|
|
238
|
+
}
|
|
239
|
+
async function run(term) {
|
|
240
|
+
const current = ++request;
|
|
241
|
+
loading.value = true;
|
|
242
|
+
failed.value = false;
|
|
243
|
+
try {
|
|
244
|
+
const response = await cmsApi("/api/cms/search", { query: { q: term } });
|
|
245
|
+
if (current !== request) return;
|
|
246
|
+
hits.value = response.hits;
|
|
247
|
+
truncated.value = response.truncated;
|
|
248
|
+
searched.value = response.query;
|
|
249
|
+
active.value = 0;
|
|
250
|
+
} catch {
|
|
251
|
+
if (current !== request) return;
|
|
252
|
+
hits.value = [];
|
|
253
|
+
truncated.value = false;
|
|
254
|
+
failed.value = true;
|
|
255
|
+
} finally {
|
|
256
|
+
if (current === request) loading.value = false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
watch(query, (value) => {
|
|
260
|
+
clearTimeout(timer);
|
|
261
|
+
const term = normalizeSearchQuery(value);
|
|
262
|
+
if (!isSearchableQuery(term)) {
|
|
263
|
+
request++;
|
|
264
|
+
hits.value = [];
|
|
265
|
+
truncated.value = false;
|
|
266
|
+
loading.value = false;
|
|
267
|
+
failed.value = false;
|
|
268
|
+
searched.value = "";
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
timer = setTimeout(() => run(term), DEBOUNCE_MS);
|
|
272
|
+
});
|
|
273
|
+
watch(active, (index) => {
|
|
274
|
+
void nextTick(
|
|
275
|
+
() => document.getElementById(optionId(index))?.scrollIntoView({ block: "nearest" })
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
function focusInput() {
|
|
279
|
+
void nextTick(() => {
|
|
280
|
+
input.value?.focus();
|
|
281
|
+
input.value?.select();
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
function show() {
|
|
285
|
+
if (open.value) {
|
|
286
|
+
focusInput();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
290
|
+
emit("open");
|
|
291
|
+
open.value = true;
|
|
292
|
+
}
|
|
293
|
+
function close() {
|
|
294
|
+
open.value = false;
|
|
295
|
+
}
|
|
296
|
+
const overlay = useCmsOverlay(close);
|
|
297
|
+
watch(open, (value) => {
|
|
298
|
+
if (value) {
|
|
299
|
+
overlay.activate();
|
|
300
|
+
focusInput();
|
|
301
|
+
} else {
|
|
302
|
+
overlay.deactivate();
|
|
303
|
+
const target = returnFocus ?? trigger.value;
|
|
304
|
+
returnFocus = null;
|
|
305
|
+
void nextTick(() => target?.focus());
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
function go(hit) {
|
|
309
|
+
if (!hit) return;
|
|
310
|
+
returnFocus = null;
|
|
311
|
+
close();
|
|
312
|
+
void navigateTo(hit.to);
|
|
313
|
+
}
|
|
314
|
+
function onKeydown(event) {
|
|
315
|
+
const count = flat.value.length;
|
|
316
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
317
|
+
event.preventDefault();
|
|
318
|
+
if (!count) return;
|
|
319
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
320
|
+
active.value = (active.value + step + count) % count;
|
|
321
|
+
} else if (event.key === "Home" && count && event.ctrlKey) {
|
|
322
|
+
event.preventDefault();
|
|
323
|
+
active.value = 0;
|
|
324
|
+
} else if (event.key === "End" && count && event.ctrlKey) {
|
|
325
|
+
event.preventDefault();
|
|
326
|
+
active.value = count - 1;
|
|
327
|
+
} else if (event.key === "Enter") {
|
|
328
|
+
event.preventDefault();
|
|
329
|
+
go(flat.value[active.value]);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function onGlobalKeydown(event) {
|
|
333
|
+
if (event.key.toLowerCase() !== "k" || event.altKey || event.shiftKey) return;
|
|
334
|
+
if (!event.metaKey && !event.ctrlKey) return;
|
|
335
|
+
event.preventDefault();
|
|
336
|
+
show();
|
|
337
|
+
}
|
|
338
|
+
onMounted(() => {
|
|
339
|
+
const platform = navigator.userAgentData?.platform ?? navigator.platform ?? navigator.userAgent;
|
|
340
|
+
if (/mac|iphone|ipad|ipod/i.test(platform)) shortcut.value = "\u2318K";
|
|
341
|
+
document.addEventListener("keydown", onGlobalKeydown);
|
|
342
|
+
});
|
|
343
|
+
onBeforeUnmount(() => {
|
|
344
|
+
clearTimeout(timer);
|
|
345
|
+
document.removeEventListener("keydown", onGlobalKeydown);
|
|
346
|
+
});
|
|
347
|
+
</script>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
2
|
+
open: () => any;
|
|
3
|
+
}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{
|
|
4
|
+
onOpen?: (() => any) | undefined;
|
|
5
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
6
|
+
declare const _default: typeof __VLS_export;
|
|
7
|
+
export default _default;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { FieldConfig } from '#nuxt-cms';
|
|
2
|
+
import type { CmsSearchFocus } from '../../../shared/search.js';
|
|
2
3
|
type __VLS_Props = {
|
|
3
4
|
entryName: string;
|
|
4
5
|
fieldKey: string;
|
|
5
6
|
fields: Record<string, FieldConfig>;
|
|
6
7
|
title: string;
|
|
7
8
|
content?: boolean;
|
|
9
|
+
focus?: CmsSearchFocus | null;
|
|
8
10
|
};
|
|
9
11
|
declare function validate(): boolean;
|
|
10
12
|
type __VLS_ModelProps = {
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
|
|
76
76
|
<div
|
|
77
77
|
:id="`cms-editor-tabpanel-${activeTab}`"
|
|
78
|
+
ref="panel"
|
|
78
79
|
class="cms-editor-panel-body"
|
|
79
80
|
role="tabpanel"
|
|
80
81
|
:aria-labelledby="tabList.length > 1 ? `cms-editor-tab-${activeTab}` : void 0"
|
|
@@ -213,6 +214,8 @@
|
|
|
213
214
|
:icon="fieldIcon(fields[key])"
|
|
214
215
|
:name="key"
|
|
215
216
|
:required="fields[key].required"
|
|
217
|
+
:data-cms-field="key"
|
|
218
|
+
:class="{ 'is-search-focus': highlighted === key }"
|
|
216
219
|
>
|
|
217
220
|
<CmsFieldInput
|
|
218
221
|
:model-value="state[key]"
|
|
@@ -267,6 +270,7 @@ import {
|
|
|
267
270
|
toggleBlockHidden
|
|
268
271
|
} from "../../../shared/preview";
|
|
269
272
|
import { useCmsConfirm } from "../../composables/cms-confirm";
|
|
273
|
+
import { useCmsFieldFocus } from "../../composables/cms-field-focus";
|
|
270
274
|
import { useCmsRuntime } from "../../composables/cms-runtime";
|
|
271
275
|
import { entryFormLayout, useCmsSettingsState } from "../../composables/cms-settings";
|
|
272
276
|
import { CMS_FORM_ERRORS, fieldIcon } from "../../utils/ui";
|
|
@@ -280,7 +284,8 @@ const props = defineProps({
|
|
|
280
284
|
fieldKey: { type: String, required: true },
|
|
281
285
|
fields: { type: Object, required: true },
|
|
282
286
|
title: { type: String, required: true },
|
|
283
|
-
content: { type: Boolean, required: false }
|
|
287
|
+
content: { type: Boolean, required: false },
|
|
288
|
+
focus: { type: [Object, null], required: false }
|
|
284
289
|
});
|
|
285
290
|
const state = defineModel("state", { type: Object, ...{ required: true } });
|
|
286
291
|
const runtime = useCmsRuntime();
|
|
@@ -465,9 +470,24 @@ async function loadLibrary() {
|
|
|
465
470
|
library.value = /* @__PURE__ */ new Map();
|
|
466
471
|
}
|
|
467
472
|
}
|
|
473
|
+
const panel = ref(null);
|
|
474
|
+
const { highlighted, reveal } = useCmsFieldFocus();
|
|
475
|
+
function applyFocus(focus) {
|
|
476
|
+
if (focus.locale && i18n.locales.includes(focus.locale)) locale.value = focus.locale;
|
|
477
|
+
const key = focus.field;
|
|
478
|
+
if (!key || !Object.hasOwn(props.fields, key)) return;
|
|
479
|
+
if (key === props.fieldKey) {
|
|
480
|
+
activeTab.value = "block";
|
|
481
|
+
if (focus.block != null && focus.block < blocks.value.length) select(focus.block);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
activeTab.value = tabOfKey(key);
|
|
485
|
+
reveal(() => panel.value, key);
|
|
486
|
+
}
|
|
468
487
|
onMounted(() => {
|
|
469
488
|
window.addEventListener("message", onMessage);
|
|
470
489
|
void loadLibrary();
|
|
490
|
+
if (props.focus) applyFocus(props.focus);
|
|
471
491
|
});
|
|
472
492
|
onBeforeUnmount(() => {
|
|
473
493
|
window.removeEventListener("message", onMessage);
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { FieldConfig } from '#nuxt-cms';
|
|
2
|
+
import type { CmsSearchFocus } from '../../../shared/search.js';
|
|
2
3
|
type __VLS_Props = {
|
|
3
4
|
entryName: string;
|
|
4
5
|
fieldKey: string;
|
|
5
6
|
fields: Record<string, FieldConfig>;
|
|
6
7
|
title: string;
|
|
7
8
|
content?: boolean;
|
|
9
|
+
focus?: CmsSearchFocus | null;
|
|
8
10
|
};
|
|
9
11
|
declare function validate(): boolean;
|
|
10
12
|
type __VLS_ModelProps = {
|