Browse Source

Add comprehensive comment system with actions and threaded replies

Implement full-featured comment system for publications with support for article-level and section-level comments, complete action menus, and threaded reply functionality.

Key features:
- Article-level comments display next to publication header with responsive layout (desktop: right sidebar, mobile: below header)
- Three-dot action menu on all comment levels (root, replies, nested replies) with View details, View JSON, Copy nevent, and Delete options
- Reply functionality for all comment levels with NIP-22 compliant tagging
- View details navigates to event page (/events?id={nevent})
- View JSON opens modal displaying full event data
- Discussion button now available for blog-type publications
- Replace placeholder in Discussion sidebar with actual comment rendering

Technical implementation:
- Responsive comment layout matching section comment pattern
- State management for reply UI, deletion, and modals
- Proper NIP-22 threading with root/parent tag structure
- Integration with deletion service for comment removal
- Navigation to event details page

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

Co-Authored-By: Claude <noreply@anthropic.com>
master
limina1 4 months ago
parent
commit
0919677f24
  1. 75
      src/lib/components/publications/Publication.svelte
  2. 674
      src/lib/components/publications/SectionComments.svelte
  3. 2
      src/lib/components/util/ArticleNav.svelte

75
src/lib/components/publications/Publication.svelte

@ -33,6 +33,7 @@
import HighlightSelectionHandler from "./HighlightSelectionHandler.svelte"; import HighlightSelectionHandler from "./HighlightSelectionHandler.svelte";
import CommentLayer from "./CommentLayer.svelte"; import CommentLayer from "./CommentLayer.svelte";
import CommentButton from "./CommentButton.svelte"; import CommentButton from "./CommentButton.svelte";
import SectionComments from "./SectionComments.svelte";
import { Textarea, P } from "flowbite-svelte"; import { Textarea, P } from "flowbite-svelte";
import { userStore } from "$lib/stores/userStore"; import { userStore } from "$lib/stores/userStore";
@ -96,6 +97,14 @@
return addresses; return addresses;
}); });
// Filter comments for the root publication (kind 30040)
let articleComments = $derived(
comments.filter(comment => {
// Check if comment targets the root publication via #a tag
const aTag = comment.tags.find(t => t[0] === 'a');
return aTag && aTag[1] === rootAddress;
})
);
// #region Loading // #region Loading
let leaves = $state<Array<NDKEvent | null>>([]); let leaves = $state<Array<NDKEvent | null>>([]);
@ -505,18 +514,42 @@
{#if $publicationColumnVisibility.main} {#if $publicationColumnVisibility.main}
<!-- Remove overflow-auto so page scroll drives it --> <!-- Remove overflow-auto so page scroll drives it -->
<div class="flex flex-col p-4 space-y-4 max-w-3xl flex-grow-2 mx-auto" bind:this={publicationContentRef}> <div class="flex flex-col p-4 space-y-4 max-w-3xl flex-grow-2 mx-auto" bind:this={publicationContentRef}>
<div <!-- Publication header with comments (similar to section layout) -->
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border" <div class="relative">
> <!-- Main header content - centered -->
<Details event={indexEvent} onDelete={handleDeletePublication} /> <div class="max-w-4xl mx-auto px-4">
</div> <div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details event={indexEvent} onDelete={handleDeletePublication} />
</div>
{#if publicationDeleted} {#if publicationDeleted}
<Alert color="yellow" class="mb-4"> <Alert color="yellow" class="mb-4">
<ExclamationCircleOutline class="w-5 h-5 inline mr-2" /> <ExclamationCircleOutline class="w-5 h-5 inline mr-2" />
Publication deleted. Redirecting to publications page... Publication deleted. Redirecting to publications page...
</Alert> </Alert>
{/if} {/if}
</div>
<!-- Mobile article comments - shown below header on smaller screens -->
<div class="xl:hidden mt-4 max-w-4xl mx-auto px-4">
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
</div>
<!-- Desktop article comments - positioned on right side on XL+ screens -->
<div class="hidden xl:block absolute left-[calc(50%+26rem)] top-0 w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]">
<SectionComments
sectionAddress={rootAddress}
comments={articleComments}
visible={commentsVisible}
/>
</div>
</div>
<!-- Action buttons row --> <!-- Action buttons row -->
<div class="flex justify-between gap-2 mb-4"> <div class="flex justify-between gap-2 mb-4">
@ -698,16 +731,16 @@
/> />
{/if} {/if}
<div class="flex flex-col w-full space-y-4"> <div class="flex flex-col w-full space-y-4">
<Card class="ArticleBox card-leather w-full grid max-w-xl"> <SectionComments
<div class="flex flex-col my-2"> sectionAddress={rootAddress}
<span>Unknown</span> comments={articleComments}
<span class="text-gray-500">1.1.1970</span> visible={commentsVisible}
</div> />
<div class="flex flex-col flex-grow space-y-4"> {#if articleComments.length === 0}
This is a very intelligent comment placeholder that applies to <p class="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
all the content equally well. No comments yet. Be the first to comment!
</div> </p>
</Card> {/if}
</div> </div>
</div> </div>
</SidebarGroup> </SidebarGroup>

674
src/lib/components/publications/SectionComments.svelte

@ -3,8 +3,12 @@
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils"; import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { getNdkContext } from "$lib/ndk"; import { getNdkContext } from "$lib/ndk";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte"; import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
import { ChevronDownOutline, ChevronRightOutline } from "flowbite-svelte-icons"; import { ChevronDownOutline, ChevronRightOutline, DotsVerticalOutline, TrashBinOutline, ClipboardCleanOutline, EyeOutline } from "flowbite-svelte-icons";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { Button, Popover, Modal, Textarea, P } from "flowbite-svelte";
import { deleteEvent, canDeleteEvent } from "$lib/services/deletion";
import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation";
let { let {
sectionAddress, sectionAddress,
@ -21,6 +25,16 @@
// State management // State management
let profiles = $state(new Map<string, any>()); let profiles = $state(new Map<string, any>());
let expandedThreads = $state(new Set<string>()); let expandedThreads = $state(new Set<string>());
let jsonModalOpen = $state<string | null>(null);
let deletingComments = $state(new Set<string>());
let replyingTo = $state<string | null>(null);
let replyContent = $state("");
let isSubmittingReply = $state(false);
let replyError = $state<string | null>(null);
let replySuccess = $state<string | null>(null);
// Subscribe to userStore
let user = $derived($userStore);
/** /**
* Parse comment threading structure * Parse comment threading structure
@ -173,6 +187,147 @@
} }
} }
/**
* Navigate to event details page
*/
function viewEventDetails(comment: NDKEvent) {
const nevent = nip19.neventEncode({
id: comment.id,
author: comment.pubkey,
kind: comment.kind,
});
goto(`/events?id=${encodeURIComponent(nevent)}`);
}
/**
* Check if user can delete a comment
*/
function canDelete(comment: NDKEvent): boolean {
return canDeleteEvent(comment, ndk);
}
/**
* Submit a reply to a comment
*/
async function submitReply(parentComment: NDKEvent) {
if (!replyContent.trim()) {
replyError = "Reply cannot be empty";
return;
}
if (!user.signedIn || !user.signer) {
replyError = "You must be signed in to reply";
return;
}
isSubmittingReply = true;
replyError = null;
replySuccess = null;
try {
const { NDKEvent: NDKEventClass } = await import("@nostr-dev-kit/ndk");
const { activeOutboxRelays } = await import("$lib/ndk");
// Get relay hint
const relays = activeOutboxRelays;
let relayHint = "";
relays.subscribe((r) => { relayHint = r[0] || ""; })();
// Create reply event (kind 1111)
const replyEvent = new NDKEventClass(ndk);
replyEvent.kind = 1111;
replyEvent.content = replyContent;
// Parse section address to get root event details
const rootParts = sectionAddress.split(":");
if (rootParts.length !== 3) {
throw new Error("Invalid section address format");
}
const [rootKindStr, rootAuthorPubkey, rootDTag] = rootParts;
const rootKind = parseInt(rootKindStr);
// NIP-22 reply tags structure:
// - Root tags (A, K, P) point to the section/article
// - Parent tags (a, k, p) point to the parent comment
// - Add 'e' tag with 'reply' marker for the parent comment
replyEvent.tags = [
// Root scope - uppercase tags (point to section)
["A", sectionAddress, relayHint, rootAuthorPubkey],
["K", rootKind.toString()],
["P", rootAuthorPubkey, relayHint],
// Parent scope - lowercase tags (point to parent comment)
["a", `1111:${parentComment.pubkey}:`, relayHint],
["k", "1111"],
["p", parentComment.pubkey, relayHint],
// Reply marker
["e", parentComment.id, relayHint, "reply"],
];
console.log("[SectionComments] Creating reply with tags:", replyEvent.tags);
// Sign and publish
await replyEvent.sign();
await replyEvent.publish();
console.log("[SectionComments] Reply published:", replyEvent.id);
replySuccess = parentComment.id;
replyContent = "";
// Close reply UI after a delay
setTimeout(() => {
replyingTo = null;
replySuccess = null;
}, 2000);
} catch (err) {
console.error("[SectionComments] Error submitting reply:", err);
replyError = err instanceof Error ? err.message : "Failed to submit reply";
} finally {
isSubmittingReply = false;
}
}
/**
* Delete a comment
*/
async function handleDeleteComment(comment: NDKEvent) {
if (!canDelete(comment)) return;
if (!confirm('Are you sure you want to delete this comment?')) {
return;
}
const newDeleting = new Set(deletingComments);
newDeleting.add(comment.id);
deletingComments = newDeleting;
try {
const result = await deleteEvent({
eventId: comment.id,
eventKind: comment.kind,
reason: 'User deleted comment',
}, ndk);
if (result.success) {
console.log('[SectionComments] Comment deleted successfully');
// Note: The comment will still show in the UI until the page is refreshed
// or the parent component refetches comments
} else {
alert(`Failed to delete comment: ${result.error}`);
}
} catch (err) {
console.error('[SectionComments] Error deleting comment:', err);
alert('Failed to delete comment');
} finally {
const newDeleting = new Set(deletingComments);
newDeleting.delete(comment.id);
deletingComments = newDeleting;
}
}
/** /**
* Pre-fetch profiles for all comment authors * Pre-fetch profiles for all comment authors
*/ */
@ -220,8 +375,91 @@
{replyCount} {replyCount === 1 ? 'reply' : 'replies'} {replyCount} {replyCount === 1 ? 'reply' : 'replies'}
</span> </span>
{/if} {/if}
<span class="text-gray-400 dark:text-gray-500"></span>
<button
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors"
onclick={(e) => {
e.stopPropagation();
replyingTo = replyingTo === rootComment.id ? null : rootComment.id;
replyError = null;
replySuccess = null;
// Auto-expand when replying from collapsed view
if (!expandedThreads.has(rootComment.id)) {
toggleThread(rootComment.id);
}
}}
>
Reply
</button>
</div> </div>
</div> </div>
<!-- Actions menu in collapsed view -->
<div class="flex-shrink-0 mt-1">
<button
id="comment-actions-collapsed-{rootComment.id}"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
aria-label="Comment actions"
onclick={(e) => { e.stopPropagation(); }}
>
<DotsVerticalOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" />
</button>
<Popover
triggeredBy="#comment-actions-collapsed-{rootComment.id}"
placement="bottom-end"
class="w-48 text-sm"
>
<ul class="space-y-1">
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
viewEventDetails(rootComment);
}}
>
<EyeOutline class="w-4 h-4" />
View details
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
jsonModalOpen = rootComment.id;
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
View JSON
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={async () => {
await copyNevent(rootComment);
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
Copy nevent
</button>
</li>
{#if canDelete(rootComment)}
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400"
onclick={() => {
handleDeleteComment(rootComment);
}}
disabled={deletingComments.has(rootComment.id)}
>
<TrashBinOutline class="w-4 h-4" />
{deletingComments.has(rootComment.id) ? 'Deleting...' : 'Delete comment'}
</button>
</li>
{/if}
</ul>
</Popover>
</div>
</div> </div>
{:else} {:else}
<!-- Expanded view --> <!-- Expanded view -->
@ -253,6 +491,72 @@
{replyCount} {replyCount === 1 ? 'reply' : 'replies'} {replyCount} {replyCount === 1 ? 'reply' : 'replies'}
</span> </span>
{/if} {/if}
<!-- Actions menu -->
<div class="ml-auto">
<button
id="comment-actions-{rootComment.id}"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
aria-label="Comment actions"
>
<DotsVerticalOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" />
</button>
<Popover
triggeredBy="#comment-actions-{rootComment.id}"
placement="bottom-end"
class="w-48 text-sm"
>
<ul class="space-y-1">
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
viewEventDetails(rootComment);
}}
>
<EyeOutline class="w-4 h-4" />
View details
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
jsonModalOpen = rootComment.id;
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
View JSON
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={async () => {
await copyNevent(rootComment);
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
Copy nevent
</button>
</li>
{#if canDelete(rootComment)}
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400"
onclick={() => {
handleDeleteComment(rootComment);
}}
disabled={deletingComments.has(rootComment.id)}
>
<TrashBinOutline class="w-4 h-4" />
{deletingComments.has(rootComment.id) ? 'Deleting...' : 'Delete comment'}
</button>
</li>
{/if}
</ul>
</Popover>
</div>
</div> </div>
<!-- Full content --> <!-- Full content -->
@ -261,12 +565,69 @@
{@render basicMarkup(rootComment.content)} {@render basicMarkup(rootComment.content)}
</div> </div>
<!-- Reply button -->
<div class="mb-3">
<Button
size="xs"
color="light"
onclick={() => {
replyingTo = replyingTo === rootComment.id ? null : rootComment.id;
replyError = null;
replySuccess = null;
}}
>
{replyingTo === rootComment.id ? 'Cancel Reply' : 'Reply'}
</Button>
</div>
<!-- Reply UI -->
{#if replyingTo === rootComment.id}
<div class="mb-4 border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-700">
<Textarea
bind:value={replyContent}
placeholder="Write your reply..."
rows={3}
disabled={isSubmittingReply}
class="mb-2"
/>
{#if replyError}
<P class="text-red-600 dark:text-red-400 text-sm mb-2">{replyError}</P>
{/if}
{#if replySuccess === rootComment.id}
<P class="text-green-600 dark:text-green-400 text-sm mb-2">Reply posted successfully!</P>
{/if}
<div class="flex gap-2">
<Button
size="sm"
onclick={() => submitReply(rootComment)}
disabled={isSubmittingReply || !replyContent.trim()}
>
{isSubmittingReply ? 'Posting...' : 'Post Reply'}
</Button>
<Button
size="sm"
color="light"
onclick={() => {
replyingTo = null;
replyContent = "";
replyError = null;
}}
>
Cancel
</Button>
</div>
</div>
{/if}
<!-- Replies --> <!-- Replies -->
{#if replyCount > 0} {#if replyCount > 0}
<div class="pl-4 border-l-2 border-gray-200 dark:border-gray-600 space-y-2"> <div class="pl-4 border-l-2 border-gray-200 dark:border-gray-600 space-y-2">
{#each renderReplies(rootComment.id, threadStructure.repliesByParent) as reply (reply.id)} {#each renderReplies(rootComment.id, threadStructure.repliesByParent) as reply (reply.id)}
<div class="bg-gray-50 dark:bg-gray-700/30 rounded p-3"> <div class="bg-gray-50 dark:bg-gray-700/30 rounded p-3">
<div class="flex items-baseline gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<button <button
class="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors" class="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors"
onclick={(e) => { e.stopPropagation(); copyNevent(reply); }} onclick={(e) => { e.stopPropagation(); copyNevent(reply); }}
@ -277,15 +638,139 @@
<span class="text-xs text-gray-500 dark:text-gray-400"> <span class="text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(reply.created_at || 0)} {formatTimestamp(reply.created_at || 0)}
</span> </span>
<!-- Three-dot menu for reply -->
<div class="ml-auto flex items-center gap-2">
<button
id="reply-actions-{reply.id}"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
aria-label="Reply actions"
onclick={(e) => { e.stopPropagation(); }}
>
<DotsVerticalOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button>
<Popover
triggeredBy="#reply-actions-{reply.id}"
placement="bottom-end"
class="w-48 text-sm"
>
<ul class="space-y-1">
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
viewEventDetails(reply);
}}
>
<EyeOutline class="w-4 h-4" />
View details
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
jsonModalOpen = reply.id;
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
View JSON
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={async () => {
await copyNevent(reply);
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
Copy nevent
</button>
</li>
{#if canDelete(reply)}
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400"
onclick={() => {
handleDeleteComment(reply);
}}
disabled={deletingComments.has(reply.id)}
>
<TrashBinOutline class="w-4 h-4" />
{deletingComments.has(reply.id) ? 'Deleting...' : 'Delete comment'}
</button>
</li>
{/if}
</ul>
</Popover>
</div>
</div> </div>
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none"> <div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none mb-2">
{@render basicMarkup(reply.content)} {@render basicMarkup(reply.content)}
</div> </div>
<!-- Reply button for first-level reply -->
<div class="mb-2">
<Button
size="xs"
color="light"
onclick={() => {
replyingTo = replyingTo === reply.id ? null : reply.id;
replyError = null;
replySuccess = null;
}}
>
{replyingTo === reply.id ? 'Cancel Reply' : 'Reply'}
</Button>
</div>
<!-- Reply UI for first-level reply -->
{#if replyingTo === reply.id}
<div class="mb-3 border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-800">
<Textarea
bind:value={replyContent}
placeholder="Write your reply..."
rows={3}
disabled={isSubmittingReply}
class="mb-2"
/>
{#if replyError}
<P class="text-red-600 dark:text-red-400 text-sm mb-2">{replyError}</P>
{/if}
{#if replySuccess === reply.id}
<P class="text-green-600 dark:text-green-400 text-sm mb-2">Reply posted successfully!</P>
{/if}
<div class="flex gap-2">
<Button
size="sm"
onclick={() => submitReply(reply)}
disabled={isSubmittingReply || !replyContent.trim()}
>
{isSubmittingReply ? 'Posting...' : 'Post Reply'}
</Button>
<Button
size="sm"
color="light"
onclick={() => {
replyingTo = null;
replyContent = "";
replyError = null;
}}
>
Cancel
</Button>
</div>
</div>
{/if}
<!-- Nested replies (one level deep) --> <!-- Nested replies (one level deep) -->
{#each renderReplies(reply.id, threadStructure.repliesByParent) as nestedReply (nestedReply.id)} {#each renderReplies(reply.id, threadStructure.repliesByParent) as nestedReply (nestedReply.id)}
<div class="ml-4 mt-2 bg-gray-100 dark:bg-gray-600/30 rounded p-2"> <div class="ml-4 mt-2 bg-gray-100 dark:bg-gray-600/30 rounded p-2">
<div class="flex items-baseline gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
<button <button
class="text-xs font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors" class="text-xs font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors"
onclick={(e) => { e.stopPropagation(); copyNevent(nestedReply); }} onclick={(e) => { e.stopPropagation(); copyNevent(nestedReply); }}
@ -296,10 +781,134 @@
<span class="text-xs text-gray-500 dark:text-gray-400"> <span class="text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(nestedReply.created_at || 0)} {formatTimestamp(nestedReply.created_at || 0)}
</span> </span>
<!-- Three-dot menu for nested reply -->
<div class="ml-auto flex items-center gap-2">
<button
id="nested-reply-actions-{nestedReply.id}"
class="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
aria-label="Nested reply actions"
onclick={(e) => { e.stopPropagation(); }}
>
<DotsVerticalOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" />
</button>
<Popover
triggeredBy="#nested-reply-actions-{nestedReply.id}"
placement="bottom-end"
class="w-48 text-sm"
>
<ul class="space-y-1">
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
viewEventDetails(nestedReply);
}}
>
<EyeOutline class="w-4 h-4" />
View details
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={() => {
jsonModalOpen = nestedReply.id;
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
View JSON
</button>
</li>
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
onclick={async () => {
await copyNevent(nestedReply);
}}
>
<ClipboardCleanOutline class="w-4 h-4" />
Copy nevent
</button>
</li>
{#if canDelete(nestedReply)}
<li>
<button
class="w-full text-left px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded flex items-center gap-2 text-red-600 dark:text-red-400"
onclick={() => {
handleDeleteComment(nestedReply);
}}
disabled={deletingComments.has(nestedReply.id)}
>
<TrashBinOutline class="w-4 h-4" />
{deletingComments.has(nestedReply.id) ? 'Deleting...' : 'Delete comment'}
</button>
</li>
{/if}
</ul>
</Popover>
</div>
</div> </div>
<div class="text-xs text-gray-700 dark:text-gray-300"> <div class="text-xs text-gray-700 dark:text-gray-300 mb-2">
{@render basicMarkup(nestedReply.content)} {@render basicMarkup(nestedReply.content)}
</div> </div>
<!-- Reply button for nested reply -->
<div class="mb-1">
<Button
size="xs"
color="light"
onclick={() => {
replyingTo = replyingTo === nestedReply.id ? null : nestedReply.id;
replyError = null;
replySuccess = null;
}}
>
{replyingTo === nestedReply.id ? 'Cancel Reply' : 'Reply'}
</Button>
</div>
<!-- Reply UI for nested reply -->
{#if replyingTo === nestedReply.id}
<div class="mb-2 border border-gray-300 dark:border-gray-600 rounded-lg p-2 bg-white dark:bg-gray-800">
<Textarea
bind:value={replyContent}
placeholder="Write your reply..."
rows={2}
disabled={isSubmittingReply}
class="mb-2 text-xs"
/>
{#if replyError}
<P class="text-red-600 dark:text-red-400 text-xs mb-1">{replyError}</P>
{/if}
{#if replySuccess === nestedReply.id}
<P class="text-green-600 dark:text-green-400 text-xs mb-1">Reply posted successfully!</P>
{/if}
<div class="flex gap-2">
<Button
size="xs"
onclick={() => submitReply(nestedReply)}
disabled={isSubmittingReply || !replyContent.trim()}
>
{isSubmittingReply ? 'Posting...' : 'Post Reply'}
</Button>
<Button
size="xs"
color="light"
onclick={() => {
replyingTo = null;
replyContent = "";
replyError = null;
}}
>
Cancel
</Button>
</div>
</div>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@ -314,6 +923,61 @@
</div> </div>
{/if} {/if}
<!-- JSON Modal -->
{#if jsonModalOpen}
{@const comment = comments.find(c => c.id === jsonModalOpen)}
{#if comment}
<Modal
title="Comment JSON"
open={true}
autoclose
outsideclose
size="lg"
class="modal-leather"
onclose={() => jsonModalOpen = null}
>
<div class="space-y-4">
<div>
<h3 class="font-semibold text-sm text-gray-700 dark:text-gray-300 mb-2">Author</h3>
<p class="text-sm font-mono break-all">{comment.pubkey}</p>
</div>
<div>
<h3 class="font-semibold text-sm text-gray-700 dark:text-gray-300 mb-2">Event ID</h3>
<p class="text-sm font-mono break-all">{comment.id}</p>
</div>
<div>
<h3 class="font-semibold text-sm text-gray-700 dark:text-gray-300 mb-2">Kind</h3>
<p class="text-sm">{comment.kind}</p>
</div>
<div>
<h3 class="font-semibold text-sm text-gray-700 dark:text-gray-300 mb-2">Created</h3>
<p class="text-sm">{new Date((comment.created_at || 0) * 1000).toLocaleString()}</p>
</div>
<div>
<h3 class="font-semibold text-sm text-gray-700 dark:text-gray-300 mb-2">Content</h3>
<p class="text-sm whitespace-pre-wrap break-words">{comment.content}</p>
</div>
<div>
<h3 class="font-semibold text-sm text-gray-700 dark:text-gray-300 mb-2">Tags</h3>
<pre class="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto">{JSON.stringify(comment.tags, null, 2)}</pre>
</div>
<div>
<h3 class="font-semibold text-sm text-gray-700 dark:text-gray-300 mb-2">Raw Event JSON</h3>
<pre class="text-xs bg-gray-100 dark:bg-gray-800 p-3 rounded overflow-x-auto max-h-96 overflow-y-auto">{JSON.stringify({
id: comment.id,
pubkey: comment.pubkey,
created_at: comment.created_at,
kind: comment.kind,
tags: comment.tags,
content: comment.content,
sig: comment.sig
}, null, 2)}</pre>
</div>
</div>
</Modal>
{/if}
{/if}
<style> <style>
/* Ensure proper text wrapping */ /* Ensure proper text wrapping */
.prose { .prose {

2
src/lib/components/util/ArticleNav.svelte

@ -211,7 +211,7 @@
<span class="hidden sm:inline">Close</span> <span class="hidden sm:inline">Close</span>
</Button> </Button>
{/if} {/if}
{#if publicationType !== "blog" && !$publicationColumnVisibility.discussion} {#if !$publicationColumnVisibility.discussion}
<Button <Button
class="btn-leather !hidden sm:flex !w-auto" class="btn-leather !hidden sm:flex !w-auto"
outline={true} outline={true}

Loading…
Cancel
Save