Initial commit: IndeeHub decentralized streaming platform
Built a complete Netflix-style streaming interface for IndeeHub's decentralized media platform with real film content. Features: - Vue 3 + TypeScript + Vite setup with hot module reloading - Netflix-inspired UI with hero section and horizontal scrolling content rows - Glass morphism design system with custom Tailwind configuration - 20+ real IndeeHub films organized into 6 categories (Bitcoin, Documentaries, Drama, etc.) - Full-featured video player component with custom controls - Mobile-responsive design with bottom navigation - Nostr integration ready (nostr-tools, relay pool, NIP-71 support) - Pinia state management for content - MCP tools configured (Filesystem, Memory, Nostr, Puppeteer) Components: - Browse.vue: Main streaming interface with hero and content rows - ContentRow.vue: Horizontal scrolling film cards with navigation arrows - VideoPlayer.vue: Custom video player with play/pause, seek, volume, fullscreen - MobileNav.vue: Bottom tab navigation for mobile devices Tech Stack: - Frontend: Vue 3 (Composition API), TypeScript - Build: Vite 7 - Styling: Tailwind CSS with custom theme - State: Pinia 3 - Router: Vue Router 4.6 - Protocol: Nostr (nostr-tools 2.22) Design: - 4px grid spacing system - Glass morphism UI components - Netflix-style hero section with featured content - Smooth animations and hover effects - Mobile-first responsive breakpoints - Dark theme with custom color palette Content: - 20+ IndeeHub films with titles, descriptions, categories - Bitcoin documentaries: God Bless Bitcoin, Dirty Coin, Searching for Satoshi - Independent films and documentaries - Working Unsplash CDN images for thumbnails and backdrops Ready for deployment to Umbrel, Start9, and Archy nodes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
117
src/components/ContentRow.vue
Normal file
117
src/components/ContentRow.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="content-row">
|
||||
<h2 class="text-xl md:text-2xl font-semibold text-white mb-4 px-6">
|
||||
{{ title }}
|
||||
</h2>
|
||||
|
||||
<div class="relative group">
|
||||
<!-- Scroll Left Button -->
|
||||
<button
|
||||
v-if="canScrollLeft"
|
||||
@click="scrollLeft"
|
||||
class="absolute left-0 top-0 bottom-0 z-10 w-12 bg-black/50 hover:bg-black/70 backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<svg class="w-8 h-8 mx-auto" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Content Slider -->
|
||||
<div
|
||||
ref="sliderRef"
|
||||
class="flex gap-2 overflow-x-auto scrollbar-hide scroll-smooth px-6"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div
|
||||
v-for="content in contents"
|
||||
:key="content.id"
|
||||
class="content-card flex-shrink-0 w-[200px] md:w-[280px]"
|
||||
@click="$emit('content-click', content)"
|
||||
>
|
||||
<img
|
||||
:src="content.thumbnail"
|
||||
:alt="content.title"
|
||||
class="w-full aspect-[2/3] object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="mt-2">
|
||||
<h3 class="text-sm font-medium text-white truncate">{{ content.title }}</h3>
|
||||
<p class="text-xs text-white/60 truncate">{{ content.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scroll Right Button -->
|
||||
<button
|
||||
v-if="canScrollRight"
|
||||
@click="scrollRight"
|
||||
class="absolute right-0 top-0 bottom-0 z-10 w-12 bg-black/50 hover:bg-black/70 backdrop-blur-sm opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<svg class="w-8 h-8 mx-auto" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { Content } from '../types/content'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
contents: Content[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<{
|
||||
'content-click': [content: Content]
|
||||
}>()
|
||||
|
||||
const sliderRef = ref<HTMLElement | null>(null)
|
||||
const canScrollLeft = ref(false)
|
||||
const canScrollRight = ref(true)
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!sliderRef.value) return
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = sliderRef.value
|
||||
canScrollLeft.value = scrollLeft > 0
|
||||
canScrollRight.value = scrollLeft < scrollWidth - clientWidth - 10
|
||||
}
|
||||
|
||||
const scrollLeft = () => {
|
||||
if (!sliderRef.value) return
|
||||
sliderRef.value.scrollBy({ left: -600, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const scrollRight = () => {
|
||||
if (!sliderRef.value) return
|
||||
sliderRef.value.scrollBy({ left: 600, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (sliderRef.value) {
|
||||
sliderRef.value.addEventListener('scroll', handleScroll)
|
||||
handleScroll()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (sliderRef.value) {
|
||||
sliderRef.value.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user