Files
indee-demo/src/components/AppHeader.vue
Dorian ad4a9f48b6 ui: hide Persona switcher and Extension button from header
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>
2026-02-13 21:58:24 +00:00

967 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 (mdxl 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>