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:
@@ -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.
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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 = ``;
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user