mirror of
https://github.com/hoornet/vega.git
synced 2026-05-12 19:58:36 -07:00
Add repost/comment to article view, redesign audio cards in feed
Article view: - Repost button in header and footer (kind-6 repost) - Comment button opens inline input, publishes note with naddr reference - Footer comment scrolls to top to show input Audio cards: - Replace inline <audio> elements with styled play cards - Clean filename display (decode URI, strip extension, humanize separators) - Single click loads into persistent podcast player - Eliminates buffering spinners from slow podcast CDNs
This commit is contained in:
@@ -4,7 +4,8 @@ import { renderMarkdown } from "../../lib/markdown";
|
|||||||
import { useUIStore } from "../../stores/ui";
|
import { useUIStore } from "../../stores/ui";
|
||||||
import { useUserStore } from "../../stores/user";
|
import { useUserStore } from "../../stores/user";
|
||||||
import { useBookmarkStore } from "../../stores/bookmark";
|
import { useBookmarkStore } from "../../stores/bookmark";
|
||||||
import { fetchArticle, publishReaction } from "../../lib/nostr";
|
import { fetchArticle, publishReaction, publishRepost, publishNote } from "../../lib/nostr";
|
||||||
|
import { nip19 } from "@nostr-dev-kit/ndk";
|
||||||
import { useProfile } from "../../hooks/useProfile";
|
import { useProfile } from "../../hooks/useProfile";
|
||||||
import { ZapModal } from "../zap/ZapModal";
|
import { ZapModal } from "../zap/ZapModal";
|
||||||
|
|
||||||
@@ -73,6 +74,9 @@ export function ArticleView() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showZap, setShowZap] = useState(false);
|
const [showZap, setShowZap] = useState(false);
|
||||||
const [reacted, setReacted] = useState(false);
|
const [reacted, setReacted] = useState(false);
|
||||||
|
const [reposted, setReposted] = useState(false);
|
||||||
|
const [showComment, setShowComment] = useState(false);
|
||||||
|
const [commentText, setCommentText] = useState("");
|
||||||
const { isBookmarked, addBookmark, removeBookmark } = useBookmarkStore();
|
const { isBookmarked, addBookmark, removeBookmark } = useBookmarkStore();
|
||||||
|
|
||||||
const naddr = pendingArticleNaddr ?? "";
|
const naddr = pendingArticleNaddr ?? "";
|
||||||
@@ -197,6 +201,28 @@ export function ArticleView() {
|
|||||||
else addBookmark(event.id);
|
else addBookmark(event.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRepost = async () => {
|
||||||
|
if (!event || reposted) return;
|
||||||
|
setReposted(true);
|
||||||
|
try {
|
||||||
|
await publishRepost(event);
|
||||||
|
} catch {
|
||||||
|
setReposted(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComment = async () => {
|
||||||
|
if (!event || !commentText.trim()) return;
|
||||||
|
const dTag = event.tags.find((t) => t[0] === "d")?.[1] ?? "";
|
||||||
|
const naddrStr = nip19.naddrEncode({ identifier: dTag, pubkey: event.pubkey, kind: 30023 });
|
||||||
|
const text = `${commentText.trim()}\n\nnostr:${naddrStr}`;
|
||||||
|
try {
|
||||||
|
await publishNote(text);
|
||||||
|
setCommentText("");
|
||||||
|
setShowComment(false);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -226,6 +252,31 @@ export function ArticleView() {
|
|||||||
⚡ zap {authorName}
|
⚡ zap {authorName}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{event && loggedIn && (
|
||||||
|
<button
|
||||||
|
onClick={handleRepost}
|
||||||
|
disabled={reposted}
|
||||||
|
className={`text-[11px] px-3 py-1 border transition-colors ${
|
||||||
|
reposted
|
||||||
|
? "border-accent/40 text-accent"
|
||||||
|
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{reposted ? "reposted" : "repost"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{event && loggedIn && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowComment(!showComment)}
|
||||||
|
className={`text-[11px] px-3 py-1 border transition-colors ${
|
||||||
|
showComment
|
||||||
|
? "border-accent/40 text-accent"
|
||||||
|
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
comment
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{naddr && (
|
{naddr && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigator.clipboard.writeText(`nostr:${naddr}`)}
|
onClick={() => navigator.clipboard.writeText(`nostr:${naddr}`)}
|
||||||
@@ -238,6 +289,28 @@ export function ArticleView() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Comment box */}
|
||||||
|
{showComment && event && (
|
||||||
|
<div className="border-b border-border px-4 py-3 flex gap-2 shrink-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleComment()}
|
||||||
|
placeholder="Write a comment about this article..."
|
||||||
|
className="flex-1 bg-bg-raised border border-border rounded-sm px-3 py-1.5 text-[12px] text-text placeholder:text-text-dim focus:outline-none focus:border-accent"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleComment}
|
||||||
|
disabled={!commentText.trim()}
|
||||||
|
className="px-3 py-1.5 bg-accent/10 text-accent text-[12px] rounded-sm hover:bg-accent/20 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
post
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||||
{/* Reading progress bar */}
|
{/* Reading progress bar */}
|
||||||
@@ -346,6 +419,27 @@ export function ArticleView() {
|
|||||||
{bookmarked ? "▪ saved" : "▫ save"}
|
{bookmarked ? "▪ saved" : "▫ save"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{loggedIn && (
|
||||||
|
<button
|
||||||
|
onClick={handleRepost}
|
||||||
|
disabled={reposted}
|
||||||
|
className={`text-[11px] px-3 py-1.5 border transition-colors ${
|
||||||
|
reposted
|
||||||
|
? "border-accent/40 text-accent"
|
||||||
|
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{reposted ? "reposted" : "repost"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{loggedIn && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowComment(true); scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||||
|
className="text-[11px] px-3 py-1.5 border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors"
|
||||||
|
>
|
||||||
|
comment
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{loggedIn && (
|
{loggedIn && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowZap(true)}
|
onClick={() => setShowZap(true)}
|
||||||
|
|||||||
@@ -20,38 +20,53 @@ export function VideoBlock({ sources }: { sources: string[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cleanAudioName(url: string): string {
|
||||||
|
const raw = url.split("/").pop()?.split("?")[0] ?? url;
|
||||||
|
// Remove file extension
|
||||||
|
const name = raw.replace(/\.(mp3|m4a|ogg|opus|wav|flac|aac)$/i, "");
|
||||||
|
// Decode URI components and replace common separators with spaces
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(name).replace(/[-_]+/g, " ");
|
||||||
|
} catch {
|
||||||
|
return name.replace(/[-_]+/g, " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AudioBlock({ sources }: { sources: string[] }) {
|
export function AudioBlock({ sources }: { sources: string[] }) {
|
||||||
const play = usePodcastStore((s) => s.play);
|
const play = usePodcastStore((s) => s.play);
|
||||||
if (sources.length === 0) return null;
|
if (sources.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 flex flex-col gap-2">
|
<div className="mt-2 flex flex-col gap-2">
|
||||||
{sources.map((src, i) => {
|
{sources.map((src, i) => {
|
||||||
const filename = src.split("/").pop()?.split("?")[0] ?? src;
|
const name = cleanAudioName(src);
|
||||||
return (
|
return (
|
||||||
<div key={i} className="rounded-sm bg-bg-raised border border-border p-2">
|
<button
|
||||||
<div className="flex items-center justify-between mb-1">
|
key={i}
|
||||||
<div className="text-[11px] text-text-muted truncate">{filename}</div>
|
onClick={(e) => {
|
||||||
<button
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
play({
|
||||||
e.stopPropagation();
|
guid: `audio:${src}`,
|
||||||
play({
|
title: name,
|
||||||
guid: `audio:${src}`,
|
enclosureUrl: src,
|
||||||
title: filename,
|
pubDate: 0,
|
||||||
enclosureUrl: src,
|
duration: 0,
|
||||||
pubDate: 0,
|
description: "",
|
||||||
duration: 0,
|
showTitle: "",
|
||||||
description: "",
|
showArtworkUrl: "",
|
||||||
showTitle: "From note",
|
});
|
||||||
showArtworkUrl: "",
|
}}
|
||||||
});
|
className="rounded-sm bg-bg-raised border border-border p-3 flex items-center gap-3 hover:bg-bg-hover transition-colors text-left w-full"
|
||||||
}}
|
>
|
||||||
className="text-[10px] text-accent hover:text-accent-hover transition-colors shrink-0 ml-2"
|
<div className="w-8 h-8 rounded-full bg-accent/10 flex items-center justify-center shrink-0">
|
||||||
>
|
<svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor" className="text-accent ml-0.5">
|
||||||
play in wrystr
|
<polygon points="0,0 10,6 0,12" />
|
||||||
</button>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<audio controls preload="metadata" className="w-full h-8" src={src} />
|
<div className="min-w-0 flex-1">
|
||||||
</div>
|
<div className="text-[12px] text-text truncate">{name}</div>
|
||||||
|
<div className="text-[10px] text-text-dim">audio · play in wrystr</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user