mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-07 07:13:11 +00:00
chore: raw UI
This commit is contained in:
parent
bf7b3f20a1
commit
d12a76ff99
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -33,3 +33,5 @@ export const GreentextRenderer: React.FC<GreentextRendererProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user