feat(ui): feed (#14)

* feat: add feed-UI

* chore: ordinal agnostic terminology
This commit is contained in:
Danish Arora 2025-08-06 15:37:48 +05:30 committed by GitHub
parent 95f168894b
commit be55804d91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 552 additions and 23 deletions

View File

@ -22,6 +22,7 @@ import CellPage from "./pages/CellPage";
import PostPage from "./pages/PostPage";
import NotFound from "./pages/NotFound";
import Dashboard from "./pages/Dashboard";
import Index from "./pages/Index";
import { appkitConfig } from "./lib/identity/wallets/appkit";
import { createAppKit } from "@reown/appkit";
import { WagmiProvider } from "wagmi";
@ -45,6 +46,7 @@ const App = () => (
<Sonner />
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/cells" element={<Index />} />
<Route path="/cell/:cellId" element={<CellPage />} />
<Route path="/post/:postId" element={<PostPage />} />
<Route path="*" element={<NotFound />} />

View File

@ -37,11 +37,19 @@ const formSchema = z.object({
}),
});
export function CreateCellDialog() {
interface CreateCellDialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCellDialogProps = {}) {
const { createCell, isPostingCell } = useForum();
const { isAuthenticated } = useAuth();
const { toast } = useToast();
const [open, setOpen] = React.useState(false);
const [internalOpen, setInternalOpen] = React.useState(false);
const open = externalOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@ -77,9 +85,11 @@ export function CreateCellDialog() {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="w-full">Create New Cell</Button>
</DialogTrigger>
{!onOpenChange && (
<DialogTrigger asChild>
<Button variant="outline" className="w-full">Create New Cell</Button>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create a New Cell</DialogTitle>
@ -151,3 +161,5 @@ export function CreateCellDialog() {
</Dialog>
);
}
export default CreateCellDialog;

View File

@ -0,0 +1,219 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { Plus, TrendingUp, Users, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useForum } from '@/contexts/useForum';
import { useAuth } from '@/contexts/useAuth';
import { CypherImage } from '@/components/ui/CypherImage';
import {CreateCellDialog} from '@/components/CreateCellDialog';
const FeedSidebar: React.FC = () => {
const { cells, posts } = useForum();
const { currentUser, verificationStatus } = useAuth();
const [showCreateCell, setShowCreateCell] = useState(false);
// Calculate trending cells based on recent post activity
const trendingCells = cells
.map(cell => {
const cellPosts = posts.filter(post => post.cellId === cell.id);
const recentPosts = cellPosts.filter(post =>
Date.now() - post.timestamp < 24 * 60 * 60 * 1000 // Last 24 hours
);
const totalScore = cellPosts.reduce((sum, post) =>
sum + (post.upvotes.length - post.downvotes.length), 0
);
return {
...cell,
postCount: cellPosts.length,
recentPostCount: recentPosts.length,
totalScore,
activity: recentPosts.length + (totalScore * 0.1) // Simple activity score
};
})
.sort((a, b) => b.activity - a.activity)
.slice(0, 5);
// User's verification status display
const getVerificationBadge = () => {
if (!currentUser) {
return <Badge variant="secondary">Not Connected</Badge>;
}
// Ethereum wallet with ENS
if (currentUser.walletType === 'ethereum') {
if (currentUser.ensName && verificationStatus === 'verified-owner') {
return <Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> Owns ENS: {currentUser.ensName}</Badge>;
} else {
return <Badge variant="outline">Read-only (No ENS detected)</Badge>;
}
}
// Bitcoin wallet with Ordinal
if (currentUser.walletType === 'bitcoin') {
if (verificationStatus === 'verified-owner') {
return <Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> Owns Ordinal</Badge>;
} else {
return <Badge variant="outline">Read-only (No Ordinal detected)</Badge>;
}
}
// Fallback cases
switch (verificationStatus) {
case 'verified-none':
return <Badge variant="outline">Read Only</Badge>;
case 'verifying':
return <Badge variant="outline">Verifying...</Badge>;
default:
return <Badge variant="secondary">Not Connected</Badge>;
}
};
return (
<div className="space-y-4">
{/* User Info Card */}
{currentUser && (
<Card className="bg-cyber-muted/20 border-cyber-muted">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-cyber-accent">Your Account</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-xs text-cyber-neutral">
{currentUser.address.slice(0, 8)}...{currentUser.address.slice(-6)}
</div>
{getVerificationBadge()}
</CardContent>
</Card>
)}
{/* Create Cell */}
<Card className="bg-cyber-muted/20 border-cyber-muted">
<CardContent className="p-4">
<Button
onClick={() => setShowCreateCell(true)}
className="w-full"
disabled={verificationStatus !== 'verified-owner'}
>
<Plus className="w-4 h-4 mr-2" />
Create Cell
</Button>
{verificationStatus !== 'verified-owner' && (
<p className="text-xs text-cyber-neutral mt-2 text-center">
{currentUser?.walletType === 'ethereum'
? 'Own an ENS name to create cells'
: 'Own a Bitcoin Ordinal to create cells'
}
</p>
)}
</CardContent>
</Card>
{/* Trending Cells */}
<Card className="bg-cyber-muted/20 border-cyber-muted">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center text-cyber-accent">
<TrendingUp className="w-4 h-4 mr-2" />
Trending Cells
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{trendingCells.length === 0 ? (
<p className="text-xs text-cyber-neutral">No cells yet</p>
) : (
trendingCells.map((cell, index) => (
<Link
key={cell.id}
to={`/cell/${cell.id}`}
className="flex items-center space-x-3 p-2 rounded-sm hover:bg-cyber-muted/50 transition-colors"
>
<div className="flex items-center space-x-2 flex-1 min-w-0">
<span className="text-xs font-medium text-cyber-neutral w-4">
{index + 1}
</span>
<CypherImage
src={cell.icon}
alt={cell.name}
className="w-6 h-6 rounded-sm flex-shrink-0"
generateUniqueFallback={true}
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-glow truncate">
r/{cell.name}
</div>
<div className="text-xs text-cyber-neutral">
{cell.postCount} posts
</div>
</div>
</div>
{cell.recentPostCount > 0 && (
<Badge variant="secondary" className="text-xs">
{cell.recentPostCount} new
</Badge>
)}
</Link>
))
)}
</CardContent>
</Card>
{/* All Cells */}
<Card className="bg-cyber-muted/20 border-cyber-muted">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center text-cyber-accent">
<Users className="w-4 h-4 mr-2" />
All Cells
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{cells.length === 0 ? (
<p className="text-xs text-cyber-neutral">No cells created yet</p>
) : (
<div className="space-y-1">
{cells.slice(0, 8).map(cell => (
<Link
key={cell.id}
to={`/cell/${cell.id}`}
className="block text-sm text-cyber-neutral hover:text-cyber-accent transition-colors"
>
r/{cell.name}
</Link>
))}
{cells.length > 8 && (
<Link
to="/"
className="block text-xs text-cyber-neutral hover:text-cyber-accent transition-colors"
>
View all cells
</Link>
)}
</div>
)}
</CardContent>
</Card>
{/* About */}
<Card className="bg-cyber-muted/20 border-cyber-muted">
<CardContent className="p-4 text-center">
<div className="text-xs text-cyber-neutral space-y-1">
<p>OpChan v1.0</p>
<p>A Decentralized Forum Prototype</p>
<div className="flex items-center justify-center space-x-1 mt-2">
<Eye className="w-3 h-3" />
<span>Powered by Waku</span>
</div>
</div>
</CardContent>
</Card>
{/* Create Cell Dialog */}
<CreateCellDialog
open={showCreateCell}
onOpenChange={setShowCreateCell}
/>
</div>
);
};
export default FeedSidebar;

View File

@ -1,10 +1,10 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/useAuth';
import { useForum } from '@/contexts/useForum';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { LogOut, Terminal, Wifi, WifiOff, AlertTriangle, CheckCircle, Key, RefreshCw, CircleSlash } from 'lucide-react';
import { LogOut, Terminal, Wifi, WifiOff, AlertTriangle, CheckCircle, Key, RefreshCw, CircleSlash, Home, Grid3X3, Plus } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useToast } from '@/components/ui/use-toast';
import { useAppKitAccount, useDisconnect } from '@reown/appkit/react';
@ -22,6 +22,7 @@ const Header = () => {
isWalletAvailable
} = useAuth();
const { isNetworkConnected, isRefreshing } = useForum();
const location = useLocation();
const { toast } = useToast();
// Use AppKit hooks for multi-chain support
const bitcoinAccount = useAppKitAccount({ namespace: "bip122" });
@ -125,12 +126,39 @@ const Header = () => {
<>
<header className="border-b border-cyber-muted bg-cyber-dark fixed top-0 left-0 right-0 z-50 h-16">
<div className="container mx-auto px-4 h-full flex justify-between items-center">
<div className="flex items-center gap-2">
<Terminal className="text-cyber-accent w-6 h-6" />
<Link to="/" className="text-xl font-bold text-glow text-cyber-accent">
OpChan
</Link>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Terminal className="text-cyber-accent w-6 h-6" />
<Link to="/" className="text-xl font-bold text-glow text-cyber-accent">
OpChan
</Link>
</div>
{/* Navigation Tabs */}
<nav className="hidden md:flex items-center space-x-1">
<Link
to="/"
className={`flex items-center space-x-2 px-3 py-2 text-sm font-medium rounded-sm transition-colors ${
location.pathname === '/'
? 'bg-cyber-accent/20 text-cyber-accent'
: 'text-gray-300 hover:text-cyber-accent hover:bg-cyber-accent/10'
}`}
>
<Home className="w-4 h-4" />
<span>Feed</span>
</Link>
<Link
to="/cells"
className={`flex items-center space-x-2 px-3 py-2 text-sm font-medium rounded-sm transition-colors ${
location.pathname === '/cells'
? 'bg-cyber-accent/20 text-cyber-accent'
: 'text-gray-300 hover:text-cyber-accent hover:bg-cyber-accent/10'
}`}
>
<Grid3X3 className="w-4 h-4" />
<span>Cells</span>
</Link>
</nav>
</div>
<div className="flex gap-3 items-center">

117
src/components/PostCard.tsx Normal file
View File

@ -0,0 +1,117 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowUp, ArrowDown, MessageSquare } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { Post } from '@/types';
import { useForum } from '@/contexts/useForum';
import { useAuth } from '@/contexts/useAuth';
interface PostCardProps {
post: Post;
commentCount?: number;
}
const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
const { getCellById, votePost, isVoting } = useForum();
const { isAuthenticated, currentUser } = useAuth();
const cell = getCellById(post.cellId);
const cellName = cell?.name || 'unknown';
// Calculate vote score
const score = post.upvotes.length - post.downvotes.length;
// Check user's vote status
const userUpvoted = currentUser ? post.upvotes.some(vote => vote.author === currentUser.address) : false;
const userDownvoted = currentUser ? post.downvotes.some(vote => vote.author === currentUser.address) : false;
// Truncate content for preview
const contentPreview = post.content.length > 200
? post.content.substring(0, 200) + '...'
: post.content;
const handleVote = async (e: React.MouseEvent, isUpvote: boolean) => {
e.preventDefault(); // Prevent navigation when clicking vote buttons
if (!isAuthenticated) return;
await votePost(post.id, isUpvote);
};
return (
<div className="bg-cyber-muted/20 border border-cyber-muted rounded-sm hover:border-cyber-accent/50 hover:bg-cyber-muted/30 transition-all duration-200 mb-2">
<div className="flex">
{/* Voting column */}
<div className="flex flex-col items-center p-2 bg-cyber-muted/50 border-r border-cyber-muted">
<button
className={`p-1 rounded hover:bg-cyber-muted transition-colors ${
userUpvoted ? 'text-cyber-accent' : 'text-cyber-neutral hover:text-cyber-accent'
}`}
onClick={(e) => handleVote(e, true)}
disabled={!isAuthenticated || isVoting}
title={isAuthenticated ? "Upvote" : "Connect wallet to vote"}
>
<ArrowUp className="w-5 h-5" />
</button>
<span className={`text-sm font-medium px-1 ${
score > 0 ? 'text-cyber-accent' :
score < 0 ? 'text-blue-400' :
'text-cyber-neutral'
}`}>
{score}
</span>
<button
className={`p-1 rounded hover:bg-cyber-muted transition-colors ${
userDownvoted ? 'text-blue-400' : 'text-cyber-neutral hover:text-blue-400'
}`}
onClick={(e) => handleVote(e, false)}
disabled={!isAuthenticated || isVoting}
title={isAuthenticated ? "Downvote" : "Connect wallet to vote"}
>
<ArrowDown className="w-5 h-5" />
</button>
</div>
{/* Content column */}
<div className="flex-1 p-3">
<Link to={`/post/${post.id}`} className="block hover:opacity-80">
{/* Post metadata */}
<div className="flex items-center text-xs text-cyber-neutral mb-2 space-x-2">
<span className="font-medium text-cyber-accent">r/{cellName}</span>
<span></span>
<span>Posted by u/{post.authorAddress.slice(0, 6)}...{post.authorAddress.slice(-4)}</span>
<span></span>
<span>{formatDistanceToNow(new Date(post.timestamp), { addSuffix: true })}</span>
</div>
{/* Post title */}
<h2 className="text-lg font-semibold text-glow mb-2 hover:text-cyber-accent transition-colors">
{post.title}
</h2>
{/* Post content preview */}
<p className="text-cyber-neutral text-sm leading-relaxed mb-3">
{contentPreview}
</p>
{/* Post actions */}
<div className="flex items-center space-x-4 text-xs text-cyber-neutral">
<div className="flex items-center space-x-1 hover:text-cyber-accent transition-colors">
<MessageSquare className="w-4 h-4" />
<span>{commentCount} comments</span>
</div>
<button className="hover:text-cyber-accent transition-colors">
Share
</button>
<button className="hover:text-cyber-accent transition-colors">
Save
</button>
</div>
</Link>
</div>
</div>
</div>
);
};
export default PostCard;

View File

@ -82,9 +82,9 @@ export function WalletWizard({
return "pending";
}
// Step 3: Key delegation - completed when delegation is valid
// Step 3: Key delegation - completed when delegation is valid AND authenticated
if (step === 3) {
if (isDelegationValid()) return "completed";
if (isAuthenticated && isDelegationValid()) return "completed";
if (currentStep === step) return "current";
return "pending";
}

View File

@ -1,15 +1,15 @@
import React from 'react';
import Index from './Index';
import ActivityFeed from '@/components/ActivityFeed';
import Header from '@/components/Header';
import FeedPage from './FeedPage';
const Dashboard: React.FC = () => {
return (
<div className="pt-16">
<div className="container mx-auto px-4 py-6">
<ActivityFeed />
<Index />
</div>
</div>
<div className="min-h-screen flex flex-col bg-cyber-dark">
<Header />
<main className="flex-1 pt-16">
<FeedPage />
</main>
</div>
);
};

151
src/pages/FeedPage.tsx Normal file
View File

@ -0,0 +1,151 @@
import React, { useMemo } from 'react';
import { RefreshCw, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import PostCard from '@/components/PostCard';
import FeedSidebar from '@/components/FeedSidebar';
import { useForum } from '@/contexts/useForum';
import { useAuth } from '@/contexts/useAuth';
const FeedPage: React.FC = () => {
const {
posts,
comments,
isInitialLoading,
isRefreshing,
refreshData
} = useForum();
const { verificationStatus } = useAuth();
// Combine posts from all cells and sort by timestamp (newest first)
const allPosts = useMemo(() => {
return [...posts]
.sort((a, b) => b.timestamp - a.timestamp)
.filter(post => !post.moderated); // Hide moderated posts from main feed
}, [posts]);
// Calculate comment counts for each post
const getCommentCount = (postId: string) => {
return comments.filter(comment => comment.postId === postId && !comment.moderated).length;
};
// Loading skeleton
if (isInitialLoading) {
return (
<div className="min-h-screen bg-cyber-dark">
<div className="container mx-auto px-4 py-6 max-w-6xl">
<div className="flex gap-6">
{/* Main feed skeleton */}
<div className="flex-1 max-w-3xl">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="bg-cyber-muted/20 border border-cyber-muted rounded-sm p-4">
<div className="flex gap-4">
<div className="w-10 space-y-2">
<Skeleton className="h-6 w-6" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-6 w-6" />
</div>
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/3" />
</div>
</div>
</div>
))}
</div>
</div>
{/* Sidebar skeleton */}
<div className="w-80 space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-cyber-muted/20 border border-cyber-muted rounded-sm p-4">
<Skeleton className="h-6 w-1/2 mb-3" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-cyber-dark">
<div className="container mx-auto px-4 py-6 max-w-6xl">
{/* Page Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-glow text-cyber-accent">
Popular Posts
</h1>
<p className="text-cyber-neutral text-sm">
Latest posts from all cells
</p>
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={refreshData}
disabled={isRefreshing}
className="flex items-center space-x-2"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</Button>
</div>
</div>
<div className="flex gap-6">
{/* Main Feed */}
<div className="flex-1 max-w-3xl">
{/* Posts Feed */}
<div className="space-y-0">
{allPosts.length === 0 ? (
<div className="bg-cyber-muted/20 border border-cyber-muted rounded-sm p-12 text-center">
<div className="space-y-3">
<h3 className="text-lg font-semibold text-glow">
No posts yet
</h3>
<p className="text-cyber-neutral">
Be the first to create a post in a cell!
</p>
{verificationStatus !== 'verified-owner' && (
<p className="text-sm text-cyber-neutral/80">
Connect your wallet and verify Ordinal ownership to start posting
</p>
)}
</div>
</div>
) : (
allPosts.map(post => (
<PostCard
key={post.id}
post={post}
commentCount={getCommentCount(post.id)}
/>
))
)}
</div>
</div>
{/* Sidebar */}
<div className="w-80 flex-shrink-0">
<FeedSidebar />
</div>
</div>
</div>
</div>
);
};
export default FeedPage;