DRY: merge VineCard into NoteCard, single component for all event kinds
- NoteCard now handles both kind 1 (text notes) and kind 34236 (vines) - Extracted NoteBody (text + images) and VineBody (title + video + hashtags) as internal sub-components within NoteCard - Same avatar, header, action buttons (reply, repost, react, zap, more), more menu, and reply modal shared across all kinds - Addressable events (kind 30000+) use naddr encoding for navigation - Deleted standalone VineCard.tsx — no longer needed - Updated SearchPage and VinesPage to use NoteCard for everything Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
+161
-55
@@ -1,5 +1,5 @@
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { MessageCircle, Repeat2, Zap, MoreHorizontal } from 'lucide-react';
|
||||
import { MessageCircle, Repeat2, Zap, MoreHorizontal, Play } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { ReactionButton } from '@/components/ReactionButton';
|
||||
@@ -9,7 +9,7 @@ import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
@@ -33,6 +33,40 @@ function extractImages(content: string): string[] {
|
||||
return matches || [];
|
||||
}
|
||||
|
||||
/** Gets a tag value by name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
/** Parse imeta tag into structured object for kind 34236. */
|
||||
function parseImeta(tags: string[][]): { url?: string; thumbnail?: string } {
|
||||
const imetaTag = tags.find(([name]) => name === 'imeta');
|
||||
if (!imetaTag) return {};
|
||||
const result: Record<string, string> = {};
|
||||
for (let i = 1; i < imetaTag.length; i++) {
|
||||
const part = imetaTag[i];
|
||||
const spaceIdx = part.indexOf(' ');
|
||||
if (spaceIdx === -1) continue;
|
||||
const key = part.slice(0, spaceIdx);
|
||||
const value = part.slice(spaceIdx + 1);
|
||||
if (key === 'url') result.url = value;
|
||||
else if (key === 'image') result.thumbnail = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Encodes the NIP-19 identifier for navigating to an event. */
|
||||
function encodeEventId(event: NostrEvent): string {
|
||||
// Addressable events use naddr
|
||||
if (event.kind >= 30000 && event.kind < 40000) {
|
||||
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 });
|
||||
}
|
||||
|
||||
export function NoteCard({ event, className }: NoteCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(event.pubkey);
|
||||
@@ -40,15 +74,22 @@ export function NoteCard({ event, className }: NoteCardProps) {
|
||||
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 encodedId = useMemo(() => encodeEventId(event), [event]);
|
||||
const { data: stats } = useEventStats(event.id);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
|
||||
// Check if content is a reply
|
||||
const isReply = event.tags.some(([name]) => name === 'e');
|
||||
const replyTo = event.tags.find(([name, , , marker]) => name === 'p' && marker !== 'mention');
|
||||
const isVine = event.kind === 34236;
|
||||
|
||||
// Kind 1 specific
|
||||
const images = useMemo(() => isVine ? [] : extractImages(event.content), [event.content, isVine]);
|
||||
const isReply = !isVine && event.tags.some(([name]) => name === 'e');
|
||||
const replyTo = !isVine ? event.tags.find(([name, , , marker]) => name === 'p' && marker !== 'mention') : undefined;
|
||||
|
||||
// Kind 34236 specific
|
||||
const imeta = useMemo(() => isVine ? parseImeta(event.tags) : undefined, [event.tags, isVine]);
|
||||
const vineTitle = isVine ? getTag(event.tags, 'title') : undefined;
|
||||
const hashtags = isVine ? event.tags.filter(([n]) => n === 't').map(([, v]) => v) : [];
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -56,9 +97,9 @@ 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}`)}
|
||||
onClick={() => navigate(`/${encodedId}`)}
|
||||
>
|
||||
{/* Reply context */}
|
||||
{/* Reply context (kind 1 only) */}
|
||||
{isReply && replyTo && (
|
||||
<ReplyContext pubkey={replyTo[1]} />
|
||||
)}
|
||||
@@ -69,7 +110,7 @@ export function NoteCard({ event, className }: NoteCardProps) {
|
||||
<Avatar className="size-11">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
@@ -99,52 +140,24 @@ export function NoteCard({ event, className }: NoteCardProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Text content */}
|
||||
<div className="mt-0.5">
|
||||
<NoteContent event={event} className="text-[15px] 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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="w-full h-auto max-h-[400px] object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
{/* Body — kind-specific content */}
|
||||
{isVine ? (
|
||||
<VineBody title={vineTitle} imeta={imeta} hashtags={hashtags} />
|
||||
) : (
|
||||
<NoteBody event={event} images={images} />
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{/* Action buttons — shared across all kinds */}
|
||||
<div className="flex items-center justify-between mt-2 max-w-md -ml-2">
|
||||
{/* 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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setReplyOpen(true);
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); setReplyOpen(true); }}
|
||||
>
|
||||
<MessageCircle className="size-[18px]" />
|
||||
{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"
|
||||
@@ -154,7 +167,6 @@ export function NoteCard({ event, className }: NoteCardProps) {
|
||||
{stats?.reposts ? <span className="text-xs">{stats.reposts}</span> : null}
|
||||
</button>
|
||||
|
||||
{/* React */}
|
||||
<ReactionButton
|
||||
eventId={event.id}
|
||||
eventPubkey={event.pubkey}
|
||||
@@ -163,7 +175,6 @@ export function NoteCard({ event, className }: NoteCardProps) {
|
||||
reactionEmojis={stats?.reactionEmojis}
|
||||
/>
|
||||
|
||||
{/* 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"
|
||||
@@ -173,23 +184,16 @@ export function NoteCard({ event, className }: NoteCardProps) {
|
||||
{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={(e) => {
|
||||
e.stopPropagation();
|
||||
setMoreMenuOpen(true);
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); setMoreMenuOpen(true); }}
|
||||
>
|
||||
<MoreHorizontal className="size-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* More menu dialog */}
|
||||
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
|
||||
|
||||
{/* Reply compose modal */}
|
||||
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,6 +201,108 @@ export function NoteCard({ event, className }: NoteCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Body content for kind 1 text notes. */
|
||||
function NoteBody({ event, images }: { event: NostrEvent; images: string[] }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mt-0.5">
|
||||
<NoteContent event={event} className="text-[15px] leading-relaxed" />
|
||||
</div>
|
||||
{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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="w-full h-auto max-h-[400px] object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Body content for kind 34236 vine events. */
|
||||
function VineBody({ title, imeta, hashtags }: { title?: string; imeta?: { url?: string; thumbnail?: string }; hashtags: string[] }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
const handlePlayToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
setIsPlaying(true);
|
||||
} else {
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{title && (
|
||||
<p className="text-[15px] mt-0.5 leading-relaxed">{title}</p>
|
||||
)}
|
||||
|
||||
{imeta?.url && (
|
||||
<div
|
||||
className="relative bg-black mt-3 rounded-2xl overflow-hidden cursor-pointer"
|
||||
onClick={handlePlayToggle}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={imeta.url}
|
||||
poster={imeta.thumbnail}
|
||||
className="w-full max-h-[70vh] object-contain"
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
/>
|
||||
{!isPlaying && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<div className="size-14 rounded-full bg-black/60 flex items-center justify-center backdrop-blur-sm">
|
||||
<Play className="size-7 text-white ml-1" fill="white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{hashtags.slice(0, 5).map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/t/${encodeURIComponent(tag)}`}
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
#{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyContext({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const name = author.data?.metadata?.name || genUserName(pubkey);
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Play, Heart, MessageCircle, Repeat2, MoreHorizontal } from 'lucide-react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useMemo, useState, useRef } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
interface VineCardProps {
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Parse imeta tag into a structured object. */
|
||||
function parseImeta(tags: string[][]): { url?: string; mime?: string; thumbnail?: string; dim?: string; blurhash?: string } {
|
||||
const imetaTag = tags.find(([name]) => name === 'imeta');
|
||||
if (!imetaTag) return {};
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (let i = 1; i < imetaTag.length; i++) {
|
||||
const part = imetaTag[i];
|
||||
const spaceIdx = part.indexOf(' ');
|
||||
if (spaceIdx === -1) continue;
|
||||
const key = part.slice(0, spaceIdx);
|
||||
const value = part.slice(spaceIdx + 1);
|
||||
// Map imeta keys
|
||||
if (key === 'url') result.url = value;
|
||||
else if (key === 'm') result.mime = value;
|
||||
else if (key === 'image') result.thumbnail = value;
|
||||
else if (key === 'dim') result.dim = value;
|
||||
else if (key === 'blurhash') result.blurhash = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
export function VineCard({ event, className }: VineCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
|
||||
|
||||
const imeta = useMemo(() => parseImeta(event.tags), [event.tags]);
|
||||
const title = getTag(event.tags, 'title');
|
||||
const hashtags = event.tags.filter(([n]) => n === 't').map(([, v]) => v);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
const handlePlayToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
setIsPlaying(true);
|
||||
} else {
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const neventId = useMemo(() => {
|
||||
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 });
|
||||
}, [event]);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
'border-b border-border hover:bg-secondary/30 transition-colors',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Author header */}
|
||||
<div className="flex items-center gap-3 px-4 pt-3 pb-2">
|
||||
<Link to={`/${npub}`} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar className="size-10">
|
||||
<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">
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Link
|
||||
to={`/${npub}`}
|
||||
className="font-bold hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{displayName}
|
||||
</Link>
|
||||
<span className="text-muted-foreground shrink-0">·</span>
|
||||
<span className="text-muted-foreground shrink-0">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
{title && (
|
||||
<p className="text-[15px] mt-0.5 line-clamp-2">{title}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video */}
|
||||
{imeta.url && (
|
||||
<div
|
||||
className="relative bg-black cursor-pointer"
|
||||
onClick={handlePlayToggle}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={imeta.url}
|
||||
poster={imeta.thumbnail}
|
||||
className="w-full max-h-[70vh] object-contain"
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
/>
|
||||
{/* Play overlay */}
|
||||
{!isPlaying && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<div className="size-14 rounded-full bg-black/60 flex items-center justify-center backdrop-blur-sm">
|
||||
<Play className="size-7 text-white ml-1" fill="white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hashtags */}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-4 pt-2">
|
||||
{hashtags.slice(0, 5).map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/t/${encodeURIComponent(tag)}`}
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
#{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="h-3" />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MainLayout } from '@/components/MainLayout';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { VineCard } from '@/components/VineCard';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -151,9 +150,7 @@ export function SearchPage() {
|
||||
) : posts.length > 0 ? (
|
||||
<div>
|
||||
{posts.map((event) => (
|
||||
event.kind === 34236
|
||||
? <VineCard key={event.id} event={event} />
|
||||
: <NoteCard key={event.id} event={event} />
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
) : searchQuery.trim() ? (
|
||||
|
||||
+13
-9
@@ -2,7 +2,7 @@ import { useSeoMeta } from '@unhead/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MainLayout } from '@/components/MainLayout';
|
||||
import { VineCard } from '@/components/VineCard';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useStreamVines } from '@/hooks/useStreamVines';
|
||||
|
||||
@@ -35,7 +35,7 @@ export function VinesPage() {
|
||||
) : vines.length > 0 ? (
|
||||
<div>
|
||||
{vines.map((event) => (
|
||||
<VineCard key={event.id} event={event} />
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -50,19 +50,23 @@ export function VinesPage() {
|
||||
|
||||
function VineSkeleton() {
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="flex items-center gap-3 px-4 pt-3 pb-2">
|
||||
<Skeleton className="size-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="w-full h-56 rounded-2xl" />
|
||||
<div className="flex gap-12 mt-2">
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="w-full h-64" />
|
||||
<div className="h-3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user