Update package dependencies and enhance application structure

- Added several new dependencies related to the Applesauce library, including 'applesauce-accounts', 'applesauce-common', 'applesauce-core', 'applesauce-loaders', 'applesauce-relay', and 'applesauce-signers', all at version 5.1.0.
- Updated the development script in package.json to specify a port for Vite and added new seed scripts for profiles and activity.
- Removed outdated image files from the public directory to clean up unused assets.
- Enhanced the App.vue structure by integrating shared components like AppHeader and AuthModal for improved user experience.
- Refactored ContentDetailModal and MobileNav components to support new features and improve usability.

These changes improve the overall functionality and maintainability of the application while ensuring it utilizes the latest libraries for better performance.
This commit is contained in:
Dorian
2026-02-12 12:24:58 +00:00
parent c970f5b29f
commit 725896673c
42 changed files with 3767 additions and 1329 deletions

View File

@@ -0,0 +1,292 @@
import { ref, computed } from 'vue'
import { COMMENT_KIND } from 'applesauce-common/helpers'
import { mapEventsToStore } from 'applesauce-core'
import { onlyEvents } from 'applesauce-relay'
import type { NostrEvent } from 'applesauce-core/helpers/event'
import type { Subscription } from 'rxjs'
import { eventStore, pool, APP_RELAYS } from '../lib/nostr'
import { TASTEMAKER_PUBKEYS } from '../data/testPersonas'
import { getExternalContentId } from './useNostr'
import type { Content } from '../types/content'
// --- Algorithm Definitions ---
export const ALGORITHMS = [
{ id: 'popularity', label: 'Popularity' },
{ id: 'trending', label: 'Trending' },
{ id: 'most-zapped', label: 'Most Zapped' },
{ id: 'most-reviews', label: 'Most Reviews' },
{ id: 'tastemaker', label: 'Tastemaker' },
] as const
export type AlgorithmId = (typeof ALGORITHMS)[number]['id'] | null
export interface ContentStats {
plusCount: number
minusCount: number
commentCount: number
reviewCount: number
recentEvents: NostrEvent[]
}
const SEVEN_DAYS = 7 * 24 * 60 * 60
// --- Shared Module State (singleton) ---
const activeAlgorithm = ref<AlgorithmId>(null)
const contentStatsMap = ref<Map<string, ContentStats>>(new Map())
const isDiscoveryLoading = ref(false)
let subscriptionRefs: Subscription[] = []
let initialized = false
function getTagValue(event: NostrEvent, tagName: string): string | undefined {
return event.tags.find((t) => t[0] === tagName)?.[1]
}
/**
* Rebuild the stats map from current EventStore data
*/
function rebuildStats() {
const map = new Map<string, ContentStats>()
const getOrCreate = (id: string): ContentStats => {
let stats = map.get(id)
if (!stats) {
stats = {
plusCount: 0,
minusCount: 0,
commentCount: 0,
reviewCount: 0,
recentEvents: [],
}
map.set(id, stats)
}
return stats
}
// Process reactions from the EventStore
const reactions = eventStore.getByFilters([{ kinds: [17], '#k': ['web'] }])
if (reactions) {
for (const event of reactions) {
const externalId = getTagValue(event, 'i')
if (!externalId) continue
const stats = getOrCreate(externalId)
if (event.content === '+') stats.plusCount++
else if (event.content === '-') stats.minusCount++
stats.recentEvents.push(event)
}
}
// Process comments from the EventStore
const comments = eventStore.getByFilters([
{ kinds: [COMMENT_KIND], '#K': ['web'] },
])
if (comments) {
for (const event of comments) {
const externalId = getTagValue(event, 'I')
if (!externalId) continue
const stats = getOrCreate(externalId)
stats.commentCount++
// Top-level comments also have #i (direct ref to external content)
if (getTagValue(event, 'i')) stats.reviewCount++
stats.recentEvents.push(event)
}
}
contentStatsMap.value = map
}
/**
* Start relay subscriptions for global discovery data.
* Only runs once; subsequent calls are no-ops.
*/
function initSubscriptions() {
if (initialized) return
initialized = true
isDiscoveryLoading.value = true
const currentRelays = APP_RELAYS
// Subscribe to all web reactions (kind 17)
const reactionSub = pool
.subscription(currentRelays, [{ kinds: [17], '#k': ['web'] }])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => rebuildStats(),
error: (err) => console.error('Discovery reaction subscription error:', err),
})
subscriptionRefs.push(reactionSub)
// Subscribe to all web comments (kind 1111)
const commentSub = pool
.subscription(currentRelays, [
{ kinds: [COMMENT_KIND], '#K': ['web'] },
])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => rebuildStats(),
error: (err) => console.error('Discovery comment subscription error:', err),
})
subscriptionRefs.push(commentSub)
// Initial build from any already-cached events
rebuildStats()
// Mark loading done after initial data window
setTimeout(() => {
isDiscoveryLoading.value = false
}, 2000)
}
// --- Public Composable ---
const EMPTY_STATS: ContentStats = {
plusCount: 0,
minusCount: 0,
commentCount: 0,
reviewCount: 0,
recentEvents: [],
}
/**
* Composable providing discovery algorithm state and content sorting.
*
* Shared state: `activeAlgorithm` and `contentStatsMap` are singletons
* so that the header, mobile nav, and browse view all see the same state.
*/
export function useContentDiscovery() {
// Ensure relay subscriptions are running
initSubscriptions()
/**
* Sort an array of Content items by the active algorithm.
* Returns content as-is when no filter is active (null).
*/
const sortContent = computed(() => {
return (contents: Content[], overrideAlgo?: ActiveAlgorithmId): Content[] => {
if (!contents || contents.length === 0) return contents
const effectiveAlgo: ActiveAlgorithmId | null = overrideAlgo ?? activeAlgorithm.value
if (!effectiveAlgo) return contents
const statsMap = contentStatsMap.value
const origin = window.location.origin
// Build entries array: [Content, stats] for each content item
const withStats: [Content, ContentStats][] = contents.map((c) => {
const externalId = `${origin}/content/${c.id}`
return [c, statsMap.get(externalId) || EMPTY_STATS]
})
// Sort entries
const sortedEntries = sortContentEntries(withStats, effectiveAlgo)
return sortedEntries.map(([content]) => content)
}
})
/**
* The label for the currently active algorithm, or null if none.
*/
const activeAlgorithmLabel = computed(() => {
if (!activeAlgorithm.value) return null
return ALGORITHMS.find((a) => a.id === activeAlgorithm.value)?.label ?? null
})
/**
* Whether a filter is currently active
*/
const isFilterActive = computed(() => activeAlgorithm.value !== null)
/**
* Set algorithm. Clicking the same algorithm again toggles it off.
*/
function setAlgorithm(algo: AlgorithmId) {
if (activeAlgorithm.value === algo) {
activeAlgorithm.value = null
} else {
activeAlgorithm.value = algo
}
}
/**
* Get stats for a specific content item
*/
function getStats(contentId: string): ContentStats {
const externalId = getExternalContentId(contentId)
return contentStatsMap.value.get(externalId) || EMPTY_STATS
}
return {
// State
activeAlgorithm,
activeAlgorithmLabel,
isFilterActive,
algorithms: ALGORITHMS,
contentStatsMap,
isDiscoveryLoading,
// Methods
sortContent,
setAlgorithm,
getStats,
}
}
// --- Internal sorting helper for Content tuples ---
type ActiveAlgorithmId = Exclude<AlgorithmId, null>
function sortContentEntries(
entries: [Content, ContentStats][],
algo: ActiveAlgorithmId,
): [Content, ContentStats][] {
const now = Math.floor(Date.now() / 1000)
switch (algo) {
case 'popularity':
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
(b[1].plusCount - b[1].minusCount + b[1].commentCount) -
(a[1].plusCount - a[1].minusCount + a[1].commentCount),
)
case 'trending': {
const trendScore = (stats: ContentStats) => {
let score = 0
for (const e of stats.recentEvents) {
score += e.created_at > now - SEVEN_DAYS ? 2 : 1
}
return score
}
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
trendScore(b[1]) - trendScore(a[1]),
)
}
case 'most-zapped':
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
b[1].plusCount - a[1].plusCount,
)
case 'most-reviews':
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
b[1].reviewCount - a[1].reviewCount,
)
case 'tastemaker': {
if (TASTEMAKER_PUBKEYS.length === 0) {
return sortContentEntries(entries, 'popularity')
}
const filtered = entries.filter(([, stats]) =>
stats.recentEvents.some((e) => TASTEMAKER_PUBKEYS.includes(e.pubkey)),
)
return sortContentEntries(
filtered.length > 0 ? filtered : entries,
'popularity',
)
}
}
}