Enhance comment seeding and search functionality
- Updated the `seedComments` function to return an array of published comment event IDs for tracking. - Introduced `seedCommentReactions` to seed upvotes and downvotes on comments, improving interaction visibility. - Enhanced the `App.vue` and `MobileNav.vue` components to support a mobile search overlay, allowing users to search films seamlessly. - Added a new `MobileSearch` component for better search experience on mobile devices. - Implemented a search feature in `AppHeader.vue` with dropdown results for improved content discovery. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -126,11 +126,67 @@
|
||||
</button>
|
||||
|
||||
<!-- Search -->
|
||||
<button v-if="showSearch" class="hidden md:block p-2 hover:bg-white/10 rounded-lg transition-colors">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="showSearch" class="hidden md:block relative search-wrapper" ref="searchWrapperRef">
|
||||
<!-- Collapsed: icon button -->
|
||||
<button v-if="!searchOpen" @click.stop="openSearch" class="p-2 hover:bg-white/10 rounded-lg transition-colors text-white/80 hover:text-white">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Expanded: search input -->
|
||||
<div v-else class="search-input-wrap" @click.stop>
|
||||
<svg class="w-4 h-4 text-white/40 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search films..."
|
||||
class="search-input"
|
||||
@keydown.escape="closeSearch"
|
||||
@keydown.down.prevent="highlightNext"
|
||||
@keydown.up.prevent="highlightPrev"
|
||||
@keydown.enter.prevent="selectHighlighted"
|
||||
/>
|
||||
<button v-if="searchQuery" @click="searchQuery = ''" class="absolute right-3 top-1/2 -translate-y-1/2 text-white/40 hover:text-white/70 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<kbd v-else class="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-white/25 border border-white/10 rounded px-1.5 py-0.5 font-mono">ESC</kbd>
|
||||
</div>
|
||||
|
||||
<!-- Results dropdown -->
|
||||
<Transition name="search-fade">
|
||||
<div v-if="searchOpen && searchQuery.trim().length > 0" class="search-results" @mousedown.stop>
|
||||
<div v-if="searchResults.length === 0" class="px-4 py-6 text-center text-white/40 text-sm">
|
||||
No results for "{{ searchQuery }}"
|
||||
</div>
|
||||
<div v-else class="py-2">
|
||||
<div class="px-3 py-1 text-[10px] text-white/30 uppercase tracking-wider font-medium">
|
||||
{{ searchResults.length }} result{{ searchResults.length === 1 ? '' : 's' }}
|
||||
</div>
|
||||
<button
|
||||
v-for="(result, idx) in searchResults"
|
||||
:key="result.id"
|
||||
@click="selectResult(result)"
|
||||
@mouseenter="highlightedIndex = idx"
|
||||
class="search-result-item"
|
||||
:class="{ 'search-result-highlighted': idx === highlightedIndex }"
|
||||
>
|
||||
<img :src="result.thumbnail" :alt="result.title" class="w-10 h-14 rounded object-cover bg-neutral-800 flex-shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-white truncate">{{ result.title }}</div>
|
||||
<div class="text-xs text-white/40 truncate">{{ result.releaseYear }} · {{ result.categories?.slice(0, 2).join(', ') }}</div>
|
||||
</div>
|
||||
<span v-if="result.type" class="text-[10px] text-white/25 uppercase tracking-wider flex-shrink-0">{{ result.type }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Active Nostr Account -->
|
||||
<div v-if="nostrLoggedIn" class="hidden md:block relative profile-dropdown">
|
||||
@@ -219,7 +275,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import { useAccounts } from '../composables/useAccounts'
|
||||
@@ -227,6 +283,9 @@ import { accountManager } from '../lib/accounts'
|
||||
import { useContentDiscovery } from '../composables/useContentDiscovery'
|
||||
import { useContentSourceStore } from '../stores/contentSource'
|
||||
import { useContentStore } from '../stores/content'
|
||||
import { indeeHubFilms } from '../data/indeeHubFilms'
|
||||
import { topDocFilms } from '../data/topDocFilms'
|
||||
import type { Content } from '../types/content'
|
||||
|
||||
type Persona = { name: string; nsec: string; pubkey: string }
|
||||
|
||||
@@ -238,6 +297,8 @@ interface Props {
|
||||
|
||||
interface Emits {
|
||||
(e: 'openAuth', redirect?: string): void
|
||||
(e: 'selectContent', content: Content): void
|
||||
(e: 'openMobileSearch'): void
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
@@ -282,6 +343,101 @@ const dropdownOpen = ref(false)
|
||||
const personaMenuOpen = ref(false)
|
||||
const algosMenuOpen = ref(false)
|
||||
|
||||
// ── Search state ──────────────────────────────────────────────────────────────
|
||||
const searchOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const highlightedIndex = ref(-1)
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
const searchWrapperRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** All films from the active content source for searching */
|
||||
const allContent = computed<Content[]>(() => {
|
||||
return contentSourceStore.activeSource === 'topdocfilms'
|
||||
? topDocFilms
|
||||
: indeeHubFilms
|
||||
})
|
||||
|
||||
/**
|
||||
* Fuzzy-ish search: matches title, description, categories, and year.
|
||||
* Returns up to 8 results ranked by match quality.
|
||||
*/
|
||||
const searchResults = computed<Content[]>(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
if (!q) return []
|
||||
|
||||
const scored = allContent.value
|
||||
.map((item) => {
|
||||
let score = 0
|
||||
const title = item.title.toLowerCase()
|
||||
const desc = item.description?.toLowerCase() || ''
|
||||
const cats = item.categories?.map(c => c.toLowerCase()).join(' ') || ''
|
||||
const year = String(item.releaseYear || '')
|
||||
|
||||
// Title exact start
|
||||
if (title.startsWith(q)) score += 100
|
||||
// Title contains
|
||||
else if (title.includes(q)) score += 60
|
||||
// Category match
|
||||
if (cats.includes(q)) score += 40
|
||||
// Year match
|
||||
if (year.includes(q)) score += 30
|
||||
// Description match
|
||||
if (desc.includes(q)) score += 20
|
||||
// Word-start matching in title (e.g. "bit" matches "Bitcoin")
|
||||
const words = title.split(/\s+/)
|
||||
if (words.some(w => w.startsWith(q))) score += 50
|
||||
|
||||
return { item, score }
|
||||
})
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 8)
|
||||
.map(({ item }) => item)
|
||||
|
||||
return scored
|
||||
})
|
||||
|
||||
function openSearch() {
|
||||
searchOpen.value = true
|
||||
searchQuery.value = ''
|
||||
highlightedIndex.value = -1
|
||||
nextTick(() => searchInputRef.value?.focus())
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
searchOpen.value = false
|
||||
searchQuery.value = ''
|
||||
highlightedIndex.value = -1
|
||||
}
|
||||
|
||||
function highlightNext() {
|
||||
if (searchResults.value.length === 0) return
|
||||
highlightedIndex.value = (highlightedIndex.value + 1) % searchResults.value.length
|
||||
}
|
||||
|
||||
function highlightPrev() {
|
||||
if (searchResults.value.length === 0) return
|
||||
highlightedIndex.value = highlightedIndex.value <= 0
|
||||
? searchResults.value.length - 1
|
||||
: highlightedIndex.value - 1
|
||||
}
|
||||
|
||||
function selectHighlighted() {
|
||||
if (highlightedIndex.value >= 0 && highlightedIndex.value < searchResults.value.length) {
|
||||
selectResult(searchResults.value[highlightedIndex.value])
|
||||
}
|
||||
}
|
||||
|
||||
function selectResult(content: Content) {
|
||||
closeSearch()
|
||||
emit('selectContent', content)
|
||||
}
|
||||
|
||||
// Reset highlight when results change
|
||||
watch(searchResults, () => {
|
||||
highlightedIndex.value = searchResults.value.length > 0 ? 0 : -1
|
||||
})
|
||||
|
||||
function toggleAlgosMenu() {
|
||||
algosMenuOpen.value = !algosMenuOpen.value
|
||||
dropdownOpen.value = false
|
||||
@@ -413,14 +569,32 @@ const handleClickOutside = (event: MouseEvent) => {
|
||||
if (algosDropdown && !algosDropdown.contains(event.target as Node)) {
|
||||
algosMenuOpen.value = false
|
||||
}
|
||||
// Close search when clicking outside the search wrapper
|
||||
if (searchWrapperRef.value && !searchWrapperRef.value.contains(event.target as Node)) {
|
||||
if (searchOpen.value) closeSearch()
|
||||
}
|
||||
}
|
||||
|
||||
/** Global keyboard shortcut: Cmd/Ctrl + K to open search */
|
||||
function handleKeyboard(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||||
event.preventDefault()
|
||||
if (searchOpen.value) {
|
||||
closeSearch()
|
||||
} else {
|
||||
openSearch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleClickOutside)
|
||||
window.addEventListener('keydown', handleKeyboard)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('click', handleClickOutside)
|
||||
window.removeEventListener('keydown', handleKeyboard)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -631,4 +805,125 @@ onUnmounted(() => {
|
||||
.profile-menu-item:hover svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Search ───────────────────────────────────────────────────────────────────── */
|
||||
.search-wrapper {
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.search-input-wrap {
|
||||
position: relative;
|
||||
width: 280px;
|
||||
animation: searchExpand 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes searchExpand {
|
||||
from {
|
||||
width: 40px;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
width: 280px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 8px 36px 8px 34px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
caret-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 380px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
background: rgba(10, 10, 10, 0.85);
|
||||
backdrop-filter: blur(60px);
|
||||
-webkit-backdrop-filter: blur(60px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 24px 80px rgba(0, 0, 0, 0.6),
|
||||
0 8px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.search-results::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.search-results::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search-results::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.search-result-item:hover,
|
||||
.search-result-highlighted {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.search-result-highlighted {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Search fade transition */
|
||||
.search-fade-enter-active {
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.search-fade-leave-active {
|
||||
transition: all 0.15s ease-in;
|
||||
}
|
||||
|
||||
.search-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.97);
|
||||
}
|
||||
|
||||
.search-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user