chore(UI): moar raw

This commit is contained in:
Danish Arora 2025-11-14 14:02:27 -05:00
parent 82cf351920
commit 78ff8b537b
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
12 changed files with 447 additions and 470 deletions

View File

@ -85,140 +85,112 @@ const CommentCard: React.FC<CommentCardProps> = ({
};
return (
<div className="border border-border rounded-none p-2 sm:p-4 bg-transparent">
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4">
<div className="flex flex-row sm:flex-col items-center justify-between sm:justify-start gap-2 sm:gap-2 w-full sm:w-auto border-b sm:border-b-0 sm:border-r border-border/60 pb-2 sm:pb-0 sm:pr-4">
<div className="flex flex-row sm:flex-col items-center gap-2">
<button
className={`p-1.5 sm:p-1 border border-transparent hover:border-border touch-manipulation ${
userUpvoted
? 'text-primary'
: 'text-muted-foreground hover:text-primary'
}`}
onClick={() => handleVoteComment(true)}
disabled={!permissions.canVote}
title={
permissions.canVote ? 'Upvote comment' : permissions.reasons.vote
}
>
<ArrowUp className="w-3 h-3 sm:w-4 sm:h-4" />
</button>
<span className="text-xs sm:text-sm font-semibold text-foreground min-w-[20px] sm:min-w-[24px] text-center">{score}</span>
<button
className={`p-1.5 sm:p-1 border border-transparent hover:border-border touch-manipulation ${
userDownvoted
? 'text-blue-400'
: 'text-muted-foreground hover:text-blue-400'
}`}
onClick={() => handleVoteComment(false)}
disabled={!permissions.canVote}
title={
permissions.canVote
? 'Downvote comment'
: permissions.reasons.vote
}
>
<ArrowDown className="w-3 h-3 sm:w-4 sm:h-4" />
</button>
</div>
{commentVotePending && (
<span className="text-[9px] sm:text-[10px] text-yellow-400 sm:mt-1 whitespace-nowrap">
syncing
</span>
)}
<div className="border-b border-border/20 py-3 pl-3 pr-2">
<div className="flex gap-3">
{/* Vote column */}
<div className="flex flex-col items-center gap-0.5 text-xs min-w-[40px]">
<button
className={`hover:text-primary ${
userUpvoted ? 'text-primary' : 'text-muted-foreground'
}`}
onClick={() => handleVoteComment(true)}
disabled={!permissions.canVote}
title={
permissions.canVote ? 'Upvote comment' : permissions.reasons.vote
}
>
</button>
<span className={`font-mono text-xs ${score > 0 ? 'text-primary' : score < 0 ? 'text-red-400' : 'text-muted-foreground'}`}>
{score}
</span>
<button
className={`hover:text-blue-400 ${
userDownvoted ? 'text-blue-400' : 'text-muted-foreground'
}`}
onClick={() => handleVoteComment(false)}
disabled={!permissions.canVote}
title={
permissions.canVote
? 'Downvote comment'
: permissions.reasons.vote
}
>
</button>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs text-muted-foreground">
<AuthorDisplay
address={comment.author}
className="text-[10px] sm:text-xs truncate"
showBadge={false}
/>
<span className="hidden sm:inline"></span>
<Clock className="w-3 h-3 flex-shrink-0" />
<span className="normal-case tracking-normal text-foreground text-[10px] sm:text-xs">
{formatDistanceToNow(new Date(comment.timestamp), {
addSuffix: true,
})}
</span>
<PendingBadge id={comment.id} />
</div>
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<ShareButton
size="sm"
url={`${window.location.origin}/post/${postId}#comment-${comment.id}`}
title={
comment.content.substring(0, 50) +
(comment.content.length > 50 ? '...' : '')
}
/>
<BookmarkButton
isBookmarked={isBookmarked}
loading={bookmarkLoading}
onClick={handleBookmark}
size="sm"
variant="ghost"
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0 text-xs">
{/* Metadata */}
<div className="flex flex-wrap items-center gap-1 text-[11px] text-muted-foreground mb-2">
<AuthorDisplay
address={comment.author}
className="text-[11px]"
showBadge={false}
/>
<span>·</span>
<span className="text-muted-foreground/80">
{formatDistanceToNow(new Date(comment.timestamp), {
addSuffix: true,
})}
</span>
{commentVotePending && (
<>
<span>·</span>
<span className="text-yellow-400 text-[10px]">syncing</span>
</>
)}
</div>
<div className="text-xs sm:text-sm break-words mb-2 sm:mb-3 prose prose-invert max-w-none">
{/* Content */}
<div className="text-xs text-foreground leading-relaxed mb-2 prose prose-invert max-w-none">
<MarkdownRenderer content={comment.content} />
</div>
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
{/* Actions */}
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
<button
onClick={handleBookmark}
disabled={bookmarkLoading}
className="hover:underline"
>
{isBookmarked ? 'unsave' : 'save'}
</button>
<ShareButton
size="sm"
url={`${window.location.origin}/post/${postId}#comment-${comment.id}`}
title={
comment.content.substring(0, 50) +
(comment.content.length > 50 ? '...' : '')
}
/>
{canModerate && !isModerated && !isOwnComment && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 sm:h-7 sm:w-7 text-muted-foreground hover:text-orange-500 touch-manipulation"
onClick={() => onModerateComment(comment.id)}
>
<MessageSquareX className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Moderate comment</p>
</TooltipContent>
</Tooltip>
<button
onClick={() => onModerateComment(comment.id)}
className="hover:underline text-orange-400"
title="Moderate comment"
>
moderate
</button>
)}
{canModerate && isModerated && !isOwnComment && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 sm:h-7 px-2 text-[10px] sm:text-[11px] text-muted-foreground hover:text-green-500 touch-manipulation"
onClick={() => onUnmoderateComment?.(comment.id)}
>
Unmoderate
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Unmoderate comment</p>
</TooltipContent>
</Tooltip>
<button
onClick={() => onUnmoderateComment?.(comment.id)}
className="hover:underline text-green-400"
title="Unmoderate comment"
>
unmoderate
</button>
)}
{cellId && canModerate && !isOwnComment && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 sm:h-7 sm:w-7 text-muted-foreground hover:text-red-500 touch-manipulation"
onClick={() => onModerateUser(comment.author)}
>
<UserX className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Moderate user</p>
</TooltipContent>
</Tooltip>
<button
onClick={() => onModerateUser(comment.author)}
className="hover:underline text-red-400"
title="Moderate user"
>
ban user
</button>
)}
</div>
</div>

View File

@ -48,7 +48,7 @@ const Footer: React.FC = () => {
</nav>
<div className="text-[9px] sm:text-[10px] uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground text-center">
Licensed under CC-BY-SA
Reference client · build your own with @opchan/core
</div>
</div>
</div>

View File

@ -345,6 +345,21 @@ const Header = () => {
</div>
</div>
{/* Builder Banner */}
<div className="mt-1 mb-1 flex items-center justify-between gap-2 text-[10px] sm:text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
<span className="truncate">
Reference client on top of @opchan/core. UI is intentionally bare.
</span>
<a
href="https://github.com/waku-org/opchan"
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 text-primary hover:underline"
>
Build your own
</a>
</div>
{/* Navigation Bar (Desktop) */}
<div className="hidden md:flex items-center justify-center border-t border-border py-2">
<nav className="flex items-center space-x-0.5 text-[11px] uppercase tracking-[0.2em]">

View File

@ -72,135 +72,99 @@ const PostCard: React.FC<PostCardProps> = ({ post }) => {
};
return (
<div className="thread-card mb-2">
<div className="flex flex-col sm:flex-row">
{/* Voting column */}
<div className="flex sm:flex-col flex-row items-center justify-between sm:justify-start gap-2 sm:gap-2 p-2 sm:p-2 border-b sm:border-b-0 sm:border-r border-border/60 bg-transparent sm:w-auto w-full">
<div className="flex sm:flex-col items-center gap-2">
<button
className={`p-1.5 sm:p-1 border border-transparent hover:border-border touch-manipulation ${
userUpvoted
? 'text-primary'
: 'text-muted-foreground hover:text-primary'
}`}
onClick={e => handleVote(e, true)}
disabled={!permissions.canVote}
title={permissions.canVote ? 'Upvote' : permissions.reasons.vote}
>
<ArrowUp className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
<span className="text-sm font-semibold text-foreground min-w-[24px] text-center">
{score}
</span>
<button
className={`p-1.5 sm:p-1 border border-transparent hover:border-border touch-manipulation ${
userDownvoted
? 'text-blue-400'
: 'text-muted-foreground hover:text-blue-400'
}`}
onClick={e => handleVote(e, false)}
disabled={!permissions.canVote}
title={permissions.canVote ? 'Downvote' : permissions.reasons.vote}
>
<ArrowDown className="w-4 h-4 sm:w-5 sm:h-5" />
</button>
</div>
{isPending && (
<span className="text-[9px] sm:text-[10px] text-yellow-400 sm:mt-1">syncing</span>
)}
<div className="border-b border-border/30 py-3 px-2 hover:bg-border/5">
<div className="flex gap-3">
{/* Vote column - compact */}
<div className="flex flex-col items-center gap-0.5 text-xs min-w-[40px]">
<button
className={`hover:text-primary ${
userUpvoted ? 'text-primary' : 'text-muted-foreground'
}`}
onClick={e => handleVote(e, true)}
disabled={!permissions.canVote}
title={permissions.canVote ? 'Upvote' : permissions.reasons.vote}
>
</button>
<span className={`font-mono text-xs ${score > 0 ? 'text-primary' : score < 0 ? 'text-red-400' : 'text-muted-foreground'}`}>
{score}
</span>
<button
className={`hover:text-blue-400 ${
userDownvoted ? 'text-blue-400' : 'text-muted-foreground'
}`}
onClick={e => handleVote(e, false)}
disabled={!permissions.canVote}
title={permissions.canVote ? 'Downvote' : permissions.reasons.vote}
>
</button>
</div>
{/* Content column */}
<div className="flex-1 p-2 sm:p-3 min-w-0">
<div className="space-y-3">
{/* Post metadata */}
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 text-[10px] sm:text-[11px] uppercase tracking-[0.1em] sm:tracking-[0.12em] text-muted-foreground">
<Link
to={cellName ? `/cell/${post.cellId}` : '#'}
className="text-primary hover:underline truncate"
tabIndex={0}
onClick={e => {
if (!cellName) e.preventDefault();
}}
title={cellName ? `Go to /${cellName}` : undefined}
>
r/{cellName || 'unknown'}
</Link>
<span className="hidden sm:inline"></span>
<span className="hidden sm:inline">Posted by u/</span>
<span className="sm:hidden">u/</span>
<AuthorDisplay
address={post.author}
className="text-[10px] sm:text-xs truncate"
showBadge={false}
/>
<span className="opacity-50 hidden sm:inline">/</span>
<span className="normal-case tracking-normal text-foreground text-[10px] sm:text-[11px]">
{formatDistanceToNow(new Date(post.timestamp), {
addSuffix: true,
})}
</span>
{'relevanceScore' in post &&
typeof (post as Post).relevanceScore === 'number' && (
<>
<span className="opacity-50 hidden sm:inline">/</span>
<RelevanceIndicator
score={(post as Post).relevanceScore as number}
details={
'relevanceDetails' in post
? (post as Post).relevanceDetails
: undefined
}
type="post"
className="text-[10px] sm:text-[11px]"
showTooltip={true}
/>
</>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 text-xs">
{/* Title */}
<Link to={`/post/${post.id}`} className="block mb-1">
<h2 className="text-sm font-semibold text-foreground hover:underline break-words">
{post.title}
</h2>
</Link>
{/* Post title and content - clickable to navigate to post */}
<div className="block">
<Link to={`/post/${post.id}`} className="block">
<h2 className="text-sm sm:text-base font-semibold text-foreground break-words">
{post.title}
</h2>
</Link>
{/* Metadata line */}
<div className="flex flex-wrap items-center gap-1 text-[11px] text-muted-foreground mb-2">
<Link
to={cellName ? `/cell/${post.cellId}` : '#'}
className="text-primary hover:underline"
onClick={e => {
if (!cellName) e.preventDefault();
}}
>
r/{cellName}
</Link>
<span>·</span>
<AuthorDisplay
address={post.author}
className="text-[11px]"
showBadge={false}
/>
<span>·</span>
<span className="text-muted-foreground/80">
{formatDistanceToNow(new Date(post.timestamp), {
addSuffix: true,
})}
</span>
{isPending && (
<>
<span>·</span>
<span className="text-yellow-400 text-[10px]">syncing</span>
</>
)}
</div>
{/* Post content preview */}
<p className="text-muted-foreground text-xs sm:text-sm leading-relaxed mt-1 sm:mt-2 break-words">
<LinkRenderer text={contentPreview} />
</p>
</div>
{/* Content preview */}
{contentPreview && (
<p className="text-muted-foreground text-xs leading-relaxed mb-2">
<LinkRenderer text={contentPreview} />
</p>
)}
{/* Post actions */}
<div className="flex flex-wrap items-center justify-between gap-2 sm:gap-4 text-[10px] sm:text-[11px] uppercase tracking-[0.12em] sm:tracking-[0.15em] text-muted-foreground mt-2 sm:mt-3">
<div className="flex items-center flex-wrap gap-2 sm:gap-4">
<div className="flex items-center space-x-1 hover:text-foreground">
<MessageSquare className="w-3 h-3 sm:w-4 sm:h-4" />
<span>{commentCount} comments</span>
</div>
{isPending && (
<span className="px-1.5 sm:px-2 py-0.5 border border-yellow-500 text-yellow-400 text-[9px] sm:text-[10px]">
syncing
</span>
)}
<ShareButton
size="sm"
url={`${window.location.origin}/post/${post.id}`}
title={post.title}
/>
</div>
<BookmarkButton
isBookmarked={isBookmarked}
loading={bookmarkLoading}
onClick={handleBookmark}
size="sm"
variant="ghost"
/>
</div>
{/* Actions */}
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
<Link to={`/post/${post.id}`} className="hover:underline">
{commentCount} {commentCount === 1 ? 'reply' : 'replies'}
</Link>
<button
onClick={handleBookmark}
disabled={bookmarkLoading}
className="hover:underline"
>
{isBookmarked ? 'unsave' : 'save'}
</button>
<ShareButton
size="sm"
url={`${window.location.origin}/post/${post.id}`}
title={post.title}
/>
</div>
</div>
</div>

View File

@ -151,67 +151,98 @@ const PostDetail = () => {
return (
<div className="container mx-auto px-3 sm:px-4 py-4 sm:py-6 max-w-5xl">
<div className="mb-4 sm:mb-6">
<Button
<button
onClick={() => navigate(`/cell/${post.cellId}`)}
variant="ghost"
size="sm"
className="mb-3 sm:mb-4 text-[10px] sm:text-[11px] px-2 sm:px-3"
className="mb-3 text-xs text-primary hover:underline"
>
<ArrowLeft className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
<span className="hidden sm:inline">Back to /{cell?.name || 'cell'}/</span>
<span className="sm:hidden">BACK</span>
</Button>
r/{cell?.name || 'cell'}
</button>
<div className="border border-border rounded-none p-2 sm:p-3 mb-4 sm:mb-6">
<div className="flex flex-col sm:flex-row gap-3 sm:gap-3">
<div className="flex flex-row sm:flex-col items-center justify-between sm:justify-start gap-2 sm:gap-1 w-full sm:w-auto border-b sm:border-b-0 sm:border-r border-border/60 pb-3 sm:pb-0 sm:pr-3">
<div className="flex flex-row sm:flex-col items-center gap-2 sm:gap-1">
<button
className={`p-1.5 sm:p-1 border border-transparent hover:border-border touch-manipulation ${
isPostUpvoted
? 'text-primary'
: 'text-muted-foreground hover:text-primary'
}`}
onClick={() => handleVotePost(true)}
disabled={!permissions.canVote}
title={
permissions.canVote ? 'Upvote post' : permissions.reasons.vote
}
<div className="border-b border-border/50 pb-4 mb-4">
<div className="flex gap-3">
{/* Vote column */}
<div className="flex flex-col items-center gap-0.5 text-xs min-w-[40px]">
<button
className={`hover:text-primary ${
isPostUpvoted ? 'text-primary' : 'text-muted-foreground'
}`}
onClick={() => handleVotePost(true)}
disabled={!permissions.canVote}
title={
permissions.canVote ? 'Upvote post' : permissions.reasons.vote
}
>
</button>
<span className={`font-mono text-xs ${score > 0 ? 'text-primary' : score < 0 ? 'text-red-400' : 'text-muted-foreground'}`}>
{score}
</span>
<button
className={`hover:text-blue-400 ${
isPostDownvoted ? 'text-blue-400' : 'text-muted-foreground'
}`}
onClick={() => handleVotePost(false)}
disabled={!permissions.canVote}
title={
permissions.canVote
? 'Downvote post'
: permissions.reasons.vote
}
>
</button>
</div>
{/* Content */}
<div className="flex-1 min-w-0 text-xs">
{/* Title */}
<h1 className="text-base sm:text-lg font-bold text-foreground mb-2">{post.title}</h1>
{/* Metadata */}
<div className="flex flex-wrap items-center gap-1 text-[11px] text-muted-foreground mb-3">
<Link
to={cell?.id ? `/cell/${cell.id}` : "#"}
className="text-primary hover:underline"
onClick={e => {
if (!cell?.id) e.preventDefault();
}}
>
<ArrowUp className="w-4 h-4 sm:w-4 sm:h-4" />
</button>
<span className="text-sm font-semibold text-foreground min-w-[24px] text-center">{score}</span>
<button
className={`p-1.5 sm:p-1 border border-transparent hover:border-border touch-manipulation ${
isPostDownvoted
? 'text-blue-400'
: 'text-muted-foreground hover:text-blue-400'
}`}
onClick={() => handleVotePost(false)}
disabled={!permissions.canVote}
title={
permissions.canVote
? 'Downvote post'
: permissions.reasons.vote
}
>
<ArrowDown className="w-4 h-4 sm:w-4 sm:h-4" />
</button>
</div>
{postVotePending && (
<span className="text-[9px] sm:text-[10px] text-yellow-400 sm:mt-0.5 whitespace-nowrap">
syncing
</span>
)}
<div className="flex flex-row sm:flex-col items-center gap-1 sm:gap-1 mt-0 sm:mt-1">
<BookmarkButton
isBookmarked={isBookmarked}
loading={bookmarkLoading}
onClick={handleBookmark}
size="sm"
variant="ghost"
showText={false}
r/{cell?.name || 'unknown'}
</Link>
<span>·</span>
<AuthorDisplay
address={post.author}
className="text-[11px]"
showBadge={false}
/>
<span>·</span>
<span className="text-muted-foreground/80">
{formatDistanceToNow(new Date(post.timestamp), {
addSuffix: true,
})}
</span>
{postPending && (
<>
<span>·</span>
<span className="text-yellow-400 text-[10px]">syncing</span>
</>
)}
</div>
{/* Content */}
<div className="text-xs sm:text-sm text-foreground leading-relaxed mb-3 prose prose-invert max-w-none">
<MarkdownRenderer content={post.content} />
</div>
{/* Actions */}
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
<button
onClick={handleBookmark}
disabled={bookmarkLoading}
className="hover:underline"
>
{isBookmarked ? 'unsave' : 'save'}
</button>
<ShareButton
size="sm"
url={`${window.location.origin}/post/${post.id}`}
@ -219,82 +250,32 @@ const PostDetail = () => {
/>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs text-muted-foreground mb-1.5 sm:mb-2">
<Link
to={cell?.id ? `/cell/${cell.id}` : "#"}
className="font-medium text-primary hover:underline focus:underline truncate"
tabIndex={0}
onClick={e => {
if (!cell?.id) e.preventDefault();
}}
title={cell?.name ? `Go to /${cell.name}` : undefined}
>
r/{cell?.name || 'unknown'}
</Link>
<span className="hidden sm:inline"></span>
<span className="hidden sm:inline">Posted by u/</span>
<span className="sm:hidden">u/</span>
<AuthorDisplay
address={post.author}
className="text-[10px] sm:text-xs truncate"
showBadge={false}
/>
<span className="hidden sm:inline"></span>
<Clock className="w-3 h-3 flex-shrink-0" />
<span className="normal-case tracking-normal text-foreground text-[10px] sm:text-xs">
{formatDistanceToNow(new Date(post.timestamp), {
addSuffix: true,
})}
</span>
{/* Relevance details unavailable in raw PostMessage; skip indicator */}
{postPending && (
<>
<span className="hidden sm:inline"></span>
<span className="px-1.5 sm:px-2 py-0.5 border border-yellow-500 text-yellow-400 text-[9px] sm:text-[10px]">
syncing
</span>
</>
)}
</div>
<h1 className="text-lg sm:text-xl md:text-2xl font-bold break-words text-foreground mb-2 sm:mb-3">{post.title}</h1>
<div className="text-xs sm:text-sm break-words prose prose-invert max-w-none">
<MarkdownRenderer content={post.content} />
</div>
</div>
</div>
</div>
</div>
{/* Comment Form */}
{permissions.canComment && (
<div className="mb-6 sm:mb-8">
<div className="mb-6">
<form onSubmit={handleCreateComment} onKeyDown={handleKeyDown}>
<h2 className="text-xs sm:text-sm font-bold mb-2 sm:mb-3 flex items-center gap-1 uppercase tracking-[0.15em] sm:tracking-[0.2em]">
<MessageCircle className="w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" />
<span>Add a comment</span>
</h2>
<div className="text-xs font-semibold mb-2 text-foreground">Reply:</div>
<MarkdownInput
placeholder="What are your thoughts?"
value={newComment}
onChange={setNewComment}
disabled={false}
minHeight={100}
initialHeight={120}
minHeight={80}
initialHeight={100}
maxHeight={600}
/>
<div className="mt-2 sm:mt-3 flex justify-end">
<Button
<div className="mt-2 flex justify-end">
<button
type="submit"
disabled={!permissions.canComment}
className="text-primary border-primary hover:bg-primary/10 text-[10px] sm:text-[11px] px-3 sm:px-4"
className="text-xs text-primary hover:underline"
>
<Send className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />
<span className="hidden sm:inline">Post Comment</span>
<span className="sm:hidden">POST</span>
</Button>
post
</button>
</div>
</form>
</div>
@ -308,30 +289,22 @@ const PostDetail = () => {
)}
{!permissions.canComment && (
<div className="mb-4 sm:mb-6 p-3 sm:p-4 border border-border rounded-none bg-transparent text-center">
<p className="text-xs sm:text-sm mb-2 sm:mb-3 text-muted-foreground">Connect your wallet to comment</p>
<Button asChild size="sm" className="text-[10px] sm:text-[11px] px-3 sm:px-4">
<Link to="/">Connect Wallet</Link>
</Button>
<div className="mb-4 p-3 border-t border-b border-border/30 text-xs text-muted-foreground">
<Link to="/" className="text-primary hover:underline">Connect wallet</Link> to comment
</div>
)}
{/* Comments */}
<div className="space-y-3 sm:space-y-4">
<h2 className="text-base sm:text-lg font-bold flex items-center gap-2 uppercase tracking-[0.2em] sm:tracking-[0.25em]">
<MessageCircle className="w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0" />
<span>Comments ({visibleComments.length})</span>
</h2>
<div className="mt-6">
<div className="text-sm font-semibold mb-3 text-foreground">
{visibleComments.length} {visibleComments.length === 1 ? 'reply' : 'replies'}
</div>
{visibleComments.length === 0 ? (
<div className="text-center py-6 sm:py-8">
<MessageCircle className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 sm:mb-4 text-muted-foreground opacity-50" />
<h3 className="text-base sm:text-lg font-bold mb-2 uppercase tracking-[0.2em] sm:tracking-[0.25em]">No comments yet</h3>
<p className="text-xs sm:text-sm text-muted-foreground px-4">
{permissions.canComment
? 'Be the first to share your thoughts!'
: 'Connect your wallet to join the conversation.'}
</p>
<div className="text-xs text-muted-foreground py-4">
{permissions.canComment
? 'No replies yet'
: 'Connect your wallet to reply'}
</div>
) : (
visibleComments.map(comment => (

View File

@ -1,5 +1,3 @@
import { Button } from '@/components/ui/button';
import { Share2 } from 'lucide-react';
import { cn } from '../../utils';
import { useToast } from '../ui/use-toast';
@ -15,23 +13,10 @@ interface ShareButtonProps {
export function ShareButton({
url,
size = 'sm',
variant = 'ghost',
className,
showText = false,
}: ShareButtonProps) {
const { toast } = useToast();
const sizeClasses = {
sm: 'h-8 w-10 flex-shrink-0',
lg: 'h-10 whitespace-nowrap px-4',
};
const iconSize = {
sm: 14,
lg: 18,
};
const handleShare = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@ -39,8 +24,8 @@ export function ShareButton({
try {
await navigator.clipboard.writeText(url);
toast({
title: 'Link copied!',
description: 'Link has been copied to your clipboard.',
title: 'Link copied',
description: 'Link copied to clipboard',
});
} catch {
// Fallback for older browsers
@ -52,26 +37,19 @@ export function ShareButton({
document.body.removeChild(textArea);
toast({
title: 'Link copied!',
description: 'Link has been copied to your clipboard.',
title: 'Link copied',
description: 'Link copied to clipboard',
});
}
};
return (
<Button
variant={variant}
size={size}
<button
onClick={handleShare}
className={cn(
sizeClasses[size],
'transition-colors duration-200 text-cyber-neutral hover:text-cyber-light',
className
)}
className={cn('hover:underline', className)}
title="Copy link"
>
<Share2 size={iconSize[size]} />
{showText && <span className="ml-2 text-xs">Share</span>}
</Button>
share
</button>
);
}

View File

@ -0,0 +1,35 @@
import React from 'react';
interface GreentextRendererProps {
text: string;
className?: string;
}
/**
* Renders text with greentext support (lines starting with > are colored green)
* 4chan-style formatting
*/
export const GreentextRenderer: React.FC<GreentextRendererProps> = ({
text,
className = ''
}) => {
const lines = text.split('\n');
return (
<div className={className}>
{lines.map((line, index) => {
const isGreentext = line.trim().startsWith('>');
return (
<div
key={index}
className={isGreentext ? 'text-green-400' : ''}
>
{line || '\u00A0'} {/* Non-breaking space for empty lines */}
</div>
);
})}
</div>
);
};

View File

@ -9,7 +9,8 @@ interface MarkdownRendererProps {
}
/**
* Renders sanitized Markdown with GFM support.
* Renders sanitized Markdown with GFM support and 4chan-style greentext.
* Lines starting with > are rendered in green.
*/
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
@ -45,6 +46,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
'td',
'a',
'img',
'span',
],
attributes: {
...defaultSchema.attributes,
@ -61,16 +63,53 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
['title'],
],
code: [...(defaultSchema.attributes?.code || []), ['className']],
span: [['className']],
},
};
// Preprocess content to wrap greentext lines (lines starting with >) in special markers
// We'll handle this by checking for lines that start with > but aren't markdown quotes (>>)
const processedContent = content
.split('\n')
.map(line => {
// Check if line starts with > but not >> (markdown quote)
// and not a quote block (which would be > followed by space typically)
const trimmedLine = line.trim();
if (trimmedLine.startsWith('>') && !trimmedLine.startsWith('>>')) {
// Wrap in a span with greentext class
return `<span class="greentext">${line}</span>`;
}
return line;
})
.join('\n');
return (
<div className={className}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeSanitize, schema]]}
components={{
p: ({ children, ...props }) => {
// Check if this paragraph contains greentext
const childText = String(children);
if (childText.trim().startsWith('>')) {
return (
<p {...props} className="text-green-400 my-1">
{children}
</p>
);
}
return <p {...props}>{children}</p>;
},
}}
>
{content || ''}
</ReactMarkdown>
<style>{`
.greentext {
color: rgb(74 222 128);
}
`}</style>
</div>
);
};

View File

@ -100,14 +100,14 @@
}
.text-glow {
text-transform: uppercase;
letter-spacing: 0.25em;
text-transform: none;
letter-spacing: 0.02em;
text-shadow: none;
}
.text-glow-subtle {
text-transform: uppercase;
letter-spacing: 0.12em;
text-transform: none;
letter-spacing: 0.01em;
text-shadow: none;
}
@ -128,7 +128,7 @@
}
.btn {
@apply inline-flex items-center justify-between border border-border px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground transition-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-60;
@apply inline-flex items-center justify-between border border-border px-3 py-2 text-xs font-normal text-foreground transition-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-60;
}
}
@ -143,11 +143,11 @@
}
.page-title {
@apply text-sm sm:text-base font-semibold uppercase tracking-[0.25em] sm:tracking-[0.3em] text-foreground mb-1;
@apply text-sm sm:text-base font-semibold text-foreground mb-1;
}
.page-subtitle {
@apply text-[10px] sm:text-xs uppercase tracking-[0.15em] sm:tracking-[0.2em] text-muted-foreground;
@apply text-[10px] sm:text-xs text-muted-foreground;
}
.page-content {
@ -159,7 +159,7 @@
}
.page-footer {
@apply border-t border-border py-2 sm:py-3 text-center text-[10px] uppercase tracking-[0.2em] text-muted-foreground px-3;
@apply border-t border-border py-2 sm:py-3 text-center text-[10px] text-muted-foreground px-3;
}
/* Card Components */
@ -219,7 +219,7 @@
/* Button Variants */
.btn-cyber {
@apply border border-border bg-transparent text-foreground uppercase tracking-[0.2em] text-[11px] hover:bg-white/10;
@apply border border-border bg-transparent text-foreground text-xs hover:bg-white/10;
}
.btn-cyber-outline {
@ -263,7 +263,7 @@
}
.empty-state-title {
@apply text-base sm:text-lg md:text-xl font-mono font-bold text-white mb-2 tracking-[0.2em] sm:tracking-[0.25em];
@apply text-base sm:text-lg md:text-xl font-mono font-bold text-white mb-2;
}
.empty-state-description {
@ -272,7 +272,7 @@
}
.cyberpunk-glow {
text-transform: uppercase;
letter-spacing: 0.2em;
text-transform: none;
letter-spacing: 0.02em;
text-shadow: none;
}

View File

@ -150,12 +150,13 @@ const FeedPage: React.FC = () => {
No posts yet
</h3>
<p className="empty-state-description">
Be the first to create a post in a cell!
This is a reference UI built on top of @opchan/core.
Swap this screen with your own feed layout.
</p>
{verificationStatus !==
EVerificationStatus.ENS_VERIFIED && (
<p className="text-xs sm:text-sm text-cyber-neutral/80">
Connect your wallet to start posting
Connect your wallet to try the reference client or fork it and build your own.
</p>
)}
</div>

88
package-lock.json generated
View File

@ -8014,17 +8014,17 @@
}
},
"node_modules/@waku/core": {
"version": "0.0.40-ff9c430.0",
"resolved": "https://registry.npmjs.org/@waku/core/-/core-0.0.40-ff9c430.0.tgz",
"integrity": "sha512-+ZY3OgAcIvazZrwX31lh3RDDtye/m4+WPyqNqrMEG+Ybd88ZJH8svdF9BxldPOltDBkM/5h2bsWoon6jLuGefw==",
"version": "0.0.41-339e26e.0",
"resolved": "https://registry.npmjs.org/@waku/core/-/core-0.0.41-339e26e.0.tgz",
"integrity": "sha512-SgD/ne6F9ib8P71xUsohSdHAIAcU6HyQGvyzPKJXu60szLbh6dBXb/nouln9druEzxjULOYosBYMSP9cv5feFA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@libp2p/ping": "2.0.35",
"@noble/hashes": "^1.3.2",
"@waku/enr": "0.0.34-ff9c430.0",
"@waku/interfaces": "0.0.35-ff9c430.0",
"@waku/proto": "0.0.15-ff9c430.0",
"@waku/utils": "0.0.28-ff9c430.0",
"@waku/enr": "0.0.34-339e26e.0",
"@waku/interfaces": "0.0.35-339e26e.0",
"@waku/proto": "0.0.16-339e26e.0",
"@waku/utils": "0.0.28-339e26e.0",
"debug": "^4.3.4",
"it-all": "^3.0.4",
"it-length-prefixed": "^9.0.4",
@ -8079,16 +8079,16 @@
}
},
"node_modules/@waku/discovery": {
"version": "0.0.13-ff9c430.0",
"resolved": "https://registry.npmjs.org/@waku/discovery/-/discovery-0.0.13-ff9c430.0.tgz",
"integrity": "sha512-FGmiaXawE9JxP2VTwVbFC8Qiafh8p1t4ahcDu6Rq0aWh5mNS0qVZj0vDCUMy3HjB9CtHO6+M6g/1u6FPYd9mXQ==",
"version": "0.0.14-339e26e.0",
"resolved": "https://registry.npmjs.org/@waku/discovery/-/discovery-0.0.14-339e26e.0.tgz",
"integrity": "sha512-MKknncK6gs/Y+g58tFxkifbOITLvy+RvLHn/eLnXQXLX0r+hs6j820wTd3HivsqlZ9xXjbxI5h/Mvus9Ycefrw==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@waku/core": "0.0.40-ff9c430.0",
"@waku/enr": "0.0.34-ff9c430.0",
"@waku/interfaces": "0.0.35-ff9c430.0",
"@waku/proto": "0.0.15-ff9c430.0",
"@waku/utils": "0.0.28-ff9c430.0",
"@waku/core": "0.0.41-339e26e.0",
"@waku/enr": "0.0.34-339e26e.0",
"@waku/interfaces": "0.0.35-339e26e.0",
"@waku/proto": "0.0.16-339e26e.0",
"@waku/utils": "0.0.28-339e26e.0",
"debug": "^4.3.4",
"dns-over-http-resolver": "^3.0.8",
"hi-base32": "^0.5.1",
@ -8099,9 +8099,9 @@
}
},
"node_modules/@waku/enr": {
"version": "0.0.34-ff9c430.0",
"resolved": "https://registry.npmjs.org/@waku/enr/-/enr-0.0.34-ff9c430.0.tgz",
"integrity": "sha512-bJc3IJ9b17B4nAa8S6n4a66fvx2eblyaS0MtzGqEcTTtEr+c//GScH8Xm/2oZ4bdyf/dm6iP1/ZeApvD9Wo+XQ==",
"version": "0.0.34-339e26e.0",
"resolved": "https://registry.npmjs.org/@waku/enr/-/enr-0.0.34-339e26e.0.tgz",
"integrity": "sha512-AVo2Eg30mid1tb+ba+0ivrVLfZpuQvkDrR0gtKJhCCpDQ+8Dg7RFlqBZvD3FQyU7nj5jtwXVP+p0rMf94F2wwg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@ethersproject/rlp": "^5.7.0",
@ -8109,7 +8109,7 @@
"@libp2p/peer-id": "5.1.7",
"@multiformats/multiaddr": "^12.0.0",
"@noble/secp256k1": "^1.7.1",
"@waku/utils": "0.0.28-ff9c430.0",
"@waku/utils": "0.0.28-339e26e.0",
"debug": "^4.3.4",
"js-sha3": "^0.9.2"
},
@ -8153,18 +8153,18 @@
}
},
"node_modules/@waku/interfaces": {
"version": "0.0.35-ff9c430.0",
"resolved": "https://registry.npmjs.org/@waku/interfaces/-/interfaces-0.0.35-ff9c430.0.tgz",
"integrity": "sha512-m6az/AVkCF7nSlZsxKtRgPmyPVEjKg6LREnOWNIyAA2uCvesx00JxEN7DhkmgST+wNrH5+1LgoyE6mXTcSDqTw==",
"version": "0.0.35-339e26e.0",
"resolved": "https://registry.npmjs.org/@waku/interfaces/-/interfaces-0.0.35-339e26e.0.tgz",
"integrity": "sha512-7if75dK/RFF13ey9v/1gnrvR/WHZ3JogCmhWGtFp3q34cA1cyfHu7l66eGarVVHbwdSgBSVSH6fM8YFMsoacDA==",
"license": "MIT OR Apache-2.0",
"engines": {
"node": ">=22"
}
},
"node_modules/@waku/proto": {
"version": "0.0.15-ff9c430.0",
"resolved": "https://registry.npmjs.org/@waku/proto/-/proto-0.0.15-ff9c430.0.tgz",
"integrity": "sha512-JuPcioC2ry7do5Sa2TABjaJ4uQ4+jAbaZsjiir5OKmZI5krCv1BqKoZehY+BaX1lMerDfrGAXMpNQHjykfbthw==",
"version": "0.0.16-339e26e.0",
"resolved": "https://registry.npmjs.org/@waku/proto/-/proto-0.0.16-339e26e.0.tgz",
"integrity": "sha512-jPHKCBt1HkBHenXO2kbRADkwqYbgVDdGalTxkHwNrWFFVK8pRTgG5VAqtSmw6yiba87P5ErstuKrDnO8ycFRjA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"protons-runtime": "^5.4.0"
@ -8174,9 +8174,9 @@
}
},
"node_modules/@waku/sdk": {
"version": "0.0.36-ff9c430.0",
"resolved": "https://registry.npmjs.org/@waku/sdk/-/sdk-0.0.36-ff9c430.0.tgz",
"integrity": "sha512-BO6svBNw1B+HeCioKDYeFsmsxhBHzbLkxPYqVMm8FDTngmZGj1dywli4YDMm8cgwWux+Kp9TcoDEkqvKgE0ctw==",
"version": "0.0.37-339e26e.0",
"resolved": "https://registry.npmjs.org/@waku/sdk/-/sdk-0.0.37-339e26e.0.tgz",
"integrity": "sha512-rIoJjoFYinY8u4OHl4jdoCD9mAhVLklLm9WxWELp8nry9mWCoST68MJ4wFvV6ZMJPXwALf/Fx5g3Pz2DiweK4g==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@chainsafe/libp2p-noise": "16.1.3",
@ -8187,12 +8187,12 @@
"@libp2p/websockets": "9.2.16",
"@noble/hashes": "^1.3.3",
"@types/lodash.debounce": "^4.0.9",
"@waku/core": "0.0.40-ff9c430.0",
"@waku/discovery": "0.0.13-ff9c430.0",
"@waku/interfaces": "0.0.35-ff9c430.0",
"@waku/proto": "0.0.15-ff9c430.0",
"@waku/sds": "0.0.8-ff9c430.0",
"@waku/utils": "0.0.28-ff9c430.0",
"@waku/core": "0.0.41-339e26e.0",
"@waku/discovery": "0.0.14-339e26e.0",
"@waku/interfaces": "0.0.35-339e26e.0",
"@waku/proto": "0.0.16-339e26e.0",
"@waku/sds": "0.0.9-339e26e.0",
"@waku/utils": "0.0.28-339e26e.0",
"libp2p": "2.8.11",
"lodash.debounce": "^4.0.8"
},
@ -8201,15 +8201,15 @@
}
},
"node_modules/@waku/sds": {
"version": "0.0.8-ff9c430.0",
"resolved": "https://registry.npmjs.org/@waku/sds/-/sds-0.0.8-ff9c430.0.tgz",
"integrity": "sha512-st2/2QeId6kFyK53L3aV4cNTyYCfrM7As77k21nlgo0PEHKHyPPFuoIkZUwRIWO6qM9ohXgiIU3RVTioEtZuTA==",
"version": "0.0.9-339e26e.0",
"resolved": "https://registry.npmjs.org/@waku/sds/-/sds-0.0.9-339e26e.0.tgz",
"integrity": "sha512-eEd8Co++8ayCid6XEQ2ex55SoMxiRJ6ZvWLz0GjhUKGhApjXZlU6cnQ8nnRpPnVCCuRvS2EU1oo8JCOHBduZHQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@libp2p/interface": "2.10.4",
"@noble/hashes": "^1.7.1",
"@waku/proto": "0.0.15-ff9c430.0",
"@waku/utils": "0.0.28-ff9c430.0",
"@waku/proto": "0.0.16-339e26e.0",
"@waku/utils": "0.0.28-339e26e.0",
"chai": "^5.1.2",
"lodash": "^4.17.21"
},
@ -8233,13 +8233,13 @@
}
},
"node_modules/@waku/utils": {
"version": "0.0.28-ff9c430.0",
"resolved": "https://registry.npmjs.org/@waku/utils/-/utils-0.0.28-ff9c430.0.tgz",
"integrity": "sha512-6tBSQ/X+vzoLV3R573c80XLESZq4qWWSlyXtG4se/+RanQU1om9cSkAzrAlm9ZhO6IcrpLlNgeOsv/dIUIz+Fw==",
"version": "0.0.28-339e26e.0",
"resolved": "https://registry.npmjs.org/@waku/utils/-/utils-0.0.28-339e26e.0.tgz",
"integrity": "sha512-lzFcCN8xj3IN6JwbUdH3zc9FLwS6UQu775zP+RM8PnR5bMNHED8dlKR1fovZXfoggCIU+KnFwgITH+HhBEcV9w==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@noble/hashes": "^1.3.2",
"@waku/interfaces": "0.0.35-ff9c430.0",
"@waku/interfaces": "0.0.35-339e26e.0",
"chai": "^4.3.10",
"debug": "^4.3.4",
"uint8arrays": "^5.0.1"
@ -19470,7 +19470,7 @@
"dependencies": {
"@noble/ed25519": "^2.2.3",
"@noble/hashes": "^1.8.0",
"@waku/sdk": "0.0.36-ff9c430.0",
"@waku/sdk": "0.0.37-339e26e.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.2",
"uuid": "^11.1.0",

View File

@ -39,7 +39,7 @@
"dependencies": {
"@noble/ed25519": "^2.2.3",
"@noble/hashes": "^1.8.0",
"@waku/sdk": "0.0.36-ff9c430.0",
"@waku/sdk": "0.0.37-339e26e.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.2",
"uuid": "^11.1.0",