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:
12
src/App.vue
Normal file
12
src/App.vue
Normal 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>
|
||||
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>
|
||||
79
src/components/MobileNav.vue
Normal file
79
src/components/MobileNav.vue
Normal 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>
|
||||
233
src/components/VideoPlayer.vue
Normal file
233
src/components/VideoPlayer.vue
Normal 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>
|
||||
84
src/composables/useMobile.ts
Normal file
84
src/composables/useMobile.ts
Normal 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
231
src/data/indeeHubFilms.ts
Normal 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
5
src/env.d.ts
vendored
Normal 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
12
src/main.ts
Normal 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
15
src/router/index.ts
Normal 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
57
src/stores/content.ts
Normal 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
124
src/style.css
Normal 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
34
src/types/content.ts
Normal 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
97
src/utils/indeeHubApi.ts
Normal 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
100
src/utils/nostr.ts
Normal 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
185
src/views/Browse.vue
Normal 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>
|
||||
Reference in New Issue
Block a user