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:
@@ -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)
|
||||
|
||||
55
src/App.vue
55
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
248
src/components/MobileSearch.vue
Normal file
248
src/components/MobileSearch.vue
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
23
src/stores/searchSelection.ts
Normal file
23
src/stores/searchSelection.ts
Normal 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 }
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user