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) ───────────────────────────────────
|
// ── 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)...')
|
console.log('\n💬 Seeding comments (kind 1111)...')
|
||||||
let count = 0
|
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
|
// Top content: several comments
|
||||||
for (const item of topContent) {
|
for (const item of topContent) {
|
||||||
@@ -359,7 +385,7 @@ async function seedComments(relay: Relay) {
|
|||||||
const content = pickComment(item.id, sentiment)
|
const content = pickComment(item.id, sentiment)
|
||||||
const age = randomInt(1 * ONE_DAY, 30 * ONE_DAY)
|
const age = randomInt(1 * ONE_DAY, 30 * ONE_DAY)
|
||||||
|
|
||||||
const ok = await publishEvent(relay, signer, {
|
const ok = await publishComment(relay, signer, {
|
||||||
kind: 1111,
|
kind: 1111,
|
||||||
content,
|
content,
|
||||||
tags: [
|
tags: [
|
||||||
@@ -386,7 +412,7 @@ async function seedComments(relay: Relay) {
|
|||||||
const content = pickComment(item.id, sentiment)
|
const content = pickComment(item.id, sentiment)
|
||||||
const age = randomInt(3 * ONE_DAY, 45 * ONE_DAY)
|
const age = randomInt(3 * ONE_DAY, 45 * ONE_DAY)
|
||||||
|
|
||||||
const ok = await publishEvent(relay, signer, {
|
const ok = await publishComment(relay, signer, {
|
||||||
kind: 1111,
|
kind: 1111,
|
||||||
content,
|
content,
|
||||||
tags: [
|
tags: [
|
||||||
@@ -411,7 +437,7 @@ async function seedComments(relay: Relay) {
|
|||||||
const content = pickComment(item.id, 'positive')
|
const content = pickComment(item.id, 'positive')
|
||||||
const age = randomInt(0, 10 * ONE_DAY)
|
const age = randomInt(0, 10 * ONE_DAY)
|
||||||
|
|
||||||
const ok = await publishEvent(relay, signer, {
|
const ok = await publishComment(relay, signer, {
|
||||||
kind: 1111,
|
kind: 1111,
|
||||||
content,
|
content,
|
||||||
tags: [
|
tags: [
|
||||||
@@ -436,7 +462,7 @@ async function seedComments(relay: Relay) {
|
|||||||
const content = pickComment(item.id, 'positive')
|
const content = pickComment(item.id, 'positive')
|
||||||
const age = randomInt(0, 3 * ONE_DAY)
|
const age = randomInt(0, 3 * ONE_DAY)
|
||||||
|
|
||||||
const ok = await publishEvent(relay, signer, {
|
const ok = await publishComment(relay, signer, {
|
||||||
kind: 1111,
|
kind: 1111,
|
||||||
content,
|
content,
|
||||||
tags: [
|
tags: [
|
||||||
@@ -472,7 +498,7 @@ async function seedComments(relay: Relay) {
|
|||||||
const content = pickComment(item.id, sentiment)
|
const content = pickComment(item.id, sentiment)
|
||||||
const age = randomInt(2 * ONE_DAY, 40 * ONE_DAY)
|
const age = randomInt(2 * ONE_DAY, 40 * ONE_DAY)
|
||||||
|
|
||||||
const ok = await publishEvent(relay, signer, {
|
const ok = await publishComment(relay, signer, {
|
||||||
kind: 1111,
|
kind: 1111,
|
||||||
content,
|
content,
|
||||||
tags: [
|
tags: [
|
||||||
@@ -488,6 +514,36 @@ async function seedComments(relay: Relay) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(` ✓ ${count} comments seeded`)
|
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 ────────────────────────────────────────────────────────
|
// ── main ────────────────────────────────────────────────────────
|
||||||
@@ -497,7 +553,8 @@ async function main() {
|
|||||||
const relay = new Relay(RELAY_URL)
|
const relay = new Relay(RELAY_URL)
|
||||||
|
|
||||||
await seedReactions(relay)
|
await seedReactions(relay)
|
||||||
await seedComments(relay)
|
const commentEventIds = await seedComments(relay)
|
||||||
|
await seedCommentReactions(relay, commentEventIds)
|
||||||
|
|
||||||
console.log('\n✅ Done! Activity seeded successfully.')
|
console.log('\n✅ Done! Activity seeded successfully.')
|
||||||
setTimeout(() => process.exit(0), 1000)
|
setTimeout(() => process.exit(0), 1000)
|
||||||
|
|||||||
55
src/App.vue
55
src/App.vue
@@ -1,13 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app" class="min-h-screen">
|
<div id="app" class="min-h-screen">
|
||||||
<!-- Shared Header -->
|
<!-- Shared Header (hidden on mobile when search overlay is open) -->
|
||||||
<AppHeader @openAuth="handleOpenAuth" />
|
<AppHeader
|
||||||
|
:class="{ 'header-hidden-mobile': showMobileSearch }"
|
||||||
|
@openAuth="handleOpenAuth"
|
||||||
|
@selectContent="handleSearchSelect"
|
||||||
|
@openMobileSearch="showMobileSearch = true"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Route Content -->
|
<!-- Route Content -->
|
||||||
<RouterView @openAuth="handleOpenAuth" />
|
<RouterView @openAuth="handleOpenAuth" />
|
||||||
|
|
||||||
<!-- Mobile Navigation (hidden on desktop) -->
|
<!-- 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) -->
|
<!-- Auth Modal (shared across all views) -->
|
||||||
<AuthModal
|
<AuthModal
|
||||||
@@ -25,14 +42,20 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from './stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import { useSearchSelectionStore } from './stores/searchSelection'
|
||||||
import AppHeader from './components/AppHeader.vue'
|
import AppHeader from './components/AppHeader.vue'
|
||||||
import AuthModal from './components/AuthModal.vue'
|
import AuthModal from './components/AuthModal.vue'
|
||||||
import MobileNav from './components/MobileNav.vue'
|
import MobileNav from './components/MobileNav.vue'
|
||||||
|
import MobileSearch from './components/MobileSearch.vue'
|
||||||
import ToastContainer from './components/ToastContainer.vue'
|
import ToastContainer from './components/ToastContainer.vue'
|
||||||
|
import type { Content } from './types/content'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const searchSelection = useSearchSelectionStore()
|
||||||
|
|
||||||
const showAuthModal = ref(false)
|
const showAuthModal = ref(false)
|
||||||
|
const showMobileSearch = ref(false)
|
||||||
const pendingRedirect = ref<string | null>(null)
|
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 () => {
|
onMounted(async () => {
|
||||||
// Initialize authentication on app mount
|
// Initialize authentication on app mount
|
||||||
await authStore.initialize()
|
await authStore.initialize()
|
||||||
})
|
})
|
||||||
</script>
|
</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>
|
</button>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<button v-if="showSearch" class="hidden md:block p-2 hover:bg-white/10 rounded-lg transition-colors">
|
<div v-if="showSearch" class="hidden md:block relative search-wrapper" ref="searchWrapperRef">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<!-- Collapsed: icon button -->
|
||||||
<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" />
|
<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>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</button>
|
<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 -->
|
<!-- Active Nostr Account -->
|
||||||
<div v-if="nostrLoggedIn" class="hidden md:block relative profile-dropdown">
|
<div v-if="nostrLoggedIn" class="hidden md:block relative profile-dropdown">
|
||||||
@@ -219,7 +275,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRouter, useRoute } from 'vue-router'
|
||||||
import { useAuth } from '../composables/useAuth'
|
import { useAuth } from '../composables/useAuth'
|
||||||
import { useAccounts } from '../composables/useAccounts'
|
import { useAccounts } from '../composables/useAccounts'
|
||||||
@@ -227,6 +283,9 @@ import { accountManager } from '../lib/accounts'
|
|||||||
import { useContentDiscovery } from '../composables/useContentDiscovery'
|
import { useContentDiscovery } from '../composables/useContentDiscovery'
|
||||||
import { useContentSourceStore } from '../stores/contentSource'
|
import { useContentSourceStore } from '../stores/contentSource'
|
||||||
import { useContentStore } from '../stores/content'
|
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 }
|
type Persona = { name: string; nsec: string; pubkey: string }
|
||||||
|
|
||||||
@@ -238,6 +297,8 @@ interface Props {
|
|||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'openAuth', redirect?: string): void
|
(e: 'openAuth', redirect?: string): void
|
||||||
|
(e: 'selectContent', content: Content): void
|
||||||
|
(e: 'openMobileSearch'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {
|
||||||
@@ -282,6 +343,101 @@ const dropdownOpen = ref(false)
|
|||||||
const personaMenuOpen = ref(false)
|
const personaMenuOpen = ref(false)
|
||||||
const algosMenuOpen = 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() {
|
function toggleAlgosMenu() {
|
||||||
algosMenuOpen.value = !algosMenuOpen.value
|
algosMenuOpen.value = !algosMenuOpen.value
|
||||||
dropdownOpen.value = false
|
dropdownOpen.value = false
|
||||||
@@ -413,14 +569,32 @@ const handleClickOutside = (event: MouseEvent) => {
|
|||||||
if (algosDropdown && !algosDropdown.contains(event.target as Node)) {
|
if (algosDropdown && !algosDropdown.contains(event.target as Node)) {
|
||||||
algosMenuOpen.value = false
|
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(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('click', handleClickOutside)
|
window.addEventListener('click', handleClickOutside)
|
||||||
|
window.addEventListener('keydown', handleKeyboard)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('click', handleClickOutside)
|
window.removeEventListener('click', handleClickOutside)
|
||||||
|
window.removeEventListener('keydown', handleKeyboard)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -631,4 +805,125 @@ onUnmounted(() => {
|
|||||||
.profile-menu-item:hover svg {
|
.profile-menu-item:hover svg {
|
||||||
opacity: 1;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
<Transition name="modal-fade">
|
<Transition name="modal-fade">
|
||||||
<div v-if="isOpen && content" class="detail-overlay" @click.self="$emit('close')">
|
<div v-if="isOpen && content" class="detail-overlay" @click.self="$emit('close')">
|
||||||
<div class="detail-container">
|
<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 -->
|
<!-- Scrollable content area -->
|
||||||
<div class="detail-scroll" ref="scrollContainer">
|
<div class="detail-scroll" ref="scrollContainer">
|
||||||
<!-- Backdrop Hero -->
|
<!-- Backdrop Hero -->
|
||||||
@@ -13,13 +20,6 @@
|
|||||||
/>
|
/>
|
||||||
<div class="hero-gradient-overlay"></div>
|
<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 -->
|
<!-- Hero Content Overlay -->
|
||||||
<div class="absolute bottom-0 left-0 right-0 p-6 md:p-8">
|
<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;">
|
<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;
|
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 */
|
/* Hero */
|
||||||
.detail-hero {
|
.detail-hero {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -34,56 +34,82 @@
|
|||||||
<!-- Tab Bar -->
|
<!-- Tab Bar -->
|
||||||
<nav class="mobile-nav fixed bottom-0 left-0 right-0 z-50 md:hidden pb-4 px-4">
|
<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="floating-glass-nav px-4 py-3 rounded-2xl">
|
||||||
<div class="flex items-center justify-around gap-1">
|
<!-- Normal tabs (fade out when search opens) -->
|
||||||
<!-- Films -->
|
<Transition name="nav-swap" mode="out-in">
|
||||||
<button
|
<div v-if="!searchActive" key="tabs" class="nav-swap-container flex items-center justify-around gap-1">
|
||||||
@click="handleFilmsClick"
|
<!-- Films -->
|
||||||
class="flex flex-col items-center gap-1 nav-tab flex-1"
|
<button
|
||||||
:class="{ 'nav-tab-active': isOnFilmsPage && !isFilterActive }"
|
@click="handleFilmsClick"
|
||||||
>
|
class="flex flex-col items-center gap-1 nav-tab flex-1"
|
||||||
<svg class="w-6 h-6 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
:class="{ 'nav-tab-active': isOnFilmsPage && !isFilterActive }"
|
||||||
<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>
|
<svg class="w-6 h-6 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<span class="text-xs font-medium whitespace-nowrap">Films</span>
|
<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"/>
|
||||||
</button>
|
</svg>
|
||||||
|
<span class="text-xs font-medium whitespace-nowrap">Films</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Algos -->
|
<!-- Algos -->
|
||||||
<button
|
<button
|
||||||
@click="showFilterSheet = true"
|
@click="showFilterSheet = true"
|
||||||
class="flex flex-col items-center gap-1 nav-tab flex-1"
|
class="flex flex-col items-center gap-1 nav-tab flex-1"
|
||||||
:class="{ 'nav-tab-active': isFilterActive || showFilterSheet }"
|
:class="{ 'nav-tab-active': isFilterActive || showFilterSheet }"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="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" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-xs font-medium whitespace-nowrap">Algos</span>
|
<span class="text-xs font-medium whitespace-nowrap">Algos</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- My List -->
|
<!-- Search -->
|
||||||
<button
|
<button
|
||||||
@click="handleMyListClick"
|
@click="emit('openSearch')"
|
||||||
class="flex flex-col items-center gap-1 nav-tab flex-1"
|
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">
|
||||||
<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" />
|
||||||
<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>
|
||||||
</svg>
|
<span class="text-xs font-medium whitespace-nowrap">Search</span>
|
||||||
<span class="text-xs font-medium whitespace-nowrap">My List</span>
|
</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Profile -->
|
<!-- My List -->
|
||||||
<button
|
<button
|
||||||
@click="handleProfileClick"
|
@click="handleMyListClick"
|
||||||
class="flex flex-col items-center gap-1 nav-tab flex-1"
|
class="flex flex-col items-center gap-1 nav-tab flex-1"
|
||||||
:class="{ 'nav-tab-active': isActive('/profile') }"
|
: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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span class="text-xs font-medium whitespace-nowrap">Profile</span>
|
<span class="text-xs font-medium whitespace-nowrap">My List</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
@@ -95,7 +121,19 @@ import { useContentDiscovery, type AlgorithmId } from '../composables/useContent
|
|||||||
import { useAuth } from '../composables/useAuth'
|
import { useAuth } from '../composables/useAuth'
|
||||||
import { useAccounts } from '../composables/useAccounts'
|
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 router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -230,6 +268,36 @@ function selectAlgorithm(algo: AlgorithmId) {
|
|||||||
pointer-events: none;
|
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 Bottom Sheet --- */
|
||||||
.filter-sheet-backdrop {
|
.filter-sheet-backdrop {
|
||||||
position: fixed;
|
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[]
|
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.
|
* Build a threaded comment tree from flat event arrays.
|
||||||
* Top-level comments have #i = externalId.
|
* Top-level comments have #i = externalId.
|
||||||
* Replies reference parent via #e tag.
|
* 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(
|
function buildCommentTree(
|
||||||
topLevel: NostrEvent[],
|
topLevel: NostrEvent[],
|
||||||
allInThread: NostrEvent[],
|
allInThread: NostrEvent[],
|
||||||
|
reactionsMap: Map<string, NostrEvent[]>,
|
||||||
): CommentNode[] {
|
): CommentNode[] {
|
||||||
// Group replies by parent event ID
|
// Group replies by parent event ID
|
||||||
const childrenMap = new Map<string, NostrEvent[]>()
|
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]
|
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)
|
.map(buildNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +203,7 @@ export function useNostr(contentId?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
allComments.value = allEvents
|
allComments.value = allEvents
|
||||||
commentTree.value = buildCommentTree(topLevelEvents, allEvents)
|
commentTree.value = buildCommentTree(topLevelEvents, allEvents, commentReactions.value)
|
||||||
|
|
||||||
// Fetch profiles for all comment authors
|
// Fetch profiles for all comment authors
|
||||||
const pubkeys = [...new Set(allEvents.map((e) => e.pubkey))]
|
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.
|
* 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) {
|
function refreshSingleCommentReactions(eventId: string) {
|
||||||
const events = eventStore.getByFilters([
|
const events = eventStore.getByFilters([
|
||||||
@@ -227,6 +260,25 @@ export function useNostr(contentId?: string) {
|
|||||||
const newMap = new Map(commentReactions.value)
|
const newMap = new Map(commentReactions.value)
|
||||||
newMap.set(eventId, [...events])
|
newMap.set(eventId, [...events])
|
||||||
commentReactions.value = newMap
|
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'
|
import { useContentDiscovery } from '../composables/useContentDiscovery'
|
||||||
// indeeHubFilms data is now loaded dynamically via the content store
|
// indeeHubFilms data is now loaded dynamically via the content store
|
||||||
import { useContentSourceStore } from '../stores/contentSource'
|
import { useContentSourceStore } from '../stores/contentSource'
|
||||||
|
import { useSearchSelectionStore } from '../stores/searchSelection'
|
||||||
import type { Content } from '../types/content'
|
import type { Content } from '../types/content'
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'openAuth', redirect?: string): void }>()
|
const emit = defineEmits<{ (e: 'openAuth', redirect?: string): void }>()
|
||||||
@@ -289,6 +290,7 @@ const contentStore = useContentStore()
|
|||||||
const { isAuthenticated, hasActiveSubscription } = useAuth()
|
const { isAuthenticated, hasActiveSubscription } = useAuth()
|
||||||
const { isLoggedIn: isNostrLoggedIn } = useAccounts()
|
const { isLoggedIn: isNostrLoggedIn } = useAccounts()
|
||||||
const { sortContent, isFilterActive, activeAlgorithmLabel } = useContentDiscovery()
|
const { sortContent, isFilterActive, activeAlgorithmLabel } = useContentDiscovery()
|
||||||
|
const searchSelection = useSearchSelectionStore()
|
||||||
|
|
||||||
// Determine active tab from route path
|
// Determine active tab from route path
|
||||||
const isMyListTab = computed(() => route.path === '/library')
|
const isMyListTab = computed(() => route.path === '/library')
|
||||||
@@ -427,6 +429,14 @@ function handleSubscriptionSuccess() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
contentStore.fetchContent()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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