Files
momentry_studio/src/views/SearchView.vue

141 lines
5.5 KiB
Vue

<template>
<div class="search-view">
<h1>Search</h1>
<div class="search-bar">
<input v-model="query" @keyup.enter="search"
placeholder="Enter search query..."
:disabled="loading" />
<select v-model="mode" class="mode-select">
<option v-for="m in modes" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
<button class="ms-fm-btn ms-fm-btn-primary" @click="search" :disabled="loading || !query">
<span v-if="loading" class="spinner"></span>
{{ loading ? 'Searching...' : 'Search' }}
</button>
</div>
<div v-if="loading && !results.length" class="loading-state">
<div class="spinner-lg"></div>
<p>Searching...</p>
</div>
<div class="results" v-if="results.length">
<div v-for="(r, i) in results" :key="i" class="result-card" @click="playVideo(r)">
<div class="result-thumb">
<img v-if="thumbs[r.file_uuid]" :src="thumbs[r.file_uuid]" alt="" loading="lazy" @vue:mounted="loadThumb(r.file_uuid)">
<div v-else class="thumb-placeholder" @vue:mounted="loadThumb(r.file_uuid)">
<div class="play-icon"></div>
</div>
</div>
<div class="result-info">
<div class="result-meta">
<span class="score">{{ (r.similarity * 100).toFixed(0) }}%</span>
<span class="time">{{ formatTime(r.start_time) }} - {{ formatTime(r.end_time) }}</span>
</div>
<p class="summary">{{ r.summary }}</p>
<p class="file-name">{{ r.file_name }}</p>
</div>
</div>
</div>
<p v-else-if="searched" class="no-results">No results found</p>
<VideoPlayer
v-if="playing"
:file-uuid="currentVideo.fileUuid"
:start-time="currentVideo.startTime"
:end-time="currentVideo.endTime"
:title="currentVideo.title"
@close="playing = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import VideoPlayer from '../components/VideoPlayer.vue'
const modes = [
{ label: 'Keyword', value: 'keyword' },
{ label: 'Semantic', value: 'semantic' }
]
const mode = ref('semantic')
const query = ref('')
const results = ref<any[]>([])
const loading = ref(false)
const searched = ref(false)
const thumbs = ref<Record<string, string>>({})
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
async function search() {
if (!query.value) return
loading.value = true
searched.value = false
try {
results.value = await invoke('search_llm_smart', { query: query.value, limit: 20 })
searched.value = true
} catch (e) {
console.error('Search failed:', e)
searched.value = true
} finally {
loading.value = false
}
}
async function loadThumb(uuid: string) {
if (!uuid || thumbs.value[uuid]) return
try {
thumbs.value[uuid] = await invoke('get_thumbnail', { uuid, frame: 30 })
} catch {
// keep placeholder
}
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function playVideo(r: any) {
currentVideo.value = {
fileUuid: r.file_uuid,
startTime: r.start_time,
endTime: r.end_time,
title: r.summary?.slice(0, 60) || r.file_name || 'Video'
}
playing.value = true
}
</script>
<style scoped>
.search-view { max-width: 900px; }
h1 { font-size: 1.5rem; margin-bottom: 24px; }
.search-bar { display: flex; gap: 10px; margin-bottom: 24px; }
.search-bar input { flex: 1; padding: 12px 16px; border: 1.5px solid #d1d5db; border-radius: 10px; font-size: 1rem; outline: none; transition: border-color 0.15s; }
.search-bar input:focus { border-color: #202124; }
.search-bar input:disabled { background: #f3f4f6; }
.mode-select { padding: 12px 16px; border: 1.5px solid #d1d5db; border-radius: 10px; font-size: 0.9rem; background: #fff; cursor: pointer; outline: none; }
.result-card { display: flex; gap: 16px; background: #fff; border: 1px solid #e8eaed; border-radius: 12px; padding: 16px; margin-bottom: 12px; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; }
.result-card:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,0.1); }
.result-thumb { width: 120px; height: 68px; border-radius: 8px; overflow: hidden; flex-shrink: 0; background: #e8eaed; }
.result-thumb img { width: 100%; height: 100%; object-fit: cover; }
.thumb-placeholder { width: 100%; height: 100%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; }
.play-icon { color: #fff; font-size: 1.5rem; opacity: 0.8; }
.result-info { flex: 1; min-width: 0; }
.result-meta { display: flex; gap: 12px; margin-bottom: 6px; }
.score { color: #1a56db; font-weight: 600; font-size: 0.85rem; }
.time { color: #9aa0a6; font-size: 0.85rem; }
.summary { color: #3c4043; margin-bottom: 4px; line-height: 1.4; }
.file-name { color: #9aa0a6; font-size: 0.8rem; }
.no-results { text-align: center; padding: 40px; color: #5f6368; }
.loading-state { text-align: center; padding: 60px 0; color: #5f6368; }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 6px; }
.spinner-lg { width: 24px; height: 24px; border: 3px solid #e8eaed; border-top-color: #202124; border-radius: 50%; animation: spin 0.7s linear infinite; margin: 0 auto 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>