chore: raw UI

This commit is contained in:
Danish Arora 2025-11-17 18:29:28 -05:00
parent bf7b3f20a1
commit d12a76ff99
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
8 changed files with 342 additions and 1085 deletions

View File

@ -2,18 +2,9 @@ import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useContent, usePermissions, useNetwork } from '@/hooks'; import { useContent, usePermissions, useNetwork } from '@/hooks';
import { import {
Layout,
MessageSquare, MessageSquare,
RefreshCw,
Loader2,
TrendingUp,
Clock,
Grid3X3,
Shield,
Hash,
} from 'lucide-react'; } from 'lucide-react';
import { CreateCellDialog } from './CreateCellDialog'; import { CreateCellDialog } from './CreateCellDialog';
import { Button } from '@/components/ui/button';
import { import {
Select, Select,
SelectContent, SelectContent,
@ -21,123 +12,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { CypherImage } from './ui/CypherImage';
import { RelevanceIndicator } from './ui/relevance-indicator';
import { ModerationToggle } from './ui/moderation-toggle'; import { ModerationToggle } from './ui/moderation-toggle';
import { sortCells, SortOption } from '@/utils/sorting'; import { sortCells, SortOption } from '@/utils/sorting';
import type { Cell } from '@opchan/core'; import type { Cell } from '@opchan/core';
import { useForum } from '@/hooks'; import { useForum } from '@/hooks';
import { ShareButton } from './ui/ShareButton';
// Empty State Component
const EmptyState: React.FC<{ canCreateCell: boolean }> = ({
canCreateCell,
}) => {
return (
<div className="col-span-2 flex flex-col items-center justify-center py-16 px-4">
{/* Visual Element */}
<div className="relative mb-8">
<div className="w-32 h-32 bg-cyber-muted/20 border border-cyber-muted/30 rounded-lg flex items-center justify-center">
<Grid3X3 className="w-16 h-16 text-cyber-accent/50" />
</div>
{/* Floating elements */}
<div className="absolute -top-2 -right-2 w-6 h-6 bg-cyber-accent/20 border border-cyber-accent/30 rounded-full flex items-center justify-center">
<Hash className="w-3 h-3 text-cyber-accent" />
</div>
<div className="absolute -bottom-2 -left-2 w-6 h-6 bg-green-500/20 border border-green-500/30 rounded-full flex items-center justify-center">
<Shield className="w-3 h-3 text-green-400" />
</div>
</div>
{/* Content */}
<div className="text-center max-w-2xl">
<h2 className="text-2xl font-mono font-bold text-white mb-4">
No Cells Found
</h2>
{canCreateCell ? (
<div className="space-y-4">
<p className="text-cyber-neutral">
Ready to start your own decentralized community?
</p>
<CreateCellDialog />
</div>
) : (
<div className="p-4 bg-orange-500/10 border border-orange-500/30 rounded-md">
<p className="text-orange-400 text-sm">
Connect your wallet and verify ownership to create cells
</p>
</div>
)}
</div>
</div>
);
};
// Separate component to properly use hooks
const CellItem: React.FC<{ cell: Cell }> = ({ cell }) => {
const { content } = useForum();
const isPending = content.pending.isPending(cell.id);
return (
<Link to={`/cell/${cell.id}`} className="group block board-card">
<div className="flex items-start gap-4">
<CypherImage
src={cell.icon}
alt={cell.name}
className="w-12 h-12 object-cover rounded-sm border border-cyber-muted flex-shrink-0"
generateUniqueFallback={true}
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<h2 className="text-lg font-bold text-glow group-hover:text-cyber-accent transition-colors line-clamp-1">
{cell.name}
</h2>
{cell.relevanceScore !== undefined && (
<RelevanceIndicator
score={cell.relevanceScore}
details={cell.relevanceDetails}
type="cell"
className="ml-2 flex-shrink-0"
showTooltip={true}
/>
)}
</div>
{isPending && (
<div className="mb-2">
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 text-xs">
syncing
</span>
</div>
)}
<p className="text-cyber-neutral text-sm mb-3 line-clamp-2">
{cell.description}
</p>
<div className="flex items-center justify-between text-xs text-cyber-neutral">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1">
<MessageSquare className="w-3 h-3" />
{cell.postCount || 0} posts
</span>
<span className="flex items-center gap-1">
<Layout className="w-3 h-3" />
{cell.activeMemberCount || 0} members
</span>
</div>
<ShareButton
size="sm"
url={`${window.location.origin}/cell/${cell.id}`}
title={cell.name}
/>
</div>
</div>
</div>
</Link>
);
};
const CellList = () => { const CellList = () => {
const { cellsWithStats } = useContent(); const { cellsWithStats } = useContent();
@ -151,97 +31,76 @@ const CellList = () => {
return sortCells(cellsWithStats, sortOption); return sortCells(cellsWithStats, sortOption);
}, [cellsWithStats, sortOption]); }, [cellsWithStats, sortOption]);
// Only show loading if store is not yet hydrated // Simple loading
if (!isHydrated && !cellsWithStats.length) { if (!isHydrated && !cellsWithStats.length) {
return ( return (
<div className="container mx-auto px-4 pt-24 pb-16 text-center"> <div className="w-full mx-auto px-2 py-4 max-w-4xl">
<Loader2 className="w-8 h-8 mx-auto mb-4 animate-spin text-primary" /> <div className="text-xs text-muted-foreground">Loading...</div>
<p className="text-lg font-medium text-muted-foreground">
Loading Cells...
</p>
</div> </div>
); );
} }
const hasCells = sortedCells.length > 0;
return ( return (
<div className="page-main"> <div className="w-full mx-auto px-2 py-2 max-w-4xl">
<div className="page-header"> <div className="mb-2 pb-1 border-b border-border/30 flex items-center justify-between text-xs">
<div className="flex justify-between items-center"> <div className="flex items-center gap-2">
<div> <span className="text-primary font-semibold">CELLS</span>
<h1 className="page-title">Decentralized Cells</h1> <ModerationToggle />
<p className="page-subtitle"> </div>
Discover communities built on Bitcoin Ordinals <div className="flex items-center gap-2">
</p> <Select
</div> value={sortOption}
onValueChange={(value: SortOption) => setSortOption(value)}
{/* Only show controls when cells exist */} >
{hasCells && ( <SelectTrigger className="w-24 text-[10px] h-6">
<div className="flex items-center gap-4"> <SelectValue />
<ModerationToggle /> </SelectTrigger>
<SelectContent>
<Select <SelectItem value="relevance">Relevance</SelectItem>
value={sortOption} <SelectItem value="activity">Activity</SelectItem>
onValueChange={(value: SortOption) => setSortOption(value)} <SelectItem value="newest">Newest</SelectItem>
> <SelectItem value="alphabetical">A-Z</SelectItem>
<SelectTrigger className="w-40 bg-cyber-muted/50 border-cyber-muted text-cyber-light"> </SelectContent>
<SelectValue /> </Select>
</SelectTrigger> <button
<SelectContent className="bg-cyber-dark border-cyber-muted/30"> onClick={content.refresh}
<SelectItem className="text-muted-foreground hover:text-foreground text-[10px]"
value="relevance" >
className="text-cyber-light hover:bg-cyber-muted/30" refresh
> </button>
<TrendingUp className="w-4 h-4 mr-2 inline" /> {canCreateCell && <CreateCellDialog />}
Relevance
</SelectItem>
<SelectItem
value="activity"
className="text-cyber-light hover:bg-cyber-muted/30"
>
<MessageSquare className="w-4 h-4 mr-2 inline" />
Activity
</SelectItem>
<SelectItem
value="newest"
className="text-cyber-light hover:bg-cyber-muted/30"
>
<Clock className="w-4 h-4 mr-2 inline" />
Newest
</SelectItem>
<SelectItem
value="alphabetical"
className="text-cyber-light hover:bg-cyber-muted/30"
>
<Layout className="w-4 h-4 mr-2 inline" />
A-Z
</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={content.refresh}
disabled={false}
title="Refresh data"
className="px-3 border-cyber-muted/30 text-cyber-neutral hover:bg-cyber-muted/30"
>
<RefreshCw className="w-4 h-4" />
</Button>
{canCreateCell && <CreateCellDialog />}
</div>
)}
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div>
{!hasCells ? ( {sortedCells.length === 0 ? (
<EmptyState canCreateCell={canCreateCell} /> <div className="py-4 text-xs text-muted-foreground text-center">
No cells yet. {canCreateCell ? 'Create one!' : 'Connect wallet to create.'}
</div>
) : ( ) : (
sortedCells.map(cell => <CellItem key={cell.id} cell={cell} />) sortedCells.map(cell => {
const { content } = useForum();
const isPending = content.pending.isPending(cell.id);
return (
<Link
key={cell.id}
to={`/cell/${cell.id}`}
className="border-b border-border/30 py-1.5 px-2 text-xs flex items-baseline gap-2 hover:bg-border/5"
>
<span className="text-primary font-medium">{cell.name}</span>
<span className="text-muted-foreground text-[10px] flex-1 truncate">
{cell.description}
</span>
<span className="text-muted-foreground text-[10px] whitespace-nowrap">
{cell.postCount || 0}p
</span>
{isPending && (
<span className="text-yellow-400 text-[10px]">syncing</span>
)}
</Link>
);
})
)} )}
</div> </div>
</div> </div>

View File

@ -1,55 +1,22 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Terminal, FileText, Shield, Github, BookOpen } from 'lucide-react';
const Footer: React.FC = () => { const Footer: React.FC = () => {
return ( return (
<footer className="bg-cyber-dark border-t border-border mt-auto"> <footer className="bg-cyber-dark border-t border-border mt-auto">
<div className="max-w-6xl mx-auto px-3 sm:px-4 py-4 sm:py-6"> <div className="max-w-6xl mx-auto px-2 py-2">
<div className="flex flex-col md:flex-row items-center justify-between gap-3 sm:gap-4 text-[10px] sm:text-[11px] uppercase tracking-[0.15em] sm:tracking-[0.2em] text-muted-foreground"> <div className="text-center text-[10px] text-muted-foreground">
<div className="flex items-center space-x-2 sm:space-x-3"> <span>© 2025 OPCHAN</span>
<Terminal className="w-3 h-3 sm:w-4 sm:h-4 text-primary flex-shrink-0" /> <span className="mx-1">|</span>
<span>© 2025 OPCHAN</span> <Link to="/terms" className="hover:text-foreground">TERMS</Link>
</div> <span className="mx-1">|</span>
<Link to="/privacy" className="hover:text-foreground">PRIVACY</Link>
<nav className="flex items-center flex-wrap justify-center gap-x-3 sm:gap-x-6 gap-y-2 text-[10px] sm:text-[11px] uppercase tracking-[0.15em] sm:tracking-[0.2em]"> <span className="mx-1">|</span>
<Link <a href="https://github.com/waku-org/opchan/" target="_blank" rel="noopener noreferrer" className="hover:text-foreground">GITHUB</a>
to="/terms" <span className="mx-1">|</span>
className="flex items-center space-x-1 sm:space-x-2 text-muted-foreground hover:text-foreground" <a href="https://docs.waku.org" target="_blank" rel="noopener noreferrer" className="hover:text-foreground">DOCS</a>
> <span className="mx-1">|</span>
<FileText className="w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" /> <span>Reference client</span>
<span>TERMS</span>
</Link>
<Link
to="/privacy"
className="flex items-center space-x-1 sm:space-x-2 text-muted-foreground hover:text-foreground"
>
<Shield className="w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" />
<span>PRIVACY</span>
</Link>
<a
href="https://github.com/waku-org/opchan/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-1 sm:space-x-2 text-muted-foreground hover:text-foreground"
>
<Github className="w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" />
<span>GITHUB</span>
</a>
<a
href="https://docs.waku.org"
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-1 sm:space-x-2 text-muted-foreground hover:text-foreground"
>
<BookOpen className="w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" />
<span>DOCS</span>
</a>
</nav>
<div className="text-[9px] sm:text-[10px] uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground text-center">
Reference client · build your own with @opchan/core
</div>
</div> </div>
</div> </div>
</footer> </footer>

View File

@ -3,26 +3,14 @@ import { Link, useLocation } from 'react-router-dom';
import { useAuth, useForum, useNetwork, useUIState } from '@/hooks'; import { useAuth, useForum, useNetwork, useUIState } from '@/hooks';
import { EVerificationStatus } from '@opchan/core'; import { EVerificationStatus } from '@opchan/core';
import { localDatabase } from '@opchan/core'; import { localDatabase } from '@opchan/core';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { import {
LogOut, LogOut,
Terminal,
AlertTriangle, AlertTriangle,
CheckCircle, CheckCircle,
Key, Key,
CircleSlash, CircleSlash,
Home,
Grid3X3,
User,
Bookmark,
Settings,
Menu,
X,
Clock,
Trash2, Trash2,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
@ -31,12 +19,6 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -53,7 +35,6 @@ import { useEthereumWallet } from '@opchan/react';
import { WalletWizard } from '@/components/ui/wallet-wizard'; import { WalletWizard } from '@/components/ui/wallet-wizard';
import { CallSignSetupDialog } from '@/components/ui/call-sign-setup-dialog'; import { CallSignSetupDialog } from '@/components/ui/call-sign-setup-dialog';
import { WakuHealthDot } from '@/components/ui/waku-health-indicator';
const Header = () => { const Header = () => {
const { currentUser, delegationInfo } = useAuth(); const { currentUser, delegationInfo } = useAuth();
@ -61,12 +42,10 @@ const Header = () => {
const location = useLocation(); const location = useLocation();
const { toast } = useToast(); const { toast } = useToast();
const { content } = useForum();
const { isConnected, disconnect } = useEthereumWallet(); const { isConnected, disconnect } = useEthereumWallet();
const [walletWizardOpen, setWalletWizardOpen] = useState(false); const [walletWizardOpen, setWalletWizardOpen] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [callSignDialogOpen, setCallSignDialogOpen] = useState(false); const [callSignDialogOpen, setCallSignDialogOpen] = useState(false);
// Use centralized UI state instead of direct LocalDatabase access // Use centralized UI state instead of direct LocalDatabase access
@ -152,177 +131,73 @@ const Header = () => {
return ( return (
<> <>
<header className="bg-cyber-dark border-b border-border sticky top-0 z-40"> <header className="bg-cyber-dark border-b border-border sticky top-0 z-40">
<div className="max-w-6xl mx-auto px-3 sm:px-4"> <div className="max-w-6xl mx-auto px-2 py-2">
{/* Top Row - Logo, Network Status, User Actions */} {/* Single Row - Logo, Nav, Status, User */}
<div className="flex items-center justify-between h-12 sm:h-14 md:h-16"> <div className="flex items-center justify-between text-xs gap-2">
{/* Left: Logo */} {/* Logo & Nav */}
<div className="flex items-center min-w-0"> <div className="flex items-center gap-3">
<Link <Link to="/" className="font-semibold text-foreground">
to="/" OPCHAN
className="flex items-center space-x-1 sm:space-x-2 text-xs sm:text-sm font-mono font-semibold uppercase tracking-[0.3em] sm:tracking-[0.4em] text-foreground truncate"
>
<Terminal className="w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0" />
<span className="truncate">opchan</span>
</Link> </Link>
</div> <nav className="hidden sm:flex items-center gap-2">
<Link
{/* Center: Network Status (Desktop) */} to="/"
<div className="hidden lg:flex items-center space-x-3"> className={location.pathname === '/' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'}
<div className="flex items-center space-x-2 px-3 py-1 border border-border text-[10px] uppercase tracking-[0.2em]"> >
<WakuHealthDot /> HOME
<span className="text-[10px] text-muted-foreground"> </Link>
{statusMessage} <span className="text-muted-foreground">|</span>
</span> <Link
{syncStatus === 'syncing' && syncDetail && syncDetail.missing > 0 && ( to="/cells"
<TooltipProvider delayDuration={200}> className={location.pathname === '/cells' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'}
<Tooltip> >
<TooltipTrigger asChild> CELLS
<div className="flex items-center space-x-1 text-[10px] text-yellow-400 cursor-help"> </Link>
<Loader2 className="w-3 h-3 animate-spin" /> {isConnected && (
<span>SYNCING ({syncDetail.missing})</span> <>
</div> <span className="text-muted-foreground">|</span>
</TooltipTrigger> <Link
<TooltipContent> to="/bookmarks"
<p className="text-xs"> className={location.pathname === '/bookmarks' ? 'text-primary' : 'text-muted-foreground hover:text-foreground'}
<strong>Syncing messages</strong>
<br />
Pending: {syncDetail.missing}
<br />
Received: {syncDetail.received}
{syncDetail.lost > 0 && (
<>
<br />
Lost: {syncDetail.lost}
</>
)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{content.lastSync && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-1 text-[10px] text-muted-foreground cursor-help">
<Clock className="w-3 h-3" />
<span>
{new Date(content.lastSync).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Last message sync time</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
{/* Right: User Actions */}
<div className="flex items-center space-x-2 sm:space-x-3 flex-shrink-0">
{/* Network Status (Mobile) */}
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="lg:hidden flex items-center space-x-1 cursor-help">
{syncStatus === 'syncing' && syncDetail && syncDetail.missing > 0 ? (
<Loader2 className="w-4 h-4 text-yellow-400 animate-spin" />
) : (
<WakuHealthDot />
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
{syncStatus === 'syncing' && syncDetail && syncDetail.missing > 0
? `Syncing ${syncDetail.missing} messages...`
: 'Network connected'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* User Status & Actions */}
{isConnected || currentUser?.verificationStatus === EVerificationStatus.ANONYMOUS ? (
<div className="flex items-center space-x-1 sm:space-x-2">
{/* Status Badge - hidden for anonymous sessions */}
{currentUser?.verificationStatus !== EVerificationStatus.ANONYMOUS && (
<Badge
variant="outline"
className={`hidden sm:flex items-center gap-1 text-[9px] sm:text-[10px] px-1.5 sm:px-2 py-0.5 ${
currentUser?.verificationStatus ===
EVerificationStatus.ENS_VERIFIED &&
delegationInfo?.isValid
? 'border-green-500 text-green-300'
: currentUser?.verificationStatus ===
EVerificationStatus.ENS_VERIFIED
? 'border-orange-500 text-orange-300'
: 'border-yellow-500 text-yellow-300'
}`}
> >
{getStatusIcon()} BOOKMARKS
<span className="hidden md:inline"> </Link>
{currentUser?.verificationStatus === </>
EVerificationStatus.WALLET_UNCONNECTED )}
? 'CONNECT' </nav>
: delegationInfo?.isValid </div>
? 'READY'
: currentUser?.verificationStatus === {/* Network Status */}
EVerificationStatus.ENS_VERIFIED <div className="hidden md:flex items-center gap-2 text-[10px] text-muted-foreground">
? 'EXPIRED' <span>{statusMessage}</span>
: 'DELEGATE'} {syncStatus === 'syncing' && syncDetail && syncDetail.missing > 0 && (
</span> <span className="text-yellow-400">SYNCING ({syncDetail.missing})</span>
</Badge> )}
)} </div>
{/* User */}
<div className="flex items-center gap-2">
{isConnected || currentUser?.verificationStatus === EVerificationStatus.ANONYMOUS ? (
<div className="flex items-center gap-2">
{/* User Dropdown */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <button className="text-foreground hover:text-primary text-[10px]">
variant="ghost" {currentUser?.displayName}
size="sm" </button>
className="flex items-center space-x-1 sm:space-x-2 text-foreground border-border px-2 sm:px-3"
>
<div className="text-[10px] sm:text-[11px] uppercase tracking-[0.15em] sm:tracking-[0.2em] truncate max-w-[80px] sm:max-w-none">
{currentUser?.displayName}
</div>
<Settings className="w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" />
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent align="end" className="w-48 bg-[#050505] border border-border text-xs">
align="end"
className="w-56 bg-[#050505] border border-border text-sm"
>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link to="/profile">Profile</Link>
to="/profile"
className="flex items-center space-x-2"
>
<User className="w-4 h-4" />
<span>Profile</span>
</Link>
</DropdownMenuItem> </DropdownMenuItem>
{currentUser?.verificationStatus === EVerificationStatus.ANONYMOUS ? ( {currentUser?.verificationStatus === EVerificationStatus.ANONYMOUS ? (
<DropdownMenuItem <DropdownMenuItem onClick={() => setCallSignDialogOpen(true)}>
onClick={() => setCallSignDialogOpen(true)} {currentUser?.callSign ? 'Update' : 'Set'} Call Sign
className="flex items-center space-x-2"
>
<User className="w-4 h-4" />
<span>{currentUser?.callSign ? 'Update' : 'Set'} Call Sign</span>
</DropdownMenuItem> </DropdownMenuItem>
) : ( ) : (
<DropdownMenuItem <DropdownMenuItem onClick={handleOpenWizard}>
onClick={handleOpenWizard} Setup Wizard
className="flex items-center space-x-2"
>
<Settings className="w-4 h-4" />
<span>Setup Wizard</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
@ -330,12 +205,8 @@ const Header = () => {
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<DropdownMenuItem <DropdownMenuItem onSelect={e => e.preventDefault()} className="text-orange-400 focus:text-orange-400">
onSelect={e => e.preventDefault()} Clear Database
className="flex items-center space-x-2 text-orange-400 focus:text-orange-400"
>
<Trash2 className="w-4 h-4" />
<span>Clear Database</span>
</DropdownMenuItem> </DropdownMenuItem>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent className="bg-[#050505] border border-border text-foreground"> <AlertDialogContent className="bg-[#050505] border border-border text-foreground">
@ -369,181 +240,22 @@ const Header = () => {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<DropdownMenuItem <DropdownMenuItem onClick={handleDisconnect} className="text-red-400 focus:text-red-400">
onClick={handleDisconnect} {currentUser?.verificationStatus === EVerificationStatus.ANONYMOUS ? 'Exit Anonymous' : 'Disconnect'}
className="flex items-center space-x-2 text-red-400 focus:text-red-400"
>
<LogOut className="w-4 h-4" />
<span>{currentUser?.verificationStatus === EVerificationStatus.ANONYMOUS ? 'Exit Anonymous' : 'Disconnect'}</span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
) : ( ) : (
<Button <button
onClick={handleConnect} onClick={handleConnect}
className="text-primary border-primary hover:bg-primary/10 text-[10px] sm:text-[11px] px-2 sm:px-3" className="text-primary hover:underline text-[10px]"
> >
<span className="hidden sm:inline">Connect</span> CONNECT
<span className="sm:hidden">CON</span> </button>
</Button>
)} )}
{/* Mobile Menu Toggle */}
<Button
variant="ghost"
size="sm"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden border-border text-foreground p-2"
>
{mobileMenuOpen ? (
<X className="w-4 h-4 sm:w-5 sm:h-5" />
) : (
<Menu className="w-4 h-4 sm:w-5 sm:h-5" />
)}
</Button>
</div> </div>
</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]">
<Link
to="/"
className={`flex items-center space-x-2 px-4 py-2 border-b ${
location.pathname === '/'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Home className="w-4 h-4" />
<span>HOME</span>
</Link>
<Link
to="/cells"
className={`flex items-center space-x-2 px-4 py-2 border-b ${
location.pathname === '/cells'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Grid3X3 className="w-4 h-4" />
<span>CELLS</span>
</Link>
{isConnected && (
<>
<Link
to="/bookmarks"
className={`flex items-center space-x-2 px-4 py-2 border-b ${
location.pathname === '/bookmarks'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Bookmark className="w-4 h-4" />
<span>BOOKMARKS</span>
</Link>
</>
)}
</nav>
</div>
{/* Mobile Navigation */}
{mobileMenuOpen && (
<div className="md:hidden border-t border-border py-4 space-y-2">
<nav className="space-y-1">
<Link
to="/"
className={`flex items-center space-x-3 px-4 py-3 border ${
location.pathname === '/'
? 'border-primary text-primary'
: 'border-border text-muted-foreground'
}`}
onClick={() => setMobileMenuOpen(false)}
>
<Home className="w-4 h-4" />
<span>HOME</span>
</Link>
<Link
to="/cells"
className={`flex items-center space-x-3 px-4 py-3 border ${
location.pathname === '/cells'
? 'border-primary text-primary'
: 'border-border text-muted-foreground'
}`}
onClick={() => setMobileMenuOpen(false)}
>
<Grid3X3 className="w-4 h-4" />
<span>CELLS</span>
</Link>
{isConnected && (
<>
<Link
to="/bookmarks"
className={`flex items-center space-x-3 px-4 py-3 border ${
location.pathname === '/bookmarks'
? 'border-primary text-primary'
: 'border-border text-muted-foreground'
}`}
onClick={() => setMobileMenuOpen(false)}
>
<Bookmark className="w-4 h-4" />
<span>BOOKMARKS</span>
</Link>
<Link
to="/profile"
className={`flex items-center space-x-3 px-4 py-3 border ${
location.pathname === '/profile'
? 'border-primary text-primary'
: 'border-border text-muted-foreground'
}`}
onClick={() => setMobileMenuOpen(false)}
>
<User className="w-4 h-4" />
<span>PROFILE</span>
</Link>
</>
)}
</nav>
{/* Mobile Network Status */}
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center space-x-2 text-[10px] uppercase tracking-[0.2em] text-muted-foreground">
<WakuHealthDot />
<span>{statusMessage}</span>
{syncStatus === 'syncing' && syncDetail && syncDetail.missing > 0 && (
<span className="text-yellow-400 flex items-center space-x-1">
<Loader2 className="w-3 h-3 animate-spin" />
<span>SYNCING ({syncDetail.missing})</span>
</span>
)}
{content.lastSync && (
<span className="ml-auto" title="Last message sync">
{new Date(content.lastSync).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</span>
)}
</div>
</div>
</div>
)}
</div> </div>
</header> </header>

View File

@ -1,14 +1,8 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ArrowUp, ArrowDown, MessageSquare } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import type { Post } from '@opchan/core'; import type { Post } from '@opchan/core';
import { RelevanceIndicator } from '@/components/ui/relevance-indicator';
import { AuthorDisplay } from '@/components/ui/author-display';
import { BookmarkButton } from '@/components/ui/bookmark-button';
import { LinkRenderer } from '@/components/ui/link-renderer';
import { useAuth, useContent, usePermissions } from '@/hooks'; import { useAuth, useContent, usePermissions } from '@/hooks';
import { ShareButton } from '@/components/ui/ShareButton';
interface PostCardProps { interface PostCardProps {
post: Post; post: Post;
@ -72,99 +66,72 @@ const PostCard: React.FC<PostCardProps> = ({ post }) => {
}; };
return ( return (
<div className="border-b border-border/30 py-3 px-2 hover:bg-border/5"> <div className="border-b border-border/30 py-1.5 px-2 text-xs">
<div className="flex gap-3"> <div className="flex items-start gap-2">
{/* Vote column - compact */} {/* Inline vote display */}
<div className="flex flex-col items-center gap-0.5 text-xs min-w-[40px]"> <button
<button className={`${userUpvoted ? 'text-primary' : 'text-muted-foreground'} hover:text-primary`}
className={`hover:text-primary ${ onClick={e => handleVote(e, true)}
userUpvoted ? 'text-primary' : 'text-muted-foreground' disabled={!permissions.canVote}
}`} title={permissions.canVote ? 'Upvote' : permissions.reasons.vote}
onClick={e => handleVote(e, true)} >
disabled={!permissions.canVote}
title={permissions.canVote ? 'Upvote' : permissions.reasons.vote} </button>
> <span className={`font-mono text-xs min-w-[2ch] text-center ${score > 0 ? 'text-primary' : score < 0 ? 'text-red-400' : 'text-muted-foreground'}`}>
{score}
</button> </span>
<span className={`font-mono text-xs ${score > 0 ? 'text-primary' : score < 0 ? 'text-red-400' : 'text-muted-foreground'}`}> <button
{score} className={`${userDownvoted ? 'text-blue-400' : 'text-muted-foreground'} hover:text-blue-400`}
</span> onClick={e => handleVote(e, false)}
<button disabled={!permissions.canVote}
className={`hover:text-blue-400 ${ title={permissions.canVote ? 'Downvote' : permissions.reasons.vote}
userDownvoted ? 'text-blue-400' : 'text-muted-foreground' >
}`}
onClick={e => handleVote(e, false)} </button>
disabled={!permissions.canVote}
title={permissions.canVote ? 'Downvote' : permissions.reasons.vote}
>
</button>
</div>
{/* Content */} {/* Content - all inline */}
<div className="flex-1 min-w-0 text-xs"> <div className="flex-1 min-w-0">
{/* Title */} <div className="flex flex-wrap items-baseline gap-1">
<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>
{/* Metadata line */}
<div className="flex flex-wrap items-center gap-1 text-[11px] text-muted-foreground mb-2">
<Link <Link
to={cellName ? `/cell/${post.cellId}` : '#'} to={cellName ? `/cell/${post.cellId}` : '#'}
className="text-primary hover:underline" className="text-primary hover:underline text-[10px]"
onClick={e => { onClick={e => {
if (!cellName) e.preventDefault(); if (!cellName) e.preventDefault();
}} }}
> >
r/{cellName} r/{cellName}
</Link> </Link>
<span>·</span> <span className="text-muted-foreground">·</span>
<AuthorDisplay <Link to={`/post/${post.id}`} className="text-foreground hover:underline font-medium">
address={post.author} {post.title}
className="text-[11px]" </Link>
showBadge={false} <span className="text-muted-foreground text-[10px]">
/> by {post.author.slice(0, 6)}...{post.author.slice(-4)}
<span>·</span> </span>
<span className="text-muted-foreground/80"> <span className="text-muted-foreground text-[10px]">·</span>
<span className="text-muted-foreground text-[10px]">
{formatDistanceToNow(new Date(post.timestamp), { {formatDistanceToNow(new Date(post.timestamp), {
addSuffix: true, addSuffix: true,
})} })}
</span> </span>
{isPending && ( <span className="text-muted-foreground text-[10px]">·</span>
<> <Link to={`/post/${post.id}`} className="text-muted-foreground hover:underline text-[10px]">
<span>·</span>
<span className="text-yellow-400 text-[10px]">syncing</span>
</>
)}
</div>
{/* Content preview */}
{contentPreview && (
<p className="text-muted-foreground text-xs leading-relaxed mb-2">
<LinkRenderer text={contentPreview} />
</p>
)}
{/* 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'} {commentCount} {commentCount === 1 ? 'reply' : 'replies'}
</Link> </Link>
<span className="text-muted-foreground text-[10px]">·</span>
<button <button
onClick={handleBookmark} onClick={handleBookmark}
disabled={bookmarkLoading} disabled={bookmarkLoading}
className="hover:underline" className="text-muted-foreground hover:underline text-[10px]"
> >
{isBookmarked ? 'unsave' : 'save'} {isBookmarked ? 'unsave' : 'save'}
</button> </button>
<ShareButton {isPending && (
size="sm" <>
url={`${window.location.origin}/post/${post.id}`} <span className="text-muted-foreground text-[10px]">·</span>
title={post.title} <span className="text-yellow-400 text-[10px]">syncing</span>
/> </>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -9,28 +9,17 @@ import type {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Skeleton } from '@/components/ui/skeleton';
import { LinkRenderer } from '@/components/ui/link-renderer'; import { LinkRenderer } from '@/components/ui/link-renderer';
import { RelevanceIndicator } from '@/components/ui/relevance-indicator';
import { ShareButton } from '@/components/ui/ShareButton';
import { import {
ArrowLeft, ArrowLeft,
MessageSquare, MessageSquare,
MessageCircle, MessageCircle,
ArrowUp, ArrowUp,
ArrowDown, ArrowDown,
RefreshCw,
MessageSquareX, MessageSquareX,
UserX, UserX,
} from 'lucide-react'; } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { CypherImage } from './ui/CypherImage';
import { AuthorDisplay } from './ui/author-display';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { InlineCallSignInput } from './ui/inline-callsign-input'; import { InlineCallSignInput } from './ui/inline-callsign-input';
import { EVerificationStatus } from '@opchan/core'; import { EVerificationStatus } from '@opchan/core';
@ -48,55 +37,14 @@ const PostList = () => {
const [newPostTitle, setNewPostTitle] = useState(''); const [newPostTitle, setNewPostTitle] = useState('');
const [newPostContent, setNewPostContent] = useState(''); const [newPostContent, setNewPostContent] = useState('');
if (!cellId) { if (!cellId || !cell) {
return ( return (
<div className="container mx-auto px-4 py-8 max-w-4xl"> <div className="w-full mx-auto px-2 py-2 max-w-4xl">
<div className="mb-6"> <Link to="/" className="text-primary hover:underline text-xs">
<Link Back
to="/" </Link>
className="text-cyber-accent hover:underline flex items-center gap-1 text-sm" <div className="py-4 text-xs text-muted-foreground">
> {!cellId ? 'Loading...' : 'Cell not found'}
<ArrowLeft className="w-4 h-4" /> Back to Cells
</Link>
</div>
<Skeleton className="h-8 w-32 mb-6 bg-cyber-muted" />
<Skeleton className="h-6 w-64 mb-6 bg-cyber-muted" />
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="border border-cyber-muted rounded-sm p-4">
<div className="mb-2">
<Skeleton className="h-6 w-full mb-2 bg-cyber-muted" />
<Skeleton className="h-6 w-3/4 mb-2 bg-cyber-muted" />
</div>
<Skeleton className="h-4 w-32 bg-cyber-muted" />
</div>
))}
</div>
</div>
);
}
if (!cell) {
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-6">
<Link
to="/"
className="text-cyber-accent hover:underline flex items-center gap-1 text-sm"
>
<ArrowLeft className="w-4 h-4" /> Back to Cells
</Link>
</div>
<div className="p-8 text-center">
<h1 className="text-2xl font-bold mb-4">Cell Not Found</h1>
<p className="text-cyber-neutral mb-6">
The cell you're looking for doesn't exist.
</p>
<Button asChild>
<Link to="/">Return to Cells</Link>
</Button>
</div> </div>
</div> </div>
); );
@ -181,236 +129,131 @@ const PostList = () => {
}; };
return ( return (
<div className="page-main"> <div className="w-full mx-auto px-2 py-2 max-w-4xl">
<div className="content-spacing"> <div className="mb-2 pb-1 border-b border-border/30 flex items-center justify-between text-xs">
<Link <div className="flex items-center gap-2">
to="/" <Link to="/" className="text-primary hover:underline">
className="text-cyber-accent hover:underline flex items-center gap-1 text-sm" Back
> </Link>
<ArrowLeft className="w-4 h-4" /> Back to Cells <span className="text-muted-foreground">|</span>
</Link> <span className="font-semibold text-foreground">r/{cell.name}</span>
</div> <span className="text-muted-foreground text-[10px]">{cell.description}</span>
<div className="flex gap-4 items-start content-spacing">
<CypherImage
src={cell.icon}
alt={cell.name}
className="w-12 h-12 object-cover rounded-sm border border-cyber-muted"
generateUniqueFallback={true}
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<h1 className="page-title text-glow">{cell.name}</h1>
<Button
variant="outline"
size="icon"
onClick={refresh}
disabled={false}
title="Refresh data"
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
<p className="page-subtitle">{cell.description}</p>
</div> </div>
<button
onClick={refresh}
className="text-muted-foreground hover:text-foreground text-[10px]"
>
refresh
</button>
</div> </div>
{canPost && ( {canPost && (
<div className="section-spacing"> <div className="mb-2 border-b border-border/30 pb-2">
<form onSubmit={handleCreatePost} onKeyDown={handleKeyDown}> <form onSubmit={handleCreatePost} onKeyDown={handleKeyDown}>
<h2 className="text-sm font-bold mb-2 flex items-center gap-1"> <div className="text-[10px] font-semibold mb-1">NEW THREAD</div>
<MessageSquare className="w-4 h-4" /> <Input
New Thread placeholder="Title"
</h2> value={newPostTitle}
<div className="mb-3"> onChange={e => setNewPostTitle(e.target.value)}
<Input className="mb-1 text-xs h-7"
placeholder="Thread title" disabled={isCreatingPost}
value={newPostTitle} />
onChange={e => setNewPostTitle(e.target.value)} <Textarea
className="mb-3 bg-cyber-muted/50 border-cyber-muted" placeholder="Content"
disabled={isCreatingPost} value={newPostContent}
/> onChange={e => setNewPostContent(e.target.value)}
<Textarea className="text-xs resize-none h-16"
placeholder="What's on your mind?" disabled={isCreatingPost}
value={newPostContent} />
onChange={e => setNewPostContent(e.target.value)} <div className="flex justify-end mt-1">
className="bg-cyber-muted/50 border-cyber-muted resize-none" <button
disabled={isCreatingPost}
/>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground mt-1 mb-2">
<span>
Press Enter for newline Ctrl+Enter or Shift+Enter to post
</span>
<span />
</div>
<div className="flex justify-end">
<Button
type="submit" type="submit"
disabled={ disabled={
isCreatingPost || isCreatingPost ||
!newPostContent.trim() || !newPostContent.trim() ||
!newPostTitle.trim() !newPostTitle.trim()
} }
className="text-primary hover:underline text-[10px] disabled:opacity-50"
> >
{isCreatingPost ? 'Posting...' : 'Post Thread'} {isCreatingPost ? 'posting...' : 'post'}
</Button> </button>
</div> </div>
</form> </form>
</div> </div>
)} )}
{/* Inline Call Sign Suggestion for Anonymous Users */}
{currentUser?.verificationStatus === EVerificationStatus.ANONYMOUS && !currentUser.callSign && canPost && (
<div className="section-spacing">
<InlineCallSignInput />
</div>
)}
{!canPost && !currentUser && ( {!canPost && !currentUser && (
<div className="section-spacing content-card-sm text-center"> <div className="mb-2 py-2 text-xs text-center text-muted-foreground border-b border-border/30">
<p className="text-sm mb-3">Connect your wallet to post</p> Connect wallet to post
<Button asChild size="sm">
<Link to="/">Connect Wallet</Link>
</Button>
</div> </div>
)} )}
<div className="space-y-4"> <div>
{visiblePosts.length === 0 ? ( {visiblePosts.length === 0 ? (
<div className="empty-state"> <div className="py-4 text-xs text-muted-foreground text-center">
<MessageCircle className="empty-state-icon text-cyber-neutral opacity-50" /> No threads yet. {canPost ? 'Be the first to post!' : 'Connect wallet to post.'}
<h2 className="empty-state-title">No Threads Yet</h2>
<p className="empty-state-description">
{canPost
? 'Be the first to post in this cell!'
: 'Connect your wallet to start a thread.'}
</p>
</div> </div>
) : ( ) : (
visiblePosts.map((post: ForumPost) => ( visiblePosts.map((post: ForumPost) => (
<div key={post.id} className="thread-card"> <div key={post.id} className="border-b border-border/30 py-1.5 text-xs">
<div className="flex gap-4"> <div className="flex items-start gap-2">
<div className="flex flex-col items-center"> <button
<button className={`${getPostVoteType(post.id) === 'upvote' ? 'text-primary' : 'text-muted-foreground'} hover:text-primary`}
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${getPostVoteType(post.id) === 'upvote' ? 'text-cyber-accent' : ''}`} onClick={() => handleVotePost(post.id, true)}
onClick={() => handleVotePost(post.id, true)} disabled={!canVote || isVoting}
disabled={!canVote || isVoting} >
title={canVote ? 'Upvote' : 'Connect your wallet to vote'}
> </button>
<ArrowUp className="w-4 h-4" /> <span className={`font-mono text-xs min-w-[2ch] text-center ${(post.upvotes.length - post.downvotes.length) > 0 ? 'text-primary' : 'text-muted-foreground'}`}>
</button> {post.upvotes.length - post.downvotes.length}
<span className="text-sm py-1"> </span>
{post.upvotes.length - post.downvotes.length} <button
</span> className={`${getPostVoteType(post.id) === 'downvote' ? 'text-blue-400' : 'text-muted-foreground'} hover:text-blue-400`}
<button onClick={() => handleVotePost(post.id, false)}
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${getPostVoteType(post.id) === 'downvote' ? 'text-cyber-accent' : ''}`} disabled={!canVote || isVoting}
onClick={() => handleVotePost(post.id, false)} >
disabled={!canVote || isVoting}
title={canVote ? 'Downvote' : 'Connect your wallet to vote'} </button>
>
<ArrowDown className="w-4 h-4" />
</button>
</div>
<div className="flex-1"> <div className="flex-1 min-w-0">
<Link to={`/post/${post.id}`} className="block"> <div className="flex flex-wrap items-baseline gap-1">
<h2 className="text-lg font-bold hover:text-cyber-accent"> <Link to={`/post/${post.id}`} className="text-foreground hover:underline font-medium">
{post.title} {post.title}
</h2> </Link>
<p className="line-clamp-2 text-sm mb-3"> <span className="text-muted-foreground text-[10px]">
<LinkRenderer text={post.content} /> by {post.author.slice(0, 6)}...{post.author.slice(-4)}
</p> </span>
<div className="flex items-center gap-4 text-xs text-cyber-neutral"> <span className="text-muted-foreground text-[10px]">·</span>
<span> <span className="text-muted-foreground text-[10px]">
{formatDistanceToNow(post.timestamp, { {formatDistanceToNow(post.timestamp, { addSuffix: true })}
addSuffix: true, </span>
})} <span className="text-muted-foreground text-[10px]">·</span>
</span> <Link to={`/post/${post.id}`} className="text-muted-foreground hover:underline text-[10px]">
<span>by </span> {commentsByPost[post.id]?.length || 0} comments
<AuthorDisplay </Link>
address={post.author} {canModerate(cell.id) && !post.moderated && (
className="text-xs" <>
showBadge={false} <span className="text-muted-foreground text-[10px]">·</span>
/> <button
<span></span>
<span>
<MessageSquare className="inline w-3 h-3 mr-1" />
{commentsByPost[post.id]?.length || 0} comments
</span>
{typeof post.relevanceScore === 'number' && (
<>
<span></span>
<RelevanceIndicator
score={post.relevanceScore}
details={post.relevanceDetails}
type="post"
showTooltip={true}
/>
</>
)}
<ShareButton
url={`${window.location.origin}/post/${post.id}`}
title={post.title}
description={post.content}
size="sm"
variant="ghost"
className="text-cyber-neutral"
showText={false}
/>
</div>
</Link>
{canModerate(cell.id) && !post.moderated && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-cyber-neutral hover:text-orange-500"
onClick={() => handleModerate(post.id)} onClick={() => handleModerate(post.id)}
className="text-orange-400 hover:underline text-[10px]"
> >
<MessageSquareX className="h-3 w-3" /> moderate
</Button> </button>
</TooltipTrigger> </>
<TooltipContent> )}
<p>Moderate post</p> {canModerate(cell.id) && post.moderated && (
</TooltipContent> <>
</Tooltip> <span className="text-muted-foreground text-[10px]">·</span>
)} <button
{canModerate(cell.id) && post.author !== cell.author && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-cyber-neutral hover:text-red-500"
onClick={() => handleModerateUser(post.author)}
>
<UserX className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Moderate user</p>
</TooltipContent>
</Tooltip>
)}
{canModerate(cell.id) && post.moderated && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-6 px-2 text-cyber-neutral hover:text-green-500"
onClick={() => handleUnmoderate(post.id)} onClick={() => handleUnmoderate(post.id)}
className="text-green-400 hover:underline text-[10px]"
> >
Unmoderate unmoderate
</Button> </button>
</TooltipTrigger> </>
<TooltipContent> )}
<p>Unmoderate post</p> </div>
</TooltipContent>
</Tooltip>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -33,3 +33,5 @@ export const GreentextRenderer: React.FC<GreentextRendererProps> = ({
); );
}; };

View File

@ -139,7 +139,7 @@
} }
.page-header { .page-header {
@apply mb-4 sm:mb-6 border-b border-border pb-3 sm:pb-4; @apply mb-2 sm:mb-3 border-b border-border pb-2 sm:pb-3;
} }
.page-title { .page-title {
@ -151,53 +151,53 @@
} }
.page-content { .page-content {
@apply flex-1 pt-8 sm:pt-10; @apply flex-1 pt-4 sm:pt-6;
} }
.page-main { .page-main {
@apply w-full mx-auto px-3 sm:px-4 md:px-8 py-4 sm:py-6 md:py-8 max-w-5xl; @apply w-full mx-auto px-2 sm:px-3 md:px-4 py-2 sm:py-3 md:py-4 max-w-5xl;
} }
.page-footer { .page-footer {
@apply border-t border-border py-2 sm:py-3 text-center text-[10px] 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 */ /* Card Components - Simplified to flat list items */
.card-base { .card-base {
@apply bg-transparent border border-border/70 rounded-none shadow-none; @apply bg-transparent border-0 rounded-none shadow-none;
} }
.card-hover { .card-hover {
@apply hover:bg-white/5; @apply hover:bg-transparent;
} }
.card-padding { .card-padding {
@apply p-3 sm:p-4; @apply p-2;
} }
.card-padding-sm { .card-padding-sm {
@apply p-2 sm:p-3; @apply p-1;
} }
.thread-card { .thread-card {
@apply card-base card-hover card-padding-sm mb-3; @apply border-b border-border/30 py-2 mb-0;
} }
.board-card { .board-card {
@apply card-base card-hover card-padding-sm mb-2; @apply border-b border-border/30 py-2 mb-0;
} }
.comment-card { .comment-card {
@apply border-l border-border pl-3 py-2 my-2; @apply border-l border-border pl-2 py-1 my-1;
} }
/* Content Cards */ /* Content Cards */
.content-card { .content-card {
@apply card-base card-hover card-padding; @apply border-b border-border/30 py-2;
} }
.content-card-sm { .content-card-sm {
@apply card-base card-hover card-padding-sm; @apply border-b border-border/30 py-1;
} }
/* Status Indicators */ /* Status Indicators */
@ -227,17 +227,17 @@
} }
/* Typography */ /* Typography */
/* Spacing Utilities */ /* Spacing Utilities - Simplified for raw layout */
.section-spacing { .section-spacing {
@apply mb-8; @apply mb-4;
} }
.content-spacing { .content-spacing {
@apply mb-6; @apply mb-3;
} }
.item-spacing { .item-spacing {
@apply mb-4; @apply mb-2;
} }
/* Grid Layouts */ /* Grid Layouts */
@ -253,21 +253,21 @@
@apply lg:col-span-1; @apply lg:col-span-1;
} }
/* Empty States */ /* Empty States - Simplified */
.empty-state { .empty-state {
@apply flex flex-col items-center justify-center py-8 sm:py-12 md:py-16 px-3 sm:px-4 text-center; @apply py-4 px-2 text-center;
} }
.empty-state-icon { .empty-state-icon {
@apply w-12 h-12 sm:w-16 sm:h-16 text-cyber-accent/50 mb-3 sm:mb-4; @apply hidden;
} }
.empty-state-title { .empty-state-title {
@apply text-base sm:text-lg md:text-xl font-mono font-bold text-white mb-2; @apply text-xs font-mono text-muted-foreground mb-1;
} }
.empty-state-description { .empty-state-description {
@apply text-sm sm:text-base text-cyber-neutral mb-3 sm:mb-4 px-2; @apply text-xs text-muted-foreground mb-2;
} }
} }

View File

@ -1,7 +1,4 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { RefreshCw, TrendingUp, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { import {
Select, Select,
SelectContent, SelectContent,
@ -10,7 +7,6 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import PostCard from '@/components/PostCard'; import PostCard from '@/components/PostCard';
import FeedSidebar from '@/components/FeedSidebar';
import { ModerationToggle } from '@/components/ui/moderation-toggle'; import { ModerationToggle } from '@/components/ui/moderation-toggle';
import { useAuth, useContent, useNetwork } from '@/hooks'; import { useAuth, useContent, useNetwork } from '@/hooks';
import { EVerificationStatus } from '@opchan/core'; import { EVerificationStatus } from '@opchan/core';
@ -26,7 +22,7 @@ const FeedPage: React.FC = () => {
[content.posts, sortOption] [content.posts, sortOption]
); );
// Loading skeleton - only show if store is not yet hydrated // Simple loading text
if ( if (
!isHydrated && !isHydrated &&
!content.posts.length && !content.posts.length &&
@ -35,52 +31,8 @@ const FeedPage: React.FC = () => {
) { ) {
return ( return (
<div className="min-h-screen bg-cyber-dark"> <div className="min-h-screen bg-cyber-dark">
<div className="container mx-auto px-3 sm:px-4 py-4 sm:py-6 max-w-6xl"> <div className="container mx-auto px-2 py-4 max-w-4xl">
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6"> <div className="text-xs text-muted-foreground">Loading...</div>
{/* Main feed skeleton */}
<div className="flex-1 lg:max-w-3xl">
<div className="space-y-3 sm:space-y-4">
{[...Array(5)].map((_, i) => (
<div
key={i}
className="bg-cyber-muted/20 border border-cyber-muted rounded-sm p-3 sm:p-4"
>
<div className="flex gap-3 sm:gap-4">
<div className="w-8 sm:w-10 space-y-2 flex-shrink-0">
<Skeleton className="h-5 w-5 sm:h-6 sm:w-6" />
<Skeleton className="h-3 w-6 sm:h-4 sm:w-8" />
<Skeleton className="h-5 w-5 sm:h-6 sm:w-6" />
</div>
<div className="flex-1 space-y-2 min-w-0">
<Skeleton className="h-3 sm:h-4 w-2/3" />
<Skeleton className="h-5 sm:h-6 w-full" />
<Skeleton className="h-3 sm:h-4 w-full" />
<Skeleton className="h-3 sm:h-4 w-3/4" />
<Skeleton className="h-3 sm:h-4 w-1/3" />
</div>
</div>
</div>
))}
</div>
</div>
{/* Sidebar skeleton - hidden on mobile */}
<div className="hidden lg:block lg: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>
</div> </div>
); );
@ -88,89 +40,44 @@ const FeedPage: React.FC = () => {
return ( return (
<div className="page-container"> <div className="page-container">
<div className="page-main"> <div className="w-full mx-auto px-2 py-2 max-w-4xl">
{/* Page Header */} {/* Minimal Header */}
<div className="page-header"> <div className="mb-2 pb-1 border-b border-border/30 flex items-center justify-between text-xs">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0"> <div className="flex items-center gap-2">
<div> <span className="text-primary font-semibold">FEED</span>
<h1 className="page-title text-primary"> <ModerationToggle />
Popular Posts </div>
</h1> <div className="flex items-center gap-2">
<p className="page-subtitle hidden sm:block">Latest posts from all cells</p> <Select
</div> value={sortOption}
<div className="flex flex-wrap items-center gap-2 sm:gap-2"> onValueChange={(value: SortOption) => setSortOption(value)}
<ModerationToggle /> >
<SelectTrigger className="w-24 text-[10px] h-6">
<Select <SelectValue />
value={sortOption} </SelectTrigger>
onValueChange={(value: SortOption) => setSortOption(value)} <SelectContent>
> <SelectItem value="relevance">Relevance</SelectItem>
<SelectTrigger className="w-32 sm:w-40 text-[11px]"> <SelectItem value="time">Newest</SelectItem>
<SelectValue /> </SelectContent>
</SelectTrigger> </Select>
<SelectContent> <button
<SelectItem value="relevance"> onClick={content.refresh}
<div className="flex items-center gap-2"> className="text-muted-foreground hover:text-foreground text-[10px]"
<TrendingUp className="w-4 h-4" /> >
<span>Relevance</span> refresh
</div> </button>
</SelectItem>
<SelectItem value="time">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>Newest</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={content.refresh}
disabled={false}
className="flex items-center space-x-1 sm:space-x-2 text-[11px]"
>
<RefreshCw className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">Refresh</span>
</Button>
</div>
</div> </div>
</div> </div>
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6"> {/* Posts List */}
{/* Main Feed */} <div>
<div className="flex-1 lg:max-w-3xl min-w-0"> {allPosts.length === 0 ? (
{/* Posts Feed */} <div className="py-4 text-xs text-muted-foreground text-center">
<div className="space-y-0"> No posts yet. Connect wallet to post.
{allPosts.length === 0 ? (
<div className="empty-state">
<div className="space-y-3">
<h3 className="empty-state-title tracking-[0.25em]">
No posts yet
</h3>
<p className="empty-state-description">
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 try the reference client or fork it and build your own.
</p>
)}
</div>
</div>
) : (
allPosts.map(post => <PostCard key={post.id} post={post} />)
)}
</div> </div>
</div> ) : (
allPosts.map(post => <PostCard key={post.id} post={post} />)
{/* Sidebar */} )}
<div className="hidden lg:block lg:w-80 lg:flex-shrink-0">
<FeedSidebar />
</div>
</div> </div>
</div> </div>
</div> </div>