Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 222 additions & 15 deletions src/components/admin/DirectPostEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { ArrowLeft, Send, Loader2, Save, Trash2, RefreshCw } from 'lucide-react'
import { ArrowLeft, Send, Loader2, Save, Trash2, RefreshCw, Plus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
Expand All @@ -20,6 +20,7 @@ import { NDKEvent } from '@nostr-dev-kit/ndk'
import { extractImageUrls } from '@/lib/blossom'
import { extractLinkUrls, fetchLinkMetadata, type LinkMetadata } from '@/lib/urlUtils'
import { cn } from '@/lib/utils/cn'
import { hasThreadMarker, joinThreadPosts, splitThreadPosts } from '@/lib/threadUtils'
import {
AlertDialog,
AlertDialogAction,
Expand Down Expand Up @@ -49,8 +50,11 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
const [summary, setSummary] = useState('')
const [content, setContent] = useState('')
const [isLongForm, setIsLongForm] = useState(false)
const [isThread, setIsThread] = useState(false)
const [threadPosts, setThreadPosts] = useState<string[]>([''])
const [coverImage, setCoverImage] = useState<string | undefined>()
const [isPublishing, setIsPublishing] = useState(false)
const [publishingPostIndex, setPublishingPostIndex] = useState<number | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [includeCredit, setIncludeCredit] = useState(creditGhostr)
const [attachedImages, setAttachedImages] = useState<string[]>([])
Expand Down Expand Up @@ -92,6 +96,10 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
setTitle(currentDraft.title)
setContent(currentDraft.content)
setIsLongForm(currentDraft.targetKind === 30023)
const savedAsThread = currentDraft.targetKind === 1 && hasThreadMarker(currentDraft.tags)
const savedThreadPosts = savedAsThread ? splitThreadPosts(currentDraft.content) : []
setIsThread(savedAsThread)
setThreadPosts(savedThreadPosts.length > 0 ? savedThreadPosts : [''])
setCoverImage(currentDraft.coverImage)
setAttachedImages(currentDraft.uploadedImages || [])
setHasChanges(false)
Expand Down Expand Up @@ -134,11 +142,14 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
attachedLinks.forEach((link) => {
tags.push(['r', link.url])
})
if (isThread) {
tags.push(['ghostr-thread', 'true'])
}
}

updateDraft(currentDraftId, {
title,
content,
content: !isLongForm && isThread ? threadContent() : content,
targetKind: isLongForm ? 30023 : 1,
tags,
coverImage,
Expand All @@ -147,7 +158,30 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
}, 1000)

return () => clearTimeout(autoSaveTimeoutRef.current)
}, [title, content, isLongForm, coverImage, attachedImages, attachedLinks, currentDraftId, currentDraft, updateDraft])
}, [title, content, isLongForm, isThread, threadPosts, coverImage, attachedImages, attachedLinks, currentDraftId, currentDraft, updateDraft])

const threadContent = (posts: string[] = threadPosts) => joinThreadPosts(posts)

const updateThreadPost = (index: number, value: string) => {
setThreadPosts((prev) => prev.map((post, i) => (i === index ? value : post)))
}

const addThreadPost = () => setThreadPosts((prev) => [...prev, ''])

const removeThreadPost = (index: number) => {
setThreadPosts((prev) => prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)
}

const enableThreadMode = () => {
setThreadPosts(content.trim() ? [content] : [''])
setIsThread(true)
setIsLongForm(false)
}

const disableThreadMode = () => {
setContent(threadContent())
setIsThread(false)
}

