141 lines
5.5 KiB
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>
|