[Draft] feat(nostr-feed): Reddit-style link aggregator #9
2 changed files with 109 additions and 16 deletions
|
|
@ -4,9 +4,9 @@
|
|||
* Displays a single comment with vote controls and nested replies
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { ChevronUp, ChevronDown, Reply, Flag, MoreHorizontal } from 'lucide-vue-next'
|
||||
import { ChevronUp, ChevronDown, Reply, Flag, MoreHorizontal, Send, X } from 'lucide-vue-next'
|
||||
import VoteControls from './VoteControls.vue'
|
||||
import type { SubmissionComment as CommentType } from '../types/submission'
|
||||
|
||||
|
|
@ -17,18 +17,49 @@ interface Props {
|
|||
getDisplayName: (pubkey: string) => string
|
||||
isAuthenticated: boolean
|
||||
currentUserPubkey?: string | null
|
||||
replyingToId?: string | null
|
||||
isSubmittingReply?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle-collapse', commentId: string): void
|
||||
(e: 'reply', comment: CommentType): void
|
||||
(e: 'cancel-reply'): void
|
||||
(e: 'submit-reply', commentId: string, text: string): void
|
||||
(e: 'upvote', comment: CommentType): void
|
||||
(e: 'downvote', comment: CommentType): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
replyingToId: null,
|
||||
isSubmittingReply: false
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Local reply text
|
||||
const replyText = ref('')
|
||||
|
||||
// Is this comment being replied to
|
||||
const isBeingRepliedTo = computed(() => props.replyingToId === props.comment.id)
|
||||
|
||||
// Handle reply click
|
||||
function onReplyClick() {
|
||||
emit('reply', props.comment)
|
||||
}
|
||||
|
||||
// Submit the reply
|
||||
function submitReply() {
|
||||
if (!replyText.value.trim()) return
|
||||
emit('submit-reply', props.comment.id, replyText.value.trim())
|
||||
replyText.value = ''
|
||||
}
|
||||
|
||||
// Cancel reply
|
||||
function cancelReply() {
|
||||
replyText.value = ''
|
||||
emit('cancel-reply')
|
||||
}
|
||||
|
||||
// Is this comment collapsed
|
||||
const isCollapsed = computed(() => props.collapsedComments.has(props.comment.id))
|
||||
|
||||
|
|
@ -168,7 +199,8 @@ const isOwnComment = computed(() =>
|
|||
<!-- 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)"
|
||||
:disabled="!isAuthenticated"
|
||||
@click="onReplyClick"
|
||||
>
|
||||
<Reply class="h-3 w-3" />
|
||||
<span>reply</span>
|
||||
|
|
@ -191,6 +223,35 @@ const isOwnComment = computed(() =>
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inline reply form -->
|
||||
<div v-if="isBeingRepliedTo" class="mt-2 pl-5">
|
||||
<div class="border rounded-lg bg-background p-2">
|
||||
<textarea
|
||||
v-model="replyText"
|
||||
placeholder="Write a reply..."
|
||||
rows="3"
|
||||
class="w-full px-2 py-1.5 text-sm bg-transparent resize-none focus:outline-none"
|
||||
:disabled="isSubmittingReply"
|
||||
/>
|
||||
<div class="flex items-center justify-end gap-2 mt-1">
|
||||
<button
|
||||
class="px-3 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="cancelReply"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1 px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
:disabled="!replyText.trim() || isSubmittingReply"
|
||||
@click="submitReply"
|
||||
>
|
||||
<Send class="h-3 w-3" />
|
||||
Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nested replies -->
|
||||
<div v-if="hasReplies" class="mt-1">
|
||||
<SubmissionComment
|
||||
|
|
@ -202,8 +263,12 @@ const isOwnComment = computed(() =>
|
|||
:get-display-name="getDisplayName"
|
||||
:is-authenticated="isAuthenticated"
|
||||
:current-user-pubkey="currentUserPubkey"
|
||||
:replying-to-id="replyingToId"
|
||||
:is-submitting-reply="isSubmittingReply"
|
||||
@toggle-collapse="emit('toggle-collapse', $event)"
|
||||
@reply="emit('reply', $event)"
|
||||
@cancel-reply="emit('cancel-reply')"
|
||||
@submit-reply="(commentId, text) => emit('submit-reply', commentId, text)"
|
||||
@upvote="emit('upvote', $event)"
|
||||
@downvote="emit('downvote', $event)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -169,14 +169,20 @@ function onShare() {
|
|||
// TODO: Show toast
|
||||
}
|
||||
|
||||
// Handle comment reply
|
||||
// Computed for passing to comment components
|
||||
const replyingToId = computed(() => replyingTo.value?.id || null)
|
||||
|
||||
// Handle comment reply - for inline replies to comments
|
||||
function startReply(comment?: SubmissionCommentType) {
|
||||
if (comment) {
|
||||
// Replying to a comment - show inline form (handled by SubmissionComment)
|
||||
replyingTo.value = { id: comment.id, author: getDisplayName(comment.pubkey) }
|
||||
showComposer.value = false // Hide top composer
|
||||
} else {
|
||||
// Top-level comment - show top composer
|
||||
replyingTo.value = null
|
||||
showComposer.value = true
|
||||
}
|
||||
showComposer.value = true
|
||||
commentText.value = ''
|
||||
}
|
||||
|
||||
|
|
@ -186,6 +192,7 @@ function cancelReply() {
|
|||
commentText.value = ''
|
||||
}
|
||||
|
||||
// Submit top-level comment (from top composer)
|
||||
async function submitComment() {
|
||||
if (!commentText.value.trim() || !isAuthenticated.value || !submissionService) return
|
||||
|
||||
|
|
@ -196,7 +203,7 @@ async function submitComment() {
|
|||
await submissionService.createComment(
|
||||
props.submissionId,
|
||||
commentText.value.trim(),
|
||||
replyingTo.value?.id
|
||||
null // Top-level comment
|
||||
)
|
||||
cancelReply()
|
||||
} catch (err: any) {
|
||||
|
|
@ -207,6 +214,28 @@ async function submitComment() {
|
|||
}
|
||||
}
|
||||
|
||||
// Submit inline reply (from SubmissionComment's inline form)
|
||||
async function submitReply(commentId: string, text: string) {
|
||||
if (!text.trim() || !isAuthenticated.value || !submissionService) return
|
||||
|
||||
isSubmittingComment.value = true
|
||||
commentError.value = null
|
||||
|
||||
try {
|
||||
await submissionService.createComment(
|
||||
props.submissionId,
|
||||
text.trim(),
|
||||
commentId
|
||||
)
|
||||
cancelReply()
|
||||
} catch (err: any) {
|
||||
console.error('Failed to submit reply:', err)
|
||||
commentError.value = err.message || 'Failed to post reply'
|
||||
} finally {
|
||||
isSubmittingComment.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle comment collapse
|
||||
function toggleCollapse(commentId: string) {
|
||||
if (collapsedComments.value.has(commentId)) {
|
||||
|
|
@ -429,17 +458,12 @@ function goBack() {
|
|||
</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>
|
||||
<!-- Top-level comment composer (only for new comments, not replies) -->
|
||||
<div v-if="isAuthenticated && !replyingTo" class="p-4 border-b">
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
v-model="commentText"
|
||||
:placeholder="isAuthenticated ? 'Write a comment...' : 'Login to comment'"
|
||||
:disabled="!isAuthenticated"
|
||||
placeholder="Write a comment..."
|
||||
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"
|
||||
/>
|
||||
|
|
@ -451,7 +475,7 @@ function goBack() {
|
|||
<div class="flex justify-end mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="!commentText.trim() || isSubmittingComment || !isAuthenticated"
|
||||
:disabled="!commentText.trim() || isSubmittingComment"
|
||||
@click="submitComment"
|
||||
>
|
||||
<Loader2 v-if="isSubmittingComment" class="h-4 w-4 animate-spin mr-2" />
|
||||
|
|
@ -490,8 +514,12 @@ function goBack() {
|
|||
:get-display-name="getDisplayName"
|
||||
:is-authenticated="isAuthenticated"
|
||||
:current-user-pubkey="currentUserPubkey"
|
||||
:replying-to-id="replyingToId"
|
||||
:is-submitting-reply="isSubmittingComment"
|
||||
@toggle-collapse="toggleCollapse"
|
||||
@reply="startReply"
|
||||
@cancel-reply="cancelReply"
|
||||
@submit-reply="submitReply"
|
||||
@upvote="onCommentUpvote"
|
||||
@downvote="onCommentDownvote"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue