Add support for Articles and Recipes (kind 30023)

- Added ArticleCard component for displaying long-form content in feeds
- Added ArticleDetail component with markdown rendering for article detail pages
- Created ArticlesFeedPage with optional tag filtering for recipes
- Added /articles and /recipes routes
- Updated PostDetailPage to use ArticleDetail for kind 30023 events
- Added Articles and Recipes to settings toggles (sidebar & feed visibility)
- Integrated articles into Feed component - kind 30023 events now use ArticleCard
- Added react-markdown and remark-gfm for proper markdown rendering
- Articles and Recipes now visible by default in sidebar navigation

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-19 05:59:37 -06:00
parent e693589e6d
commit 052aca55cb
14 changed files with 809 additions and 10 deletions
+22
View File
@@ -59,9 +59,11 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.71.1",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.3",
@@ -6785,6 +6787,16 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
"integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/react-remove-scroll": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz",
@@ -6994,6 +7006,16 @@
"node": ">=8"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+2
View File
@@ -60,9 +60,11 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.71.1",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.3",
"react-router-dom": "^6.26.2",
"recharts": "^2.12.7",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.3",
+4
View File
@@ -46,12 +46,16 @@ const defaultConfig: AppConfig = {
showTreasureFoundLogs: true,
showColors: false,
showPacks: true,
showArticles: true,
showRecipes: true,
feedIncludeVines: false,
feedIncludePolls: false,
feedIncludeTreasureGeocaches: false,
feedIncludeTreasureFoundLogs: false,
feedIncludeColors: false,
feedIncludePacks: false,
feedIncludeArticles: false,
feedIncludeRecipes: false,
},
nip85StatsPubkey: "5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea",
nip85OnlyMode: true,
+4 -1
View File
@@ -1,5 +1,5 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { Clapperboard, BarChart3, Palette, PartyPopper } from "lucide-react";
import { Clapperboard, BarChart3, Palette, PartyPopper, BookOpen, ChefHat } from "lucide-react";
import { ScrollToTop } from "./components/ScrollToTop";
import Index from "./pages/Index";
@@ -12,6 +12,7 @@ import { HashtagPage } from "./pages/HashtagPage";
import { BookmarksPage } from "./pages/BookmarksPage";
import { KindFeedPage } from "./pages/KindFeedPage";
import { ArticlesFeedPage } from "./pages/ArticlesFeedPage";
import { TreasuresPage } from "./pages/TreasuresPage";
import NotFound from "./pages/NotFound";
@@ -33,6 +34,8 @@ export function AppRouter() {
<Route path="/treasures" element={<TreasuresPage />} />
<Route path="/colors" element={<KindFeedPage kind={3367} title="Colors" icon={<Palette className="size-5" />} />} />
<Route path="/packs" element={<KindFeedPage kind={39089} title="Follow Packs" icon={<PartyPopper className="size-5" />} />} />
<Route path="/articles" element={<ArticlesFeedPage title="Articles" icon={<BookOpen className="size-5" />} description="Long-form content on Nostr" />} />
<Route path="/recipes" element={<ArticlesFeedPage tagFilter="zapcooking" title="Recipes" icon={<ChefHat className="size-5" />} description="Cooking recipes from zap.cooking" />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
+288
View File
@@ -0,0 +1,288 @@
import { Link, useNavigate } from 'react-router-dom';
import { MessageCircle, Repeat2, Zap, MoreHorizontal, Clock, Tag } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ReactionButton } from '@/components/ReactionButton';
import { RepostMenu } from '@/components/RepostMenu';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventStats } from '@/hooks/useTrending';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { canZap } from '@/lib/canZap';
import { cn } from '@/lib/utils';
import { Nip05Badge } from '@/components/Nip05Badge';
import { nip19 } from 'nostr-tools';
import { useMemo, useState } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ZapDialog } from '@/components/ZapDialog';
interface ArticleCardProps {
event: NostrEvent;
className?: string;
/** If true, hide action buttons (used for embeds). */
compact?: boolean;
}
/** Formats a sats amount into a compact human-readable string. */
function formatSats(sats: number): string {
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
if (sats >= 1_000) return `${(sats / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
return sats.toString();
}
/** Gets a tag value by name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
/** Gets all tag values by name. */
function getTags(tags: string[][], name: string): string[] {
return tags.filter(([n]) => n === name).map(([, v]) => v);
}
/** Encodes the NIP-19 identifier for navigating to an event. */
function encodeEventId(event: NostrEvent): string {
const dTag = getTag(event.tags, 'd');
if (dTag) {
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
}
return nip19.neventEncode({ id: event.id, author: event.pubkey });
}
/** Formats a timestamp into a readable date like "Feb 16, 2026". */
function formatDate(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
/** Truncates text to a max length with ellipsis. */
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength).trim() + '…';
}
export function ArticleCard({ event, className, compact }: ArticleCardProps) {
const navigate = useNavigate();
const { user } = useCurrentUser();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(event.pubkey);
const nip05 = metadata?.nip05;
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
const encodedId = useMemo(() => encodeEventId(event), [event]);
const { data: stats } = useEventStats(event.id);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
// Extract article metadata
const title = getTag(event.tags, 'title') || 'Untitled Article';
const summary = getTag(event.tags, 'summary');
const imageUrl = getTag(event.tags, 'image');
const publishedAt = getTag(event.tags, 'published_at');
const hashtags = getTags(event.tags, 't').slice(0, 5);
// Determine if this is a recipe (has zapcooking tags)
const isRecipe = hashtags.some(tag => tag.toLowerCase().includes('zapcooking'));
// Use published_at if available, otherwise use created_at
const displayDate = publishedAt ? parseInt(publishedAt) : event.created_at;
// Check if the current user can zap this event's author
const canZapAuthor = user && canZap(metadata);
// Handler to navigate to article detail
const handleCardClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
// Don't navigate if clicking on interactive elements or dialogs
if (
target.closest('button') ||
target.closest('a') ||
target.closest('[role="dialog"]') ||
target.closest('[data-radix-dialog-overlay]') ||
target.closest('[data-radix-dialog-content]') ||
target.closest('[data-vaul-drawer]') ||
target.closest('[data-vaul-drawer-overlay]')
) {
return;
}
navigate(`/${encodedId}`);
};
return (
<article
className={cn(
'group cursor-pointer',
className,
)}
onClick={handleCardClick}
>
<Card className="border-border hover:border-primary/50 transition-all hover:shadow-md">
<CardContent className="p-0">
{/* Article image */}
{imageUrl && (
<div className="relative aspect-[2/1] overflow-hidden rounded-t-lg">
<img
src={imageUrl}
alt={title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{isRecipe && (
<Badge className="absolute top-3 right-3 bg-amber-500/90 text-white border-0">
Recipe
</Badge>
)}
</div>
)}
<div className="p-4 space-y-3">
{/* Author info */}
<div className="flex items-center gap-2">
{author.isLoading ? (
<>
<Skeleton className="size-8 rounded-full shrink-0" />
<Skeleton className="h-4 w-24" />
</>
) : (
<>
<Link to={`/${npub}`} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar className="size-8">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className="flex items-center gap-1.5 text-sm text-muted-foreground min-w-0">
<Link
to={`/${npub}`}
className="font-medium hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{displayName}
</Link>
{nip05 && (
<>
<span>·</span>
<Nip05Badge nip05={nip05} />
</>
)}
</div>
</>
)}
</div>
{/* Article title */}
<h2 className="text-xl font-bold leading-tight line-clamp-2 group-hover:text-primary transition-colors">
{title}
</h2>
{/* Article summary */}
{summary && (
<p className="text-sm text-muted-foreground line-clamp-3 leading-relaxed">
{summary}
</p>
)}
{/* If no summary, show truncated content */}
{!summary && event.content && (
<p className="text-sm text-muted-foreground line-clamp-3 leading-relaxed">
{truncate(event.content.replace(/^#+\s+/gm, '').replace(/\n/g, ' '), 200)}
</p>
)}
{/* Hashtags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{hashtags.map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
<Tag className="size-3" />
{tag}
</Link>
))}
</div>
)}
{/* Metadata footer */}
<div className="flex items-center justify-between pt-2 border-t border-border">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="size-3.5" />
<span>{formatDate(displayDate)}</span>
</div>
{/* Action buttons */}
{!compact && (
<div className="flex items-center gap-3 -mr-2">
<button
className="flex items-center gap-1 p-1.5 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Comments"
onClick={(e) => e.stopPropagation()}
>
<MessageCircle className="size-4" />
{stats?.replies ? <span className="text-xs tabular-nums">{stats.replies}</span> : null}
</button>
<RepostMenu event={event}>
<button
className="flex items-center gap-1 p-1.5 rounded-full text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
title="Repost"
onClick={(e) => e.stopPropagation()}
>
<Repeat2 className="size-4" />
{(stats?.reposts || stats?.quotes) ? <span className="text-xs tabular-nums">{(stats?.reposts ?? 0) + (stats?.quotes ?? 0)}</span> : null}
</button>
</RepostMenu>
<ReactionButton
eventId={event.id}
eventPubkey={event.pubkey}
eventKind={event.kind}
reactionCount={stats?.reactions}
compact
/>
{canZapAuthor && (
<ZapDialog target={event}>
<button
className="flex items-center gap-1 p-1.5 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Zap"
onClick={(e) => e.stopPropagation()}
>
<Zap className="size-4" />
{stats?.zapAmount ? <span className="text-xs tabular-nums">{formatSats(stats.zapAmount)}</span> : null}
</button>
</ZapDialog>
)}
<button
className="p-1.5 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="More"
onClick={(e) => { e.stopPropagation(); setMoreMenuOpen(true); }}
>
<MoreHorizontal className="size-4" />
</button>
</div>
)}
</div>
</div>
</CardContent>
</Card>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
</article>
);
}
+269
View File
@@ -0,0 +1,269 @@
import { Link } from 'react-router-dom';
import { MessageCircle, Repeat2, Zap, MoreHorizontal, Clock, Tag, User, Calendar } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { ReactionButton } from '@/components/ReactionButton';
import { RepostMenu } from '@/components/RepostMenu';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useEventStats } from '@/hooks/useTrending';
import { genUserName } from '@/lib/genUserName';
import { canZap } from '@/lib/canZap';
import { Nip05Badge } from '@/components/Nip05Badge';
import { nip19 } from 'nostr-tools';
import { useMemo, useState } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ZapDialog } from '@/components/ZapDialog';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface ArticleDetailProps {
event: NostrEvent;
}
/** Formats a sats amount into a compact human-readable string. */
function formatSats(sats: number): string {
if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
if (sats >= 1_000) return `${(sats / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
return sats.toString();
}
/** Gets a tag value by name. */
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
/** Gets all tag values by name. */
function getTags(tags: string[][], name: string): string[] {
return tags.filter(([n]) => n === name).map(([, v]) => v);
}
/** Formats a timestamp into a full readable date. */
function formatFullDate(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
export function ArticleDetail({ event }: ArticleDetailProps) {
const { user } = useCurrentUser();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(event.pubkey);
const nip05 = metadata?.nip05;
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
const { data: stats } = useEventStats(event.id);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
// Extract article metadata
const title = getTag(event.tags, 'title') || 'Untitled Article';
const summary = getTag(event.tags, 'summary');
const imageUrl = getTag(event.tags, 'image');
const publishedAt = getTag(event.tags, 'published_at');
const hashtags = getTags(event.tags, 't');
// Determine if this is a recipe (has zapcooking tags)
const isRecipe = hashtags.some(tag => tag.toLowerCase().includes('zapcooking'));
// Use published_at if available, otherwise use created_at
const displayDate = publishedAt ? parseInt(publishedAt) : event.created_at;
// Check if the current user can zap this event's author
const canZapAuthor = user && canZap(metadata);
return (
<article className="max-w-3xl mx-auto">
{/* Header image */}
{imageUrl && (
<div className="relative -mx-4 sm:mx-0 mb-8 aspect-[2.5/1] overflow-hidden sm:rounded-xl">
<img
src={imageUrl}
alt={title}
className="w-full h-full object-cover"
/>
{isRecipe && (
<Badge className="absolute top-4 right-4 bg-amber-500 text-white border-0 text-sm px-3 py-1">
🍳 Recipe
</Badge>
)}
</div>
)}
{/* Article header */}
<header className="mb-8 space-y-4">
<h1 className="text-4xl md:text-5xl font-bold leading-tight">{title}</h1>
{summary && (
<p className="text-xl text-muted-foreground leading-relaxed">{summary}</p>
)}
{/* Author info */}
<div className="flex items-center gap-3 pt-2">
{author.isLoading ? (
<>
<Skeleton className="size-12 rounded-full shrink-0" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</>
) : (
<>
<Link to={`/${npub}`} className="shrink-0">
<Avatar className="size-12">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className="space-y-1">
<Link
to={`/${npub}`}
className="font-semibold text-base hover:underline block"
>
{displayName}
</Link>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{nip05 && (
<>
<Nip05Badge nip05={nip05} />
<span>·</span>
</>
)}
<div className="flex items-center gap-1">
<Calendar className="size-3.5" />
<span>{formatFullDate(displayDate)}</span>
</div>
</div>
</div>
</>
)}
</div>
{/* Hashtags */}
{hashtags.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
{hashtags.map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-secondary text-sm hover:bg-secondary/80 transition-colors"
>
<Tag className="size-3.5" />
{tag}
</Link>
))}
</div>
)}
</header>
<Separator className="mb-8" />
{/* Article content with markdown rendering */}
<div className="prose prose-neutral dark:prose-invert max-w-none mb-8">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Custom image rendering to handle responsive images
img: ({ node, ...props }) => (
<img
{...props}
className="rounded-lg w-full h-auto"
loading="lazy"
/>
),
// Custom link rendering
a: ({ node, ...props }) => (
<a
{...props}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
/>
),
// Custom code block rendering
pre: ({ node, ...props }) => (
<pre
{...props}
className="bg-secondary p-4 rounded-lg overflow-x-auto"
/>
),
code: ({ node, ...props }) => (
<code
{...props}
className="bg-secondary px-1.5 py-0.5 rounded text-sm"
/>
),
}}
>
{event.content}
</ReactMarkdown>
</div>
<Separator className="mb-6" />
{/* Action buttons */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-4">
<button
className="flex items-center gap-2 px-4 py-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Reply"
onClick={() => setReplyOpen(true)}
>
<MessageCircle className="size-5" />
{stats?.replies ? <span className="text-sm font-medium tabular-nums">{stats.replies}</span> : null}
</button>
<RepostMenu event={event}>
<button
className="flex items-center gap-2 px-4 py-2 rounded-full text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
title="Repost"
>
<Repeat2 className="size-5" />
{(stats?.reposts || stats?.quotes) ? <span className="text-sm font-medium tabular-nums">{(stats?.reposts ?? 0) + (stats?.quotes ?? 0)}</span> : null}
</button>
</RepostMenu>
<ReactionButton
eventId={event.id}
eventPubkey={event.pubkey}
eventKind={event.kind}
reactionCount={stats?.reactions}
/>
{canZapAuthor && (
<ZapDialog target={event}>
<button
className="flex items-center gap-2 px-4 py-2 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Zap"
>
<Zap className="size-5" />
{stats?.zapAmount ? <span className="text-sm font-medium tabular-nums">{formatSats(stats.zapAmount)}</span> : null}
</button>
</ZapDialog>
)}
</div>
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="More"
onClick={() => setMoreMenuOpen(true)}
>
<MoreHorizontal className="size-5" />
</button>
</div>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
</article>
);
}
+3 -1
View File
@@ -136,7 +136,7 @@ export function ContentSettings() {
}
// Import the internals from FeedSettingsForm (we'll need to export them)
import { Clapperboard, BarChart3, Palette, PartyPopper } from 'lucide-react';
import { Clapperboard, BarChart3, Palette, PartyPopper, BookOpen, ChefHat } from 'lucide-react';
import { ChestIcon } from '@/components/icons/ChestIcon';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
@@ -152,6 +152,8 @@ const ICONS: Record<string, React.ReactNode> = {
treasures: <ChestIcon className="size-5" />,
colors: <Palette className="size-5" />,
packs: <PartyPopper className="size-5" />,
articles: <BookOpen className="size-5" />,
recipes: <ChefHat className="size-5" />,
};
function KindBadge({ kind }: { kind: number }) {
+20 -7
View File
@@ -3,6 +3,7 @@ import { useInView } from 'react-intersection-observer';
import { useQueryClient } from '@tanstack/react-query';
import { ComposeBox } from '@/components/ComposeBox';
import { NoteCard } from '@/components/NoteCard';
import { ArticleCard } from '@/components/ArticleCard';
import { PullToRefresh } from '@/components/PullToRefresh';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
@@ -195,13 +196,25 @@ export function Feed() {
</div>
) : feedItems.length > 0 ? (
<div>
{feedItems.map((item: FeedItem) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
))}
{feedItems.map((item: FeedItem) => {
// Use ArticleCard for kind 30023 (long-form content)
if (item.event.kind === 30023) {
return (
<div key={item.event.id} className="px-4 py-4">
<ArticleCard event={item.event} />
</div>
);
}
// Use NoteCard for all other kinds
return (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
);
})}
{/* Infinite scroll trigger */}
{hasNextPage && (
<div ref={scrollRef} className="py-4">
+3 -1
View File
@@ -1,6 +1,6 @@
import { useState, useMemo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Home, Bell, Search, Clapperboard, BarChart3, Palette, PartyPopper, User, Settings, Bookmark, UserPlus, LogOut, Check, Moon, Sun, Cat, Heart, ChevronDown } from 'lucide-react';
import { Home, Bell, Search, Clapperboard, BarChart3, Palette, PartyPopper, User, Settings, Bookmark, UserPlus, LogOut, Check, Moon, Sun, Cat, Heart, ChevronDown, BookOpen, ChefHat } from 'lucide-react';
import { ChestIcon } from '@/components/icons/ChestIcon';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
@@ -69,6 +69,8 @@ export function LeftSidebar() {
treasures: <ChestIcon className="size-6" />,
colors: <Palette className="size-6" />,
packs: <PartyPopper className="size-6" />,
articles: <BookOpen className="size-6" />,
recipes: <ChefHat className="size-6" />,
};
const navItems = useMemo(() => {
+8
View File
@@ -25,6 +25,10 @@ export interface FeedSettings {
showColors: boolean;
/** Show Follow Packs (kind 39089) link in sidebar */
showPacks: boolean;
/** Show Articles (kind 30023) link in sidebar */
showArticles: boolean;
/** Show Recipes (kind 30023 with zapcooking tag) link in sidebar */
showRecipes: boolean;
/** Include Vines in the follows/global feed */
feedIncludeVines: boolean;
/** Include Polls in the follows/global feed */
@@ -37,6 +41,10 @@ export interface FeedSettings {
feedIncludeColors: boolean;
/** Include Follow Packs in the follows/global feed */
feedIncludePacks: boolean;
/** Include Articles in the follows/global feed */
feedIncludeArticles: boolean;
/** Include Recipes in the follows/global feed */
feedIncludeRecipes: boolean;
}
export interface AppConfig {
+18
View File
@@ -98,6 +98,24 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
route: 'packs',
addressable: true,
},
{
kind: 30023,
showKey: 'showArticles',
feedKey: 'feedIncludeArticles',
label: 'Articles',
description: 'Long-form content',
route: 'articles',
addressable: true,
},
{
kind: 30023,
showKey: 'showRecipes',
feedKey: 'feedIncludeRecipes',
label: 'Recipes',
description: 'Cooking recipes',
route: 'recipes',
addressable: true,
},
];
/** Return the kind numbers the user has opted to include in mixed feeds. */
+133
View File
@@ -0,0 +1,133 @@
import { useSeoMeta } from '@unhead/react';
import { ArrowLeft } from 'lucide-react';
import { Link } from 'react-router-dom';
import { MainLayout } from '@/components/MainLayout';
import { ArticleCard } from '@/components/ArticleCard';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent } from '@/components/ui/card';
import { useStreamKind } from '@/hooks/useStreamKind';
import { cn, STICKY_HEADER_CLASS } from '@/lib/utils';
interface ArticlesFeedPageProps {
/** Optional tag filter (e.g., "zapcooking" for recipes) */
tagFilter?: string;
title: string;
icon?: React.ReactNode;
description?: string;
}
export function ArticlesFeedPage({ tagFilter, title, icon, description }: ArticlesFeedPageProps) {
useSeoMeta({
title: `${title} | Mew`,
description: description || `${title} on Nostr`,
});
const { events, isLoading } = useStreamKind(30023);
// Filter by tag if specified
const filteredEvents = tagFilter
? events.filter((event) =>
event.tags.some(([name, value]) =>
name === 't' && value.toLowerCase().includes(tagFilter.toLowerCase())
)
)
: events;
return (
<MainLayout>
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l xl:border-r border-border min-h-screen">
{/* Header */}
<div className={cn(STICKY_HEADER_CLASS, 'flex items-center gap-4 px-4 mt-4 mb-5 bg-background/95 backdrop-blur-md z-10')}>
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
<ArrowLeft className="size-5" />
</Link>
<div className="flex items-center gap-2">
{icon}
<div>
<h1 className="text-xl font-bold">{title}</h1>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
</div>
</div>
{/* Feed */}
{isLoading && filteredEvents.length === 0 ? (
<div className="space-y-4 px-4">
{Array.from({ length: 3 }).map((_, i) => (
<ArticleSkeleton key={i} />
))}
</div>
) : filteredEvents.length > 0 ? (
<div className="space-y-4 px-4 pb-8">
{filteredEvents.map((event) => (
<ArticleCard key={event.id} event={event} />
))}
</div>
) : (
<div className="px-4">
<Card className="border-dashed">
<CardContent className="py-16 px-8 text-center">
<div className="max-w-sm mx-auto space-y-3">
<p className="text-muted-foreground">
{tagFilter
? `No ${title.toLowerCase()} found yet. Check back soon!`
: 'No articles found yet. Check back soon!'
}
</p>
</div>
</CardContent>
</Card>
</div>
)}
</main>
</MainLayout>
);
}
function ArticleSkeleton() {
return (
<Card className="border-border">
<CardContent className="p-0">
{/* Image skeleton */}
<Skeleton className="aspect-[2/1] w-full rounded-t-lg" />
<div className="p-4 space-y-3">
{/* Author skeleton */}
<div className="flex items-center gap-2">
<Skeleton className="size-8 rounded-full shrink-0" />
<Skeleton className="h-4 w-24" />
</div>
{/* Title skeleton */}
<Skeleton className="h-7 w-4/5" />
<Skeleton className="h-7 w-3/5" />
{/* Summary skeleton */}
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
{/* Hashtags skeleton */}
<div className="flex gap-1.5">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
<Skeleton className="h-5 w-14" />
</div>
{/* Footer skeleton */}
<div className="flex items-center justify-between pt-2 border-t border-border">
<Skeleton className="h-4 w-24" />
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-8 w-8 rounded-full" />
</div>
</div>
</div>
</CardContent>
</Card>
);
}
+31
View File
@@ -28,10 +28,15 @@ import { FoundLogContent } from '@/components/FoundLogContent';
import { ColorMomentContent } from '@/components/ColorMomentContent';
import { FollowPackContent } from '@/components/FollowPackContent';
import { FollowPackDetailContent } from '@/components/FollowPackDetailContent';
import { ArticleDetail } from '@/components/ArticleDetail';
import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
/** Kinds that get the full follow-pack detail view. */
const FOLLOW_PACK_KINDS = new Set([30000, 39089]);
/** Kinds that get the article detail view (long-form content). */
const ARTICLE_KINDS = new Set([30023]);
import { useReplies } from '@/hooks/useReplies';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -218,6 +223,19 @@ export function PostDetailPage({ eventId, relays, authorHint }: PostDetailPagePr
);
}
// Articles (kind 30023) get the article detail view with markdown rendering
if (ARTICLE_KINDS.has(resolvedEvent.kind)) {
return (
<MainLayout>
<PostDetailShell>
<div className="px-4 pb-8">
<ArticleDetail event={resolvedEvent} />
</div>
</PostDetailShell>
</MainLayout>
);
}
return (
<MainLayout>
<PostDetailShell>
@@ -274,6 +292,19 @@ export function AddrPostDetailPage({ addr, relays }: AddrPostDetailPageProps) {
);
}
// Articles (kind 30023) get the article detail view with markdown rendering
if (ARTICLE_KINDS.has(resolvedEvent.kind)) {
return (
<MainLayout>
<PostDetailShell>
<div className="px-4 pb-8">
<ArticleDetail event={resolvedEvent} />
</div>
</PostDetailShell>
</MainLayout>
);
}
return (
<MainLayout>
<PostDetailShell>
+4
View File
@@ -38,12 +38,16 @@ export function TestApp({ children }: TestAppProps) {
showTreasureFoundLogs: true,
showColors: false,
showPacks: true,
showArticles: true,
showRecipes: true,
feedIncludeVines: false,
feedIncludePolls: false,
feedIncludeTreasureGeocaches: false,
feedIncludeTreasureFoundLogs: false,
feedIncludeColors: false,
feedIncludePacks: false,
feedIncludeArticles: false,
feedIncludeRecipes: false,
},
nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea',
nip85OnlyMode: false,