Files
indee-demo/src/views/Browse.vue
Dorian 989dd75a84 style: change content grid from 5 to 6 cards per row on desktop
Cards were feeling too large at 5 per row. Updated calc to fit 6
cards uniformly across ContentRow and Browse (My List sections).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-13 22:56:35 +00:00

596 lines
21 KiB
Vue

<template>
<!-- Splash Intro -->
<SplashIntro />
<div class="browse-view">
<!-- Hero / Featured Content -->
<section class="relative h-[56vh] md:h-[61vh] overflow-hidden">
<!-- Background Image -->
<div class="absolute inset-0">
<img
:src="featuredContent?.backdrop || '/images/god-bless-bitcoin-backdrop.jpg'"
alt="Featured content"
class="w-full h-full object-cover object-center"
style="image-rendering: -webkit-optimize-contrast; image-rendering: crisp-edges;"
/>
<div class="absolute inset-0 hero-gradient"></div>
</div>
<!-- Hero Content -->
<div class="relative mx-auto px-4 md:px-8 h-full flex items-center pt-16" style="max-width: 90%;">
<div class="max-w-2xl space-y-2.5 md:space-y-3 animate-fade-in">
<!-- Resume label (My List only) -->
<div v-if="isMyListTab && resumeItem" class="flex items-center gap-2 text-xs md:text-sm text-white/70 uppercase tracking-widest font-medium">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Continue where you left off
</div>
<!-- Title -->
<h1 class="hero-title w-full text-3xl md:text-5xl lg:text-6xl font-bold drop-shadow-2xl leading-tight uppercase" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 700;">
{{ featuredContent?.title || 'GOD BLESS BITCOIN' }}
</h1>
<!-- Description -->
<p class="text-sm md:text-base lg:text-lg text-white/90 drop-shadow-lg line-clamp-2 md:line-clamp-3">
{{ featuredContent?.description || 'A groundbreaking documentary exploring the intersection of faith, finance, and the future of money through the lens of Bitcoin.' }}
</p>
<!-- Progress bar (My List resume) -->
<div v-if="isMyListTab && resumeItem" class="flex items-center gap-3 pt-0.5">
<div class="flex-1 h-1.5 bg-white/15 rounded-full overflow-hidden max-w-xs">
<div class="h-full bg-white/80 rounded-full transition-all duration-500" :style="{ width: `${resumeItem.progress}%` }"></div>
</div>
<span class="text-xs text-white/60 font-medium">{{ resumeItem.progress }}% watched</span>
</div>
<!-- Meta Info (non-resume) -->
<div v-else-if="featuredContent" class="flex items-center gap-2.5 text-xs md:text-sm text-white/80">
<span v-if="featuredContent.rating" class="bg-white/20 px-2.5 py-0.5 rounded">{{ featuredContent.rating }}</span>
<span v-if="featuredContent.releaseYear">{{ featuredContent.releaseYear }}</span>
<span v-if="featuredContent.duration">{{ featuredContent.duration }}min</span>
<span v-else>{{ featuredContent.type === 'film' ? 'Film' : 'Series' }}</span>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2.5 md:gap-3 pt-1.5 md:pt-2">
<button @click="handlePlayClick" class="hero-play-button flex items-center gap-2">
<svg class="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{{ isMyListTab && resumeItem ? 'Resume' : 'Play' }}
</button>
<button @click="handleInfoClick" class="hero-info-button flex items-center gap-2">
<svg class="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
More Info
</button>
</div>
</div>
</div>
</section>
<!-- Content Section -->
<section class="relative pt-8 pb-20 px-4">
<div class="mx-auto space-y-12">
<!-- ===== MY LIST TAB (only rendered when logged in) ===== -->
<template v-if="isMyListTab && isLoggedInAnywhere">
<!-- Continue Watching -->
<div v-if="continueWatching.length > 0" class="content-row">
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-4 px-4 uppercase">Continue Watching</h2>
<div class="flex gap-8 overflow-x-auto overflow-y-visible scrollbar-hide scroll-smooth px-4 pt-6 pb-8">
<div
v-for="item in continueWatching"
:key="item.content.id"
class="content-card flex-shrink-0 w-[200px] card-desktop-6 group/card cursor-pointer"
@click="handleContentClick(item.content)"
>
<div class="glass-card rounded-lg p-1.5 transition-all duration-300 relative">
<img
:src="item.content.thumbnail"
:alt="item.content.title"
class="w-full aspect-[2/3] object-contain rounded-md bg-neutral-900"
loading="lazy"
/>
<div class="absolute bottom-2 left-2 right-2 h-1 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-white/80 rounded-full" :style="{ width: `${item.progress}%` }"></div>
</div>
</div>
<div class="mt-2">
<h3 class="text-base md:text-xl font-semibold md:font-bold text-white truncate">{{ item.content.title }}</h3>
<p class="text-base text-white/60 truncate hidden md:block">{{ item.content.description }}</p>
</div>
</div>
</div>
</div>
<!-- Saved Films -->
<div v-if="myListContent.length > 0" class="content-row">
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-4 px-4 uppercase">Saved Films</h2>
<div class="flex gap-8 overflow-x-auto overflow-y-visible scrollbar-hide scroll-smooth px-4 pt-6 pb-8">
<div
v-for="content in myListContent"
:key="content.id"
class="content-card flex-shrink-0 w-[200px] card-desktop-6 group/card cursor-pointer"
@click="handleContentClick(content)"
>
<div class="glass-card rounded-lg p-1.5 transition-all duration-300">
<img
:src="content.thumbnail"
:alt="content.title"
class="w-full aspect-[2/3] object-contain rounded-md bg-neutral-900"
loading="lazy"
/>
</div>
<div class="mt-2">
<h3 class="text-base md:text-xl font-semibold md:font-bold text-white truncate">{{ content.title }}</h3>
<p class="text-base text-white/60 truncate hidden md:block">{{ content.description }}</p>
</div>
</div>
</div>
</div>
<!-- Rentals -->
<div v-if="rentedContent.length > 0" class="content-row">
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-4 px-4 uppercase">My Rentals</h2>
<div class="flex gap-8 overflow-x-auto overflow-y-visible scrollbar-hide scroll-smooth px-4 pt-6 pb-8">
<div
v-for="content in rentedContent"
:key="content.id"
class="content-card flex-shrink-0 w-[200px] card-desktop-6 group/card cursor-pointer"
@click="handleContentClick(content)"
>
<div class="glass-card rounded-lg p-1.5 transition-all duration-300 relative">
<img
:src="content.thumbnail"
:alt="content.title"
class="w-full aspect-[2/3] object-contain rounded-md bg-neutral-900"
loading="lazy"
/>
<div class="absolute top-3 right-3 bg-black/70 backdrop-blur-md px-2 py-0.5 rounded-full text-xs text-white/80">
48h left
</div>
</div>
<div class="mt-2">
<h3 class="text-base md:text-xl font-semibold md:font-bold text-white truncate">{{ content.title }}</h3>
<p class="text-base text-white/60 truncate hidden md:block">{{ content.description }}</p>
</div>
</div>
</div>
</div>
<!-- Empty library -->
<div v-if="continueWatching.length === 0 && myListContent.length === 0 && rentedContent.length === 0" class="text-center py-16">
<div class="text-white/50 text-lg mb-6">Your list is empty</div>
<router-link to="/" class="hero-play-button inline-block text-decoration-none">Browse Films</router-link>
</div>
</template>
<!-- ===== FILMS TAB: Filtered Grid ===== -->
<template v-else-if="isFilterActive">
<div class="content-row">
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-4 px-4 uppercase">
{{ activeAlgorithmLabel }}
</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6 lg:gap-8 px-4 pt-6 pb-8">
<div
v-for="content in filteredContent"
:key="content.id"
class="content-card group/card cursor-pointer"
@click="handleContentClick(content)"
>
<div class="glass-card rounded-lg p-1.5 transition-all duration-300">
<img
:src="content.thumbnail"
:alt="content.title"
class="w-full aspect-[2/3] object-contain rounded-md bg-neutral-900"
loading="lazy"
/>
</div>
<div class="mt-2">
<h3 class="text-sm md:text-xl font-semibold md:font-bold text-white truncate">{{ content.title }}</h3>
<p class="text-xs md:text-base text-white/60 line-clamp-2 md:truncate">{{ content.description }}</p>
</div>
</div>
</div>
</div>
</template>
<!-- ===== FILMS TAB: Default Category Rows ===== -->
<template v-else>
<ContentRow
title="Featured Films"
:contents="featuredFilms"
@content-click="handleContentClick"
/>
<ContentRow
title="New Releases"
:contents="newReleases"
@content-click="handleContentClick"
/>
<ContentRow
title="Bitcoin & Cryptocurrency"
:contents="bitcoinContent"
@content-click="handleContentClick"
/>
<ContentRow
title="Documentaries"
:contents="documentaryContent"
@content-click="handleContentClick"
/>
<ContentRow
title="Independent Cinema"
:contents="independentCinema"
@content-click="handleContentClick"
/>
<ContentRow
title="Drama Films"
:contents="dramas"
@content-click="handleContentClick"
/>
</template>
</div>
</section>
<!-- Modals -->
<ContentDetailModal
:isOpen="showDetailModal"
:content="selectedContent"
@close="showDetailModal = false"
@openAuth="$emit('openAuth')"
/>
<!-- Hero-only modals (for direct Play button) -->
<SubscriptionModal
:isOpen="showSubscriptionModal"
@close="showSubscriptionModal = false"
@success="handleSubscriptionSuccess"
/>
<VideoPlayer
:isOpen="showVideoPlayer"
:content="selectedContent"
@close="showVideoPlayer = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ContentRow from '../components/ContentRow.vue'
import SplashIntro from '../components/SplashIntro.vue'
import ContentDetailModal from '../components/ContentDetailModal.vue'
import SubscriptionModal from '../components/SubscriptionModal.vue'
import VideoPlayer from '../components/VideoPlayer.vue'
import { useContentStore } from '../stores/content'
import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
import { useContentDiscovery } from '../composables/useContentDiscovery'
// indeeHubFilms data is now loaded dynamically via the content store
import { useContentSourceStore } from '../stores/contentSource'
import { useSearchSelectionStore } from '../stores/searchSelection'
import type { Content } from '../types/content'
const emit = defineEmits<{ (e: 'openAuth', redirect?: string): void }>()
const route = useRoute()
const router = useRouter()
const contentStore = useContentStore()
const { isAuthenticated, hasActiveSubscription } = useAuth()
const { isLoggedIn: isNostrLoggedIn } = useAccounts()
const { sortContent, isFilterActive, activeAlgorithmLabel } = useContentDiscovery()
const searchSelection = useSearchSelectionStore()
// Determine active tab from route path
const isMyListTab = computed(() => route.path === '/library')
// Auth: either app auth or Nostr
const isLoggedInAnywhere = computed(() => isAuthenticated.value || isNostrLoggedIn.value)
// ===== FILMS TAB DATA =====
// The "continue watching" item shown in the hero on My List
const resumeItem = computed(() => continueWatching.value[0] ?? null)
// Hero banner: resume film on My List, top-ranked on filter, or default
const featuredContent = computed(() => {
if (isMyListTab.value && isLoggedInAnywhere.value && resumeItem.value) {
return resumeItem.value.content
}
if (isFilterActive.value && filteredContent.value.length > 0) {
return filteredContent.value[0]
}
return contentStore.featuredContent
})
// Default category rows (unfiltered)
const featuredFilms = computed(() => contentStore.contentRows.featured)
const newReleases = computed(() => contentStore.contentRows.newReleases)
const bitcoinContent = computed(() => contentStore.contentRows.bitcoin)
const independentCinema = computed(() => contentStore.contentRows.independent)
const dramas = computed(() => contentStore.contentRows.dramas)
const documentaryContent = computed(() => contentStore.contentRows.documentaries)
// Deduplicated + sorted content for filter grid view
const filteredContent = computed(() => {
const rows = contentStore.contentRows
const seen = new Set<string>()
const allContent: Content[] = []
for (const row of Object.values(rows)) {
for (const item of row) {
if (!seen.has(item.id)) {
seen.add(item.id)
allContent.push(item)
}
}
}
return sortContent.value(allContent)
})
// ===== MY LIST TAB DATA =====
const continueWatching = ref<Array<{ content: Content; progress: number }>>([])
const myListContent = ref<Content[]>([])
const rentedContent = ref<Content[]>([])
const contentSourceStore = useContentSourceStore()
let lastLoadedSource: string | null = null
/**
* Populate library with dummy data from the current content catalog.
* Re-runs when the content source changes so My List reflects the active catalog.
* In production this would come from the API.
*/
function loadDummyLibrary() {
const source = contentSourceStore.activeSource
if (myListContent.value.length > 0 && lastLoadedSource === source) return
lastLoadedSource = source
const rows = contentStore.contentRows
const featured = rows.featured || []
const btc = rows.bitcoin || []
const docs = rows.documentaries || []
continueWatching.value = featured.slice(0, 3).map((film) => ({
content: film,
progress: Math.floor(Math.random() * 70) + 10,
}))
myListContent.value = [
...btc.slice(0, 3),
...featured.slice(5, 8),
]
rentedContent.value = docs.slice(0, 2)
}
// If someone navigates directly to /library without being logged in,
// open the auth modal and redirect back to Films.
watch([isMyListTab, isLoggedInAnywhere], ([onListTab, loggedIn]) => {
if (onListTab && !loggedIn) {
emit('openAuth', '/library')
router.replace('/')
return
}
if (onListTab && loggedIn) {
loadDummyLibrary()
}
}, { immediate: true })
// ===== SHARED =====
const showDetailModal = ref(false)
const showSubscriptionModal = ref(false)
const showVideoPlayer = ref(false)
const selectedContent = ref<Content | null>(null)
const handleContentClick = (content: Content) => {
selectedContent.value = content
showDetailModal.value = true
}
const handlePlayClick = () => {
if (!isAuthenticated.value) {
emit('openAuth')
return
}
if (hasActiveSubscription.value) {
selectedContent.value = featuredContent.value
showVideoPlayer.value = true
return
}
showSubscriptionModal.value = true
}
const handleInfoClick = () => {
selectedContent.value = featuredContent.value
showDetailModal.value = true
}
function handleSubscriptionSuccess() {
showSubscriptionModal.value = false
}
onMounted(() => {
contentStore.fetchContent()
})
// Watch for search selection from the global search bar
watch(() => searchSelection.pendingContent, (content) => {
if (content) {
selectedContent.value = searchSelection.consume()
showDetailModal.value = true
}
})
</script>
<style scoped>
.browse-view {
min-height: 100vh;
overflow-x: hidden;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Hero Title Styles */
.hero-title {
background: linear-gradient(to right, #fafafa, #9ca3af);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 0.05em;
}
/* Hero Button Styles */
.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;
text-decoration: none;
}
.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);
}
.hero-info-button {
position: relative;
padding: 10px 28px;
font-size: 14px;
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;
white-space: nowrap;
letter-spacing: 0.02em;
}
.hero-info-button::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;
}
.hero-info-button:hover {
transform: translateY(-2px);
background: rgba(0, 0, 0, 0.40);
box-shadow:
0 16px 40px rgba(0, 0, 0, 0.7),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
@media (min-width: 768px) {
.hero-play-button,
.hero-info-button {
padding: 12px 32px;
font-size: 16px;
}
}
/* Content section styles */
.content-row-title {
background: linear-gradient(to right, #fafafa, #9ca3af);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 0.05em;
}
.glass-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.group\/card:hover .glass-card {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05));
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2), 0 0 20px rgba(255, 255, 255, 0.05);
transform: translateY(-4px);
}
/* Show exactly 6 cards on desktop (matches ContentRow) */
@media (min-width: 768px) {
.card-desktop-6 {
width: calc((100vw - 14rem) / 6);
max-width: 300px;
}
}
</style>