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:
co-authored by
Claude Opus 4.6
parent
771e13aaf2
commit
1991d75850
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user