refactor: harden article editor — encryption, mobile UX, deduplication, source toggle

- Encrypt drafts with NIP-44 via NIP-37 (kind 31234) instead of
  plaintext kind 30024
- Fix slug auto-generation overwriting manual edits
- Guard auto-save state setters against unmount
- Deduplicate save logic, load handlers, tag extraction, and types
  via shared ArticleFields/parseArticleEvent helpers
- Replace derived state (wordCount/readingTime) with useMemo
- Mobile UX: sticky toolbar, touch-friendly header image swap,
  adaptive tooltips (pointer:fine only), FAB bottom clearance,
  responsive editor min-height
- Editor placeholder: hide on focus, handle trailing whitespace
- Tighten editor padding and paragraph spacing
- Add raw markdown source toggle (Eye/EyeOff) in toolbar
- Shrink slug/tag fields, consistent sizing
This commit is contained in:
Chad Curtis
2026-04-02 03:46:53 -05:00
parent 89c71ed073
commit fa34922cce
13 changed files with 581 additions and 847 deletions
+37
View File
@@ -716,6 +716,43 @@ await publishEvent({ kind: 10003, content: freshEvent?.content ?? '', tags: newT
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
### D-Tag Collision Prevention for Addressable Events
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
#### When to Check for Collisions
**Must check before publishing** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.). **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
#### Implementation Pattern
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
```typescript
// Before publishing a new addressable event:
const slug = slugify(title, { lower: true, strict: true });
const existing = await nostr.query([
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
]);
if (existing.length > 0) {
toast({
title: 'Slug already in use',
description: 'Change the slug or edit the existing item.',
variant: 'destructive',
});
return;
}
// Safe to publish
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
```
**Skip the check in edit mode** -- when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
### Nostr Login
To enable login with Nostr, simply use the `LoginArea` component already included in this project.
-2
View File
@@ -34,7 +34,6 @@ 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 })));
@@ -215,7 +214,6 @@ 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={
+171 -197
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import {
@@ -17,6 +17,7 @@ import {
ChevronRight,
} from 'lucide-react';
import slugify from 'slugify';
import { useNostr } from '@nostrify/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -42,17 +43,11 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useDrafts, type Draft } from '@/hooks/useDrafts';
import { usePublishedArticles } from '@/hooks/usePublishedArticles';
import { saveDraft as saveLocalDraft, deleteDraftBySlug, getLocalDrafts } from '@/lib/localDrafts';
import { saveDraft as saveLocalDraft, deleteDraftBySlug, deleteLocalDraftById, getLocalDrafts } from '@/lib/localDrafts';
import type { ArticleFields } from '@/lib/articleHelpers';
import { MilkdownEditor } from './MilkdownEditor';
export interface ArticleData {
title: string;
summary: string;
content: string;
image: string;
tags: string[];
slug: string;
}
export type ArticleData = ArticleFields;
interface ArticleEditorProps {
/** Pre-filled data for editing an existing article or loading a draft. */
@@ -65,6 +60,7 @@ type EditorTab = 'write' | 'drafts';
export function ArticleEditor({ initialData, editMode = false }: ArticleEditorProps) {
const navigate = useNavigate();
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { mutate: publishEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
@@ -72,15 +68,12 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
const { articles: publishedArticles } = 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 slugManuallyEdited = useRef(!!initialData?.slug);
const [isPublished, setIsPublished] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
@@ -99,31 +92,62 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
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);
}
// Keep a ref to the latest article data so the auto-save timer doesn't
// need `article` in its dependency array (which would reset it on every keystroke).
const articleRef = useRef(article);
articleRef.current = article;
const mountedRef = useRef(true);
useEffect(() => () => { mountedRef.current = false; }, []);
/** Save draft to relay (with localStorage fallback). Shared by manual save + auto-save. */
const persistDraft = useCallback(async (data: ArticleData, { silent }: { silent?: boolean } = {}) => {
if (user) {
try {
await saveRelayDraft(data);
if (!mountedRef.current) return;
setLastSaved(new Date());
setHasUnsavedChanges(false);
}, 30000);
if (!silent) {
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(data);
if (!mountedRef.current) return;
setLastSaved(new Date());
setHasUnsavedChanges(false);
if (!silent) {
toast({ title: 'Draft saved locally', description: 'Could not sync to relays. Saved to your browser.', variant: 'destructive' });
}
}
} else {
saveLocalDraft(data);
if (!mountedRef.current) return;
setLastSaved(new Date());
setHasUnsavedChanges(false);
if (!silent) {
toast({ title: 'Draft saved', description: 'Your article has been saved locally.' });
}
}
}, [user, saveRelayDraft]);
// Auto-save 30s after the first unsaved change. The timer starts once and
// is only reset when `hasUnsavedChanges` transitions, not on every keystroke.
useEffect(() => {
if (!hasUnsavedChanges) return;
autoSaveTimeoutRef.current = setTimeout(() => {
const current = articleRef.current;
if (current.content.length === 0) return;
persistDraft(current, { silent: true });
}, 30000);
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}, [hasUnsavedChanges, article, user, saveRelayDraft]);
}, [hasUnsavedChanges, persistDraft]);
// Reference to handlers for keyboard shortcuts
const handlePublishRef = useRef<(() => void) | null>(null);
@@ -143,9 +167,9 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges, article.title, article.content]);
// Auto-generate slug from title
// Auto-generate slug from title (skip if user manually edited the slug)
useEffect(() => {
if (article.title && !initialData?.slug) {
if (article.title && !slugManuallyEdited.current) {
const newSlug = slugify(article.title, {
lower: true,
strict: true,
@@ -153,18 +177,11 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
});
setArticle((prev) => ({ ...prev, slug: newSlug }));
}
}, [article.title, initialData?.slug]);
}, [article.title]);
// 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]);
// Derived stats
const wordCount = useMemo(() => article.content.trim().split(/\s+/).filter(Boolean).length, [article.content]);
const readingTime = Math.ceil(wordCount / 200);
// Load local drafts when drafts tab is shown
useEffect(() => {
@@ -174,7 +191,7 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
}, [activeTab]);
// Combine relay and local drafts, avoiding duplicates by slug
const combinedDrafts = (() => {
const combinedDrafts = useMemo(() => {
const drafts: (Draft & { isLocal: boolean })[] = [];
const seenSlugs = new Set<string>();
@@ -190,43 +207,28 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
}
return drafts.sort((a, b) => b.updatedAt - a.updatedAt);
})();
}, [relayDrafts, localDrafts]);
const handleLoadDraft = useCallback((draft: Draft & { isLocal: boolean }) => {
/** Load a draft or published article into the editor. */
const handleLoadItem = useCallback((item: ArticleData & { publishedAt?: number }, isPublishedArticle: boolean) => {
setArticle({
title: draft.title,
summary: draft.summary,
content: draft.content,
image: draft.image,
tags: draft.tags,
slug: draft.slug,
title: item.title,
summary: item.summary,
content: item.content,
image: item.image,
tags: item.tags,
slug: item.slug,
});
setIsEditMode(false);
setOriginalPublishedAt(null);
slugManuallyEdited.current = !!item.slug;
setIsEditMode(isPublishedArticle);
setOriginalPublishedAt(item.publishedAt ?? 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.',
title: isPublishedArticle ? 'Article loaded for editing' : 'Draft loaded',
description: isPublishedArticle
? 'Make changes and publish to update your article.'
: 'Your draft has been loaded into the editor.',
});
}, []);
@@ -234,17 +236,7 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
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);
}
setLocalDrafts(deleteLocalDraftById(deleteTarget.id));
toast({ title: 'Draft deleted', description: 'Removed from your browser.' });
} else {
try {
@@ -316,84 +308,13 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
[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]);
await persistDraft(article);
}, [article, persistDraft]);
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;
}
/** Perform the actual publish (called directly or after overwrite confirmation). */
const doPublish = useCallback(() => {
if (!user) return;
// Use original published_at when editing, current time for new articles
const publishedAtTimestamp =
@@ -470,6 +391,59 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
navigate,
]);
const handlePublish = useCallback(async () => {
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;
}
// In edit mode we're intentionally overwriting, so skip the collision check
if (!isEditMode) {
const slug = article.slug || slugify(article.title, { lower: true, strict: true });
try {
const existing = await nostr.query([
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
]);
if (existing.length > 0) {
toast({
title: 'Slug already in use',
description: 'You already have a published article with this slug. Change the slug or edit the existing article from My Articles.',
variant: 'destructive',
});
return;
}
} catch {
// If the check fails (e.g. relay timeout), proceed anyway
}
}
doPublish();
}, [user, article, isEditMode, nostr, doPublish]);
// Set refs for keyboard shortcuts
handlePublishRef.current = handlePublish;
handleSaveDraftRef.current = handleSaveDraft;
@@ -511,15 +485,6 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
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'}
@@ -577,17 +542,9 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
onChange={handleHeaderImageUpload}
className="hidden"
/>
<input
ref={inlineImageInputRef}
type="file"
accept="image/*"
onChange={handleInlineImageUpload}
className="hidden"
/>
{/* ── New article tab ──────────────────────────────────────── */}
{activeTab === 'write' && (
<div className="px-4 py-6 space-y-6">
<div className="px-4 py-6 pb-24 space-y-6">
{/* Header Image */}
{article.image ? (
<div className="relative rounded-xl overflow-hidden group">
@@ -596,7 +553,8 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
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">
{/* Desktop: centered overlay on hover */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors hidden sm:flex items-center justify-center opacity-0 group-hover:opacity-100">
<Button
variant="secondary"
size="sm"
@@ -611,6 +569,20 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
Change Image
</Button>
</div>
{/* Mobile: persistent corner button */}
<Button
variant="secondary"
size="icon"
className="absolute top-2 right-2 h-8 w-8 rounded-full shadow-md sm:hidden"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Image className="w-4 h-4" />
)}
</Button>
</div>
) : (
<button
@@ -650,23 +622,26 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
/>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<div className="space-y-2 flex-1">
<Label htmlFor="slug" className="text-muted-foreground text-sm">URL Slug</Label>
<div className="flex flex-col sm:flex-row gap-3">
<div className="space-y-1.5 flex-1">
<Label htmlFor="slug" className="text-muted-foreground text-xs leading-none">URL Slug</Label>
<Input
id="slug"
value={article.slug}
onChange={(e) => updateArticle('slug', e.target.value)}
onChange={(e) => {
slugManuallyEdited.current = true;
updateArticle('slug', e.target.value);
}}
placeholder="article-url-slug"
className="font-mono text-sm"
className="h-8 font-mono text-xs"
/>
</div>
<div className="space-y-2 flex-1">
<Label className="text-muted-foreground text-sm flex items-center gap-1.5">
<Hash className="w-3.5 h-3.5" />
<div className="space-y-1.5 flex-1">
<Label className="text-muted-foreground text-xs inline-flex items-center gap-1 leading-none">
<Hash className="w-3 h-3 shrink-0" />
Tags
</Label>
<div className="flex gap-2">
<div className="flex gap-1.5">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
@@ -674,10 +649,10 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
e.key === 'Enter' && (e.preventDefault(), handleAddTag())
}
placeholder="Add a tag..."
className="flex-1"
className="h-8 text-xs flex-1"
/>
<Button type="button" variant="secondary" size="sm" onClick={handleAddTag}>
Add
<Button type="button" variant="secondary" size="icon" className="h-8 w-8 shrink-0" onClick={handleAddTag}>
<span className="text-base leading-none">+</span>
</Button>
</div>
</div>
@@ -702,9 +677,8 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
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]"
className="rounded-xl border border-border bg-card min-h-[250px] sm:min-h-[400px]"
/>
{/* Stats + Save */}
@@ -761,7 +735,7 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
<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)}
onClick={() => handleLoadItem(draft, false)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
@@ -814,7 +788,7 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr
<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)}
onClick={() => handleLoadItem(pub, true)}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
-1
View File
@@ -80,7 +80,6 @@ export function LinkDialog({ open, onOpenChange, selectedText, onSubmit }: LinkD
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
type="url"
autoFocus={hasSelectedText}
/>
</div>
+91 -23
View File
@@ -16,17 +16,20 @@ interface MilkdownEditorInnerProps {
value: string;
onChange: (markdown: string) => void;
onUploadImage?: (file: File) => Promise<string | null>;
onImageButtonClick?: () => void;
placeholder?: string;
showToolbar?: boolean;
sourceMode?: boolean;
onToggleSource?: () => void;
}
function MilkdownEditorInner({ value, onChange, onUploadImage, onImageButtonClick, placeholder, showToolbar = true }: MilkdownEditorInnerProps) {
function MilkdownEditorInner({ value, onChange, onUploadImage, placeholder, showToolbar = true, sourceMode, onToggleSource }: MilkdownEditorInnerProps) {
const initialValueRef = useRef(value);
const editorRef = useRef<Editor | null>(null);
const lastExternalValue = useRef(value);
const onUploadImageRef = useRef(onUploadImage);
const fileInputRef = useRef<HTMLInputElement>(null);
// Link dialog state
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [selectedTextForLink, setSelectedTextForLink] = useState<string>('');
@@ -121,6 +124,28 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, onImageButtonClic
editorRef.current = get() ?? null;
}, [get]);
// Toggle `has-content` class on blur so CSS can hide the placeholder
// when the editor has real content (including trailing whitespace that
// ProseMirror collapses out of the DOM).
useEffect(() => {
const editor = get();
if (!editor) return;
let dom: HTMLElement;
try {
dom = editor.ctx.get(editorViewCtx).dom;
} catch {
return;
}
const check = () => {
const hasContent = !!lastExternalValue.current.replace(/\n/g, '');
dom.classList.toggle('has-content', hasContent);
};
// Set initial state
check();
dom.addEventListener('blur', check);
return () => dom.removeEventListener('blur', check);
}, [get]);
// Handle external value changes (e.g., loading a draft)
useEffect(() => {
const editor = get();
@@ -131,20 +156,6 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, onImageButtonClic
}
}, [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();
@@ -200,6 +211,39 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, onImageButtonClic
}
}, [get]);
// Handle image upload via file picker + ProseMirror insertion
const handleImageButtonClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleImageFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !onUploadImageRef.current) return;
const url = await onUploadImageRef.current(file);
if (!url) return;
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state, dispatch } = view;
const { schema } = state;
const node = schema.nodes.image.createAndFill({ src: url, alt: file.name });
if (node) {
const { from } = state.selection;
dispatch(state.tr.insert(from, node));
view.focus();
}
} catch (error) {
console.error('Failed to insert image:', error);
}
// Reset so the same file can be re-selected
e.target.value = '';
}, [get]);
// Handle toolbar commands
const handleCommand = useCallback((command: string) => {
const editor = get();
@@ -262,12 +306,34 @@ function MilkdownEditorInner({ value, onChange, onUploadImage, onImageButtonClic
{showToolbar && (
<MilkdownToolbar
onCommand={handleCommand}
onImageUpload={onImageButtonClick}
onImageUpload={onUploadImage ? handleImageButtonClick : undefined}
sourceMode={sourceMode}
onToggleSource={onToggleSource}
/>
)}
<div className="milkdown-content">
<Milkdown />
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageFileChange}
className="hidden"
/>
{sourceMode ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full min-h-[250px] sm:min-h-[350px] p-3 bg-transparent font-mono text-sm resize-y outline-none"
placeholder={placeholder}
spellCheck={false}
/>
) : (
<div
className="milkdown-content"
style={placeholder ? { '--ph': `"${placeholder.replace(/"/g, '\\"')}"` } as React.CSSProperties : undefined}
>
<Milkdown />
</div>
)}
<LinkDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
@@ -282,13 +348,14 @@ 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) {
export function MilkdownEditor({ value, onChange, onUploadImage, placeholder, className, showToolbar = true }: MilkdownEditorProps) {
const [sourceMode, setSourceMode] = useState(false);
return (
<div className={`milkdown-editor ${className || ''}`}>
<MilkdownProvider>
@@ -296,9 +363,10 @@ export function MilkdownEditor({ value, onChange, onUploadImage, onImageButtonCl
value={value}
onChange={onChange}
onUploadImage={onUploadImage}
onImageButtonClick={onImageButtonClick}
placeholder={placeholder}
showToolbar={showToolbar}
sourceMode={sourceMode}
onToggleSource={() => setSourceMode((s) => !s)}
/>
</MilkdownProvider>
</div>
+124 -90
View File
@@ -13,6 +13,8 @@ import {
Image,
Minus,
HelpCircle,
Eye,
EyeOff,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -64,6 +66,9 @@ function MarkdownHelpPopover() {
);
}
const hasPointerFine = typeof window !== 'undefined'
&& window.matchMedia('(pointer: fine)').matches;
interface ToolbarButtonProps {
icon: React.ReactNode;
label: string;
@@ -73,20 +78,27 @@ interface ToolbarButtonProps {
}
function ToolbarButton({ icon, label, shortcut, onClick, active }: ToolbarButtonProps) {
const button = (
<Button
variant="ghost"
size="icon"
onClick={onClick}
aria-label={label}
className={cn(
"h-8 w-8 text-muted-foreground hover:text-foreground",
active && "bg-muted text-foreground"
)}
>
{icon}
</Button>
);
if (!hasPointerFine) return button;
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>
{button}
</TooltipTrigger>
<TooltipContent>
<span>{label}</span>
@@ -99,101 +111,123 @@ function ToolbarButton({ icon, label, shortcut, onClick, active }: ToolbarButton
interface MilkdownToolbarProps {
onCommand: (command: string) => void;
onImageUpload?: () => void;
sourceMode?: boolean;
onToggleSource?: () => void;
className?: string;
}
export function MilkdownToolbar({ onCommand, onImageUpload, className }: MilkdownToolbarProps) {
export function MilkdownToolbar({ onCommand, onImageUpload, sourceMode, onToggleSource, className }: MilkdownToolbarProps) {
return (
<div className={cn(
"flex items-center gap-0.5 p-1.5 border-b border-border bg-card/50 flex-wrap",
"flex items-center gap-0.5 p-1.5 border-b border-border bg-card/95 backdrop-blur-sm flex-wrap sticky top-0 z-10 rounded-t-xl",
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')}
/>
{!sourceMode && (
<>
{/* 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" />
<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')}
/>
{/* 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" />
<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')}
/>
{/* 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" />
<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 && (
{/* 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 />
</>
)}
{sourceMode && (
<>
<span className="text-xs text-muted-foreground px-1.5">Markdown Source</span>
<span className="flex-1" />
</>
)}
{onToggleSource && (
<ToolbarButton
icon={<Image className="h-4 w-4" />}
label="Insert Image"
onClick={onImageUpload}
icon={sourceMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
label={sourceMode ? 'Rich text editor' : 'Markdown source'}
active={sourceMode}
onClick={onToggleSource}
/>
)}
<ToolbarButton
icon={<Minus className="h-4 w-4" />}
label="Horizontal Rule"
onClick={() => onCommand('hr')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
<MarkdownHelpPopover />
</div>
);
}
+77 -93
View File
@@ -3,42 +3,56 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { type ArticleFields } from '@/lib/articleHelpers';
export interface Draft {
/** Kind 31234 — NIP-37 Draft Wrap. */
const DRAFT_WRAP_KIND = 31234;
/** The inner draft kind we're wrapping. */
const ARTICLE_KIND = 30023;
export interface Draft extends ArticleFields {
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
eventId?: string;
}
interface DraftData {
title: string;
summary: string;
content: string;
image: string;
tags: string[];
slug: string;
}
type DraftData = ArticleFields;
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]);
/** Build an unsigned kind-30023 event object from draft data. */
function buildInnerDraftEvent(draft: DraftData): Record<string, unknown> {
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]));
return {
id: event.id,
eventId: event.id,
kind: ARTICLE_KIND,
content: draft.content,
tags,
};
}
/** Parse a decrypted inner draft event back into a Draft. */
function parseDraftPayload(inner: Record<string, unknown>, wrapEvent: NostrEvent): Draft | null {
const tags = (inner.tags ?? []) as string[][];
const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
return {
id: wrapEvent.id,
eventId: wrapEvent.id,
title: getTag('title'),
summary: getTag('summary'),
content: event.content,
content: (inner.content as string) || '',
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
updatedAt: event.created_at * 1000,
updatedAt: wrapEvent.created_at * 1000,
};
}
@@ -46,108 +60,78 @@ export function useDrafts() {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const queryClient = useQueryClient();
const { mutateAsync: publishEvent } = useNostrPublish();
// Query drafts from relay
// Query and decrypt drafts from relay
const query = useQuery<Draft[]>({
queryKey: ['drafts', user?.pubkey ?? ''],
queryFn: async ({ signal }) => {
if (!user?.pubkey) {
return [];
}
if (!user?.pubkey || !user.signer.nip44) return [];
const events = await nostr.query(
[{ kinds: [30024], authors: [user.pubkey] }],
[{ kinds: [DRAFT_WRAP_KIND], authors: [user.pubkey], '#k': [String(ARTICLE_KIND)], limit: 100 }],
{ 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);
const drafts: Draft[] = [];
for (const event of events) {
// Blank content means deleted
if (!event.content.trim()) continue;
try {
const decrypted = await user.signer.nip44.decrypt(user.pubkey, event.content);
const inner = JSON.parse(decrypted) as Record<string, unknown>;
const draft = parseDraftPayload(inner, event);
if (draft && draft.content.trim()) drafts.push(draft);
} catch {
// Skip events that fail to decrypt or parse
continue;
}
}
return drafts.sort((a, b) => b.updatedAt - a.updatedAt);
},
enabled: !!user?.pubkey,
staleTime: 30 * 1000, // 30 seconds
enabled: !!user?.pubkey && !!user?.signer.nip44,
staleTime: 30 * 1000,
});
// Save draft to relay
// Save draft: encrypt inner event and publish as kind 31234
const saveMutation = useMutation({
mutationFn: async (draft: DraftData) => {
if (!user) {
throw new Error('User is not logged in');
}
if (!user?.signer.nip44) throw new Error('NIP-44 encryption not supported by signer');
const tags: string[][] = [
['d', draft.slug],
['title', draft.title],
];
const inner = buildInnerDraftEvent(draft);
const plaintext = JSON.stringify(inner);
const encrypted = await user.signer.nip44.encrypt(user.pubkey, plaintext);
if (draft.summary) {
tags.push(['summary', draft.summary]);
}
if (draft.image) {
tags.push(['image', draft.image]);
}
draft.tags.forEach(tag => {
tags.push(['t', tag]);
return publishEvent({
kind: DRAFT_WRAP_KIND,
content: encrypted,
tags: [
['d', draft.slug],
['k', String(ARTICLE_KIND)],
],
});
// 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)
// Delete draft (publish kind 5 deletion event)
const deleteMutation = useMutation({
mutationFn: async (slug: string) => {
if (!user) {
throw new Error('User is not logged in');
}
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({
const event = await publishEvent({
kind: 5,
content: '',
tags,
created_at: Math.floor(Date.now() / 1000),
tags: [['a', `${DRAFT_WRAP_KIND}:${user.pubkey}:${slug}`]],
});
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);
+5 -21
View File
@@ -3,37 +3,21 @@ import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCurrentUser } from './useCurrentUser';
import { parseArticleEvent, type ArticleFields } from '@/lib/articleHelpers';
export interface PublishedArticle {
export interface PublishedArticle extends ArticleFields {
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;
const parsed = parseArticleEvent(event);
return {
...parsed,
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,
};
}
@@ -50,7 +34,7 @@ export function usePublishedArticles() {
}
const events = await nostr.query(
[{ kinds: [30023], authors: [user.pubkey] }],
[{ kinds: [30023], authors: [user.pubkey], limit: 100 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
);
+8 -10
View File
@@ -501,7 +501,7 @@
}
.milkdown-editor .editor {
@apply outline-none min-h-[400px] p-4;
@apply outline-none min-h-[400px] p-3;
font-size: 1.125rem;
line-height: 1.75;
}
@@ -550,7 +550,7 @@
/* Block elements */
.milkdown-editor p {
@apply my-3;
@apply my-1.5;
}
.milkdown-editor blockquote {
@@ -629,11 +629,7 @@
@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 {
@@ -645,11 +641,13 @@
@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;
/* Placeholder — only when unfocused AND content is empty. */
.milkdown-editor .ProseMirror:not(:focus):not(.has-content) > p:first-child::before {
@apply text-muted-foreground pointer-events-none float-left h-0;
content: var(--ph, '');
}
/* Milkdown content area */
.milkdown-editor .milkdown-content .ProseMirror {
@apply min-h-[350px];
}
+33
View File
@@ -0,0 +1,33 @@
import type { NostrEvent } from '@nostrify/nostrify';
/** Fields shared by drafts and published articles. */
export interface ArticleFields {
title: string;
summary: string;
content: string;
image: string;
tags: string[];
slug: string;
}
/**
* Extract common article fields from a Nostr event's tags + content.
* Works for kind 30023 (published) events and the inner event of NIP-37 draft wraps.
*/
export function parseArticleEvent(event: NostrEvent): ArticleFields & { publishedAt: number } {
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 {
title: getTag('title'),
summary: getTag('summary'),
content: event.content,
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
publishedAt,
};
}
+16
View File
@@ -49,6 +49,22 @@ export function deleteDraftBySlug(slug: string): void {
}
}
/** Delete a draft by id from localStorage. Returns the remaining drafts. */
export function deleteLocalDraftById(id: string): Draft[] {
try {
const stored = localStorage.getItem(DRAFTS_KEY);
if (!stored) return [];
const drafts: Draft[] = JSON.parse(stored);
const filtered = drafts.filter(d => d.id !== id);
localStorage.setItem(DRAFTS_KEY, JSON.stringify(filtered));
return filtered;
} catch (error) {
console.error('Failed to delete draft:', error);
return [];
}
}
/** Get all local drafts. */
export function getLocalDrafts(): Draft[] {
try {
-380
View File
@@ -1,380 +0,0 @@
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>
</>
);
}
+19 -30
View File
@@ -9,6 +9,7 @@ import { ArticleEditor, type ArticleData } from '@/components/articles/ArticleEd
import { useLayoutOptions } from '@/contexts/LayoutContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { getLocalDrafts } from '@/lib/localDrafts';
import { parseArticleEvent } from '@/lib/articleHelpers';
/** Thin page wrapper for /articles/new and /articles/edit/:naddr */
export function ArticleEditorPage() {
@@ -25,25 +26,26 @@ export function ArticleEditorPage() {
const [editMode, setEditMode] = useState(false);
const [loading, setLoading] = useState(!!naddrParam || !!draftSlug);
// Load draft from relay or localStorage if ?draft=<slug>
// Load draft from relay (NIP-37 kind 31234, encrypted) 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) {
if (user?.signer.nip44) {
try {
const events = await nostr.query([
{ kinds: [30024], authors: [user.pubkey], '#d': [draftSlug], limit: 1 },
{ kinds: [31234], 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]);
if (events.length > 0 && events[0].content.trim()) {
const decrypted = await user.signer.nip44.decrypt(user.pubkey, events[0].content);
const inner = JSON.parse(decrypted) as Record<string, unknown>;
const tags = (inner.tags ?? []) as string[][];
const getTag = (name: string) => tags.find(t => t[0] === name)?.[1] || '';
const getTags = (name: string) => tags.filter(t => t[0] === name).map(t => t[1]);
setInitialData({
title: getTag('title'),
summary: getTag('summary'),
content: event.content,
content: (inner.content as string) || '',
image: getTag('image'),
tags: getTags('t'),
slug: getTag('d'),
@@ -93,6 +95,12 @@ export function ArticleEditorPage() {
const addr = decoded.data;
// Only allow editing your own articles
if (user && addr.pubkey !== user.pubkey) {
setLoading(false);
return;
}
nostr
.query([
{
@@ -104,26 +112,7 @@ export function ArticleEditorPage() {
])
.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,
});
setInitialData(parseArticleEvent(events[0]));
setEditMode(true);
}
})
@@ -133,7 +122,7 @@ export function ArticleEditorPage() {
.finally(() => {
setLoading(false);
});
}, [naddrParam, nostr]);
}, [naddrParam, nostr, user]);
if (loading) {
return (