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:
Dorian
2026-02-12 14:57:16 +00:00
parent 53a88b012a
commit f19fd6feef
10 changed files with 898 additions and 74 deletions

View File

@@ -344,9 +344,35 @@ function pickComment(itemId: string, sentiment: 'positive' | 'mixed' | 'negative
}
// ── seed comments (kind 1111) ───────────────────────────────────
async function seedComments(relay: Relay) {
// Returns array of published comment event IDs for use by seedCommentReactions
async function seedComments(relay: Relay): Promise<string[]> {
console.log('\n💬 Seeding comments (kind 1111)...')
let count = 0
const commentEventIds: string[] = []
/**
* Helper that publishes a comment and tracks the event ID if successful.
*/
async function publishComment(
relay: Relay,
signer: PrivateKeySigner,
event: { kind: number; content: string; tags: string[][]; created_at: number },
label: string,
): Promise<boolean> {
const signed = await signer.signEvent(event)
try {
const res = await relay.publish(signed, { timeout: 5000 })
if (!res.ok) {
console.warn(`${label}: ${res.message}`)
} else {
commentEventIds.push(signed.id)
}
return true
} catch (err) {
console.error(`${label}:`, err instanceof Error ? err.message : err)
return false
}
}
// Top content: several comments
for (const item of topContent) {
@@ -359,7 +385,7 @@ async function seedComments(relay: Relay) {
const content = pickComment(item.id, sentiment)
const age = randomInt(1 * ONE_DAY, 30 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
const ok = await publishComment(relay, signer, {
kind: 1111,
content,
tags: [
@@ -386,7 +412,7 @@ async function seedComments(relay: Relay) {
const content = pickComment(item.id, sentiment)
const age = randomInt(3 * ONE_DAY, 45 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
const ok = await publishComment(relay, signer, {
kind: 1111,
content,
tags: [
@@ -411,7 +437,7 @@ async function seedComments(relay: Relay) {
const content = pickComment(item.id, 'positive')
const age = randomInt(0, 10 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
const ok = await publishComment(relay, signer, {
kind: 1111,
content,
tags: [
@@ -436,7 +462,7 @@ async function seedComments(relay: Relay) {
const content = pickComment(item.id, 'positive')
const age = randomInt(0, 3 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
const ok = await publishComment(relay, signer, {
kind: 1111,
content,
tags: [
@@ -472,7 +498,7 @@ async function seedComments(relay: Relay) {
const content = pickComment(item.id, sentiment)
const age = randomInt(2 * ONE_DAY, 40 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
const ok = await publishComment(relay, signer, {
kind: 1111,
content,
tags: [
@@ -488,6 +514,36 @@ async function seedComments(relay: Relay) {
}
console.log(`${count} comments seeded`)
return commentEventIds
}
// ── seed comment reactions (kind 7) ─────────────────────────────
// Seeds upvotes/downvotes on comment events so vote sorting is visible
async function seedCommentReactions(relay: Relay, commentEventIds: string[]) {
console.log('\n👍 Seeding comment reactions (kind 7)...')
let count = 0
for (const eventId of commentEventIds) {
const numVotes = randomInt(2, 8)
const voters = pick(allPersonas, numVotes)
for (const persona of voters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
// ~80% upvote, ~20% downvote for realistic distribution
const emoji = Math.random() < 0.8 ? '+' : '-'
const age = randomInt(0, 14 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 7,
content: emoji,
tags: [['e', eventId], ['k', '1111']],
created_at: now - age,
}, `comment-reaction ${persona.name}->${eventId.slice(0, 8)}`)
if (ok) count++
}
}
console.log(`${count} comment reactions seeded`)
}
// ── main ────────────────────────────────────────────────────────
@@ -497,7 +553,8 @@ async function main() {
const relay = new Relay(RELAY_URL)
await seedReactions(relay)
await seedComments(relay)
const commentEventIds = await seedComments(relay)
await seedCommentReactions(relay, commentEventIds)
console.log('\n✅ Done! Activity seeded successfully.')
setTimeout(() => process.exit(0), 1000)

View File

@@ -1,13 +1,30 @@
<template>
<div id="app" class="min-h-screen">
<!-- Shared Header -->
<AppHeader @openAuth="handleOpenAuth" />
<!-- Shared Header (hidden on mobile when search overlay is open) -->
<AppHeader
:class="{ 'header-hidden-mobile': showMobileSearch }"
@openAuth="handleOpenAuth"
@selectContent="handleSearchSelect"
@openMobileSearch="showMobileSearch = true"
/>
<!-- Route Content -->
<RouterView @openAuth="handleOpenAuth" />
<!-- Mobile Navigation (hidden on desktop) -->
<MobileNav @openAuth="handleOpenAuth" />
<MobileNav
:searchActive="showMobileSearch"
@openAuth="handleOpenAuth"
@openSearch="showMobileSearch = true"
@closeSearch="showMobileSearch = false"
/>
<!-- Mobile Search Overlay -->
<MobileSearch
:isOpen="showMobileSearch"
@close="showMobileSearch = false"
@select="handleSearchSelect"
/>
<!-- Auth Modal (shared across all views) -->
<AuthModal
@@ -25,14 +42,20 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth'
import { useSearchSelectionStore } from './stores/searchSelection'
import AppHeader from './components/AppHeader.vue'
import AuthModal from './components/AuthModal.vue'
import MobileNav from './components/MobileNav.vue'
import MobileSearch from './components/MobileSearch.vue'
import ToastContainer from './components/ToastContainer.vue'
import type { Content } from './types/content'
const router = useRouter()
const authStore = useAuthStore()
const searchSelection = useSearchSelectionStore()
const showAuthModal = ref(false)
const showMobileSearch = ref(false)
const pendingRedirect = ref<string | null>(null)
/**
@@ -57,8 +80,34 @@ function handleAuthSuccess() {
}
}
/**
* When a search result is selected, store the content in the
* shared selection store and navigate to the home page so
* Browse.vue can pick it up and open the detail modal.
*/
function handleSearchSelect(content: Content) {
showMobileSearch.value = false
searchSelection.select(content)
// Navigate to home if not already there — Browse.vue watches the store
if (router.currentRoute.value.path !== '/') {
router.push('/')
}
}
onMounted(async () => {
// Initialize authentication on app mount
await authStore.initialize()
})
</script>
<style scoped>
/* Hide the header on mobile when search overlay is active */
@media (max-width: 767px) {
.header-hidden-mobile {
opacity: 0;
pointer-events: none;
transform: translateY(-100%);
transition: all 0.25s ease-in;
}
}
</style>

View File

@@ -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>

View File

@@ -2,6 +2,13 @@
<Transition name="modal-fade">
<div v-if="isOpen && content" class="detail-overlay" @click.self="$emit('close')">
<div class="detail-container">
<!-- Close Button (fixed above scroll area, always accessible) -->
<button @click="$emit('close')" class="detail-close-btn">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Scrollable content area -->
<div class="detail-scroll" ref="scrollContainer">
<!-- Backdrop Hero -->
@@ -13,13 +20,6 @@
/>
<div class="hero-gradient-overlay"></div>
<!-- Close Button -->
<button @click="$emit('close')" class="absolute top-4 right-4 z-10 p-2 bg-black/50 backdrop-blur-md rounded-full text-white/80 hover:text-white transition-colors">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Hero Content Overlay -->
<div class="absolute bottom-0 left-0 right-0 p-6 md:p-8">
<h1 class="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3 drop-shadow-lg" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;">
@@ -457,6 +457,28 @@ function openSubscriptionFromRental() {
border-radius: 3px;
}
/* Close Button (always visible, above scroll) */
.detail-close-btn {
position: absolute;
top: 16px;
right: 16px;
z-index: 20;
padding: 8px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 9999px;
color: rgba(255, 255, 255, 0.8);
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.detail-close-btn:hover {
color: white;
background: rgba(0, 0, 0, 0.7);
}
/* Hero */
.detail-hero {
position: relative;

View File

@@ -34,56 +34,82 @@
<!-- Tab Bar -->
<nav class="mobile-nav fixed bottom-0 left-0 right-0 z-50 md:hidden pb-4 px-4">
<div class="floating-glass-nav px-4 py-3 rounded-2xl">
<div class="flex items-center justify-around gap-1">
<!-- Films -->
<button
@click="handleFilmsClick"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isOnFilmsPage && !isFilterActive }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z"/>
</svg>
<span class="text-xs font-medium whitespace-nowrap">Films</span>
</button>
<!-- Normal tabs (fade out when search opens) -->
<Transition name="nav-swap" mode="out-in">
<div v-if="!searchActive" key="tabs" class="nav-swap-container flex items-center justify-around gap-1">
<!-- Films -->
<button
@click="handleFilmsClick"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isOnFilmsPage && !isFilterActive }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z"/>
</svg>
<span class="text-xs font-medium whitespace-nowrap">Films</span>
</button>
<!-- Algos -->
<button
@click="showFilterSheet = true"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isFilterActive || showFilterSheet }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="text-xs font-medium whitespace-nowrap">Algos</span>
</button>
<!-- Algos -->
<button
@click="showFilterSheet = true"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isFilterActive || showFilterSheet }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="text-xs font-medium whitespace-nowrap">Algos</span>
</button>
<!-- My List -->
<button
@click="handleMyListClick"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isActive('/library') && !isFilterActive }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
<span class="text-xs font-medium whitespace-nowrap">My List</span>
</button>
<!-- Search -->
<button
@click="emit('openSearch')"
class="flex flex-col items-center gap-1 nav-tab flex-1"
>
<svg class="w-6 h-6 flex-shrink-0" 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>
<span class="text-xs font-medium whitespace-nowrap">Search</span>
</button>
<!-- Profile -->
<button
@click="handleProfileClick"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isActive('/profile') }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span class="text-xs font-medium whitespace-nowrap">Profile</span>
</button>
</div>
<!-- My List -->
<button
@click="handleMyListClick"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isActive('/library') && !isFilterActive }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
<span class="text-xs font-medium whitespace-nowrap">My List</span>
</button>
<!-- Profile -->
<button
@click="handleProfileClick"
class="flex flex-col items-center gap-1 nav-tab flex-1"
:class="{ 'nav-tab-active': isActive('/profile') }"
>
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span class="text-xs font-medium whitespace-nowrap">Profile</span>
</button>
</div>
<!-- Search-active: single centered X icon with glass active style -->
<div v-else key="close" class="nav-swap-container flex items-center justify-center">
<button
@click="emit('closeSearch')"
class="flex flex-col items-center gap-1 nav-tab nav-tab-active nav-close-icon"
>
<svg class="w-6 h-6 flex-shrink-0" 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>
</div>
</Transition>
</div>
</nav>
</template>
@@ -95,7 +121,19 @@ import { useContentDiscovery, type AlgorithmId } from '../composables/useContent
import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
const emit = defineEmits<{ (e: 'openAuth', redirect?: string): void }>()
interface Props {
searchActive?: boolean
}
withDefaults(defineProps<Props>(), {
searchActive: false,
})
const emit = defineEmits<{
(e: 'openAuth', redirect?: string): void
(e: 'openSearch'): void
(e: 'closeSearch'): void
}>()
const router = useRouter()
const route = useRoute()
@@ -230,6 +268,36 @@ function selectAlgorithm(algo: AlgorithmId) {
pointer-events: none;
}
/* Both transition states share the same fixed height so the bar never resizes */
.nav-swap-container {
min-height: 60px;
width: 100%;
}
/* Close icon — match normal tab size, no label */
.nav-close-icon {
max-width: 80px;
}
/* Tab swap transition (out-in: old fades/scales out, then new fades/scales in) */
.nav-swap-enter-active {
transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); /* slight overshoot */
}
.nav-swap-leave-active {
transition: all 0.15s ease-in;
}
.nav-swap-enter-from {
opacity: 0;
transform: scale(0.85);
}
.nav-swap-leave-to {
opacity: 0;
transform: scale(0.85);
}
/* --- Filter Bottom Sheet --- */
.filter-sheet-backdrop {
position: fixed;

View File

@@ -0,0 +1,248 @@
<template>
<Teleport to="body">
<Transition name="mobile-search">
<div v-if="isOpen" class="mobile-search-overlay">
<!-- Search input header -->
<div class="mobile-search-header">
<div class="relative flex-1">
<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="mobileInputRef"
v-model="query"
type="text"
placeholder="Search films..."
class="mobile-search-input"
@keydown.escape="$emit('close')"
/>
<button v-if="query" @click="query = ''" 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>
</div>
</div>
<!-- Body -->
<div class="mobile-search-body">
<!-- Empty state (vertically centered) -->
<div v-if="query.trim().length === 0" class="mobile-search-empty">
<svg class="w-12 h-12 mb-4 text-white/15" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span class="text-sm text-white/30">Search for films, categories, or years</span>
</div>
<!-- No results (vertically centered) -->
<div v-else-if="results.length === 0" class="mobile-search-empty">
<span class="text-sm text-white/30">No results for "{{ query }}"</span>
</div>
<!-- Results list -->
<div v-else class="mobile-search-results-list">
<div class="px-4 py-2 text-[10px] text-white/30 uppercase tracking-wider font-medium">
{{ results.length }} result{{ results.length === 1 ? '' : 's' }}
</div>
<button
v-for="result in results"
:key="result.id"
@click="handleSelect(result)"
class="mobile-search-result"
>
<img :src="result.thumbnail" :alt="result.title" class="w-12 h-16 rounded-lg 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 mt-0.5">{{ result.releaseYear }} · {{ result.categories?.slice(0, 2).join(', ') }}</div>
<div class="text-xs text-white/25 line-clamp-1 mt-0.5">{{ result.description }}</div>
</div>
<svg class="w-4 h-4 text-white/20 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { indeeHubFilms } from '../data/indeeHubFilms'
import { topDocFilms } from '../data/topDocFilms'
import { useContentSourceStore } from '../stores/contentSource'
import type { Content } from '../types/content'
interface Props {
isOpen: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'select', content: Content): void
}>()
const contentSourceStore = useContentSourceStore()
const query = ref('')
const mobileInputRef = ref<HTMLInputElement | null>(null)
const allContent = computed<Content[]>(() => {
return contentSourceStore.activeSource === 'topdocfilms'
? topDocFilms
: indeeHubFilms
})
const results = computed<Content[]>(() => {
const q = query.value.trim().toLowerCase()
if (!q) return []
return 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 || '')
if (title.startsWith(q)) score += 100
else if (title.includes(q)) score += 60
if (cats.includes(q)) score += 40
if (year.includes(q)) score += 30
if (desc.includes(q)) score += 20
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, 12)
.map(({ item }) => item)
})
function handleSelect(content: Content) {
emit('select', content)
emit('close')
}
// Auto-focus input when opened
watch(() => props.isOpen, (open) => {
if (open) {
query.value = ''
nextTick(() => mobileInputRef.value?.focus())
}
})
</script>
<style scoped>
.mobile-search-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
/* Extend to bottom; the tab bar (z-50) sits on top */
bottom: 0;
z-index: 45;
background: rgba(8, 8, 8, 0.97);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
display: flex;
flex-direction: column;
/* Extra bottom padding so content doesn't hide behind tab bar */
padding-bottom: calc(env(safe-area-inset-bottom, 0) + 90px);
}
.mobile-search-header {
display: flex;
align-items: center;
padding: 16px;
padding-top: calc(env(safe-area-inset-top, 0) + 16px);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.mobile-search-input {
width: 100%;
padding: 10px 36px 10px 34px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.95);
font-size: 16px; /* 16px prevents iOS zoom */
font-weight: 400;
outline: none;
transition: all 0.2s ease;
caret-color: rgba(255, 255, 255, 0.5);
-webkit-appearance: none;
}
.mobile-search-input::placeholder {
color: rgba(255, 255, 255, 0.25);
}
.mobile-search-input:focus {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.09);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.03);
}
.mobile-search-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
display: flex;
flex-direction: column;
}
/* Empty / no-results state: vertically centered */
.mobile-search-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
/* Results list fills remaining space and scrolls */
.mobile-search-results-list {
flex: 1;
}
.mobile-search-result {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 16px;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
}
.mobile-search-result:active {
background: rgba(255, 255, 255, 0.06);
}
/* Transitions */
.mobile-search-enter-active {
transition: all 0.25s ease-out;
}
.mobile-search-leave-active {
transition: all 0.2s ease-in;
}
.mobile-search-enter-from {
opacity: 0;
transform: translateY(-20px);
}
.mobile-search-leave-to {
opacity: 0;
transform: translateY(-20px);
}
</style>

View File

@@ -31,14 +31,41 @@ export interface CommentNode {
replies: CommentNode[]
}
/**
* Compute the net vote score (positive - negative) for a comment event
* from a reactions map.
*/
function getNetVotes(eventId: string, reactionsMap: Map<string, NostrEvent[]>): number {
const reactions = reactionsMap.get(eventId) || []
// Deduplicate: one vote per pubkey, keep latest
const byPubkey = new Map<string, NostrEvent>()
for (const r of reactions) {
const existing = byPubkey.get(r.pubkey)
if (!existing || r.created_at > existing.created_at) {
byPubkey.set(r.pubkey, r)
}
}
let net = 0
for (const r of byPubkey.values()) {
if (r.content === '+') net++
else if (r.content === '-') net--
}
return net
}
/**
* Build a threaded comment tree from flat event arrays.
* Top-level comments have #i = externalId.
* Replies reference parent via #e tag.
*
* Top-level comments are sorted by net votes (most upvoted first),
* with created_at descending as tiebreaker.
* Replies are sorted chronologically (oldest first).
*/
function buildCommentTree(
topLevel: NostrEvent[],
allInThread: NostrEvent[],
reactionsMap: Map<string, NostrEvent[]>,
): CommentNode[] {
// Group replies by parent event ID
const childrenMap = new Map<string, NostrEvent[]>()
@@ -66,8 +93,13 @@ function buildCommentTree(
}
}
// Sort top-level by net votes descending, then newest first as tiebreaker
return [...topLevel]
.sort((a, b) => b.created_at - a.created_at)
.sort((a, b) => {
const voteDiff = getNetVotes(b.id, reactionsMap) - getNetVotes(a.id, reactionsMap)
if (voteDiff !== 0) return voteDiff
return b.created_at - a.created_at
})
.map(buildNode)
}
@@ -171,7 +203,7 @@ export function useNostr(contentId?: string) {
}
allComments.value = allEvents
commentTree.value = buildCommentTree(topLevelEvents, allEvents)
commentTree.value = buildCommentTree(topLevelEvents, allEvents, commentReactions.value)
// Fetch profiles for all comment authors
const pubkeys = [...new Set(allEvents.map((e) => e.pubkey))]
@@ -218,6 +250,7 @@ export function useNostr(contentId?: string) {
/**
* Refresh reactions for a single comment event from the store.
* Also re-sorts the comment tree so most-voted comments float to top.
*/
function refreshSingleCommentReactions(eventId: string) {
const events = eventStore.getByFilters([
@@ -227,6 +260,25 @@ export function useNostr(contentId?: string) {
const newMap = new Map(commentReactions.value)
newMap.set(eventId, [...events])
commentReactions.value = newMap
// Re-sort the comment tree with updated vote counts
if (currentExternalId) {
const topLevel = eventStore.getByFilters([
{ kinds: [COMMENT_KIND], '#i': [currentExternalId] },
])
const allInThread = eventStore.getByFilters([
{ kinds: [COMMENT_KIND], '#I': [currentExternalId] },
])
if (topLevel && allInThread) {
const topLevelEvents = [...topLevel]
const allEvents = [...allInThread]
const allIds = new Set(allEvents.map((e) => e.id))
for (const e of topLevelEvents) {
if (!allIds.has(e.id)) allEvents.push(e)
}
commentTree.value = buildCommentTree(topLevelEvents, allEvents, newMap)
}
}
}
}

View File

@@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Content } from '../types/content'
/**
* Lightweight store to bridge search result selection
* from the header/mobile search to Browse.vue's detail modal.
*/
export const useSearchSelectionStore = defineStore('searchSelection', () => {
const pendingContent = ref<Content | null>(null)
function select(content: Content) {
pendingContent.value = content
}
function consume(): Content | null {
const content = pendingContent.value
pendingContent.value = null
return content
}
return { pendingContent, select, consume }
})

View File

@@ -279,6 +279,7 @@ import { useAccounts } from '../composables/useAccounts'
import { useContentDiscovery } from '../composables/useContentDiscovery'
// indeeHubFilms data is now loaded dynamically via the content store
import { useContentSourceStore } from '../stores/contentSource'
import { useSearchSelectionStore } from '../stores/searchSelection'
import type { Content } from '../types/content'
const emit = defineEmits<{ (e: 'openAuth', redirect?: string): void }>()
@@ -289,6 +290,7 @@ const contentStore = useContentStore()
const { isAuthenticated, hasActiveSubscription } = useAuth()
const { isLoggedIn: isNostrLoggedIn } = useAccounts()
const { sortContent, isFilterActive, activeAlgorithmLabel } = useContentDiscovery()
const searchSelection = useSearchSelectionStore()
// Determine active tab from route path
const isMyListTab = computed(() => route.path === '/library')
@@ -427,6 +429,14 @@ function handleSubscriptionSuccess() {
onMounted(() => {
contentStore.fetchContent()
})
// Watch for search selection from the global search bar
watch(() => searchSelection.pendingContent, (content) => {
if (content) {
selectedContent.value = searchSelection.consume()
showDetailModal.value = true
}
})
</script>
<style scoped>

View File

@@ -1 +1 @@
{"root":["./src/env.d.ts","./src/main.ts","./src/composables/useaccess.ts","./src/composables/useaccounts.ts","./src/composables/useauth.ts","./src/composables/usecontentdiscovery.ts","./src/composables/usemobile.ts","./src/composables/usenostr.ts","./src/composables/useobservable.ts","./src/composables/usetoast.ts","./src/config/api.config.ts","./src/data/indeehubfilms.ts","./src/data/testpersonas.ts","./src/data/topdocfilms.ts","./src/lib/accounts.ts","./src/lib/nostr.ts","./src/lib/relay.ts","./src/router/guards.ts","./src/router/index.ts","./src/services/api.service.ts","./src/services/auth.service.ts","./src/services/content.service.ts","./src/services/library.service.ts","./src/services/subscription.service.ts","./src/stores/auth.ts","./src/stores/content.ts","./src/stores/contentsource.ts","./src/types/api.ts","./src/types/content.ts","./src/utils/indeehubapi.ts","./src/utils/mappers.ts","./src/utils/nostr.ts","./src/app.vue","./src/components/appheader.vue","./src/components/authmodal.vue","./src/components/commentnode.vue","./src/components/contentdetailmodal.vue","./src/components/contentrow.vue","./src/components/mobilenav.vue","./src/components/rentalmodal.vue","./src/components/splashintro.vue","./src/components/splashintroicon.vue","./src/components/subscriptionmodal.vue","./src/components/toastcontainer.vue","./src/components/videoplayer.vue","./src/views/browse.vue","./src/views/profile.vue"],"version":"5.9.3"}
{"root":["./src/env.d.ts","./src/main.ts","./src/composables/useaccess.ts","./src/composables/useaccounts.ts","./src/composables/useauth.ts","./src/composables/usecontentdiscovery.ts","./src/composables/usemobile.ts","./src/composables/usenostr.ts","./src/composables/useobservable.ts","./src/composables/usetoast.ts","./src/config/api.config.ts","./src/data/indeehubfilms.ts","./src/data/testpersonas.ts","./src/data/topdocfilms.ts","./src/lib/accounts.ts","./src/lib/nostr.ts","./src/lib/relay.ts","./src/router/guards.ts","./src/router/index.ts","./src/services/api.service.ts","./src/services/auth.service.ts","./src/services/content.service.ts","./src/services/library.service.ts","./src/services/subscription.service.ts","./src/stores/auth.ts","./src/stores/content.ts","./src/stores/contentsource.ts","./src/stores/searchselection.ts","./src/types/api.ts","./src/types/content.ts","./src/utils/indeehubapi.ts","./src/utils/mappers.ts","./src/utils/nostr.ts","./src/app.vue","./src/components/appheader.vue","./src/components/authmodal.vue","./src/components/commentnode.vue","./src/components/contentdetailmodal.vue","./src/components/contentrow.vue","./src/components/mobilenav.vue","./src/components/mobilesearch.vue","./src/components/rentalmodal.vue","./src/components/splashintro.vue","./src/components/splashintroicon.vue","./src/components/subscriptionmodal.vue","./src/components/toastcontainer.vue","./src/components/videoplayer.vue","./src/views/browse.vue","./src/views/profile.vue"],"version":"5.9.3"}