These are dev/testing tools that shouldn't be visible in production. Commented out for now so they can be re-enabled easily. Co-authored-by: Cursor <cursoragent@cursor.com>
967 lines
33 KiB
Vue
967 lines
33 KiB
Vue
<template>
|
||
<header class="fixed top-0 left-0 right-0 z-50 pt-4 px-4">
|
||
<div class="floating-glass-header mx-auto px-4 md:px-6 py-3.5 rounded-2xl transition-all duration-300" style="max-width: 100%;">
|
||
<div class="flex items-center justify-between">
|
||
<!-- Logo + Navigation (Left Side) -->
|
||
<div class="flex items-center gap-4 lg:gap-10">
|
||
<router-link to="/" class="flex-shrink-0">
|
||
<img src="/assets/images/logo-desktop.svg" alt="IndeeHub" class="h-8 md:h-10 ml-2 md:ml-0" />
|
||
</router-link>
|
||
|
||
<!-- Navigation - Desktop -->
|
||
<nav v-if="showNav" class="hidden md:flex items-center gap-3">
|
||
<button @click="handleFilmsClick" :class="isRoute('/') && !activeAlgorithm ? 'nav-button-active' : 'nav-button'">Films</button>
|
||
<button @click="handleMyListClick" :class="isRoute('/library') && !activeAlgorithm ? 'nav-button-active' : 'nav-button'">My List</button>
|
||
|
||
<!-- Inline Algorithm Buttons (xl+ screens where they fit) -->
|
||
<div class="hidden xl:flex items-center gap-3">
|
||
<button
|
||
v-for="algo in algorithms"
|
||
:key="'inline-' + algo.id"
|
||
@click="handleAlgoSelect(algo.id)"
|
||
:class="activeAlgorithm === algo.id ? 'nav-button-active' : 'nav-button'"
|
||
>
|
||
{{ algo.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Algos Dropdown (md–xl screens where inline buttons would overflow) -->
|
||
<div class="relative algos-dropdown flex xl:hidden">
|
||
<button
|
||
@click="toggleAlgosMenu"
|
||
:class="activeAlgorithm ? 'nav-button-active' : 'nav-button'"
|
||
>
|
||
{{ activeAlgorithmLabel || 'Algos' }}
|
||
<svg class="w-3.5 h-3.5 ml-1.5 inline transition-transform" :class="{ 'rotate-180': algosMenuOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<div v-if="algosMenuOpen" class="profile-menu absolute left-0 mt-2 w-52">
|
||
<div class="floating-glass-header py-2 rounded-xl">
|
||
<div class="px-3 py-1 text-xs text-white/40 uppercase tracking-wider">Discovery Algorithms</div>
|
||
<button
|
||
v-for="algo in algorithms"
|
||
:key="'dropdown-' + algo.id"
|
||
@click="handleAlgoSelect(algo.id)"
|
||
class="profile-menu-item flex items-center justify-between px-4 py-2.5 w-full text-left"
|
||
>
|
||
<span>{{ algo.label }}</span>
|
||
<svg
|
||
v-if="activeAlgorithm === algo.id"
|
||
class="w-4 h-4 text-white/80"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
</button>
|
||
<template v-if="activeAlgorithm">
|
||
<div class="border-t border-white/10 my-1"></div>
|
||
<button
|
||
@click="handleAlgoClear"
|
||
class="profile-menu-item flex items-center gap-3 px-4 py-2.5 text-white/50 w-full text-left text-sm"
|
||
>
|
||
Clear filter
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
</div>
|
||
|
||
<!-- Right Side Actions -->
|
||
<div class="flex items-center gap-4">
|
||
<!-- Nostr Login (persona switcher or extension) — hidden for now
|
||
<div v-if="!hasNostrSession" class="hidden md:flex items-center gap-2">
|
||
<div class="relative persona-dropdown">
|
||
<button @click="togglePersonaMenu" class="nav-button px-3 py-2 text-xs">
|
||
Persona
|
||
<svg class="w-3 h-3 ml-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
<div v-if="personaMenuOpen" class="profile-menu absolute right-0 mt-2 w-52">
|
||
<div class="floating-glass-header py-2 rounded-xl">
|
||
<div class="px-3 py-1 text-xs text-white/40 uppercase tracking-wider">Test Personas</div>
|
||
<button
|
||
v-for="persona in testPersonas"
|
||
:key="persona.pubkey"
|
||
@click="handlePersonaLogin(persona)"
|
||
class="profile-menu-item flex items-center gap-3 px-4 py-2 w-full text-left"
|
||
>
|
||
<img :src="`https://robohash.org/${persona.pubkey}.png`" class="w-6 h-6 rounded-full" :alt="persona.name" />
|
||
<span>{{ persona.name }}</span>
|
||
</button>
|
||
<div class="border-t border-white/10 my-1"></div>
|
||
<div class="px-3 py-1 text-xs text-white/40 uppercase tracking-wider">Tastemakers</div>
|
||
<button
|
||
v-for="persona in tastemakerPersonas"
|
||
:key="persona.pubkey"
|
||
@click="handlePersonaLogin(persona)"
|
||
class="profile-menu-item flex items-center gap-3 px-4 py-2 w-full text-left"
|
||
>
|
||
<img :src="`https://robohash.org/${persona.pubkey}.png`" class="w-6 h-6 rounded-full" :alt="persona.name" />
|
||
<span>{{ persona.name }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button @click="handleExtensionLogin" class="nav-button px-3 py-2 text-xs">
|
||
Extension
|
||
</button>
|
||
</div>
|
||
-->
|
||
|
||
<!-- Sign In (app auth, if not Nostr logged in) -->
|
||
<button
|
||
v-if="!isAuthenticated && showAuth && !hasNostrSession"
|
||
@click="$emit('openAuth')"
|
||
class="hidden md:block hero-play-button px-4 py-2 text-sm"
|
||
>
|
||
Sign In
|
||
</button>
|
||
|
||
<!-- Search -->
|
||
<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="hasNostrSession" class="hidden md:block relative profile-dropdown">
|
||
<button
|
||
@click="toggleDropdown"
|
||
class="profile-button flex items-center gap-2"
|
||
>
|
||
<img
|
||
v-if="nostrActivePubkey"
|
||
:src="`https://robohash.org/${nostrActivePubkey}.png`"
|
||
class="w-8 h-8 rounded-full"
|
||
alt="Avatar"
|
||
/>
|
||
<span class="text-white text-sm font-medium">{{ nostrActiveName }}</span>
|
||
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': dropdownOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
|
||
<div v-if="dropdownOpen" class="profile-menu absolute right-0 mt-2 w-48">
|
||
<div class="floating-glass-header py-2 rounded-xl">
|
||
<button @click="navigateTo('/profile')" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 w-full text-left">
|
||
<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="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>Profile</span>
|
||
</button>
|
||
<button @click="navigateTo('/library')" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 w-full text-left">
|
||
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||
</svg>
|
||
<span>My Library</span>
|
||
</button>
|
||
<!-- Backstage (filmmaker only) -->
|
||
<button v-if="isFilmmakerUser" @click="navigateTo('/backstage')" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 w-full text-left">
|
||
<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="M7 4V20M17 4V20M3 8H7M17 8H21M3 12H21M3 16H7M17 16H21M4 20H20C20.5523 20 21 19.5523 21 19V5C21 4.44772 20.5523 4 20 4H4C3.44772 4 3 4.44772 3 5V19C3 19.5523 3.44772 20 4 20Z" />
|
||
</svg>
|
||
<span>Backstage</span>
|
||
</button>
|
||
|
||
<!-- Content Source Selector -->
|
||
<div class="px-4 py-2">
|
||
<p class="text-[10px] text-white/40 uppercase tracking-wider mb-2">Content Source</p>
|
||
<div class="flex flex-col gap-1">
|
||
<button
|
||
v-for="source in contentSourceStore.availableSources"
|
||
:key="source.id"
|
||
@click="handleSourceSelect(source.id)"
|
||
class="profile-menu-item flex items-center gap-3 px-3 py-2 w-full text-left rounded-lg transition-all"
|
||
:class="contentSourceStore.activeSource === source.id ? 'bg-white/10 text-white' : 'text-white/60 hover:text-white'"
|
||
>
|
||
<span class="w-2 h-2 rounded-full" :class="contentSourceStore.activeSource === source.id ? 'bg-[#F7931A]' : 'bg-white/20'"></span>
|
||
<span class="flex-1 text-sm">{{ source.label }}</span>
|
||
<span v-if="contentSourceStore.activeSource === source.id" class="text-[10px] text-[#F7931A] uppercase tracking-wider">Active</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="border-t border-white/10 my-1"></div>
|
||
<button @click="handleLogout" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 text-red-400 w-full text-left">
|
||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||
</svg>
|
||
<span>Sign Out</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fallback: App-auth profile (when Nostr not logged in but app auth is) -->
|
||
<div v-else-if="isAuthenticated" class="hidden md:block relative profile-dropdown">
|
||
<button @click="toggleDropdown" class="profile-button flex items-center gap-2">
|
||
<div class="w-8 h-8 rounded-full bg-gradient-to-br from-orange-500 to-pink-500 flex items-center justify-center text-xs font-bold text-white">
|
||
{{ userInitials }}
|
||
</div>
|
||
<span class="text-white text-sm font-medium">{{ userName }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Mobile -->
|
||
<div class="md:hidden flex items-center gap-2 mr-2">
|
||
<img
|
||
v-if="hasNostrSession && nostrActivePubkey"
|
||
:src="`https://robohash.org/${nostrActivePubkey}.png`"
|
||
class="w-7 h-7 rounded-full"
|
||
alt="Avatar"
|
||
/>
|
||
<span v-if="hasNostrSession" class="text-white text-sm font-medium">{{ nostrActiveName }}</span>
|
||
<template v-else-if="isAuthenticated">
|
||
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-orange-500 to-pink-500 flex items-center justify-center text-xs font-bold text-white">
|
||
{{ userInitials }}
|
||
</div>
|
||
<span class="text-white text-sm font-medium">{{ userName }}</span>
|
||
</template>
|
||
<button v-else-if="showAuth" @click="$emit('openAuth')" class="text-white text-sm font-medium">Sign In</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
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'
|
||
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 }
|
||
|
||
interface Props {
|
||
showNav?: boolean
|
||
showSearch?: boolean
|
||
showAuth?: boolean
|
||
}
|
||
|
||
interface Emits {
|
||
(e: 'openAuth', redirect?: string): void
|
||
(e: 'selectContent', content: Content): void
|
||
(e: 'openMobileSearch'): void
|
||
}
|
||
|
||
withDefaults(defineProps<Props>(), {
|
||
showNav: true,
|
||
showSearch: true,
|
||
showAuth: true,
|
||
})
|
||
|
||
const emit = defineEmits<Emits>()
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const { user, isAuthenticated, isFilmmaker: isFilmmakerComputed, loginWithNostr: appLoginWithNostr, logout: appLogout } = useAuth()
|
||
const {
|
||
isLoggedIn: nostrLoggedIn,
|
||
activePubkey: nostrActivePubkey,
|
||
activeName: nostrActiveName,
|
||
testPersonas,
|
||
tastemakerPersonas,
|
||
loginWithExtension,
|
||
loginWithPersona,
|
||
logout: nostrLogout,
|
||
} = useAccounts()
|
||
|
||
const {
|
||
activeAlgorithm,
|
||
activeAlgorithmLabel,
|
||
algorithms,
|
||
setAlgorithm: _setAlgorithm,
|
||
} = useContentDiscovery()
|
||
|
||
const contentSourceStore = useContentSourceStore()
|
||
const contentStore = useContentStore()
|
||
const isFilmmakerUser = isFilmmakerComputed
|
||
|
||
/**
|
||
* Treat the user as "truly Nostr-logged-in" only when the
|
||
* accountManager has an active account AND there's a matching
|
||
* session token. This prevents showing a stale profile pill
|
||
* after site data has been cleared.
|
||
*/
|
||
const hasNostrSession = computed(() => {
|
||
if (!nostrLoggedIn.value) return false
|
||
// If we also have a backend session, the login is genuine
|
||
if (isAuthenticated.value) return true
|
||
// Otherwise verify there's at least a stored token to back the account
|
||
return !!(sessionStorage.getItem('nostr_token') || sessionStorage.getItem('auth_token'))
|
||
})
|
||
|
||
/** Switch content source and reload */
|
||
function handleSourceSelect(sourceId: string) {
|
||
contentSourceStore.setSource(sourceId as any)
|
||
contentStore.fetchContent()
|
||
}
|
||
|
||
// Source toggle is now handled by handleSourceSelect above
|
||
|
||
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[]>(() => {
|
||
// When using API source, search from the content store's loaded data
|
||
if (contentSourceStore.activeSource === 'indeehub-api') {
|
||
return Object.values(contentStore.contentRows).flat()
|
||
}
|
||
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
|
||
personaMenuOpen.value = false
|
||
}
|
||
|
||
/** Select an algorithm from the dropdown, navigate to Films if needed */
|
||
function handleAlgoSelect(id: string) {
|
||
_setAlgorithm(id as any)
|
||
algosMenuOpen.value = false
|
||
if (route.path !== '/') {
|
||
router.push('/')
|
||
}
|
||
}
|
||
|
||
/** Clear the active filter from the dropdown */
|
||
function handleAlgoClear() {
|
||
if (activeAlgorithm.value) {
|
||
_setAlgorithm(activeAlgorithm.value as any) // toggle off
|
||
}
|
||
algosMenuOpen.value = false
|
||
}
|
||
|
||
const userInitials = computed(() => {
|
||
if (nostrActiveName.value) return nostrActiveName.value[0].toUpperCase()
|
||
if (!user.value?.legalName) return 'U'
|
||
const names = user.value.legalName.split(' ')
|
||
return names.length > 1
|
||
? `${names[0][0]}${names[names.length - 1][0]}`
|
||
: names[0][0]
|
||
})
|
||
|
||
const userName = computed(() => {
|
||
if (nostrActiveName.value) return nostrActiveName.value
|
||
return user.value?.legalName?.split(' ')[0] || 'Guest'
|
||
})
|
||
|
||
function isRoute(path: string): boolean {
|
||
return route.path === path
|
||
}
|
||
|
||
/** Navigate to Films and clear any active filter */
|
||
function handleFilmsClick() {
|
||
if (activeAlgorithm.value) {
|
||
_setAlgorithm(activeAlgorithm.value as any) // toggle off
|
||
}
|
||
if (route.path !== '/') {
|
||
router.push('/')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Navigate to My List if logged in, otherwise open the auth modal
|
||
* with a redirect so the user lands on My List after login.
|
||
*/
|
||
function handleMyListClick() {
|
||
if (activeAlgorithm.value) {
|
||
_setAlgorithm(activeAlgorithm.value as any) // toggle off
|
||
}
|
||
if (!isAuthenticated.value && !hasNostrSession.value) {
|
||
emit('openAuth', '/library')
|
||
return
|
||
}
|
||
if (route.path !== '/library') {
|
||
router.push('/library')
|
||
}
|
||
}
|
||
|
||
function toggleDropdown() {
|
||
dropdownOpen.value = !dropdownOpen.value
|
||
personaMenuOpen.value = false
|
||
algosMenuOpen.value = false
|
||
}
|
||
|
||
function togglePersonaMenu() {
|
||
personaMenuOpen.value = !personaMenuOpen.value
|
||
dropdownOpen.value = false
|
||
algosMenuOpen.value = false
|
||
}
|
||
|
||
function navigateTo(path: string) {
|
||
dropdownOpen.value = false
|
||
router.push(path)
|
||
}
|
||
|
||
async function handlePersonaLogin(persona: Persona) {
|
||
personaMenuOpen.value = false
|
||
// Set up Nostr account (for commenting/reactions)
|
||
await loginWithPersona(persona)
|
||
// Also populate auth store (for subscription/My List access)
|
||
try {
|
||
await appLoginWithNostr(persona.pubkey, 'persona', {})
|
||
} catch (err) {
|
||
// Backend auth failed — persona still works for commenting/reactions
|
||
// via accountManager, just won't have full API access
|
||
console.warn('[PersonaLogin] Backend auth failed:', (err as Error).message)
|
||
}
|
||
}
|
||
|
||
async function handleExtensionLogin() {
|
||
// Set up Nostr account (for commenting/reactions)
|
||
await loginWithExtension()
|
||
// Also populate auth store (for subscription/My List access)
|
||
try {
|
||
const pubkey = accountManager.active?.pubkey
|
||
if (pubkey) {
|
||
await appLoginWithNostr(pubkey, 'extension', {})
|
||
}
|
||
} catch {
|
||
// Auth store mock login — non-critical if it fails
|
||
}
|
||
}
|
||
|
||
async function handleLogout() {
|
||
nostrLogout()
|
||
await appLogout()
|
||
dropdownOpen.value = false
|
||
router.push('/')
|
||
}
|
||
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
const dropdown = document.querySelector('.profile-dropdown')
|
||
const personaDropdown = document.querySelector('.persona-dropdown')
|
||
const algosDropdown = document.querySelector('.algos-dropdown')
|
||
if (dropdown && !dropdown.contains(event.target as Node)) {
|
||
dropdownOpen.value = false
|
||
}
|
||
if (personaDropdown && !personaDropdown.contains(event.target as Node)) {
|
||
personaMenuOpen.value = false
|
||
}
|
||
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>
|
||
|
||
<style scoped>
|
||
/* Glass Header */
|
||
.floating-glass-header {
|
||
background: rgba(0, 0, 0, 0.65);
|
||
backdrop-filter: blur(40px);
|
||
-webkit-backdrop-filter: blur(40px);
|
||
border-radius: 24px;
|
||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||
box-shadow:
|
||
0 20px 60px rgba(0, 0, 0, 0.3),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
/* Navigation Button Styles */
|
||
.nav-button {
|
||
position: relative;
|
||
padding: 12px 24px;
|
||
font-size: 16px;
|
||
font-weight: 500;
|
||
line-height: 1.4;
|
||
border-radius: 16px;
|
||
background: rgba(0, 0, 0, 0.25);
|
||
color: rgba(255, 255, 255, 0.96);
|
||
box-shadow:
|
||
0 8px 24px rgba(0, 0, 0, 0.45),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||
backdrop-filter: blur(24px);
|
||
-webkit-backdrop-filter: blur(24px);
|
||
border: none;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
text-decoration: none;
|
||
display: inline-block;
|
||
white-space: nowrap;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.nav-button::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: inherit;
|
||
padding: 2px;
|
||
background: linear-gradient(135deg, rgba(0, 0, 0, 0.8), transparent);
|
||
-webkit-mask:
|
||
linear-gradient(#fff 0 0) content-box,
|
||
linear-gradient(#fff 0 0);
|
||
-webkit-mask-composite: xor;
|
||
mask-composite: exclude;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.nav-button:hover {
|
||
transform: translateY(-2px);
|
||
background: rgba(0, 0, 0, 0.35);
|
||
box-shadow:
|
||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||
}
|
||
|
||
.nav-button:hover::before {
|
||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
|
||
}
|
||
|
||
.nav-button-active {
|
||
position: relative;
|
||
padding: 12px 24px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
border-radius: 16px;
|
||
background: rgba(0, 0, 0, 0.35);
|
||
color: rgba(255, 255, 255, 1);
|
||
box-shadow:
|
||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||
backdrop-filter: blur(24px);
|
||
-webkit-backdrop-filter: blur(24px);
|
||
border: none;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
text-decoration: none;
|
||
display: inline-block;
|
||
white-space: nowrap;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.nav-button-active::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: inherit;
|
||
padding: 2px;
|
||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), transparent);
|
||
-webkit-mask:
|
||
linear-gradient(#fff 0 0) content-box,
|
||
linear-gradient(#fff 0 0);
|
||
-webkit-mask-composite: xor;
|
||
mask-composite: exclude;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.nav-button-active:hover {
|
||
transform: translateY(-2px);
|
||
background: rgba(0, 0, 0, 0.40);
|
||
box-shadow:
|
||
0 12px 32px rgba(0, 0, 0, 0.6),
|
||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||
}
|
||
|
||
/* Sign In Button (reuses hero-play-button pattern) */
|
||
.hero-play-button {
|
||
position: relative;
|
||
padding: 10px 28px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
border-radius: 16px;
|
||
background: rgba(255, 255, 255, 0.85);
|
||
color: rgba(0, 0, 0, 0.9);
|
||
box-shadow:
|
||
0 12px 32px rgba(0, 0, 0, 0.4),
|
||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||
backdrop-filter: blur(24px);
|
||
-webkit-backdrop-filter: blur(24px);
|
||
border: none;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
white-space: nowrap;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.hero-play-button::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: inherit;
|
||
padding: 2px;
|
||
background: linear-gradient(135deg, rgba(100, 100, 100, 0.4), rgba(50, 50, 50, 0.2));
|
||
-webkit-mask:
|
||
linear-gradient(#fff 0 0) content-box,
|
||
linear-gradient(#fff 0 0);
|
||
-webkit-mask-composite: xor;
|
||
mask-composite: exclude;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.hero-play-button:hover {
|
||
transform: translateY(-2px);
|
||
background: rgba(255, 255, 255, 0.95);
|
||
box-shadow:
|
||
0 16px 40px rgba(0, 0, 0, 0.5),
|
||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||
}
|
||
|
||
/* Profile Dropdown Styles */
|
||
.profile-button {
|
||
padding: 6px 12px 6px 6px;
|
||
border-radius: 12px;
|
||
background: transparent;
|
||
border: none;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.profile-button:hover {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
}
|
||
|
||
.profile-menu {
|
||
z-index: 100;
|
||
animation: slideDown 0.2s ease-out;
|
||
}
|
||
|
||
@keyframes slideDown {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-10px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.profile-menu-item {
|
||
color: rgba(255, 255, 255, 0.9);
|
||
text-decoration: none;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.profile-menu-item:hover {
|
||
background: rgba(255, 255, 255, 0.1);
|
||
color: rgba(255, 255, 255, 1);
|
||
}
|
||
|
||
.profile-menu-item svg {
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.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>
|