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:
Dorian
2026-02-02 22:19:47 +00:00
commit 0bb1bcc5f9
50 changed files with 8278 additions and 0 deletions

12
src/App.vue Normal file
View File

@@ -0,0 +1,12 @@
<template>
<div id="app" class="min-h-screen">
<RouterView />
<!-- Mobile Navigation (hidden on desktop) -->
<MobileNav />
</div>
</template>
<script setup lang="ts">
import MobileNav from './components/MobileNav.vue'
</script>

View 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>

View File

@@ -0,0 +1,79 @@
<template>
<nav class="mobile-nav fixed bottom-0 left-0 right-0 z-50 md:hidden">
<div class="glass-card rounded-t-3xl border-t">
<div class="grid grid-cols-5 gap-1 px-2 py-3">
<button
v-for="item in navItems"
:key="item.name"
@click="navigate(item.path)"
class="flex flex-col items-center gap-1 p-2 rounded-lg transition-colors"
:class="{ 'text-netflix-red': isActive(item.path), 'text-white/60': !isActive(item.path) }"
>
<component :is="item.icon" class="w-6 h-6" />
<span class="text-xs font-medium">{{ item.name }}</span>
</button>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
import { h } from 'vue'
const router = useRouter()
const route = useRoute()
const navItems = [
{
name: 'Home',
path: '/',
icon: () => h('svg', { class: 'w-6 h-6', fill: 'currentColor', viewBox: '0 0 24 24' },
h('path', { d: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z' })
)
},
{
name: 'Search',
path: '/search',
icon: () => h('svg', { class: 'w-6 h-6', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
h('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' })
)
},
{
name: 'My List',
path: '/mylist',
icon: () => h('svg', { class: 'w-6 h-6', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z' })
)
},
{
name: 'Creators',
path: '/creators',
icon: () => h('svg', { class: 'w-6 h-6', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' })
)
},
{
name: 'Profile',
path: '/profile',
icon: () => h('svg', { class: 'w-6 h-6', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
h('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' })
)
}
]
const navigate = (path: string) => {
router.push(path)
}
const isActive = (path: string) => {
return route.path === path
}
</script>
<style scoped>
.mobile-nav {
/* Safe area for iPhone notch/home indicator */
padding-bottom: env(safe-area-inset-bottom, 0);
}
</style>

View File

@@ -0,0 +1,233 @@
<template>
<div class="video-player-container" :class="{ 'fullscreen': isFullscreen }">
<div class="relative w-full h-full bg-black rounded-lg overflow-hidden group">
<!-- Video Element -->
<video
ref="videoRef"
class="w-full h-full"
:src="src"
@click="togglePlay"
@timeupdate="handleTimeUpdate"
@loadedmetadata="handleMetadata"
@ended="handleEnded"
/>
<!-- Controls Overlay -->
<div
class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
:class="{ 'opacity-100': showControls || !playing }"
>
<!-- Top Bar -->
<div class="absolute top-0 left-0 right-0 p-4 flex items-center justify-between">
<button
v-if="showBackButton"
@click="$emit('close')"
class="p-2 hover:bg-white/20 rounded-full transition-colors"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</button>
<h3 class="text-lg font-semibold">{{ title }}</h3>
<div class="w-10"></div>
</div>
<!-- Center Play Button -->
<div
v-if="!playing"
class="absolute inset-0 flex items-center justify-center"
@click="togglePlay"
>
<button class="p-6 bg-white/20 hover:bg-white/30 backdrop-blur-md rounded-full transition-all transform hover:scale-110">
<svg class="w-16 h-16" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
</div>
<!-- Bottom Controls -->
<div class="absolute bottom-0 left-0 right-0 p-4 space-y-2">
<!-- Progress Bar -->
<div
class="w-full h-1 bg-white/30 rounded-full cursor-pointer hover:h-2 transition-all"
@click="seek"
ref="progressRef"
>
<div
class="h-full bg-netflix-red rounded-full transition-all"
:style="{ width: `${progress}%` }"
></div>
</div>
<!-- Control Buttons -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<!-- Play/Pause -->
<button @click="togglePlay" class="p-2 hover:bg-white/20 rounded-full transition-colors">
<svg v-if="playing" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<!-- Volume -->
<button @click="toggleMute" class="p-2 hover:bg-white/20 rounded-full transition-colors">
<svg v-if="!muted" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
</svg>
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
</button>
<!-- Time Display -->
<span class="text-sm text-white/80">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
</div>
<div class="flex items-center gap-2">
<!-- Fullscreen -->
<button @click="toggleFullscreen" class="p-2 hover:bg-white/20 rounded-full transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
interface Props {
src: string
title?: string
showBackButton?: boolean
}
const props = defineProps<Props>()
defineEmits<{
'close': []
}>()
const videoRef = ref<HTMLVideoElement | null>(null)
const progressRef = ref<HTMLElement | null>(null)
const playing = ref(false)
const muted = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const progress = ref(0)
const showControls = ref(false)
const isFullscreen = ref(false)
let hideControlsTimeout: NodeJS.Timeout | null = null
const togglePlay = () => {
if (!videoRef.value) return
if (playing.value) {
videoRef.value.pause()
} else {
videoRef.value.play()
}
playing.value = !playing.value
}
const toggleMute = () => {
if (!videoRef.value) return
videoRef.value.muted = !videoRef.value.muted
muted.value = videoRef.value.muted
}
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
videoRef.value?.requestFullscreen()
isFullscreen.value = true
} else {
document.exitFullscreen()
isFullscreen.value = false
}
}
const handleTimeUpdate = () => {
if (!videoRef.value) return
currentTime.value = videoRef.value.currentTime
progress.value = (currentTime.value / duration.value) * 100
}
const handleMetadata = () => {
if (!videoRef.value) return
duration.value = videoRef.value.duration
}
const handleEnded = () => {
playing.value = false
// TODO: Show related content or next episode
}
const seek = (event: MouseEvent) => {
if (!videoRef.value || !progressRef.value) return
const rect = progressRef.value.getBoundingClientRect()
const pos = (event.clientX - rect.left) / rect.width
videoRef.value.currentTime = pos * duration.value
}
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
const handleMouseMove = () => {
showControls.value = true
if (hideControlsTimeout) {
clearTimeout(hideControlsTimeout)
}
hideControlsTimeout = setTimeout(() => {
if (playing.value) {
showControls.value = false
}
}, 3000)
}
onMounted(() => {
document.addEventListener('mousemove', handleMouseMove)
})
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove)
if (hideControlsTimeout) {
clearTimeout(hideControlsTimeout)
}
})
</script>
<style scoped>
.video-player-container {
width: 100%;
aspect-ratio: 16/9;
}
.fullscreen {
position: fixed;
inset: 0;
z-index: 9999;
aspect-ratio: auto;
}
video {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,84 @@
import { ref, onMounted, onUnmounted } from 'vue'
/**
* Mobile back button handler
* Inspired by neode-ui mobile navigation
*/
export function useMobileBackButton(onBack: () => void) {
const handlePopState = () => {
onBack()
}
onMounted(() => {
window.addEventListener('popstate', handlePopState)
})
onUnmounted(() => {
window.removeEventListener('popstate', handlePopState)
})
}
/**
* Detect mobile device
*/
export function useIsMobile() {
const isMobile = ref(false)
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
return { isMobile }
}
/**
* Touch gestures for mobile
*/
export function useSwipeGesture(
onSwipeLeft?: () => void,
onSwipeRight?: () => void
) {
let touchStartX = 0
let touchEndX = 0
const handleTouchStart = (e: TouchEvent) => {
touchStartX = e.changedTouches[0].screenX
}
const handleTouchEnd = (e: TouchEvent) => {
touchEndX = e.changedTouches[0].screenX
handleSwipe()
}
const handleSwipe = () => {
const swipeThreshold = 50
const diff = touchStartX - touchEndX
if (Math.abs(diff) < swipeThreshold) return
if (diff > 0 && onSwipeLeft) {
onSwipeLeft()
} else if (diff < 0 && onSwipeRight) {
onSwipeRight()
}
}
onMounted(() => {
document.addEventListener('touchstart', handleTouchStart)
document.addEventListener('touchend', handleTouchEnd)
})
onUnmounted(() => {
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchend', handleTouchEnd)
})
}

231
src/data/indeeHubFilms.ts Normal file
View File

@@ -0,0 +1,231 @@
// Real IndeeHub films extracted from screening room
// Based on https://indeehub.studio/screening-room?type=film
import type { Content } from '../types/content'
export const indeeHubFilms: Content[] = [
{
id: 'god-bless-bitcoin',
title: 'God Bless Bitcoin',
description: 'A documentary exploring the intersection of faith, finance, and the future of money through the lens of Bitcoin and its impact on religious communities worldwide.',
thumbnail: 'https://images.unsplash.com/photo-1518546305927-5a555bb7020d?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1518546305927-5a555bb7020d?w=1920&h=1080&fit=crop',
type: 'film',
duration: 90,
releaseYear: 2024,
categories: ['Documentary', 'Bitcoin', 'Religion'],
nostrEventId: ''
},
{
id: 'dirty-coin',
title: 'Dirty Coin: The Bitcoin Mining Documentary',
description: 'An in-depth investigation into Bitcoin mining, exploring the reality of energy consumption, environmental impact, and the transformation of the energy grid through decentralized mining operations.',
thumbnail: 'https://images.unsplash.com/photo-1639762681485-074b7f938ba0?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1639762681485-074b7f938ba0?w=1920&h=1080&fit=crop',
type: 'film',
duration: 90,
releaseYear: 2024,
categories: ['Documentary', 'Bitcoin', 'Technology'],
nostrEventId: ''
},
{
id: 'searching-for-satoshi',
title: 'Searching for Satoshi: The Mysterious Disappearance of the Bitcoin Creator',
description: 'A thrilling investigation into one of the greatest mysteries of the digital age: the true identity of Satoshi Nakamoto, Bitcoin\'s enigmatic creator who vanished without a trace.',
thumbnail: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?w=1920&h=1080&fit=crop',
type: 'film',
duration: 88,
releaseYear: 2024,
categories: ['Documentary', 'Bitcoin', 'Mystery'],
nostrEventId: ''
},
{
id: 'bitcoin-end-of-money',
title: 'Bitcoin: The End of Money as We Know It',
description: 'A comprehensive documentary examining Bitcoin as a revolutionary technology that challenges traditional monetary systems, exploring its potential to reshape global finance and individual sovereignty.',
thumbnail: 'https://images.unsplash.com/photo-1621416894569-0f39ed31d247?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1621416894569-0f39ed31d247?w=1920&h=1080&fit=crop',
type: 'film',
duration: 60,
releaseYear: 2015,
categories: ['Documentary', 'Bitcoin', 'Finance'],
nostrEventId: ''
},
{
id: 'the-things-we-carry',
title: 'The Things We Carry',
description: 'A compelling narrative exploring themes that resonate',
thumbnail: 'https://images.unsplash.com/photo-1485846234645-a62644f84728?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1485846234645-a62644f84728?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Drama'],
nostrEventId: ''
},
{
id: 'hard-money',
title: 'Hard Money',
description: 'Understanding sound money principles and economic freedom',
thumbnail: 'https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Documentary', 'Finance', 'Bitcoin'],
nostrEventId: ''
},
{
id: 'satoshi-sculpture-garden',
title: 'The Satoshi Sculpture Garden',
description: 'Art meets Bitcoin in this unique documentary exploring creativity and decentralization',
thumbnail: 'https://images.unsplash.com/photo-1536924940846-227afb31e2a5?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1536924940846-227afb31e2a5?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Documentary', 'Bitcoin', 'Art'],
nostrEventId: ''
},
{
id: 'everybody-does-it',
title: 'Everybody Does It',
description: 'An insightful look at common experiences and human nature',
thumbnail: 'https://images.unsplash.com/photo-1478720568477-152d9b164e26?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1478720568477-152d9b164e26?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Documentary'],
nostrEventId: ''
},
{
id: 'the-edited',
title: 'The Edited',
description: 'A thrilling narrative about truth and manipulation in the digital age',
thumbnail: 'https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Thriller'],
nostrEventId: ''
},
{
id: 'in-the-darkness',
title: 'In The Darkness',
description: 'A gripping story that unfolds in shadows and mystery',
thumbnail: 'https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Drama', 'Thriller'],
nostrEventId: ''
},
{
id: 'anatomy-of-the-state',
title: 'Anatomy of the State',
description: 'A deep dive into government power structures and political theory',
thumbnail: 'https://images.unsplash.com/photo-1541872703-74c5e44368f9?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1541872703-74c5e44368f9?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Documentary', 'Political'],
nostrEventId: ''
},
{
id: 'gods-of-their-own-religion',
title: 'Gods of Their Own Religion',
description: 'Exploring belief systems, power dynamics, and personal faith',
thumbnail: 'https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Documentary'],
nostrEventId: ''
},
{
id: 'anne',
title: 'Anne',
description: 'A personal story of resilience and human spirit',
thumbnail: 'https://images.unsplash.com/photo-1594908900066-3f47337549d8?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1594908900066-3f47337549d8?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Drama'],
nostrEventId: ''
},
{
id: 'kismet',
title: 'Kismet',
description: 'Fate and destiny intertwine in this captivating tale',
thumbnail: 'https://images.unsplash.com/photo-1440404653325-ab127d49abc1?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1440404653325-ab127d49abc1?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Drama'],
nostrEventId: ''
},
{
id: 'one-mans-trash',
title: "One Man's Trash",
description: 'Finding value and beauty in unexpected places',
thumbnail: 'https://images.unsplash.com/photo-1532012197267-da84d127e765?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1532012197267-da84d127e765?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Drama'],
nostrEventId: ''
},
{
id: 'menger-notes',
title: 'Menger. Notes on the margin',
description: 'Economic theory and Austrian economics explored through a historical lens',
thumbnail: 'https://images.unsplash.com/photo-1554224311-beee4f0388c9?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1554224311-beee4f0388c9?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Documentary', 'Economics'],
nostrEventId: ''
},
{
id: 'clemont',
title: 'Clemont',
description: 'A character-driven narrative of transformation',
thumbnail: 'https://images.unsplash.com/photo-1524712245354-2c4e5e7121c0?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1524712245354-2c4e5e7121c0?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Drama'],
nostrEventId: ''
},
{
id: 'duel',
title: 'Duel',
description: 'Confrontation and resolution in an intense showdown',
thumbnail: 'https://images.unsplash.com/photo-1509347528160-9a9e33742cdb?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1509347528160-9a9e33742cdb?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Drama', 'Action'],
nostrEventId: ''
},
{
id: 'shatter',
title: 'SHATTER',
description: 'Breaking boundaries and shattering expectations',
thumbnail: 'https://images.unsplash.com/photo-1478720568477-152d9b164e26?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1478720568477-152d9b164e26?w=1920&h=1080&fit=crop',
type: 'film',
categories: ['Drama'],
nostrEventId: ''
},
{
id: 'stranded-dirty-coin',
title: 'STRANDED: A DIRTY COIN Short',
description: 'A companion piece to the Dirty Coin documentary',
thumbnail: 'https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=400&h=600&fit=crop',
backdrop: 'https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=1920&h=1080&fit=crop',
type: 'short',
categories: ['Documentary', 'Bitcoin'],
nostrEventId: ''
}
]
// Helper to get films by category
export function getFilmsByCategory(category: string): Content[] {
return indeeHubFilms.filter(film =>
film.categories.some(cat => cat.toLowerCase().includes(category.toLowerCase()))
)
}
// Get Bitcoin-related films
export const bitcoinFilms = getFilmsByCategory('bitcoin')
// Get documentaries
export const documentaries = getFilmsByCategory('documentary')
// Get dramas
export const dramas = getFilmsByCategory('drama')

5
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

12
src/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

15
src/router/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import Browse from '../views/Browse.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'browse',
component: Browse
}
]
})
export default router

57
src/stores/content.ts Normal file
View File

@@ -0,0 +1,57 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Content } from '../types/content'
import { indeeHubFilms, bitcoinFilms, documentaries, dramas } from '../data/indeeHubFilms'
export const useContentStore = defineStore('content', () => {
const featuredContent = ref<Content | null>(null)
const contentRows = ref<{ [key: string]: Content[] }>({
featured: [],
newReleases: [],
bitcoin: [],
documentaries: [],
dramas: [],
independent: []
})
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchContent() {
loading.value = true
error.value = null
try {
// Simulate loading delay for UX
await new Promise(resolve => setTimeout(resolve, 300))
// Set featured content (first Bitcoin doc)
featuredContent.value = bitcoinFilms[0] || indeeHubFilms[0]
// Organize content into rows
contentRows.value = {
featured: indeeHubFilms.slice(0, 10),
newReleases: indeeHubFilms.slice(0, 8).reverse(),
bitcoin: bitcoinFilms,
documentaries: documentaries.slice(0, 10),
dramas: dramas.slice(0, 10),
independent: indeeHubFilms.filter(f =>
!f.categories.includes('Bitcoin') && !f.categories.includes('Documentary')
).slice(0, 10)
}
} catch (e) {
error.value = 'Failed to load content'
console.error(e)
} finally {
loading.value = false
}
}
return {
featuredContent,
contentRows,
loading,
error,
fetchContent
}
})

124
src/style.css Normal file
View File

@@ -0,0 +1,124 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', 'Avenir Next', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(135deg, #0a0a0a 0%, #1a0a14 100%);
color: white;
min-height: 100vh;
}
#app {
min-height: 100vh;
}
/* Glass Morphism Styles from neode-ui */
.glass-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.glass-button {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.18);
color: white;
transition: all 0.2s ease;
}
.glass-button:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
}
.glass-button:active {
transform: translateY(0);
}
/* Content Card Styles */
.content-card {
position: relative;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.content-card:hover {
transform: scale(1.05);
z-index: 10;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.content-card img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Scrollbar Styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Netflix-style hero gradient */
.hero-gradient {
background: linear-gradient(
to top,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0.7) 50%,
rgba(0, 0, 0, 0.4) 75%,
rgba(0, 0, 0, 0) 100%
);
}
/* Loading Animation */
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.loading-shimmer {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0.05) 100%
);
background-size: 1000px 100%;
animation: shimmer 2s infinite;
}

34
src/types/content.ts Normal file
View File

@@ -0,0 +1,34 @@
// Content types
export interface Content {
id: string
title: string
description: string
thumbnail: string
backdrop?: string
type: 'film' | 'series' | 'short'
duration?: number
releaseYear?: number
rating?: string
creator?: string
creatorNpub?: string
nostrEventId?: string
views?: number
categories: string[]
}
// Nostr event types
export interface NostrEvent {
id: string
pubkey: string
created_at: number
kind: number
tags: string[][]
content: string
sig: string
}
// Content row for Netflix-style interface
export interface ContentRow {
title: string
contents: Content[]
}

97
src/utils/indeeHubApi.ts Normal file
View File

@@ -0,0 +1,97 @@
// Utility to fetch content from IndeeHub API
// Update with your actual API endpoints
const INDEEDHUB_API = 'https://indeehub.studio/api'
export interface IndeeHubFilm {
id: string
title: string
description: string
thumbnailUrl: string
backdropUrl?: string
type: 'film' | 'series' | 'short'
duration?: number
releaseYear?: number
rating?: string
creator?: {
name: string
npub?: string
}
nostrEventId?: string
categories: string[]
}
/**
* Fetch films from IndeeHub screening room
* TODO: Replace with actual API call when authenticated
*/
export async function fetchFilms(): Promise<IndeeHubFilm[]> {
try {
// TODO: Add authentication headers (NIP-98 for Nostr auth)
const response = await fetch(`${INDEEHHUB_API}/screening-room?type=film`, {
headers: {
// Add your auth headers here
// 'Authorization': 'Bearer ...'
}
})
if (!response.ok) {
throw new Error('Failed to fetch films')
}
return await response.json()
} catch (error) {
console.error('Error fetching films:', error)
// Return mock data for development
return getMockFilms()
}
}
/**
* Mock data for development
* Replace with real IndeeHub data
*/
function getMockFilms(): IndeeHubFilm[] {
return [
{
id: '1',
title: 'Sample Film 1',
description: 'Replace with actual IndeeHub film data',
thumbnailUrl: 'https://images.unsplash.com/photo-1518546305927-5a555bb7020d?w=400',
backdropUrl: 'https://images.unsplash.com/photo-1518546305927-5a555bb7020d?w=1920',
type: 'film',
duration: 120,
releaseYear: 2024,
categories: ['Drama']
},
// Add more mock films...
]
}
/**
* Fetch featured content
*/
export async function fetchFeaturedContent(): Promise<IndeeHubFilm | null> {
try {
const response = await fetch(`${INDEEHHUB_API}/featured`)
if (!response.ok) return null
return await response.json()
} catch (error) {
console.error('Error fetching featured:', error)
return null
}
}
/**
* Fetch films by category
*/
export async function fetchFilmsByCategory(category: string): Promise<IndeeHubFilm[]> {
try {
const response = await fetch(`${INDEEHHUB_API}/films?category=${category}`)
if (!response.ok) return []
return await response.json()
} catch (error) {
console.error('Error fetching category:', error)
return []
}
}

100
src/utils/nostr.ts Normal file
View File

@@ -0,0 +1,100 @@
// Nostr relay pool and connection management
import { SimplePool, Event as NostrEvent } from 'nostr-tools'
const DEFAULT_RELAYS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.snort.social'
]
// Kind numbers for IndeeHub content types
export const NOSTR_KINDS = {
VIDEO_HORIZONTAL: 34235, // NIP-71 Video horizontal
VIDEO_VERTICAL: 34236, // NIP-71 Video vertical
LONG_FORM: 30023, // NIP-23 Long-form content
SHORT_FORM: 1, // Regular notes for shorts
}
class NostrService {
private pool: SimplePool
private relays: string[]
constructor(relays: string[] = DEFAULT_RELAYS) {
this.pool = new SimplePool()
this.relays = relays
}
/**
* Fetch video content from Nostr
* Using NIP-71 for video events
*/
async fetchVideos(limit: number = 50): Promise<NostrEvent[]> {
try {
const events = await this.pool.querySync(this.relays, {
kinds: [NOSTR_KINDS.VIDEO_HORIZONTAL, NOSTR_KINDS.VIDEO_VERTICAL],
limit
})
return events
} catch (error) {
console.error('Error fetching videos from Nostr:', error)
return []
}
}
/**
* Fetch content by creator (pubkey)
*/
async fetchByCreator(pubkey: string, limit: number = 20): Promise<NostrEvent[]> {
try {
const events = await this.pool.querySync(this.relays, {
kinds: [NOSTR_KINDS.VIDEO_HORIZONTAL, NOSTR_KINDS.VIDEO_VERTICAL],
authors: [pubkey],
limit
})
return events
} catch (error) {
console.error('Error fetching creator content:', error)
return []
}
}
/**
* Subscribe to new content events
*/
subscribeToContent(
callback: (event: NostrEvent) => void,
kinds: number[] = [NOSTR_KINDS.VIDEO_HORIZONTAL, NOSTR_KINDS.VIDEO_VERTICAL]
) {
const sub = this.pool.subscribeMany(
this.relays,
[{ kinds, limit: 10 }],
{
onevent(event) {
callback(event)
}
}
)
return () => sub.close()
}
/**
* Publish a view/watch event
*/
async publishView(videoEventId: string, userPrivkey: string) {
// TODO: Implement NIP-XX for view tracking
console.log('Publishing view for:', videoEventId)
}
/**
* Close all connections
*/
close() {
this.pool.close(this.relays)
}
}
export const nostrService = new NostrService()

