Compare commits
5 commits
74ce584eff
...
b84b23b9dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b84b23b9dc | ||
|
|
a0c9a45470 | ||
|
|
e39cb7acd7 | ||
|
|
1036a56cf5 | ||
|
|
92089d63b5 |
11 changed files with 81 additions and 1002 deletions
|
|
@ -63,12 +63,6 @@ export async function createAppInstance() {
|
||||||
component: () => import('./pages/Login.vue'),
|
component: () => import('./pages/Login.vue'),
|
||||||
meta: { requiresAuth: false }
|
meta: { requiresAuth: false }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/link-aggregator-test',
|
|
||||||
name: 'link-aggregator-test',
|
|
||||||
component: () => import('./pages/LinkAggregatorTest.vue'),
|
|
||||||
meta: { requiresAuth: false }
|
|
||||||
},
|
|
||||||
// Pre-register module routes
|
// Pre-register module routes
|
||||||
...moduleRoutes
|
...moduleRoutes
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@
|
||||||
|
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { ChevronUp, ChevronDown, Reply, Flag, MoreHorizontal, Send, X } from 'lucide-vue-next'
|
import { ChevronUp, ChevronDown, Reply, Flag, MoreHorizontal, Send } from 'lucide-vue-next'
|
||||||
import VoteControls from './VoteControls.vue'
|
|
||||||
import type { SubmissionComment as CommentType } from '../types/submission'
|
import type { SubmissionComment as CommentType } from '../types/submission'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
* Displays complete submission content and threaded comments
|
* Displays complete submission content and threaded comments
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
|
@ -17,9 +17,6 @@ import {
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronUp,
|
|
||||||
ChevronDown,
|
|
||||||
Reply,
|
|
||||||
Send
|
Send
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
@ -31,7 +28,6 @@ import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { ProfileService } from '../services/ProfileService'
|
import type { ProfileService } from '../services/ProfileService'
|
||||||
import type { SubmissionService } from '../services/SubmissionService'
|
import type { SubmissionService } from '../services/SubmissionService'
|
||||||
import type {
|
import type {
|
||||||
SubmissionWithMeta,
|
|
||||||
SubmissionComment as SubmissionCommentType,
|
SubmissionComment as SubmissionCommentType,
|
||||||
LinkSubmission,
|
LinkSubmission,
|
||||||
MediaSubmission,
|
MediaSubmission,
|
||||||
|
|
@ -85,9 +81,7 @@ const currentUserPubkey = computed(() => authService?.user?.value?.pubkey || nul
|
||||||
// Get display name for a pubkey
|
// Get display name for a pubkey
|
||||||
function getDisplayName(pubkey: string): string {
|
function getDisplayName(pubkey: string): string {
|
||||||
if (profileService) {
|
if (profileService) {
|
||||||
const profile = profileService.getProfile(pubkey)
|
return profileService.getDisplayName(pubkey)
|
||||||
if (profile?.display_name) return profile.display_name
|
|
||||||
if (profile?.name) return profile.name
|
|
||||||
}
|
}
|
||||||
return `${pubkey.slice(0, 8)}...`
|
return `${pubkey.slice(0, 8)}...`
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +197,7 @@ async function submitComment() {
|
||||||
await submissionService.createComment(
|
await submissionService.createComment(
|
||||||
props.submissionId,
|
props.submissionId,
|
||||||
commentText.value.trim(),
|
commentText.value.trim(),
|
||||||
null // Top-level comment
|
undefined // Top-level comment
|
||||||
)
|
)
|
||||||
cancelReply()
|
cancelReply()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -247,19 +241,37 @@ function toggleCollapse(commentId: string) {
|
||||||
collapsedComments.value = new Set(collapsedComments.value)
|
collapsedComments.value = new Set(collapsedComments.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count total replies
|
|
||||||
function countReplies(comment: SubmissionCommentType): number {
|
|
||||||
let count = comment.replies?.length || 0
|
|
||||||
comment.replies?.forEach(reply => {
|
|
||||||
count += countReplies(reply)
|
|
||||||
})
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go back
|
// Go back
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.back()
|
router.back()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to collect all pubkeys from comments recursively
|
||||||
|
function collectCommentPubkeys(comments: SubmissionCommentType[]): string[] {
|
||||||
|
const pubkeys: string[] = []
|
||||||
|
for (const comment of comments) {
|
||||||
|
pubkeys.push(comment.pubkey)
|
||||||
|
if (comment.replies?.length) {
|
||||||
|
pubkeys.push(...collectCommentPubkeys(comment.replies))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pubkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch profiles when submission loads
|
||||||
|
watch(submission, (sub) => {
|
||||||
|
if (profileService && sub) {
|
||||||
|
profileService.fetchProfiles([sub.pubkey])
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Fetch profiles when comments load
|
||||||
|
watch(comments, (newComments) => {
|
||||||
|
if (profileService && newComments.length > 0) {
|
||||||
|
const pubkeys = [...new Set(collectCommentPubkeys(newComments))]
|
||||||
|
profileService.fetchProfiles(pubkeys)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Includes sort tabs, submission rows, and loading states
|
* Includes sort tabs, submission rows, and loading states
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import { Loader2 } from 'lucide-vue-next'
|
import { Loader2 } from 'lucide-vue-next'
|
||||||
import SortTabs from './SortTabs.vue'
|
import SortTabs from './SortTabs.vue'
|
||||||
import SubmissionRow from './SubmissionRow.vue'
|
import SubmissionRow from './SubmissionRow.vue'
|
||||||
|
|
@ -76,9 +76,7 @@ const isAuthenticated = computed(() => authService?.isAuthenticated?.value || fa
|
||||||
// Get display name for a pubkey
|
// Get display name for a pubkey
|
||||||
function getDisplayName(pubkey: string): string {
|
function getDisplayName(pubkey: string): string {
|
||||||
if (profileService) {
|
if (profileService) {
|
||||||
const profile = profileService.getProfile(pubkey)
|
return profileService.getDisplayName(pubkey)
|
||||||
if (profile?.display_name) return profile.display_name
|
|
||||||
if (profile?.name) return profile.name
|
|
||||||
}
|
}
|
||||||
// Fallback to truncated pubkey
|
// Fallback to truncated pubkey
|
||||||
return `${pubkey.slice(0, 8)}...`
|
return `${pubkey.slice(0, 8)}...`
|
||||||
|
|
@ -143,6 +141,14 @@ function onReport(submission: SubmissionWithMeta) {
|
||||||
console.log('Report:', submission.id)
|
console.log('Report:', submission.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch profiles when submissions change
|
||||||
|
watch(submissions, (newSubmissions) => {
|
||||||
|
if (profileService && newSubmissions.length > 0) {
|
||||||
|
const pubkeys = [...new Set(newSubmissions.map(s => s.pubkey))]
|
||||||
|
profileService.fetchProfiles(pubkeys)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
// Subscribe when community changes
|
// Subscribe when community changes
|
||||||
watch(() => props.community, () => {
|
watch(() => props.community, () => {
|
||||||
subscribe({
|
subscribe({
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { Link, FileText, Image, Film, MessageSquare, ExternalLink } from 'lucide-vue-next'
|
import { FileText, Image, ExternalLink } from 'lucide-vue-next'
|
||||||
import type { SubmissionType } from '../types/submission'
|
import type { SubmissionType } from '../types/submission'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Provides reactive access to the SubmissionService for Reddit-style submissions.
|
* Provides reactive access to the SubmissionService for Reddit-style submissions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
|
import { computed, ref, onMounted, onUnmounted, watch, type Ref, type ComputedRef } from 'vue'
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import type { SubmissionService } from '../services/SubmissionService'
|
import type { SubmissionService } from '../services/SubmissionService'
|
||||||
import type { LinkPreviewService } from '../services/LinkPreviewService'
|
import type { LinkPreviewService } from '../services/LinkPreviewService'
|
||||||
|
|
@ -31,13 +31,13 @@ export interface UseSubmissionsOptions {
|
||||||
|
|
||||||
export interface UseSubmissionsReturn {
|
export interface UseSubmissionsReturn {
|
||||||
// State
|
// State
|
||||||
submissions: ReturnType<typeof computed<SubmissionWithMeta[]>>
|
submissions: ComputedRef<SubmissionWithMeta[]>
|
||||||
isLoading: ReturnType<typeof computed<boolean>>
|
isLoading: ComputedRef<boolean>
|
||||||
error: ReturnType<typeof computed<string | null>>
|
error: ComputedRef<string | null>
|
||||||
|
|
||||||
// Sorting
|
// Sorting
|
||||||
currentSort: ReturnType<typeof ref<SortType>>
|
currentSort: Ref<SortType>
|
||||||
currentTimeRange: ReturnType<typeof ref<TimeRange>>
|
currentTimeRange: Ref<TimeRange>
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
subscribe: (config?: Partial<SubmissionFeedConfig>) => Promise<void>
|
subscribe: (config?: Partial<SubmissionFeedConfig>) => Promise<void>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Used when creating link submissions to embed preview data.
|
* Used when creating link submissions to embed preview data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { BaseService } from '@/core/base/BaseService'
|
import { BaseService } from '@/core/base/BaseService'
|
||||||
import type { LinkPreview } from '../types/submission'
|
import type { LinkPreview } from '../types/submission'
|
||||||
import { extractDomain } from '../types/submission'
|
import { extractDomain } from '../types/submission'
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import {
|
||||||
type SubmissionComment,
|
type SubmissionComment,
|
||||||
type LinkPreview,
|
type LinkPreview,
|
||||||
type MediaAttachment,
|
type MediaAttachment,
|
||||||
type CommunityRef,
|
|
||||||
parseCommunityRef,
|
parseCommunityRef,
|
||||||
formatCommunityRef,
|
formatCommunityRef,
|
||||||
extractDomain,
|
extractDomain,
|
||||||
|
|
@ -505,7 +504,7 @@ export class SubmissionService extends BaseService {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
created_at: event.created_at,
|
created_at: event.created_at,
|
||||||
kind: SUBMISSION_KINDS.SUBMISSION as const,
|
kind: SUBMISSION_KINDS.SUBMISSION,
|
||||||
tags,
|
tags,
|
||||||
title,
|
title,
|
||||||
communityRef: this.extractCommunityRef(tags),
|
communityRef: this.extractCommunityRef(tags),
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
* Submissions are kind 1111 events scoped to a community with structured metadata.
|
* Submissions are kind 1111 events scoped to a community with structured metadata.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Event as NostrEvent } from 'nostr-tools'
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Constants
|
// Constants
|
||||||
|
|
@ -193,7 +192,7 @@ export interface SubmissionRanking {
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/** Complete submission with all associated data */
|
/** Complete submission with all associated data */
|
||||||
export interface SubmissionWithMeta extends Submission {
|
export type SubmissionWithMeta = Submission & {
|
||||||
/** Vote counts and user state */
|
/** Vote counts and user state */
|
||||||
votes: SubmissionVotes
|
votes: SubmissionVotes
|
||||||
/** Ranking scores */
|
/** Ranking scores */
|
||||||
|
|
|
||||||
|
|
@ -1,268 +1,56 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col h-screen bg-background">
|
<div class="flex flex-col h-screen bg-background">
|
||||||
<PWAInstallPrompt auto-show />
|
<PWAInstallPrompt auto-show />
|
||||||
<!-- TODO: Implement push notifications properly - currently commenting out admin notifications dialog -->
|
|
||||||
<!-- <NotificationPermission auto-show /> -->
|
|
||||||
|
|
||||||
<!-- Compact Header with Filters Toggle (Mobile) -->
|
<!-- Header -->
|
||||||
<div class="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
|
<div class="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
|
||||||
<div class="flex items-center justify-between px-4 py-2 sm:px-6">
|
<div class="max-w-4xl mx-auto flex items-center justify-between px-4 py-2 sm:px-6">
|
||||||
<h1 class="text-lg font-semibold">Feed</h1>
|
<h1 class="text-lg font-semibold">Feed</h1>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- Active Filter Indicator -->
|
|
||||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<span v-if="activeFilterCount > 0">{{ activeFilterCount }} filters</span>
|
|
||||||
<span v-else>All content</span>
|
|
||||||
</div>
|
|
||||||
<!-- Filter Toggle Button -->
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
@click="showFilters = !showFilters"
|
|
||||||
class="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<Filter class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Collapsible Filter Panel -->
|
<!-- Main Feed Area -->
|
||||||
<div v-if="showFilters" class="border-t bg-background/95 backdrop-blur">
|
|
||||||
<div class="px-4 py-3 sm:px-6">
|
|
||||||
<FeedFilters v-model="selectedFilters" :admin-pubkeys="adminPubkeys" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Feed Area - Takes remaining height with scrolling -->
|
|
||||||
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||||
<!-- Collapsible Composer -->
|
<div class="max-w-4xl mx-auto px-4 sm:px-6">
|
||||||
<div v-if="showComposer || replyTo" class="border-b bg-background sticky top-0 z-10">
|
<SubmissionList
|
||||||
<div class="max-h-[70vh] overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
:show-ranks="false"
|
||||||
<div class="px-4 py-3 sm:px-6">
|
:show-time-range="true"
|
||||||
<!-- Regular Note Composer -->
|
initial-sort="hot"
|
||||||
<NoteComposer
|
@submission-click="onSubmissionClick"
|
||||||
v-if="composerType === 'note' || replyTo"
|
|
||||||
:reply-to="replyTo"
|
|
||||||
@note-published="onNotePublished"
|
|
||||||
@clear-reply="onClearReply"
|
|
||||||
@close="onCloseComposer"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Rideshare Composer -->
|
|
||||||
<RideshareComposer
|
|
||||||
v-else-if="composerType === 'rideshare'"
|
|
||||||
@rideshare-published="onRidesharePublished"
|
|
||||||
@close="onCloseComposer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feed Content - Natural flow with padding for sticky elements -->
|
|
||||||
<div>
|
|
||||||
<NostrFeed
|
|
||||||
:feed-type="feedType"
|
|
||||||
:content-filters="selectedFilters"
|
|
||||||
:admin-pubkeys="adminPubkeys"
|
|
||||||
:key="feedKey"
|
|
||||||
:compact-mode="true"
|
|
||||||
@reply-to-note="onReplyToNote"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Floating Action Buttons for Compose -->
|
<!-- Floating Action Button for Create Post -->
|
||||||
<div v-if="!showComposer && !replyTo" class="fixed bottom-6 right-6 z-50">
|
<div class="fixed bottom-6 right-6 z-50">
|
||||||
<!-- Main compose button -->
|
|
||||||
<div class="flex flex-col items-end gap-3">
|
|
||||||
<!-- Secondary buttons (when expanded) -->
|
|
||||||
<div v-if="showComposerOptions" class="flex flex-col gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
@click="openComposer('note')"
|
@click="navigateToSubmit"
|
||||||
size="lg"
|
|
||||||
class="h-12 px-4 rounded-full shadow-lg hover:shadow-xl transition-all gap-2 bg-card border-2 border-border hover:bg-accent text-card-foreground"
|
|
||||||
>
|
|
||||||
<MessageSquare class="h-4 w-4" />
|
|
||||||
<span class="text-sm font-medium">Note</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
@click="openComposer('rideshare')"
|
|
||||||
size="lg"
|
|
||||||
class="h-12 px-4 rounded-full shadow-lg hover:shadow-xl transition-all gap-2 bg-card border-2 border-border hover:bg-accent text-card-foreground"
|
|
||||||
>
|
|
||||||
<Car class="h-4 w-4" />
|
|
||||||
<span class="text-sm font-medium">Rideshare</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main FAB -->
|
|
||||||
<Button
|
|
||||||
@click="toggleComposerOptions"
|
|
||||||
size="lg"
|
size="lg"
|
||||||
class="h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all bg-primary hover:bg-primary/90 border-2 border-primary-foreground/20 flex items-center justify-center p-0"
|
class="h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all bg-primary hover:bg-primary/90 border-2 border-primary-foreground/20 flex items-center justify-center p-0"
|
||||||
>
|
>
|
||||||
<Plus
|
<Plus class="h-6 w-6 stroke-[2.5]" />
|
||||||
class="h-6 w-6 stroke-[2.5] transition-transform duration-200"
|
|
||||||
:class="{ 'rotate-45': showComposerOptions }"
|
|
||||||
/>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Filters Bar (Mobile) -->
|
|
||||||
<div class="md:hidden sticky bottom-0 z-30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-t">
|
|
||||||
<div class="flex overflow-x-auto px-4 py-2 gap-2 scrollbar-hide">
|
|
||||||
<Button
|
|
||||||
v-for="(preset, key) in quickFilterPresets"
|
|
||||||
:key="key"
|
|
||||||
:variant="isPresetActive(key) ? 'default' : 'outline'"
|
|
||||||
size="sm"
|
|
||||||
@click="setQuickFilter(key)"
|
|
||||||
class="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{{ preset.label }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// NostrFeed is now registered globally by the nostr-feed module
|
import { useRouter } from 'vue-router'
|
||||||
// No need to import it directly - use the modular version
|
|
||||||
// TODO: Re-enable when push notifications are properly implemented
|
|
||||||
// import NotificationPermission from '@/components/notifications/NotificationPermission.vue'
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Filter, Plus, MessageSquare, Car } from 'lucide-vue-next'
|
import { Plus } from 'lucide-vue-next'
|
||||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||||
import FeedFilters from '@/modules/nostr-feed/components/FeedFilters.vue'
|
import SubmissionList from '@/modules/nostr-feed/components/SubmissionList.vue'
|
||||||
import NoteComposer from '@/modules/nostr-feed/components/NoteComposer.vue'
|
import type { SubmissionWithMeta } from '@/modules/nostr-feed/types/submission'
|
||||||
import RideshareComposer from '@/modules/nostr-feed/components/RideshareComposer.vue'
|
|
||||||
import NostrFeed from '@/modules/nostr-feed/components/NostrFeed.vue'
|
|
||||||
import { FILTER_PRESETS } from '@/modules/nostr-feed/config/content-filters'
|
|
||||||
import appConfig from '@/app.config'
|
|
||||||
import type { ContentFilter } from '@/modules/nostr-feed/services/FeedService'
|
|
||||||
import type { ReplyToNote } from '@/modules/nostr-feed/components/NoteComposer.vue'
|
|
||||||
|
|
||||||
// Get admin pubkeys from app config
|
const router = useRouter()
|
||||||
const adminPubkeys = appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
|
|
||||||
|
|
||||||
// UI state
|
// Handle submission click - navigate to detail page
|
||||||
const showFilters = ref(false)
|
function onSubmissionClick(submission: SubmissionWithMeta) {
|
||||||
const showComposer = ref(false)
|
router.push({ name: 'submission-detail', params: { id: submission.id } })
|
||||||
const showComposerOptions = ref(false)
|
|
||||||
const composerType = ref<'note' | 'rideshare'>('note')
|
|
||||||
|
|
||||||
// Feed configuration
|
|
||||||
const selectedFilters = ref<ContentFilter[]>(FILTER_PRESETS.all)
|
|
||||||
const feedKey = ref(0) // Force feed component to re-render when filters change
|
|
||||||
|
|
||||||
// Note composer state
|
|
||||||
const replyTo = ref<ReplyToNote | undefined>()
|
|
||||||
|
|
||||||
// Quick filter presets for mobile bottom bar
|
|
||||||
const quickFilterPresets = {
|
|
||||||
all: { label: 'All', filters: FILTER_PRESETS.all },
|
|
||||||
announcements: { label: 'Announcements', filters: FILTER_PRESETS.announcements },
|
|
||||||
rideshare: { label: 'Rideshare', filters: FILTER_PRESETS.rideshare }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed properties
|
// Navigate to submit page
|
||||||
const activeFilterCount = computed(() => selectedFilters.value.length)
|
function navigateToSubmit() {
|
||||||
|
router.push({ name: 'submit-post' })
|
||||||
const isPresetActive = (presetKey: string) => {
|
|
||||||
const preset = quickFilterPresets[presetKey as keyof typeof quickFilterPresets]
|
|
||||||
if (!preset) return false
|
|
||||||
|
|
||||||
return preset.filters.length === selectedFilters.value.length &&
|
|
||||||
preset.filters.every(pf => selectedFilters.value.some(sf => sf.id === pf.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine feed type based on selected filters
|
|
||||||
const feedType = computed(() => {
|
|
||||||
if (selectedFilters.value.length === 0) return 'all'
|
|
||||||
|
|
||||||
// Check if it matches the 'all' preset
|
|
||||||
if (selectedFilters.value.length === FILTER_PRESETS.all.length &&
|
|
||||||
FILTER_PRESETS.all.every(pf => selectedFilters.value.some(sf => sf.id === pf.id))) {
|
|
||||||
return 'all'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it matches the announcements preset
|
|
||||||
if (selectedFilters.value.length === FILTER_PRESETS.announcements.length &&
|
|
||||||
FILTER_PRESETS.announcements.every(pf => selectedFilters.value.some(sf => sf.id === pf.id))) {
|
|
||||||
return 'announcements'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it matches the rideshare preset
|
|
||||||
if (selectedFilters.value.length === FILTER_PRESETS.rideshare.length &&
|
|
||||||
FILTER_PRESETS.rideshare.every(pf => selectedFilters.value.some(sf => sf.id === pf.id))) {
|
|
||||||
return 'rideshare'
|
|
||||||
}
|
|
||||||
|
|
||||||
// For all other cases, use custom
|
|
||||||
return 'custom'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Force feed to reload when filters change
|
|
||||||
watch(selectedFilters, () => {
|
|
||||||
feedKey.value++
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// Handle note composer events
|
|
||||||
// Methods
|
|
||||||
const setQuickFilter = (presetKey: string) => {
|
|
||||||
const preset = quickFilterPresets[presetKey as keyof typeof quickFilterPresets]
|
|
||||||
if (preset) {
|
|
||||||
selectedFilters.value = preset.filters
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onNotePublished = (noteId: string) => {
|
|
||||||
console.log('Note published:', noteId)
|
|
||||||
// Refresh the feed to show the new note
|
|
||||||
feedKey.value++
|
|
||||||
// Clear reply state and hide composer
|
|
||||||
replyTo.value = undefined
|
|
||||||
showComposer.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onClearReply = () => {
|
|
||||||
replyTo.value = undefined
|
|
||||||
showComposer.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onReplyToNote = (note: ReplyToNote) => {
|
|
||||||
replyTo.value = note
|
|
||||||
showComposer.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCloseComposer = () => {
|
|
||||||
showComposer.value = false
|
|
||||||
showComposerOptions.value = false
|
|
||||||
replyTo.value = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
// New composer methods
|
|
||||||
const toggleComposerOptions = () => {
|
|
||||||
showComposerOptions.value = !showComposerOptions.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const openComposer = (type: 'note' | 'rideshare') => {
|
|
||||||
composerType.value = type
|
|
||||||
showComposer.value = true
|
|
||||||
showComposerOptions.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onRidesharePublished = (noteId: string) => {
|
|
||||||
console.log('Rideshare post published:', noteId)
|
|
||||||
// Refresh the feed to show the new rideshare post
|
|
||||||
feedKey.value++
|
|
||||||
// Hide composer
|
|
||||||
showComposer.value = false
|
|
||||||
showComposerOptions.value = false
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,718 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="min-h-screen bg-background">
|
|
||||||
<!-- Header -->
|
|
||||||
<header class="sticky top-0 z-40 bg-background/95 backdrop-blur border-b">
|
|
||||||
<div class="max-w-4xl mx-auto px-4 py-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-lg font-semibold">Link Aggregator Test</h1>
|
|
||||||
<p class="text-xs text-muted-foreground">Reddit/Lemmy-style feed UI</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- View toggle -->
|
|
||||||
<div class="flex items-center gap-1 text-sm">
|
|
||||||
<button
|
|
||||||
:class="[
|
|
||||||
'px-2 py-1 rounded text-xs',
|
|
||||||
viewMode === 'submissions' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent'
|
|
||||||
]"
|
|
||||||
@click="viewMode = 'submissions'"
|
|
||||||
>
|
|
||||||
Submissions
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="[
|
|
||||||
'px-2 py-1 rounded text-xs',
|
|
||||||
viewMode === 'mock' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-accent'
|
|
||||||
]"
|
|
||||||
@click="viewMode = 'mock'"
|
|
||||||
>
|
|
||||||
Mock Data
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Main content -->
|
|
||||||
<main class="max-w-4xl mx-auto">
|
|
||||||
<!-- Test Relay Connection & Composer Panel -->
|
|
||||||
<div class="p-4 border-b">
|
|
||||||
<button
|
|
||||||
@click="showComposer = !showComposer"
|
|
||||||
class="flex items-center gap-2 text-sm font-medium w-full"
|
|
||||||
>
|
|
||||||
<component :is="showComposer ? ChevronUp : ChevronDown" class="h-4 w-4" />
|
|
||||||
Test Relay & Submission Composer
|
|
||||||
<span
|
|
||||||
:class="[
|
|
||||||
'ml-2 px-2 py-0.5 rounded text-xs',
|
|
||||||
isConnected ? 'bg-green-500/20 text-green-600' : 'bg-muted text-muted-foreground'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ isConnected ? 'Connected' : 'Disconnected' }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="showComposer" class="mt-4 space-y-4">
|
|
||||||
<!-- Relay connection -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
v-model="testRelayUrl"
|
|
||||||
type="text"
|
|
||||||
placeholder="Relay URL"
|
|
||||||
class="flex-1 px-3 py-2 text-sm border rounded-lg bg-background"
|
|
||||||
:disabled="isConnected"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="!isConnected"
|
|
||||||
@click="connectToRelay"
|
|
||||||
:disabled="isConnecting"
|
|
||||||
class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="isConnecting" class="h-4 w-4 animate-spin" />
|
|
||||||
<Wifi v-else class="h-4 w-4" />
|
|
||||||
{{ isConnecting ? 'Connecting...' : 'Connect' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
@click="disconnectFromRelay"
|
|
||||||
class="px-4 py-2 text-sm bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<WifiOff class="h-4 w-4" />
|
|
||||||
Disconnect
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="connectionError" class="text-sm text-destructive">
|
|
||||||
{{ connectionError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="publicKey" class="text-xs text-muted-foreground">
|
|
||||||
Pubkey: {{ publicKey.slice(0, 16) }}...{{ publicKey.slice(-8) }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submission form (only when connected) -->
|
|
||||||
<div v-if="isConnected" class="border rounded-lg p-4 space-y-3">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button
|
|
||||||
@click="postType = 'self'"
|
|
||||||
:class="[
|
|
||||||
'px-3 py-1 text-sm rounded',
|
|
||||||
postType === 'self' ? 'bg-primary text-primary-foreground' : 'bg-muted'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
Self Post
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="postType = 'link'"
|
|
||||||
:class="[
|
|
||||||
'px-3 py-1 text-sm rounded',
|
|
||||||
postType === 'link' ? 'bg-primary text-primary-foreground' : 'bg-muted'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
Link Post
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
|
||||||
v-model="title"
|
|
||||||
type="text"
|
|
||||||
placeholder="Title"
|
|
||||||
class="w-full px-3 py-2 text-sm border rounded-lg bg-background"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<input
|
|
||||||
v-if="postType === 'link'"
|
|
||||||
v-model="url"
|
|
||||||
type="text"
|
|
||||||
placeholder="URL"
|
|
||||||
class="w-full px-3 py-2 text-sm border rounded-lg bg-background"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
v-model="content"
|
|
||||||
:placeholder="postType === 'self' ? 'Post content...' : 'Optional description...'"
|
|
||||||
rows="3"
|
|
||||||
class="w-full px-3 py-2 text-sm border rounded-lg bg-background resize-none"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
@click="publishSubmission"
|
|
||||||
:disabled="isPublishing || !title"
|
|
||||||
class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Loader2 v-if="isPublishing" class="h-4 w-4 animate-spin" />
|
|
||||||
<Send v-else class="h-4 w-4" />
|
|
||||||
{{ isPublishing ? 'Publishing...' : 'Publish to Relay' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="publishResult" :class="['text-sm', publishResult.success ? 'text-green-600' : 'text-destructive']">
|
|
||||||
{{ publishResult.message }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Received events -->
|
|
||||||
<div v-if="isConnected && receivedEvents.length > 0" class="border rounded-lg p-4">
|
|
||||||
<h4 class="text-sm font-medium mb-2">Received Kind 1111 Events ({{ receivedEvents.length }})</h4>
|
|
||||||
<div class="space-y-2 max-h-48 overflow-y-auto">
|
|
||||||
<div
|
|
||||||
v-for="event in receivedEvents"
|
|
||||||
:key="event.id"
|
|
||||||
class="text-xs p-2 bg-muted rounded"
|
|
||||||
>
|
|
||||||
<div class="font-mono text-muted-foreground">{{ event.id.slice(0, 24) }}...</div>
|
|
||||||
<div class="font-medium mt-1">
|
|
||||||
{{ event.tags.find(t => t[0] === 'title')?.[1] || '(no title)' }}
|
|
||||||
</div>
|
|
||||||
<div class="text-muted-foreground truncate">{{ event.content.slice(0, 100) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Real submissions from Nostr -->
|
|
||||||
<div v-if="viewMode === 'submissions'" class="p-4">
|
|
||||||
<div class="mb-4 p-3 bg-muted/50 rounded-lg text-sm">
|
|
||||||
<p class="font-medium">Live Mode</p>
|
|
||||||
<p class="text-muted-foreground text-xs mt-1">
|
|
||||||
Fetching kind 1111 submissions from connected relays.
|
|
||||||
If empty, no submissions exist yet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SubmissionList
|
|
||||||
:show-ranks="showRanks"
|
|
||||||
:show-time-range="true"
|
|
||||||
initial-sort="hot"
|
|
||||||
@submission-click="onSubmissionClick"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mock data demo -->
|
|
||||||
<div v-else class="p-4">
|
|
||||||
<div class="mb-4 p-3 bg-muted/50 rounded-lg text-sm">
|
|
||||||
<p class="font-medium">Mock Data Mode</p>
|
|
||||||
<p class="text-muted-foreground text-xs mt-1">
|
|
||||||
Displaying sample submissions to preview the UI.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Options -->
|
|
||||||
<div class="flex items-center gap-4 mb-4 text-sm">
|
|
||||||
<label class="flex items-center gap-2">
|
|
||||||
<input type="checkbox" v-model="showRanks" class="rounded" />
|
|
||||||
<span>Show ranks</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sort tabs -->
|
|
||||||
<SortTabs
|
|
||||||
:current-sort="currentSort"
|
|
||||||
:current-time-range="currentTimeRange"
|
|
||||||
:show-time-range="true"
|
|
||||||
@update:sort="currentSort = $event"
|
|
||||||
@update:time-range="currentTimeRange = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Mock submission rows -->
|
|
||||||
<div class="divide-y divide-border">
|
|
||||||
<div
|
|
||||||
v-for="(submission, index) in sortedMockSubmissions"
|
|
||||||
:key="submission.id"
|
|
||||||
class="flex items-start gap-2 py-2 px-1 hover:bg-accent/30 transition-colors group"
|
|
||||||
>
|
|
||||||
<!-- Rank -->
|
|
||||||
<div v-if="showRanks" class="w-6 text-right text-sm text-muted-foreground font-medium pt-1">
|
|
||||||
{{ index + 1 }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Vote controls -->
|
|
||||||
<VoteControls
|
|
||||||
:score="submission.votes.score"
|
|
||||||
:user-vote="submission.votes.userVote"
|
|
||||||
@upvote="onMockUpvote(submission)"
|
|
||||||
@downvote="onMockDownvote(submission)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Thumbnail -->
|
|
||||||
<SubmissionThumbnail
|
|
||||||
:src="submission.thumbnail"
|
|
||||||
:post-type="submission.postType"
|
|
||||||
:nsfw="submission.nsfw"
|
|
||||||
:size="70"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<!-- Title row -->
|
|
||||||
<div class="flex items-start gap-2">
|
|
||||||
<h3 class="text-sm font-medium leading-snug cursor-pointer hover:underline">
|
|
||||||
{{ submission.title }}
|
|
||||||
</h3>
|
|
||||||
<span v-if="submission.domain" class="text-xs text-muted-foreground flex-shrink-0">
|
|
||||||
({{ submission.domain }})
|
|
||||||
</span>
|
|
||||||
<span v-if="submission.postType === 'self'" class="text-xs text-muted-foreground flex-shrink-0">
|
|
||||||
(self)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Badges -->
|
|
||||||
<div v-if="submission.nsfw || submission.flair" class="flex items-center gap-1 mt-0.5">
|
|
||||||
<span v-if="submission.nsfw" class="text-[10px] px-1 py-0 bg-destructive text-destructive-foreground rounded">
|
|
||||||
NSFW
|
|
||||||
</span>
|
|
||||||
<span v-if="submission.flair" class="text-[10px] px-1 py-0 bg-secondary text-secondary-foreground rounded">
|
|
||||||
{{ submission.flair }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Metadata -->
|
|
||||||
<div class="text-xs text-muted-foreground mt-1">
|
|
||||||
<span>submitted {{ submission.timeAgo }}</span>
|
|
||||||
<span> by </span>
|
|
||||||
<span class="hover:underline cursor-pointer">{{ submission.author }}</span>
|
|
||||||
<template v-if="submission.community">
|
|
||||||
<span> to </span>
|
|
||||||
<span class="hover:underline cursor-pointer font-medium">{{ submission.community }}</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
|
||||||
<button class="flex items-center gap-1 hover:text-foreground">
|
|
||||||
<MessageSquare class="h-3.5 w-3.5" />
|
|
||||||
<span>{{ submission.commentCount }} comments</span>
|
|
||||||
</button>
|
|
||||||
<button class="flex items-center gap-1 hover:text-foreground opacity-0 group-hover:opacity-100">
|
|
||||||
<Share2 class="h-3.5 w-3.5" />
|
|
||||||
<span>share</span>
|
|
||||||
</button>
|
|
||||||
<button class="flex items-center gap-1 hover:text-foreground opacity-0 group-hover:opacity-100">
|
|
||||||
<Bookmark class="h-3.5 w-3.5" />
|
|
||||||
<span>save</span>
|
|
||||||
</button>
|
|
||||||
<button class="flex items-center gap-1 hover:text-foreground opacity-0 group-hover:opacity-100">
|
|
||||||
<EyeOff class="h-3.5 w-3.5" />
|
|
||||||
<span>hide</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Floating back button -->
|
|
||||||
<div class="fixed bottom-6 left-6">
|
|
||||||
<button
|
|
||||||
@click="$router.push('/')"
|
|
||||||
class="px-4 py-2 bg-primary text-primary-foreground rounded-full shadow-lg hover:shadow-xl transition-all text-sm font-medium"
|
|
||||||
>
|
|
||||||
Back to Feed
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { MessageSquare, Share2, Bookmark, EyeOff, Send, Loader2, ChevronDown, ChevronUp, Wifi, WifiOff } from 'lucide-vue-next'
|
|
||||||
import { SimplePool, finalizeEvent, generateSecretKey, getPublicKey, type Event as NostrEvent } from 'nostr-tools'
|
|
||||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
|
||||||
import SubmissionList from '@/modules/nostr-feed/components/SubmissionList.vue'
|
|
||||||
import VoteControls from '@/modules/nostr-feed/components/VoteControls.vue'
|
|
||||||
import SortTabs from '@/modules/nostr-feed/components/SortTabs.vue'
|
|
||||||
import SubmissionThumbnail from '@/modules/nostr-feed/components/SubmissionThumbnail.vue'
|
|
||||||
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
|
|
||||||
import type { SortType, TimeRange, VoteType } from '@/modules/nostr-feed/types/submission'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// View mode
|
|
||||||
const viewMode = ref<'submissions' | 'mock'>('mock')
|
|
||||||
|
|
||||||
// ===== Direct Relay Connection =====
|
|
||||||
const testRelayUrl = ref('ws://localhost:5000/nostrrelay/test')
|
|
||||||
const pool = new SimplePool()
|
|
||||||
const isConnected = ref(false)
|
|
||||||
const connectionError = ref<string | null>(null)
|
|
||||||
const isConnecting = ref(false)
|
|
||||||
|
|
||||||
// Composer state
|
|
||||||
const showComposer = ref(true)
|
|
||||||
const isPublishing = ref(false)
|
|
||||||
const publishResult = ref<{ success: boolean; message: string } | null>(null)
|
|
||||||
const receivedEvents = ref<NostrEvent[]>([])
|
|
||||||
|
|
||||||
// Form state
|
|
||||||
const postType = ref<'self' | 'link'>('self')
|
|
||||||
const title = ref('Test submission from Link Aggregator')
|
|
||||||
const content = ref('This is a test self post to verify kind 1111 submissions work correctly.')
|
|
||||||
const url = ref('https://example.com/test-article')
|
|
||||||
|
|
||||||
// Test keypair (generate new or use stored)
|
|
||||||
const privateKey = ref<Uint8Array | null>(null)
|
|
||||||
const publicKey = ref<string>('')
|
|
||||||
|
|
||||||
// Try to get user's key from auth service, otherwise generate test key
|
|
||||||
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
|
|
||||||
|
|
||||||
function initKeys() {
|
|
||||||
// Try to use authenticated user's key
|
|
||||||
if (authService?.user?.value?.prvkey) {
|
|
||||||
try {
|
|
||||||
privateKey.value = hexToBytes(authService.user.value.prvkey)
|
|
||||||
publicKey.value = authService.user.value.pubkey
|
|
||||||
console.log('Using authenticated user key:', publicKey.value.slice(0, 16) + '...')
|
|
||||||
return
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to use auth key:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate test keypair
|
|
||||||
const stored = localStorage.getItem('test_nostr_privkey')
|
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
privateKey.value = hexToBytes(stored)
|
|
||||||
publicKey.value = getPublicKey(privateKey.value)
|
|
||||||
console.log('Using stored test key:', publicKey.value.slice(0, 16) + '...')
|
|
||||||
return
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to restore stored key:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new
|
|
||||||
privateKey.value = generateSecretKey()
|
|
||||||
publicKey.value = getPublicKey(privateKey.value)
|
|
||||||
localStorage.setItem('test_nostr_privkey', bytesToHex(privateKey.value))
|
|
||||||
console.log('Generated new test key:', publicKey.value.slice(0, 16) + '...')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectToRelay() {
|
|
||||||
isConnecting.value = true
|
|
||||||
connectionError.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
await pool.ensureRelay(testRelayUrl.value)
|
|
||||||
isConnected.value = true
|
|
||||||
console.log('Connected to relay:', testRelayUrl.value)
|
|
||||||
|
|
||||||
// Subscribe to kind 1111 events
|
|
||||||
subscribeToSubmissions()
|
|
||||||
} catch (err: any) {
|
|
||||||
connectionError.value = err.message || 'Failed to connect'
|
|
||||||
isConnected.value = false
|
|
||||||
console.error('Connection error:', err)
|
|
||||||
} finally {
|
|
||||||
isConnecting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function disconnectFromRelay() {
|
|
||||||
pool.close([testRelayUrl.value])
|
|
||||||
isConnected.value = false
|
|
||||||
receivedEvents.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
let activeSub: any = null
|
|
||||||
|
|
||||||
function subscribeToSubmissions() {
|
|
||||||
if (activeSub) {
|
|
||||||
activeSub.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
receivedEvents.value = []
|
|
||||||
|
|
||||||
activeSub = pool.subscribeMany(
|
|
||||||
[testRelayUrl.value],
|
|
||||||
[{ kinds: [1111], limit: 50 }],
|
|
||||||
{
|
|
||||||
onevent(event: NostrEvent) {
|
|
||||||
console.log('Received event:', event)
|
|
||||||
// Add to beginning, avoid duplicates
|
|
||||||
if (!receivedEvents.value.find(e => e.id === event.id)) {
|
|
||||||
receivedEvents.value = [event, ...receivedEvents.value]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
oneose() {
|
|
||||||
console.log('EOSE received')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publishSubmission() {
|
|
||||||
if (!privateKey.value || !isConnected.value) {
|
|
||||||
publishResult.value = { success: false, message: 'Not connected or no key' }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isPublishing.value = true
|
|
||||||
publishResult.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build kind 1111 event
|
|
||||||
const tags: string[][] = [
|
|
||||||
['title', title.value],
|
|
||||||
['post-type', postType.value]
|
|
||||||
]
|
|
||||||
|
|
||||||
let eventContent = ''
|
|
||||||
|
|
||||||
if (postType.value === 'link') {
|
|
||||||
tags.push(['r', url.value])
|
|
||||||
eventContent = content.value || `Link: ${url.value}`
|
|
||||||
} else {
|
|
||||||
eventContent = content.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventTemplate = {
|
|
||||||
kind: 1111,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags,
|
|
||||||
content: eventContent
|
|
||||||
}
|
|
||||||
|
|
||||||
const signedEvent = finalizeEvent(eventTemplate, privateKey.value)
|
|
||||||
console.log('Publishing event:', signedEvent)
|
|
||||||
|
|
||||||
// Publish
|
|
||||||
await Promise.any(pool.publish([testRelayUrl.value], signedEvent))
|
|
||||||
|
|
||||||
publishResult.value = {
|
|
||||||
success: true,
|
|
||||||
message: `Published! Event ID: ${signedEvent.id.slice(0, 16)}...`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh subscription
|
|
||||||
subscribeToSubmissions()
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Publish error:', err)
|
|
||||||
publishResult.value = {
|
|
||||||
success: false,
|
|
||||||
message: err.message || 'Failed to publish'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isPublishing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
initKeys()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (activeSub) activeSub.close()
|
|
||||||
pool.close([testRelayUrl.value])
|
|
||||||
})
|
|
||||||
|
|
||||||
// UI options
|
|
||||||
const showRanks = ref(true)
|
|
||||||
const currentSort = ref<SortType>('hot')
|
|
||||||
const currentTimeRange = ref<TimeRange>('day')
|
|
||||||
|
|
||||||
// Mock submission type
|
|
||||||
interface MockSubmission {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
postType: 'link' | 'media' | 'self'
|
|
||||||
domain?: string
|
|
||||||
thumbnail?: string
|
|
||||||
author: string
|
|
||||||
community?: string
|
|
||||||
timeAgo: string
|
|
||||||
commentCount: number
|
|
||||||
votes: {
|
|
||||||
score: number
|
|
||||||
upvotes: number
|
|
||||||
downvotes: number
|
|
||||||
userVote: VoteType
|
|
||||||
}
|
|
||||||
nsfw?: boolean
|
|
||||||
flair?: string
|
|
||||||
created_at: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock data
|
|
||||||
const mockSubmissions = ref<MockSubmission[]>([
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
title: 'What are some cool and obscure data structures you know of?',
|
|
||||||
postType: 'self',
|
|
||||||
author: 'lil_shi',
|
|
||||||
community: 'Programming',
|
|
||||||
timeAgo: '10 hours ago',
|
|
||||||
commentCount: 29,
|
|
||||||
votes: { score: 84, upvotes: 92, downvotes: 8, userVote: null },
|
|
||||||
created_at: Date.now() / 1000 - 36000
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: "I haven't written code at all these holidays",
|
|
||||||
postType: 'self',
|
|
||||||
author: 'Matty_r',
|
|
||||||
community: 'Programming',
|
|
||||||
timeAgo: '1 day ago',
|
|
||||||
commentCount: 25,
|
|
||||||
votes: { score: 77, upvotes: 85, downvotes: 8, userVote: null },
|
|
||||||
created_at: Date.now() / 1000 - 86400
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
title: 'HDMI monitor for headless linux server',
|
|
||||||
postType: 'link',
|
|
||||||
domain: 'ttrp.network',
|
|
||||||
author: 'AldinTheMage',
|
|
||||||
community: 'Linux',
|
|
||||||
timeAgo: '19 hours ago',
|
|
||||||
commentCount: 19,
|
|
||||||
votes: { score: 28, upvotes: 32, downvotes: 4, userVote: null },
|
|
||||||
created_at: Date.now() / 1000 - 68400
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
title: '2026 is THE year of the linux desktop',
|
|
||||||
postType: 'self',
|
|
||||||
author: 'cm0002',
|
|
||||||
community: 'Linux',
|
|
||||||
timeAgo: '9 hours ago',
|
|
||||||
commentCount: 20,
|
|
||||||
votes: { score: 57, upvotes: 65, downvotes: 8, userVote: null },
|
|
||||||
flair: 'Discussion',
|
|
||||||
created_at: Date.now() / 1000 - 32400
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
title: 'NTFSPlus Becomes "NTFS" as Driver Moves Closer to Kernel Integration',
|
|
||||||
postType: 'link',
|
|
||||||
domain: 'itsfoss.com',
|
|
||||||
thumbnail: 'https://picsum.photos/seed/ntfs/200',
|
|
||||||
author: 'cm0002',
|
|
||||||
community: 'Linux',
|
|
||||||
timeAgo: '1 hour ago',
|
|
||||||
commentCount: 0,
|
|
||||||
votes: { score: 32, upvotes: 35, downvotes: 3, userVote: null },
|
|
||||||
flair: 'News',
|
|
||||||
created_at: Date.now() / 1000 - 3600
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '6',
|
|
||||||
title: 'ReactOS Starts 2026 With Another "Major Step" Toward Windows NT6 Compatibility',
|
|
||||||
postType: 'link',
|
|
||||||
domain: 'phoronix.com',
|
|
||||||
thumbnail: 'https://picsum.photos/seed/reactos/200',
|
|
||||||
author: 'cm0002',
|
|
||||||
community: 'Linux',
|
|
||||||
timeAgo: '49 minutes ago',
|
|
||||||
commentCount: 0,
|
|
||||||
votes: { score: 15, upvotes: 17, downvotes: 2, userVote: null },
|
|
||||||
created_at: Date.now() / 1000 - 2940
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '7',
|
|
||||||
title: 'Check out this mass adoption happening at a Bitcoin meetup',
|
|
||||||
postType: 'media',
|
|
||||||
thumbnail: 'https://picsum.photos/seed/bitcoin/200',
|
|
||||||
author: 'satoshi_fan',
|
|
||||||
community: 'Bitcoin',
|
|
||||||
timeAgo: '3 hours ago',
|
|
||||||
commentCount: 42,
|
|
||||||
votes: { score: 156, upvotes: 180, downvotes: 24, userVote: null },
|
|
||||||
created_at: Date.now() / 1000 - 10800
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '8',
|
|
||||||
title: 'NSFW content should be behind a toggle [Meta Discussion]',
|
|
||||||
postType: 'self',
|
|
||||||
author: 'moderator_joe',
|
|
||||||
community: 'Meta',
|
|
||||||
timeAgo: '2 days ago',
|
|
||||||
commentCount: 89,
|
|
||||||
votes: { score: 234, upvotes: 290, downvotes: 56, userVote: null },
|
|
||||||
nsfw: true,
|
|
||||||
created_at: Date.now() / 1000 - 172800
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// Sort mock submissions
|
|
||||||
const sortedMockSubmissions = computed(() => {
|
|
||||||
const sorted = [...mockSubmissions.value]
|
|
||||||
|
|
||||||
switch (currentSort.value) {
|
|
||||||
case 'new':
|
|
||||||
return sorted.sort((a, b) => b.created_at - a.created_at)
|
|
||||||
case 'top':
|
|
||||||
return sorted.sort((a, b) => b.votes.score - a.votes.score)
|
|
||||||
case 'controversial':
|
|
||||||
// Balance between up and down votes
|
|
||||||
const controversy = (s: MockSubmission) => {
|
|
||||||
const total = s.votes.upvotes + s.votes.downvotes
|
|
||||||
if (total === 0) return 0
|
|
||||||
const balance = Math.min(s.votes.upvotes, s.votes.downvotes) / Math.max(s.votes.upvotes, s.votes.downvotes)
|
|
||||||
return Math.pow(total, 0.8) * balance
|
|
||||||
}
|
|
||||||
return sorted.sort((a, b) => controversy(b) - controversy(a))
|
|
||||||
case 'hot':
|
|
||||||
default:
|
|
||||||
// Hot rank: score + recency
|
|
||||||
const hotRank = (s: MockSubmission) => {
|
|
||||||
const order = Math.log10(Math.max(Math.abs(s.votes.score), 1))
|
|
||||||
const sign = s.votes.score > 0 ? 1 : s.votes.score < 0 ? -1 : 0
|
|
||||||
return sign * order + s.created_at / 45000
|
|
||||||
}
|
|
||||||
return sorted.sort((a, b) => hotRank(b) - hotRank(a))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mock vote handlers
|
|
||||||
function onMockUpvote(submission: MockSubmission) {
|
|
||||||
if (submission.votes.userVote === 'upvote') {
|
|
||||||
// Remove upvote
|
|
||||||
submission.votes.score -= 1
|
|
||||||
submission.votes.upvotes -= 1
|
|
||||||
submission.votes.userVote = null
|
|
||||||
} else {
|
|
||||||
// Add upvote (remove downvote if exists)
|
|
||||||
if (submission.votes.userVote === 'downvote') {
|
|
||||||
submission.votes.score += 1
|
|
||||||
submission.votes.downvotes -= 1
|
|
||||||
}
|
|
||||||
submission.votes.score += 1
|
|
||||||
submission.votes.upvotes += 1
|
|
||||||
submission.votes.userVote = 'upvote'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMockDownvote(submission: MockSubmission) {
|
|
||||||
if (submission.votes.userVote === 'downvote') {
|
|
||||||
// Remove downvote
|
|
||||||
submission.votes.score += 1
|
|
||||||
submission.votes.downvotes -= 1
|
|
||||||
submission.votes.userVote = null
|
|
||||||
} else {
|
|
||||||
// Add downvote (remove upvote if exists)
|
|
||||||
if (submission.votes.userVote === 'upvote') {
|
|
||||||
submission.votes.score -= 1
|
|
||||||
submission.votes.upvotes -= 1
|
|
||||||
}
|
|
||||||
submission.votes.score -= 1
|
|
||||||
submission.votes.downvotes += 1
|
|
||||||
submission.votes.userVote = 'downvote'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Real submission click handler
|
|
||||||
function onSubmissionClick(submission: any) {
|
|
||||||
console.log('Clicked submission:', submission)
|
|
||||||
router.push({ name: 'submission-detail', params: { id: submission.id } })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue