Files
indee-demo/src/components/ContentDetailModal.vue
Dorian 793df81798 Redesign comment replies as glass bubbles
- Top-level comments keep their existing layout (avatar, name,
  timestamp, text, action bar)
- Replies now render in a rounded glass bubble with subtle
  background and border, chat-message style (flat top-left
  corner, rounded on the other three)
- Smaller avatars and more compact action buttons for replies
- Reply form pulled outside the comment layout for cleaner
  nesting at all depths
- Better spacing between comment threads

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

590 lines
18 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="hasVoted || !isNostrLoggedIn"
class="action-btn"
:class="{ 'action-btn-active': userReaction === '+' }"
:style="{ opacity: userReaction === '+' ? 1 : hasVoted ? 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="hasVoted || !isNostrLoggedIn"
class="action-btn"
:class="{ 'action-btn-active': userReaction === '-' }"
:style="{ opacity: userReaction === '-' ? 1 : hasVoted ? 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>()
defineEmits<Emits>()
const { isAuthenticated, hasActiveSubscription } = useAuth()
const { isLoggedIn: isNostrLoggedIn, activePubkey } = useAccounts()
const newComment = ref('')
const isPostingComment = 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() {
if (!isAuthenticated.value) return
if (hasActiveSubscription.value) {
showVideoPlayer.value = true
return
}
showRentalModal.value = true
}
function toggleMyList() {
isInMyList.value = !isInMyList.value
}
async function handleLike() {
if (!isNostrLoggedIn.value || hasVoted.value) return
if (props.content?.id) {
try {
await nostr.postReaction(true, props.content.id)
} catch (err) {
console.error('Failed to post reaction:', err)
}
}
}
async function handleDislike() {
if (!isNostrLoggedIn.value || hasVoted.value) return
if (props.content?.id) {
try {
await nostr.postReaction(false, props.content.id)
} catch (err) {
console.error('Failed to post reaction:', err)
}
}
}
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>