185
src/views/Browse.vue Normal file
View File

@@ -0,0 +1,185 @@
<template>
<div class="browse-view">
<!-- Header / Navigation -->
<header class="fixed top-0 left-0 right-0 z-50 transition-all duration-300"
:class="{ 'bg-black/90 backdrop-blur-md': scrolled }">
<div class="container mx-auto px-6 py-4">
<div class="flex items-center justify-between">
<!-- Logo -->
<div class="flex items-center gap-8">
<img src="/assets/images/logo.svg" alt="IndeedHub" class="h-10" />
<!-- Navigation -->
<nav class="hidden md:flex items-center gap-6">
<a href="#" class="text-white hover:text-white/80 transition-colors">Home</a>
<a href="#" class="text-white/70 hover:text-white transition-colors">Films</a>
<a href="#" class="text-white/70 hover:text-white transition-colors">Series</a>
<a href="#" class="text-white/70 hover:text-white transition-colors">Creators</a>
<a href="#" class="text-white/70 hover:text-white transition-colors">My List</a>
</nav>
</div>
<!-- Right Side Actions -->
<div class="flex items-center gap-4">
<!-- Search -->
<button class="p-2 hover:bg-white/10 rounded-lg transition-colors" @click="toggleSearch">
<svg class="w-6 h-6" 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>
<!-- User Avatar -->
<div class="w-8 h-8 rounded bg-gradient-to-br from-orange-500 to-pink-500"></div>
</div>
</div>
</div>
</header>
<!-- Hero / Featured Content -->
<section class="relative h-[70vh] md:h-[80vh] overflow-hidden">
<!-- Background Image -->
<div class="absolute inset-0">
<img
:src="featuredContent?.backdrop || 'https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=1920'"
alt="Featured content"
class="w-full h-full object-cover"
/>
<div class="absolute inset-0 hero-gradient"></div>
</div>
<!-- Hero Content -->
<div class="relative container mx-auto px-6 h-full flex items-center md:items-end pb-8 md:pb-24">
<div class="max-w-2xl space-y-3 md:space-y-4 animate-fade-in pt-24 md:pt-0">
<!-- Title -->
<h1 class="text-4xl md:text-6xl lg:text-7xl font-bold drop-shadow-2xl leading-tight">
{{ featuredContent?.title || 'Welcome to IndeedHub' }}
</h1>
<!-- Description -->
<p class="text-base md:text-lg lg:text-xl text-white/90 drop-shadow-lg line-clamp-3">
{{ featuredContent?.description || 'Discover decentralized content from independent creators and filmmakers around the world.' }}
</p>
<!-- Meta Info -->
<div v-if="featuredContent" class="flex items-center gap-3 text-sm text-white/80">
<span v-if="featuredContent.rating" class="bg-white/20 px-3 py-1 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-3 md:gap-4 pt-2 md:pt-4">
<button class="px-6 md:px-8 py-2.5 md:py-3 bg-white text-black font-semibold rounded-lg hover:bg-white/90 transition-all flex items-center gap-2 shadow-xl text-sm md:text-base">
<svg class="w-5 h-5 md:w-6 md:h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play
</button>
<button class="px-6 md:px-8 py-2.5 md:py-3 bg-white/20 text-white font-semibold rounded-lg hover:bg-white/30 transition-all backdrop-blur-md flex items-center gap-2 text-sm md:text-base">
<svg class="w-5 h-5 md:w-6 md:h-6" 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 Rows -->
<section class="relative -mt-32 pb-20">
<div class="container mx-auto px-6 space-y-12">
<!-- Featured Films -->
<ContentRow
title="Featured Films"
:contents="featuredFilms"
@content-click="handleContentClick"
/>
<!-- New Releases -->
<ContentRow
title="New Releases"
:contents="newReleases"
@content-click="handleContentClick"
/>
<!-- Bitcoin & Crypto -->
<ContentRow
title="Bitcoin & Cryptocurrency"
:contents="bitcoinFilms"
@content-click="handleContentClick"
/>
<!-- Documentaries -->
<ContentRow
title="Documentaries"
:contents="documentaries"
@content-click="handleContentClick"
/>
<!-- Independent Cinema -->
<ContentRow
title="Independent Cinema"
:contents="independentCinema"
@content-click="handleContentClick"
/>
<!-- Dramas -->
<ContentRow
title="Drama Films"
:contents="dramas"
@content-click="handleContentClick"
/>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import ContentRow from '../components/ContentRow.vue'
import { useContentStore } from '../stores/content'
import type { Content } from '../types/content'
const contentStore = useContentStore()
const scrolled = ref(false)
const featuredContent = computed(() => contentStore.featuredContent)
const featuredFilms = computed(() => contentStore.contentRows.featured)
const newReleases = computed(() => contentStore.contentRows.newReleases)
const bitcoinFilms = computed(() => contentStore.contentRows.bitcoin)
const independentCinema = computed(() => contentStore.contentRows.independent)
const dramas = computed(() => contentStore.contentRows.dramas)
const documentaries = computed(() => contentStore.contentRows.documentaries)
const handleScroll = () => {
scrolled.value = window.scrollY > 50
}
const toggleSearch = () => {
// TODO: Implement search modal
console.log('Search clicked')
}
const handleContentClick = (content: Content) => {
console.log('Content clicked:', content)
// TODO: Navigate to content detail page
}
onMounted(() => {
window.addEventListener('scroll', handleScroll)
contentStore.fetchContent()
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>
<style scoped>
.browse-view {
min-height: 100vh;
padding-top: 64px;
}
</style>