Enhance content management and user interaction features
- 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>
This commit is contained in:
@@ -164,6 +164,16 @@
|
||||
</svg>
|
||||
<span>My Library</span>
|
||||
</button>
|
||||
|
||||
<!-- Content Source Toggle -->
|
||||
<button @click="handleSourceToggle" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 w-full text-left">
|
||||
<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 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
<span class="flex-1">{{ contentSourceStore.activeSource === 'indeehub' ? 'IndeeHub Films' : 'TopDoc Films' }}</span>
|
||||
<span class="text-[10px] text-white/40 uppercase tracking-wider">Switch</span>
|
||||
</button>
|
||||
|
||||
<div class="border-t border-white/10 my-1"></div>
|
||||
<button @click="handleLogout" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 text-red-400 w-full text-left">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -215,6 +225,8 @@ import { useAuth } from '../composables/useAuth'
|
||||
import { useAccounts } from '../composables/useAccounts'
|
||||
import { accountManager } from '../lib/accounts'
|
||||
import { useContentDiscovery } from '../composables/useContentDiscovery'
|
||||
import { useContentSourceStore } from '../stores/contentSource'
|
||||
import { useContentStore } from '../stores/content'
|
||||
|
||||
type Persona = { name: string; nsec: string; pubkey: string }
|
||||
|
||||
@@ -257,6 +269,15 @@ const {
|
||||
setAlgorithm: _setAlgorithm,
|
||||
} = useContentDiscovery()
|
||||
|
||||
const contentSourceStore = useContentSourceStore()
|
||||
const contentStore = useContentStore()
|
||||
|
||||
/** Toggle between IndeeHub and TopDocFilms catalogs, then reload content */
|
||||
function handleSourceToggle() {
|
||||
contentSourceStore.toggle()
|
||||
contentStore.fetchContent()
|
||||
}
|
||||
|
||||
const dropdownOpen = ref(false)
|
||||
const personaMenuOpen = ref(false)
|
||||
const algosMenuOpen = ref(false)
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Login Button -->
|
||||
<!-- Nostr Login Button (NIP-07 Browser Extension) -->
|
||||
<button
|
||||
@click="handleNostrLogin"
|
||||
:disabled="isLoading"
|
||||
@@ -100,7 +100,21 @@
|
||||
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
Sign in with Nostr
|
||||
Sign in with Nostr Extension
|
||||
</button>
|
||||
|
||||
<!-- Amber Login Button (NIP-55 Android Signer) -->
|
||||
<button
|
||||
@click="handleAmberLogin"
|
||||
:disabled="isLoading"
|
||||
class="amber-login-button w-full flex items-center justify-center gap-2 mt-3"
|
||||
>
|
||||
<!-- Amber shield icon -->
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 2L3 7v6c0 5.25 3.75 10.15 9 11.25C17.25 23.15 21 18.25 21 13V7l-9-5z" fill="#F7931A" opacity="0.2" stroke="#F7931A" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<path d="M12 8v4m0 0v4m0-4h4m-4 0H8" stroke="#F7931A" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Sign in with Amber
|
||||
</button>
|
||||
|
||||
<!-- Toggle Mode -->
|
||||
@@ -141,7 +155,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { login, loginWithNostr, register, isLoading: authLoading } = useAuth()
|
||||
const { loginWithExtension } = useAccounts()
|
||||
const { loginWithExtension, loginWithAmber, isAmberSupported } = useAccounts()
|
||||
|
||||
const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
|
||||
const formData = ref({
|
||||
@@ -226,6 +240,47 @@ async function handleNostrLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with Amber (NIP-55 Android Signer)
|
||||
* Uses the AmberClipboardSigner from applesauce-signers which
|
||||
* handles the Android intent flow and clipboard-based result reading.
|
||||
* Amber copies the pubkey to clipboard, and when the user returns
|
||||
* to the browser the signer reads it automatically.
|
||||
*/
|
||||
async function handleAmberLogin() {
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
// Get the pubkey from Amber (opens intent, reads clipboard on return)
|
||||
const pubkey = await loginWithAmber()
|
||||
|
||||
// Create auth session with the pubkey from Amber
|
||||
await loginWithNostr(pubkey, 'amber-nip55', {
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['u', window.location.origin],
|
||||
['method', 'POST'],
|
||||
],
|
||||
content: '',
|
||||
pubkey,
|
||||
})
|
||||
|
||||
emit('success')
|
||||
closeModal()
|
||||
} catch (error: any) {
|
||||
console.error('Amber login failed:', error)
|
||||
|
||||
if (error.message?.includes('non-Android')) {
|
||||
errorMessage.value = 'Amber is only available on Android devices. Please use a Nostr browser extension instead.'
|
||||
} else if (error.message?.includes('clipboard') || error.message?.includes('Empty')) {
|
||||
errorMessage.value = 'Could not read from clipboard. Please ensure Amber is installed and clipboard permissions are granted.'
|
||||
} else {
|
||||
errorMessage.value = error.message || 'Amber login failed. Please try again.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Declare window.nostr for TypeScript
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -312,4 +367,28 @@ declare global {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Amber Login Button */
|
||||
.amber-login-button {
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #F7931A;
|
||||
background: rgba(247, 147, 26, 0.08);
|
||||
border: 1px solid rgba(247, 147, 26, 0.25);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.amber-login-button:hover {
|
||||
background: rgba(247, 147, 26, 0.15);
|
||||
border-color: rgba(247, 147, 26, 0.4);
|
||||
box-shadow: 0 0 20px rgba(247, 147, 26, 0.1);
|
||||
}
|
||||
|
||||
.amber-login-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<button v-if="isLoggedIn" @click="handleReact(true)" :disabled="hasVoted" class="comment-action-btn" :class="{ 'comment-action-active': userVote === '+' }" :style="{ opacity: userVote === '+' ? 1 : hasVoted ? 0.4 : 1 }">
|
||||
<button v-if="isLoggedIn" @click="handleReact(true)" :disabled="hasVoted || isReacting" class="comment-action-btn" :class="{ 'comment-action-active': userVote === '+' }" :style="{ opacity: userVote === '+' ? 1 : (hasVoted || isReacting) ? 0.4 : 1 }">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
|
||||
<span v-if="reactionCounts.positive > 0">{{ reactionCounts.positive }}</span>
|
||||
</button>
|
||||
<button v-if="isLoggedIn" @click="handleReact(false)" :disabled="hasVoted" class="comment-action-btn" :class="{ 'comment-action-active': userVote === '-' }" :style="{ opacity: userVote === '-' ? 1 : hasVoted ? 0.4 : 1 }">
|
||||
<button v-if="isLoggedIn" @click="handleReact(false)" :disabled="hasVoted || isReacting" class="comment-action-btn" :class="{ 'comment-action-active': userVote === '-' }" :style="{ opacity: userVote === '-' ? 1 : (hasVoted || isReacting) ? 0.4 : 1 }">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
||||
<span v-if="reactionCounts.negative > 0">{{ reactionCounts.negative }}</span>
|
||||
</button>
|
||||
@@ -58,11 +58,11 @@
|
||||
|
||||
<!-- Reply actions (compact) -->
|
||||
<div class="flex items-center gap-2 mt-1 ml-1">
|
||||
<button v-if="isLoggedIn" @click="handleReact(true)" :disabled="hasVoted" class="reply-action-btn" :class="{ 'reply-action-active': userVote === '+' }" :style="{ opacity: userVote === '+' ? 1 : hasVoted ? 0.35 : 1 }">
|
||||
<button v-if="isLoggedIn" @click="handleReact(true)" :disabled="hasVoted || isReacting" class="reply-action-btn" :class="{ 'reply-action-active': userVote === '+' }" :style="{ opacity: userVote === '+' ? 1 : (hasVoted || isReacting) ? 0.35 : 1 }">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
|
||||
<span v-if="reactionCounts.positive > 0">{{ reactionCounts.positive }}</span>
|
||||
</button>
|
||||
<button v-if="isLoggedIn" @click="handleReact(false)" :disabled="hasVoted" class="reply-action-btn" :class="{ 'reply-action-active': userVote === '-' }" :style="{ opacity: userVote === '-' ? 1 : hasVoted ? 0.35 : 1 }">
|
||||
<button v-if="isLoggedIn" @click="handleReact(false)" :disabled="hasVoted || isReacting" class="reply-action-btn" :class="{ 'reply-action-active': userVote === '-' }" :style="{ opacity: userVote === '-' ? 1 : (hasVoted || isReacting) ? 0.35 : 1 }">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
||||
<span v-if="reactionCounts.negative > 0">{{ reactionCounts.negative }}</span>
|
||||
</button>
|
||||
|
||||
@@ -58,10 +58,10 @@
|
||||
<!-- Like Button -->
|
||||
<button
|
||||
@click="handleLike"
|
||||
:disabled="hasVoted || !isNostrLoggedIn"
|
||||
:disabled="isNostrLoggedIn && (hasVoted || isReacting)"
|
||||
class="action-btn"
|
||||
:class="{ 'action-btn-active': userReaction === '+' }"
|
||||
:style="{ opacity: userReaction === '+' ? 1 : hasVoted ? 0.4 : 1 }"
|
||||
: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" />
|
||||
@@ -72,10 +72,10 @@
|
||||
<!-- Dislike Button -->
|
||||
<button
|
||||
@click="handleDislike"
|
||||
:disabled="hasVoted || !isNostrLoggedIn"
|
||||
:disabled="isNostrLoggedIn && (hasVoted || isReacting)"
|
||||
class="action-btn"
|
||||
:class="{ 'action-btn-active': userReaction === '-' }"
|
||||
:style="{ opacity: userReaction === '-' ? 1 : hasVoted ? 0.4 : 1 }"
|
||||
: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" />
|
||||
@@ -241,13 +241,14 @@ interface Emits {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
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)
|
||||
@@ -296,7 +297,16 @@ function getProfile(pubkey: string) {
|
||||
}
|
||||
|
||||
function handlePlay() {
|
||||
if (!isAuthenticated.value) return
|
||||
// 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
|
||||
@@ -307,27 +317,45 @@ function handlePlay() {
|
||||
}
|
||||
|
||||
function toggleMyList() {
|
||||
if (!isAuthenticated.value && !isNostrLoggedIn.value) {
|
||||
emit('openAuth')
|
||||
return
|
||||
}
|
||||
isInMyList.value = !isInMyList.value
|
||||
}
|
||||
|
||||
async function handleLike() {
|
||||
if (!isNostrLoggedIn.value || hasVoted.value) return
|
||||
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 || hasVoted.value) return
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,9 +47,8 @@
|
||||
<span class="text-xs font-medium whitespace-nowrap">Films</span>
|
||||
</button>
|
||||
|
||||
<!-- Algos (visible on Films and My List) -->
|
||||
<!-- Algos -->
|
||||
<button
|
||||
v-if="isOnFilmsPage || isActive('/library')"
|
||||
@click="showFilterSheet = true"
|
||||
class="flex flex-col items-center gap-1 nav-tab flex-1"
|
||||
:class="{ 'nav-tab-active': isFilterActive || showFilterSheet }"
|
||||
@@ -75,7 +74,7 @@
|
||||
|
||||
<!-- Profile -->
|
||||
<button
|
||||
@click="navigate('/profile')"
|
||||
@click="handleProfileClick"
|
||||
class="flex flex-col items-center gap-1 nav-tab flex-1"
|
||||
:class="{ 'nav-tab-active': isActive('/profile') }"
|
||||
>
|
||||
@@ -148,6 +147,18 @@ function handleMyListClick() {
|
||||
navigate('/library')
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to Profile if logged in, otherwise open the auth modal
|
||||
* with a redirect so the user lands on Profile after login.
|
||||
*/
|
||||
function handleProfileClick() {
|
||||
if (!isLoggedInAnywhere.value) {
|
||||
emit('openAuth', '/profile')
|
||||
return
|
||||
}
|
||||
navigate('/profile')
|
||||
}
|
||||
|
||||
function selectAlgorithm(algo: AlgorithmId) {
|
||||
setAlgorithm(algo)
|
||||
showFilterSheet.value = false
|
||||
@@ -176,55 +187,47 @@ function selectAlgorithm(algo: AlgorithmId) {
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
position: relative;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.nav-tab:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.nav-tab-active {
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: rgba(255, 255, 255, 1);
|
||||
font-weight: 600;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
box-shadow:
|
||||
0 8px 24px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
0 8px 20px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
/* Subtle variant with darker grey border for tab bar */
|
||||
.nav-tab-active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
padding: 2px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02));
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
padding: 1.5px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.25), transparent);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* --- Filter Bottom Sheet --- */
|
||||
|
||||
@@ -9,132 +9,168 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Video Area (Dummy Player) -->
|
||||
<div class="video-area">
|
||||
<img
|
||||
v-if="content?.backdrop || content?.thumbnail"
|
||||
:src="content?.backdrop || content?.thumbnail"
|
||||
:alt="content?.title"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
<!-- Play Overlay -->
|
||||
<div class="video-overlay">
|
||||
<button class="play-button" @click="togglePlay">
|
||||
<svg v-if="!isPlaying" class="w-20 h-20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
<svg v-else class="w-20 h-20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- ── Real YouTube Embed ── -->
|
||||
<template v-if="embedUrl">
|
||||
<div class="video-area iframe-area">
|
||||
<iframe
|
||||
:src="embedUrl"
|
||||
class="w-full h-full"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<!-- Dummy Notice -->
|
||||
<div class="demo-notice">
|
||||
<div class="demo-badge">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
<!-- Minimal info bar -->
|
||||
<div class="stream-info-bar">
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<span class="text-white font-medium">{{ content?.title }}</span>
|
||||
<span v-if="content?.releaseYear" class="text-white/50">{{ content.releaseYear }}</span>
|
||||
<span v-if="content?.duration" class="text-white/50">{{ content.duration }} min</span>
|
||||
</div>
|
||||
<a
|
||||
:href="content?.streamingUrl?.replace('/embed/', '/watch?v=') ?? '#'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="yt-link"
|
||||
>
|
||||
Open on YouTube
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
Demo Mode
|
||||
</div>
|
||||
<p class="text-sm">Video player preview - Full streaming coming soon</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Video Controls -->
|
||||
<div class="video-controls">
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-filled" :style="{ width: `${progress}%` }"></div>
|
||||
<div class="progress-handle" :style="{ left: `${progress}%` }"></div>
|
||||
</div>
|
||||
<div class="time-display">
|
||||
<span>{{ formatTime(currentTime) }}</span>
|
||||
<span class="text-white/60">/</span>
|
||||
<span class="text-white/60">{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="control-buttons">
|
||||
<!-- Left Side -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="togglePlay" class="control-btn">
|
||||
<svg v-if="!isPlaying" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<!-- ── Demo Player (no streaming URL) ── -->
|
||||
<template v-else>
|
||||
<div class="video-area">
|
||||
<img
|
||||
v-if="content?.backdrop || content?.thumbnail"
|
||||
:src="content?.backdrop || content?.thumbnail"
|
||||
:alt="content?.title"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
<!-- Play Overlay -->
|
||||
<div class="video-overlay">
|
||||
<button class="play-button" @click="togglePlay">
|
||||
<svg v-if="!isPlaying" class="w-20 h-20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg v-else class="w-20 h-20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="control-btn">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M5 4v16l7-8z M12 4v16l7-8z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="control-btn">
|
||||
<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="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-white font-medium text-sm">{{ content?.title }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="control-btn">
|
||||
<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.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<!-- Demo Notice -->
|
||||
<div class="demo-notice">
|
||||
<div class="demo-badge">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
Demo Mode
|
||||
</div>
|
||||
<p class="text-sm">Video player preview - Full streaming coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Controls -->
|
||||
<div class="video-controls">
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-filled" :style="{ width: `${progress}%` }"></div>
|
||||
<div class="progress-handle" :style="{ left: `${progress}%` }"></div>
|
||||
</div>
|
||||
<div class="time-display">
|
||||
<span>{{ formatTime(currentTime) }}</span>
|
||||
<span class="text-white/60">/</span>
|
||||
<span class="text-white/60">{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="control-buttons">
|
||||
<!-- Left Side -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="togglePlay" class="control-btn">
|
||||
<svg v-if="!isPlaying" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
<svg v-else class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="quality-selector">
|
||||
<button class="control-btn">
|
||||
{{ quality }}
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M5 4v16l7-8z M12 4v16l7-8z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="control-btn">
|
||||
<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="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-white font-medium text-sm">{{ content?.title }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="control-btn">
|
||||
<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.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="quality-selector">
|
||||
<button class="control-btn">
|
||||
{{ quality }}
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="control-btn" @click="toggleFullscreen">
|
||||
<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>
|
||||
|
||||
<button class="control-btn" @click="toggleFullscreen">
|
||||
<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>
|
||||
|
||||
<!-- Content Info Panel -->
|
||||
<div class="content-info-panel">
|
||||
<h2 class="text-2xl font-bold text-white mb-2">{{ content?.title }}</h2>
|
||||
<div class="flex items-center gap-4 text-sm text-white/80 mb-4">
|
||||
<span v-if="content?.releaseYear" class="bg-white/10 px-2 py-0.5 rounded">{{ content.releaseYear }}</span>
|
||||
<span v-if="content?.rating">{{ content.rating }}</span>
|
||||
<span v-if="content?.duration">{{ content.duration }}min</span>
|
||||
<span class="text-green-400 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Cinephile Access
|
||||
</span>
|
||||
<!-- Content Info Panel -->
|
||||
<div class="content-info-panel">
|
||||
<h2 class="text-2xl font-bold text-white mb-2">{{ content?.title }}</h2>
|
||||
<div class="flex items-center gap-4 text-sm text-white/80 mb-4">
|
||||
<span v-if="content?.releaseYear" class="bg-white/10 px-2 py-0.5 rounded">{{ content.releaseYear }}</span>
|
||||
<span v-if="content?.rating">{{ content.rating }}</span>
|
||||
<span v-if="content?.duration">{{ content.duration }}min</span>
|
||||
<span class="text-green-400 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Cinephile Access
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm line-clamp-3">{{ content?.description }}</p>
|
||||
</div>
|
||||
<p class="text-white/70 text-sm line-clamp-3">{{ content?.description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { Content } from '../types/content'
|
||||
|
||||
interface Props {
|
||||
@@ -157,6 +193,17 @@ const quality = ref('4K')
|
||||
|
||||
let playInterval: number | null = null
|
||||
|
||||
/**
|
||||
* Build the YouTube embed URL with autoplay when the player opens.
|
||||
* Returns null when the content has no streamingUrl.
|
||||
*/
|
||||
const embedUrl = computed(() => {
|
||||
if (!props.content?.streamingUrl) return null
|
||||
const base = props.content.streamingUrl
|
||||
const sep = base.includes('?') ? '&' : '?'
|
||||
return `${base}${sep}autoplay=1&rel=0&modestbranding=1`
|
||||
})
|
||||
|
||||
watch(() => props.isOpen, (newVal) => {
|
||||
if (newVal) {
|
||||
// Reset player state when opened
|
||||
@@ -184,7 +231,6 @@ function togglePlay() {
|
||||
|
||||
function startPlay() {
|
||||
isPlaying.value = true
|
||||
console.log('▶️ Video playing (demo mode)')
|
||||
|
||||
// Simulate playback progress
|
||||
playInterval = window.setInterval(() => {
|
||||
@@ -208,7 +254,13 @@ function stopPlay() {
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
console.log('🖥️ Fullscreen toggled (demo)')
|
||||
const el = document.querySelector('.video-player-overlay')
|
||||
if (!el) return
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
} else {
|
||||
el.requestFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
@@ -275,6 +327,42 @@ function formatTime(seconds: number): string {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* YouTube iframe fills the entire area */
|
||||
.iframe-area iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Minimal info bar for YouTube embed mode */
|
||||
.stream-info-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.yt-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.yt-link:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -477,5 +565,11 @@ function formatTime(seconds: number): string {
|
||||
.control-buttons .text-white {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stream-info-bar {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
import { accountManager } from '../lib/accounts'
|
||||
import { accountManager, AmberClipboardSigner, AmberClipboardAccount } from '../lib/accounts'
|
||||
import { TEST_PERSONAS, TASTEMAKER_PERSONAS } from '../data/testPersonas'
|
||||
import type { Subscription } from 'rxjs'
|
||||
|
||||
@@ -101,6 +101,35 @@ export function useAccounts() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Amber signer is supported on this platform (Android + clipboard)
|
||||
*/
|
||||
const isAmberSupported = computed(() => !!AmberClipboardSigner.SUPPORTED)
|
||||
|
||||
/**
|
||||
* Login with Amber (NIP-55 Android Signer).
|
||||
* Uses the AmberClipboardSigner to request the pubkey via Android intents.
|
||||
* The signer is retained for future event signing (comments, reactions, etc.)
|
||||
*/
|
||||
async function loginWithAmber() {
|
||||
isLoggingIn.value = true
|
||||
loginError.value = null
|
||||
try {
|
||||
const signer = new AmberClipboardSigner()
|
||||
const pubkey = await signer.getPublicKey()
|
||||
const account = new AmberClipboardAccount(pubkey, signer)
|
||||
accountManager.addAccount(account)
|
||||
accountManager.setActive(account)
|
||||
return pubkey
|
||||
} catch (err: any) {
|
||||
loginError.value = err.message || 'Amber login failed'
|
||||
console.error('Amber login error:', err)
|
||||
throw err
|
||||
} finally {
|
||||
isLoggingIn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout current account
|
||||
*/
|
||||
@@ -142,8 +171,12 @@ export function useAccounts() {
|
||||
loginWithExtension,
|
||||
loginWithPersona,
|
||||
loginWithPrivateKey,
|
||||
loginWithAmber,
|
||||
logout,
|
||||
|
||||
// Platform checks
|
||||
isAmberSupported,
|
||||
|
||||
// Personas for dev UI
|
||||
testPersonas,
|
||||
tastemakerPersonas,
|
||||
|
||||
@@ -320,6 +320,7 @@ export function useNostr(contentId?: string) {
|
||||
/**
|
||||
* Post a reaction (+/-) on the movie/content itself.
|
||||
* Uses kind 17 with #i and #k tags.
|
||||
* One vote per user per content — rejects duplicates.
|
||||
*/
|
||||
async function postReaction(positive: boolean, id: string = contentId!) {
|
||||
if (!id) throw new Error('Content ID required')
|
||||
@@ -327,6 +328,14 @@ export function useNostr(contentId?: string) {
|
||||
const account = accountManager.active
|
||||
if (!account) throw new Error('Not logged in')
|
||||
|
||||
// Prevent duplicate votes (check store, not just UI state)
|
||||
const existingVote = reactions.value.find(
|
||||
(r) => r.pubkey === account.pubkey,
|
||||
)
|
||||
if (existingVote) {
|
||||
throw new Error('You have already voted on this content')
|
||||
}
|
||||
|
||||
const externalId = getExternalContentId(id)
|
||||
|
||||
try {
|
||||
@@ -353,11 +362,18 @@ export function useNostr(contentId?: string) {
|
||||
/**
|
||||
* React to a comment event (+/-).
|
||||
* Uses factory.reaction(event, emoji) which creates kind 7 events.
|
||||
* One reaction per user per comment — rejects duplicates.
|
||||
*/
|
||||
async function reactToComment(event: NostrEvent, positive: boolean) {
|
||||
const account = accountManager.active
|
||||
if (!account) throw new Error('Not logged in')
|
||||
|
||||
// Prevent duplicate comment reactions
|
||||
const existing = commentReactions.value.get(event.id) || []
|
||||
if (existing.some((r) => r.pubkey === account.pubkey)) {
|
||||
throw new Error('You have already reacted to this comment')
|
||||
}
|
||||
|
||||
const currentRelays = relays.value ?? APP_RELAYS
|
||||
|
||||
try {
|
||||
@@ -375,21 +391,39 @@ export function useNostr(contentId?: string) {
|
||||
// --- Computed State ---
|
||||
|
||||
/**
|
||||
* Movie-level reaction counts.
|
||||
* Deduplicated reactions: keep only the latest reaction per pubkey.
|
||||
* Prevents inflated counts when a user has published multiple events.
|
||||
*/
|
||||
const uniqueReactions = computed(() => {
|
||||
const byPubkey = new Map<string, NostrEvent>()
|
||||
for (const r of reactions.value) {
|
||||
const existing = byPubkey.get(r.pubkey)
|
||||
if (!existing || r.created_at > existing.created_at) {
|
||||
byPubkey.set(r.pubkey, r)
|
||||
}
|
||||
}
|
||||
return [...byPubkey.values()]
|
||||
})
|
||||
|
||||
/**
|
||||
* Movie-level reaction counts (deduplicated per user).
|
||||
*/
|
||||
const reactionCounts = computed(() => {
|
||||
const positive = reactions.value.filter((r) => r.content === '+').length
|
||||
const negative = reactions.value.filter((r) => r.content === '-').length
|
||||
const positive = uniqueReactions.value.filter((r) => r.content === '+').length
|
||||
const negative = uniqueReactions.value.filter((r) => r.content === '-').length
|
||||
return { positive, negative, total: positive - negative }
|
||||
})
|
||||
|
||||
/**
|
||||
* Current user's movie-level reaction, read from relay data.
|
||||
* Uses the latest event if multiple exist.
|
||||
*/
|
||||
const userContentReaction = computed((): string | null => {
|
||||
const account = accountManager.active
|
||||
if (!account) return null
|
||||
const userReaction = reactions.value.find((r) => r.pubkey === account.pubkey)
|
||||
const userReaction = uniqueReactions.value.find(
|
||||
(r) => r.pubkey === account.pubkey,
|
||||
)
|
||||
return userReaction?.content || null
|
||||
})
|
||||
|
||||
@@ -399,12 +433,27 @@ export function useNostr(contentId?: string) {
|
||||
const hasVotedOnContent = computed(() => userContentReaction.value !== null)
|
||||
|
||||
/**
|
||||
* Get reaction counts for a specific comment event.
|
||||
* Deduplicate comment reactions: one per pubkey, keep latest.
|
||||
*/
|
||||
function getUniqueCommentReactions(eventId: string): NostrEvent[] {
|
||||
const events = commentReactions.value.get(eventId) || []
|
||||
const byPubkey = new Map<string, NostrEvent>()
|
||||
for (const r of events) {
|
||||
const existing = byPubkey.get(r.pubkey)
|
||||
if (!existing || r.created_at > existing.created_at) {
|
||||
byPubkey.set(r.pubkey, r)
|
||||
}
|
||||
}
|
||||
return [...byPubkey.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reaction counts for a specific comment event (deduplicated per user).
|
||||
*/
|
||||
function getCommentReactionCounts(eventId: string) {
|
||||
const events = commentReactions.value.get(eventId) || []
|
||||
const positive = events.filter((r) => r.content === '+').length
|
||||
const negative = events.filter((r) => r.content === '-').length
|
||||
const unique = getUniqueCommentReactions(eventId)
|
||||
const positive = unique.filter((r) => r.content === '+').length
|
||||
const negative = unique.filter((r) => r.content === '-').length
|
||||
return { positive, negative }
|
||||
}
|
||||
|
||||
@@ -414,8 +463,8 @@ export function useNostr(contentId?: string) {
|
||||
function getUserCommentReaction(eventId: string): string | null {
|
||||
const account = accountManager.active
|
||||
if (!account) return null
|
||||
const events = commentReactions.value.get(eventId) || []
|
||||
const userReaction = events.find((r) => r.pubkey === account.pubkey)
|
||||
const unique = getUniqueCommentReactions(eventId)
|
||||
const userReaction = unique.find((r) => r.pubkey === account.pubkey)
|
||||
return userReaction?.content || null
|
||||
}
|
||||
|
||||
|
||||
301
src/data/topDocFilms.ts
Normal file
301
src/data/topDocFilms.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
// Curated documentaries from topdocumentaryfilms.com
|
||||
// Focused on Bitcoin, cryptocurrency, money, and economics
|
||||
//
|
||||
// Poster images: downloaded locally from TMDB and topdocumentaryfilms.com.
|
||||
// Streaming: YouTube embed URLs for playback inside the VideoPlayer component.
|
||||
|
||||
import type { Content } from '../types/content'
|
||||
|
||||
const P = '/images/films/posters/topdoc'
|
||||
const YT_THUMB = 'https://img.youtube.com/vi'
|
||||
|
||||
export const topDocFilms: Content[] = [
|
||||
// ── Bitcoin & Cryptocurrency ────────────────────────────────
|
||||
{
|
||||
id: 'tdf-god-bless-bitcoin',
|
||||
title: 'God Bless Bitcoin',
|
||||
description: 'A groundbreaking documentary exploring the intersection of faith, finance, and the future of money through the lens of Bitcoin and its transformative impact on religious communities worldwide.',
|
||||
thumbnail: `${P}/god-bless-bitcoin.jpg`,
|
||||
backdrop: '/images/god-bless-bitcoin-backdrop.jpg',
|
||||
streamingUrl: 'https://www.youtube.com/embed/3XEuqixD2Zg',
|
||||
type: 'film',
|
||||
releaseYear: 2024,
|
||||
categories: ['Documentary', 'Bitcoin', 'Religion'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-bitcoin-end-of-money',
|
||||
title: 'Bitcoin: The End of Money as We Know It',
|
||||
description: 'Tracing the history of money from barter to Bitcoin, this award-winning documentary examines how decentralized digital currency could upend the global financial system and redefine what money means.',
|
||||
thumbnail: `${P}/bitcoin-end-of-money.jpg`,
|
||||
backdrop: `${YT_THUMB}/zpNlG3VtcBM/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/zpNlG3VtcBM',
|
||||
type: 'film',
|
||||
releaseYear: 2015,
|
||||
categories: ['Documentary', 'Bitcoin', 'Economics'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-bitcoin-beyond-bubble',
|
||||
title: 'Bitcoin: Beyond the Bubble',
|
||||
description: 'An accessible explainer for those intimidated by crypto jargon, tracing currency evolution from precious metals to the dollar to Bitcoin and its promise for the unbanked worldwide.',
|
||||
thumbnail: `${P}/bitcoin-beyond-bubble.jpg`,
|
||||
backdrop: `${YT_THUMB}/URrmfEu0cZ8/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/URrmfEu0cZ8',
|
||||
type: 'film',
|
||||
releaseYear: 2018,
|
||||
rating: '8.34',
|
||||
categories: ['Documentary', 'Bitcoin', 'Economics'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-bitcoin-gospel',
|
||||
title: 'The Bitcoin Gospel',
|
||||
description: 'Following entrepreneurs and activists who believe Bitcoin offers an escape from bank and government financial control, examining whether it can truly redefine capitalism globally.',
|
||||
thumbnail: `${P}/bitcoin-gospel.jpg`,
|
||||
backdrop: `${YT_THUMB}/8zKuoqZLyKg/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/8zKuoqZLyKg',
|
||||
type: 'film',
|
||||
releaseYear: 2015,
|
||||
duration: 49,
|
||||
rating: '7.93',
|
||||
categories: ['Documentary', 'Bitcoin', 'Economics'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-bitcoin-psyop',
|
||||
title: 'The Bitcoin Psyop',
|
||||
description: 'A short film examining whether Bitcoin and blockchain are genuine technological innovations or hype, and whether they will decentralize power or enable greater government control.',
|
||||
thumbnail: `${P}/bitcoin-psyop.jpg`,
|
||||
backdrop: `${YT_THUMB}/XBlai36NorA/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/XBlai36NorA',
|
||||
type: 'short',
|
||||
releaseYear: 2018,
|
||||
rating: '7.44',
|
||||
categories: ['Documentary', 'Bitcoin', 'Conspiracy'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-missing-cryptoqueen',
|
||||
title: 'The Missing Cryptoqueen: Dead or Alive?',
|
||||
description: 'The extraordinary story of Ruja Ignatova, the self-styled Cryptoqueen who persuaded millions to invest in her cryptocurrency OneCoin before vanishing with billions.',
|
||||
thumbnail: `${P}/missing-cryptoqueen.jpg`,
|
||||
backdrop: `${YT_THUMB}/FTnTToWEHvI/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/FTnTToWEHvI',
|
||||
type: 'film',
|
||||
releaseYear: 2024,
|
||||
duration: 53,
|
||||
categories: ['Documentary', 'Crypto', 'Crime'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-billion-dollar-scam',
|
||||
title: 'The Billion Dollar Scam',
|
||||
description: 'An investigation into one of the largest financial frauds in history, tracing how billions disappeared through elaborate schemes and the investigators racing to uncover the truth.',
|
||||
thumbnail: `${P}/billion-dollar-scam.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/3QpdU9LS540',
|
||||
type: 'film',
|
||||
releaseYear: 2024,
|
||||
categories: ['Documentary', 'Crypto', 'Crime'],
|
||||
},
|
||||
|
||||
// ── Money & Banking ─────────────────────────────────────────
|
||||
{
|
||||
id: 'tdf-money-banking-fed',
|
||||
title: 'Money, Banking, and The Federal Reserve',
|
||||
description: 'Featuring Ron Paul, Joseph Salerno, Hans Hoppe, and Lew Rockwell, this film explains the Federal Reserve\'s operations and history through Austrian economics principles.',
|
||||
thumbnail: `${P}/money-banking-fed.jpg`,
|
||||
backdrop: `${YT_THUMB}/YLYL_NVU1bg/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/YLYL_NVU1bg',
|
||||
type: 'film',
|
||||
duration: 42,
|
||||
rating: '5.30',
|
||||
categories: ['Documentary', 'Economics', 'Money'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-american-dream',
|
||||
title: 'The American Dream',
|
||||
description: 'An animated film examining how money is created and how the Federal Reserve System affects daily life, connecting current economic problems to historical warnings about the financial system.',
|
||||
thumbnail: `${P}/american-dream.jpg`,
|
||||
backdrop: `${YT_THUMB}/8NBSwDEf8a8/sddefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/8NBSwDEf8a8',
|
||||
type: 'short',
|
||||
duration: 30,
|
||||
rating: '7.97',
|
||||
categories: ['Documentary', 'Economics', 'Money'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-century-enslavement',
|
||||
title: 'Century of Enslavement: The History of the Federal Reserve',
|
||||
description: 'An exhaustive examination of how the Fed was created in 1913 to address banking panics, yet allowed the 2008 financial crisis to occur, with some economists viewing Bitcoin as an alternative.',
|
||||
thumbnail: `${P}/century-enslavement.jpg`,
|
||||
backdrop: `${YT_THUMB}/TmYtPfdwYtY/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/TmYtPfdwYtY',
|
||||
type: 'film',
|
||||
rating: '8.18',
|
||||
categories: ['Documentary', 'Economics', 'Money'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-money-power-wall-street',
|
||||
title: 'Money, Power and Wall Street',
|
||||
description: 'After almost 80 years, another global financial crisis threatened to bring the world economy to the brink of collapse. This traces the 2008 Wall Street crash and its devastating aftermath.',
|
||||
thumbnail: `${P}/money-power-wall-street.jpg`,
|
||||
backdrop: `${YT_THUMB}/W-Q9AOp2FW8/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/W-Q9AOp2FW8',
|
||||
type: 'film',
|
||||
releaseYear: 2012,
|
||||
rating: '8.36',
|
||||
categories: ['Documentary', 'Economics', 'Money'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-gold-6000-year',
|
||||
title: 'Gold: The Story of Man\'s 6000 Year Obsession',
|
||||
description: 'The story of gold is the story of civilizations. What is it about this precious metal that inspires such a level of devotion across millennia of human history?',
|
||||
thumbnail: `${P}/gold-6000-year.jpg`,
|
||||
backdrop: `${YT_THUMB}/vM8CtejAelM/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/vM8CtejAelM',
|
||||
type: 'film',
|
||||
releaseYear: 2018,
|
||||
rating: '8.56',
|
||||
categories: ['Documentary', 'Economics', 'History'],
|
||||
},
|
||||
|
||||
// ── Modern Economics ────────────────────────────────────────
|
||||
{
|
||||
id: 'tdf-debtasized',
|
||||
title: 'Debtasized',
|
||||
description: 'Our reliance on credit has fundamentally altered how we perceive affordability. With a plethora of credit options readily available, the focus has shifted from the total cost to the monthly payment.',
|
||||
thumbnail: `${P}/debtasized.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/A63afuvkbmk',
|
||||
type: 'film',
|
||||
releaseYear: 2024,
|
||||
rating: '7.67',
|
||||
categories: ['Documentary', 'Economics'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-crash-next-crisis',
|
||||
title: 'Crash: Are We Ready for the Next Crisis?',
|
||||
description: 'In 2008, the world was hit by a financial crisis, the worst since the Great Depression. Governments had to bail out banks to prevent total collapse. Are we prepared for the next one?',
|
||||
thumbnail: `${P}/crash-next-crisis.jpg`,
|
||||
backdrop: `${YT_THUMB}/O2pD_y61jx4/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/O2pD_y61jx4',
|
||||
type: 'film',
|
||||
releaseYear: 2019,
|
||||
rating: '8.17',
|
||||
categories: ['Documentary', 'Economics'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-pension-gamble',
|
||||
title: 'The Pension Gamble',
|
||||
description: 'Older civil servant workers in America are worried. Despite steady jobs as firefighters, teachers, and police officers, they can no longer count on one of the most basic financial safety nets.',
|
||||
thumbnail: `${P}/pension-gamble.jpg`,
|
||||
backdrop: `${YT_THUMB}/lkOQNPIsO-Q/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/lkOQNPIsO-Q',
|
||||
type: 'film',
|
||||
releaseYear: 2021,
|
||||
duration: 54,
|
||||
rating: '9.22',
|
||||
categories: ['Documentary', 'Economics'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-why-americans-poor',
|
||||
title: 'Why Americans Feel So Poor?',
|
||||
description: 'The American middle class has been facing financial challenges and instability, despite being considered a symbol of the American dream in the past.',
|
||||
thumbnail: `${P}/why-americans-poor.jpg`,
|
||||
backdrop: `${YT_THUMB}/kCQiywN7pH4/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/kCQiywN7pH4',
|
||||
type: 'film',
|
||||
releaseYear: 2023,
|
||||
rating: '8.10',
|
||||
categories: ['Documentary', 'Economics', 'Society'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-chain-reaction',
|
||||
title: 'Chain Reaction',
|
||||
description: 'Many of us took for granted that online orders arrive in days. Delays were rare until the ongoing supply chain crisis hit the world in full force in 2021.',
|
||||
thumbnail: `${P}/chain-reaction.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/HMmdPgtXUUA',
|
||||
type: 'film',
|
||||
releaseYear: 2022,
|
||||
rating: '7.82',
|
||||
categories: ['Documentary', 'Economics'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-usa-on-brink',
|
||||
title: 'USA on the Brink',
|
||||
description: 'When the COVID-19 pandemic hit, the magnitude of the economic upheaval it caused was similar to what the USA experienced during the Great Depression.',
|
||||
thumbnail: `${P}/usa-on-brink.jpg`,
|
||||
backdrop: `${YT_THUMB}/G7z1kjkdRxU/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/G7z1kjkdRxU',
|
||||
type: 'film',
|
||||
releaseYear: 2020,
|
||||
rating: '7.28',
|
||||
categories: ['Documentary', 'Economics'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-big-four',
|
||||
title: 'The Big Four: Accounting Firms Under Scrutiny',
|
||||
description: 'In 2020, Wirecard AG filed for bankruptcy after losing 1.9 billion euros. This film exposes the role of the world\'s biggest accounting firms in corporate scandals.',
|
||||
thumbnail: `${P}/big-four.jpg`,
|
||||
backdrop: `${YT_THUMB}/C_0XEIFGK5o/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/C_0XEIFGK5o',
|
||||
type: 'film',
|
||||
releaseYear: 2021,
|
||||
rating: '7.82',
|
||||
categories: ['Documentary', 'Economics', 'Crime'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-congo-millionaires',
|
||||
title: 'Congo: Millionaires of Chaos',
|
||||
description: 'The Democratic Republic of Congo is six times the size of Germany and home to over 100 million people. Armed uprisings, political upheavals and violence have marked its history.',
|
||||
thumbnail: `${P}/congo-millionaires.jpg`,
|
||||
type: 'film',
|
||||
releaseYear: 2018,
|
||||
rating: '8.00',
|
||||
categories: ['Documentary', 'Economics', 'Politics'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-economics-of',
|
||||
title: 'The Economics Of',
|
||||
description: 'Chick-fil-A, IKEA, and others have built devoted customer bases by focusing on excellent service and unique business models. A look at what makes them work.',
|
||||
thumbnail: `${P}/economics-of.jpg`,
|
||||
backdrop: `${YT_THUMB}/grkHcEyZu04/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/3JKWmWAlMD4',
|
||||
type: 'film',
|
||||
releaseYear: 2023,
|
||||
rating: '6.75',
|
||||
categories: ['Documentary', 'Economics'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-big-business-food',
|
||||
title: 'Big Business: Food Empires',
|
||||
description: 'From dry-aged steaks to artisanal burger patties, exploring how third-generation butchers and food entrepreneurs build empires of quality and dedication.',
|
||||
thumbnail: `${P}/big-business-food.jpg`,
|
||||
backdrop: `${YT_THUMB}/4ArVvrhhnyI/maxresdefault.jpg`,
|
||||
streamingUrl: 'https://www.youtube.com/embed/4ArVvrhhnyI',
|
||||
type: 'film',
|
||||
releaseYear: 2023,
|
||||
rating: '8.00',
|
||||
categories: ['Documentary', 'Economics'],
|
||||
},
|
||||
{
|
||||
id: 'tdf-so-long-superstores',
|
||||
title: 'So Long, Superstores?',
|
||||
description: 'Examining the changing retail landscape and the impact it is having on the traditional superstore model, from big-box to e-commerce.',
|
||||
thumbnail: `${P}/so-long-superstores.jpg`,
|
||||
type: 'film',
|
||||
releaseYear: 2021,
|
||||
rating: '7.50',
|
||||
categories: ['Documentary', 'Economics'],
|
||||
},
|
||||
]
|
||||
|
||||
// Helper to get films by category
|
||||
export function getTopDocByCategory(category: string): Content[] {
|
||||
return topDocFilms.filter(film =>
|
||||
film.categories.some(cat => cat.toLowerCase().includes(category.toLowerCase()))
|
||||
)
|
||||
}
|
||||
|
||||
// Category exports
|
||||
export const topDocBitcoin = getTopDocByCategory('bitcoin')
|
||||
export const topDocCrypto = [
|
||||
...getTopDocByCategory('bitcoin'),
|
||||
...getTopDocByCategory('crypto'),
|
||||
].filter((film, i, arr) => arr.findIndex(f => f.id === film.id) === i)
|
||||
export const topDocMoney = getTopDocByCategory('money')
|
||||
export const topDocEconomics = getTopDocByCategory('economics')
|
||||
@@ -1,6 +1,8 @@
|
||||
import { AccountManager, Accounts } from 'applesauce-accounts'
|
||||
import type { NostrEvent } from 'applesauce-core/helpers/event'
|
||||
import { NostrConnectSigner } from 'applesauce-signers'
|
||||
import { AmberClipboardSigner } from 'applesauce-signers/signers/amber-clipboard-signer'
|
||||
import { AmberClipboardAccount } from 'applesauce-accounts/accounts/amber-clipboard-account'
|
||||
import { filter, map } from 'rxjs'
|
||||
import { pool } from './relay'
|
||||
|
||||
@@ -13,6 +15,12 @@ export const accountManager = new AccountManager()
|
||||
// Register common account types (Extension, PrivateKey, NostrConnect, etc.)
|
||||
Accounts.registerCommonAccountTypes(accountManager)
|
||||
|
||||
// Register Amber clipboard account type (NIP-55 Android signer)
|
||||
accountManager.registerType(AmberClipboardAccount)
|
||||
|
||||
// Re-export for use in composables
|
||||
export { AmberClipboardSigner, AmberClipboardAccount }
|
||||
|
||||
// Wire NostrConnect signer to use our relay pool
|
||||
NostrConnectSigner.subscriptionMethod = (relays, filters) => {
|
||||
return pool.subscription(relays, filters).pipe(
|
||||
|
||||
@@ -2,8 +2,10 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Content } from '../types/content'
|
||||
import { indeeHubFilms, bitcoinFilms, documentaries, dramas } from '../data/indeeHubFilms'
|
||||
import { topDocFilms, topDocBitcoin, topDocCrypto, topDocMoney, topDocEconomics } from '../data/topDocFilms'
|
||||
import { contentService } from '../services/content.service'
|
||||
import { mapApiProjectsToContents } from '../utils/mappers'
|
||||
import { useContentSourceStore } from './contentSource'
|
||||
|
||||
const USE_MOCK_DATA = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
|
||||
|
||||
@@ -67,10 +69,9 @@ export const useContentStore = defineStore('content', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch content from mock data
|
||||
* Fetch IndeeHub mock content (original catalog)
|
||||
*/
|
||||
function fetchContentFromMock() {
|
||||
// Set featured content immediately - God Bless Bitcoin
|
||||
function fetchIndeeHubMock() {
|
||||
const godBlessBitcoin = bitcoinFilms.find(f => f.title === 'God Bless Bitcoin') || bitcoinFilms[0]
|
||||
if (godBlessBitcoin) {
|
||||
featuredContent.value = {
|
||||
@@ -81,7 +82,6 @@ export const useContentStore = defineStore('content', () => {
|
||||
featuredContent.value = indeeHubFilms[0]
|
||||
}
|
||||
|
||||
// Organize content into rows
|
||||
contentRows.value = {
|
||||
featured: indeeHubFilms.slice(0, 10),
|
||||
newReleases: indeeHubFilms.slice(0, 8).reverse(),
|
||||
@@ -94,6 +94,34 @@ export const useContentStore = defineStore('content', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch TopDocumentaryFilms mock content
|
||||
*/
|
||||
function fetchTopDocMock() {
|
||||
featuredContent.value = topDocFilms[0]
|
||||
|
||||
contentRows.value = {
|
||||
featured: topDocFilms.slice(0, 10),
|
||||
newReleases: [...topDocFilms].sort((a, b) => (b.releaseYear ?? 0) - (a.releaseYear ?? 0)).slice(0, 8),
|
||||
bitcoin: topDocBitcoin,
|
||||
documentaries: topDocEconomics.slice(0, 10),
|
||||
dramas: topDocMoney,
|
||||
independent: topDocCrypto.slice(0, 10)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route to the correct mock loader based on the active content source
|
||||
*/
|
||||
function fetchContentFromMock() {
|
||||
const sourceStore = useContentSourceStore()
|
||||
if (sourceStore.activeSource === 'topdocfilms') {
|
||||
fetchTopDocMock()
|
||||
} else {
|
||||
fetchIndeeHubMock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main fetch content method
|
||||
*/
|
||||
|
||||
26
src/stores/contentSource.ts
Normal file
26
src/stores/contentSource.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export type ContentSourceId = 'indeehub' | 'topdocfilms'
|
||||
|
||||
const STORAGE_KEY = 'indeedhub:content-source'
|
||||
|
||||
export const useContentSourceStore = defineStore('contentSource', () => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) as ContentSourceId | null
|
||||
const activeSource = ref<ContentSourceId>(saved === 'topdocfilms' ? 'topdocfilms' : 'indeehub')
|
||||
|
||||
// Persist to localStorage on change
|
||||
watch(activeSource, (v) => {
|
||||
localStorage.setItem(STORAGE_KEY, v)
|
||||
})
|
||||
|
||||
function setSource(source: ContentSourceId) {
|
||||
activeSource.value = source
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
activeSource.value = activeSource.value === 'indeehub' ? 'topdocfilms' : 'indeehub'
|
||||
}
|
||||
|
||||
return { activeSource, setSource, toggle }
|
||||
})
|
||||
@@ -177,11 +177,11 @@
|
||||
<h2 class="content-row-title text-xl md:text-2xl font-bold text-white mb-4 px-4 uppercase">
|
||||
{{ activeAlgorithmLabel }}
|
||||
</h2>
|
||||
<div class="flex gap-8 overflow-x-auto overflow-y-visible scrollbar-hide scroll-smooth px-4 pt-6 pb-8 flex-wrap">
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6 lg:gap-8 px-4 pt-6 pb-8">
|
||||
<div
|
||||
v-for="content in filteredContent"
|
||||
:key="content.id"
|
||||
class="content-card flex-shrink-0 w-[200px] md:w-[280px] group/card cursor-pointer"
|
||||
class="content-card group/card cursor-pointer"
|
||||
@click="handleContentClick(content)"
|
||||
>
|
||||
<div class="glass-card rounded-lg p-1.5 transition-all duration-300">
|
||||
@@ -193,8 +193,8 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<h3 class="text-base md:text-xl font-semibold md:font-bold text-white truncate">{{ content.title }}</h3>
|
||||
<p class="text-base text-white/60 truncate hidden md:block">{{ content.description }}</p>
|
||||
<h3 class="text-sm md:text-xl font-semibold md:font-bold text-white truncate">{{ content.title }}</h3>
|
||||
<p class="text-xs md:text-base text-white/60 line-clamp-2 md:truncate">{{ content.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,7 +277,8 @@ import { useContentStore } from '../stores/content'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import { useAccounts } from '../composables/useAccounts'
|
||||
import { useContentDiscovery } from '../composables/useContentDiscovery'
|
||||
import { indeeHubFilms, bitcoinFilms, documentaries } from '../data/indeeHubFilms'
|
||||
// indeeHubFilms data is now loaded dynamically via the content store
|
||||
import { useContentSourceStore } from '../stores/contentSource'
|
||||
import type { Content } from '../types/content'
|
||||
|
||||
const emit = defineEmits<{ (e: 'openAuth', redirect?: string): void }>()
|
||||
@@ -343,24 +344,35 @@ const continueWatching = ref<Array<{ content: Content; progress: number }>>([])
|
||||
const myListContent = ref<Content[]>([])
|
||||
const rentedContent = ref<Content[]>([])
|
||||
|
||||
const contentSourceStore = useContentSourceStore()
|
||||
let lastLoadedSource: string | null = null
|
||||
|
||||
/**
|
||||
* Populate library with dummy data from the film catalog.
|
||||
* Populate library with dummy data from the current content catalog.
|
||||
* Re-runs when the content source changes so My List reflects the active catalog.
|
||||
* In production this would come from the API.
|
||||
*/
|
||||
function loadDummyLibrary() {
|
||||
if (myListContent.value.length > 0) return // Already loaded
|
||||
const source = contentSourceStore.activeSource
|
||||
if (myListContent.value.length > 0 && lastLoadedSource === source) return
|
||||
lastLoadedSource = source
|
||||
|
||||
continueWatching.value = indeeHubFilms.slice(0, 3).map((film) => ({
|
||||
const rows = contentStore.contentRows
|
||||
const featured = rows.featured || []
|
||||
const btc = rows.bitcoin || []
|
||||
const docs = rows.documentaries || []
|
||||
|
||||
continueWatching.value = featured.slice(0, 3).map((film) => ({
|
||||
content: film,
|
||||
progress: Math.floor(Math.random() * 70) + 10,
|
||||
}))
|
||||
|
||||
myListContent.value = [
|
||||
...bitcoinFilms.slice(0, 3),
|
||||
...indeeHubFilms.slice(5, 8),
|
||||
...btc.slice(0, 3),
|
||||
...featured.slice(5, 8),
|
||||
]
|
||||
|
||||
rentedContent.value = documentaries.slice(0, 2)
|
||||
rentedContent.value = docs.slice(0, 2)
|
||||
}
|
||||
|
||||
// If someone navigates directly to /library without being logged in,
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Filmmaker Section (if applicable) -->
|
||||
<section v-if="user?.filmmaker" class="glass-card p-6">
|
||||
<section v-if="user?.filmmaker" class="glass-card p-6 mb-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">Filmmaker Dashboard</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
@@ -106,6 +106,42 @@
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Quick Actions (mirrors desktop profile dropdown) -->
|
||||
<section class="glass-card p-6 mb-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">Quick Actions</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- My Library -->
|
||||
<button @click="$router.push('/library')" class="profile-action-row">
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<span class="flex-1 text-white font-medium">My Library</span>
|
||||
<svg class="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Content Source Toggle -->
|
||||
<button @click="handleSourceToggle" class="profile-action-row">
|
||||
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
<span class="flex-1 text-white font-medium">Content Source</span>
|
||||
<span class="text-sm text-white/50">{{ contentSourceLabel }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Sign Out -->
|
||||
<div class="border-t border-white/10 my-1"></div>
|
||||
<button @click="handleSignOut" class="profile-action-row text-red-400">
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
<span class="flex-1 font-medium">Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -114,11 +150,34 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuth } from '../composables/useAuth'
|
||||
import { useAccounts } from '../composables/useAccounts'
|
||||
import { useContentSourceStore } from '../stores/contentSource'
|
||||
import { useContentStore } from '../stores/content'
|
||||
import { subscriptionService } from '../services/subscription.service'
|
||||
import type { ApiSubscription } from '../types/api'
|
||||
|
||||
const { user, linkNostr, unlinkNostr } = useAuth()
|
||||
const router = useRouter()
|
||||
const { user, linkNostr, unlinkNostr, logout: authLogout } = useAuth()
|
||||
const { logout: nostrLogout } = useAccounts()
|
||||
const contentSourceStore = useContentSourceStore()
|
||||
const contentStore = useContentStore()
|
||||
|
||||
const contentSourceLabel = computed(() =>
|
||||
contentSourceStore.activeSource === 'indeehub' ? 'IndeeHub Films' : 'TopDoc Films'
|
||||
)
|
||||
|
||||
function handleSourceToggle() {
|
||||
contentSourceStore.toggle()
|
||||
contentStore.fetchContent()
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
nostrLogout()
|
||||
await authLogout()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const subscription = ref<ApiSubscription | null>(null)
|
||||
|
||||
@@ -230,4 +289,22 @@ declare global {
|
||||
0 8px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.profile-action-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.profile-action-row:active {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user