Add post detail page with replies and reply composer
- Create PostDetailPage with expanded post view showing author info, full content, images, stats (reposts, likes, zap amount), full date, action buttons, and inline reply composer - Create useEvent hook for fetching single events by hex ID - Create useReplies hook for fetching kind:1 replies to an event - Update NIP19Page to render PostDetailPage for note1/nevent1 identifiers - Make NoteCard clickable to navigate to post detail via nevent1 URL - Fix NoteMoreMenu to use React Router navigate instead of window.location.href Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { MessageCircle, Repeat2, Heart, Zap, MoreHorizontal } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
@@ -32,11 +32,13 @@ function extractImages(content: string): string[] {
|
||||
}
|
||||
|
||||
export function NoteCard({ event, className }: NoteCardProps) {
|
||||
const navigate = useNavigate();
|
||||
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 neventId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event.id, event.pubkey]);
|
||||
const images = useMemo(() => extractImages(event.content), [event.content]);
|
||||
const { data: stats } = useEventStats(event.id);
|
||||
const [liked, setLiked] = useState(false);
|
||||
@@ -52,6 +54,7 @@ export function NoteCard({ event, className }: NoteCardProps) {
|
||||
'px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
onClick={() => navigate(`/${neventId}`)}
|
||||
>
|
||||
{/* Reply context */}
|
||||
{isReply && replyTo && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Bookmark,
|
||||
@@ -55,6 +56,7 @@ function MenuItem({ icon, label, onClick, destructive }: MenuItemProps) {
|
||||
}
|
||||
|
||||
export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
const { isBookmarked, toggleBookmark } = useBookmarks();
|
||||
const bookmarked = isBookmarked(event.id);
|
||||
const author = useAuthor(event.pubkey);
|
||||
@@ -83,8 +85,7 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
};
|
||||
|
||||
const handleShowDetails = () => {
|
||||
const url = `/${neventId}`;
|
||||
window.location.href = url;
|
||||
navigate(`/${neventId}`);
|
||||
close();
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/** Fetches a single Nostr event by its hex ID. */
|
||||
export function useEvent(eventId: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<NostrEvent | null>({
|
||||
queryKey: ['event', eventId ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!eventId) return null;
|
||||
const events = await nostr.query(
|
||||
[{ ids: [eventId], limit: 1 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
||||
);
|
||||
return events[0] ?? null;
|
||||
},
|
||||
enabled: !!eventId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/** Fetches kind:1 reply events that reference the given event ID. */
|
||||
export function useReplies(eventId: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery<NostrEvent[]>({
|
||||
queryKey: ['replies', eventId ?? ''],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!eventId) return [];
|
||||
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [1], '#e': [eventId], limit: 100 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
||||
);
|
||||
|
||||
// Sort oldest first for threaded conversation view
|
||||
return events.sort((a, b) => a.created_at - b.created_at);
|
||||
},
|
||||
enabled: !!eventId,
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useParams, Navigate } from 'react-router-dom';
|
||||
import NotFound from './NotFound';
|
||||
import { PostDetailPage } from './PostDetailPage';
|
||||
|
||||
export function NIP19Page() {
|
||||
const { nip19: identifier } = useParams<{ nip19: string }>();
|
||||
@@ -25,11 +26,10 @@ export function NIP19Page() {
|
||||
return <Navigate to={`/u/${identifier}`} replace />;
|
||||
|
||||
case 'note':
|
||||
// Note view placeholder
|
||||
return <div className="min-h-screen flex items-center justify-center text-muted-foreground">Note view coming soon</div>;
|
||||
return <PostDetailPage eventId={decoded.data as string} />;
|
||||
|
||||
case 'nevent':
|
||||
return <div className="min-h-screen flex items-center justify-center text-muted-foreground">Event view coming soon</div>;
|
||||
return <PostDetailPage eventId={(decoded.data as { id: string }).id} />;
|
||||
|
||||
case 'naddr':
|
||||
return <div className="min-h-screen flex items-center justify-center text-muted-foreground">Addressable event view coming soon</div>;
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, MessageCircle, Repeat2, Heart, Zap, MoreHorizontal, Loader2 } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
|
||||
import { MainLayout } from '@/components/MainLayout';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
import { useEvent } from '@/hooks/useEvent';
|
||||
import { useReplies } from '@/hooks/useReplies';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useEventStats } from '@/hooks/useTrending';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import NotFound from './NotFound';
|
||||
|
||||
interface PostDetailPageProps {
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
||||
/** Extracts image URLs from note content. */
|
||||
function extractImages(content: string): string[] {
|
||||
const urlRegex = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?/gi;
|
||||
return content.match(urlRegex) || [];
|
||||
}
|
||||
|
||||
/** Formats a timestamp into a full date string like "Feb 16, 2026, 2:53 PM". */
|
||||
function formatFullDate(timestamp: number): string {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function PostDetailPage({ eventId }: PostDetailPageProps) {
|
||||
const { data: event, isLoading, isError } = useEvent(eventId);
|
||||
|
||||
useSeoMeta({
|
||||
title: event ? 'Post Details - Mew' : 'Loading... - Mew',
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<MainLayout>
|
||||
<PostDetailShell>
|
||||
<PostDetailSkeleton />
|
||||
</PostDetailShell>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !event) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<PostDetailShell>
|
||||
<PostDetailContent event={event} />
|
||||
</PostDetailShell>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function PostDetailShell({ children }: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l lg:border-r border-border min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="sticky top-10 sidebar:top-0 z-10 flex items-center gap-4 px-4 h-[53px] bg-background/80 backdrop-blur-md border-b border-border">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<h1 className="text-lg font-bold">Post Details</h1>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
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 images = useMemo(() => extractImages(event.content), [event.content]);
|
||||
const { data: stats } = useEventStats(event.id);
|
||||
const { data: replies, isLoading: repliesLoading } = useReplies(event.id);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Main post — expanded view */}
|
||||
<article className="px-4 pt-4">
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to={`/${npub}`}>
|
||||
<Avatar className="size-12">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link to={`/${npub}`} className="font-bold text-[15px] hover:underline block truncate">
|
||||
{displayName}
|
||||
</Link>
|
||||
{nip05 && (
|
||||
<span className="text-sm text-muted-foreground truncate block">
|
||||
@{nip05}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{metadata?.bot && (
|
||||
<span className="text-sm text-primary" title="Bot account">🤖</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Post content — larger text */}
|
||||
<div className="mt-3">
|
||||
<NoteContent event={event} className="text-base leading-relaxed" />
|
||||
</div>
|
||||
|
||||
{/* Image attachments */}
|
||||
{images.length > 0 && (
|
||||
<div className={cn(
|
||||
'mt-3 rounded-2xl overflow-hidden border border-border',
|
||||
images.length > 1 && 'grid grid-cols-2 gap-0.5',
|
||||
)}>
|
||||
{images.slice(0, 4).map((url, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="w-full h-auto max-h-[500px] object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats row: reposts, reactions, date */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-3 text-sm text-muted-foreground">
|
||||
{stats?.reposts ? (
|
||||
<span>
|
||||
<span className="font-semibold text-foreground">{stats.reposts}</span>{' '}
|
||||
Repost{stats.reposts !== 1 ? 's' : ''}
|
||||
</span>
|
||||
) : null}
|
||||
{stats?.reactions ? (
|
||||
<span>
|
||||
<span className="font-semibold text-foreground">{stats.reactions}</span>{' '}
|
||||
Like{stats.reactions !== 1 ? 's' : ''}
|
||||
</span>
|
||||
) : null}
|
||||
{stats?.zapAmount ? (
|
||||
<span>
|
||||
<span className="font-semibold text-foreground">{formatSats(stats.zapAmount)}</span> sats
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ml-auto shrink-0">{formatFullDate(event.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-between py-2 mt-1 border-t border-b border-border -mx-4 px-4">
|
||||
<div className="flex items-center justify-between w-full max-w-md">
|
||||
{/* Reply */}
|
||||
<button
|
||||
className="flex items-center gap-1 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="Reply"
|
||||
>
|
||||
<MessageCircle className="size-5" />
|
||||
{stats?.replies ? <span className="text-xs">{stats.replies}</span> : null}
|
||||
</button>
|
||||
|
||||
{/* Repost */}
|
||||
<button
|
||||
className="flex items-center gap-1 p-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 ? <span className="text-xs">{stats.reposts}</span> : null}
|
||||
</button>
|
||||
|
||||
{/* Like */}
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1 p-2 rounded-full transition-colors',
|
||||
liked
|
||||
? 'text-pink-500'
|
||||
: 'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
|
||||
)}
|
||||
title="Like"
|
||||
onClick={() => setLiked(!liked)}
|
||||
>
|
||||
<Heart className={cn('size-5', liked && 'fill-pink-500')} />
|
||||
{stats?.reactions ? <span className="text-xs">{stats.reactions}</span> : null}
|
||||
</button>
|
||||
|
||||
{/* Zap */}
|
||||
<button
|
||||
className="flex items-center gap-1 p-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-xs">{formatSats(stats.zapAmount)}</span> : null}
|
||||
</button>
|
||||
|
||||
{/* More */}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
|
||||
</article>
|
||||
|
||||
{/* Reply composer */}
|
||||
<ReplyComposer event={event} />
|
||||
|
||||
{/* Replies */}
|
||||
<div>
|
||||
{repliesLoading ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<ReplyCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : replies && replies.length > 0 ? (
|
||||
replies.map((reply) => (
|
||||
<NoteCard key={reply.id} event={reply} />
|
||||
))
|
||||
) : (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
No replies yet. Be the first to reply!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline reply composer for the post detail page. */
|
||||
function ReplyComposer({ event }: { event: NostrEvent }) {
|
||||
const { user, metadata } = useCurrentUser();
|
||||
const { mutateAsync: createEvent, isPending } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [content, setContent] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!content.trim() || isPending) return;
|
||||
|
||||
try {
|
||||
const tags: string[][] = [
|
||||
['e', event.id, '', 'root'],
|
||||
['p', event.pubkey],
|
||||
];
|
||||
|
||||
// Extract hashtags
|
||||
const hashtags = content.match(/#\w+/g)?.map((t) => t.slice(1)) || [];
|
||||
for (const t of hashtags) {
|
||||
tags.push(['t', t.toLowerCase()]);
|
||||
}
|
||||
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content: content.trim(),
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
setContent('');
|
||||
queryClient.invalidateQueries({ queryKey: ['replies', event.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['event-stats', event.id] });
|
||||
toast({ title: 'Reply posted!' });
|
||||
} catch {
|
||||
toast({ title: 'Error', description: 'Failed to post reply.', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 px-4 py-3 border-b border-border">
|
||||
<Avatar className="size-10 shrink-0 mt-0.5">
|
||||
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{(metadata?.name?.[0] || '?').toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Post your reply"
|
||||
className="w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-[15px] py-2 min-h-[44px]"
|
||||
rows={1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = 'auto';
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!content.trim() || isPending}
|
||||
className="rounded-full px-5 font-bold"
|
||||
size="sm"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin mr-1.5" />
|
||||
Replying...
|
||||
</>
|
||||
) : (
|
||||
'Reply'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostDetailSkeleton() {
|
||||
return (
|
||||
<div className="px-4 pt-4">
|
||||
{/* Author */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-12 rounded-full" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-4/5" />
|
||||
<Skeleton className="h-5 w-3/5" />
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex gap-4 mt-4">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-4 w-32 ml-auto" />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-8 py-3 mt-2 border-t border-b border-border">
|
||||
<Skeleton className="h-5 w-8" />
|
||||
<Skeleton className="h-5 w-8" />
|
||||
<Skeleton className="h-5 w-8" />
|
||||
<Skeleton className="h-5 w-8" />
|
||||
<Skeleton className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
{/* Replies skeleton */}
|
||||
<div className="mt-4 divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<ReplyCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyCardSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<Skeleton className="h-3 w-6" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
<div className="flex gap-12 mt-1">
|
||||
<Skeleton className="h-4 w-6" />
|
||||
<Skeleton className="h-4 w-6" />
|
||||
<Skeleton className="h-4 w-6" />
|
||||
<Skeleton className="h-4 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user