feat(nostr-feed): Add submission detail view (Phase 4)

- Add SubmissionDetail.vue for full post view with content display
- Add SubmissionComment.vue for recursive threaded comments
- Add SubmissionDetailPage.vue route wrapper
- Add /submission/:id route to module
- Update SubmissionList share URL to use correct route
- Update test page to navigate to detail view on click
- Update plan document with Phase 4 completion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-01 19:01:58 +01:00
parent c0840912c7
commit 8315a8ee99
7 changed files with 732 additions and 17 deletions

View file

@ -78,30 +78,37 @@ Transform the nostr-feed module into a Reddit-style link aggregator with support
## Implementation Phases
### Phase 1: Core Data Model (Current)
### Phase 1: Core Data Model
- [x] Create feature branch
- [x] Document plan
- [ ] Create `types/submission.ts` - Type definitions
- [ ] Create `SubmissionService.ts` - Submission CRUD
- [ ] Create `LinkPreviewService.ts` - OG tag fetching
- [ ] Extend `FeedService.ts` - Handle kind 1111
- [x] Create `types/submission.ts` - Type definitions
- [x] Create `SubmissionService.ts` - Submission CRUD
- [x] Create `LinkPreviewService.ts` - OG tag fetching
- [x] Extend `FeedService.ts` - Handle kind 1111
### Phase 2: Post Creation
### Phase 2: Post Creation (Pending)
- [ ] Create `SubmitComposer.vue` - Multi-type composer
- [ ] Add link preview on URL paste
- [ ] Integrate with pictrs for media upload
- [ ] Add NSFW toggle
### Phase 3: Feed Display
- [ ] Create `SubmissionCard.vue` - Link aggregator card
- [ ] Create `VoteButtons.vue` - Up/down voting
- [ ] Add feed sorting (hot, new, top, controversial)
- [ ] Add score calculation
- [x] Create `SubmissionRow.vue` - Link aggregator row (Reddit/Lemmy style)
- [x] Create `VoteControls.vue` - Up/down voting
- [x] Create `SortTabs.vue` - Sort tabs (hot, new, top, controversial)
- [x] Create `SubmissionList.vue` - Main feed container
- [x] Create `SubmissionThumbnail.vue` - Thumbnail display
- [x] Add feed sorting (hot, new, top, controversial)
- [x] Add score calculation
- [x] Create `LinkAggregatorTest.vue` - Test page with mock data & live mode
### Phase 4: Detail View
- [ ] Create `SubmissionDetail.vue` - Full post view
- [ ] Integrate `ThreadedPost.vue` for comments
- [ ] Add comment sorting
- [x] Create `SubmissionDetail.vue` - Full post view with content display
- [x] Create `SubmissionComment.vue` - Recursive threaded comments
- [x] Create `SubmissionDetailPage.vue` - Route page wrapper
- [x] Add route `/submission/:id` for detail view
- [ ] Add comment sorting (pending)
- [ ] Integrate comment submission (pending)
### Phase 5: Communities (Future)
- [ ] Create `CommunityService.ts`

View file

@ -0,0 +1,206 @@
<script setup lang="ts">
/**
* SubmissionComment - Recursive comment component for submission threads
* Displays a single comment with vote controls and nested replies
*/
import { computed } from 'vue'
import { formatDistanceToNow } from 'date-fns'
import { ChevronUp, ChevronDown, Reply, Flag, MoreHorizontal } from 'lucide-vue-next'
import VoteControls from './VoteControls.vue'
import type { SubmissionComment as CommentType } from '../types/submission'
interface Props {
comment: CommentType
depth: number
collapsedComments: Set<string>
getDisplayName: (pubkey: string) => string
isAuthenticated: boolean
currentUserPubkey?: string | null
}
interface Emits {
(e: 'toggle-collapse', commentId: string): void
(e: 'reply', comment: CommentType): void
(e: 'upvote', comment: CommentType): void
(e: 'downvote', comment: CommentType): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// Is this comment collapsed
const isCollapsed = computed(() => props.collapsedComments.has(props.comment.id))
// Has replies
const hasReplies = computed(() => props.comment.replies && props.comment.replies.length > 0)
// Count total nested replies
const replyCount = computed(() => {
const count = (c: CommentType): number => {
let total = c.replies?.length || 0
c.replies?.forEach(r => { total += count(r) })
return total
}
return count(props.comment)
})
// Format time
function formatTime(timestamp: number): string {
return formatDistanceToNow(timestamp * 1000, { addSuffix: true })
}
// Depth colors for threading lines
const depthColors = [
'border-blue-400',
'border-green-400',
'border-yellow-400',
'border-red-400',
'border-purple-400',
'border-pink-400',
'border-orange-400',
'border-cyan-400'
]
const borderColor = computed(() => depthColors[props.depth % depthColors.length])
// Is own comment
const isOwnComment = computed(() =>
props.currentUserPubkey && props.currentUserPubkey === props.comment.pubkey
)
</script>
<template>
<div
:class="[
'relative',
depth > 0 ? 'ml-3' : ''
]"
>
<!-- Threading line -->
<div
v-if="depth > 0"
:class="[
'absolute left-0 top-0 bottom-0 w-0.5',
borderColor,
'hover:w-1 transition-all cursor-pointer'
]"
@click="emit('toggle-collapse', comment.id)"
/>
<!-- Comment content -->
<div
:class="[
'py-2',
depth > 0 ? 'pl-3' : '',
'hover:bg-accent/20 transition-colors'
]"
>
<!-- Header row -->
<div class="flex items-center gap-2 text-xs">
<!-- Collapse toggle -->
<button
v-if="hasReplies"
class="text-muted-foreground hover:text-foreground"
@click="emit('toggle-collapse', comment.id)"
>
<ChevronDown v-if="!isCollapsed" class="h-3.5 w-3.5" />
<ChevronUp v-else class="h-3.5 w-3.5" />
</button>
<div v-else class="w-3.5" />
<!-- Author -->
<span class="font-medium hover:underline cursor-pointer">
{{ getDisplayName(comment.pubkey) }}
</span>
<!-- Score -->
<span class="text-muted-foreground">
{{ comment.votes.score }} {{ comment.votes.score === 1 ? 'point' : 'points' }}
</span>
<!-- Time -->
<span class="text-muted-foreground">
{{ formatTime(comment.created_at) }}
</span>
<!-- Collapsed indicator -->
<span v-if="isCollapsed && hasReplies" class="text-muted-foreground">
({{ replyCount }} {{ replyCount === 1 ? 'child' : 'children' }})
</span>
</div>
<!-- Content (hidden when collapsed) -->
<div v-if="!isCollapsed">
<!-- Comment body -->
<div class="mt-1 text-sm whitespace-pre-wrap leading-relaxed pl-5">
{{ comment.content }}
</div>
<!-- Actions -->
<div class="flex items-center gap-1 mt-1 pl-5">
<!-- Vote buttons (inline style) -->
<button
class="p-1 text-muted-foreground hover:text-orange-500 transition-colors"
:class="{ 'text-orange-500': comment.votes.userVote === 'upvote' }"
:disabled="!isAuthenticated"
@click="emit('upvote', comment)"
>
<ChevronUp class="h-4 w-4" />
</button>
<button
class="p-1 text-muted-foreground hover:text-blue-500 transition-colors"
:class="{ 'text-blue-500': comment.votes.userVote === 'downvote' }"
:disabled="!isAuthenticated"
@click="emit('downvote', comment)"
>
<ChevronDown class="h-4 w-4" />
</button>
<!-- Reply -->
<button
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
@click="emit('reply', comment)"
>
<Reply class="h-3 w-3" />
<span>reply</span>
</button>
<!-- Report (not for own comments) -->
<button
v-if="!isOwnComment"
class="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
>
<Flag class="h-3 w-3" />
<span>report</span>
</button>
<!-- More options -->
<button
class="p-1 text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
>
<MoreHorizontal class="h-3 w-3" />
</button>
</div>
<!-- Nested replies -->
<div v-if="hasReplies" class="mt-1">
<SubmissionComment
v-for="reply in comment.replies"
:key="reply.id"
:comment="reply"
:depth="depth + 1"
:collapsed-comments="collapsedComments"
:get-display-name="getDisplayName"
:is-authenticated="isAuthenticated"
:current-user-pubkey="currentUserPubkey"
@toggle-collapse="emit('toggle-collapse', $event)"
@reply="emit('reply', $event)"
@upvote="emit('upvote', $event)"
@downvote="emit('downvote', $event)"
/>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,464 @@
<script setup lang="ts">
/**
* SubmissionDetail - Full post view with comments
* Displays complete submission content and threaded comments
*/
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { formatDistanceToNow } from 'date-fns'
import {
ArrowLeft,
MessageSquare,
Share2,
Bookmark,
Flag,
ExternalLink,
Image as ImageIcon,
FileText,
Loader2,
ChevronUp,
ChevronDown,
Reply,
Send
} from 'lucide-vue-next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import VoteControls from './VoteControls.vue'
import SubmissionCommentComponent from './SubmissionComment.vue'
import { useSubmission, useSubmissions } from '../composables/useSubmissions'
import { tryInjectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ProfileService } from '../services/ProfileService'
import type {
SubmissionWithMeta,
SubmissionComment as SubmissionCommentType,
LinkSubmission,
MediaSubmission,
SelfSubmission
} from '../types/submission'
interface Props {
/** Submission ID to display */
submissionId: string
}
const props = defineProps<Props>()
const router = useRouter()
// Inject services
const profileService = tryInjectService<ProfileService>(SERVICE_TOKENS.PROFILE_SERVICE)
const authService = tryInjectService<any>(SERVICE_TOKENS.AUTH_SERVICE)
// Use submission composable
const { submission, comments, upvote, downvote } = useSubmission(props.submissionId)
// Subscribe to fetch the submission if not already loaded
const { subscribe, isLoading, error } = useSubmissions({ autoSubscribe: false })
// Comment composer state
const showComposer = ref(false)
const replyingTo = ref<{ id: string; author: string } | null>(null)
const commentText = ref('')
const isSubmittingComment = ref(false)
// Collapsed comments state
const collapsedComments = ref(new Set<string>())
// Auth state
const isAuthenticated = computed(() => authService?.isAuthenticated?.value || false)
const currentUserPubkey = computed(() => authService?.user?.value?.pubkey || null)
// Get display name for a pubkey
function getDisplayName(pubkey: string): string {
if (profileService) {
const profile = profileService.getProfile(pubkey)
if (profile?.display_name) return profile.display_name
if (profile?.name) return profile.name
}
return `${pubkey.slice(0, 8)}...`
}
// Format timestamp
function formatTime(timestamp: number): string {
return formatDistanceToNow(timestamp * 1000, { addSuffix: true })
}
// Extract domain from URL
function extractDomain(url: string): string {
try {
return new URL(url).hostname.replace('www.', '')
} catch {
return url
}
}
// Cast submission to specific type
const linkSubmission = computed(() =>
submission.value?.postType === 'link' ? submission.value as LinkSubmission : null
)
const mediaSubmission = computed(() =>
submission.value?.postType === 'media' ? submission.value as MediaSubmission : null
)
const selfSubmission = computed(() =>
submission.value?.postType === 'self' ? submission.value as SelfSubmission : null
)
// Community name
const communityName = computed(() => {
if (!submission.value?.communityRef) return null
const parts = submission.value.communityRef.split(':')
return parts.length >= 3 ? parts.slice(2).join(':') : null
})
// Handle voting
async function onUpvote() {
if (!isAuthenticated.value) return
try {
await upvote()
} catch (err) {
console.error('Failed to upvote:', err)
}
}
async function onDownvote() {
if (!isAuthenticated.value) return
try {
await downvote()
} catch (err) {
console.error('Failed to downvote:', err)
}
}
// Handle share
function onShare() {
const url = window.location.href
navigator.clipboard?.writeText(url)
// TODO: Show toast
}
// Handle comment reply
function startReply(comment?: SubmissionCommentType) {
if (comment) {
replyingTo.value = { id: comment.id, author: getDisplayName(comment.pubkey) }
} else {
replyingTo.value = null
}
showComposer.value = true
commentText.value = ''
}
function cancelReply() {
showComposer.value = false
replyingTo.value = null
commentText.value = ''
}
async function submitComment() {
if (!commentText.value.trim() || !isAuthenticated.value) return
isSubmittingComment.value = true
try {
// TODO: Implement comment submission via SubmissionService
console.log('Submit comment:', {
text: commentText.value,
parentId: replyingTo.value?.id || props.submissionId
})
cancelReply()
} catch (err) {
console.error('Failed to submit comment:', err)
} finally {
isSubmittingComment.value = false
}
}
// Toggle comment collapse
function toggleCollapse(commentId: string) {
if (collapsedComments.value.has(commentId)) {
collapsedComments.value.delete(commentId)
} else {
collapsedComments.value.add(commentId)
}
// Trigger reactivity
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
function goBack() {
router.back()
}
// Subscribe on mount if submission not loaded
onMounted(() => {
if (!submission.value) {
subscribe({ limit: 50 })
}
})
</script>
<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 gap-3">
<Button variant="ghost" size="sm" @click="goBack" class="h-8 w-8 p-0">
<ArrowLeft class="h-4 w-4" />
</Button>
<div class="flex-1 min-w-0">
<h1 class="text-sm font-medium truncate">
{{ submission?.title || 'Loading...' }}
</h1>
<p v-if="communityName" class="text-xs text-muted-foreground">
{{ communityName }}
</p>
</div>
</div>
</div>
</header>
<!-- Loading state -->
<div v-if="isLoading && !submission" class="flex items-center justify-center py-16">
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
<span class="ml-2 text-sm text-muted-foreground">Loading submission...</span>
</div>
<!-- Error state -->
<div v-else-if="error" class="max-w-4xl mx-auto p-4">
<div class="text-center py-8">
<p class="text-sm text-destructive">{{ error }}</p>
<Button variant="outline" size="sm" class="mt-4" @click="goBack">
Go Back
</Button>
</div>
</div>
<!-- Submission content -->
<main v-else-if="submission" class="max-w-4xl mx-auto">
<article class="p-4 border-b">
<!-- Post header with votes -->
<div class="flex gap-3">
<!-- Vote controls -->
<VoteControls
:score="submission.votes.score"
:user-vote="submission.votes.userVote"
:disabled="!isAuthenticated"
@upvote="onUpvote"
@downvote="onDownvote"
/>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Title -->
<h1 class="text-xl font-semibold leading-tight mb-2">
{{ submission.title }}
</h1>
<!-- Badges -->
<div v-if="submission.nsfw || submission.flair" class="flex items-center gap-2 mb-2">
<Badge v-if="submission.nsfw" variant="destructive" class="text-xs">
NSFW
</Badge>
<Badge v-if="submission.flair" variant="secondary" class="text-xs">
{{ submission.flair }}
</Badge>
</div>
<!-- Metadata -->
<div class="text-sm text-muted-foreground mb-4">
<span>submitted {{ formatTime(submission.created_at) }}</span>
<span> by </span>
<span class="font-medium hover:underline cursor-pointer">
{{ getDisplayName(submission.pubkey) }}
</span>
<template v-if="communityName">
<span> to </span>
<span class="font-medium hover:underline cursor-pointer">{{ communityName }}</span>
</template>
</div>
<!-- Link post content -->
<div v-if="linkSubmission" class="mb-4">
<a
:href="linkSubmission.url"
target="_blank"
rel="noopener noreferrer"
class="block p-4 border rounded-lg hover:bg-accent/30 transition-colors group"
>
<div class="flex items-start gap-4">
<!-- Preview image -->
<div
v-if="linkSubmission.preview?.image"
class="flex-shrink-0 w-32 h-24 rounded overflow-hidden bg-muted"
>
<img
:src="linkSubmission.preview.image"
:alt="linkSubmission.preview.title || ''"
class="w-full h-full object-cover"
/>
</div>
<div v-else class="flex-shrink-0 w-16 h-16 rounded bg-muted flex items-center justify-center">
<ExternalLink class="h-6 w-6 text-muted-foreground" />
</div>
<!-- Preview content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<ExternalLink class="h-3 w-3" />
<span>{{ extractDomain(linkSubmission.url) }}</span>
</div>
<h3 v-if="linkSubmission.preview?.title" class="font-medium text-sm group-hover:underline">
{{ linkSubmission.preview.title }}
</h3>
<p v-if="linkSubmission.preview?.description" class="text-xs text-muted-foreground mt-1 line-clamp-2">
{{ linkSubmission.preview.description }}
</p>
</div>
</div>
</a>
<!-- Optional body -->
<div v-if="linkSubmission.body" class="mt-4 text-sm whitespace-pre-wrap">
{{ linkSubmission.body }}
</div>
</div>
<!-- Media post content -->
<div v-if="mediaSubmission" class="mb-4">
<div class="rounded-lg overflow-hidden bg-muted">
<img
v-if="mediaSubmission.media.mimeType?.startsWith('image/')"
:src="mediaSubmission.media.url"
:alt="mediaSubmission.media.alt || ''"
class="max-w-full max-h-[600px] mx-auto"
/>
<video
v-else-if="mediaSubmission.media.mimeType?.startsWith('video/')"
:src="mediaSubmission.media.url"
controls
class="max-w-full max-h-[600px] mx-auto"
/>
<div v-else class="p-8 flex flex-col items-center justify-center">
<ImageIcon class="h-12 w-12 text-muted-foreground mb-2" />
<a
:href="mediaSubmission.media.url"
target="_blank"
class="text-sm text-primary hover:underline"
>
View media
</a>
</div>
</div>
<!-- Caption -->
<div v-if="mediaSubmission.body" class="mt-4 text-sm whitespace-pre-wrap">
{{ mediaSubmission.body }}
</div>
</div>
<!-- Self post content -->
<div v-if="selfSubmission" class="mb-4">
<div class="text-sm whitespace-pre-wrap leading-relaxed">
{{ selfSubmission.body }}
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-4 text-sm text-muted-foreground">
<button
class="flex items-center gap-1.5 hover:text-foreground transition-colors"
@click="startReply()"
>
<MessageSquare class="h-4 w-4" />
<span>{{ submission.commentCount }} comments</span>
</button>
<button
class="flex items-center gap-1.5 hover:text-foreground transition-colors"
@click="onShare"
>
<Share2 class="h-4 w-4" />
<span>share</span>
</button>
<button class="flex items-center gap-1.5 hover:text-foreground transition-colors">
<Bookmark class="h-4 w-4" />
<span>save</span>
</button>
<button class="flex items-center gap-1.5 hover:text-foreground transition-colors">
<Flag class="h-4 w-4" />
<span>report</span>
</button>
</div>
</div>
</div>
</article>
<!-- Comment composer -->
<div v-if="showComposer || isAuthenticated" class="p-4 border-b">
<div v-if="replyingTo" class="text-xs text-muted-foreground mb-2">
Replying to {{ replyingTo.author }}
<button class="ml-2 text-primary hover:underline" @click="cancelReply">Cancel</button>
</div>
<div class="flex gap-2">
<textarea
v-model="commentText"
:placeholder="isAuthenticated ? 'Write a comment...' : 'Login to comment'"
:disabled="!isAuthenticated"
rows="3"
class="flex-1 px-3 py-2 text-sm border rounded-lg bg-background resize-none focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div class="flex justify-end mt-2">
<Button
size="sm"
:disabled="!commentText.trim() || isSubmittingComment || !isAuthenticated"
@click="submitComment"
>
<Loader2 v-if="isSubmittingComment" class="h-4 w-4 animate-spin mr-2" />
<Send v-else class="h-4 w-4 mr-2" />
Comment
</Button>
</div>
</div>
<!-- Comments section -->
<section class="divide-y divide-border">
<div v-if="comments.length === 0" class="p-8 text-center text-sm text-muted-foreground">
No comments yet. Be the first to comment!
</div>
<!-- Recursive comment rendering -->
<template v-for="comment in comments" :key="comment.id">
<SubmissionCommentComponent
:comment="comment"
:depth="0"
:collapsed-comments="collapsedComments"
:get-display-name="getDisplayName"
:is-authenticated="isAuthenticated"
:current-user-pubkey="currentUserPubkey"
@toggle-collapse="toggleCollapse"
@reply="startReply"
/>
</template>
</section>
</main>
<!-- Not found state -->
<div v-else class="max-w-4xl mx-auto p-4">
<div class="text-center py-16">
<FileText class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p class="text-sm text-muted-foreground">Submission not found</p>
<Button variant="outline" size="sm" class="mt-4" @click="goBack">
Go Back
</Button>
</div>
</div>
</div>
</template>

View file

@ -120,7 +120,7 @@ function onSubmissionClick(submission: SubmissionWithMeta) {
// Handle share
function onShare(submission: SubmissionWithMeta) {
// Copy link to clipboard or open share dialog
const url = `${window.location.origin}/post/${submission.id}`
const url = `${window.location.origin}/submission/${submission.id}`
navigator.clipboard?.writeText(url)
// TODO: Show toast notification
}

View file

@ -4,10 +4,12 @@ import { container, SERVICE_TOKENS } from '@/core/di-container'
import NostrFeed from './components/NostrFeed.vue'
import SubmissionList from './components/SubmissionList.vue'
import SubmissionRow from './components/SubmissionRow.vue'
import SubmissionDetail from './components/SubmissionDetail.vue'
import SubmissionComment from './components/SubmissionComment.vue'
import VoteControls from './components/VoteControls.vue'
import SortTabs from './components/SortTabs.vue'
import { useFeed } from './composables/useFeed'
import { useSubmissions } from './composables/useSubmissions'
import { useSubmissions, useSubmission } from './composables/useSubmissions'
import { FeedService } from './services/FeedService'
import { ProfileService } from './services/ProfileService'
import { ReactionService } from './services/ReactionService'
@ -23,6 +25,18 @@ export const nostrFeedModule: ModulePlugin = {
version: '1.0.0',
dependencies: ['base'],
routes: [
{
path: '/submission/:id',
name: 'submission-detail',
component: () => import('./views/SubmissionDetailPage.vue'),
meta: {
title: 'Submission',
requiresAuth: false
}
}
],
async install(app: App) {
console.log('nostr-feed module: Starting installation...')
@ -76,13 +90,16 @@ export const nostrFeedModule: ModulePlugin = {
NostrFeed,
SubmissionList,
SubmissionRow,
SubmissionDetail,
SubmissionComment,
VoteControls,
SortTabs
},
composables: {
useFeed,
useSubmissions
useSubmissions,
useSubmission
}
}

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
/**
* SubmissionDetailPage - Page wrapper for submission detail view
* Extracts route params and passes to SubmissionDetail component
*/
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import SubmissionDetail from '../components/SubmissionDetail.vue'
const route = useRoute()
const submissionId = computed(() => route.params.id as string)
</script>
<template>
<SubmissionDetail :submission-id="submissionId" />
</template>

View file

@ -321,6 +321,7 @@
<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'
@ -331,6 +332,8 @@ import SubmissionThumbnail from '@/modules/nostr-feed/components/SubmissionThumb
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')
@ -710,6 +713,6 @@ function onMockDownvote(submission: MockSubmission) {
// Real submission click handler
function onSubmissionClick(submission: any) {
console.log('Clicked submission:', submission)
// TODO: Navigate to detail view
router.push({ name: 'submission-detail', params: { id: submission.id } })
}
</script>