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:
Generated
+22
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user