mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-02 12:53:10 +00:00
chore: mobile responsive + UI be more raw
This commit is contained in:
parent
a70b04f3e0
commit
82cf351920
982
MASTER_PROMPT.md
982
MASTER_PROMPT.md
@ -1,982 +0,0 @@
|
||||
# Master Prompt: Building a Decentralized Forum App with @opchan/react
|
||||
|
||||
## Objective
|
||||
Build a complete, production-ready decentralized forum application using the `@opchan/react` library. The app should support anonymous and wallet-based authentication, real-time content updates, and a modern, cyberpunk-inspired UI.
|
||||
|
||||
## Core Requirements
|
||||
|
||||
### 1. Authentication System
|
||||
|
||||
Implement a **dual-mode authentication system**:
|
||||
|
||||
#### Anonymous Mode
|
||||
- Users can browse and interact immediately without wallet
|
||||
- Generate browser-based session (UUID) with ed25519 keypair
|
||||
- Allow posting, commenting, and voting
|
||||
- Support optional call sign for identity
|
||||
- Session persists in IndexedDB
|
||||
|
||||
#### Wallet Mode
|
||||
- Support Ethereum wallet connection (MetaMask, WalletConnect, Coinbase)
|
||||
- Use wagmi v2.x for wallet management
|
||||
- Implement ENS verification for premium features
|
||||
- Support browser key delegation to reduce signature prompts
|
||||
|
||||
**Implementation Pattern:**
|
||||
```tsx
|
||||
const { currentUser, connect, startAnonymous, verificationStatus } = useAuth();
|
||||
|
||||
// Offer both options
|
||||
<button onClick={connect}>Connect Wallet</button>
|
||||
<button onClick={startAnonymous}>Continue Anonymously</button>
|
||||
|
||||
// Check verification level
|
||||
if (verificationStatus === EVerificationStatus.ANONYMOUS) {
|
||||
// Show call sign setup
|
||||
} else if (verificationStatus === EVerificationStatus.ENS_VERIFIED) {
|
||||
// Show cell creation
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Content Management
|
||||
|
||||
Implement a **hierarchical content system**:
|
||||
|
||||
- **Cells** - Discussion boards (require ENS to create)
|
||||
- **Posts** - Threads within cells (any authenticated user)
|
||||
- **Comments** - Replies to posts (any authenticated user)
|
||||
- **Votes** - Upvote/downvote (any authenticated user)
|
||||
|
||||
**Implementation Pattern:**
|
||||
```tsx
|
||||
const { createPost, createComment, vote, posts, comments } = useContent();
|
||||
|
||||
// Create content
|
||||
await createPost({ cellId, title, content });
|
||||
await createComment({ postId, content });
|
||||
await vote({ targetId: postId, isUpvote: true });
|
||||
|
||||
// Display content with grouped data
|
||||
const { postsByCell, commentsByPost } = useContent();
|
||||
const cellPosts = postsByCell[cellId] || [];
|
||||
const postComments = commentsByPost[postId] || [];
|
||||
```
|
||||
|
||||
### 3. Permission System
|
||||
|
||||
Implement **role-based access control**:
|
||||
|
||||
| Action | Anonymous | Wallet | ENS Verified |
|
||||
|--------|-----------|--------|--------------|
|
||||
| View | ✅ | ✅ | ✅ |
|
||||
| Vote | ✅ | ✅ | ✅ |
|
||||
| Comment | ✅ | ✅ | ✅ |
|
||||
| Post | ✅ | ✅ | ✅ |
|
||||
| Create Cell | ❌ | ❌ | ✅ |
|
||||
| Moderate | ❌ | ❌ | ✅ (own cells) |
|
||||
|
||||
**Implementation Pattern:**
|
||||
```tsx
|
||||
const { canPost, canComment, canCreateCell, canModerate, check } = usePermissions();
|
||||
|
||||
// Show/hide UI based on permissions
|
||||
{canCreateCell && <CreateCellButton />}
|
||||
|
||||
// Detailed permission check
|
||||
const { allowed, reason } = check('canPost');
|
||||
{!allowed && <p>{reason}</p>}
|
||||
|
||||
// Conditional features
|
||||
{canModerate(cellId) && <ModerationTools />}
|
||||
```
|
||||
|
||||
### 4. User Identity Display
|
||||
|
||||
Implement **flexible identity rendering** for both wallet and anonymous users:
|
||||
|
||||
**Implementation Pattern:**
|
||||
```tsx
|
||||
const { displayName, callSign, ensName, ensAvatar } = useUserDisplay(address);
|
||||
|
||||
// Render user identity
|
||||
<div>
|
||||
{ensAvatar && <img src={ensAvatar} />}
|
||||
<span>{displayName}</span>
|
||||
|
||||
{/* Verification badges */}
|
||||
{callSign && <Badge><Hash /> Call Sign</Badge>}
|
||||
{ensName && <Badge><Crown /> ENS</Badge>}
|
||||
{isAnonymous(address) && <Badge><UserX /> Anonymous</Badge>}
|
||||
</div>
|
||||
|
||||
// Detect anonymous users
|
||||
function isAnonymous(address: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(address);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Onboarding Flow
|
||||
|
||||
Create a **3-step wizard for wallet users**, **instant access for anonymous**:
|
||||
|
||||
**Wallet Wizard Steps:**
|
||||
1. **Connect Wallet** - Choose wallet connector
|
||||
2. **Verify Ownership** - Check for ENS (optional, can skip)
|
||||
3. **Delegate Key** - Generate browser keys for better UX
|
||||
|
||||
**Anonymous Flow:**
|
||||
- Click "Continue Anonymously" → Wizard closes immediately
|
||||
- Show call sign setup in header dropdown
|
||||
- Allow interaction immediately
|
||||
|
||||
**Implementation Pattern:**
|
||||
```tsx
|
||||
// In wallet wizard
|
||||
const handleStepComplete = (step) => {
|
||||
if (step === 1 && verificationStatus === EVerificationStatus.ANONYMOUS) {
|
||||
// Close wizard immediately for anonymous users
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
// Continue to next step for wallet users
|
||||
setCurrentStep(step + 1);
|
||||
};
|
||||
```
|
||||
|
||||
### 6. Real-Time Updates
|
||||
|
||||
Implement **optimistic UI updates** with network sync:
|
||||
|
||||
**Implementation Pattern:**
|
||||
```tsx
|
||||
const { pending, lastSync } = useContent();
|
||||
const isPending = pending.isPending(post.id);
|
||||
|
||||
// Show pending state
|
||||
{isPending && <Loader2 className="animate-spin" />}
|
||||
|
||||
// Listen to pending changes
|
||||
useEffect(() => {
|
||||
return pending.onChange(() => {
|
||||
// Update UI when pending state changes
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Network status
|
||||
{!network.isConnected && <button onClick={refresh}>Reconnect</button>}
|
||||
```
|
||||
|
||||
### 7. Call Sign System
|
||||
|
||||
Allow **all users (wallet and anonymous) to set call signs**:
|
||||
|
||||
**Implementation Patterns:**
|
||||
```tsx
|
||||
// Anonymous users: Show inline prompt
|
||||
{currentUser?.verificationStatus === EVerificationStatus.ANONYMOUS &&
|
||||
!currentUser.callSign && (
|
||||
<InlineCallSignInput />
|
||||
)}
|
||||
|
||||
// All users: Header dropdown option
|
||||
<DropdownMenuItem onClick={() => setCallSignDialogOpen(true)}>
|
||||
{currentUser?.callSign ? 'Update' : 'Set'} Call Sign
|
||||
</DropdownMenuItem>
|
||||
|
||||
// Update profile
|
||||
await updateProfile({ callSign: 'my_username' });
|
||||
|
||||
// Display shows call sign when available
|
||||
{callSign || 'Anonymous User'}
|
||||
```
|
||||
|
||||
### 8. Moderation System
|
||||
|
||||
Implement **cell-based moderation** (no global censorship):
|
||||
|
||||
**Implementation Pattern:**
|
||||
```tsx
|
||||
const { moderate } = useContent();
|
||||
const { canModerate } = usePermissions();
|
||||
|
||||
if (canModerate(cellId)) {
|
||||
// Moderate content
|
||||
await moderate.post(cellId, postId, 'Spam');
|
||||
await moderate.comment(cellId, commentId, 'Off-topic');
|
||||
await moderate.user(cellId, userAddress, 'Harassment');
|
||||
|
||||
// Unmoderate
|
||||
await moderate.unpost(cellId, postId);
|
||||
}
|
||||
|
||||
// Hide moderated content by default
|
||||
const visiblePosts = posts.filter(p => !p.moderated);
|
||||
|
||||
// Admin toggle to show moderated
|
||||
const [showModerated, setShowModerated] = useUIState('showModerated', false);
|
||||
const displayPosts = showModerated ? posts : visiblePosts;
|
||||
```
|
||||
|
||||
### 9. Relevance Scoring UI
|
||||
|
||||
Display **relevance scores** to help users find quality content:
|
||||
|
||||
**Implementation Pattern:**
|
||||
```tsx
|
||||
// Sort by relevance
|
||||
const sortedPosts = [...posts].sort((a, b) =>
|
||||
(b.relevanceScore || 0) - (a.relevanceScore || 0)
|
||||
);
|
||||
|
||||
// Show relevance details
|
||||
{post.relevanceDetails && (
|
||||
<div>
|
||||
<span>Score: {post.relevanceDetails.finalScore.toFixed(1)}</span>
|
||||
<span>Upvotes: {post.relevanceDetails.upvotes}</span>
|
||||
<span>Verified: {post.relevanceDetails.verifiedUpvotes}</span>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 10. Network Status Indicator
|
||||
|
||||
Show **Waku network connection state**:
|
||||
|
||||
**Implementation Pattern:**
|
||||
```tsx
|
||||
const { isConnected, statusMessage, isHydrated } = useNetwork();
|
||||
|
||||
// Health indicator
|
||||
<div className={isConnected ? 'text-green-500' : 'text-red-500'}>
|
||||
<WakuHealthDot />
|
||||
<span>{statusMessage}</span>
|
||||
</div>
|
||||
|
||||
// Wait for hydration before showing content
|
||||
{isHydrated ? <Content /> : <LoadingSkeleton />}
|
||||
```
|
||||
|
||||
## Critical Implementation Details
|
||||
|
||||
### State Preservation for Anonymous Users
|
||||
|
||||
**Problem**: Wallet sync effects may clear anonymous users
|
||||
**Solution**: Check verification status before clearing:
|
||||
|
||||
```tsx
|
||||
// In useAuth hook
|
||||
useEffect(() => {
|
||||
if (!wallet.isConnected && currentUser &&
|
||||
currentUser.verificationStatus !== EVerificationStatus.ANONYMOUS) {
|
||||
// Only clear non-anonymous users
|
||||
clearUser();
|
||||
}
|
||||
}, [wallet.isConnected, currentUser]);
|
||||
```
|
||||
|
||||
### Verification Status Preservation
|
||||
|
||||
**Problem**: Profile updates may reset verification status
|
||||
**Solution**: Force preservation when updating:
|
||||
|
||||
```tsx
|
||||
const updateProfile = async (updates) => {
|
||||
const res = await client.userIdentityService.updateProfile(address, updates);
|
||||
const updated = {
|
||||
...currentUser,
|
||||
...res.identity,
|
||||
verificationStatus: currentUser.verificationStatus, // Preserve!
|
||||
};
|
||||
setOpchanState(prev => ({ ...prev, session: { ...prev.session, currentUser: updated } }));
|
||||
};
|
||||
```
|
||||
|
||||
### Verification Status Mapping
|
||||
|
||||
**Problem**: Status mapper doesn't handle ANONYMOUS
|
||||
**Solution**: Add ANONYMOUS case:
|
||||
|
||||
```tsx
|
||||
private mapVerificationStatus(status: string): EVerificationStatus {
|
||||
switch (status) {
|
||||
case EVerificationStatus.ANONYMOUS:
|
||||
return EVerificationStatus.ANONYMOUS; // Add this!
|
||||
case EVerificationStatus.WALLET_CONNECTED:
|
||||
return EVerificationStatus.WALLET_CONNECTED;
|
||||
// ... other cases
|
||||
default:
|
||||
return EVerificationStatus.WALLET_UNCONNECTED;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Message Verification
|
||||
|
||||
**Problem**: Network peers must accept anonymous messages
|
||||
**Solution**: Make delegationProof optional in verification:
|
||||
|
||||
```tsx
|
||||
async verify(message: OpchanMessage): Promise<boolean> {
|
||||
// Verify message signature (always required)
|
||||
if (!verifyMessageSignature(message)) return false;
|
||||
|
||||
// If has delegationProof, verify it (wallet user)
|
||||
if (message.delegationProof) {
|
||||
return verifyDelegationProof(message.delegationProof, message.author);
|
||||
}
|
||||
|
||||
// Anonymous message - verify session ID format
|
||||
return isValidSessionId(message.author);
|
||||
}
|
||||
```
|
||||
|
||||
## UI/UX Guidelines
|
||||
|
||||
### Design System
|
||||
- **Colors**: Dark theme with cyan/blue accents
|
||||
- **Typography**: Monospace fonts for technical aesthetic
|
||||
- **Components**: Use shadcn/ui patterns
|
||||
- **Spacing**: Consistent spacing with Tailwind scale
|
||||
- **Icons**: Lucide React icons
|
||||
|
||||
### Key UI Components to Build
|
||||
|
||||
1. **Header Component**
|
||||
- Logo/branding
|
||||
- Navigation (Home, Cells, Profile, Bookmarks)
|
||||
- Auth button (Connect/User dropdown)
|
||||
- Network status indicator
|
||||
- Call sign setup for anonymous users
|
||||
|
||||
2. **Wallet Wizard**
|
||||
- Step indicator (1/2/3)
|
||||
- Wallet connection (Step 1)
|
||||
- ENS verification (Step 2)
|
||||
- Key delegation (Step 3)
|
||||
- Anonymous bypass option
|
||||
|
||||
3. **Cell List**
|
||||
- Grid/list view of cells
|
||||
- Active member count
|
||||
- Recent activity indicator
|
||||
- "Create Cell" button (ENS only)
|
||||
|
||||
4. **Post List**
|
||||
- Post cards with vote buttons
|
||||
- Author display with badges
|
||||
- Relevance score indicator
|
||||
- Create post form (if has permission)
|
||||
- Call sign prompt (anonymous without call sign)
|
||||
|
||||
5. **Post Detail**
|
||||
- Full post content (markdown)
|
||||
- Vote buttons
|
||||
- Comment thread
|
||||
- Comment form
|
||||
- Moderation tools (if cell admin)
|
||||
|
||||
6. **Author Display**
|
||||
- Handle wallet addresses (0x...)
|
||||
- Handle anonymous (UUID pattern)
|
||||
- Show call sign when available
|
||||
- Show ENS when available
|
||||
- Badge system (ENS, Call Sign, Anonymous)
|
||||
|
||||
7. **Profile Page**
|
||||
- User information
|
||||
- Call sign management
|
||||
- Display preferences
|
||||
- Security section (wallet: delegation status, anonymous: session info)
|
||||
|
||||
8. **Inline Call Sign Input**
|
||||
- For anonymous users without call sign
|
||||
- Contextual placement (where interaction blocked)
|
||||
- Validation (3-20 chars, alphanumeric)
|
||||
- Immediate feedback
|
||||
|
||||
## Data Patterns
|
||||
|
||||
### Content Grouping
|
||||
|
||||
```tsx
|
||||
// Group posts by cell
|
||||
const { postsByCell } = useContent();
|
||||
const cellPosts = postsByCell[cellId] || [];
|
||||
|
||||
// Group comments by post
|
||||
const { commentsByPost } = useContent();
|
||||
const postComments = commentsByPost[postId] || [];
|
||||
|
||||
// User verification status cache
|
||||
const { userVerificationStatus } = useContent();
|
||||
const isVerified = userVerificationStatus[authorAddress]?.isVerified;
|
||||
```
|
||||
|
||||
### Bookmarks
|
||||
|
||||
```tsx
|
||||
const { bookmarks, togglePostBookmark, toggleCommentBookmark } = useContent();
|
||||
|
||||
// Check if bookmarked
|
||||
const isBookmarked = bookmarks.some(b => b.targetId === post.id && b.type === 'post');
|
||||
|
||||
// Toggle bookmark
|
||||
await togglePostBookmark(post, cellId);
|
||||
await toggleCommentBookmark(comment, postId);
|
||||
```
|
||||
|
||||
### Sorting
|
||||
|
||||
```tsx
|
||||
// Sort by relevance (default)
|
||||
const sorted = [...posts].sort((a, b) =>
|
||||
(b.relevanceScore || 0) - (a.relevanceScore || 0)
|
||||
);
|
||||
|
||||
// Sort by time
|
||||
const recent = [...posts].sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
// Sort by votes
|
||||
const popular = [...posts].sort((a, b) =>
|
||||
(b.upvotes?.length || 0) - (a.upvotes?.length || 0)
|
||||
);
|
||||
```
|
||||
|
||||
## Critical Success Factors
|
||||
|
||||
### ✅ Must Have
|
||||
|
||||
1. **Immediate Engagement** - Anonymous users can interact without barriers
|
||||
2. **Clear Permission Feedback** - Users know what they can/can't do and why
|
||||
3. **Identity Flexibility** - Support wallet addresses, ENS names, call signs, and anonymous
|
||||
4. **Optimistic UI** - Instant feedback with background sync
|
||||
5. **Network Resilience** - Handle disconnections gracefully
|
||||
6. **State Preservation** - Don't clear anonymous users on wallet events
|
||||
7. **Verification Preservation** - Maintain verification status through profile updates
|
||||
|
||||
### ⚠️ Common Pitfalls to Avoid
|
||||
|
||||
1. ❌ **Clearing anonymous sessions** - Check verification status before clearing users
|
||||
2. ❌ **Hardcoding wallet-only flows** - Always support anonymous mode
|
||||
3. ❌ **Forgetting to add ANONYMOUS to enums** - Update all status mappers
|
||||
4. ❌ **Blocking anonymous users** - They should interact freely (except cell creation)
|
||||
5. ❌ **Not showing call sign option** - Make it prominent for anonymous users
|
||||
6. ❌ **Complex onboarding** - Keep anonymous flow instant (no wizard steps)
|
||||
7. ❌ **Assuming addresses are 0x format** - Support both wallet addresses and UUIDs
|
||||
|
||||
## Complete Feature Checklist
|
||||
|
||||
### Authentication & Identity
|
||||
- [ ] Anonymous session creation
|
||||
- [ ] Wallet connection (Ethereum)
|
||||
- [ ] ENS verification
|
||||
- [ ] Key delegation (wallet users)
|
||||
- [ ] Call sign setup (all users)
|
||||
- [ ] Display preferences
|
||||
- [ ] Session persistence
|
||||
- [ ] Disconnect/exit flows
|
||||
|
||||
### Content Features
|
||||
- [ ] Cell creation (ENS only)
|
||||
- [ ] Post creation
|
||||
- [ ] Comment creation (nested threading)
|
||||
- [ ] Upvote/downvote
|
||||
- [ ] Bookmark management
|
||||
- [ ] Markdown support
|
||||
- [ ] Content sorting (relevance, new, top)
|
||||
- [ ] Author attribution
|
||||
|
||||
### Moderation
|
||||
- [ ] Moderate posts
|
||||
- [ ] Moderate comments
|
||||
- [ ] Moderate users
|
||||
- [ ] Admin-only access
|
||||
- [ ] Show/hide moderated toggle
|
||||
- [ ] Moderation reasons
|
||||
|
||||
### UI Components
|
||||
- [ ] Header with auth state
|
||||
- [ ] Wallet wizard (3 steps)
|
||||
- [ ] Cell list with stats
|
||||
- [ ] Post list/feed
|
||||
- [ ] Post detail with comments
|
||||
- [ ] Comment threads
|
||||
- [ ] Vote buttons
|
||||
- [ ] Author display with badges
|
||||
- [ ] Network status indicator
|
||||
- [ ] Loading states
|
||||
- [ ] Error handling
|
||||
- [ ] Call sign input (inline + dialog)
|
||||
- [ ] Profile page
|
||||
|
||||
### Developer Experience
|
||||
- [ ] TypeScript strict mode
|
||||
- [ ] Proper error handling
|
||||
- [ ] Loading states
|
||||
- [ ] Optimistic updates
|
||||
- [ ] Hot reload support
|
||||
- [ ] Build optimization
|
||||
|
||||
## Technical Stack
|
||||
|
||||
### Required Dependencies
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@opchan/react": "^1.1.0",
|
||||
"@opchan/core": "^1.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"wagmi": "^2.0.0",
|
||||
"viem": "^2.0.0",
|
||||
"buffer": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"tailwindcss": "^3.4.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Critical Setup Requirements
|
||||
|
||||
**⚠️ IMPORTANT: Provider Configuration**
|
||||
|
||||
The `OpChanProvider` must be properly configured to avoid the "useClient must be used within ClientProvider" error:
|
||||
|
||||
```tsx
|
||||
// ✅ CORRECT - Complete setup
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { OpChanProvider } from '@opchan/react';
|
||||
import { Buffer } from 'buffer';
|
||||
import App from './App';
|
||||
|
||||
// Required polyfill for crypto libraries
|
||||
if (!(window as any).Buffer) {
|
||||
(window as any).Buffer = Buffer;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<OpChanProvider
|
||||
config={{
|
||||
wakuConfig: {
|
||||
contentTopic: '/opchan/1/messages/proto',
|
||||
reliableChannelId: 'opchan-messages'
|
||||
},
|
||||
reownProjectId: 'your-reown-project-id' // ⚠️ REQUIRED for WalletConnect
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</OpChanProvider>
|
||||
);
|
||||
```
|
||||
|
||||
**Common Setup Mistakes:**
|
||||
|
||||
1. **Missing reownProjectId** - Causes wallet connection failures
|
||||
2. **Missing Buffer polyfill** - Causes crypto library errors
|
||||
3. **Provider not wrapping all components** - Causes "useClient must be used within ClientProvider" error
|
||||
4. **Using hooks outside provider** - All `@opchan/react` hooks must be inside `OpChanProvider`
|
||||
|
||||
**Environment Variables:**
|
||||
|
||||
```bash
|
||||
# .env
|
||||
VITE_REOWN_SECRET=your_reown_project_id_here
|
||||
```
|
||||
|
||||
**Vite Configuration (if using Vite):**
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
global: 'globalThis',
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['buffer'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Recommended UI Libraries
|
||||
|
||||
- **shadcn/ui** - Accessible component primitives
|
||||
- **Radix UI** - Unstyled accessible components
|
||||
- **Tailwind CSS** - Utility-first styling
|
||||
- **Lucide React** - Icon library
|
||||
- **date-fns** - Date formatting
|
||||
- **react-hook-form** - Form handling
|
||||
- **zod** - Schema validation
|
||||
|
||||
## Example App Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.tsx # Entry point with OpChanProvider
|
||||
├── App.tsx # Router setup
|
||||
├── hooks/
|
||||
│ └── index.ts # Re-export @opchan/react hooks
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui components
|
||||
│ │ ├── wallet-wizard.tsx # 3-step onboarding
|
||||
│ │ ├── wallet-connection-step.tsx
|
||||
│ │ ├── verification-step.tsx
|
||||
│ │ ├── delegation-step.tsx
|
||||
│ │ ├── author-display.tsx # User identity display
|
||||
│ │ ├── inline-callsign-input.tsx
|
||||
│ │ ├── call-sign-setup-dialog.tsx
|
||||
│ │ └── ... # Other UI primitives
|
||||
│ ├── Header.tsx # Navigation + auth
|
||||
│ ├── PostList.tsx # Post feed
|
||||
│ ├── PostDetail.tsx # Single post view
|
||||
│ ├── PostCard.tsx # Post preview
|
||||
│ ├── CommentCard.tsx # Comment display
|
||||
│ ├── CellList.tsx # Cell grid
|
||||
│ └── CreateCellDialog.tsx # Cell creation modal
|
||||
├── pages/
|
||||
│ ├── Dashboard.tsx # Landing page
|
||||
│ ├── Index.tsx # Cell list page
|
||||
│ ├── CellPage.tsx # Cell detail
|
||||
│ ├── PostPage.tsx # Post detail
|
||||
│ ├── ProfilePage.tsx # User profile
|
||||
│ ├── BookmarksPage.tsx # Saved content
|
||||
│ └── NotFound.tsx # 404 page
|
||||
└── utils/
|
||||
└── sorting.ts # Content sorting utilities
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Complete Anonymous Flow Implementation
|
||||
|
||||
```tsx
|
||||
// 1. Wallet Wizard with Anonymous Option
|
||||
export function WalletWizard() {
|
||||
const { startAnonymous, verificationStatus } = useAuth();
|
||||
|
||||
const handleAnonymous = async () => {
|
||||
const sessionId = await startAnonymous();
|
||||
if (sessionId) {
|
||||
onComplete(); // Close wizard immediately
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogContent>
|
||||
{/* Wallet connection UI */}
|
||||
<Button onClick={connectWallet}>Connect Wallet</Button>
|
||||
|
||||
{/* Anonymous option */}
|
||||
<div className="separator">Or</div>
|
||||
<Button variant="outline" onClick={handleAnonymous}>
|
||||
Continue Anonymously
|
||||
</Button>
|
||||
<p className="text-xs">You can post, comment, and vote (but not create cells)</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Header with Call Sign Access
|
||||
export function Header() {
|
||||
const { currentUser } = useAuth();
|
||||
const [callSignOpen, setCallSignOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header>
|
||||
{currentUser && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>{currentUser.displayName}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{currentUser.verificationStatus === EVerificationStatus.ANONYMOUS && (
|
||||
<DropdownMenuItem onClick={() => setCallSignOpen(true)}>
|
||||
{currentUser.callSign ? 'Update' : 'Set'} Call Sign
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem>Profile</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
{currentUser.verificationStatus === EVerificationStatus.ANONYMOUS
|
||||
? 'Exit Anonymous'
|
||||
: 'Disconnect'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<CallSignSetupDialog open={callSignOpen} onOpenChange={setCallSignOpen} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Author Display with Anonymous Support
|
||||
export function AuthorDisplay({ address }: { address: string }) {
|
||||
const { displayName, callSign, ensName } = useUserDisplay(address);
|
||||
const isAnonymous = /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(address);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{displayName}</span>
|
||||
|
||||
{isAnonymous ? (
|
||||
callSign ? (
|
||||
<Badge className="bg-green-900/20"><Hash /> Call Sign</Badge>
|
||||
) : (
|
||||
<Badge className="bg-neutral-800/50"><UserX /> Anonymous</Badge>
|
||||
)
|
||||
) : (
|
||||
ensName && <Badge className="bg-green-900/20"><Crown /> ENS</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Inline Call Sign Input
|
||||
export function InlineCallSignInput() {
|
||||
const { currentUser, updateProfile } = useAuth();
|
||||
const [callSign, setCallSign] = useState('');
|
||||
|
||||
// Only show for anonymous users without call sign
|
||||
if (currentUser?.verificationStatus !== EVerificationStatus.ANONYMOUS ||
|
||||
currentUser?.callSign) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const success = await updateProfile({ callSign });
|
||||
if (success) {
|
||||
toast({ title: 'Call Sign Set!' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 border rounded bg-muted">
|
||||
<p className="text-sm mb-3">Set a call sign to personalize your identity</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="your_call_sign"
|
||||
value={callSign}
|
||||
onChange={(e) => setCallSign(e.target.value)}
|
||||
maxLength={20}
|
||||
/>
|
||||
<Button onClick={handleSubmit}>Set Call Sign</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Permission-Based Content Forms
|
||||
export function PostDetail({ postId }: { postId: string }) {
|
||||
const { permissions, currentUser } = useForum();
|
||||
const { createComment } = useContent();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Post content */}
|
||||
|
||||
{/* Call sign suggestion for anonymous users */}
|
||||
{currentUser?.verificationStatus === EVerificationStatus.ANONYMOUS &&
|
||||
!currentUser.callSign &&
|
||||
permissions.canComment && (
|
||||
<InlineCallSignInput />
|
||||
)}
|
||||
|
||||
{/* Comment form */}
|
||||
{permissions.canComment && (
|
||||
<CommentForm onSubmit={(content) => createComment({ postId, content })} />
|
||||
)}
|
||||
|
||||
{/* Blocked state */}
|
||||
{!permissions.canComment && (
|
||||
<div>
|
||||
<p>Connect your wallet to comment</p>
|
||||
<Button>Connect Wallet</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
### Error: "useClient must be used within ClientProvider"
|
||||
|
||||
**Root Cause:** Components using `@opchan/react` hooks are not wrapped by `OpChanProvider`.
|
||||
|
||||
**Solution:**
|
||||
```tsx
|
||||
// ❌ WRONG - Hooks used outside provider
|
||||
function App() {
|
||||
const { currentUser } = useAuth(); // This will fail
|
||||
return <div>Hello</div>;
|
||||
}
|
||||
|
||||
// ✅ CORRECT - All hooks inside provider
|
||||
function App() {
|
||||
return (
|
||||
<OpChanProvider config={config}>
|
||||
<MainApp />
|
||||
</OpChanProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MainApp() {
|
||||
const { currentUser } = useAuth(); // This works
|
||||
return <div>Hello</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Error: Wallet Connection Fails
|
||||
|
||||
**Root Cause:** Missing or invalid `reownProjectId` in provider config.
|
||||
|
||||
**Solution:**
|
||||
```tsx
|
||||
// ❌ WRONG - Missing reownProjectId
|
||||
<OpChanProvider config={{ wakuConfig: {...} }}>
|
||||
|
||||
// ✅ CORRECT - Include reownProjectId
|
||||
<OpChanProvider config={{
|
||||
wakuConfig: {...},
|
||||
reownProjectId: 'your-project-id'
|
||||
}}>
|
||||
```
|
||||
|
||||
### Error: "Buffer is not defined"
|
||||
|
||||
**Root Cause:** Missing Buffer polyfill for crypto libraries.
|
||||
|
||||
**Solution:**
|
||||
```tsx
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
// Add before rendering
|
||||
if (!(window as any).Buffer) {
|
||||
(window as any).Buffer = Buffer;
|
||||
}
|
||||
```
|
||||
|
||||
### Error: Anonymous users can't interact after setting call sign
|
||||
|
||||
**Root Cause:** Verification status not preserved during profile updates.
|
||||
|
||||
**Solution:**
|
||||
```tsx
|
||||
// In updateProfile function
|
||||
const updated: User = {
|
||||
...user,
|
||||
...identity,
|
||||
verificationStatus: user.verificationStatus, // Preserve!
|
||||
};
|
||||
```
|
||||
|
||||
## Testing Your Implementation
|
||||
|
||||
### Manual Test Checklist
|
||||
|
||||
**Anonymous Flow:**
|
||||
- [ ] Can start anonymous session
|
||||
- [ ] Can post without call sign
|
||||
- [ ] Can comment without call sign
|
||||
- [ ] Can vote without call sign
|
||||
- [ ] Can set call sign from header
|
||||
- [ ] Call sign appears in author display
|
||||
- [ ] Session persists after call sign update
|
||||
- [ ] Cannot create cells
|
||||
- [ ] Can exit anonymous session
|
||||
|
||||
**Wallet Flow:**
|
||||
- [ ] Can connect wallet
|
||||
- [ ] ENS resolves automatically
|
||||
- [ ] Can delegate browser keys
|
||||
- [ ] Can create cells (if ENS verified)
|
||||
- [ ] Can post without repeated signatures
|
||||
- [ ] Can moderate own cells
|
||||
- [ ] Can update profile
|
||||
- [ ] Can disconnect wallet
|
||||
|
||||
**Edge Cases:**
|
||||
- [ ] Anonymous user sets call sign → remains anonymous
|
||||
- [ ] Wallet disconnects → doesn't clear anonymous users
|
||||
- [ ] Profile update → preserves verification status
|
||||
- [ ] Network disconnect → shows reconnect option
|
||||
- [ ] Content pending → shows sync status
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
1. **Memoization** - Use React.memo for expensive components
|
||||
2. **Virtual Lists** - For long comment threads
|
||||
3. **Lazy Loading** - Code-split routes
|
||||
4. **Image Optimization** - Lazy load ENS avatars
|
||||
5. **Bundle Size** - Tree-shake unused components
|
||||
|
||||
## Deployment
|
||||
|
||||
### Build Commands
|
||||
|
||||
```bash
|
||||
# Build all packages
|
||||
npm run build
|
||||
|
||||
# Build for production
|
||||
cd app
|
||||
npm run build
|
||||
# Output: app/dist/
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# .env.production
|
||||
VITE_REOWN_SECRET=your_production_reown_id
|
||||
```
|
||||
|
||||
### Static Hosting
|
||||
|
||||
Deploy `app/dist/` to:
|
||||
- Vercel
|
||||
- Netlify
|
||||
- GitHub Pages
|
||||
- IPFS
|
||||
|
||||
Configure SPA routing:
|
||||
```json
|
||||
{
|
||||
"routes": [
|
||||
{ "handle": "filesystem" },
|
||||
{ "src": "/(.*)", "dest": "/index.html" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
Building with `@opchan/react` requires understanding:
|
||||
|
||||
1. **Dual authentication model** - Anonymous and wallet modes
|
||||
2. **Permission-based UI** - Show features based on capabilities
|
||||
3. **Key delegation** - Browser keys reduce wallet friction
|
||||
4. **Local-first architecture** - IndexedDB with network sync
|
||||
5. **Real-time updates** - React state tied to network messages
|
||||
6. **Identity flexibility** - Handle addresses, ENS, call signs, and sessions
|
||||
7. **Verification preservation** - Critical for anonymous users
|
||||
8. **Message verification** - Optional delegation proofs for protocol v2
|
||||
|
||||
Follow these patterns and you'll build a robust, user-friendly decentralized forum that works for everyone - from crypto natives to complete newcomers.
|
||||
|
||||
---
|
||||
|
||||
**For complete examples, see the reference implementation in `/app`**
|
||||
|
||||
@ -85,63 +85,67 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-muted rounded-sm p-4 bg-card">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
|
||||
userUpvoted
|
||||
? 'text-cyber-accent'
|
||||
: 'text-cyber-neutral hover:text-cyber-accent'
|
||||
}`}
|
||||
onClick={() => handleVoteComment(true)}
|
||||
disabled={!permissions.canVote}
|
||||
title={
|
||||
permissions.canVote ? 'Upvote comment' : permissions.reasons.vote
|
||||
}
|
||||
>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
</button>
|
||||
<span className="text-sm font-bold">{score}</span>
|
||||
<button
|
||||
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${
|
||||
userDownvoted
|
||||
? 'text-cyber-accent'
|
||||
: 'text-cyber-neutral hover:text-cyber-accent'
|
||||
}`}
|
||||
onClick={() => handleVoteComment(false)}
|
||||
disabled={!permissions.canVote}
|
||||
title={
|
||||
permissions.canVote
|
||||
? 'Downvote comment'
|
||||
: permissions.reasons.vote
|
||||
}
|
||||
>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="border border-border rounded-none p-2 sm:p-4 bg-transparent">
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4">
|
||||
<div className="flex flex-row sm:flex-col items-center justify-between sm:justify-start gap-2 sm:gap-2 w-full sm:w-auto border-b sm:border-b-0 sm:border-r border-border/60 pb-2 sm:pb-0 sm:pr-4">
|
||||
<div className="flex flex-row sm:flex-col items-center gap-2">
|
||||
<button
|
||||
className={`p-1.5 sm:p-1 border border-transparent hover:border-border touch-manipulation ${
|
||||
userUpvoted
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-primary'
|
||||
}`}
|
||||
onClick={() => handleVoteComment(true)}
|
||||
disabled={!permissions.canVote}
|
||||
title={
|
||||
permissions.canVote ? 'Upvote comment' : permissions.reasons.vote
|
||||
}
|
||||
>
|
||||
<ArrowUp className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
</button>
|
||||
<span className="text-xs sm:text-sm font-semibold text-foreground min-w-[20px] sm:min-w-[24px] text-center">{score}</span>
|
||||
<button
|
||||
className={`p-1.5 sm:p-1 border border-transparent hover:border-border touch-manipulation ${
|
||||
userDownvoted
|
||||
? 'text-blue-400'
|
||||
: 'text-muted-foreground hover:text-blue-400'
|
||||
}`}
|
||||
onClick={() => handleVoteComment(false)}
|
||||
disabled={!permissions.canVote}
|
||||
title={
|
||||
permissions.canVote
|
||||
? 'Downvote comment'
|
||||
: permissions.reasons.vote
|
||||
}
|
||||
>
|
||||
<ArrowDown className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{commentVotePending && (
|
||||
<span className="mt-1 text-[10px] text-yellow-500">syncing…</span>
|
||||
<span className="text-[9px] sm:text-[10px] text-yellow-400 sm:mt-1 whitespace-nowrap">
|
||||
syncing…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<AuthorDisplay
|
||||
address={comment.author}
|
||||
className="text-xs"
|
||||
className="text-[10px] sm:text-xs truncate"
|
||||
showBadge={false}
|
||||
/>
|
||||
<span>•</span>
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<Clock className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="normal-case tracking-normal text-foreground text-[10px] sm:text-xs">
|
||||
{formatDistanceToNow(new Date(comment.timestamp), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
<PendingBadge id={comment.id} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
||||
<ShareButton
|
||||
size="sm"
|
||||
url={`${window.location.origin}/post/${postId}#comment-${comment.id}`}
|
||||
@ -160,21 +164,21 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm break-words mb-2 prose prose-invert max-w-none">
|
||||
<div className="text-xs sm:text-sm break-words mb-2 sm:mb-3 prose prose-invert max-w-none">
|
||||
<MarkdownRenderer content={comment.content} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
|
||||
{canModerate && !isModerated && !isOwnComment && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 text-cyber-neutral hover:text-orange-500"
|
||||
className="h-6 w-6 sm:h-7 sm:w-7 text-muted-foreground hover:text-orange-500 touch-manipulation"
|
||||
onClick={() => onModerateComment(comment.id)}
|
||||
>
|
||||
<MessageSquareX className="h-3 w-3" />
|
||||
<MessageSquareX className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@ -188,7 +192,7 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-cyber-neutral hover:text-green-500"
|
||||
className="h-6 sm:h-7 px-2 text-[10px] sm:text-[11px] text-muted-foreground hover:text-green-500 touch-manipulation"
|
||||
onClick={() => onUnmoderateComment?.(comment.id)}
|
||||
>
|
||||
Unmoderate
|
||||
@ -205,10 +209,10 @@ const CommentCard: React.FC<CommentCardProps> = ({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 text-cyber-neutral hover:text-red-500"
|
||||
className="h-6 w-6 sm:h-7 sm:w-7 text-muted-foreground hover:text-red-500 touch-manipulation"
|
||||
onClick={() => onModerateUser(comment.author)}
|
||||
>
|
||||
<UserX className="h-3 w-3" />
|
||||
<UserX className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
|
||||
@ -33,39 +33,39 @@ const FeedSidebar: React.FC = () => {
|
||||
// User's verification status display
|
||||
const getVerificationBadge = () => {
|
||||
if (verificationStatus === EVerificationStatus.ENS_VERIFIED) {
|
||||
return { text: 'Verified Owner', color: 'bg-green-500' };
|
||||
return { text: 'Verified Owner', color: 'border-primary text-primary' };
|
||||
} else if (verificationStatus === EVerificationStatus.WALLET_CONNECTED) {
|
||||
return { text: 'Verified', color: 'bg-blue-500' };
|
||||
return { text: 'Verified', color: 'border-white/40 text-foreground' };
|
||||
} else if (currentUser?.ensDetails) {
|
||||
return { text: 'ENS User', color: 'bg-purple-500' };
|
||||
return { text: 'ENS User', color: 'border-purple-400 text-purple-200' };
|
||||
} else if (currentUser?.ordinalDetails) {
|
||||
return { text: 'Ordinal User', color: 'bg-orange-500' };
|
||||
return { text: 'Ordinal User', color: 'border-amber-400 text-amber-300' };
|
||||
}
|
||||
return { text: 'Unverified', color: 'bg-gray-500' };
|
||||
return { text: 'Unverified', color: 'border-border text-muted-foreground' };
|
||||
};
|
||||
|
||||
const verificationBadge = getVerificationBadge();
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-cyber-muted/10 border-l border-cyber-muted p-4 space-y-6 overflow-y-auto">
|
||||
<div className="w-80 border-l border-border/60 p-4 space-y-6">
|
||||
{/* User Status Card */}
|
||||
{currentUser && (
|
||||
<Card className="bg-cyber-muted/20 border-cyber-muted">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Your Status</CardTitle>
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="pb-0 border-b-0 text-[11px] tracking-[0.2em] text-muted-foreground">
|
||||
<CardTitle className="text-xs uppercase tracking-[0.2em]">Your Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 rounded-full bg-cyber-accent/20 flex items-center justify-center">
|
||||
<Users className="w-5 h-5 text-cyber-accent" />
|
||||
<div className="w-10 h-10 border border-border/70 flex items-center justify-center text-muted-foreground">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{currentUser?.displayName}
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`${verificationBadge.color} text-white text-xs`}
|
||||
variant="outline"
|
||||
className={`${verificationBadge.color}`}
|
||||
>
|
||||
{verificationBadge.text}
|
||||
</Badge>
|
||||
@ -90,58 +90,62 @@ const FeedSidebar: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Forum Stats */}
|
||||
<Card className="bg-cyber-muted/20 border-cyber-muted">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center">
|
||||
<TrendingUp className="w-4 h-4 mr-2" />
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="pb-0 border-b-0">
|
||||
<CardTitle className="text-xs uppercase tracking-[0.2em] flex items-center gap-2 text-muted-foreground">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
Forum Stats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-cyber-accent">
|
||||
<CardContent className="pt-2">
|
||||
<div className="grid grid-cols-3 gap-4 text-center text-[11px] uppercase tracking-[0.15em]">
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-semibold text-primary">
|
||||
{stats.totalCells}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Cells</div>
|
||||
<div className="text-[10px] text-muted-foreground">Cells</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-cyber-accent">
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-semibold text-primary">
|
||||
{stats.totalPosts}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Posts</div>
|
||||
<div className="text-[10px] text-muted-foreground">Posts</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-cyber-accent">
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-semibold text-primary">
|
||||
{stats.totalComments}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Comments</div>
|
||||
<div className="text-[10px] text-muted-foreground">Comments</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Trending Cells */}
|
||||
<Card className="bg-cyber-muted/20 border-cyber-muted">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Trending Cells</CardTitle>
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="pb-0 border-b-0">
|
||||
<CardTitle className="text-xs uppercase tracking-[0.2em]">
|
||||
Trending Cells
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-3 pt-2">
|
||||
{trendingCells.map(cell => (
|
||||
<Link
|
||||
key={cell.id}
|
||||
to={`/cell/${cell.id}`}
|
||||
className="flex items-center space-x-3 p-2 rounded-sm hover:bg-cyber-muted/30 transition-colors"
|
||||
className="flex items-center space-x-3 p-2 border border-border/40 hover:border-border transition-colors"
|
||||
>
|
||||
<CypherImage
|
||||
src={cell.icon}
|
||||
alt={cell.name}
|
||||
className="w-8 h-8 rounded-sm"
|
||||
className="w-10 h-10 border border-border/60"
|
||||
generateUniqueFallback={true}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate">{cell.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="font-semibold text-sm truncate text-foreground">
|
||||
{cell.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground uppercase tracking-[0.15em]">
|
||||
{cell.postCount} posts • {cell.activeUsers} members
|
||||
</div>
|
||||
</div>
|
||||
@ -149,7 +153,7 @@ const FeedSidebar: React.FC = () => {
|
||||
))}
|
||||
|
||||
{trendingCells.length === 0 && (
|
||||
<div className="text-center text-xs text-muted-foreground py-4">
|
||||
<div className="text-center text-[10px] uppercase tracking-[0.2em] text-muted-foreground py-4">
|
||||
No active cells yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -4,55 +4,50 @@ import { Terminal, FileText, Shield, Github, BookOpen } from 'lucide-react';
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="bg-black/80 border-t border-cyber-muted/30 backdrop-blur-md mt-auto">
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
{/* Left: Logo and Copyright */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Terminal className="w-5 h-5 text-cyber-accent" />
|
||||
<span className="text-sm font-mono text-cyber-neutral">
|
||||
© 2025 <span className="text-white font-bold tracking-wider">opchan</span>
|
||||
</span>
|
||||
<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="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="flex items-center space-x-2 sm:space-x-3">
|
||||
<Terminal className="w-3 h-3 sm:w-4 sm:h-4 text-primary flex-shrink-0" />
|
||||
<span>© 2025 OPCHAN</span>
|
||||
</div>
|
||||
|
||||
{/* Center: Links */}
|
||||
<nav className="flex items-center flex-wrap justify-center gap-x-6 gap-y-2">
|
||||
<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]">
|
||||
<Link
|
||||
to="/terms"
|
||||
className="flex items-center space-x-1 text-sm font-mono text-cyber-neutral hover:text-cyber-accent transition-colors"
|
||||
className="flex items-center space-x-1 sm:space-x-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Terms of Use</span>
|
||||
<FileText className="w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span>TERMS</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="flex items-center space-x-1 text-sm font-mono text-cyber-neutral hover:text-cyber-accent transition-colors"
|
||||
className="flex items-center space-x-1 sm:space-x-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Privacy Policy</span>
|
||||
<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 text-sm font-mono text-cyber-neutral hover:text-cyber-accent transition-colors"
|
||||
className="flex items-center space-x-1 sm:space-x-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
<span>GitHub</span>
|
||||
<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 text-sm font-mono text-cyber-neutral hover:text-cyber-accent transition-colors"
|
||||
className="flex items-center space-x-1 sm:space-x-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>Docs</span>
|
||||
<BookOpen className="w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span>DOCS</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{/* Right: Additional Info */}
|
||||
<div className="text-xs font-mono text-cyber-neutral/60">
|
||||
<div className="text-[9px] sm:text-[10px] uppercase tracking-[0.2em] sm:tracking-[0.3em] text-muted-foreground text-center">
|
||||
Licensed under CC-BY-SA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -144,30 +144,30 @@ const Header = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="bg-black/80 border-b border-cyber-muted/30 sticky top-0 z-50 backdrop-blur-md">
|
||||
<div className="container mx-auto px-4">
|
||||
<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">
|
||||
{/* Top Row - Logo, Network Status, User Actions */}
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center justify-between h-12 sm:h-14 md:h-16">
|
||||
{/* Left: Logo */}
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center min-w-0">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center space-x-2 text-xl font-mono font-bold text-white hover:text-cyber-accent transition-colors"
|
||||
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-6 h-6" />
|
||||
<span className="tracking-wider">opchan</span>
|
||||
<Terminal className="w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0" />
|
||||
<span className="truncate">opchan</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Center: Network Status (Desktop) */}
|
||||
<div className="hidden lg:flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2 px-3 py-1 bg-cyber-muted/20 rounded-full border border-cyber-muted/30">
|
||||
<div className="flex items-center space-x-2 px-3 py-1 border border-border text-[10px] uppercase tracking-[0.2em]">
|
||||
<WakuHealthDot />
|
||||
<span className="text-xs font-mono text-cyber-neutral">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{statusMessage}
|
||||
</span>
|
||||
{content.lastSync && (
|
||||
<div className="flex items-center space-x-1 text-xs text-cyber-neutral/70">
|
||||
<div className="flex items-center space-x-1 text-[10px] text-muted-foreground">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
{new Date(content.lastSync).toLocaleTimeString([], {
|
||||
@ -181,7 +181,7 @@ const Header = () => {
|
||||
</div>
|
||||
|
||||
{/* Right: User Actions */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2 sm:space-x-3 flex-shrink-0">
|
||||
{/* Network Status (Mobile) */}
|
||||
<div className="lg:hidden">
|
||||
<WakuHealthDot />
|
||||
@ -189,24 +189,24 @@ const Header = () => {
|
||||
|
||||
{/* User Status & Actions */}
|
||||
{isConnected || currentUser?.verificationStatus === EVerificationStatus.ANONYMOUS ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<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={`font-mono text-xs border-0 ${
|
||||
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
|
||||
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||
? 'border-green-500 text-green-300'
|
||||
: currentUser?.verificationStatus ===
|
||||
EVerificationStatus.ENS_VERIFIED
|
||||
? 'bg-orange-500/20 text-orange-400 border-orange-500/30'
|
||||
: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
|
||||
? 'border-orange-500 text-orange-300'
|
||||
: 'border-yellow-500 text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
{getStatusIcon()}
|
||||
<span className="ml-1">
|
||||
<span className="hidden md:inline">
|
||||
{currentUser?.verificationStatus ===
|
||||
EVerificationStatus.WALLET_UNCONNECTED
|
||||
? 'CONNECT'
|
||||
@ -226,17 +226,17 @@ const Header = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 text-white hover:bg-cyber-muted/30"
|
||||
className="flex items-center space-x-1 sm:space-x-2 text-foreground border-border px-2 sm:px-3"
|
||||
>
|
||||
<div className="text-sm font-mono">
|
||||
<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-4 h-4" />
|
||||
<Settings className="w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-56 bg-black/95 border-cyber-muted/30"
|
||||
className="w-56 bg-[#050505] border border-border text-sm"
|
||||
>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
@ -266,7 +266,7 @@ const Header = () => {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator className="bg-cyber-muted/30" />
|
||||
<DropdownMenuSeparator className="bg-border" />
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@ -278,12 +278,12 @@ const Header = () => {
|
||||
<span>Clear Database</span>
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="bg-black/95 border-cyber-muted/30">
|
||||
<AlertDialogContent className="bg-[#050505] border border-border text-foreground">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white">
|
||||
<AlertDialogTitle className="text-foreground uppercase tracking-[0.2em] text-sm">
|
||||
Clear Local Database
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-cyber-neutral">
|
||||
<AlertDialogDescription className="text-muted-foreground">
|
||||
This will permanently delete all locally stored
|
||||
data including:
|
||||
<br />• Posts and comments
|
||||
@ -296,12 +296,12 @@ const Header = () => {
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="bg-cyber-muted/20 border-cyber-muted/30 text-white hover:bg-cyber-muted/30">
|
||||
<AlertDialogCancel className="border border-border text-foreground hover:bg-white/5">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleClearDatabase}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
className="border border-red-600 text-red-400 hover:bg-red-600/10"
|
||||
>
|
||||
Clear Database
|
||||
</AlertDialogAction>
|
||||
@ -322,9 +322,10 @@ const Header = () => {
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
className="bg-cyber-accent hover:bg-cyber-accent/80 text-black font-mono font-medium"
|
||||
className="text-primary border-primary hover:bg-primary/10 text-[10px] sm:text-[11px] px-2 sm:px-3"
|
||||
>
|
||||
Connect
|
||||
<span className="hidden sm:inline">Connect</span>
|
||||
<span className="sm:hidden">CON</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@ -333,26 +334,26 @@ const Header = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden text-white hover:bg-cyber-muted/30"
|
||||
className="md:hidden border-border text-foreground p-2"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="w-5 h-5" />
|
||||
<X className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
) : (
|
||||
<Menu className="w-5 h-5" />
|
||||
<Menu className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Bar (Desktop) */}
|
||||
<div className="hidden md:flex items-center justify-center border-t border-cyber-muted/20 py-2">
|
||||
<nav className="flex items-center space-x-1">
|
||||
<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 rounded-md text-sm font-mono transition-all ${
|
||||
className={`flex items-center space-x-2 px-4 py-2 border-b ${
|
||||
location.pathname === '/'
|
||||
? 'bg-cyber-accent/20 text-cyber-accent border border-cyber-accent/30'
|
||||
: 'text-cyber-neutral hover:text-white hover:bg-cyber-muted/20'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
@ -360,10 +361,10 @@ const Header = () => {
|
||||
</Link>
|
||||
<Link
|
||||
to="/cells"
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-md text-sm font-mono transition-all ${
|
||||
className={`flex items-center space-x-2 px-4 py-2 border-b ${
|
||||
location.pathname === '/cells'
|
||||
? 'bg-cyber-accent/20 text-cyber-accent border border-cyber-accent/30'
|
||||
: 'text-cyber-neutral hover:text-white hover:bg-cyber-muted/20'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Grid3X3 className="w-4 h-4" />
|
||||
@ -373,10 +374,10 @@ const Header = () => {
|
||||
<>
|
||||
<Link
|
||||
to="/bookmarks"
|
||||
className={`flex items-center space-x-2 px-4 py-2 rounded-md text-sm font-mono transition-all ${
|
||||
className={`flex items-center space-x-2 px-4 py-2 border-b ${
|
||||
location.pathname === '/bookmarks'
|
||||
? 'bg-cyber-accent/20 text-cyber-accent border border-cyber-accent/30'
|
||||
: 'text-cyber-neutral hover:text-white hover:bg-cyber-muted/20'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
@ -389,14 +390,14 @@ const Header = () => {
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-cyber-muted/20 py-4 space-y-2">
|
||||
<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 rounded-md text-sm font-mono transition-all ${
|
||||
className={`flex items-center space-x-3 px-4 py-3 border ${
|
||||
location.pathname === '/'
|
||||
? 'bg-cyber-accent/20 text-cyber-accent border border-cyber-accent/30'
|
||||
: 'text-cyber-neutral hover:text-white hover:bg-cyber-muted/20'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-border text-muted-foreground'
|
||||
}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
@ -405,10 +406,10 @@ const Header = () => {
|
||||
</Link>
|
||||
<Link
|
||||
to="/cells"
|
||||
className={`flex items-center space-x-3 px-4 py-3 rounded-md text-sm font-mono transition-all ${
|
||||
className={`flex items-center space-x-3 px-4 py-3 border ${
|
||||
location.pathname === '/cells'
|
||||
? 'bg-cyber-accent/20 text-cyber-accent border border-cyber-accent/30'
|
||||
: 'text-cyber-neutral hover:text-white hover:bg-cyber-muted/20'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-border text-muted-foreground'
|
||||
}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
@ -419,10 +420,10 @@ const Header = () => {
|
||||
<>
|
||||
<Link
|
||||
to="/bookmarks"
|
||||
className={`flex items-center space-x-3 px-4 py-3 rounded-md text-sm font-mono transition-all ${
|
||||
className={`flex items-center space-x-3 px-4 py-3 border ${
|
||||
location.pathname === '/bookmarks'
|
||||
? 'bg-cyber-accent/20 text-cyber-accent border border-cyber-accent/30'
|
||||
: 'text-cyber-neutral hover:text-white hover:bg-cyber-muted/20'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-border text-muted-foreground'
|
||||
}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
@ -431,10 +432,10 @@ const Header = () => {
|
||||
</Link>
|
||||
<Link
|
||||
to="/profile"
|
||||
className={`flex items-center space-x-3 px-4 py-3 rounded-md text-sm font-mono transition-all ${
|
||||
className={`flex items-center space-x-3 px-4 py-3 border ${
|
||||
location.pathname === '/profile'
|
||||
? 'bg-cyber-accent/20 text-cyber-accent border border-cyber-accent/30'
|
||||
: 'text-cyber-neutral hover:text-white hover:bg-cyber-muted/20'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-border text-muted-foreground'
|
||||
}`}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
@ -446,8 +447,8 @@ const Header = () => {
|
||||
</nav>
|
||||
|
||||
{/* Mobile Network Status */}
|
||||
<div className="px-4 py-3 border-t border-cyber-muted/20">
|
||||
<div className="flex items-center space-x-2 text-xs text-cyber-neutral">
|
||||
<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>
|
||||
{content.lastSync && (
|
||||
|
||||
@ -73,53 +73,53 @@ const PostCard: React.FC<PostCardProps> = ({ post }) => {
|
||||
|
||||
return (
|
||||
<div className="thread-card mb-2">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
{/* Voting column */}
|
||||
<div className="flex flex-col items-center p-2 bg-cyber-muted/50 border-r border-cyber-muted">
|
||||
<button
|
||||
className={`p-1 rounded hover:bg-cyber-muted transition-colors ${
|
||||
userUpvoted
|
||||
? 'text-cyber-accent'
|
||||
: 'text-cyber-neutral hover:text-cyber-accent'
|
||||
}`}
|
||||
onClick={e => handleVote(e, true)}
|
||||
disabled={!permissions.canVote}
|
||||
title={permissions.canVote ? 'Upvote' : permissions.reasons.vote}
|
||||
>
|
||||
<ArrowUp className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex sm:flex-col flex-row items-center justify-between sm:justify-start gap-2 sm:gap-2 p-2 sm:p-2 border-b sm:border-b-0 sm:border-r border-border/60 bg-transparent sm:w-auto w-full">
|
||||
<div className="flex sm:flex-col items-center gap-2">
|
||||
<button
|
||||
className={`p-1.5 sm:p-1 border border-transparent hover:border-border touch-manipulation ${
|
||||
userUpvoted
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-primary'
|
||||
}`}
|
||||
onClick={e => handleVote(e, true)}
|
||||
disabled={!permissions.canVote}
|
||||
title={permissions.canVote ? 'Upvote' : permissions.reasons.vote}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
|
||||
<span
|
||||
className={`text-sm font-medium px-1`}
|
||||
>
|
||||
{score}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-foreground min-w-[24px] text-center">
|
||||
{score}
|
||||
</span>
|
||||
|
||||
<button
|
||||
className={`p-1 rounded hover:bg-cyber-muted transition-colors ${
|
||||
userDownvoted
|
||||
? 'text-blue-400'
|
||||
: 'text-cyber-neutral hover:text-blue-400'
|
||||
}`}
|
||||
onClick={e => handleVote(e, false)}
|
||||
disabled={!permissions.canVote}
|
||||
title={permissions.canVote ? 'Downvote' : permissions.reasons.vote}
|
||||
>
|
||||
<ArrowDown className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
className={`p-1.5 sm:p-1 border border-transparent hover:border-border touch-manipulation ${
|
||||
userDownvoted
|
||||
? 'text-blue-400'
|
||||
: 'text-muted-foreground hover:text-blue-400'
|
||||
}`}
|
||||
onClick={e => handleVote(e, false)}
|
||||
disabled={!permissions.canVote}
|
||||
title={permissions.canVote ? 'Downvote' : permissions.reasons.vote}
|
||||
>
|
||||
<ArrowDown className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{isPending && (
|
||||
<span className="mt-1 text-[10px] text-yellow-400">syncing…</span>
|
||||
<span className="text-[9px] sm:text-[10px] text-yellow-400 sm:mt-1">syncing…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content column */}
|
||||
<div className="flex-1 p-3">
|
||||
<div className="block hover:opacity-80">
|
||||
<div className="flex-1 p-2 sm:p-3 min-w-0">
|
||||
<div className="space-y-3">
|
||||
{/* Post metadata */}
|
||||
<div className="flex items-center text-xs text-cyber-neutral mb-2 space-x-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 text-[10px] sm:text-[11px] uppercase tracking-[0.1em] sm:tracking-[0.12em] text-muted-foreground">
|
||||
<Link
|
||||
to={cellName ? `/cell/${post.cellId}` : "#"}
|
||||
className="font-medium text-cyber-accent hover:underline focus:underline"
|
||||
to={cellName ? `/cell/${post.cellId}` : '#'}
|
||||
className="text-primary hover:underline truncate"
|
||||
tabIndex={0}
|
||||
onClick={e => {
|
||||
if (!cellName) e.preventDefault();
|
||||
@ -128,15 +128,16 @@ const PostCard: React.FC<PostCardProps> = ({ post }) => {
|
||||
>
|
||||
r/{cellName || 'unknown'}
|
||||
</Link>
|
||||
<span>•</span>
|
||||
<span>Posted by u/</span>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span className="hidden sm:inline">Posted by u/</span>
|
||||
<span className="sm:hidden">u/</span>
|
||||
<AuthorDisplay
|
||||
address={post.author}
|
||||
className="text-xs"
|
||||
className="text-[10px] sm:text-xs truncate"
|
||||
showBadge={false}
|
||||
/>
|
||||
<span>•</span>
|
||||
<span>
|
||||
<span className="opacity-50 hidden sm:inline">/</span>
|
||||
<span className="normal-case tracking-normal text-foreground text-[10px] sm:text-[11px]">
|
||||
{formatDistanceToNow(new Date(post.timestamp), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
@ -144,7 +145,7 @@ const PostCard: React.FC<PostCardProps> = ({ post }) => {
|
||||
{'relevanceScore' in post &&
|
||||
typeof (post as Post).relevanceScore === 'number' && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="opacity-50 hidden sm:inline">/</span>
|
||||
<RelevanceIndicator
|
||||
score={(post as Post).relevanceScore as number}
|
||||
details={
|
||||
@ -153,7 +154,7 @@ const PostCard: React.FC<PostCardProps> = ({ post }) => {
|
||||
: undefined
|
||||
}
|
||||
type="post"
|
||||
className="text-xs"
|
||||
className="text-[10px] sm:text-[11px]"
|
||||
showTooltip={true}
|
||||
/>
|
||||
</>
|
||||
@ -163,26 +164,26 @@ const PostCard: React.FC<PostCardProps> = ({ post }) => {
|
||||
{/* Post title and content - clickable to navigate to post */}
|
||||
<div className="block">
|
||||
<Link to={`/post/${post.id}`} className="block">
|
||||
<h2 className="text-lg font-semibold text-glow mb-2 hover:text-cyber-accent transition-colors">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-foreground break-words">
|
||||
{post.title}
|
||||
</h2>
|
||||
</Link>
|
||||
|
||||
{/* Post content preview */}
|
||||
<p className="text-cyber-neutral text-sm leading-relaxed mb-3">
|
||||
<p className="text-muted-foreground text-xs sm:text-sm leading-relaxed mt-1 sm:mt-2 break-words">
|
||||
<LinkRenderer text={contentPreview} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Post actions */}
|
||||
<div className="flex items-center justify-between text-xs text-cyber-neutral">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-1 hover:text-cyber-accent transition-colors">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 sm:gap-4 text-[10px] sm:text-[11px] uppercase tracking-[0.12em] sm:tracking-[0.15em] text-muted-foreground mt-2 sm:mt-3">
|
||||
<div className="flex items-center flex-wrap gap-2 sm:gap-4">
|
||||
<div className="flex items-center space-x-1 hover:text-foreground">
|
||||
<MessageSquare className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span>{commentCount} comments</span>
|
||||
</div>
|
||||
{isPending && (
|
||||
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
|
||||
<span className="px-1.5 sm:px-2 py-0.5 border border-yellow-500 text-yellow-400 text-[9px] sm:text-[10px]">
|
||||
syncing…
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -47,14 +47,14 @@ const PostDetail = () => {
|
||||
|
||||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
if (!postId) return <div>Invalid post ID</div>;
|
||||
if (!postId) return <div className="container mx-auto px-3 sm:px-4 py-4 sm:py-6 text-center">Invalid post ID</div>;
|
||||
|
||||
// ✅ Loading state handled by hook
|
||||
if (postPending) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-16 text-center">
|
||||
<Loader2 className="w-8 h-8 mx-auto mb-4 animate-spin text-primary" />
|
||||
<p className="text-lg font-medium text-muted-foreground">
|
||||
<div className="container mx-auto px-3 sm:px-4 py-12 sm:py-16 text-center">
|
||||
<Loader2 className="w-6 h-6 sm:w-8 sm:h-8 mx-auto mb-3 sm:mb-4 animate-spin text-primary" />
|
||||
<p className="text-sm sm:text-lg font-medium text-muted-foreground">
|
||||
Loading Post...
|
||||
</p>
|
||||
</div>
|
||||
@ -63,12 +63,12 @@ const PostDetail = () => {
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-4">Post not found</h2>
|
||||
<p className="mb-4">
|
||||
<div className="container mx-auto px-3 sm:px-4 py-4 sm:py-6 text-center">
|
||||
<h2 className="text-lg sm:text-xl font-bold mb-3 sm:mb-4 uppercase tracking-[0.2em]">Post not found</h2>
|
||||
<p className="text-xs sm:text-sm mb-3 sm:mb-4 text-muted-foreground px-4">
|
||||
The post you're looking for doesn't exist or has been removed.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Button asChild size="sm" className="text-[10px] sm:text-[11px] px-3 sm:px-4">
|
||||
<Link to="/">Go back home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@ -149,64 +149,82 @@ const PostDetail = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<div className="mb-6">
|
||||
<div className="container mx-auto px-3 sm:px-4 py-4 sm:py-6 max-w-5xl">
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<Button
|
||||
onClick={() => navigate(`/cell/${post.cellId}`)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mb-4"
|
||||
className="mb-3 sm:mb-4 text-[10px] sm:text-[11px] px-2 sm:px-3"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
Back to /{cell?.name || 'cell'}/
|
||||
<ArrowLeft className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
|
||||
<span className="hidden sm:inline">Back to /{cell?.name || 'cell'}/</span>
|
||||
<span className="sm:hidden">BACK</span>
|
||||
</Button>
|
||||
|
||||
<div className="border border-muted rounded-sm p-3 mb-6">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
className={`p-1 rounded-sm hover:bg-muted/50 ${
|
||||
isPostUpvoted
|
||||
? 'text-cyber-accent'
|
||||
: 'text-cyber-neutral hover:text-cyber-accent'
|
||||
}`}
|
||||
onClick={() => handleVotePost(true)}
|
||||
disabled={!permissions.canVote}
|
||||
title={
|
||||
permissions.canVote ? 'Upvote post' : permissions.reasons.vote
|
||||
}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-bold">{score}</span>
|
||||
<button
|
||||
className={`p-1 rounded-sm hover:bg-muted/50 ${
|
||||
isPostDownvoted
|
||||
? 'text-cyber-accent'
|
||||
: 'text-cyber-neutral hover:text-cyber-accent'
|
||||
}`}
|
||||
onClick={() => handleVotePost(false)}
|
||||
disabled={!permissions.canVote}
|
||||
title={
|
||||
permissions.canVote
|
||||
? 'Downvote post'
|
||||
: permissions.reasons.vote
|
||||
}
|
||||
>
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="border border-border rounded-none p-2 sm:p-3 mb-4 sm:mb-6">
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-3">
|
||||
<div className="flex flex-row sm:flex-col items-center justify-between sm:justify-start gap-2 sm:gap-1 w-full sm:w-auto border-b sm:border-b-0 sm:border-r border-border/60 pb-3 sm:pb-0 sm:pr-3">
|
||||
<div className="flex flex-row sm:flex-col items-center gap-2 sm:gap-1">
|
||||
<button
|
||||
className={`p-1.5 sm:p-1 border border-transparent hover:border-border touch-manipulation ${
|
||||
isPostUpvoted
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-primary'
|
||||
}`}
|
||||
onClick={() => handleVotePost(true)}
|
||||
disabled={!permissions.canVote}
|
||||
title={
|
||||
permissions.canVote ? 'Upvote post' : permissions.reasons.vote
|
||||
}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4 sm:w-4 sm:h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-foreground min-w-[24px] text-center">{score}</span>
|
||||
<button
|
||||
className={`p-1.5 sm:p-1 border border-transparent hover:border-border touch-manipulation ${
|
||||
isPostDownvoted
|
||||
? 'text-blue-400'
|
||||
: 'text-muted-foreground hover:text-blue-400'
|
||||
}`}
|
||||
onClick={() => handleVotePost(false)}
|
||||
disabled={!permissions.canVote}
|
||||
title={
|
||||
permissions.canVote
|
||||
? 'Downvote post'
|
||||
: permissions.reasons.vote
|
||||
}
|
||||
>
|
||||
<ArrowDown className="w-4 h-4 sm:w-4 sm:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{postVotePending && (
|
||||
<span className="mt-1 text-[10px] text-yellow-500">
|
||||
<span className="text-[9px] sm:text-[10px] text-yellow-400 sm:mt-0.5 whitespace-nowrap">
|
||||
syncing…
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-row sm:flex-col items-center gap-1 sm:gap-1 mt-0 sm:mt-1">
|
||||
<BookmarkButton
|
||||
isBookmarked={isBookmarked}
|
||||
loading={bookmarkLoading}
|
||||
onClick={handleBookmark}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
showText={false}
|
||||
/>
|
||||
<ShareButton
|
||||
size="sm"
|
||||
url={`${window.location.origin}/post/${post.id}`}
|
||||
title={post.title}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs text-muted-foreground mb-1.5 sm:mb-2">
|
||||
<Link
|
||||
to={cell?.id ? `/cell/${cell.id}` : "#"}
|
||||
className="font-medium text-primary hover:underline focus:underline"
|
||||
className="font-medium text-primary hover:underline focus:underline truncate"
|
||||
tabIndex={0}
|
||||
onClick={e => {
|
||||
if (!cell?.id) e.preventDefault();
|
||||
@ -215,16 +233,17 @@ const PostDetail = () => {
|
||||
>
|
||||
r/{cell?.name || 'unknown'}
|
||||
</Link>
|
||||
<span>•</span>
|
||||
<span>Posted by u/</span>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span className="hidden sm:inline">Posted by u/</span>
|
||||
<span className="sm:hidden">u/</span>
|
||||
<AuthorDisplay
|
||||
address={post.author}
|
||||
className="text-sm"
|
||||
className="text-[10px] sm:text-xs truncate"
|
||||
showBadge={false}
|
||||
/>
|
||||
<span>•</span>
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<Clock className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="normal-case tracking-normal text-foreground text-[10px] sm:text-xs">
|
||||
{formatDistanceToNow(new Date(post.timestamp), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
@ -232,31 +251,16 @@ const PostDetail = () => {
|
||||
{/* Relevance details unavailable in raw PostMessage; skip indicator */}
|
||||
{postPending && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="px-2 py-0.5 rounded-sm bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span className="px-1.5 sm:px-2 py-0.5 border border-yellow-500 text-yellow-400 text-[9px] sm:text-[10px]">
|
||||
syncing…
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h1 className="text-2xl font-bold flex-1">{post.title}</h1>
|
||||
<BookmarkButton
|
||||
isBookmarked={isBookmarked}
|
||||
loading={bookmarkLoading}
|
||||
onClick={handleBookmark}
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
showText={true}
|
||||
/>
|
||||
<ShareButton
|
||||
size="lg"
|
||||
url={`${window.location.origin}/post/${post.id}`}
|
||||
title={post.title}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm break-words prose prose-invert max-w-none">
|
||||
<h1 className="text-lg sm:text-xl md:text-2xl font-bold break-words text-foreground mb-2 sm:mb-3">{post.title}</h1>
|
||||
<div className="text-xs sm:text-sm break-words prose prose-invert max-w-none">
|
||||
<MarkdownRenderer content={post.content} />
|
||||
</div>
|
||||
</div>
|
||||
@ -266,11 +270,11 @@ const PostDetail = () => {
|
||||
|
||||
{/* Comment Form */}
|
||||
{permissions.canComment && (
|
||||
<div className="mb-8">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<form onSubmit={handleCreateComment} onKeyDown={handleKeyDown}>
|
||||
<h2 className="text-sm font-bold mb-2 flex items-center gap-1">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
Add a comment
|
||||
<h2 className="text-xs sm:text-sm font-bold mb-2 sm:mb-3 flex items-center gap-1 uppercase tracking-[0.15em] sm:tracking-[0.2em]">
|
||||
<MessageCircle className="w-3 h-3 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span>Add a comment</span>
|
||||
</h2>
|
||||
<MarkdownInput
|
||||
placeholder="What are your thoughts?"
|
||||
@ -278,17 +282,18 @@ const PostDetail = () => {
|
||||
onChange={setNewComment}
|
||||
disabled={false}
|
||||
minHeight={100}
|
||||
initialHeight={140}
|
||||
initialHeight={120}
|
||||
maxHeight={600}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<div className="mt-2 sm:mt-3 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!permissions.canComment}
|
||||
className="bg-cyber-accent hover:bg-cyber-accent/80"
|
||||
className="text-primary border-primary hover:bg-primary/10 text-[10px] sm:text-[11px] px-3 sm:px-4"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
Post Comment
|
||||
<Send className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2" />
|
||||
<span className="hidden sm:inline">Post Comment</span>
|
||||
<span className="sm:hidden">POST</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@ -303,26 +308,26 @@ const PostDetail = () => {
|
||||
)}
|
||||
|
||||
{!permissions.canComment && (
|
||||
<div className="mb-6 p-4 border border-cyber-muted rounded-sm bg-cyber-muted/20 text-center">
|
||||
<p className="text-sm mb-3">Connect your wallet to comment</p>
|
||||
<Button asChild size="sm">
|
||||
<div className="mb-4 sm:mb-6 p-3 sm:p-4 border border-border rounded-none bg-transparent text-center">
|
||||
<p className="text-xs sm:text-sm mb-2 sm:mb-3 text-muted-foreground">Connect your wallet to comment</p>
|
||||
<Button asChild size="sm" className="text-[10px] sm:text-[11px] px-3 sm:px-4">
|
||||
<Link to="/">Connect Wallet</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||||
<MessageCircle className="w-5 h-5" />
|
||||
Comments ({visibleComments.length})
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<h2 className="text-base sm:text-lg font-bold flex items-center gap-2 uppercase tracking-[0.2em] sm:tracking-[0.25em]">
|
||||
<MessageCircle className="w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0" />
|
||||
<span>Comments ({visibleComments.length})</span>
|
||||
</h2>
|
||||
|
||||
{visibleComments.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<MessageCircle className="w-12 h-12 mx-auto mb-4 text-muted-foreground opacity-50" />
|
||||
<h3 className="text-lg font-bold mb-2">No comments yet</h3>
|
||||
<p className="text-muted-foreground">
|
||||
<div className="text-center py-6 sm:py-8">
|
||||
<MessageCircle className="w-10 h-10 sm:w-12 sm:h-12 mx-auto mb-3 sm:mb-4 text-muted-foreground opacity-50" />
|
||||
<h3 className="text-base sm:text-lg font-bold mb-2 uppercase tracking-[0.2em] sm:tracking-[0.25em]">No comments yet</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground px-4">
|
||||
{permissions.canComment
|
||||
? 'Be the first to share your thoughts!'
|
||||
: 'Connect your wallet to join the conversation.'}
|
||||
|
||||
@ -23,7 +23,7 @@ export function ShareButton({
|
||||
const { toast } = useToast();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 w-10',
|
||||
sm: 'h-8 w-10 flex-shrink-0',
|
||||
lg: 'h-10 whitespace-nowrap px-4',
|
||||
};
|
||||
|
||||
|
||||
@ -4,17 +4,16 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'inline-flex items-center border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.15em] rounded-none bg-transparent focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
default: 'border-border text-foreground',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
'border-secondary bg-secondary text-secondary-foreground',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
'border-destructive text-destructive',
|
||||
outline: 'border-border text-muted-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@ -22,7 +22,7 @@ export function BookmarkButton({
|
||||
showText = false,
|
||||
}: BookmarkButtonProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 w-10',
|
||||
sm: showText ? 'h-8 px-2 whitespace-nowrap' : 'h-8 w-10',
|
||||
lg: 'h-10 whitespace-nowrap px-4',
|
||||
};
|
||||
|
||||
@ -39,7 +39,7 @@ export function BookmarkButton({
|
||||
disabled={loading}
|
||||
className={cn(
|
||||
sizeClasses[size],
|
||||
'transition-colors duration-200',
|
||||
'transition-colors duration-200 flex-shrink-0',
|
||||
isBookmarked
|
||||
? 'text-cyber-accent hover:text-cyber-light'
|
||||
: 'text-cyber-neutral hover:text-cyber-light',
|
||||
|
||||
@ -1,25 +1,26 @@
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
'inline-flex items-center justify-between gap-2 whitespace-nowrap rounded-none border border-border bg-transparent px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] ring-offset-background transition-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60 [&_svg]:pointer-events-none [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
default: 'text-foreground hover:bg-white/10',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
'border-destructive text-destructive hover:bg-destructive/20',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
'border-border text-foreground hover:bg-white/5 hover:text-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
ghost:
|
||||
'border border-transparent text-muted-foreground hover:border-border hover:bg-white/5 hover:text-foreground',
|
||||
link: 'border-0 px-0 py-0 text-primary underline-offset-4 hover:underline tracking-normal',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
default: 'px-3 py-2',
|
||||
sm: 'px-2 py-1 text-[10px]',
|
||||
lg: 'px-4 py-3 text-[12px]',
|
||||
icon: 'w-10 h-10 px-0 py-0 justify-center',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
'border border-border/70 bg-card text-card-foreground rounded-none shadow-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@ -23,7 +23,10 @@ const CardHeader = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
className={cn(
|
||||
'flex flex-col gap-1 border-b border-border/60 px-4 py-3 uppercase tracking-[0.12em] text-xs text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@ -60,7 +63,7 @@ const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
<div ref={ref} className={cn('px-4 py-3', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
@ -70,7 +73,10 @@ const CardFooter = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
className={cn(
|
||||
'flex items-center justify-between border-t border-border/60 px-4 py-3 gap-2 text-xs uppercase tracking-[0.1em]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@ -6,44 +6,44 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 226 20% 12%;
|
||||
--foreground: 0 0% 95%;
|
||||
--background: 210 5% 6%;
|
||||
--foreground: 45 18% 92%;
|
||||
|
||||
--card: 226 20% 12%;
|
||||
--card-foreground: 0 0% 94%;
|
||||
--card: 210 4% 8%;
|
||||
--card-foreground: 45 18% 92%;
|
||||
|
||||
--popover: 226 20% 18%;
|
||||
--popover-foreground: 0 0% 94%;
|
||||
--popover: 210 5% 10%;
|
||||
--popover-foreground: 45 18% 92%;
|
||||
|
||||
--primary: 195 82% 42%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--primary: 38 80% 62%;
|
||||
--primary-foreground: 0 0% 12%;
|
||||
|
||||
--secondary: 226 20% 18%;
|
||||
--secondary-foreground: 0 0% 94%;
|
||||
--secondary: 210 4% 12%;
|
||||
--secondary-foreground: 45 18% 92%;
|
||||
|
||||
--muted: 226 13% 27%;
|
||||
--muted-foreground: 225 6% 57%;
|
||||
--muted: 210 5% 18%;
|
||||
--muted-foreground: 210 10% 60%;
|
||||
|
||||
--accent: 195 82% 42%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--accent: 38 80% 62%;
|
||||
--accent-foreground: 0 0% 12%;
|
||||
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--destructive: 2 64% 48%;
|
||||
--destructive-foreground: 45 18% 96%;
|
||||
|
||||
--border: 226 13% 27%;
|
||||
--input: 226 13% 27%;
|
||||
--ring: 195 82% 42%;
|
||||
--border: 210 6% 22%;
|
||||
--input: 210 6% 22%;
|
||||
--ring: 38 80% 62%;
|
||||
|
||||
--radius: 0.25rem;
|
||||
--radius: 0rem;
|
||||
|
||||
--sidebar-background: 226 20% 14%;
|
||||
--sidebar-foreground: 0 0% 94%;
|
||||
--sidebar-primary: 195 82% 42%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 226 20% 18%;
|
||||
--sidebar-accent-foreground: 0 0% 94%;
|
||||
--sidebar-border: 226 13% 27%;
|
||||
--sidebar-ring: 195 82% 42%;
|
||||
--sidebar-background: 210 5% 8%;
|
||||
--sidebar-foreground: 45 18% 92%;
|
||||
--sidebar-primary: 38 80% 62%;
|
||||
--sidebar-primary-foreground: 0 0% 12%;
|
||||
--sidebar-accent: 210 4% 12%;
|
||||
--sidebar-accent-foreground: 45 18% 90%;
|
||||
--sidebar-border: 210 6% 22%;
|
||||
--sidebar-ring: 38 80% 62%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,6 +68,19 @@
|
||||
'Open Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
letter-spacing: 0.03em;
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.03) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(
|
||||
to right,
|
||||
rgba(255, 255, 255, 0.02) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 100% 32px, 32px 100%;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@ -75,24 +88,31 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--secondary));
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 3px;
|
||||
background: hsl(var(--border));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.text-glow {
|
||||
text-shadow: 0 0 8px rgba(15, 160, 206, 0.5);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.25em;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.text-glow-subtle {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
@apply outline-none ring-2 ring-primary/70 ring-offset-1 ring-offset-background;
|
||||
@apply outline-none ring-1 ring-primary/60 ring-offset-2 ring-offset-background;
|
||||
}
|
||||
|
||||
h1,
|
||||
@ -108,67 +128,67 @@
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-sm px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none disabled:opacity-50;
|
||||
@apply inline-flex items-center justify-between border border-border px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-foreground transition-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-60;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Page Layout Components */
|
||||
.page-container {
|
||||
@apply min-h-screen flex flex-col bg-cyber-dark text-white;
|
||||
@apply min-h-screen flex flex-col bg-cyber-dark text-foreground;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@apply mb-8;
|
||||
@apply mb-4 sm:mb-6 border-b border-border pb-3 sm:pb-4;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply text-3xl font-mono font-bold text-white mb-2;
|
||||
@apply text-sm sm:text-base font-semibold uppercase tracking-[0.25em] sm:tracking-[0.3em] text-foreground mb-1;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
@apply text-cyber-neutral;
|
||||
@apply text-[10px] sm:text-xs uppercase tracking-[0.15em] sm:tracking-[0.2em] text-muted-foreground;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
@apply flex-1 pt-16;
|
||||
@apply flex-1 pt-8 sm:pt-10;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
@apply container mx-auto px-4 py-8 max-w-6xl;
|
||||
@apply w-full mx-auto px-3 sm:px-4 md:px-8 py-4 sm:py-6 md:py-8 max-w-5xl;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
@apply border-t border-cyber-muted py-4 text-center text-xs text-cyber-neutral;
|
||||
@apply border-t border-border py-2 sm:py-3 text-center text-[10px] uppercase tracking-[0.2em] text-muted-foreground px-3;
|
||||
}
|
||||
|
||||
/* Card Components */
|
||||
.card-base {
|
||||
@apply bg-cyber-muted/20 border border-cyber-muted/30 rounded-sm transition-all duration-200;
|
||||
@apply bg-transparent border border-border/70 rounded-none shadow-none;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply hover:bg-cyber-muted/30 hover:border-cyber-accent/50;
|
||||
@apply hover:bg-white/5;
|
||||
}
|
||||
|
||||
.card-padding {
|
||||
@apply p-6;
|
||||
@apply p-3 sm:p-4;
|
||||
}
|
||||
|
||||
.card-padding-sm {
|
||||
@apply p-4;
|
||||
@apply p-2 sm:p-3;
|
||||
}
|
||||
|
||||
.thread-card {
|
||||
@apply card-base card-hover card-padding-sm mb-4;
|
||||
}
|
||||
|
||||
.board-card {
|
||||
@apply card-base card-hover card-padding-sm mb-3;
|
||||
}
|
||||
|
||||
.board-card {
|
||||
@apply card-base card-hover card-padding-sm mb-2;
|
||||
}
|
||||
|
||||
.comment-card {
|
||||
@apply border-l-2 border-muted pl-3 py-2 my-2 hover:border-primary transition-colors;
|
||||
@apply border-l border-border pl-3 py-2 my-2;
|
||||
}
|
||||
|
||||
/* Content Cards */
|
||||
@ -199,22 +219,14 @@
|
||||
|
||||
/* Button Variants */
|
||||
.btn-cyber {
|
||||
@apply bg-cyber-accent hover:bg-cyber-accent/80 text-black font-mono font-medium;
|
||||
@apply border border-border bg-transparent text-foreground uppercase tracking-[0.2em] text-[11px] hover:bg-white/10;
|
||||
}
|
||||
|
||||
.btn-cyber-outline {
|
||||
@apply border-cyber-muted/30 text-cyber-neutral hover:bg-cyber-muted/30;
|
||||
@apply border border-border text-muted-foreground hover:text-foreground hover:bg-white/5;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
.text-glow {
|
||||
text-shadow: 0 0 8px rgba(15, 160, 206, 0.5);
|
||||
}
|
||||
|
||||
.text-glow-subtle {
|
||||
text-shadow: 0 0 4px rgba(15, 160, 206, 0.3);
|
||||
}
|
||||
|
||||
/* Spacing Utilities */
|
||||
.section-spacing {
|
||||
@apply mb-8;
|
||||
@ -243,60 +255,24 @@
|
||||
|
||||
/* Empty States */
|
||||
.empty-state {
|
||||
@apply flex flex-col items-center justify-center py-16 px-4 text-center;
|
||||
@apply flex flex-col items-center justify-center py-8 sm:py-12 md:py-16 px-3 sm:px-4 text-center;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
@apply w-16 h-16 text-cyber-accent/50 mb-4;
|
||||
@apply w-12 h-12 sm:w-16 sm:h-16 text-cyber-accent/50 mb-3 sm:mb-4;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
@apply text-xl font-mono font-bold text-white mb-2;
|
||||
@apply text-base sm:text-lg md:text-xl font-mono font-bold text-white mb-2 tracking-[0.2em] sm:tracking-[0.25em];
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
@apply text-cyber-neutral mb-4;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cyber-flicker {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.8));
|
||||
}
|
||||
8%,
|
||||
10% {
|
||||
opacity: 0.8;
|
||||
filter: drop-shadow(0 0 5px rgba(0, 255, 255, 0.8));
|
||||
}
|
||||
20%,
|
||||
25% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 1px rgba(0, 255, 255, 0.5));
|
||||
}
|
||||
30% {
|
||||
opacity: 0.6;
|
||||
filter: drop-shadow(0 0 8px rgba(0, 255, 255, 1));
|
||||
}
|
||||
40%,
|
||||
45% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.6));
|
||||
}
|
||||
50%,
|
||||
55% {
|
||||
opacity: 0.9;
|
||||
filter: drop-shadow(0 0 3px rgba(0, 255, 255, 0.8));
|
||||
}
|
||||
60%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.6));
|
||||
@apply text-sm sm:text-base text-cyber-neutral mb-3 sm:mb-4 px-2;
|
||||
}
|
||||
}
|
||||
|
||||
.cyberpunk-glow {
|
||||
animation: cyber-flicker 8s infinite;
|
||||
will-change: filter, opacity;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.2em;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
@ -35,28 +35,28 @@ const FeedPage: React.FC = () => {
|
||||
) {
|
||||
return (
|
||||
<div className="min-h-screen bg-cyber-dark">
|
||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||
<div className="flex gap-6">
|
||||
<div className="container mx-auto px-3 sm:px-4 py-4 sm:py-6 max-w-6xl">
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||
{/* Main feed skeleton */}
|
||||
<div className="flex-1 max-w-3xl">
|
||||
<div className="space-y-4">
|
||||
<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-4"
|
||||
className="bg-cyber-muted/20 border border-cyber-muted rounded-sm p-3 sm:p-4"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<div className="w-10 space-y-2">
|
||||
<Skeleton className="h-6 w-6" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<Skeleton className="h-6 w-6" />
|
||||
<div 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">
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<div 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>
|
||||
@ -64,8 +64,8 @@ const FeedPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar skeleton */}
|
||||
<div className="w-80 space-y-4">
|
||||
{/* Sidebar skeleton - hidden on mobile */}
|
||||
<div className="hidden lg:block lg:w-80 space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
@ -91,21 +91,21 @@ const FeedPage: React.FC = () => {
|
||||
<div className="page-main">
|
||||
{/* Page Header */}
|
||||
<div className="page-header">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
|
||||
<div>
|
||||
<h1 className="page-title text-glow text-cyber-accent">
|
||||
<h1 className="page-title text-primary">
|
||||
Popular Posts
|
||||
</h1>
|
||||
<p className="page-subtitle">Latest posts from all cells</p>
|
||||
<p className="page-subtitle hidden sm:block">Latest posts from all cells</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-2">
|
||||
<ModerationToggle />
|
||||
|
||||
<Select
|
||||
value={sortOption}
|
||||
onValueChange={(value: SortOption) => setSortOption(value)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectTrigger className="w-32 sm:w-40 text-[11px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -129,24 +129,24 @@ const FeedPage: React.FC = () => {
|
||||
size="sm"
|
||||
onClick={content.refresh}
|
||||
disabled={false}
|
||||
className="flex items-center space-x-2"
|
||||
className="flex items-center space-x-1 sm:space-x-2 text-[11px]"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Refresh</span>
|
||||
<RefreshCw className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="hidden sm:inline">Refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||
{/* Main Feed */}
|
||||
<div className="flex-1 max-w-3xl">
|
||||
<div className="flex-1 lg:max-w-3xl min-w-0">
|
||||
{/* Posts Feed */}
|
||||
<div className="space-y-0">
|
||||
{allPosts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="space-y-3">
|
||||
<h3 className="empty-state-title text-glow">
|
||||
<h3 className="empty-state-title tracking-[0.25em]">
|
||||
No posts yet
|
||||
</h3>
|
||||
<p className="empty-state-description">
|
||||
@ -154,7 +154,7 @@ const FeedPage: React.FC = () => {
|
||||
</p>
|
||||
{verificationStatus !==
|
||||
EVerificationStatus.ENS_VERIFIED && (
|
||||
<p className="text-sm text-cyber-neutral/80">
|
||||
<p className="text-xs sm:text-sm text-cyber-neutral/80">
|
||||
Connect your wallet to start posting
|
||||
</p>
|
||||
)}
|
||||
@ -167,7 +167,7 @@ const FeedPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="w-80 flex-shrink-0">
|
||||
<div className="hidden lg:block lg:w-80 lg:flex-shrink-0">
|
||||
<FeedSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,7 @@ const PostPage = () => {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-cyber-dark text-white">
|
||||
<Header />
|
||||
<main className="flex-1 pt-16">
|
||||
<main className="flex-1 pt-12 sm:pt-14 md:pt-16">
|
||||
<PostDetail />
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@ -59,14 +59,14 @@ export default {
|
||||
border: 'hsl(var(--sidebar-border))',
|
||||
ring: 'hsl(var(--sidebar-ring))',
|
||||
},
|
||||
// Cypherpunk theme colors
|
||||
// Raw terminal-inspired palette
|
||||
cyber: {
|
||||
dark: '#1A1F2C',
|
||||
light: '#F1F1F1',
|
||||
accent: '#0FA0CE',
|
||||
accent2: '#1EAEDB',
|
||||
neutral: '#8E9196',
|
||||
muted: '#3A3F4C',
|
||||
dark: '#050505',
|
||||
light: '#F4F4F4',
|
||||
accent: '#F0C674',
|
||||
accent2: '#DD6B20',
|
||||
neutral: '#9F9F9F',
|
||||
muted: '#1A1A1A',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user