feat(app): add federated search across content libraries

Create useFederatedSearch.ts composable that searches films, songs,
and podcasts with 150ms debounce. Add SearchResults.vue overlay with
grouped results and type icons. ChatInput.vue detects /search command
prefix and shows results above the input, inserting content reference
tags on selection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 20:44:27 +00:00
co-authored by Claude Opus 4.6
parent 771e13aaf2
commit 1991d75850
3 changed files with 272 additions and 2 deletions
+36 -2
View File
@@ -1,5 +1,11 @@
<template>
<div class="p-3 md:p-4">
<div class="p-3 md:p-4 relative">
<SearchResults
v-if="isSearchMode"
:results="searchResults"
:is-searching="isSearching"
@select="handleSearchSelect"
/>
<div
class="path-glass-bubble rounded-2xl px-4 py-3 flex items-end gap-2 transition-all duration-300"
:class="focused
@@ -68,8 +74,10 @@
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { ref, computed, nextTick, watch } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useFederatedSearch, type SearchResult } from '@/composables/useFederatedSearch'
import SearchResults from '@/components/ui/SearchResults.vue'
const props = withDefaults(
defineProps<{
@@ -124,4 +132,30 @@ function autoResize() {
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
// Federated search via /search command
const { results: searchResults, isSearching, search: doSearch, clear: clearSearch } = useFederatedSearch()
const isSearchMode = computed(() => text.value.trimStart().startsWith('/search '))
watch(text, (val) => {
if (isSearchMode.value) {
const searchQuery = val.trimStart().replace(/^\/search\s+/, '')
doSearch(searchQuery)
} else {
clearSearch()
}
})
function handleSearchSelect(result: SearchResult) {
// Insert content reference tag based on type
const tags: Record<string, string> = {
film: `[[film:${result.id}]]`,
song: `[[song:${result.id}]]`,
podcast: `[[podcast:${result.id}]]`,
}
text.value = tags[result.type] ?? result.title
clearSearch()
nextTick(() => textareaRef.value?.focus())
}
</script>
@@ -0,0 +1,129 @@
<template>
<div
v-if="results.length > 0 || isSearching"
class="absolute bottom-full left-0 right-0 mb-1 max-h-72 overflow-y-auto rounded-xl shadow-lg z-50"
:class="isDark
? 'bg-gray-900/95 backdrop-blur-xl border border-white/10'
: 'bg-white/95 backdrop-blur-xl border border-gray-200'"
>
<div v-if="isSearching" class="p-3 text-center">
<span class="text-xs" :class="isDark ? 'text-white/40' : 'text-gray-400'">
Searching...
</span>
</div>
<template v-else>
<div v-for="group in groupedResults" :key="group.type" class="py-1">
<div
class="px-3 py-1 text-[10px] font-bold uppercase tracking-wider"
:class="isDark ? 'text-white/30' : 'text-gray-400'"
>
{{ group.label }}
</div>
<button
v-for="(result, i) in group.items"
:key="result.id"
class="w-full text-left px-3 py-2 flex items-center gap-2 transition-colors"
:class="[
selectedIndex === group.startIndex + i
? isDark ? 'bg-white/10' : 'bg-gray-100'
: isDark ? 'hover:bg-white/5' : 'hover:bg-gray-50'
]"
@click="$emit('select', result)"
@mouseenter="selectedIndex = group.startIndex + i"
>
<span
class="text-[10px] w-5 h-5 rounded flex items-center justify-center shrink-0"
:class="typeColor(result.type)"
>
{{ typeIcon(result.type) }}
</span>
<div class="min-w-0 flex-1">
<div
class="text-xs font-medium truncate"
:class="isDark ? 'text-white/80' : 'text-gray-800'"
>
{{ result.title }}
</div>
<div
class="text-[10px] truncate"
:class="isDark ? 'text-white/40' : 'text-gray-500'"
>
{{ result.subtitle }}
</div>
</div>
</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import type { SearchResult } from '@/composables/useFederatedSearch'
const props = defineProps<{
results: SearchResult[]
isSearching: boolean
}>()
defineEmits<{
select: [result: SearchResult]
}>()
const { isDark } = useTheme()
const selectedIndex = ref(0)
interface ResultGroup {
type: string
label: string
items: SearchResult[]
startIndex: number
}
const groupedResults = computed<ResultGroup[]>(() => {
const groups: Record<string, SearchResult[]> = {}
for (const r of props.results) {
if (!groups[r.type]) groups[r.type] = []
groups[r.type].push(r)
}
const labels: Record<string, string> = {
film: 'Films',
song: 'Songs',
podcast: 'Podcasts',
}
let idx = 0
return Object.entries(groups).map(([type, items]) => {
const group = { type, label: labels[type] ?? type, items, startIndex: idx }
idx += items.length
return group
})
})
function typeIcon(type: string): string {
const icons: Record<string, string> = { film: '🎬', song: '🎵', podcast: '🎙' }
return icons[type] ?? '📄'
}
function typeColor(type: string): string {
if (isDark.value) {
const colors: Record<string, string> = {
film: 'bg-blue-500/20 text-blue-400',
song: 'bg-green-500/20 text-green-400',
podcast: 'bg-orange-500/20 text-orange-400',
}
return colors[type] ?? 'bg-white/10 text-white/40'
}
const colors: Record<string, string> = {
film: 'bg-blue-50 text-blue-600',
song: 'bg-green-50 text-green-600',
podcast: 'bg-orange-50 text-orange-600',
}
return colors[type] ?? 'bg-gray-50 text-gray-600'
}
defineExpose({ selectedIndex })
</script>
@@ -0,0 +1,107 @@
import { ref, watch } from 'vue'
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
import { mockPodcasts } from '@/mocks/podcasts'
export interface SearchResult {
type: 'film' | 'song' | 'podcast'
title: string
subtitle: string
id: string
data: unknown
}
const query = ref('')
const results = ref<SearchResult[]>([])
const isSearching = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function searchLibrary(q: string): SearchResult[] {
const lower = q.toLowerCase()
const matched: SearchResult[] = []
for (const film of mockFilms) {
if (
film.title.toLowerCase().includes(lower) ||
film.director.toLowerCase().includes(lower) ||
film.genres.some(g => g.toLowerCase().includes(lower))
) {
matched.push({
type: 'film',
title: film.title,
subtitle: `${film.year} · ${film.director}`,
id: film.id,
data: film,
})
}
}
for (const song of mockSongs) {
if (
song.title.toLowerCase().includes(lower) ||
song.artist.toLowerCase().includes(lower) ||
(song.album ?? '').toLowerCase().includes(lower)
) {
matched.push({
type: 'song',
title: song.title,
subtitle: song.artist,
id: song.id,
data: song,
})
}
}
for (const podcast of mockPodcasts) {
if (
podcast.title.toLowerCase().includes(lower) ||
(podcast.host ?? '').toLowerCase().includes(lower)
) {
matched.push({
type: 'podcast',
title: podcast.title,
subtitle: podcast.host ?? 'Unknown',
id: podcast.id,
data: podcast,
})
}
}
return matched.slice(0, 20)
}
export function useFederatedSearch() {
function search(q: string) {
query.value = q
}
function clear() {
query.value = ''
results.value = []
isSearching.value = false
}
watch(query, (q) => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!q.trim()) {
results.value = []
isSearching.value = false
return
}
isSearching.value = true
debounceTimer = setTimeout(() => {
results.value = searchLibrary(q.trim())
isSearching.value = false
}, 150)
})
return {
query,
results,
isSearching,
search,
clear,
}
}