From f19fd6feef81fb2fde0bcb8c1189acec6852b39d Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 12 Feb 2026 14:57:16 +0000 Subject: [PATCH] Enhance comment seeding and search functionality - Updated the `seedComments` function to return an array of published comment event IDs for tracking. - Introduced `seedCommentReactions` to seed upvotes and downvotes on comments, improving interaction visibility. - Enhanced the `App.vue` and `MobileNav.vue` components to support a mobile search overlay, allowing users to search films seamlessly. - Added a new `MobileSearch` component for better search experience on mobile devices. - Implemented a search feature in `AppHeader.vue` with dropdown results for improved content discovery. Co-authored-by: Cursor --- scripts/seed-activity.ts | 71 +++++- src/App.vue | 55 ++++- src/components/AppHeader.vue | 307 +++++++++++++++++++++++++- src/components/ContentDetailModal.vue | 36 ++- src/components/MobileNav.vue | 164 ++++++++++---- src/components/MobileSearch.vue | 248 +++++++++++++++++++++ src/composables/useNostr.ts | 56 ++++- src/stores/searchSelection.ts | 23 ++ src/views/Browse.vue | 10 + tsconfig.tsbuildinfo | 2 +- 10 files changed, 898 insertions(+), 74 deletions(-) create mode 100644 src/components/MobileSearch.vue create mode 100644 src/stores/searchSelection.ts diff --git a/scripts/seed-activity.ts b/scripts/seed-activity.ts index 246dd01..eef669f 100644 --- a/scripts/seed-activity.ts +++ b/scripts/seed-activity.ts @@ -344,9 +344,35 @@ function pickComment(itemId: string, sentiment: 'positive' | 'mixed' | 'negative } // ── seed comments (kind 1111) ─────────────────────────────────── -async function seedComments(relay: Relay) { +// Returns array of published comment event IDs for use by seedCommentReactions +async function seedComments(relay: Relay): Promise { console.log('\n💬 Seeding comments (kind 1111)...') let count = 0 + const commentEventIds: string[] = [] + + /** + * Helper that publishes a comment and tracks the event ID if successful. + */ + async function publishComment( + relay: Relay, + signer: PrivateKeySigner, + event: { kind: number; content: string; tags: string[][]; created_at: number }, + label: string, + ): Promise { + const signed = await signer.signEvent(event) + try { + const res = await relay.publish(signed, { timeout: 5000 }) + if (!res.ok) { + console.warn(` ⚠ ${label}: ${res.message}`) + } else { + commentEventIds.push(signed.id) + } + return true + } catch (err) { + console.error(` ✗ ${label}:`, err instanceof Error ? err.message : err) + return false + } + } // Top content: several comments for (const item of topContent) { @@ -359,7 +385,7 @@ async function seedComments(relay: Relay) { const content = pickComment(item.id, sentiment) const age = randomInt(1 * ONE_DAY, 30 * ONE_DAY) - const ok = await publishEvent(relay, signer, { + const ok = await publishComment(relay, signer, { kind: 1111, content, tags: [ @@ -386,7 +412,7 @@ async function seedComments(relay: Relay) { const content = pickComment(item.id, sentiment) const age = randomInt(3 * ONE_DAY, 45 * ONE_DAY) - const ok = await publishEvent(relay, signer, { + const ok = await publishComment(relay, signer, { kind: 1111, content, tags: [ @@ -411,7 +437,7 @@ async function seedComments(relay: Relay) { const content = pickComment(item.id, 'positive') const age = randomInt(0, 10 * ONE_DAY) - const ok = await publishEvent(relay, signer, { + const ok = await publishComment(relay, signer, { kind: 1111, content, tags: [ @@ -436,7 +462,7 @@ async function seedComments(relay: Relay) { const content = pickComment(item.id, 'positive') const age = randomInt(0, 3 * ONE_DAY) - const ok = await publishEvent(relay, signer, { + const ok = await publishComment(relay, signer, { kind: 1111, content, tags: [ @@ -472,7 +498,7 @@ async function seedComments(relay: Relay) { const content = pickComment(item.id, sentiment) const age = randomInt(2 * ONE_DAY, 40 * ONE_DAY) - const ok = await publishEvent(relay, signer, { + const ok = await publishComment(relay, signer, { kind: 1111, content, tags: [ @@ -488,6 +514,36 @@ async function seedComments(relay: Relay) { } console.log(` ✓ ${count} comments seeded`) + return commentEventIds +} + +// ── seed comment reactions (kind 7) ───────────────────────────── +// Seeds upvotes/downvotes on comment events so vote sorting is visible +async function seedCommentReactions(relay: Relay, commentEventIds: string[]) { + console.log('\n👍 Seeding comment reactions (kind 7)...') + let count = 0 + + for (const eventId of commentEventIds) { + const numVotes = randomInt(2, 8) + const voters = pick(allPersonas, numVotes) + + for (const persona of voters) { + const signer = PrivateKeySigner.fromKey(persona.nsec) + // ~80% upvote, ~20% downvote for realistic distribution + const emoji = Math.random() < 0.8 ? '+' : '-' + const age = randomInt(0, 14 * ONE_DAY) + + const ok = await publishEvent(relay, signer, { + kind: 7, + content: emoji, + tags: [['e', eventId], ['k', '1111']], + created_at: now - age, + }, `comment-reaction ${persona.name}->${eventId.slice(0, 8)}`) + if (ok) count++ + } + } + + console.log(` ✓ ${count} comment reactions seeded`) } // ── main ──────────────────────────────────────────────────────── @@ -497,7 +553,8 @@ async function main() { const relay = new Relay(RELAY_URL) await seedReactions(relay) - await seedComments(relay) + const commentEventIds = await seedComments(relay) + await seedCommentReactions(relay, commentEventIds) console.log('\n✅ Done! Activity seeded successfully.') setTimeout(() => process.exit(0), 1000) diff --git a/src/App.vue b/src/App.vue index c29073d..1e0423c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,13 +1,30 @@ @@ -631,4 +805,125 @@ onUnmounted(() => { .profile-menu-item:hover svg { opacity: 1; } + +/* ── Search ───────────────────────────────────────────────────────────────────── */ +.search-wrapper { + z-index: 60; +} + +.search-input-wrap { + position: relative; + width: 280px; + animation: searchExpand 0.25s ease-out; +} + +@keyframes searchExpand { + from { + width: 40px; + opacity: 0; + } + to { + width: 280px; + opacity: 1; + } +} + +.search-input { + width: 100%; + padding: 8px 36px 8px 34px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.08); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + color: rgba(255, 255, 255, 0.95); + font-size: 14px; + font-weight: 400; + outline: none; + transition: all 0.2s ease; + caret-color: rgba(255, 255, 255, 0.6); +} + +.search-input::placeholder { + color: rgba(255, 255, 255, 0.3); +} + +.search-input:focus { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.12); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.04); +} + +.search-results { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 380px; + max-height: 420px; + overflow-y: auto; + background: rgba(10, 10, 10, 0.85); + backdrop-filter: blur(60px); + -webkit-backdrop-filter: blur(60px); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: + 0 24px 80px rgba(0, 0, 0, 0.6), + 0 8px 24px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + z-index: 100; +} + +.search-results::-webkit-scrollbar { + width: 4px; +} + +.search-results::-webkit-scrollbar-track { + background: transparent; +} + +.search-results::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 2px; +} + +.search-result-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 16px; + border: none; + background: transparent; + cursor: pointer; + text-align: left; + transition: background 0.15s ease; +} + +.search-result-item:hover, +.search-result-highlighted { + background: rgba(255, 255, 255, 0.08); +} + +.search-result-highlighted { + background: rgba(255, 255, 255, 0.1); +} + +/* Search fade transition */ +.search-fade-enter-active { + transition: all 0.2s ease-out; +} + +.search-fade-leave-active { + transition: all 0.15s ease-in; +} + +.search-fade-enter-from { + opacity: 0; + transform: translateY(-8px) scale(0.97); +} + +.search-fade-leave-to { + opacity: 0; + transform: translateY(-4px) scale(0.98); +} diff --git a/src/components/ContentDetailModal.vue b/src/components/ContentDetailModal.vue index 16b688a..1024c53 100644 --- a/src/components/ContentDetailModal.vue +++ b/src/components/ContentDetailModal.vue @@ -2,6 +2,13 @@
+ + +
@@ -13,13 +20,6 @@ />
- - -

@@ -457,6 +457,28 @@ function openSubscriptionFromRental() { border-radius: 3px; } +/* Close Button (always visible, above scroll) */ +.detail-close-btn { + position: absolute; + top: 16px; + right: 16px; + z-index: 20; + padding: 8px; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-radius: 9999px; + color: rgba(255, 255, 255, 0.8); + border: none; + cursor: pointer; + transition: all 0.2s ease; +} + +.detail-close-btn:hover { + color: white; + background: rgba(0, 0, 0, 0.7); +} + /* Hero */ .detail-hero { position: relative; diff --git a/src/components/MobileNav.vue b/src/components/MobileNav.vue index 264ad56..fc42f0e 100644 --- a/src/components/MobileNav.vue +++ b/src/components/MobileNav.vue @@ -34,56 +34,82 @@ @@ -95,7 +121,19 @@ import { useContentDiscovery, type AlgorithmId } from '../composables/useContent import { useAuth } from '../composables/useAuth' import { useAccounts } from '../composables/useAccounts' -const emit = defineEmits<{ (e: 'openAuth', redirect?: string): void }>() +interface Props { + searchActive?: boolean +} + +withDefaults(defineProps(), { + searchActive: false, +}) + +const emit = defineEmits<{ + (e: 'openAuth', redirect?: string): void + (e: 'openSearch'): void + (e: 'closeSearch'): void +}>() const router = useRouter() const route = useRoute() @@ -230,6 +268,36 @@ function selectAlgorithm(algo: AlgorithmId) { pointer-events: none; } +/* Both transition states share the same fixed height so the bar never resizes */ +.nav-swap-container { + min-height: 60px; + width: 100%; +} + +/* Close icon — match normal tab size, no label */ +.nav-close-icon { + max-width: 80px; +} + +/* Tab swap transition (out-in: old fades/scales out, then new fades/scales in) */ +.nav-swap-enter-active { + transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); /* slight overshoot */ +} + +.nav-swap-leave-active { + transition: all 0.15s ease-in; +} + +.nav-swap-enter-from { + opacity: 0; + transform: scale(0.85); +} + +.nav-swap-leave-to { + opacity: 0; + transform: scale(0.85); +} + /* --- Filter Bottom Sheet --- */ .filter-sheet-backdrop { position: fixed; diff --git a/src/components/MobileSearch.vue b/src/components/MobileSearch.vue new file mode 100644 index 0000000..7108faa --- /dev/null +++ b/src/components/MobileSearch.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/src/composables/useNostr.ts b/src/composables/useNostr.ts index 076eb4e..7668311 100644 --- a/src/composables/useNostr.ts +++ b/src/composables/useNostr.ts @@ -31,14 +31,41 @@ export interface CommentNode { replies: CommentNode[] } +/** + * Compute the net vote score (positive - negative) for a comment event + * from a reactions map. + */ +function getNetVotes(eventId: string, reactionsMap: Map): number { + const reactions = reactionsMap.get(eventId) || [] + // Deduplicate: one vote per pubkey, keep latest + const byPubkey = new Map() + for (const r of reactions) { + const existing = byPubkey.get(r.pubkey) + if (!existing || r.created_at > existing.created_at) { + byPubkey.set(r.pubkey, r) + } + } + let net = 0 + for (const r of byPubkey.values()) { + if (r.content === '+') net++ + else if (r.content === '-') net-- + } + return net +} + /** * Build a threaded comment tree from flat event arrays. * Top-level comments have #i = externalId. * Replies reference parent via #e tag. + * + * Top-level comments are sorted by net votes (most upvoted first), + * with created_at descending as tiebreaker. + * Replies are sorted chronologically (oldest first). */ function buildCommentTree( topLevel: NostrEvent[], allInThread: NostrEvent[], + reactionsMap: Map, ): CommentNode[] { // Group replies by parent event ID const childrenMap = new Map() @@ -66,8 +93,13 @@ function buildCommentTree( } } + // Sort top-level by net votes descending, then newest first as tiebreaker return [...topLevel] - .sort((a, b) => b.created_at - a.created_at) + .sort((a, b) => { + const voteDiff = getNetVotes(b.id, reactionsMap) - getNetVotes(a.id, reactionsMap) + if (voteDiff !== 0) return voteDiff + return b.created_at - a.created_at + }) .map(buildNode) } @@ -171,7 +203,7 @@ export function useNostr(contentId?: string) { } allComments.value = allEvents - commentTree.value = buildCommentTree(topLevelEvents, allEvents) + commentTree.value = buildCommentTree(topLevelEvents, allEvents, commentReactions.value) // Fetch profiles for all comment authors const pubkeys = [...new Set(allEvents.map((e) => e.pubkey))] @@ -218,6 +250,7 @@ export function useNostr(contentId?: string) { /** * Refresh reactions for a single comment event from the store. + * Also re-sorts the comment tree so most-voted comments float to top. */ function refreshSingleCommentReactions(eventId: string) { const events = eventStore.getByFilters([ @@ -227,6 +260,25 @@ export function useNostr(contentId?: string) { const newMap = new Map(commentReactions.value) newMap.set(eventId, [...events]) commentReactions.value = newMap + + // Re-sort the comment tree with updated vote counts + if (currentExternalId) { + const topLevel = eventStore.getByFilters([ + { kinds: [COMMENT_KIND], '#i': [currentExternalId] }, + ]) + const allInThread = eventStore.getByFilters([ + { kinds: [COMMENT_KIND], '#I': [currentExternalId] }, + ]) + if (topLevel && allInThread) { + const topLevelEvents = [...topLevel] + const allEvents = [...allInThread] + const allIds = new Set(allEvents.map((e) => e.id)) + for (const e of topLevelEvents) { + if (!allIds.has(e.id)) allEvents.push(e) + } + commentTree.value = buildCommentTree(topLevelEvents, allEvents, newMap) + } + } } } diff --git a/src/stores/searchSelection.ts b/src/stores/searchSelection.ts new file mode 100644 index 0000000..0e5642c --- /dev/null +++ b/src/stores/searchSelection.ts @@ -0,0 +1,23 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { Content } from '../types/content' + +/** + * Lightweight store to bridge search result selection + * from the header/mobile search to Browse.vue's detail modal. + */ +export const useSearchSelectionStore = defineStore('searchSelection', () => { + const pendingContent = ref(null) + + function select(content: Content) { + pendingContent.value = content + } + + function consume(): Content | null { + const content = pendingContent.value + pendingContent.value = null + return content + } + + return { pendingContent, select, consume } +}) diff --git a/src/views/Browse.vue b/src/views/Browse.vue index 7e0a2d2..e08c17b 100644 --- a/src/views/Browse.vue +++ b/src/views/Browse.vue @@ -279,6 +279,7 @@ import { useAccounts } from '../composables/useAccounts' import { useContentDiscovery } from '../composables/useContentDiscovery' // indeeHubFilms data is now loaded dynamically via the content store import { useContentSourceStore } from '../stores/contentSource' +import { useSearchSelectionStore } from '../stores/searchSelection' import type { Content } from '../types/content' const emit = defineEmits<{ (e: 'openAuth', redirect?: string): void }>() @@ -289,6 +290,7 @@ const contentStore = useContentStore() const { isAuthenticated, hasActiveSubscription } = useAuth() const { isLoggedIn: isNostrLoggedIn } = useAccounts() const { sortContent, isFilterActive, activeAlgorithmLabel } = useContentDiscovery() +const searchSelection = useSearchSelectionStore() // Determine active tab from route path const isMyListTab = computed(() => route.path === '/library') @@ -427,6 +429,14 @@ function handleSubscriptionSuccess() { onMounted(() => { contentStore.fetchContent() }) + +// Watch for search selection from the global search bar +watch(() => searchSelection.pendingContent, (content) => { + if (content) { + selectedContent.value = searchSelection.consume() + showDetailModal.value = true + } +})