diff --git a/AGENTS.md b/AGENTS.md index cf78b483..0dbaae89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 26767acf..4e8fc10f 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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() { } /> } /> } /> - } /> (null); - const inlineImageInputRef = useRef(null); const autoSaveTimeoutRef = useRef | null>(null); const [activeTab, setActiveTab] = useState('write'); const [localDrafts, setLocalDrafts] = useState([]); 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(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(); @@ -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) => { - 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 ? ( {isEditMode ? 'Updated' : 'Published'} @@ -577,17 +542,9 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr onChange={handleHeaderImageUpload} className="hidden" /> - - {/* ── New article tab ──────────────────────────────────────── */} {activeTab === 'write' && ( -
+
{/* Header Image */} {article.image ? (
@@ -596,7 +553,8 @@ export function ArticleEditor({ initialData, editMode = false }: ArticleEditorPr alt="Header" className="w-full h-48 sm:h-64 object-cover" /> -
+ {/* Desktop: centered overlay on hover */} +
+ {/* Mobile: persistent corner button */} +
) : (
-
-
- +
+
+ 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" />
-
-