[Draft] feat(nostr-feed): Reddit-style link aggregator #9
7 changed files with 732 additions and 17 deletions
|
|
@ -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`
|
||||
|
|
|
|||
206
src/modules/nostr-feed/components/SubmissionComment.vue
Normal file
206
src/modules/nostr-feed/components/SubmissionComment.vue
Normal 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>
|
||||
464
src/modules/nostr-feed/components/SubmissionDetail.vue
Normal file
464
src/modules/nostr-feed/components/SubmissionDetail.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
18
src/modules/nostr-feed/views/SubmissionDetailPage.vue
Normal file
18
src/modules/nostr-feed/views/SubmissionDetailPage.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue