Files
indee-demo/src/views/Browse.vue
Dorian 2c6e311705 Add truncated descriptions to My List film cards
Add the same truncated description line to Continue Watching
and My Rentals grids so card sizing matches the other screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 12:47:33 +00:00

557 lines
20 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-6 px-4 uppercase">Continue Watching</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6 px-4">
<div
v-for="item in continueWatching"
:key="item.content.id"
class="content-card 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-sm md:text-base font-semibold text-white truncate">{{ item.content.title }}</h3>
<p class="text-xs 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-6 px-4 uppercase">Saved Films</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6 px-4">
<div
v-for="content in myListContent"
: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-base font-semibold text-white truncate">{{ content.title }}</h3>
<p class="text-xs 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-6 px-4 uppercase">My Rentals</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6 px-4">
<div
v-for="content in rentedContent"
: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 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-sm md:text-base font-semibold text-white truncate">{{ content.title }}</h3>
<p class="text-xs 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-6 px-4 uppercase">
{{ activeAlgorithmLabel }}
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6 px-4">
<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-base font-semibold text-white truncate">{{ content.title }}</h3>
<p class="text-xs text-white/60 truncate hidden md:block">{{ 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'
import { indeeHubFilms, bitcoinFilms, documentaries } from '../data/indeeHubFilms'
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()
// 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[]>([])
/**
* Populate library with dummy data from the film catalog.
* In production this would come from the API.
*/
function loadDummyLibrary() {
if (myListContent.value.length > 0) return // Already loaded
continueWatching.value = indeeHubFilms.slice(0, 3).map((film) => ({
content: film,
progress: Math.floor(Math.random() * 70) + 10,
}))
myListContent.value = [
...bitcoinFilms.slice(0, 3),
...indeeHubFilms.slice(5, 8),
]
rentedContent.value = documentaries.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()
})
</script>
<style scoped>
.browse-view {
min-height: 100vh;
overflow-x: hidden;
}
/* 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);
}
</style>