Skip to content

Commit a4617a4

Browse files
authored
Merge pull request #79 from r4topunk/feat/feed-propdate-media
feat(feed): clean propdate card + exact detail link + media auto-embed
2 parents 876f1da + 3371bc8 commit a4617a4

3 files changed

Lines changed: 128 additions & 7 deletions

File tree

src/components/common/Markdown.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
99
import rehypeSlug from "rehype-slug";
1010
import remarkBreaks from "remark-breaks";
1111
import remarkGfm from "remark-gfm";
12+
import { classifyMediaUrl } from "@/lib/markdown-media";
1213
import { cn } from "@/lib/utils";
1314

1415
interface MarkdownProps {
@@ -65,15 +66,48 @@ export function Markdown({ children, className }: MarkdownProps) {
6566
],
6667
]}
6768
components={((): Components => ({
68-
a({ className, ...props }) {
69+
a({ className, href, children, node: _node, ...props }) {
70+
void _node;
71+
const url = typeof href === "string" ? href : undefined;
72+
const kind = classifyMediaUrl(url);
73+
if (url && kind === "image") {
74+
return (
75+
<a href={url} target="_blank" rel="noopener noreferrer">
76+
{/* eslint-disable-next-line @next/next/no-img-element */}
77+
<img
78+
src={url}
79+
alt={typeof children === "string" ? children : ""}
80+
loading="lazy"
81+
className="rounded-md border mx-auto my-2 max-h-[460px] w-auto max-w-full"
82+
/>
83+
</a>
84+
);
85+
}
86+
if (url && kind === "video") {
87+
return (
88+
<video
89+
src={url}
90+
controls
91+
playsInline
92+
preload="metadata"
93+
className="rounded-md border mx-auto my-2 max-h-[460px] w-full"
94+
/>
95+
);
96+
}
97+
if (url && kind === "audio") {
98+
return <audio src={url} controls preload="metadata" className="my-2 w-full" />;
99+
}
69100
return (
70101
<a
102+
href={url}
71103
className={cn(
72104
"text-amber-400 underline decoration-amber-400/50 hover:decoration-amber-400",
73105
className,
74106
)}
75107
{...props}
76-
/>
108+
>
109+
{children}
110+
</a>
77111
);
78112
},
79113
img({ src, alt, ...props }) {

src/components/feed/GovernanceEventCard.tsx

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "lucide-react";
2424
import { AddressDisplay } from "@/components/ui/address-display";
2525
import { Card, CardContent } from "@/components/ui/card";
26+
import { extractFirstMedia, stripMarkdown } from "@/lib/markdown-media";
2627
import type { FeedEvent } from "@/lib/types/feed-events";
2728
import { cn } from "@/lib/utils";
2829

@@ -215,6 +216,8 @@ function ProposalUpdatedContent({
215216
// Builder propdate enum: 0 = original/update, 1 = reply. Anything else
216217
// falls back to "update" wording so future enum additions don't crash.
217218
const isReply = event.messageType === 1;
219+
const media = extractFirstMedia(event.message);
220+
const snippet = stripMarkdown(event.message ?? "", 160);
218221
return (
219222
<div className="space-y-1.5">
220223
<div className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -233,10 +236,30 @@ function ProposalUpdatedContent({
233236
Proposal #{event.proposalNumber}
234237
</Link>
235238
</div>
236-
{event.message && (
237-
<p className="text-xs italic text-muted-foreground border-l-2 border-muted pl-2 line-clamp-3">
238-
{event.message}
239-
</p>
239+
{(media || snippet) && (
240+
<div className="flex gap-2 border-l-2 border-muted pl-2">
241+
{media?.kind === "image" && (
242+
// eslint-disable-next-line @next/next/no-img-element
243+
<img
244+
src={media.url}
245+
alt=""
246+
loading="lazy"
247+
className="h-16 w-16 flex-shrink-0 rounded-md border object-cover"
248+
/>
249+
)}
250+
{media?.kind === "video" && (
251+
<video
252+
src={media.url}
253+
muted
254+
playsInline
255+
preload="metadata"
256+
className="h-16 w-16 flex-shrink-0 rounded-md border object-cover"
257+
/>
258+
)}
259+
{snippet && (
260+
<p className="text-xs italic text-muted-foreground line-clamp-3">{snippet}</p>
261+
)}
262+
</div>
240263
)}
241264
</div>
242265
);
@@ -357,7 +380,7 @@ function getEventDisplay(event: Extract<FeedEvent, { category: "governance" }>)
357380

358381
function getEventLink(event: Extract<FeedEvent, { category: "governance" }>): string {
359382
if (event.type === "ProposalUpdated") {
360-
return `/propdates#proposal-${event.proposalNumber}`;
383+
return `/propdates/${event.transactionHash}`;
361384
}
362385
if ("proposalNumber" in event) {
363386
return `/proposals/${event.proposalNumber}`;

src/lib/markdown-media.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg", "avif"] as const;
2+
const VIDEO_EXTENSIONS = ["mp4", "webm", "mov", "m4v", "ogv"] as const;
3+
const AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "m4a", "flac"] as const;
4+
5+
export type MediaKind = "image" | "video" | "audio";
6+
7+
export function classifyMediaUrl(url: string | undefined | null): MediaKind | null {
8+
if (!url) return null;
9+
let pathname: string;
10+
try {
11+
pathname = new URL(url, "https://placeholder.local").pathname;
12+
} catch {
13+
return null;
14+
}
15+
const lower = pathname.toLowerCase();
16+
const ext = lower.split(".").pop();
17+
if (!ext) return null;
18+
if ((IMAGE_EXTENSIONS as readonly string[]).includes(ext)) return "image";
19+
if ((VIDEO_EXTENSIONS as readonly string[]).includes(ext)) return "video";
20+
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) return "audio";
21+
return null;
22+
}
23+
24+
export interface MediaMatch {
25+
url: string;
26+
kind: MediaKind;
27+
}
28+
29+
const IMAGE_MARKDOWN = /!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/;
30+
const LINK_MARKDOWN = /\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/g;
31+
const BARE_URL = /(https?:\/\/[^\s)]+)/g;
32+
33+
export function extractFirstMedia(markdown: string | undefined | null): MediaMatch | null {
34+
if (!markdown) return null;
35+
36+
const img = markdown.match(IMAGE_MARKDOWN);
37+
if (img?.[1]) {
38+
const kind = classifyMediaUrl(img[1]) ?? "image";
39+
return { url: img[1], kind };
40+
}
41+
42+
for (const match of markdown.matchAll(LINK_MARKDOWN)) {
43+
const kind = classifyMediaUrl(match[1]);
44+
if (kind) return { url: match[1], kind };
45+
}
46+
47+
for (const match of markdown.matchAll(BARE_URL)) {
48+
const kind = classifyMediaUrl(match[1]);
49+
if (kind) return { url: match[1], kind };
50+
}
51+
52+
return null;
53+
}
54+
55+
export function stripMarkdown(markdown: string, maxLength = 180): string {
56+
const withoutImages = markdown.replace(/!\[[^\]]*\]\([^)]+\)/g, "");
57+
const withoutLinks = withoutImages.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
58+
const withoutMarks = withoutLinks
59+
.replace(/[#>*_`~]+/g, " ")
60+
.replace(/\s+/g, " ")
61+
.trim();
62+
if (withoutMarks.length <= maxLength) return withoutMarks;
63+
return `${withoutMarks.slice(0, maxLength).trimEnd()}…`;
64+
}

0 commit comments

Comments
 (0)