mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-08 15:53:08 +00:00
feat(ui): feed (#14)
* feat: add feed-UI * chore: ordinal agnostic terminology
This commit is contained in:
parent
95f168894b
commit
be55804d91
@ -22,6 +22,7 @@ import CellPage from "./pages/CellPage";
|
|||||||
import PostPage from "./pages/PostPage";
|
import PostPage from "./pages/PostPage";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
|
import Index from "./pages/Index";
|
||||||
import { appkitConfig } from "./lib/identity/wallets/appkit";
|
import { appkitConfig } from "./lib/identity/wallets/appkit";
|
||||||
import { createAppKit } from "@reown/appkit";
|
import { createAppKit } from "@reown/appkit";
|
||||||
import { WagmiProvider } from "wagmi";
|
import { WagmiProvider } from "wagmi";
|
||||||
@ -45,6 +46,7 @@ const App = () => (
|
|||||||
<Sonner />
|
<Sonner />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/cells" element={<Index />} />
|
||||||
<Route path="/cell/:cellId" element={<CellPage />} />
|
<Route path="/cell/:cellId" element={<CellPage />} />
|
||||||
<Route path="/post/:postId" element={<PostPage />} />
|
<Route path="/post/:postId" element={<PostPage />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
|
|||||||
@ -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 { createCell, isPostingCell } = useForum();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
const { toast } = useToast();
|
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>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
@ -77,9 +85,11 @@ export function CreateCellDialog() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
{!onOpenChange && (
|
||||||
<Button variant="outline" className="w-full">Create New Cell</Button>
|
<DialogTrigger asChild>
|
||||||
</DialogTrigger>
|
<Button variant="outline" className="w-full">Create New Cell</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
)}
|
||||||
<DialogContent className="sm:max-w-[425px]">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create a New Cell</DialogTitle>
|
<DialogTitle>Create a New Cell</DialogTitle>
|
||||||
@ -151,3 +161,5 @@ export function CreateCellDialog() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default CreateCellDialog;
|
||||||
|
|||||||
219
src/components/FeedSidebar.tsx
Normal file
219
src/components/FeedSidebar.tsx
Normal 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;
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '@/contexts/useAuth';
|
import { useAuth } from '@/contexts/useAuth';
|
||||||
import { useForum } from '@/contexts/useForum';
|
import { useForum } from '@/contexts/useForum';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { useAppKitAccount, useDisconnect } from '@reown/appkit/react';
|
import { useAppKitAccount, useDisconnect } from '@reown/appkit/react';
|
||||||
@ -22,6 +22,7 @@ const Header = () => {
|
|||||||
isWalletAvailable
|
isWalletAvailable
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
const { isNetworkConnected, isRefreshing } = useForum();
|
const { isNetworkConnected, isRefreshing } = useForum();
|
||||||
|
const location = useLocation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
// Use AppKit hooks for multi-chain support
|
// Use AppKit hooks for multi-chain support
|
||||||
const bitcoinAccount = useAppKitAccount({ namespace: "bip122" });
|
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">
|
<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="container mx-auto px-4 h-full flex justify-between items-center">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-6">
|
||||||
<Terminal className="text-cyber-accent w-6 h-6" />
|
<div className="flex items-center gap-2">
|
||||||
<Link to="/" className="text-xl font-bold text-glow text-cyber-accent">
|
<Terminal className="text-cyber-accent w-6 h-6" />
|
||||||
OpChan
|
<Link to="/" className="text-xl font-bold text-glow text-cyber-accent">
|
||||||
</Link>
|
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>
|
||||||
|
|
||||||
<div className="flex gap-3 items-center">
|
<div className="flex gap-3 items-center">
|
||||||
|
|||||||
117
src/components/PostCard.tsx
Normal file
117
src/components/PostCard.tsx
Normal 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;
|
||||||
@ -82,9 +82,9 @@ export function WalletWizard({
|
|||||||
return "pending";
|
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 (step === 3) {
|
||||||
if (isDelegationValid()) return "completed";
|
if (isAuthenticated && isDelegationValid()) return "completed";
|
||||||
if (currentStep === step) return "current";
|
if (currentStep === step) return "current";
|
||||||
return "pending";
|
return "pending";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Index from './Index';
|
import Header from '@/components/Header';
|
||||||
import ActivityFeed from '@/components/ActivityFeed';
|
import FeedPage from './FeedPage';
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className="pt-16">
|
<div className="min-h-screen flex flex-col bg-cyber-dark">
|
||||||
<div className="container mx-auto px-4 py-6">
|
<Header />
|
||||||
<ActivityFeed />
|
<main className="flex-1 pt-16">
|
||||||
<Index />
|
<FeedPage />
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
151
src/pages/FeedPage.tsx
Normal file
151
src/pages/FeedPage.tsx
Normal 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;
|
||||||
Loading…
x
Reference in New Issue
Block a user