diff --git a/.contentrain/content/system/ui-strings/en.json b/.contentrain/content/system/ui-strings/en.json index 3d496232..ea6e2555 100644 --- a/.contentrain/content/system/ui-strings/en.json +++ b/.contentrain/content/system/ui-strings/en.json @@ -273,8 +273,10 @@ "content.edit_content": "Edit content", "content.edit_entry": "Edit entry", "content.entry_count": "{count} entry(s)", + "content.entry_count_filtered": "Showing {shown} of {matched} matches in {total} entries", + "content.entry_count_partial": "Showing {shown} of {total}", "content.field_required": "This field is required", - "content.filter_keys": "Filter keys...", + "content.filter_keys": "Filter keys or values...", "content.keep_editing": "Keep editing", "content.key_column": "Key", "content.keys_count": "keys", @@ -302,7 +304,13 @@ "content.resize_panel": "Resize content panel", "content.save_all": "Save changes", "content.save_error": "Failed to save content. Please try again.", + "content.search_entries": "Search entries...", + "content.search_entries_group": "Entries", + "content.search_unavailable_description": "The content index is still loading. Try again in a moment.", + "content.search_unavailable_title": "Search is not ready yet", + "content.searching_description": "Looking through every entry in this model.", "content.select_entry": "Select an entry", + "content.show_more": "Show more", "content.slug_hint": "Lowercase letters, numbers, and hyphens only", "content.stat_entries": "Entries", "content.stat_locales": "Locales", diff --git a/app/app.vue b/app/app.vue index 6a6043e9..1b395f8a 100644 --- a/app/app.vue +++ b/app/app.vue @@ -30,8 +30,12 @@ useSeoMeta({ diff --git a/app/components/atoms/Tooltip.vue b/app/components/atoms/Tooltip.vue index 497b51a3..af344f74 100644 --- a/app/components/atoms/Tooltip.vue +++ b/app/components/atoms/Tooltip.vue @@ -1,17 +1,11 @@ diff --git a/app/components/atoms/TooltipScope.vue b/app/components/atoms/TooltipScope.vue new file mode 100644 index 00000000..0c6ff52d --- /dev/null +++ b/app/components/atoms/TooltipScope.vue @@ -0,0 +1,25 @@ + + + diff --git a/app/components/organisms/CommandPalette.vue b/app/components/organisms/CommandPalette.vue index 4d61390d..4945fe01 100644 --- a/app/components/organisms/CommandPalette.vue +++ b/app/components/organisms/CommandPalette.vue @@ -69,8 +69,80 @@ const modeBadge = computed((): { label: string, color: 'primary' | 'secondary' | const isInProject = computed(() => !!route.params.projectId) +// ── Entry search ─────────────────────────────────────────── +// The palette's own help promised "search models, entries, vocabulary" and +// "@modelId to search within a model", and neither searched an entry: nothing +// in the app called `searchContent`. It does now. +// +// `buildResults` is synchronous and `searchContent` is not, so entry hits are +// filled into their own ref and appended, rather than forced into the computed. +const brain = useContentBrain() + +interface EntryHit { modelId: string, entryId: string, locale: string, title: string } +const entryHits = ref([]) + +let entryToken = 0 +let entryTimer: ReturnType | null = null + +async function resolveEntryHits(query: string, modelId?: string) { + const token = ++entryToken + const results = await brain.searchContent(query, { modelId, limit: 8 }) + if (token !== entryToken) return + + const hits: EntryHit[] = [] + for (const r of results) { + // The index stores no title — `SearchResult` is ids and a score — so the + // entry is read back and titled the same way the list titles it. Without + // this the palette would list `f3a81c09d24e`. + const model = brain.models.value.find(m => m.id === r.modelId) ?? null + const content = await brain.queryContent(r.modelId, r.locale) + const data = content?.data as Record> | Array> | null + let entry: Record | undefined + if (Array.isArray(data)) entry = data.find(d => d.slug === r.entryId) + else if (data) entry = data[r.entryId] + + hits.push({ + modelId: r.modelId, + entryId: r.entryId, + locale: r.locale, + title: resolveEntryTitle(entry, model, r.entryId), + }) + } + + if (token !== entryToken) return + entryHits.value = hits +} + +watch(parsed, ({ mode, query, modelId }) => { + if (entryTimer) clearTimeout(entryTimer) + + const trimmed = query.trim() + const wantsEntries = isInProject.value && trimmed.length > 1 && (mode === 'global' || mode === 'model') + if (!wantsEntries) { + entryToken++ + entryHits.value = [] + return + } + + entryTimer = setTimeout(() => resolveEntryHits(trimmed, mode === 'model' ? modelId : undefined), 150) +}) + +onBeforeUnmount(() => { + if (entryTimer) clearTimeout(entryTimer) +}) + +const entryResults = computed(() => entryHits.value.map(hit => ({ + id: `entry:${hit.modelId}:${hit.entryId}`, + label: hit.title, + sublabel: hit.modelId, + icon: 'icon-[annon--file-text]', + group: t('content.search_entries_group'), + type: 'entry', + action: () => navigateToEntry(hit), +}))) + // Build results via composable -const results = computed(() => { +const baseResults = computed(() => { const { mode, query, modelId } = parsed.value return buildResults({ mode, @@ -96,10 +168,17 @@ const results = computed(() => { }) }) +// Entries go after the synchronous items so keyboard navigation starts where it +// always did — the top result does not jump when an async search lands. +const results = computed(() => [...baseResults.value, ...entryResults.value]) + const groupedResults = computed(() => groupResults(results.value)) const hasResults = computed(() => results.value.length > 0) -watch(results, () => { +// Reset the cursor when the QUERY changes, not when results do: entry hits +// arrive a beat later, and resetting on those would yank the selection out from +// under someone already arrowing through the list. +watch(baseResults, () => { selectedIndex.value = 0 }) @@ -198,6 +277,17 @@ function navigateToModel(modelId: string, modelName: string) { open.value = false } +/** + * Open the model AND point at the entry. Opening the model alone would hand + * someone a thousand rows and let them find it themselves, which is what they + * used the search for. + */ +function navigateToEntry(hit: EntryHit) { + addRecent({ id: `entry:${hit.modelId}:${hit.entryId}`, label: hit.title, sublabel: hit.modelId, icon: 'icon-[annon--file-text]', type: 'entry' }) + router.replace({ query: { model: hit.modelId, entry: hit.entryId } }) + open.value = false +} + function navigateToBranch(branchName: string) { addRecent({ id: `branch:${branchName}`, label: branchName.replace('contentrain/', ''), icon: 'icon-[annon--arrow-swap]', type: 'branch' }) router.replace({ query: { branch: encodeURIComponent(branchName) } }) diff --git a/app/components/organisms/ContentCollectionView.vue b/app/components/organisms/ContentCollectionView.vue index 4ace0c6c..ab865ce5 100644 --- a/app/components/organisms/ContentCollectionView.vue +++ b/app/components/organisms/ContentCollectionView.vue @@ -41,6 +41,112 @@ function getEntryUpdatedAt(entryId: string, metaData: Record | return d.toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) } +// ── Search + progressive rendering ───────────────────────── +// The list used to render every entry: one `
`, one stateful Radix +// dropdown and three action buttons each. At 1000 articles that is the whole +// cost of opening a model — not the data, the components. Rendering a page at a +// time fixes it without touching the row, and without a virtualiser (a new +// dependency, and rows change height when they expand). +const PAGE_SIZE = 50 + +const brain = useContentBrain() + +const searchQuery = ref('') +const searchIds = ref(null) +const searching = ref(false) +const visibleCount = ref(PAGE_SIZE) + +const allIds = computed(() => Object.keys(props.content)) + +/** + * Search runs against the brain's index, not the rendered rows — otherwise it + * would only ever find what is already on screen, which is the opposite of what + * it is for. The index holds every entry, so a match on page twenty is found + * without paging there. + */ +const matchedIds = computed(() => { + if (!searchQuery.value.trim()) return allIds.value + if (!searchIds.value) return [] + // Intersect rather than trust the index: it is rebuilt per sync, so it can + // briefly name an entry this locale's payload no longer has. + const present = new Set(allIds.value) + return searchIds.value.filter(id => present.has(id)) +}) + +/** + * An entry named by `?entry=` — the palette's search result — is pulled to the + * front and opened. Otherwise "go to this entry" would drop someone at the top + * of a thousand rows to find it themselves, which is what they searched to + * avoid. Paging cannot hide it either: it is prepended, not paged to. + */ +const route = useRoute() +const targetEntryId = computed(() => { + const id = route.query.entry + return typeof id === 'string' && id in props.content ? id : null +}) + +const visibleEntries = computed(() => { + const target = targetEntryId.value + const ordered = target + ? [target, ...matchedIds.value.filter(id => id !== target)] + : matchedIds.value + + return ordered + .slice(0, visibleCount.value) + .map(id => ({ id, entry: props.content[id] as Record })) +}) +const hasMore = computed(() => matchedIds.value.length > visibleCount.value) + +// Distinguishable from "nothing matched": the worker resolves an empty array +// when it is not there, and reporting that as no results is a lie. +const searchUnavailable = computed(() => !!searchQuery.value.trim() && !brain.searchReady.value) + +// Hand-rolled rather than pulling in a utility library for one debounce. The +// token guards against a slow early query landing after a faster later one and +// overwriting it — the classic way a search box shows the wrong results. +let searchToken = 0 +let searchTimer: ReturnType | null = null + +watch(searchQuery, (query) => { + if (searchTimer) clearTimeout(searchTimer) + visibleCount.value = PAGE_SIZE + + const trimmed = query.trim() + if (!trimmed) { + searchToken++ + searchIds.value = null + searching.value = false + return + } + + searching.value = true + searchTimer = setTimeout(async () => { + const token = ++searchToken + // A high limit on purpose: the palette wants a shortlist, a filtered list + // wants every match. `hasMore` still keeps the render bounded. + const results = await brain.searchContent(trimmed, { + modelId: props.modelId, + locale: props.locale, + limit: 1000, + }) + if (token !== searchToken) return + searchIds.value = results.map(r => r.entryId) + searching.value = false + }, 200) +}) + +onBeforeUnmount(() => { + if (searchTimer) clearTimeout(searchTimer) +}) + +// A different model or locale is a different list; the old query and page +// position mean nothing there. +watch(() => [props.modelId, props.locale], () => { + searchQuery.value = '' + searchIds.value = null + visibleCount.value = PAGE_SIZE +}) + const getFieldType = inject(getFieldTypeKey, () => 'string') const getEntryTitle = inject(getEntryTitleKey, (_e: Record, f: string) => f) const getUserFieldIds = inject(getUserFieldIdsKey, () => []) @@ -146,10 +252,24 @@ function onFieldDragStart(e: DragEvent, entryId: string, fieldId: string, value: