mirror of
https://github.com/hoornet/vega.git
synced 2026-05-06 20:29:12 -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 { useUserStore } from "../../stores/user";
|
||||
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 { ZapModal } from "../zap/ZapModal";
|
||||
|
||||
@@ -73,6 +74,9 @@ export function ArticleView() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showZap, setShowZap] = 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 naddr = pendingArticleNaddr ?? "";
|
||||
@@ -197,6 +201,28 @@ export function ArticleView() {
|
||||
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 (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
@@ -226,6 +252,31 @@ export function ArticleView() {
|
||||
⚡ zap {authorName}
|
||||
</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 && (
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(`nostr:${naddr}`)}
|
||||
@@ -238,6 +289,28 @@ export function ArticleView() {
|
||||
</div>
|
||||
</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 */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
{/* Reading progress bar */}
|
||||
@@ -346,6 +419,27 @@ export function ArticleView() {
|
||||
{bookmarked ? "▪ saved" : "▫ save"}
|
||||
</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 && (
|
||||
<button
|
||||
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[] }) {
|
||||
const play = usePodcastStore((s) => s.play);
|
||||
if (sources.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
{sources.map((src, i) => {
|
||||
const filename = src.split("/").pop()?.split("?")[0] ?? src;
|
||||
const name = cleanAudioName(src);
|
||||
return (
|
||||
<div key={i} className="rounded-sm bg-bg-raised border border-border p-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-[11px] text-text-muted truncate">{filename}</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
play({
|
||||
guid: `audio:${src}`,
|
||||
title: filename,
|
||||
enclosureUrl: src,
|
||||
pubDate: 0,
|
||||
duration: 0,
|
||||
description: "",
|
||||
showTitle: "From note",
|
||||
showArtworkUrl: "",
|
||||
});
|
||||
}}
|
||||
className="text-[10px] text-accent hover:text-accent-hover transition-colors shrink-0 ml-2"
|
||||
>
|
||||
play in wrystr
|
||||
</button>
|
||||
<button
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
play({
|
||||
guid: `audio:${src}`,
|
||||
title: name,
|
||||
enclosureUrl: src,
|
||||
pubDate: 0,
|
||||
duration: 0,
|
||||
description: "",
|
||||
showTitle: "",
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<polygon points="0,0 10,6 0,12" />
|
||||
</svg>
|
||||
</div>
|
||||
<audio controls preload="metadata" className="w-full h-8" src={src} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12px] text-text truncate">{name}</div>
|
||||
<div className="text-[10px] text-text-dim">audio · play in wrystr</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user