@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.
Files changed (36) hide show
  1. package/README.md +1 -1
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +10 -0
  4. package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +2 -0
  5. package/dist/runtime/app/components/cms/EntryForm.vue +34 -3
  6. package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +2 -0
  7. package/dist/runtime/app/components/cms/MediaDetail.vue +25 -13
  8. package/dist/runtime/app/components/cms/Search.d.vue.ts +7 -0
  9. package/dist/runtime/app/components/cms/Search.vue +347 -0
  10. package/dist/runtime/app/components/cms/Search.vue.d.ts +7 -0
  11. package/dist/runtime/app/components/cms/VisualEditor.d.vue.ts +2 -0
  12. package/dist/runtime/app/components/cms/VisualEditor.vue +21 -1
  13. package/dist/runtime/app/components/cms/VisualEditor.vue.d.ts +2 -0
  14. package/dist/runtime/app/composables/cms-field-focus.d.ts +4 -0
  15. package/dist/runtime/app/composables/cms-field-focus.js +20 -0
  16. package/dist/runtime/app/layouts/cms-admin.d.vue.ts +2 -2
  17. package/dist/runtime/app/layouts/cms-admin.vue +2 -0
  18. package/dist/runtime/app/layouts/cms-admin.vue.d.ts +2 -2
  19. package/dist/runtime/app/pages/admin-collection.vue +3 -0
  20. package/dist/runtime/app/pages/admin-content.vue +3 -0
  21. package/dist/runtime/app/pages/admin-entry.vue +3 -0
  22. package/dist/runtime/assets/main.css +1 -1
  23. package/dist/runtime/server/api/collection.get.js +2 -4
  24. package/dist/runtime/server/api/media-download.get.d.ts +2 -0
  25. package/dist/runtime/server/api/media-download.get.js +90 -0
  26. package/dist/runtime/server/api/search.get.d.ts +3 -0
  27. package/dist/runtime/server/api/search.get.js +24 -0
  28. package/dist/runtime/server/utils/media-download.d.ts +1 -0
  29. package/dist/runtime/server/utils/media-download.js +8 -0
  30. package/dist/runtime/server/utils/media.d.ts +1 -0
  31. package/dist/runtime/server/utils/media.js +18 -3
  32. package/dist/runtime/server/utils/search.d.ts +20 -0
  33. package/dist/runtime/server/utils/search.js +233 -0
  34. package/dist/runtime/shared/search.d.ts +52 -0
  35. package/dist/runtime/shared/search.js +207 -0
  36. 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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
3
  "configKey": "cms",
4
- "version": "0.2.3",
4
+ "version": "0.3.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
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 :id="formId" :state="state" :schema="schema" @submit="emit('submit')" @error="onError">
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 v-if="item.url" class="cms-actions">
17
- <CmsButton
18
- :label="copied ? 'Copied' : 'Copy URL'"
19
- :icon="copied ? 'check' : 'clipboard-document'"
20
- variant="soft"
21
- color="neutral"
22
- size="sm"
23
- @click="copyUrl"
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="item.url"
27
- target="_blank"
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-top-right-on-square" class="size-4 shrink-0" />Open
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 = {
@@ -0,0 +1,4 @@
1
+ export declare function useCmsFieldFocus(): {
2
+ highlighted: import("vue").Ref<string | null, string | null>;
3
+ reveal: (root: () => HTMLElement | null | undefined, key: string) => void;
4
+ };