mirror of
https://github.com/hoornet/vega.git
synced 2026-05-14 01:48:35 -07:00
Fix thread disappearing bug, improve article editor UX
Move selectedNote guard after hooks in ThreadView to fix React rules of hooks violation that caused thread content to blank out. Article editor gets inline image previews, readable toolbar labels, and drag-and-drop image support. Thread reply textareas now auto-expand.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { renderMarkdown } from "../../lib/markdown";
|
import { renderMarkdown } from "../../lib/markdown";
|
||||||
import { publishArticle } from "../../lib/nostr";
|
import { publishArticle } from "../../lib/nostr";
|
||||||
import { useUIStore } from "../../stores/ui";
|
import { useUIStore } from "../../stores/ui";
|
||||||
@@ -9,6 +9,17 @@ import { readFile } from "@tauri-apps/plugin-fs";
|
|||||||
import { uploadBytes, uploadImage } from "../../lib/upload";
|
import { uploadBytes, uploadImage } from "../../lib/upload";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|
||||||
|
/** Extract image URLs from markdown  patterns */
|
||||||
|
function extractImages(md: string): { alt: string; url: string }[] {
|
||||||
|
const re = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||||
|
const images: { alt: string; url: string }[] = [];
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(md))) {
|
||||||
|
images.push({ alt: m[1], url: m[2] });
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
export function ArticleEditor() {
|
export function ArticleEditor() {
|
||||||
const { goBack } = useUIStore();
|
const { goBack } = useUIStore();
|
||||||
const { activeDraftId, drafts, updateDraft, deleteDraft, setActiveDraft, createDraft } = useDraftStore();
|
const { activeDraftId, drafts, updateDraft, deleteDraft, setActiveDraft, createDraft } = useDraftStore();
|
||||||
@@ -103,6 +114,7 @@ export function ArticleEditor() {
|
|||||||
const renderedHtml = renderMarkdown(content || "*Nothing to preview yet.*");
|
const renderedHtml = renderMarkdown(content || "*Nothing to preview yet.*");
|
||||||
const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0;
|
const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0;
|
||||||
const canPublish = title.trim().length > 0 && content.trim().length > 0;
|
const canPublish = title.trim().length > 0 && content.trim().length > 0;
|
||||||
|
const inlineImages = useMemo(() => extractImages(content), [content]);
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
if (!canPublish || publishing) return;
|
if (!canPublish || publishing) return;
|
||||||
@@ -398,6 +410,23 @@ export function ArticleEditor() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Inline image previews (write mode only) */}
|
||||||
|
{mode === "write" && inlineImages.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-6 py-2 border-b border-border bg-bg-raised/50 overflow-x-auto shrink-0">
|
||||||
|
<span className="text-text-dim text-[10px] shrink-0">{inlineImages.length} {inlineImages.length === 1 ? "image" : "images"}</span>
|
||||||
|
{inlineImages.map((img, i) => (
|
||||||
|
<div key={i} className="relative shrink-0 group">
|
||||||
|
<img
|
||||||
|
src={img.url}
|
||||||
|
alt={img.alt}
|
||||||
|
className="h-12 w-auto rounded-sm border border-border object-cover"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content area */}
|
{/* Content area */}
|
||||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||||
{mode === "write" ? (
|
{mode === "write" ? (
|
||||||
@@ -407,7 +436,32 @@ export function ArticleEditor() {
|
|||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
onKeyDown={(e) => handleEditorKeyDown(e, textareaRef, content, setContent)}
|
onKeyDown={(e) => handleEditorKeyDown(e, textareaRef, content, setContent)}
|
||||||
onPaste={handleArticlePaste}
|
onPaste={handleArticlePaste}
|
||||||
placeholder="Write your article in Markdown…"
|
onDrop={async (e) => {
|
||||||
|
const file = Array.from(e.dataTransfer.files).find((f) => f.type.startsWith("image/"));
|
||||||
|
if (!file) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const url = await uploadImage(file);
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (ta) {
|
||||||
|
const start = ta.selectionStart ?? content.length;
|
||||||
|
const end = ta.selectionEnd ?? content.length;
|
||||||
|
const md = ``;
|
||||||
|
setContent(content.slice(0, start) + md + content.slice(end));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Image upload failed: ${err}`);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (Array.from(e.dataTransfer.types).includes("Files")) e.preventDefault();
|
||||||
|
}}
|
||||||
|
placeholder="Write your article in Markdown… (paste or drop images)"
|
||||||
className="w-full h-full min-h-[400px] bg-transparent text-text text-[14px] leading-relaxed placeholder:text-text-dim resize-none focus:outline-none font-mono"
|
className="w-full h-full min-h-[400px] bg-transparent text-text text-[14px] leading-relaxed placeholder:text-text-dim resize-none focus:outline-none font-mono"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -116,15 +116,15 @@ function applyMarkdown(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOOLS: { action: MarkdownAction; label: string; title: string }[] = [
|
const TOOLS: { action: MarkdownAction; label: string; title: string; bold?: boolean; italic?: boolean }[] = [
|
||||||
{ action: "bold", label: "B", title: "Bold (Ctrl+B)" },
|
{ action: "bold", label: "B", title: "Bold (Ctrl+B)", bold: true },
|
||||||
{ action: "italic", label: "I", title: "Italic (Ctrl+I)" },
|
{ action: "italic", label: "I", title: "Italic (Ctrl+I)", italic: true },
|
||||||
{ action: "heading", label: "H", title: "Heading" },
|
{ action: "heading", label: "H", title: "Heading" },
|
||||||
{ action: "link", label: "🔗", title: "Link (Ctrl+K)" },
|
{ action: "link", label: "Link", title: "Insert link (Ctrl+K)" },
|
||||||
{ action: "image", label: "🖼", title: "Image" },
|
{ action: "image", label: "Image", title: "Upload image" },
|
||||||
{ action: "quote", label: "❝", title: "Quote" },
|
{ action: "quote", label: "Quote", title: "Block quote" },
|
||||||
{ action: "code", label: "</>", title: "Code" },
|
{ action: "code", label: "Code", title: "Code block" },
|
||||||
{ action: "list", label: "☰", title: "List" },
|
{ action: "list", label: "List", title: "Bullet list" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function MarkdownToolbar({ textareaRef, content, setContent, setUploading, setError }: ToolbarProps) {
|
export function MarkdownToolbar({ textareaRef, content, setContent, setUploading, setError }: ToolbarProps) {
|
||||||
@@ -175,13 +175,13 @@ export function MarkdownToolbar({ textareaRef, content, setContent, setUploading
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0.5 border-b border-border px-2 py-1 bg-bg-raised shrink-0">
|
<div className="flex items-center gap-0.5 border-b border-border px-2 py-1 bg-bg-raised shrink-0">
|
||||||
{TOOLS.map(({ action, label, title }) => (
|
{TOOLS.map(({ action, label, title, bold, italic }) => (
|
||||||
<button
|
<button
|
||||||
key={action}
|
key={action}
|
||||||
onClick={() => handleClick(action)}
|
onClick={() => handleClick(action)}
|
||||||
title={title}
|
title={title}
|
||||||
className="px-2 py-0.5 text-[12px] text-text-muted hover:text-text hover:bg-bg-hover transition-colors rounded-sm"
|
className="px-2 py-0.5 text-[11px] text-text-muted hover:text-text hover:bg-bg-hover transition-colors rounded-sm"
|
||||||
style={action === "bold" ? { fontWeight: "bold" } : action === "italic" ? { fontStyle: "italic" } : undefined}
|
style={{ fontWeight: bold ? "bold" : undefined, fontStyle: italic ? "italic" : undefined }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { publishReply } from "../../lib/nostr";
|
|||||||
import { useProfile } from "../../hooks/useProfile";
|
import { useProfile } from "../../hooks/useProfile";
|
||||||
import { shortenPubkey } from "../../lib/utils";
|
import { shortenPubkey } from "../../lib/utils";
|
||||||
import { EmojiPicker } from "../shared/EmojiPicker";
|
import { EmojiPicker } from "../shared/EmojiPicker";
|
||||||
|
import { useAutoResize } from "../../hooks/useAutoResize";
|
||||||
|
|
||||||
interface ThreadNodeProps {
|
interface ThreadNodeProps {
|
||||||
node: ThreadNodeType;
|
node: ThreadNodeType;
|
||||||
@@ -31,6 +32,7 @@ function InlineThreadReply({ replyTo, rootEvent, onPublished }: {
|
|||||||
const [sent, setSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
const [showEmoji, setShowEmoji] = useState(false);
|
const [showEmoji, setShowEmoji] = useState(false);
|
||||||
const ref = useRef<HTMLTextAreaElement>(null);
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const autoResize = useAutoResize(2, 8);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!text.trim() || replying) return;
|
if (!text.trim() || replying) return;
|
||||||
@@ -60,7 +62,7 @@ function InlineThreadReply({ replyTo, rootEvent, onPublished }: {
|
|||||||
<textarea
|
<textarea
|
||||||
ref={ref}
|
ref={ref}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => { setText(e.target.value); autoResize(e); }}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Write a reply..."
|
placeholder="Write a reply..."
|
||||||
rows={2}
|
rows={2}
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export function ThreadView() {
|
|||||||
const { selectedNote, goBack } = useUIStore();
|
const { selectedNote, goBack } = useUIStore();
|
||||||
const { loggedIn } = useUserStore();
|
const { loggedIn } = useUserStore();
|
||||||
const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore();
|
const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore();
|
||||||
if (!selectedNote) { goBack(); return null; }
|
|
||||||
const focusedEvent = selectedNote;
|
|
||||||
|
|
||||||
const [rootEvent, setRootEvent] = useState<NDKEvent | null>(null);
|
const [rootEvent, setRootEvent] = useState<NDKEvent | null>(null);
|
||||||
const [ancestors, setAncestors] = useState<NDKEvent[]>([]);
|
const [ancestors, setAncestors] = useState<NDKEvent[]>([]);
|
||||||
@@ -32,9 +30,12 @@ export function ThreadView() {
|
|||||||
const autoResize = useAutoResize(2, 8);
|
const autoResize = useAutoResize(2, 8);
|
||||||
const replyRef = useRef<HTMLTextAreaElement>(null);
|
const replyRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [retryCount, setRetryCount] = useState(0);
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
|
||||||
|
// Guard AFTER all hooks to satisfy React rules of hooks
|
||||||
|
if (!selectedNote) { goBack(); return null; }
|
||||||
|
const focusedEvent = selectedNote;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user