feat: add in-app article editor with Milkdown WYSIWYG

Replace external Inkwell link with a built-in article creation experience.
Uses Milkdown editor with tabbed UI (Write/Details/Drafts) matching the
letters compose pattern, FAB publish button, relay+local draft support,
and kind 30023/30024 publishing.
This commit is contained in:
Derek Ross
2026-03-31 18:17:12 -04:00
parent 6be49ec14a
commit e93c665123
15 changed files with 4429 additions and 18 deletions
+1848 -15
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -53,6 +53,17 @@
"@fontsource/special-elite": "^5.2.8",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@milkdown/core": "^7.20.0",
"@milkdown/ctx": "^7.20.0",
"@milkdown/plugin-clipboard": "^7.20.0",
"@milkdown/plugin-history": "^7.20.0",
"@milkdown/plugin-listener": "^7.20.0",
"@milkdown/plugin-upload": "^7.20.0",
"@milkdown/preset-commonmark": "^7.20.0",
"@milkdown/preset-gfm": "^7.20.0",
"@milkdown/prose": "^7.20.0",
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@nostrify/nostrify": "^0.51.0",
"@nostrify/react": "^0.4.0",
"@nostrify/types": "^0.36.9",
@@ -117,6 +128,7 @@
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"rehype-sanitize": "^6.0.0",
"slugify": "^1.6.8",
"smol-toml": "^1.6.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
+6
View File
@@ -29,6 +29,8 @@ const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.H
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
const ArticleDraftsPage = lazy(() => import("./pages/ArticleDraftsPage").then(m => ({ default: m.ArticleDraftsPage })));
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
const BlobbiPage = lazy(() => import("./pages/BlobbiPage").then(m => ({ default: m.BlobbiPage })));
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
@@ -184,6 +186,9 @@ export function AppRouter() {
}
/>
<Route path="/webxdc" element={<WebxdcFeedPage />} />
<Route path="/articles/new" element={<ArticleEditorPage />} />
<Route path="/articles/edit/:naddr" element={<ArticleEditorPage />} />
<Route path="/articles/drafts" element={<ArticleDraftsPage />} />
<Route
path="/articles"
element={
@@ -191,6 +196,7 @@ export function AppRouter() {
kind={articlesDef.kind}
title={articlesDef.label}
icon={sidebarItemIcon("articles", "size-5")}
fabHref="/articles/new"
/>
}
/>
+969
View File
@@ -0,0 +1,969 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import {
ArrowLeft,
Image,
Save,
Send,
Loader2,
Hash,
FileText,
X,
Calendar,
Clock,
Cloud,
HardDrive,
Trash2,
PenLine,
Settings2,
ChevronRight,
BookOpen,
} from 'lucide-react';
import slugify from 'slugify';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { FabButton } from '@/components/FabButton';
import { toast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDrafts, type Draft } from '@/hooks/useDrafts';
import { usePublishedArticles } from '@/hooks/usePublishedArticles';
import { cn } from '@/lib/utils';
import { saveDraft as saveLocalDraft, deleteDraftBySlug, getLocalDrafts } from '@/lib/localDrafts';
import { MilkdownEditor } from './MilkdownEditor';
export interface ArticleData {
title: string;
summary: string;
content: string;
image: string;
tags: string[];
slug: string;
}
interface ArticleEditorProps {
/** Pre-filled data for editing an existing article or loading a draft. */
initialData?: Partial<ArticleData> & { publishedAt?: number };
/** Whether the editor is in edit mode (updating an existing article). */
editMode?: boolean;
}
type EditorTab = 'write' | 'details' | 'drafts';
export function ArticleEditor({ initialData, editMode = false }: ArticleEditorProps) {
const navigate = useNavigate();
const { user } = useCurrentUser();
const { mutate: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { drafts: relayDrafts, isLoading: isDraftsLoading, saveDraft: saveRelayDraft, deleteDraft: deleteRelayDraft, isDeleting } = useDrafts();
const { articles: publishedArticles, isLoading: isArticlesLoading } = usePublishedArticles();
const fileInputRef = useRef<HTMLInputElement>(null);
const inlineImageInputRef = useRef<HTMLInputElement>(null);
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [activeTab, setActiveTab] = useState<EditorTab>('write');
const [localDrafts, setLocalDrafts] = useState<Draft[]>([]);
const [deleteTarget, setDeleteTarget] = useState<{ id: string; slug: string; isLocal: boolean } | null>(null);
const [wordCount, setWordCount] = useState(0);
const [charCount, setCharCount] = useState(0);
const [readingTime, setReadingTime] = useState(0);
const [tagInput, setTagInput] = useState('');
const [isPublished, setIsPublished] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
const [isEditMode, setIsEditMode] = useState(editMode);
const [originalPublishedAt, setOriginalPublishedAt] = useState<number | null>(
initialData?.publishedAt ?? null,
);
const [article, setArticle] = useState<ArticleData>({
title: initialData?.title || '',
summary: initialData?.summary || '',
content: initialData?.content || '',
image: initialData?.image || '',
tags: initialData?.tags || [],
slug: initialData?.slug || '',
});
// Auto-save every 30 seconds if there are changes
useEffect(() => {
if (hasUnsavedChanges && article.content.length > 0) {
autoSaveTimeoutRef.current = setTimeout(async () => {
if (user) {
try {
await saveRelayDraft(article);
} catch {
// Fallback to local
saveLocalDraft(article);
}
} else {
saveLocalDraft(article);
}
setLastSaved(new Date());
setHasUnsavedChanges(false);
}, 30000);
}
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}, [hasUnsavedChanges, article, user, saveRelayDraft]);
// Reference to handlers for keyboard shortcuts
const handlePublishRef = useRef<(() => void) | null>(null);
const handleSaveDraftRef = useRef<(() => void) | null>(null);
// Warn before leaving page with unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges && (article.title || article.content)) {
e.preventDefault();
e.returnValue = '';
return '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges, article.title, article.content]);
// Auto-generate slug from title
useEffect(() => {
if (article.title && !initialData?.slug) {
const newSlug = slugify(article.title, {
lower: true,
strict: true,
trim: true,
});
setArticle((prev) => ({ ...prev, slug: newSlug }));
}
}, [article.title, initialData?.slug]);
// Calculate stats
useEffect(() => {
const words = article.content.trim().split(/\s+/).filter(Boolean).length;
const chars = article.content.length;
const minutes = Math.ceil(words / 200);
setWordCount(words);
setCharCount(chars);
setReadingTime(minutes);
}, [article.content]);
// Load local drafts when drafts tab is shown
useEffect(() => {
if (activeTab === 'drafts') {
setLocalDrafts(getLocalDrafts());
}
}, [activeTab]);
// Combine relay and local drafts, avoiding duplicates by slug
const combinedDrafts = (() => {
const drafts: (Draft & { isLocal: boolean })[] = [];
const seenSlugs = new Set<string>();
for (const draft of relayDrafts) {
if (draft.slug) seenSlugs.add(draft.slug);
drafts.push({ ...draft, isLocal: false });
}
for (const draft of localDrafts) {
if (!draft.slug || !seenSlugs.has(draft.slug)) {
drafts.push({ ...draft, isLocal: true });
}
}
return drafts.sort((a, b) => b.updatedAt - a.updatedAt);
})();
const handleLoadDraft = useCallback((draft: Draft & { isLocal: boolean }) => {
setArticle({
title: draft.title,
summary: draft.summary,
content: draft.content,
image: draft.image,
tags: draft.tags,
slug: draft.slug,
});
setIsEditMode(false);
setOriginalPublishedAt(null);
setHasUnsavedChanges(false);
setActiveTab('write');
toast({
title: 'Draft loaded',
description: 'Your draft has been loaded into the editor.',
});
}, []);
const handleLoadArticle = useCallback((articleData: { title: string; summary: string; content: string; image: string; tags: string[]; slug: string; publishedAt: number }) => {
setArticle({
title: articleData.title,
summary: articleData.summary,
content: articleData.content,
image: articleData.image,
tags: articleData.tags,
slug: articleData.slug,
});
setIsEditMode(true);
setOriginalPublishedAt(articleData.publishedAt);
setHasUnsavedChanges(false);
setActiveTab('write');
toast({
title: 'Article loaded for editing',
description: 'Make changes and publish to update your article.',
});
}, []);
const handleDeleteDraft = useCallback(async () => {
if (!deleteTarget) return;
if (deleteTarget.isLocal) {
try {
const stored = localStorage.getItem('article-drafts');
if (stored) {
const drafts: Draft[] = JSON.parse(stored);
const filtered = drafts.filter((d) => d.id !== deleteTarget.id);
localStorage.setItem('article-drafts', JSON.stringify(filtered));
setLocalDrafts(filtered);
}
} catch (error) {
console.error('Failed to delete local draft:', error);
}
toast({ title: 'Draft deleted', description: 'Removed from your browser.' });
} else {
try {
await deleteRelayDraft(deleteTarget.slug);
toast({ title: 'Draft deleted', description: 'Deletion published to relays.' });
} catch (error) {
const message = error instanceof Error ? error.message : '';
toast({ title: 'Delete failed', description: message || 'Could not delete draft.', variant: 'destructive' });
}
}
setDeleteTarget(null);
}, [deleteTarget, deleteRelayDraft]);
const updateArticle = useCallback(
(field: keyof ArticleData, value: string | string[]) => {
setArticle((prev) => ({ ...prev, [field]: value }));
setHasUnsavedChanges(true);
},
[],
);
const handleAddTag = useCallback(() => {
const newTag = tagInput.trim().toLowerCase().replace(/^#/, '');
if (newTag && !article.tags.includes(newTag)) {
setArticle((prev) => ({ ...prev, tags: [...prev.tags, newTag] }));
setTagInput('');
}
}, [tagInput, article.tags]);
const handleRemoveTag = useCallback((tagToRemove: string) => {
setArticle((prev) => ({
...prev,
tags: prev.tags.filter((tag) => tag !== tagToRemove),
}));
}, []);
const handleImageUpload = useCallback(
async (file: File) => {
try {
const [[, url]] = await uploadFile(file);
return url;
} catch (error) {
console.error('Upload failed:', error);
toast({
title: 'Upload failed',
description: 'Could not upload the image. Please try again.',
variant: 'destructive',
});
return null;
}
},
[uploadFile],
);
const handleHeaderImageUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const url = await handleImageUpload(file);
if (url) {
updateArticle('image', url);
toast({
title: 'Image uploaded',
description: 'Header image has been set successfully.',
});
}
},
[handleImageUpload, updateArticle],
);
const handleInlineImageButtonClick = useCallback(() => {
inlineImageInputRef.current?.click();
}, []);
const handleInlineImageUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const url = await handleImageUpload(file);
if (url) {
const imageMarkdown = `![${file.name}](${url})`;
updateArticle('content', article.content + '\n' + imageMarkdown + '\n');
}
e.target.value = '';
},
[handleImageUpload, updateArticle, article.content],
);
const handleSaveDraft = useCallback(async () => {
if (user) {
try {
await saveRelayDraft(article);
setLastSaved(new Date());
setHasUnsavedChanges(false);
toast({
title: 'Draft saved',
description: 'Your article has been saved to Nostr relays.',
});
} catch (error) {
console.error('Failed to save draft to relay:', error);
saveLocalDraft(article);
setLastSaved(new Date());
setHasUnsavedChanges(false);
toast({
title: 'Draft saved locally',
description: 'Could not sync to relays. Saved to your browser.',
variant: 'destructive',
});
}
} else {
saveLocalDraft(article);
setLastSaved(new Date());
setHasUnsavedChanges(false);
toast({
title: 'Draft saved',
description: 'Your article has been saved locally.',
});
}
}, [article, user, saveRelayDraft]);
const handlePublish = useCallback(() => {
if (!user) {
toast({
title: 'Login required',
description: 'Please login to publish your article.',
variant: 'destructive',
});
return;
}
if (!article.title.trim()) {
toast({
title: 'Title required',
description: 'Please add a title to your article.',
variant: 'destructive',
});
return;
}
if (!article.content.trim()) {
toast({
title: 'Content required',
description: 'Please write some content for your article.',
variant: 'destructive',
});
return;
}
// Use original published_at when editing, current time for new articles
const publishedAtTimestamp =
isEditMode && originalPublishedAt
? Math.floor(originalPublishedAt / 1000)
: Math.floor(Date.now() / 1000);
const tags: string[][] = [
['d', article.slug || slugify(article.title, { lower: true, strict: true })],
['title', article.title],
['published_at', publishedAtTimestamp.toString()],
];
if (article.summary) {
tags.push(['summary', article.summary]);
}
if (article.image) {
tags.push(['image', article.image]);
}
article.tags.forEach((tag) => {
tags.push(['t', tag]);
});
publishEvent(
{
kind: 30023,
content: article.content,
tags,
},
{
onSuccess: async () => {
setIsPublished(true);
setHasUnsavedChanges(false);
// Remove draft after publishing
if (article.slug) {
deleteDraftBySlug(article.slug);
try {
await deleteRelayDraft(article.slug);
} catch (error) {
console.error('Failed to delete draft from relay:', error);
}
}
toast({
title: isEditMode ? 'Article updated' : 'Article published',
description: isEditMode
? 'Your article has been updated on Nostr.'
: 'Your article is now live on Nostr.',
});
// Navigate back to articles feed
navigate('/articles');
},
onError: (error) => {
toast({
title: 'Publishing failed',
description:
error.message || 'Could not publish your article. Please try again.',
variant: 'destructive',
});
},
},
);
}, [
user,
article,
publishEvent,
deleteRelayDraft,
isEditMode,
originalPublishedAt,
navigate,
]);
// Set refs for keyboard shortcuts
handlePublishRef.current = handlePublish;
handleSaveDraftRef.current = handleSaveDraft;
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
handleSaveDraftRef.current?.();
}
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && user) {
e.preventDefault();
handlePublishRef.current?.();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [user]);
// Handle back navigation with unsaved changes check
const handleBack = useCallback(() => {
if (hasUnsavedChanges && (article.title || article.content)) {
setShowLeaveDialog(true);
} else {
navigate('/articles');
}
}, [hasUnsavedChanges, article.title, article.content, navigate]);
const handleLeaveWithoutSaving = useCallback(() => {
setShowLeaveDialog(false);
navigate('/articles');
}, [navigate]);
const handleSaveAndLeave = useCallback(async () => {
await handleSaveDraft();
setShowLeaveDialog(false);
navigate('/articles');
}, [handleSaveDraft, navigate]);
// Sync editMode prop with internal state
useEffect(() => {
setIsEditMode(editMode);
}, [editMode]);
useEffect(() => {
setOriginalPublishedAt(initialData?.publishedAt ?? null);
}, [initialData?.publishedAt]);
const statusLabel = isPublished ? (
<span className="text-green-600 dark:text-green-400 text-sm">
{isEditMode ? 'Updated' : 'Published'}
</span>
) : isEditMode ? (
hasUnsavedChanges ? (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
Editing
</span>
) : (
<span className="text-blue-600 dark:text-blue-400 text-sm">Editing</span>
)
) : hasUnsavedChanges ? (
<span className="flex items-center gap-1 text-sm text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
Unsaved
</span>
) : lastSaved ? (
<span className="text-sm text-muted-foreground">Saved</span>
) : null;
const totalDrafts = combinedDrafts.length;
return (
<div className="flex flex-col">
{/* Sticky header — matches letters/compose pattern */}
<div className="sticky top-0 z-20">
<SubHeaderBar pinned className="relative !top-0">
<button
onClick={handleBack}
className="pl-3 pr-1 py-1.5 text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<ArrowLeft className="size-5" />
</button>
<TabButton
label="Write"
active={activeTab === 'write'}
onClick={() => setActiveTab('write')}
>
<PenLine className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
<TabButton
label="Details"
active={activeTab === 'details'}
onClick={() => setActiveTab('details')}
>
<Settings2 className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
<TabButton
label="Drafts"
active={activeTab === 'drafts'}
onClick={() => setActiveTab('drafts')}
>
<BookOpen className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleSaveDraft}
className="pr-3 pl-1 py-1.5 text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<Save className="size-5" strokeWidth={2.5} />
</button>
</TooltipTrigger>
<TooltipContent>Save Draft (Ctrl+S)</TooltipContent>
</Tooltip>
</SubHeaderBar>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleHeaderImageUpload}
className="hidden"
/>
<input
ref={inlineImageInputRef}
type="file"
accept="image/*"
onChange={handleInlineImageUpload}
className="hidden"
/>
{/* ── Write tab ────────────────────────────────────────────── */}
{activeTab === 'write' && (
<div className="px-4 py-6">
{/* Title Input */}
<input
type="text"
value={article.title}
onChange={(e) => updateArticle('title', e.target.value)}
placeholder="Your article title..."
className="w-full text-3xl sm:text-4xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/40 mb-4"
/>
{/* Editor */}
<MilkdownEditor
value={article.content}
onChange={(value) => updateArticle('content', value || '')}
onUploadImage={handleImageUpload}
onImageButtonClick={handleInlineImageButtonClick}
placeholder="Start writing your article..."
className="rounded-xl overflow-hidden border border-border bg-card min-h-[400px]"
/>
{/* Stats Bar */}
<div className="mt-4 flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<span>{wordCount} words</span>
<span>·</span>
<span>{charCount} chars</span>
<span>·</span>
<span>{readingTime} min read</span>
{statusLabel && (
<>
<span>·</span>
{statusLabel}
</>
)}
</div>
</div>
)}
{/* ── Details tab ──────────────────────────────────────────── */}
{activeTab === 'details' && (
<div className="px-4 py-6 space-y-6">
{/* Header Image */}
<div className="space-y-2">
<Label className="text-muted-foreground">Header Image</Label>
{article.image ? (
<div className="relative rounded-xl overflow-hidden group">
<img
src={article.image}
alt="Header"
className="w-full h-48 sm:h-64 object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
<Button
variant="secondary"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Image className="w-4 h-4 mr-2" />
)}
Change Image
</Button>
</div>
</div>
) : (
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full h-32 border-2 border-dashed border-border rounded-xl flex flex-col items-center justify-center text-muted-foreground hover:border-primary/50 hover:text-primary/70 transition-colors"
>
{isUploading ? (
<Loader2 className="w-8 h-8 animate-spin mb-2" />
) : (
<Image className="w-8 h-8 mb-2" />
)}
<span className="text-sm">Add a header image</span>
</button>
)}
</div>
{/* URL Slug */}
<div className="space-y-2">
<Label htmlFor="slug" className="text-muted-foreground">URL Slug</Label>
<Input
id="slug"
value={article.slug}
onChange={(e) => updateArticle('slug', e.target.value)}
placeholder="article-url-slug"
className="font-mono text-sm"
/>
</div>
{/* Tags */}
<div className="space-y-2">
<Label className="text-muted-foreground flex items-center gap-2">
<Hash className="w-3.5 h-3.5" />
Tags
</Label>
<div className="flex gap-2">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) =>
e.key === 'Enter' && (e.preventDefault(), handleAddTag())
}
placeholder="Add a tag..."
className="flex-1"
/>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleAddTag}
>
Add
</Button>
</div>
{article.tags.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
{article.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1 px-2 py-1">
#{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="ml-1 hover:text-destructive"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* Summary */}
<div className="space-y-2">
<Label htmlFor="summary" className="text-muted-foreground">Summary</Label>
<Textarea
id="summary"
value={article.summary}
onChange={(e) => updateArticle('summary', e.target.value)}
placeholder="A brief description of your article..."
rows={3}
className="resize-none"
/>
</div>
</div>
)}
{/* ── Drafts tab ───────────────────────────────────────────── */}
{activeTab === 'drafts' && (
<div className="px-4 py-4">
{isDraftsLoading && user ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground mb-4" />
<p className="text-muted-foreground">Loading drafts...</p>
</div>
) : totalDrafts === 0 && publishedArticles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<FileText className="w-8 h-8 text-muted-foreground" />
</div>
<p className="text-muted-foreground">No drafts or articles yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Save a draft or publish to see content here
</p>
</div>
) : (
<div className="space-y-6">
{/* Drafts section */}
{totalDrafts > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground px-1">Drafts ({totalDrafts})</h3>
{combinedDrafts.map((draft) => (
<div
key={draft.id}
className="group p-4 rounded-xl border border-border hover:border-primary/30 hover:bg-card transition-all cursor-pointer"
onClick={() => handleLoadDraft(draft)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">
{draft.title || 'Untitled Draft'}
</h3>
{draft.summary && (
<p className="text-sm text-muted-foreground truncate mt-1">
{draft.summary}
</p>
)}
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-1" />
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
{draft.isLocal ? (
<HardDrive className="w-3 h-3 shrink-0" />
) : (
<Cloud className="w-3 h-3 text-primary shrink-0" />
)}
<Clock className="w-3 h-3 shrink-0" />
<span>{formatDistanceToNow(draft.updatedAt, { addSuffix: true })}</span>
{draft.tags.length > 0 && (
<>
<span>·</span>
<span>{draft.tags.length} tags</span>
</>
)}
<span className="flex-1" />
<button
className="p-1 rounded-full text-muted-foreground hover:text-destructive transition-colors"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget({ id: draft.id, slug: draft.slug, isLocal: draft.isLocal });
}}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
{/* Published articles section */}
{publishedArticles.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground px-1">Published ({publishedArticles.length})</h3>
{publishedArticles.map((pub) => (
<div
key={pub.id}
className="group p-4 rounded-xl border border-border hover:border-green-500/30 hover:bg-card transition-all cursor-pointer"
onClick={() => handleLoadArticle(pub)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">
{pub.title || 'Untitled Article'}
</h3>
{pub.summary && (
<p className="text-sm text-muted-foreground truncate mt-1">
{pub.summary}
</p>
)}
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-1" />
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<Clock className="w-3 h-3 shrink-0" />
<span>Published {formatDistanceToNow(pub.publishedAt, { addSuffix: true })}</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{/* Publish FAB — mobile: fixed bottom right */}
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
<FabButton
onClick={handlePublish}
disabled={isPublishing || !user}
title={isEditMode ? 'Update article' : 'Publish article'}
icon={isPublishing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
{/* Publish FAB — desktop: sticky within column */}
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
<div className="flex justify-end pr-4">
<div className="pointer-events-auto">
<FabButton
onClick={handlePublish}
disabled={isPublishing || !user}
title={isEditMode ? 'Update article' : 'Publish article'}
icon={isPublishing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
</div>
</div>
{/* Leave Confirmation Dialog */}
<AlertDialog open={showLeaveDialog} onOpenChange={setShowLeaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Would you like to save your draft before
leaving?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
<AlertDialogCancel onClick={() => setShowLeaveDialog(false)}>
Cancel
</AlertDialogCancel>
<Button variant="outline" onClick={handleLeaveWithoutSaving}>
Discard
</Button>
<AlertDialogAction onClick={handleSaveAndLeave}>
Save Draft
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete Draft Confirmation Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete draft?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget?.isLocal
? 'This draft will be permanently deleted from your browser.'
: 'This draft will be deleted from Nostr relays.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteDraft}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Deleting...
</>
) : (
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface LinkDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedText?: string;
onSubmit: (text: string, url: string) => void;
}
export function LinkDialog({ open, onOpenChange, selectedText, onSubmit }: LinkDialogProps) {
const [text, setText] = useState('');
const [url, setUrl] = useState('');
// Reset form when dialog opens
useEffect(() => {
if (open) {
setText(selectedText || '');
setUrl('');
}
}, [open, selectedText]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (url.trim()) {
const finalText = text.trim() || url.trim();
let finalUrl = url.trim();
// Add https:// if no protocol specified
if (!/^https?:\/\//i.test(finalUrl)) {
finalUrl = 'https://' + finalUrl;
}
onSubmit(finalText, finalUrl);
onOpenChange(false);
}
};
const hasSelectedText = !!selectedText;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Insert Link</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
{!hasSelectedText && (
<div className="space-y-2">
<Label htmlFor="link-text">Link Text</Label>
<Input
id="link-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter link text..."
autoFocus={!hasSelectedText}
/>
</div>
)}
{hasSelectedText && (
<div className="space-y-2">
<Label className="text-muted-foreground">Link Text</Label>
<p className="text-sm bg-muted px-3 py-2 rounded-md">{selectedText}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="link-url">URL</Label>
<Input
id="link-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
type="url"
autoFocus={hasSelectedText}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!url.trim()}>
Insert Link
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+306
View File
@@ -0,0 +1,306 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { Editor, rootCtx, defaultValueCtx, editorViewCtx } from '@milkdown/core';
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBlockquoteCommand, insertHrCommand, turnIntoTextCommand, wrapInHeadingCommand, toggleInlineCodeCommand, wrapInBulletListCommand, wrapInOrderedListCommand } from '@milkdown/preset-commonmark';
import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm';
import { history } from '@milkdown/plugin-history';
import { clipboard } from '@milkdown/plugin-clipboard';
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import { upload, uploadConfig } from '@milkdown/plugin-upload';
import { Decoration } from '@milkdown/prose/view';
import { replaceAll, callCommand } from '@milkdown/utils';
import { MilkdownToolbar } from './MilkdownToolbar';
import { LinkDialog } from './LinkDialog';
interface MilkdownEditorInnerProps {
value: string;
onChange: (markdown: string) => void;
onUploadImage?: (file: File) => Promise<string | null>;
onImageButtonClick?: () => void;
placeholder?: string;
showToolbar?: boolean;
}
function MilkdownEditorInner({ value, onChange, onUploadImage, onImageButtonClick, placeholder, showToolbar = true }: MilkdownEditorInnerProps) {
const initialValueRef = useRef(value);
const editorRef = useRef<Editor | null>(null);
const lastExternalValue = useRef(value);
const onUploadImageRef = useRef(onUploadImage);
// Link dialog state
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [selectedTextForLink, setSelectedTextForLink] = useState<string>('');
const selectionRef = useRef<{ from: number; to: number } | null>(null);
// Keep ref updated
useEffect(() => {
onUploadImageRef.current = onUploadImage;
}, [onUploadImage]);
const { get } = useEditor((root) => {
const editor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root);
ctx.set(defaultValueCtx, initialValueRef.current);
ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
lastExternalValue.current = markdown;
onChange(markdown);
});
// Configure upload plugin
ctx.set(uploadConfig.key, {
uploader: async (files, schema) => {
const images: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files.item(i);
if (!file) continue;
// Only handle images
if (!file.type.includes('image')) continue;
images.push(file);
}
const nodes: ReturnType<typeof schema.nodes.image.createAndFill>[] = [];
for (const image of images) {
try {
// Use the upload handler if provided
if (onUploadImageRef.current) {
const url = await onUploadImageRef.current(image);
if (url) {
const node = schema.nodes.image.createAndFill({
src: url,
alt: image.name,
});
if (node) nodes.push(node);
}
} else {
// Fallback to base64 if no upload handler
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve) => {
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(image);
});
const node = schema.nodes.image.createAndFill({
src: dataUrl,
alt: image.name,
});
if (node) nodes.push(node);
}
} catch (error) {
console.error('Failed to upload image:', error);
}
}
return nodes.filter((node): node is NonNullable<typeof node> => node !== null);
},
enableHtmlFileUploader: true,
uploadWidgetFactory: (pos, spec) => {
// Create a placeholder widget while uploading
const widgetEl = document.createElement('span');
widgetEl.className = 'milkdown-upload-placeholder';
widgetEl.textContent = 'Uploading...';
return Decoration.widget(pos, widgetEl, spec);
},
});
})
.use(commonmark)
.use(gfm)
.use(history)
.use(clipboard)
.use(listener)
.use(upload);
return editor;
});
// Store editor reference
useEffect(() => {
editorRef.current = get() ?? null;
}, [get]);
// Handle external value changes (e.g., loading a draft)
useEffect(() => {
const editor = get();
if (editor && value !== lastExternalValue.current) {
// Only update if the value changed externally (not from user typing)
editor.action(replaceAll(value));
lastExternalValue.current = value;
}
}, [value, get]);
// Add placeholder support
useEffect(() => {
const editor = get();
if (editor && placeholder) {
try {
const view = editor.ctx.get(editorViewCtx);
const editorDom = view.dom;
editorDom.setAttribute('data-placeholder', placeholder);
} catch {
// Editor not ready yet
}
}
}, [get, placeholder]);
// Handle link dialog open
const handleLinkButtonClick = useCallback(() => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state } = view;
const { from, to } = state.selection;
const selectedText = state.doc.textBetween(from, to);
// Store selection for later use
selectionRef.current = { from, to };
setSelectedTextForLink(selectedText);
setLinkDialogOpen(true);
} catch (error) {
console.error('Failed to get selection:', error);
}
}, [get]);
// Handle link insertion from dialog
const handleLinkSubmit = useCallback((text: string, url: string) => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state, dispatch } = view;
const { schema } = state;
// Create a link mark
const linkMark = schema.marks.link.create({ href: url });
// Create text node with link mark
const linkNode = schema.text(text, [linkMark]);
const tr = state.tr;
if (selectionRef.current) {
const { from, to } = selectionRef.current;
// Replace selection with linked text
tr.replaceWith(from, to, linkNode);
} else {
// Insert at current position
const { from } = state.selection;
tr.insert(from, linkNode);
}
dispatch(tr);
view.focus();
} catch (error) {
console.error('Failed to insert link:', error);
}
}, [get]);
// Handle toolbar commands
const handleCommand = useCallback((command: string) => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
switch (command) {
case 'toggleBold':
editor.action(callCommand(toggleStrongCommand.key));
break;
case 'toggleItalic':
editor.action(callCommand(toggleEmphasisCommand.key));
break;
case 'toggleStrikethrough':
editor.action(callCommand(toggleStrikethroughCommand.key));
break;
case 'toggleInlineCode':
editor.action(callCommand(toggleInlineCodeCommand.key));
break;
case 'heading1':
editor.action(callCommand(wrapInHeadingCommand.key, 1));
break;
case 'heading2':
editor.action(callCommand(wrapInHeadingCommand.key, 2));
break;
case 'heading3':
editor.action(callCommand(wrapInHeadingCommand.key, 3));
break;
case 'bulletList':
editor.action(callCommand(wrapInBulletListCommand.key));
break;
case 'orderedList':
editor.action(callCommand(wrapInOrderedListCommand.key));
break;
case 'blockquote':
editor.action(callCommand(wrapInBlockquoteCommand.key));
break;
case 'link':
handleLinkButtonClick();
return; // Don't refocus, dialog will handle it
case 'hr':
editor.action(callCommand(insertHrCommand.key));
break;
case 'paragraph':
editor.action(callCommand(turnIntoTextCommand.key));
break;
}
// Refocus the editor
view.focus();
} catch (error) {
console.error('Command failed:', error);
}
}, [get, handleLinkButtonClick]);
return (
<>
{showToolbar && (
<MilkdownToolbar
onCommand={handleCommand}
onImageUpload={onImageButtonClick}
/>
)}
<div className="milkdown-content">
<Milkdown />
</div>
<LinkDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
selectedText={selectedTextForLink}
onSubmit={handleLinkSubmit}
/>
</>
);
}
interface MilkdownEditorProps {
value: string;
onChange: (markdown: string) => void;
onUploadImage?: (file: File) => Promise<string | null>;
onImageButtonClick?: () => void;
placeholder?: string;
className?: string;
showToolbar?: boolean;
}
export function MilkdownEditor({ value, onChange, onUploadImage, onImageButtonClick, placeholder, className, showToolbar = true }: MilkdownEditorProps) {
return (
<div className={`milkdown-editor ${className || ''}`}>
<MilkdownProvider>
<MilkdownEditorInner
value={value}
onChange={onChange}
onUploadImage={onUploadImage}
onImageButtonClick={onImageButtonClick}
placeholder={placeholder}
showToolbar={showToolbar}
/>
</MilkdownProvider>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Link,
Image,
Minus,
HelpCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
function MarkdownHelpPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<HelpCircle className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end">
<div className="space-y-2">
<h4 className="font-medium text-sm">Markdown Quick Reference</h4>
<div className="text-xs space-y-1.5 font-mono text-muted-foreground">
<div className="flex justify-between"><span>**bold**</span><span className="font-sans font-bold">bold</span></div>
<div className="flex justify-between"><span>*italic*</span><span className="font-sans italic">italic</span></div>
<div className="flex justify-between"><span># Heading 1</span><span className="font-sans">H1</span></div>
<div className="flex justify-between"><span>## Heading 2</span><span className="font-sans">H2</span></div>
<div className="flex justify-between"><span>- list item</span><span className="font-sans">* item</span></div>
<div className="flex justify-between"><span>1. numbered</span><span className="font-sans">1. item</span></div>
<div className="flex justify-between"><span>[text](url)</span><span className="font-sans text-primary">link</span></div>
<div className="flex justify-between"><span>![alt](url)</span><span className="font-sans">image</span></div>
<div className="flex justify-between"><span>&gt; quote</span><span className="font-sans border-l-2 pl-1">quote</span></div>
<div className="flex justify-between"><span>`code`</span><span className="font-sans bg-muted px-1 rounded">code</span></div>
</div>
<p className="text-xs text-muted-foreground pt-2 border-t">
Drag & drop or paste images to upload
</p>
</div>
</PopoverContent>
</Popover>
);
}
interface ToolbarButtonProps {
icon: React.ReactNode;
label: string;
shortcut?: string;
onClick: () => void;
active?: boolean;
}
function ToolbarButton({ icon, label, shortcut, onClick, active }: ToolbarButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onClick}
className={cn(
"h-8 w-8 text-muted-foreground hover:text-foreground",
active && "bg-muted text-foreground"
)}
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent>
<span>{label}</span>
{shortcut && <span className="ml-2 text-muted-foreground text-xs">{shortcut}</span>}
</TooltipContent>
</Tooltip>
);
}
interface MilkdownToolbarProps {
onCommand: (command: string) => void;
onImageUpload?: () => void;
className?: string;
}
export function MilkdownToolbar({ onCommand, onImageUpload, className }: MilkdownToolbarProps) {
return (
<div className={cn(
"flex items-center gap-0.5 p-1.5 border-b border-border bg-card/50 flex-wrap",
className
)}>
{/* Text formatting */}
<ToolbarButton
icon={<Bold className="h-4 w-4" />}
label="Bold"
shortcut="Ctrl+B"
onClick={() => onCommand('toggleBold')}
/>
<ToolbarButton
icon={<Italic className="h-4 w-4" />}
label="Italic"
shortcut="Ctrl+I"
onClick={() => onCommand('toggleItalic')}
/>
<ToolbarButton
icon={<Strikethrough className="h-4 w-4" />}
label="Strikethrough"
onClick={() => onCommand('toggleStrikethrough')}
/>
<ToolbarButton
icon={<Code className="h-4 w-4" />}
label="Inline Code"
onClick={() => onCommand('toggleInlineCode')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Headings */}
<ToolbarButton
icon={<Heading1 className="h-4 w-4" />}
label="Heading 1"
onClick={() => onCommand('heading1')}
/>
<ToolbarButton
icon={<Heading2 className="h-4 w-4" />}
label="Heading 2"
onClick={() => onCommand('heading2')}
/>
<ToolbarButton
icon={<Heading3 className="h-4 w-4" />}
label="Heading 3"
onClick={() => onCommand('heading3')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Lists */}
<ToolbarButton
icon={<List className="h-4 w-4" />}
label="Bullet List"
onClick={() => onCommand('bulletList')}
/>
<ToolbarButton
icon={<ListOrdered className="h-4 w-4" />}
label="Numbered List"
onClick={() => onCommand('orderedList')}
/>
<ToolbarButton
icon={<Quote className="h-4 w-4" />}
label="Blockquote"
onClick={() => onCommand('blockquote')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Links and media */}
<ToolbarButton
icon={<Link className="h-4 w-4" />}
label="Insert Link"
onClick={() => onCommand('link')}
/>
{onImageUpload && (
<ToolbarButton
icon={<Image className="h-4 w-4" />}
label="Insert Image"
onClick={onImageUpload}
/>
)}
<ToolbarButton
icon={<Minus className="h-4 w-4" />}
label="Horizontal Rule"
onClick={() => onCommand('hr')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
<MarkdownHelpPopover />
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
export interface Draft {
id: string;
title: string;
summary: string;
content: string;
image: string;
tags: string[];
slug: string;
updatedAt: number;
eventId?: string; // The nostr event id if saved to relay
}
interface DraftData {
title: string;
summary: string;
content: string;
image: string;
tags: string[];
slug: string;
}
function eventToDraft(event: NostrEvent): Draft {
const getTag = (name: string) => event.tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => event.tags.filter(t => t[0] === name).map(t => t[1]);
return {
id: event.id,
eventId: event.id,
title: getTag('title'),
summary: getTag('summary'),
content: event.content,
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
updatedAt: event.created_at * 1000,
};
}
export function useDrafts() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
// Query drafts from relay
const query = useQuery<Draft[]>({
queryKey: ['drafts', user?.pubkey ?? ''],
queryFn: async ({ signal }) => {
if (!user?.pubkey) {
return [];
}
const events = await nostr.query(
[{ kinds: [30024], authors: [user.pubkey] }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
// Filter out deleted/empty drafts and convert to Draft objects
return events
.filter(e => e.content.trim().length > 0)
.map(eventToDraft)
.sort((a, b) => b.updatedAt - a.updatedAt);
},
enabled: !!user?.pubkey,
staleTime: 30 * 1000, // 30 seconds
});
// Save draft to relay
const saveMutation = useMutation({
mutationFn: async (draft: DraftData) => {
if (!user) {
throw new Error('User is not logged in');
}
const tags: string[][] = [
['d', draft.slug],
['title', draft.title],
];
if (draft.summary) {
tags.push(['summary', draft.summary]);
}
if (draft.image) {
tags.push(['image', draft.image]);
}
draft.tags.forEach(tag => {
tags.push(['t', tag]);
});
// Add client tag
if (location.protocol === 'https:') {
tags.push(['client', location.hostname]);
}
const event = await user.signer.signEvent({
kind: 30024,
content: draft.content,
tags,
created_at: Math.floor(Date.now() / 1000),
});
await nostr.event(event, { signal: AbortSignal.timeout(5000) });
return event;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['drafts', user?.pubkey] });
},
});
// Delete draft from relay (publish kind 5 deletion event)
const deleteMutation = useMutation({
mutationFn: async (slug: string) => {
if (!user) {
throw new Error('User is not logged in');
}
// Find the draft event to get its id (optional - we can delete by 'a' tag alone)
const drafts = query.data || [];
const draft = drafts.find(d => d.slug === slug);
// Build deletion tags - always include 'a' tag for addressable events
const tags: string[][] = [
['a', `30024:${user.pubkey}:${slug}`],
];
// Also include 'e' tag if we know the specific event id
if (draft?.eventId) {
tags.push(['e', draft.eventId]);
}
// Publish a kind 5 deletion event
const event = await user.signer.signEvent({
kind: 5,
content: '',
tags,
created_at: Math.floor(Date.now() / 1000),
});
await nostr.event(event, { signal: AbortSignal.timeout(5000) });
return { event, slug };
},
onSuccess: (data) => {
// Optimistically remove the draft from the cache immediately
queryClient.setQueryData(['drafts', user?.pubkey], (oldData: Draft[] | undefined) => {
if (!oldData) return [];
return oldData.filter(d => d.slug !== data?.slug);
});
},
});
return {
drafts: query.data || [],
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
saveDraft: saveMutation.mutateAsync,
isSaving: saveMutation.isPending,
deleteDraft: deleteMutation.mutateAsync,
isDeleting: deleteMutation.isPending,
};
}
+72
View File
@@ -0,0 +1,72 @@
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
export interface PublishedArticle {
id: string;
eventId: string;
title: string;
summary: string;
content: string;
image: string;
tags: string[];
slug: string;
publishedAt: number;
updatedAt: number;
}
function eventToArticle(event: NostrEvent): PublishedArticle {
const getTag = (name: string) => event.tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => event.tags.filter(t => t[0] === name).map(t => t[1]);
const publishedAtTag = getTag('published_at');
const publishedAt = publishedAtTag ? parseInt(publishedAtTag) * 1000 : event.created_at * 1000;
return {
id: event.id,
eventId: event.id,
title: getTag('title'),
summary: getTag('summary'),
content: event.content,
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
publishedAt,
updatedAt: event.created_at * 1000,
};
}
export function usePublishedArticles() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const query = useQuery<PublishedArticle[]>({
queryKey: ['published-articles', user?.pubkey ?? ''],
queryFn: async ({ signal }) => {
if (!user?.pubkey) {
return [];
}
const events = await nostr.query(
[{ kinds: [30023], authors: [user.pubkey] }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
return events
.filter(e => e.content.trim().length > 0)
.map(eventToArticle)
.sort((a, b) => b.publishedAt - a.publishedAt);
},
enabled: !!user?.pubkey,
staleTime: 30 * 1000,
});
return {
articles: query.data || [],
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}
+160
View File
@@ -494,3 +494,163 @@
}
}
/* ── Milkdown Editor Styles ─────────────────────────────────────────────────── */
.milkdown-editor .milkdown {
@apply outline-none;
}
.milkdown-editor .editor {
@apply outline-none min-h-[400px] p-4;
font-size: 1.125rem;
line-height: 1.75;
}
.milkdown-editor .ProseMirror {
@apply outline-none min-h-[400px];
}
.milkdown-editor .ProseMirror:focus {
@apply outline-none;
}
/* Headings */
.milkdown-editor h1 {
@apply text-3xl font-bold mt-6 mb-4;
}
.milkdown-editor h2 {
@apply text-2xl font-semibold mt-5 mb-3;
}
.milkdown-editor h3 {
@apply text-xl font-semibold mt-4 mb-2;
}
.milkdown-editor h4 {
@apply text-lg font-medium mt-3 mb-2;
}
/* Inline styles */
.milkdown-editor strong {
@apply font-bold;
}
.milkdown-editor em {
@apply italic;
}
.milkdown-editor del {
@apply line-through text-muted-foreground;
}
.milkdown-editor code {
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono;
}
/* Block elements */
.milkdown-editor p {
@apply my-3;
}
.milkdown-editor blockquote {
@apply border-l-4 border-primary/40 pl-4 my-4 italic text-muted-foreground;
}
.milkdown-editor pre {
@apply bg-muted rounded-lg p-4 my-4 overflow-x-auto;
}
.milkdown-editor pre code {
@apply bg-transparent p-0 font-mono;
font-size: 0.875rem;
}
/* Lists */
.milkdown-editor ul {
@apply list-disc list-inside my-3 space-y-1;
}
.milkdown-editor ol {
@apply list-decimal list-inside my-3 space-y-1;
}
.milkdown-editor li {
@apply pl-1;
}
.milkdown-editor li p {
@apply inline my-0;
}
/* Task lists (GFM) */
.milkdown-editor ul.task-list {
@apply list-none pl-0;
}
.milkdown-editor li.task-list-item {
@apply flex items-start gap-2 pl-0;
}
.milkdown-editor li.task-list-item input[type="checkbox"] {
@apply mt-1.5 h-4 w-4 rounded border-border;
}
/* Links */
.milkdown-editor a {
@apply text-primary underline underline-offset-2 hover:text-primary/80 transition-colors;
}
/* Horizontal rule */
.milkdown-editor hr {
@apply my-6 border-border;
}
/* Images */
.milkdown-editor img {
@apply max-w-full h-auto rounded-lg my-4;
}
/* Tables (GFM) */
.milkdown-editor table {
@apply w-full border-collapse my-4;
}
.milkdown-editor th,
.milkdown-editor td {
@apply border border-border px-3 py-2 text-left;
}
.milkdown-editor th {
@apply bg-muted font-semibold;
}
.milkdown-editor tr:nth-child(even) {
@apply bg-muted/30;
}
/* Placeholder */
.milkdown-editor .ProseMirror p.is-editor-empty:first-child::before {
@apply text-muted-foreground pointer-events-none float-left h-0;
content: attr(data-placeholder);
}
/* Upload placeholder */
.milkdown-upload-placeholder {
@apply inline-flex items-center gap-2 px-3 py-1.5 bg-muted/50 rounded-md text-sm text-muted-foreground;
}
.milkdown-upload-placeholder::before {
content: '';
@apply w-4 h-4 border-2 border-muted-foreground/30 border-t-primary rounded-full animate-spin;
}
/* Milkdown content area */
.milkdown-editor .milkdown-content {
@apply p-4;
}
.milkdown-editor .milkdown-content .ProseMirror {
@apply min-h-[350px];
}
+1 -2
View File
@@ -128,8 +128,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
route: 'articles',
addressable: true,
section: 'feed',
blurb: 'Blog posts, essays, and guides. Write and publish from a dedicated editor.',
sites: [{ url: 'https://inkwell.shakespeare.wtf' }],
blurb: 'Blog posts, essays, and guides. Write and publish long-form articles.',
},
// Media
{
+60
View File
@@ -0,0 +1,60 @@
import type { Draft } from '@/hooks/useDrafts';
const DRAFTS_KEY = 'article-drafts';
/** Save a draft to localStorage. Returns the draft ID or null on failure. */
export function saveDraft(draft: Omit<Draft, 'id' | 'updatedAt'> & { id?: string }): string | null {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
const drafts: Draft[] = stored ? JSON.parse(stored) : [];
const existingIndex = draft.id
? drafts.findIndex(d => d.id === draft.id)
: drafts.findIndex(d => d.slug === draft.slug);
const newDraft: Draft = {
...draft,
id: draft.id || (existingIndex >= 0 ? drafts[existingIndex].id : crypto.randomUUID()),
updatedAt: Date.now(),
};
if (existingIndex >= 0) {
drafts[existingIndex] = newDraft;
} else {
drafts.unshift(newDraft);
}
// Keep only the 20 most recent drafts
const trimmedDrafts = drafts.slice(0, 20);
localStorage.setItem(DRAFTS_KEY, JSON.stringify(trimmedDrafts));
return newDraft.id;
} catch (error) {
console.error('Failed to save draft:', error);
return null;
}
}
/** Delete a draft by slug from localStorage. */
export function deleteDraftBySlug(slug: string): void {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
if (!stored) return;
const drafts: Draft[] = JSON.parse(stored);
const filtered = drafts.filter(d => d.slug !== slug);
localStorage.setItem(DRAFTS_KEY, JSON.stringify(filtered));
} catch (error) {
console.error('Failed to delete draft:', error);
}
}
/** Get all local drafts. */
export function getLocalDrafts(): Draft[] {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
+380
View File
@@ -0,0 +1,380 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import { nip19 } from 'nostr-tools';
import {
FileText,
Trash2,
Clock,
ChevronRight,
Cloud,
HardDrive,
Loader2,
CheckCircle2,
BookOpen,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { PageHeader } from '@/components/PageHeader';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useDrafts, type Draft } from '@/hooks/useDrafts';
import { usePublishedArticles } from '@/hooks/usePublishedArticles';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { toast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
interface LocalDraft extends Draft {
isLocal: true;
}
interface RelayDraft extends Draft {
isLocal: false;
}
type CombinedDraft = LocalDraft | RelayDraft;
const DRAFTS_KEY = 'article-drafts';
export function ArticleDraftsPage() {
const navigate = useNavigate();
const { user } = useCurrentUser();
// Go back to wherever the user came from; fall back to /articles
const handleBack = useMemo(() => {
return () => window.history.length > 1 ? navigate(-1) : navigate('/articles');
}, [navigate]);
const { drafts: relayDrafts, isLoading, deleteDraft, isDeleting } = useDrafts();
const { articles: publishedArticles, isLoading: isLoadingArticles } = usePublishedArticles();
const [localDrafts, setLocalDrafts] = useState<Draft[]>([]);
const [activeTab, setActiveTab] = useState<'drafts' | 'published'>('drafts');
const [deleteTarget, setDeleteTarget] = useState<{
id: string;
slug: string;
isLocal: boolean;
} | null>(null);
useLayoutOptions({ showFAB: false, hasSubHeader: true });
const loadLocalDrafts = useCallback(() => {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
if (stored) {
setLocalDrafts(JSON.parse(stored));
}
} catch (error) {
console.error('Failed to load local drafts:', error);
}
}, []);
useEffect(() => {
loadLocalDrafts();
}, [loadLocalDrafts]);
// Combine relay and local drafts, avoiding duplicates by slug
const combinedDrafts: CombinedDraft[] = (() => {
const drafts: CombinedDraft[] = [];
const seenSlugs = new Set<string>();
for (const draft of relayDrafts) {
if (draft.slug) seenSlugs.add(draft.slug);
drafts.push({ ...draft, isLocal: false });
}
for (const draft of localDrafts) {
if (!draft.slug || !seenSlugs.has(draft.slug)) {
drafts.push({ ...draft, isLocal: true });
}
}
return drafts.sort((a, b) => b.updatedAt - a.updatedAt);
})();
const handleSelectDraft = (draft: CombinedDraft) => {
if (draft.isLocal) {
navigate(`/articles/new?draft=${encodeURIComponent(draft.slug)}`);
} else {
// For relay drafts, navigate with slug as query param (the editor will fetch it)
navigate(`/articles/new?draft=${encodeURIComponent(draft.slug)}`);
}
};
const handleSelectArticle = (article: { slug: string; tags: string[] }) => {
if (!user) return;
const naddr = nip19.naddrEncode({
kind: 30023,
pubkey: user.pubkey,
identifier: article.slug,
});
navigate(`/articles/edit/${naddr}`);
};
const handleDelete = async () => {
if (!deleteTarget) return;
if (deleteTarget.isLocal) {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
if (stored) {
const drafts: Draft[] = JSON.parse(stored);
const filtered = drafts.filter((d) => d.id !== deleteTarget.id);
localStorage.setItem(DRAFTS_KEY, JSON.stringify(filtered));
setLocalDrafts(filtered);
}
} catch (error) {
console.error('Failed to delete local draft:', error);
}
toast({
title: 'Draft deleted',
description: 'The draft has been removed from your browser.',
});
} else {
try {
await deleteDraft(deleteTarget.slug);
toast({
title: 'Draft deleted',
description: 'The draft deletion event has been published to relays.',
});
} catch (error) {
const message = error instanceof Error ? error.message : '';
toast({
title: 'Delete failed',
description: message || 'Could not delete draft from relays.',
variant: 'destructive',
});
}
}
setDeleteTarget(null);
};
const totalDrafts = combinedDrafts.length;
return (
<>
<PageHeader
title="Your Articles"
icon={<BookOpen className="size-5" />}
onBack={handleBack}
alwaysShowBack
/>
<SubHeaderBar>
<TabButton
label={`Drafts${totalDrafts > 0 ? ` (${totalDrafts})` : ''}`}
active={activeTab === 'drafts'}
onClick={() => setActiveTab('drafts')}
/>
<TabButton
label={`Published${publishedArticles.length > 0 ? ` (${publishedArticles.length})` : ''}`}
active={activeTab === 'published'}
onClick={() => setActiveTab('published')}
/>
</SubHeaderBar>
<div className="px-4 py-4">
{activeTab === 'drafts' && (
<>
{isLoading && user ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground mb-4" />
<p className="text-muted-foreground">Loading drafts...</p>
</div>
) : totalDrafts === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<FileText className="w-8 h-8 text-muted-foreground" />
</div>
<p className="text-muted-foreground">No drafts yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Your saved drafts will appear here
</p>
<Button
className="mt-4"
onClick={() => navigate('/articles/new')}
>
Write an article
</Button>
</div>
) : (
<div className="space-y-3">
{combinedDrafts.map((draft) => (
<div
key={draft.id}
className={cn(
'group p-4 rounded-xl border border-border',
'hover:border-primary/30 hover:bg-card transition-all cursor-pointer',
)}
onClick={() => handleSelectDraft(draft)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium truncate">
{draft.title || 'Untitled Draft'}
</h3>
{draft.summary && (
<p className="text-sm text-muted-foreground truncate mt-1">
{draft.summary}
</p>
)}
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-1" />
</div>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
{draft.isLocal ? (
<HardDrive className="w-3 h-3 shrink-0" />
) : (
<Cloud className="w-3 h-3 text-primary shrink-0" />
)}
<Clock className="w-3 h-3 shrink-0" />
<span>
{formatDistanceToNow(draft.updatedAt, { addSuffix: true })}
</span>
{draft.tags.length > 0 && (
<>
<span>·</span>
<span>{draft.tags.length} tags</span>
</>
)}
<span className="flex-1" />
<button
className="p-1 rounded-full text-muted-foreground hover:text-destructive transition-colors"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget({
id: draft.id,
slug: draft.slug,
isLocal: draft.isLocal,
});
}}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</>
)}
{activeTab === 'published' && (
<>
{isLoadingArticles && user ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground mb-4" />
<p className="text-muted-foreground">Loading articles...</p>
</div>
) : !user ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<CheckCircle2 className="w-8 h-8 text-muted-foreground" />
</div>
<p className="text-muted-foreground">Sign in to see your articles</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Your published articles will appear here
</p>
</div>
) : publishedArticles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<CheckCircle2 className="w-8 h-8 text-muted-foreground" />
</div>
<p className="text-muted-foreground">No published articles yet</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Your published articles will appear here
</p>
</div>
) : (
<div className="space-y-3">
{publishedArticles.map((article) => (
<div
key={article.id}
className={cn(
'group relative p-4 rounded-xl border border-border',
'hover:border-green-500/30 hover:bg-card transition-all cursor-pointer',
)}
onClick={() => handleSelectArticle(article)}
>
<div className="pr-8">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate flex-1">
{article.title || 'Untitled Article'}
</h3>
<CheckCircle2 className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />
</div>
{article.summary && (
<p className="text-sm text-muted-foreground truncate mt-1">
{article.summary}
</p>
)}
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
<span>
Published{' '}
{formatDistanceToNow(article.publishedAt, {
addSuffix: true,
})}
</span>
{article.tags.length > 0 && (
<>
<span>·</span>
<span>{article.tags.length} tags</span>
</>
)}
</div>
</div>
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1">
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
</div>
))}
</div>
)}
</>
)}
</div>
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete draft?</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget?.isLocal
? 'This action cannot be undone. The draft will be permanently deleted from your browser.'
: 'This action cannot be undone. The draft will be deleted from Nostr relays.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Deleting...
</>
) : (
'Delete'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useState } from 'react';
import { useSearchParams, useParams } from 'react-router-dom';
import { useNostr } from '@nostrify/react';
import { nip19 } from 'nostr-tools';
import type { AddressPointer } from 'nostr-tools/nip19';
import { Loader2 } from 'lucide-react';
import { ArticleEditor, type ArticleData } from '@/components/articles/ArticleEditor';
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { getLocalDrafts } from '@/lib/localDrafts';
/** Thin page wrapper for /articles/new and /articles/edit/:naddr */
export function ArticleEditorPage() {
useLayoutOptions({ showFAB: false, hideBottomNav: true });
const [searchParams] = useSearchParams();
const { naddr: naddrParam } = useParams<{ naddr: string }>();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const draftSlug = searchParams.get('draft');
const [initialData, setInitialData] = useState<(Partial<ArticleData> & { publishedAt?: number }) | undefined>(undefined);
const [editMode, setEditMode] = useState(false);
const [loading, setLoading] = useState(!!naddrParam || !!draftSlug);
// Load draft from relay or localStorage if ?draft=<slug>
useEffect(() => {
if (!draftSlug) return;
// Try relay draft first if logged in, then fall back to localStorage
const loadDraft = async () => {
if (user) {
try {
const events = await nostr.query([
{ kinds: [30024], authors: [user.pubkey], '#d': [draftSlug], limit: 1 },
]);
if (events.length > 0) {
const event = events[0];
const getTag = (name: string) => event.tags.find((t) => t[0] === name)?.[1] || '';
const getTags = (name: string) => event.tags.filter((t) => t[0] === name).map((t) => t[1]);
setInitialData({
title: getTag('title'),
summary: getTag('summary'),
content: event.content,
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
});
setLoading(false);
return;
}
} catch {
// Fall through to localStorage
}
}
// Fallback to localStorage
const drafts = getLocalDrafts();
const draft = drafts.find((d) => d.slug === draftSlug);
if (draft) {
setInitialData({
title: draft.title,
summary: draft.summary,
content: draft.content,
image: draft.image,
tags: draft.tags,
slug: draft.slug,
});
}
setLoading(false);
};
loadDraft();
}, [draftSlug, user, nostr]);
// Load existing article for editing if /articles/edit/:naddr
useEffect(() => {
if (!naddrParam) return;
let decoded: { type: string; data: AddressPointer };
try {
decoded = nip19.decode(naddrParam) as { type: 'naddr'; data: AddressPointer };
if (decoded.type !== 'naddr') {
setLoading(false);
return;
}
} catch {
setLoading(false);
return;
}
const addr = decoded.data;
nostr
.query([
{
kinds: [addr.kind],
authors: [addr.pubkey],
'#d': [addr.identifier],
limit: 1,
},
])
.then((events) => {
if (events.length > 0) {
const event = events[0];
const getTag = (name: string) =>
event.tags.find((t) => t[0] === name)?.[1] || '';
const getTags = (name: string) =>
event.tags.filter((t) => t[0] === name).map((t) => t[1]);
const publishedAtTag = getTag('published_at');
const publishedAt = publishedAtTag
? parseInt(publishedAtTag) * 1000
: event.created_at * 1000;
setInitialData({
title: getTag('title'),
summary: getTag('summary'),
content: event.content,
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
publishedAt,
});
setEditMode(true);
}
})
.catch((err) => {
console.error('Failed to load article for editing:', err);
})
.finally(() => {
setLoading(false);
});
}, [naddrParam, nostr]);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return <ArticleEditor initialData={initialData} editMode={editMode} />;
}
+1 -1
View File
@@ -50,7 +50,7 @@ export function KindFeedPage({ kind, title, icon, emptyMessage, kindDef, backTo
description: `${title} on Nostr`,
});
const fabClick = onFabClick ?? (resolvedDef ? () => setInfoOpen(true) : undefined);
const fabClick = onFabClick ?? (!fabHref && resolvedDef ? () => setInfoOpen(true) : undefined);
useLayoutOptions({ showFAB, fabKind: primaryKind, fabHref, onFabClick: fabClick, hasSubHeader: !!user });
const kinds = Array.isArray(kind) ? kind : [kind];