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:
Dorian
2026-02-12 14:24:52 +00:00
parent ab0560de00
commit 35bc78b890
38 changed files with 1107 additions and 185 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -21,7 +21,7 @@ const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:7777'
const ORIGIN = process.env.ORIGIN || 'http://localhost:5174'
// ── Content catalog (matching src/data/indeeHubFilms.ts) ──────────
const CONTENT = [
const INDEEHUB_CONTENT = [
{ id: 'god-bless-bitcoin', title: 'God Bless Bitcoin' },
{ id: 'thethingswecarry', title: 'The Things We Carry' },
{ id: 'duel', title: 'Duel' },
@@ -46,6 +46,36 @@ const CONTENT = [
{ id: '34f042bd-23d6-40f4-9707-4b3bb62fdd58', title: 'Little Billy' },
]
// ── TopDocumentaryFilms catalog (matching src/data/topDocFilms.ts) ──
const TOPDOC_CONTENT = [
{ id: 'tdf-god-bless-bitcoin', title: 'God Bless Bitcoin' },
{ id: 'tdf-bitcoin-end-of-money', title: 'Bitcoin: The End of Money as We Know It' },
{ id: 'tdf-bitcoin-beyond-bubble', title: 'Bitcoin: Beyond the Bubble' },
{ id: 'tdf-bitcoin-gospel', title: 'The Bitcoin Gospel' },
{ id: 'tdf-bitcoin-psyop', title: 'The Bitcoin Psyop' },
{ id: 'tdf-missing-cryptoqueen', title: 'The Missing Cryptoqueen' },
{ id: 'tdf-billion-dollar-scam', title: 'The Billion Dollar Scam' },
{ id: 'tdf-money-banking-fed', title: 'Money, Banking, and The Federal Reserve' },
{ id: 'tdf-american-dream', title: 'The American Dream' },
{ id: 'tdf-century-enslavement', title: 'Century of Enslavement' },
{ id: 'tdf-money-power-wall-street', title: 'Money, Power and Wall Street' },
{ id: 'tdf-gold-6000-year', title: "Gold: Man's 6000 Year Obsession" },
{ id: 'tdf-debtasized', title: 'Debtasized' },
{ id: 'tdf-crash-next-crisis', title: 'Crash: Are We Ready?' },
{ id: 'tdf-pension-gamble', title: 'The Pension Gamble' },
{ id: 'tdf-why-americans-poor', title: 'Why Americans Feel So Poor?' },
{ id: 'tdf-chain-reaction', title: 'Chain Reaction' },
{ id: 'tdf-usa-on-brink', title: 'USA on the Brink' },
{ id: 'tdf-big-four', title: 'The Big Four' },
{ id: 'tdf-congo-millionaires', title: 'Congo: Millionaires of Chaos' },
{ id: 'tdf-economics-of', title: 'The Economics Of' },
{ id: 'tdf-big-business-food', title: 'Big Business: Food Empires' },
{ id: 'tdf-so-long-superstores', title: 'So Long, Superstores?' },
]
// Combined catalog — seeder covers both sources
const CONTENT = [...INDEEHUB_CONTENT, ...TOPDOC_CONTENT]
// ── helpers ──────────────────────────────────────────────────────
type Persona = { name: string; nsec: string; pubkey: string }
@@ -74,10 +104,23 @@ const ONE_DAY = 86400
const ONE_WEEK = 7 * ONE_DAY
// Content subsets for different activity patterns
const topContent = CONTENT.slice(0, 8)
const midContent = CONTENT.slice(8, 16)
const trendingContent = pick(CONTENT.slice(0, 12), 5)
const tastemakerFaves = pick(CONTENT.slice(0, 10), 6)
// Draw from BOTH catalogs to ensure TopDoc films get activity
const topContent = [
...INDEEHUB_CONTENT.slice(0, 5),
...TOPDOC_CONTENT.slice(0, 5),
]
const midContent = [
...INDEEHUB_CONTENT.slice(5, 11),
...TOPDOC_CONTENT.slice(5, 11),
]
const trendingContent = [
...pick(INDEEHUB_CONTENT.slice(0, 8), 3),
...pick(TOPDOC_CONTENT.slice(0, 8), 3),
]
const tastemakerFaves = [
...pick(INDEEHUB_CONTENT.slice(0, 8), 3),
...pick(TOPDOC_CONTENT.slice(0, 8), 3),
]
// ── sample comments ─────────────────────────────────────────────
const POSITIVE_COMMENTS = [
@@ -93,6 +136,12 @@ const POSITIVE_COMMENTS = [
'Rewatched it last night — still holds up beautifully.',
'Such an important documentary. Everyone should see this.',
'The storytelling here is on another level.',
'Required viewing. This opened my eyes to things I never considered.',
'The depth of research in this is incredible. Really well done.',
'Finally a documentary that treats the subject seriously.',
'Shared this with my whole family. Everyone needs to see this.',
'This is the kind of content that changes how you see the world.',
'Brilliant piece of journalism. Respect to the filmmakers.',
]
const MIXED_COMMENTS = [
@@ -104,12 +153,44 @@ const MIXED_COMMENTS = [
'Some great moments, but also some really slow stretches.',
'I can see why people love it, just not my cup of tea.',
'Better than I expected, worse than the reviews suggest.',
'Interesting topic but the production quality could be better.',
'They barely scratched the surface on this topic. Wanted more depth.',
'Decent intro to the topic but experts won\'t learn anything new.',
]
const NEGATIVE_COMMENTS = [
'I really don\'t understand the hype around this one.',
'Couldn\'t finish it. Way too slow for my taste.',
'Overrated. There are much better films in this genre.',
'Felt more like a sales pitch than a documentary.',
]
// Documentary-specific comments for TopDoc films
const DOC_POSITIVE_COMMENTS = [
'One of the best Bitcoin documentaries out there. Really explains the fundamentals.',
'Everyone who thinks they understand money should watch this.',
'This completely changed how I think about the financial system.',
'Incredible deep dive into a topic most people don\'t understand.',
'The interviews in this are absolutely fascinating.',
'Been orange-pilled for years but this doc still taught me new things.',
'Sent this to my dad and now he finally gets it.',
'The production quality for a documentary like this is outstanding.',
'This should be required viewing in economics classes.',
'The parallels they draw to historical events are eye-opening.',
'Watched this with my skeptical friends. They were impressed.',
'Finally a balanced take on cryptocurrency. Well researched.',
'This doc captures the movement perfectly. A time capsule for future generations.',
'The personal stories in this really humanize what can feel like a dry topic.',
'Phenomenal. I\'ve recommended this to at least 20 people.',
]
const DOC_MIXED_COMMENTS = [
'Good overview but a bit surface-level for people already in the space.',
'Some parts felt dated already given how fast things move in crypto.',
'Wish they had interviewed more diverse perspectives.',
'Interesting but could have been 30 minutes shorter.',
'The first half is excellent, second half loses steam.',
'Fair attempt but misses some key nuances about the technology.',
]
// ── publishing helper ───────────────────────────────────────────
@@ -213,9 +294,55 @@ async function seedReactions(relay: Relay) {
}
}
// ── Ensure EVERY TopDoc film has at least some reactions ───────
const alreadyReacted = new Set([
...topContent.map(c => c.id),
...midContent.map(c => c.id),
...trendingContent.map(c => c.id),
...tastemakerFaves.map(c => c.id),
])
const unreactedTopDoc = TOPDOC_CONTENT.filter(c => !alreadyReacted.has(c.id))
for (const item of unreactedTopDoc) {
const voters = pick(allPersonas, randomInt(3, 7))
for (const persona of voters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const emoji = Math.random() < 0.75 ? '+' : '-'
const age = randomInt(1 * ONE_DAY, 35 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 17,
content: emoji,
tags: [
['i', contentUrl(item.id)],
['k', 'web'],
],
created_at: now - age,
}, `catch-all-reaction ${persona.name}->${item.title}`)
if (ok) count++
}
}
console.log(`${count} reactions seeded`)
}
// ── comment picker (uses doc-specific comments for TopDoc films) ─
function pickComment(itemId: string, sentiment: 'positive' | 'mixed' | 'negative'): string {
const isTopDoc = itemId.startsWith('tdf-')
if (sentiment === 'positive') {
const pool = isTopDoc
? [...DOC_POSITIVE_COMMENTS, ...POSITIVE_COMMENTS]
: POSITIVE_COMMENTS
return pool[randomInt(0, pool.length - 1)]
}
if (sentiment === 'mixed') {
const pool = isTopDoc
? [...DOC_MIXED_COMMENTS, ...MIXED_COMMENTS]
: MIXED_COMMENTS
return pool[randomInt(0, pool.length - 1)]
}
return NEGATIVE_COMMENTS[randomInt(0, NEGATIVE_COMMENTS.length - 1)]
}
// ── seed comments (kind 1111) ───────────────────────────────────
async function seedComments(relay: Relay) {
console.log('\n💬 Seeding comments (kind 1111)...')
@@ -224,13 +351,12 @@ async function seedComments(relay: Relay) {
// Top content: several comments
for (const item of topContent) {
const url = contentUrl(item.id)
const commenters = pick(allPersonas, randomInt(2, 5))
const commenters = pick(allPersonas, randomInt(3, 6))
for (const persona of commenters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const comments =
Math.random() < 0.7 ? POSITIVE_COMMENTS : MIXED_COMMENTS
const content = comments[randomInt(0, comments.length - 1)]
const sentiment = Math.random() < 0.7 ? 'positive' : 'mixed'
const content = pickComment(item.id, sentiment)
const age = randomInt(1 * ONE_DAY, 30 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
@@ -249,14 +375,15 @@ async function seedComments(relay: Relay) {
}
// Mid content: occasional comments
for (const item of pick(midContent, 4)) {
for (const item of pick(midContent, 8)) {
const url = contentUrl(item.id)
const commenters = pick(allPersonas, randomInt(1, 2))
const commenters = pick(allPersonas, randomInt(1, 3))
for (const persona of commenters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const pool = [...MIXED_COMMENTS, ...NEGATIVE_COMMENTS]
const content = pool[randomInt(0, pool.length - 1)]
const r = Math.random()
const sentiment = r < 0.4 ? 'positive' : r < 0.8 ? 'mixed' : 'negative'
const content = pickComment(item.id, sentiment)
const age = randomInt(3 * ONE_DAY, 45 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
@@ -275,13 +402,13 @@ async function seedComments(relay: Relay) {
}
// Tastemaker reviews on their faves
for (const item of tastemakerFaves.slice(0, 4)) {
for (const item of tastemakerFaves) {
const url = contentUrl(item.id)
const reviewers = pick(tastemakers, randomInt(1, 3))
for (const persona of reviewers) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const content = POSITIVE_COMMENTS[randomInt(0, POSITIVE_COMMENTS.length - 1)]
const content = pickComment(item.id, 'positive')
const age = randomInt(0, 10 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
@@ -300,13 +427,13 @@ async function seedComments(relay: Relay) {
}
// Trending content: recent comments
for (const item of trendingContent.slice(0, 3)) {
for (const item of trendingContent) {
const url = contentUrl(item.id)
const commenters = pick(allPersonas, randomInt(2, 4))
for (const persona of commenters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const content = POSITIVE_COMMENTS[randomInt(0, POSITIVE_COMMENTS.length - 1)]
const content = pickComment(item.id, 'positive')
const age = randomInt(0, 3 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
@@ -324,6 +451,42 @@ async function seedComments(relay: Relay) {
}
}
// ── Ensure EVERY TopDoc film has at least some comments ────────
// This catches any TopDoc films not already in top/mid/trending subsets
const alreadyCommented = new Set([
...topContent.map(c => c.id),
...midContent.map(c => c.id),
...trendingContent.map(c => c.id),
...tastemakerFaves.map(c => c.id),
])
const uncommentedTopDoc = TOPDOC_CONTENT.filter(c => !alreadyCommented.has(c.id))
for (const item of uncommentedTopDoc) {
const url = contentUrl(item.id)
const commenters = pick(allPersonas, randomInt(2, 4))
for (const persona of commenters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const r = Math.random()
const sentiment = r < 0.6 ? 'positive' : r < 0.9 ? 'mixed' : 'negative'
const content = pickComment(item.id, sentiment)
const age = randomInt(2 * ONE_DAY, 40 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 1111,
content,
tags: [
['I', url],
['K', 'web'],
['i', url],
['k', 'web'],
],
created_at: now - age,
}, `catch-all-comment ${persona.name}->${item.title}`)
if (ok) count++
}
}
console.log(`${count} comments seeded`)
}

View File

@@ -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)

View File

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

View File

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

View File

@@ -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
}
}
}

View File

@@ -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 --- */

View File

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

View File

@@ -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,

View File

@@ -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
View 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')

View File

@@ -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(

View File

@@ -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
*/

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

View File

@@ -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,

View File

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