- Introduced a new content source toggle in the profile and app header to switch between IndeeHub and TopDoc films. - Updated the content fetching logic to dynamically load content based on the selected source. - Enhanced the seeding process to include a combined catalog of IndeeHub and TopDoc films, ensuring diverse content availability. - Improved user interaction by preventing duplicate reactions and ensuring a smoother voting experience across comments and content. - Added support for Amber login (NIP-55) for Android users, integrating it into the existing authentication flow. Co-authored-by: Cursor <cursoragent@cursor.com>
618 lines
19 KiB
Vue
618 lines
19 KiB
Vue
<template>
|
|
<Transition name="modal-fade">
|
|
<div v-if="isOpen && content" class="detail-overlay" @click.self="$emit('close')">
|
|
<div class="detail-container">
|
|
<!-- Scrollable content area -->
|
|
<div class="detail-scroll" ref="scrollContainer">
|
|
<!-- Backdrop Hero -->
|
|
<div class="detail-hero">
|
|
<img
|
|
:src="content.backdrop || content.thumbnail"
|
|
:alt="content.title"
|
|
class="w-full h-full object-cover object-center"
|
|
/>
|
|
<div class="hero-gradient-overlay"></div>
|
|
|
|
<!-- Close Button -->
|
|
<button @click="$emit('close')" class="absolute top-4 right-4 z-10 p-2 bg-black/50 backdrop-blur-md rounded-full text-white/80 hover:text-white transition-colors">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Hero Content Overlay -->
|
|
<div class="absolute bottom-0 left-0 right-0 p-6 md:p-8">
|
|
<h1 class="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-3 drop-shadow-lg" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;">
|
|
{{ content.title }}
|
|
</h1>
|
|
|
|
<!-- Meta Row -->
|
|
<div class="flex flex-wrap items-center gap-2.5 text-sm text-white/80 mb-4">
|
|
<span v-if="content.rating" class="bg-white/20 backdrop-blur-sm px-2.5 py-0.5 rounded text-white">{{ content.rating }}</span>
|
|
<span v-if="content.releaseYear">{{ content.releaseYear }}</span>
|
|
<span v-if="content.duration">{{ content.duration }} min</span>
|
|
<span v-if="content.type" class="capitalize">{{ content.type }}</span>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<!-- Play Button -->
|
|
<button @click="handlePlay" class="play-btn flex items-center gap-2">
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
Play
|
|
</button>
|
|
|
|
<!-- Add to My List -->
|
|
<button @click="toggleMyList" class="action-btn" :class="{ 'action-btn-active': isInMyList }">
|
|
<svg v-if="!isInMyList" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
<svg v-else class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
|
</svg>
|
|
<span class="hidden sm:inline">My List</span>
|
|
</button>
|
|
|
|
<!-- Like Button -->
|
|
<button
|
|
@click="handleLike"
|
|
:disabled="isNostrLoggedIn && (hasVoted || isReacting)"
|
|
class="action-btn"
|
|
:class="{ 'action-btn-active': userReaction === '+' }"
|
|
:style="{ opacity: userReaction === '+' ? 1 : (isNostrLoggedIn && (hasVoted || isReacting)) ? 0.4 : 1 }"
|
|
>
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
|
|
</svg>
|
|
<span class="text-xs">{{ reactionCounts.positive }}</span>
|
|
</button>
|
|
|
|
<!-- Dislike Button -->
|
|
<button
|
|
@click="handleDislike"
|
|
:disabled="isNostrLoggedIn && (hasVoted || isReacting)"
|
|
class="action-btn"
|
|
:class="{ 'action-btn-active': userReaction === '-' }"
|
|
:style="{ opacity: userReaction === '-' ? 1 : (isNostrLoggedIn && (hasVoted || isReacting)) ? 0.4 : 1 }"
|
|
>
|
|
<svg class="w-5 h-5 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
|
|
</svg>
|
|
<span class="text-xs">{{ reactionCounts.negative }}</span>
|
|
</button>
|
|
|
|
<!-- Share Button -->
|
|
<button @click="handleShare" class="action-btn">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
|
</svg>
|
|
<span class="hidden sm:inline">Share</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content Body -->
|
|
<div class="detail-body">
|
|
<!-- Description -->
|
|
<div class="mb-6">
|
|
<p class="text-white/80 text-base leading-relaxed">{{ content.description }}</p>
|
|
</div>
|
|
|
|
<!-- Categories -->
|
|
<div v-if="content.categories && content.categories.length > 0" class="flex flex-wrap gap-2 mb-6">
|
|
<span
|
|
v-for="category in content.categories"
|
|
:key="category"
|
|
class="category-tag"
|
|
>
|
|
{{ category }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Creator Attribution -->
|
|
<div v-if="content.creator" class="flex items-center gap-3 mb-8 text-white/60 text-sm">
|
|
<span>Directed by</span>
|
|
<span class="text-white font-medium">{{ content.creator }}</span>
|
|
</div>
|
|
|
|
<!-- Divider -->
|
|
<div class="border-t border-white/10 mb-6"></div>
|
|
|
|
<!-- Comments Section -->
|
|
<div class="comments-section">
|
|
<h3 class="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
|
Comments
|
|
<span class="text-sm font-normal text-white/50">({{ commentCount }})</span>
|
|
<span v-if="!relayConnected" class="text-xs bg-orange-500/20 text-orange-300/60 px-2 py-0.5 rounded-full ml-auto">Relay Offline</span>
|
|
</h3>
|
|
|
|
<!-- Comment Input -->
|
|
<div v-if="isNostrLoggedIn" class="comment-input-wrap mb-6">
|
|
<div class="flex gap-3">
|
|
<img
|
|
:src="currentUserAvatar"
|
|
class="w-8 h-8 rounded-full flex-shrink-0 object-cover"
|
|
alt="You"
|
|
/>
|
|
<div class="flex-1">
|
|
<textarea
|
|
v-model="newComment"
|
|
placeholder="Share your thoughts..."
|
|
class="comment-textarea"
|
|
rows="2"
|
|
@keydown.meta.enter="submitComment"
|
|
@keydown.ctrl.enter="submitComment"
|
|
></textarea>
|
|
<div class="flex justify-end mt-2">
|
|
<button
|
|
@click="submitComment"
|
|
:disabled="!newComment.trim() || isPostingComment"
|
|
class="submit-comment-btn"
|
|
:class="{ 'opacity-40 cursor-not-allowed': !newComment.trim() || isPostingComment }"
|
|
>
|
|
{{ isPostingComment ? 'Posting...' : 'Post' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sign in prompt for comments -->
|
|
<div v-else class="text-center py-4 mb-6 bg-white/5 rounded-xl">
|
|
<p class="text-white/50 text-sm">
|
|
<button @click="$emit('openAuth')" class="text-white underline hover:text-white/80">Sign in</button>
|
|
to leave a comment
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Comments List -->
|
|
<div v-if="isLoadingComments" class="text-center py-8">
|
|
<div class="text-white/40 text-sm">Loading comments...</div>
|
|
</div>
|
|
|
|
<div v-else-if="commentTree.length === 0" class="text-center py-8">
|
|
<div class="text-white/40 text-sm">No comments yet. Be the first!</div>
|
|
</div>
|
|
|
|
<!-- Threaded comments -->
|
|
<div v-else class="space-y-2">
|
|
<template v-for="node in commentTree" :key="node.event.id">
|
|
<CommentNode
|
|
:node="node"
|
|
:depth="0"
|
|
:nostr="nostr"
|
|
:is-logged-in="isNostrLoggedIn"
|
|
:current-user-avatar="currentUserAvatar"
|
|
@get-profile="getProfile"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sub-modals triggered from within this modal -->
|
|
<VideoPlayer
|
|
:isOpen="showVideoPlayer"
|
|
:content="content"
|
|
@close="showVideoPlayer = false"
|
|
/>
|
|
|
|
<SubscriptionModal
|
|
:isOpen="showSubscriptionModal"
|
|
@close="showSubscriptionModal = false"
|
|
@success="handleSubscriptionSuccess"
|
|
/>
|
|
|
|
<RentalModal
|
|
:isOpen="showRentalModal"
|
|
:content="content"
|
|
@close="showRentalModal = false"
|
|
@success="handleRentalSuccess"
|
|
@openSubscription="openSubscriptionFromRental"
|
|
/>
|
|
</div>
|
|
</Transition>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, watch, computed } from 'vue'
|
|
import { useAuth } from '../composables/useAuth'
|
|
import { useAccounts } from '../composables/useAccounts'
|
|
import { useNostr } from '../composables/useNostr'
|
|
import type { Content } from '../types/content'
|
|
import VideoPlayer from './VideoPlayer.vue'
|
|
import SubscriptionModal from './SubscriptionModal.vue'
|
|
import RentalModal from './RentalModal.vue'
|
|
import CommentNode from './CommentNode.vue'
|
|
|
|
interface Props {
|
|
isOpen: boolean
|
|
content: Content | null
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'close'): void
|
|
(e: 'openAuth'): void
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
const emit = defineEmits<Emits>()
|
|
|
|
const { isAuthenticated, hasActiveSubscription } = useAuth()
|
|
const { isLoggedIn: isNostrLoggedIn, activePubkey } = useAccounts()
|
|
|
|
const newComment = ref('')
|
|
const isPostingComment = ref(false)
|
|
const isReacting = ref(false)
|
|
const isInMyList = ref(false)
|
|
const showVideoPlayer = ref(false)
|
|
const showSubscriptionModal = ref(false)
|
|
const showRentalModal = ref(false)
|
|
const relayConnected = ref(true)
|
|
|
|
// Nostr social data -- subscribes to relay in real time
|
|
const nostr = useNostr()
|
|
const commentTree = computed(() => nostr.commentTree.value)
|
|
const reactionCounts = computed(() => nostr.reactionCounts.value)
|
|
const isLoadingComments = computed(() => nostr.isLoading.value)
|
|
const commentCount = computed(() => nostr.commentCount.value)
|
|
|
|
// User's existing reaction read from relay (not local state)
|
|
const userReaction = computed(() => nostr.userContentReaction.value)
|
|
const hasVoted = computed(() => nostr.hasVotedOnContent.value)
|
|
|
|
// Current user avatar (robohash)
|
|
const currentUserAvatar = computed(() => {
|
|
if (activePubkey.value) {
|
|
return `https://robohash.org/${activePubkey.value}.png`
|
|
}
|
|
return 'https://robohash.org/anonymous.png'
|
|
})
|
|
|
|
// Subscribe to Nostr data when content changes
|
|
watch(() => props.content?.id, (newId) => {
|
|
if (newId && props.isOpen) {
|
|
loadSocialData(newId)
|
|
}
|
|
})
|
|
|
|
watch(() => props.isOpen, (open) => {
|
|
if (open && props.content?.id) {
|
|
loadSocialData(props.content.id)
|
|
}
|
|
})
|
|
|
|
function loadSocialData(contentId: string) {
|
|
nostr.cleanup()
|
|
nostr.subscribeToContent(contentId)
|
|
}
|
|
|
|
function getProfile(pubkey: string) {
|
|
return nostr.profiles.value.get(pubkey)
|
|
}
|
|
|
|
function handlePlay() {
|
|
// Free content with a streaming URL can play without auth
|
|
if (props.content?.streamingUrl) {
|
|
showVideoPlayer.value = true
|
|
return
|
|
}
|
|
|
|
if (!isAuthenticated.value && !isNostrLoggedIn.value) {
|
|
emit('openAuth')
|
|
return
|
|
}
|
|
|
|
if (hasActiveSubscription.value) {
|
|
showVideoPlayer.value = true
|
|
return
|
|
}
|
|
|
|
showRentalModal.value = true
|
|
}
|
|
|
|
function toggleMyList() {
|
|
if (!isAuthenticated.value && !isNostrLoggedIn.value) {
|
|
emit('openAuth')
|
|
return
|
|
}
|
|
isInMyList.value = !isInMyList.value
|
|
}
|
|
|
|
async function handleLike() {
|
|
if (!isNostrLoggedIn.value) {
|
|
emit('openAuth')
|
|
return
|
|
}
|
|
if (hasVoted.value || isReacting.value) return
|
|
if (props.content?.id) {
|
|
isReacting.value = true
|
|
try {
|
|
await nostr.postReaction(true, props.content.id)
|
|
} catch (err) {
|
|
console.error('Failed to post reaction:', err)
|
|
} finally {
|
|
isReacting.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleDislike() {
|
|
if (!isNostrLoggedIn.value) {
|
|
emit('openAuth')
|
|
return
|
|
}
|
|
if (hasVoted.value || isReacting.value) return
|
|
if (props.content?.id) {
|
|
isReacting.value = true
|
|
try {
|
|
await nostr.postReaction(false, props.content.id)
|
|
} catch (err) {
|
|
console.error('Failed to post reaction:', err)
|
|
} finally {
|
|
isReacting.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleShare() {
|
|
const url = `${window.location.origin}/content/${props.content?.id}`
|
|
if (navigator.share) {
|
|
navigator.share({
|
|
title: props.content?.title,
|
|
text: props.content?.description,
|
|
url,
|
|
}).catch(() => {
|
|
// User cancelled share
|
|
})
|
|
} else {
|
|
navigator.clipboard.writeText(url)
|
|
}
|
|
}
|
|
|
|
async function submitComment() {
|
|
if (!newComment.value.trim() || !props.content?.id || isPostingComment.value) return
|
|
|
|
isPostingComment.value = true
|
|
try {
|
|
await nostr.postComment(newComment.value.trim(), props.content.id)
|
|
newComment.value = ''
|
|
} catch (err) {
|
|
console.error('Failed to post comment:', err)
|
|
} finally {
|
|
isPostingComment.value = false
|
|
}
|
|
}
|
|
|
|
function handleSubscriptionSuccess() {
|
|
showSubscriptionModal.value = false
|
|
}
|
|
|
|
function handleRentalSuccess() {
|
|
showRentalModal.value = false
|
|
showVideoPlayer.value = true
|
|
}
|
|
|
|
function openSubscriptionFromRental() {
|
|
showRentalModal.value = false
|
|
showSubscriptionModal.value = true
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.detail-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1000;
|
|
background: rgba(0, 0, 0, 0.85);
|
|
backdrop-filter: blur(8px);
|
|
-webkit-backdrop-filter: blur(8px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.detail-overlay {
|
|
padding: 2rem;
|
|
}
|
|
}
|
|
|
|
.detail-container {
|
|
width: 100%;
|
|
height: 100%;
|
|
max-width: 900px;
|
|
max-height: 100vh;
|
|
background: #141414;
|
|
border-radius: 0;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.detail-container {
|
|
max-height: 90vh;
|
|
border-radius: 16px;
|
|
}
|
|
}
|
|
|
|
.detail-scroll {
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
overscroll-behavior: contain;
|
|
}
|
|
|
|
.detail-scroll::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.detail-scroll::-webkit-scrollbar-thumb {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
/* Hero */
|
|
.detail-hero {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 300px;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.detail-hero {
|
|
height: 400px;
|
|
}
|
|
}
|
|
|
|
.hero-gradient-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: linear-gradient(
|
|
to top,
|
|
#141414 0%,
|
|
rgba(20, 20, 20, 0.85) 30%,
|
|
rgba(20, 20, 20, 0.3) 60%,
|
|
rgba(20, 20, 20, 0) 100%
|
|
);
|
|
}
|
|
|
|
/* Buttons */
|
|
.play-btn {
|
|
position: relative;
|
|
padding: 10px 28px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
line-height: 1.4;
|
|
border-radius: 16px;
|
|
background: rgba(255, 255, 255, 0.85);
|
|
color: rgba(0, 0, 0, 0.9);
|
|
box-shadow:
|
|
0 12px 32px rgba(0, 0, 0, 0.4),
|
|
inset 0 1px 0 rgba(255, 255, 255, 1);
|
|
backdrop-filter: blur(24px);
|
|
-webkit-backdrop-filter: blur(24px);
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.play-btn:hover {
|
|
transform: translateY(-2px);
|
|
background: rgba(255, 255, 255, 0.95);
|
|
box-shadow:
|
|
0 16px 40px rgba(0, 0, 0, 0.5),
|
|
inset 0 1px 0 rgba(255, 255, 255, 1);
|
|
}
|
|
|
|
.action-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 14px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
border-radius: 12px;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
color: rgba(255, 255, 255, 0.85);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.action-btn:hover {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
border-color: rgba(255, 255, 255, 0.2);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.action-btn-active {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
border-color: rgba(255, 255, 255, 0.3);
|
|
color: white;
|
|
}
|
|
|
|
/* Category Tags */
|
|
.category-tag {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
border-radius: 20px;
|
|
background: rgba(255, 255, 255, 0.06);
|
|
color: rgba(255, 255, 255, 0.6);
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
/* Content Body */
|
|
.detail-body {
|
|
padding: 0 1.5rem 2rem;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.detail-body {
|
|
padding: 0 2rem 2rem;
|
|
}
|
|
}
|
|
|
|
/* Comments */
|
|
.comment-textarea {
|
|
width: 100%;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 12px;
|
|
padding: 12px 14px;
|
|
color: white;
|
|
font-size: 14px;
|
|
resize: none;
|
|
outline: none;
|
|
transition: border-color 0.2s ease;
|
|
}
|
|
|
|
.comment-textarea::placeholder {
|
|
color: rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
.comment-textarea:focus {
|
|
border-color: rgba(255, 255, 255, 0.25);
|
|
}
|
|
|
|
.submit-comment-btn {
|
|
padding: 6px 20px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
border-radius: 10px;
|
|
background: rgba(255, 255, 255, 0.85);
|
|
color: rgba(0, 0, 0, 0.9);
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.submit-comment-btn:hover:not(:disabled) {
|
|
background: rgba(255, 255, 255, 0.95);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
/* Transitions */
|
|
.modal-fade-enter-active {
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
.modal-fade-leave-active {
|
|
transition: opacity 0.25s ease;
|
|
}
|
|
|
|
.modal-fade-enter-from,
|
|
.modal-fade-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|