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