From 5d4341d73875d9b71e420a5b7d8ce599f39d1cdf Mon Sep 17 00:00:00 2001 From: highperfocused Date: Sun, 5 Oct 2025 19:05:24 +0200 Subject: [PATCH] implement pt1 --- src/components/ProfessionalBlogPostForm.tsx | 485 ++++++++++++++++++++ src/components/blocks/editor-00/plugins.tsx | 2 +- src/pages/CreatePostPage.tsx | 6 +- src/pages/EditPostPage.tsx | 6 +- 4 files changed, 492 insertions(+), 7 deletions(-) create mode 100644 src/components/ProfessionalBlogPostForm.tsx diff --git a/src/components/ProfessionalBlogPostForm.tsx b/src/components/ProfessionalBlogPostForm.tsx new file mode 100644 index 0000000..8421809 --- /dev/null +++ b/src/components/ProfessionalBlogPostForm.tsx @@ -0,0 +1,485 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { SerializedEditorState } from 'lexical'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { usePublishBlogPost } from '@/hooks/usePublishBlogPost'; +import { useBlogPost } from '@/hooks/useBlogPost'; +import { useUploadFile } from '@/hooks/useUploadFile'; +import { useIsMobile } from '@/hooks/useIsMobile'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { AlertCircle, Loader2, Upload, Image as ImageIcon, FileText, Hash, Calendar } from 'lucide-react'; +import { Editor } from '@/components/blocks/editor-00/editor'; + +interface ProfessionalBlogPostFormProps { + /** Existing post identifier for editing (optional) */ + editIdentifier?: string; +} + +const initialEditorState = { + root: { + children: [ + { + children: [], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1, + }, +} as unknown as SerializedEditorState; + +export function ProfessionalBlogPostForm({ editIdentifier }: ProfessionalBlogPostFormProps) { + const { user } = useCurrentUser(); + const navigate = useNavigate(); + const isMobile = useIsMobile(); + const { mutateAsync: publishPost, isPending: isPublishing } = usePublishBlogPost(); + const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); + + // Load existing post if editing (using the current user's pubkey) + const { data: existingPost, isLoading: isLoadingPost } = useBlogPost( + user?.pubkey || '', + editIdentifier || '' + ); + + const [editorState, setEditorState] = useState(initialEditorState); + const [metadata, setMetadata] = useState({ + identifier: '', + title: '', + summary: '', + image: '', + hashtags: '', + }); + const [showMetadata, setShowMetadata] = useState(true); + + // Load existing post data when editing + useEffect(() => { + if (existingPost && editIdentifier) { + const d = existingPost.tags.find(([name]) => name === 'd')?.[1] || ''; + const title = existingPost.tags.find(([name]) => name === 'title')?.[1] || ''; + const summary = existingPost.tags.find(([name]) => name === 'summary')?.[1] || ''; + const image = existingPost.tags.find(([name]) => name === 'image')?.[1] || ''; + const hashtags = existingPost.tags + .filter(([name]) => name === 't') + .map(([, value]) => value) + .join(', '); + + setMetadata({ + identifier: d, + title, + summary, + image, + hashtags, + }); + + // Convert markdown content to editor state + // We'll use a simple approach - the editor will handle the markdown + // For now, we'll just set it as the initial state + if (existingPost.content) { + try { + // Create a simple editor state with the markdown content as text + // The Lexical markdown plugin should handle conversion + const contentState = { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: existingPost.content, + type: "text", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1, + }, + } as unknown as SerializedEditorState; + setEditorState(contentState); + } catch (error) { + console.error('Failed to parse existing content:', error); + } + } + } + }, [existingPost, editIdentifier]); + + const handleMetadataChange = (field: keyof typeof metadata, value: string) => { + setMetadata(prev => ({ ...prev, [field]: value })); + }; + + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const [[_, url]] = await uploadFile(file); + setMetadata(prev => ({ ...prev, image: url })); + } catch (error) { + console.error('Failed to upload image:', error); + } + }; + + const getMarkdownFromEditor = (): string => { + // Extract text content from the editor state + // In a full implementation, you'd use $convertToMarkdownString with proper transformers + try { + interface EditorNode { + children?: Array<{ text?: string }>; + } + + const root = editorState.root as { children?: EditorNode[] }; + const content = (root.children || []) + .map((child: EditorNode) => { + if (child.children && Array.isArray(child.children)) { + return child.children + .map((textNode) => textNode.text || '') + .join(''); + } + return ''; + }) + .join('\n\n'); + return content; + } catch (error) { + console.error('Failed to extract markdown:', error); + return ''; + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!metadata.identifier.trim()) { + alert('Please provide a unique identifier for your post'); + return; + } + + if (!metadata.title.trim()) { + alert('Please provide a title for your post'); + return; + } + + const markdownContent = getMarkdownFromEditor(); + + if (!markdownContent.trim()) { + alert('Please write some content for your post'); + return; + } + + try { + const publishedAt = editIdentifier && existingPost + ? parseInt(existingPost.tags.find(([name]) => name === 'published_at')?.[1] || '0') + : Math.floor(Date.now() / 1000); + + const event = await publishPost({ + identifier: metadata.identifier, + title: metadata.title, + summary: metadata.summary || undefined, + image: metadata.image || undefined, + content: markdownContent, + hashtags: metadata.hashtags + ? metadata.hashtags.split(',').map(t => t.trim()).filter(Boolean) + : undefined, + publishedAt: publishedAt || undefined, + }); + + // Navigate to the post + const naddr = nip19.naddrEncode({ + kind: 30023, + pubkey: event.pubkey, + identifier: metadata.identifier, + }); + navigate(`/${naddr}`); + } catch (error) { + console.error('Failed to publish post:', error); + alert('Failed to publish post. Please try again.'); + } + }; + + if (!user) { + return ( + + + + You must be logged in to create a blog post. + + + ); + } + + // Check if user is trying to edit someone else's post + if (editIdentifier && existingPost && existingPost.pubkey !== user.pubkey) { + return ( + + + + You can only edit your own posts. + + + ); + } + + if (isLoadingPost) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ {editIdentifier ? 'Edit Article' : 'Write New Article'} +

+

+ {editIdentifier ? 'Update your article' : 'Share your thoughts with the world'} +

+
+
+ + +
+
+ + + + {/* Metadata Section */} + + setShowMetadata(!showMetadata)}> +
+ + + Article Metadata + + +
+
+ {showMetadata && ( + + {/* Identifier */} +
+ + handleMetadataChange('identifier', e.target.value)} + placeholder="my-awesome-article" + required + disabled={!!editIdentifier} + className="font-mono" + /> +

+ URL-friendly identifier (e.g., "my-awesome-article"). Cannot be changed after publishing. +

+
+ + {/* Title */} +
+ + handleMetadataChange('title', e.target.value)} + placeholder="The Amazing Story of..." + required + className="text-lg" + /> +
+ + {/* Summary */} +
+ +