mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 10:11:12 +02:00
feat: community NIPs
This commit is contained in:
@@ -11,6 +11,7 @@ import { Kind1337DetailRenderer } from "./nostr/kinds/CodeSnippetDetailRenderer"
|
||||
import { Kind9802DetailRenderer } from "./nostr/kinds/HighlightDetailRenderer";
|
||||
import { Kind10002DetailRenderer } from "./nostr/kinds/RelayListDetailRenderer";
|
||||
import { Kind30023DetailRenderer } from "./nostr/kinds/ArticleDetailRenderer";
|
||||
import { CommunityNIPDetailRenderer } from "./nostr/kinds/CommunityNIPDetailRenderer";
|
||||
import { RepositoryDetailRenderer } from "./nostr/kinds/RepositoryDetailRenderer";
|
||||
import { JsonViewer } from "./JsonViewer";
|
||||
import { RelayLink } from "./nostr/RelayLink";
|
||||
@@ -284,6 +285,8 @@ export function EventDetailViewer({ pointer }: EventDetailViewerProps) {
|
||||
<Kind10002DetailRenderer event={event} />
|
||||
) : event.kind === kinds.LongFormArticle ? (
|
||||
<Kind30023DetailRenderer event={event} />
|
||||
) : event.kind === 30817 ? (
|
||||
<CommunityNIPDetailRenderer event={event} />
|
||||
) : event.kind === 30617 ? (
|
||||
<RepositoryDetailRenderer event={event} />
|
||||
) : (
|
||||
|
||||
@@ -63,7 +63,7 @@ export function SyntaxHighlight({
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={`language-${normalizedLanguage} ${showLineNumbers ? "line-numbers" : ""} ${className}`.trim()}
|
||||
className={`language-${normalizedLanguage} ${showLineNumbers ? "line-numbers" : ""} overflow-x-auto max-w-full ${className}`.trim()}
|
||||
>
|
||||
<code ref={codeRef} className={`language-${normalizedLanguage}`}>
|
||||
{code}
|
||||
|
||||
@@ -25,7 +25,8 @@ export function WindowTile({
|
||||
|
||||
// Convert title to string for MosaicWindow (which only accepts strings)
|
||||
// The actual title (with React elements) is rendered in the custom toolbar
|
||||
const titleString = typeof title === "string" ? title : tooltip || window.title;
|
||||
const titleString =
|
||||
typeof title === "string" ? title : tooltip || window.title;
|
||||
|
||||
// Custom toolbar renderer to include icon
|
||||
const renderToolbar = () => {
|
||||
|
||||
299
src/components/nostr/MarkdownContent.tsx
Normal file
299
src/components/nostr/MarkdownContent.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { useMemo } from "react";
|
||||
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { remarkNostrMentions } from "applesauce-content/markdown";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { UserName } from "./UserName";
|
||||
import { EmbeddedEvent } from "./EmbeddedEvent";
|
||||
import { MediaEmbed } from "./MediaEmbed";
|
||||
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
|
||||
import { CodeCopyButton } from "@/components/CodeCopyButton";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
|
||||
/**
|
||||
* Component to render nostr: mentions inline
|
||||
*/
|
||||
function NostrMention({ href }: { href: string }) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
try {
|
||||
// Remove nostr: prefix and any trailing characters
|
||||
const cleanHref = href.replace(/^nostr:/, "").trim();
|
||||
|
||||
// If it doesn't look like a nostr identifier, just return the href as-is
|
||||
if (!cleanHref.match(/^(npub|nprofile|note|nevent|naddr)/)) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-accent underline decoration-dotted break-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = nip19.decode(cleanHref);
|
||||
|
||||
switch (parsed.type) {
|
||||
case "npub":
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
<UserName
|
||||
pubkey={parsed.data}
|
||||
className="text-accent font-semibold"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
case "nprofile":
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
<UserName
|
||||
pubkey={parsed.data.pubkey}
|
||||
className="text-accent font-semibold"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
case "note":
|
||||
return (
|
||||
<EmbeddedEvent
|
||||
eventId={parsed.data}
|
||||
onOpen={(id) => {
|
||||
addWindow(
|
||||
"open",
|
||||
{ id: id as string },
|
||||
`Event ${(id as string).slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "nevent":
|
||||
return (
|
||||
<EmbeddedEvent
|
||||
eventId={parsed.data.id}
|
||||
onOpen={(id) => {
|
||||
addWindow(
|
||||
"open",
|
||||
{ id: id as string },
|
||||
`Event ${(id as string).slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "naddr":
|
||||
return (
|
||||
<EmbeddedEvent
|
||||
addressPointer={parsed.data}
|
||||
onOpen={(pointer) => {
|
||||
addWindow(
|
||||
"open",
|
||||
pointer,
|
||||
`${parsed.data.kind}:${parsed.data.identifier.slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <span className="text-muted-foreground">{cleanHref}</span>;
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, just render as a regular link
|
||||
console.error("Failed to parse nostr link:", href, error);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-accent underline decoration-dotted break-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Code block wrapper with copy button
|
||||
* Renders syntax-highlighted code or plain code with a copy button
|
||||
*/
|
||||
function CodeBlock({
|
||||
code,
|
||||
language,
|
||||
}: {
|
||||
code: string;
|
||||
language: string | null;
|
||||
}) {
|
||||
const { copy, copied } = useCopy();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{language ? (
|
||||
<SyntaxHighlight code={code} language={language as any} />
|
||||
) : (
|
||||
<pre className="bg-muted p-4 pr-12 border border-border rounded overflow-x-auto max-w-full">
|
||||
<code className="text-xs font-mono">{code}</code>
|
||||
</pre>
|
||||
)}
|
||||
<CodeCopyButton onCopy={() => copy(code)} copied={copied} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface MarkdownContentProps {
|
||||
content: string;
|
||||
canonicalUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared markdown renderer for Nostr content (articles, NIPs, etc.)
|
||||
* Handles nostr: mentions, syntax highlighting, media embeds, and relative URLs
|
||||
*/
|
||||
export function MarkdownContent({
|
||||
content,
|
||||
canonicalUrl = null,
|
||||
}: MarkdownContentProps) {
|
||||
// Helper to resolve relative URLs using canonical URL as base
|
||||
const resolveUrl = useMemo(
|
||||
() =>
|
||||
(url: string): string | null => {
|
||||
// If it's already absolute, return as-is
|
||||
if (url.match(/^https?:\/\//)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// If we have a canonical URL, try to resolve relative URLs
|
||||
if (canonicalUrl) {
|
||||
try {
|
||||
return new URL(url, canonicalUrl).toString();
|
||||
} catch {
|
||||
console.warn("Failed to resolve relative URL:", url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// No canonical URL and it's relative - can't resolve
|
||||
return null;
|
||||
},
|
||||
[canonicalUrl],
|
||||
);
|
||||
|
||||
return (
|
||||
<article className="prose prose-invert prose-sm max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkNostrMentions]}
|
||||
skipHtml
|
||||
urlTransform={(url) => {
|
||||
if (url.startsWith("nostr:")) return url;
|
||||
return defaultUrlTransform(url);
|
||||
}}
|
||||
components={{
|
||||
// Enable images with zoom
|
||||
img: ({ src, alt }) => {
|
||||
if (!src) return null;
|
||||
|
||||
const resolvedUrl = resolveUrl(src);
|
||||
if (!resolvedUrl) {
|
||||
// Can't resolve URL - show fallback
|
||||
return (
|
||||
<div className="my-4 p-4 border border-border rounded-lg bg-muted/10 text-sm text-muted-foreground">
|
||||
<p>Media unavailable (relative URL without base)</p>
|
||||
<p className="text-xs mt-1 break-all">{src}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaEmbed
|
||||
url={resolvedUrl}
|
||||
alt={alt}
|
||||
preset="preview"
|
||||
enableZoom
|
||||
className="my-4"
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Handle nostr: links
|
||||
a: ({ href, children, ...props }) => {
|
||||
if (!href) return null;
|
||||
|
||||
// Render nostr: mentions inline
|
||||
if (href.startsWith("nostr:")) {
|
||||
return <NostrMention href={href} />;
|
||||
}
|
||||
|
||||
// Regular links
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-accent underline decoration-dotted"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Don't render pre wrapper when we have a CodeBlock (it has its own container)
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
// Style adjustments for dark theme
|
||||
h1: ({ ...props }) => (
|
||||
<h1 className="text-2xl font-bold mt-8 mb-4" {...props} />
|
||||
),
|
||||
h2: ({ ...props }) => (
|
||||
<h2 className="text-xl font-bold mt-6 mb-3" {...props} />
|
||||
),
|
||||
h3: ({ ...props }) => (
|
||||
<h3 className="text-lg font-bold mt-4 mb-2" {...props} />
|
||||
),
|
||||
p: ({ ...props }) => (
|
||||
<p className="text-sm leading-relaxed mb-4" {...props} />
|
||||
),
|
||||
code: ({ className, children, ...props }: any) => {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
const language = match ? match[1] : null;
|
||||
const code = String(children).replace(/\n$/, "");
|
||||
|
||||
// Inline code (no language)
|
||||
if (!language) {
|
||||
return (
|
||||
<code
|
||||
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// Block code with syntax highlighting and copy button
|
||||
return <CodeBlock code={code} language={language} />;
|
||||
},
|
||||
blockquote: ({ ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-muted pl-4 italic text-muted-foreground my-4"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ ...props }) => (
|
||||
<ul
|
||||
className="text-sm list-disc list-inside my-4 space-y-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ ...props }) => (
|
||||
<ol
|
||||
className="text-sm list-decimal list-inside my-4 space-y-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
hr: () => <hr className="my-4" />,
|
||||
}}
|
||||
>
|
||||
{content.replace(/\\n/g, "\n")}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { remarkNostrMentions } from "applesauce-content/markdown";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import {
|
||||
getArticleTitle,
|
||||
getArticleSummary,
|
||||
@@ -10,115 +6,10 @@ import {
|
||||
getArticleImage,
|
||||
} from "applesauce-core/helpers/article";
|
||||
import { UserName } from "../UserName";
|
||||
import { EmbeddedEvent } from "../EmbeddedEvent";
|
||||
import { MediaEmbed } from "../MediaEmbed";
|
||||
import { SyntaxHighlight } from "@/components/SyntaxHighlight";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
/**
|
||||
* Component to render nostr: mentions inline
|
||||
*/
|
||||
function NostrMention({ href }: { href: string }) {
|
||||
const { addWindow } = useGrimoire();
|
||||
|
||||
try {
|
||||
// Remove nostr: prefix and any trailing characters
|
||||
const cleanHref = href.replace(/^nostr:/, "").trim();
|
||||
|
||||
// If it doesn't look like a nostr identifier, just return the href as-is
|
||||
if (!cleanHref.match(/^(npub|nprofile|note|nevent|naddr)/)) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-accent underline decoration-dotted break-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = nip19.decode(cleanHref);
|
||||
|
||||
switch (parsed.type) {
|
||||
case "npub":
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
<UserName
|
||||
pubkey={parsed.data}
|
||||
className="text-accent font-semibold"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
case "nprofile":
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
<UserName
|
||||
pubkey={parsed.data.pubkey}
|
||||
className="text-accent font-semibold"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
case "note":
|
||||
return (
|
||||
<EmbeddedEvent
|
||||
eventId={parsed.data}
|
||||
onOpen={(id) => {
|
||||
addWindow(
|
||||
"open",
|
||||
{ id: id as string },
|
||||
`Event ${(id as string).slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "nevent":
|
||||
return (
|
||||
<EmbeddedEvent
|
||||
eventId={parsed.data.id}
|
||||
onOpen={(id) => {
|
||||
addWindow(
|
||||
"open",
|
||||
{ id: id as string },
|
||||
`Event ${(id as string).slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "naddr":
|
||||
return (
|
||||
<EmbeddedEvent
|
||||
addressPointer={parsed.data}
|
||||
onOpen={(pointer) => {
|
||||
addWindow(
|
||||
"open",
|
||||
pointer,
|
||||
`${parsed.data.kind}:${parsed.data.identifier.slice(0, 8)}...`,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <span className="text-muted-foreground">{cleanHref}</span>;
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails, just render as a regular link
|
||||
console.error("Failed to parse nostr link:", href, error);
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-accent underline decoration-dotted break-all"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{href}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 30023 - Long-form Article
|
||||
* Displays full markdown content with metadata
|
||||
@@ -135,27 +26,6 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
return rTag?.[1] || null;
|
||||
}, [event]);
|
||||
|
||||
// Helper to resolve relative URLs using canonical URL as base
|
||||
const resolveUrl = (url: string): string | null => {
|
||||
// If it's already absolute, return as-is
|
||||
if (url.match(/^https?:\/\//)) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// If we have a canonical URL, try to resolve relative URLs
|
||||
if (canonicalUrl) {
|
||||
try {
|
||||
return new URL(url, canonicalUrl).toString();
|
||||
} catch {
|
||||
console.warn("Failed to resolve relative URL:", url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// No canonical URL and it's relative - can't resolve
|
||||
return null;
|
||||
};
|
||||
|
||||
// Format published date
|
||||
const publishedDate = published
|
||||
? new Date(published * 1000).toLocaleDateString("en-US", {
|
||||
@@ -166,7 +36,18 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
: null;
|
||||
|
||||
// Resolve article image URL
|
||||
const resolvedImageUrl = imageUrl ? resolveUrl(imageUrl) : null;
|
||||
const resolvedImageUrl = useMemo(() => {
|
||||
if (!imageUrl) return null;
|
||||
if (imageUrl.match(/^https?:\/\//)) return imageUrl;
|
||||
if (canonicalUrl) {
|
||||
try {
|
||||
return new URL(imageUrl, canonicalUrl).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [imageUrl, canonicalUrl]);
|
||||
|
||||
return (
|
||||
<div dir="auto" className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
|
||||
@@ -204,131 +85,7 @@ export function Kind30023DetailRenderer({ event }: { event: NostrEvent }) {
|
||||
</header>
|
||||
|
||||
{/* Article Content - Markdown */}
|
||||
<article className="prose prose-invert prose-sm max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkNostrMentions]}
|
||||
skipHtml
|
||||
urlTransform={(url) => {
|
||||
if (url.startsWith("nostr:")) return url;
|
||||
return defaultUrlTransform(url);
|
||||
}}
|
||||
components={{
|
||||
// Enable images with zoom
|
||||
img: ({ src, alt }) => {
|
||||
if (!src) return null;
|
||||
|
||||
const resolvedUrl = resolveUrl(src);
|
||||
if (!resolvedUrl) {
|
||||
// Can't resolve URL - show fallback
|
||||
return (
|
||||
<div className="my-4 p-4 border border-border rounded-lg bg-muted/10 text-sm text-muted-foreground">
|
||||
<p>Media unavailable (relative URL without base)</p>
|
||||
<p className="text-xs mt-1 break-all">{src}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaEmbed
|
||||
url={resolvedUrl}
|
||||
alt={alt}
|
||||
preset="preview"
|
||||
enableZoom
|
||||
className="my-4"
|
||||
/>
|
||||
);
|
||||
},
|
||||
// Handle nostr: links
|
||||
a: ({ href, children, ...props }) => {
|
||||
if (!href) return null;
|
||||
|
||||
// Render nostr: mentions inline
|
||||
if (href.startsWith("nostr:")) {
|
||||
return <NostrMention href={href} />;
|
||||
}
|
||||
|
||||
// Regular links
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-accent underline decoration-dotted"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Make pre elements display inline
|
||||
pre: ({ children, ...props }) => (
|
||||
<span className="inline" {...props}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
// Style adjustments for dark theme
|
||||
h1: ({ ...props }) => (
|
||||
<h1 className="text-2xl font-bold mt-8 mb-4" {...props} />
|
||||
),
|
||||
h2: ({ ...props }) => (
|
||||
<h2 className="text-xl font-bold mt-6 mb-3" {...props} />
|
||||
),
|
||||
h3: ({ ...props }) => (
|
||||
<h3 className="text-lg font-bold mt-4 mb-2" {...props} />
|
||||
),
|
||||
p: ({ ...props }) => (
|
||||
<p className="text-sm leading-relaxed mb-4" {...props} />
|
||||
),
|
||||
code: ({ className, children, ...props }: any) => {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
const language = match ? match[1] : null;
|
||||
const code = String(children).replace(/\n$/, "");
|
||||
|
||||
// Inline code (no language)
|
||||
if (!language) {
|
||||
return (
|
||||
<code
|
||||
className="bg-muted px-0.5 py-0.5 rounded text-xs font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// Block code with syntax highlighting
|
||||
return (
|
||||
<SyntaxHighlight
|
||||
code={code}
|
||||
language={language as any}
|
||||
className="my-4"
|
||||
/>
|
||||
);
|
||||
},
|
||||
blockquote: ({ ...props }) => (
|
||||
<blockquote
|
||||
className="border-l-4 border-muted pl-4 italic text-muted-foreground my-4"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ ...props }) => (
|
||||
<ul
|
||||
className="text-sm list-disc list-inside my-4 space-y-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ ...props }) => (
|
||||
<ol
|
||||
className="text-sm list-decimal list-inside my-4 space-y-2"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
hr: () => <hr className="my-4" />,
|
||||
}}
|
||||
>
|
||||
{event.content.replace(/\\n/g, '\n')}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
<MarkdownContent content={event.content} canonicalUrl={canonicalUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
54
src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx
Normal file
54
src/components/nostr/kinds/CommunityNIPDetailRenderer.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useMemo } from "react";
|
||||
import { getTagValue } from "applesauce-core/helpers";
|
||||
import { UserName } from "../UserName";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import type { NostrEvent } from "@/types/nostr";
|
||||
|
||||
/**
|
||||
* Detail renderer for Kind 30817 - Community NIP
|
||||
* Displays full markdown content with NIP-specific metadata
|
||||
*/
|
||||
export function CommunityNIPDetailRenderer({ event }: { event: NostrEvent }) {
|
||||
const title = useMemo(
|
||||
() => getTagValue(event, "title") || "Untitled NIP",
|
||||
[event],
|
||||
);
|
||||
|
||||
// Get canonical URL from "r" tag to resolve relative URLs
|
||||
const canonicalUrl = useMemo(() => {
|
||||
return getTagValue(event, "r");
|
||||
}, [event]);
|
||||
|
||||
// Format created date
|
||||
const createdDate = new Date(event.created_at * 1000).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div dir="auto" className="flex flex-col gap-6 p-6 max-w-3xl mx-auto">
|
||||
{/* NIP Header */}
|
||||
<header className="flex flex-col gap-4 border-b border-border pb-6">
|
||||
{/* Title */}
|
||||
<h1 className="text-3xl font-bold">{title}</h1>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Proposed by</span>
|
||||
<UserName pubkey={event.pubkey} className="font-semibold" />
|
||||
</div>
|
||||
<span>•</span>
|
||||
<time>{createdDate}</time>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* NIP Content - Markdown */}
|
||||
<MarkdownContent content={event.content} canonicalUrl={canonicalUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/components/nostr/kinds/CommunityNIPRenderer.tsx
Normal file
28
src/components/nostr/kinds/CommunityNIPRenderer.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
BaseEventContainer,
|
||||
BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { getArticleTitle } from "applesauce-core/helpers";
|
||||
|
||||
/**
|
||||
* Renderer for Kind 30817 - Community NIP
|
||||
* Displays NIP identifier, title and summary in feed
|
||||
*/
|
||||
export function CommunityNIPRenderer({ event }: BaseEventProps) {
|
||||
const title = getArticleTitle(event);
|
||||
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div dir="auto" className="flex flex-col gap-2">
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={title}
|
||||
className="text-lg font-bold text-foreground flex-1"
|
||||
>
|
||||
{title}
|
||||
</ClickableEventTitle>
|
||||
</div>
|
||||
</BaseEventContainer>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
getRepositoryName,
|
||||
getRepositoryIdentifier,
|
||||
} from "@/lib/nip34-helpers";
|
||||
import { UserName } from "../UserName";
|
||||
import { Label } from "@/components/ui/Label";
|
||||
|
||||
/**
|
||||
@@ -70,37 +69,38 @@ export function IssueRenderer({ event }: BaseEventProps) {
|
||||
return (
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Issue Title */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={title || "Untitled Issue"}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{title || "Untitled Issue"}
|
||||
</ClickableEventTitle>
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Issue Title */}
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={title || "Untitled Issue"}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{title || "Untitled Issue"}
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Repository Reference */}
|
||||
{repoAddress && repoPointer && (
|
||||
<div className="flex items-center gap-1 text-xs line-clamp-1">
|
||||
<span>in </span>
|
||||
<div
|
||||
onClick={handleRepoClick}
|
||||
className={`flex items-center gap-1 text-muted-foreground
|
||||
{/* Repository Reference */}
|
||||
{repoAddress && repoPointer && (
|
||||
<div className="text-xs line-clamp-1">
|
||||
<div
|
||||
onClick={handleRepoClick}
|
||||
className={`flex items-center gap-1 text-muted-foreground
|
||||
cursor-crosshair underline decoration-dotted hover:text-primary
|
||||
`}
|
||||
>
|
||||
<FolderGit2 className="size-3" />
|
||||
<span>{repoName}</span>
|
||||
>
|
||||
<FolderGit2 className="size-3" />
|
||||
<span>{repoName}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span>by</span>
|
||||
<UserName pubkey={repoPointer.pubkey} />
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div
|
||||
className="flex
|
||||
flex-wrap
|
||||
line-clamp-2
|
||||
items-center gap-1 overflow-x-scroll my-1"
|
||||
>
|
||||
{labels.map((label, idx) => (
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
type BaseEventProps,
|
||||
ClickableEventTitle,
|
||||
} from "./BaseEventRenderer";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import { FolderGit2, GitBranch } from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { useNostrEvent } from "@/hooks/useNostrEvent";
|
||||
import {
|
||||
@@ -73,38 +73,31 @@ export function PullRequestRenderer({ event }: BaseEventProps) {
|
||||
<BaseEventContainer event={event}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* PR Title */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={subject || "Untitled Pull Request"}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{subject || "Untitled Pull Request"}
|
||||
</ClickableEventTitle>
|
||||
</div>
|
||||
<ClickableEventTitle
|
||||
event={event}
|
||||
windowTitle={subject || "Untitled Pull Request"}
|
||||
className="font-semibold text-foreground"
|
||||
>
|
||||
{subject || "Untitled Pull Request"}
|
||||
</ClickableEventTitle>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span>in</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Repository */}
|
||||
{repoAddress && repoPointer && (
|
||||
<div
|
||||
onClick={handleRepoClick}
|
||||
className="flex items-center gap-1 text-muted-foreground cursor-crosshair underline decoration-dotted hover:text-primary"
|
||||
className="flex items-center gap-1 text-muted-foreground cursor-crosshair underline decoration-dotted hover:text-primary truncate line-clamp-1 text-xs"
|
||||
>
|
||||
<FolderGit2 className="size-3" />
|
||||
<FolderGit2 className="size-3 flex-shrink-0" />
|
||||
<span>{repoName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branch Name */}
|
||||
{branchName && (
|
||||
<>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<code className="text-muted-foreground font-mono text-xs">
|
||||
{branchName}
|
||||
</code>
|
||||
</>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<GitBranch className="size-3" />
|
||||
<span>{branchName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Kind9735Renderer } from "./ZapReceiptRenderer";
|
||||
import { Kind9802Renderer } from "./HighlightRenderer";
|
||||
import { Kind10002Renderer } from "./RelayListRenderer";
|
||||
import { Kind30023Renderer } from "./ArticleRenderer";
|
||||
import { CommunityNIPRenderer } from "./CommunityNIPRenderer";
|
||||
import { RepositoryRenderer } from "./RepositoryRenderer";
|
||||
import { Kind39701Renderer } from "./BookmarkRenderer";
|
||||
import { GenericRelayListRenderer } from "./GenericRelayListRenderer";
|
||||
@@ -52,6 +53,7 @@ const kindRenderers: Record<number, React.ComponentType<BaseEventProps>> = {
|
||||
10050: GenericRelayListRenderer, // DM Relay List (NIP-51)
|
||||
30002: GenericRelayListRenderer, // Relay Sets (NIP-51)
|
||||
30023: Kind30023Renderer, // Long-form Article
|
||||
30817: CommunityNIPRenderer, // Community NIP
|
||||
30617: RepositoryRenderer, // Repository (NIP-34)
|
||||
39701: Kind39701Renderer, // Web Bookmarks (NIP-B0)
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ export function Label({ children, className, size = "sm" }: LabelProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"border border-muted border-dotted text-muted-foreground text-xs",
|
||||
"truncate line-clamp-1 border border-muted border-dotted text-muted-foreground text-xs",
|
||||
size === "sm" && "px-2 py-0.5",
|
||||
size === "md" && "px-3 py-1",
|
||||
className,
|
||||
|
||||
@@ -1234,6 +1234,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
|
||||
nip: "34",
|
||||
icon: FolderGit2,
|
||||
},
|
||||
30817: {
|
||||
kind: 30817,
|
||||
name: "Community NIP",
|
||||
description: "Community-published NIP document",
|
||||
nip: "",
|
||||
icon: FileText,
|
||||
},
|
||||
30818: {
|
||||
kind: 30818,
|
||||
name: "Wiki",
|
||||
|
||||
@@ -256,7 +256,9 @@ export function getPullRequestCloneUrls(event: NostrEvent): string[] {
|
||||
* @param event PR event (kind 1618)
|
||||
* @returns Branch name or undefined
|
||||
*/
|
||||
export function getPullRequestBranchName(event: NostrEvent): string | undefined {
|
||||
export function getPullRequestBranchName(
|
||||
event: NostrEvent,
|
||||
): string | undefined {
|
||||
return getTagValue(event, "branch-name");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user