const handleImageUpload = (url: string) => {
if (isLongForm) {
Expand Down Expand Up @@ -212,12 +246,15 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
attachedLinks.forEach((link) => {
tags.push(['r', link.url])
})
if (isThread) {
tags.push(['ghostr-thread', 'true'])
}
}

// Update local state
updateDraft(currentDraft.id, {
title,
content,
content: !isLongForm && isThread ? threadContent() : content,
targetKind: isLongForm ? 30023 : 1,
tags,
coverImage,
Expand Down Expand Up @@ -264,11 +301,14 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
attachedLinks.forEach((link) => {
tags.push(['r', link.url])
})
if (isThread) {
tags.push(['ghostr-thread', 'true'])
}
}

updateDraft(currentDraft.id, {
title,
content,
content: !isLongForm && isThread ? threadContent() : content,
targetKind: isLongForm ? 30023 : 1,
tags,
coverImage,
Expand Down Expand Up @@ -305,8 +345,99 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
}

setIsPublishing(true)
setPublishingPostIndex(null)
let threadPublishedIds: string[] = []
let threadPostCount = 0

try {
const cleanThreadPosts = isThread && !isLongForm
? threadPosts.map((post) => post.trim()).filter(Boolean)
: []
threadPostCount = cleanThreadPosts.length

if (!isLongForm && isThread) {
if (cleanThreadPosts.length < 2) {
toast({
title: 'Cannot publish thread',
description: 'Add at least two non-empty posts before publishing a thread.',
variant: 'destructive',
})
return
}

const publishedIds: string[] = []
threadPublishedIds = publishedIds
let publisherPubkey: string | undefined
for (const [index, postContent] of cleanThreadPosts.entries()) {
setPublishingPostIndex(index)
let postFinalContent = postContent
if (index === cleanThreadPosts.length - 1 && attachedImages.length > 0) {
const newImages = attachedImages.filter(url => !postFinalContent.includes(url))
if (newImages.length > 0) {
postFinalContent += '\n\n' + newImages.join('\n')
}
}

const threadEvent = new NDKEvent(ndk)
threadEvent.kind = 1
threadEvent.content = postFinalContent
const tags: string[][] = []
if (includeCredit) {
tags.push(['client', 'Ghostr'])
}
if (index > 0) {
const rootId = publishedIds[0]
const replyId = publishedIds[publishedIds.length - 1]
if (!rootId || !replyId) throw new Error('Missing thread root or reply event id')
tags.push(['e', rootId, '', 'root'])
tags.push(['e', replyId, '', 'reply'])
if (publisherPubkey) {
tags.push(['p', publisherPubkey])
}
}
threadEvent.tags = tags
await threadEvent.sign(signer)
publisherPubkey = publisherPubkey || threadEvent.pubkey
await threadEvent.publish()
publishedIds.push(threadEvent.id)

addItem({
id: threadEvent.id,
content: postFinalContent,
kind: 1,
publishedAt: Date.now(),
source: 'direct',
})
}

toast({
title: 'Thread published!',
description: `${publishedIds.length} posts have been published as a thread.`,
})

if (currentDraftId) {
updateDraft(currentDraftId, {
status: 'published',
publishedEventId: publishedIds[0],
})
setCurrentDraft(null)
}

setTitle('')
setSummary('')
setContent('')
setThreadPosts([''])
setIsThread(false)
setCoverImage(undefined)
setAttachedImages([])
setAttachedLinks([])
initializedDraftId.current = null

onPublished?.()
onBack()
return
}

const event = new NDKEvent(ndk)
event.kind = isLongForm ? 30023 : 1

Expand Down Expand Up @@ -407,13 +538,18 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
onBack()
} catch (err) {
console.error('Failed to publish:', err)
const baseMessage = err instanceof Error ? err.message : 'An error occurred'
const description = threadPostCount > 0 && threadPublishedIds.length > 0 && threadPublishedIds.length < threadPostCount
? `${threadPublishedIds.length} of ${threadPostCount} posts went live before the error — posts 1 through ${threadPublishedIds.length} are already published. ${baseMessage}`
: baseMessage
toast({
title: 'Failed to publish',
description: err instanceof Error ? err.message : 'An error occurred',
description,
variant: 'destructive',
})
} finally {
setIsPublishing(false)
setPublishingPostIndex(null)
}
}

Expand Down Expand Up @@ -479,6 +615,16 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
onClick={() => {
const draft = createDraft(isLongForm ? 30023 : 1)
initializedDraftId.current = draft.id
const tags: string[][] = []
if (!isLongForm && isThread) tags.push(['ghostr-thread', 'true'])
updateDraft(draft.id, {
title,
content: !isLongForm && isThread ? threadContent() : content,
targetKind: isLongForm ? 30023 : 1,
tags,
coverImage,
uploadedImages: attachedImages,
})
setHasChanges(false)
toast({
title: 'Draft saved',
Expand All @@ -497,7 +643,11 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
) : (
<Send className="mr-2 h-4 w-4" />
)}
{isPublishing ? 'Publishing...' : isDirectPostMode.current ? 'Publish Now' : 'Publish'}
{isPublishing
? (!isLongForm && isThread && publishingPostIndex !== null
? `Publishing post ${publishingPostIndex + 1} of ${threadPosts.map((post) => post.trim()).filter(Boolean).length}…`
: 'Publishing...')
: isDirectPostMode.current ? 'Publish Now' : 'Publish'}
</Button>
</div>
</div>
Expand Down Expand Up @@ -544,7 +694,44 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
<Label htmlFor="content">Content</Label>
{!isLongForm && <ImageUploadButton onUpload={handleImageUpload} />}
</div>
{isLongForm ? (
{!isLongForm && isThread ? (
<div className="space-y-3">
<div className="rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
Publisher thread mode publishes each box as a sequential kind 1 reply. Paste text separated by lines containing only <code className="font-mono">---</code>, then split if needed.
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setThreadPosts(splitThreadPosts(content).length > 0 ? splitThreadPosts(content) : splitThreadPosts(threadContent()))}
>
Split on ---
</Button>
<Button type="button" variant="outline" size="sm" onClick={addThreadPost}>
<Plus className="mr-2 h-4 w-4" /> Add post
</Button>
</div>
{threadPosts.map((post, index) => (
<div key={index} className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between">
<Label>Post {index + 1}</Label>
{threadPosts.length > 1 && (
<Button type="button" variant="ghost" size="sm" onClick={() => removeThreadPost(index)}>
<X className="h-4 w-4" />
</Button>
)}
</div>
<MentionPillTextarea
value={post}
onChange={(value) => updateThreadPost(index, value)}
placeholder={`Thread post ${index + 1}`}
minHeight="120px"
/>
</div>
))}
</div>
) : isLongForm ? (
<MarkdownEditor
value={content}
onChange={setContent}
Expand Down Expand Up @@ -572,10 +759,11 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
/>
</>
)}
<p className="text-xs text-muted-foreground">
{content.length} characters
{hasImages && !isLongForm && ` | ${attachedImages.length} image${attachedImages.length !== 1 ? 's' : ''}`}
</p>
{hasImages && !isLongForm && (
<p className="text-xs text-muted-foreground">
{attachedImages.length} image{attachedImages.length !== 1 ? 's' : ''}
</p>
)}
</div>
</div>

Expand All @@ -584,7 +772,10 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
<h3 className="font-medium">Post Type</h3>
<div className="flex items-center bg-muted rounded-full p-1">
<button
onClick={() => setIsLongForm(false)}
onClick={() => {
setIsLongForm(false)
setIsThread(false)
}}
className={cn(
'flex-1 px-3 py-2 rounded-full text-sm font-medium transition-colors',
!isLongForm
Expand All @@ -595,7 +786,21 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
Short Note
</button>
<button
onClick={() => setIsLongForm(true)}
onClick={enableThreadMode}
className={cn(
'flex-1 px-3 py-2 rounded-full text-sm font-medium transition-colors',
!isLongForm && isThread
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
Thread
</button>
<button
onClick={() => {
if (isThread) disableThreadMode()
setIsLongForm(true)
}}
className={cn(
'flex-1 px-3 py-2 rounded-full text-sm font-medium transition-colors',
isLongForm
Expand All @@ -607,7 +812,9 @@ export function DirectPostEditor({ onBack, onPublished }: DirectPostEditorProps)
</button>
</div>
<p className="text-xs text-muted-foreground">
{isLongForm
{!isLongForm && isThread
? 'Threads publish multiple kind 1 notes with root/reply tags.'
: isLongForm
? 'Long-form articles (NIP-23, kind 30023) support markdown and are best for blog posts and articles.'
: 'Short notes (kind 1) are like tweets - brief updates and thoughts.'}
</p>
Expand Down
Loading