chore: linting + types + refactor

This commit is contained in:
Danish Arora 2025-08-30 18:34:50 +05:30
parent 14d333ff0d
commit 55ba3f374e
No known key found for this signature in database
GPG Key ID: 1C6EF37CDAE1426E
134 changed files with 5653 additions and 3745 deletions

View File

@ -1,3 +1,2 @@
VITE_REOWN_SECRETVITE_REOWN_SECRET VITE_REOWN_SECRET=
# Mock/bypass settings for development VITE_OPCHAN_MOCK_ORDINAL_CHECK=
VITE_OPCHAN_MOCK_ORDINAL_CHECK=false

3
.gitignore vendored
View File

@ -1,8 +1,7 @@
.cursorrules .cursorrules
comparison.md
.giga/ .giga/
furps.md furps-comparison.md
README-task-master.md README-task-master.md
.cursor .cursor

14
.prettierignore Normal file
View File

@ -0,0 +1,14 @@
node_modules
dist
build
.next
coverage
*.min.js
*.min.css
package-lock.json
yarn.lock
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"printWidth": 80,
"bracketSpacing": true,
"arrowParens": "avoid"
}

View File

@ -13,22 +13,26 @@ A decentralized forum application built as a Proof of Concept for a Waku-powered
### Installation ### Installation
1. **Clone the repository** 1. **Clone the repository**
```bash ```bash
git clone https://github.com/waku-org/OpChan.git git clone https://github.com/waku-org/OpChan.git
cd OpChan cd OpChan
``` ```
2. **Install dependencies** 2. **Install dependencies**
```bash ```bash
npm install npm install
``` ```
3. **Setup environment variables** 3. **Setup environment variables**
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
Edit `.env` to configure development settings: Edit `.env` to configure development settings:
```env ```env
# Set to 'true' to bypass verification in development # Set to 'true' to bypass verification in development
VITE_OPCHAN_MOCK_ORDINAL_CHECK=false VITE_OPCHAN_MOCK_ORDINAL_CHECK=false
@ -101,6 +105,7 @@ OpChan uses a two-tier authentication system:
7. Open a Pull Request 7. Open a Pull Request
## TODOs ## TODOs
- [x] replace mock wallet connection/disconnection - [x] replace mock wallet connection/disconnection
- supports Phantom - supports Phantom
- [x] replace mock Ordinal verification (API) - [x] replace mock Ordinal verification (API)
@ -119,7 +124,6 @@ OpChan implements a decentralized architecture with these key components:
- **Content Addressing**: Messages are cryptographically signed and verifiable - **Content Addressing**: Messages are cryptographically signed and verifiable
- **Moderation Layer**: Cell-based moderation without global censorship - **Moderation Layer**: Cell-based moderation without global censorship
## Support ## Support
For questions, issues, or contributions: For questions, issues, or contributions:
@ -131,4 +135,4 @@ For questions, issues, or contributions:
--- ---
**Note**: This is a Proof of Concept implementation. Use at your own risk in production environments. **Note**: This is a Proof of Concept implementation. Use at your own risk in production environments.

View File

@ -17,4 +17,4 @@
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
} }
} }

View File

@ -1,32 +1,35 @@
import js from "@eslint/js"; import js from '@eslint/js';
import globals from "globals"; import globals from 'globals';
import reactHooks from "eslint-plugin-react-hooks"; import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from "eslint-plugin-react-refresh"; import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from "typescript-eslint"; import tseslint from 'typescript-eslint';
export default tseslint.config( export default tseslint.config(
{ ignores: ["dist"] }, { ignores: ['dist'] },
{ {
extends: [js.configs.recommended, ...tseslint.configs.recommended], extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"], files: ['**/*.{ts,tsx}'],
languageOptions: { languageOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
plugins: { plugins: {
"react-hooks": reactHooks, 'react-hooks': reactHooks,
"react-refresh": reactRefresh, 'react-refresh': reactRefresh,
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [ 'react-refresh/only-export-components': [
"warn", 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
"@typescript-eslint/no-unused-vars": ["error", { '@typescript-eslint/no-unused-vars': [
"argsIgnorePattern": "^_", 'error',
"varsIgnorePattern": "^_" {
}], argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
}, },
} }
); );

233
furps-report.md Normal file
View File

@ -0,0 +1,233 @@
# Waku Forum FURPS Compliance Report
**Generated:** December 2024
**Codebase Analysis Date:** Current HEAD
Legend: ✅ **Fully Implemented** | 🟡 **Partially Implemented** | ❌ **Not Implemented** | ❔ **Unclear/Ambiguous**
---
## Executive Summary
This report provides a comprehensive analysis of the OpChan codebase against the specified FURPS requirements. The application shows **strong implementation** of core forum functionality, authentication systems, and Waku network integration. Key strengths include a sophisticated relevance scoring system, comprehensive moderation capabilities, and effective key delegation for improved UX. Major gaps exist in anonymous user interactions, user identity features, and some usability enhancements.
**Overall Compliance: 72% (26/36 requirements fully implemented)**
---
## Functionality Requirements
| # | Requirement | Status | Implementation Evidence | File References |
| ------ | ------------------------------------------------------------------------------------------ | :----: | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| **1** | Users can identify themselves by signing with their Bitcoin key | ✅ | Full Bitcoin wallet integration via ReOwnWalletService with signing capabilities | `src/lib/identity/wallets/ReOwnWalletService.ts:169-188` |
| **2** | Only users owning Logos ordinal or an ENS can create a cell | 🟡 | ENS ownership checks implemented, but ordinal verification bypassed in development mode | `src/lib/forum/actions.ts:180-187`, `src/lib/identity/ordinal.ts:13-20` |
| **3** | Any user (authenticated or not) can see the content; basic encryption functionality | ✅ | All content viewing routes accessible without authentication | `src/pages/*.tsx`, `src/components/PostList.tsx` |
| **4** | Existing cells can be listed | ✅ | Comprehensive cell listing with sorting and filtering | `src/components/CellList.tsx:35-120` |
| **5** | Cell can be created with a name, description, icon; icon size restricted; creator is admin | 🟡 | Form validation for URL exists but no size restriction enforcement | `src/components/CreateCellDialog.tsx:64-75` |
| **6** | Post can be created in a cell with title and body; text only | ✅ | Full post creation with text validation | `src/lib/forum/actions.ts:24-96` |
| **7** | Comments can be made on posts; text only | ✅ | Complete comment system with threading | `src/lib/forum/actions.ts:98-168` |
| **8** | Posts can be upvoted | ✅ | Comprehensive voting system with upvote/downvote tracking | `src/lib/forum/actions.ts:233-304` |
| **9** | Users can setup call sign; ordinal used as avatar | ❌ | No nickname/call sign fields in User interface; only truncated addresses displayed | `src/types/forum.ts:5-24` |
| **10** | Cell admin can mark posts and comments as moderated | ✅ | Full moderation system with reason tracking | `src/lib/forum/actions.ts:310-414` |
| **11** | Cell admin can mark users as moderated | ✅ | User-level moderation with cell-scoped restrictions | `src/lib/forum/actions.ts:416-459` |
| **12** | Users can identify themselves by signing with Web3 key | ✅ | Ethereum wallet support with ENS resolution via Wagmi | `src/lib/identity/wallets/ReOwnWalletService.ts:222-251` |
| **13** | Posts, comments, cells have relevance index for ordering/hiding | ✅ | Sophisticated RelevanceCalculator with multiple scoring factors | `src/lib/forum/relevance.ts:4-340` |
| **14** | Relevance lowered for moderated content/users | ✅ | Moderation penalty of 50% reduction applied | `src/lib/forum/relevance.ts:62-63` |
| **15** | Relevance increased for ENS/Ordinal owners | ✅ | 25% bonus for verified owners, 10% for basic verification | `src/lib/forum/relevance.ts:16-17` |
| **16** | Relevance increased for verified upvoters | ✅ | Verified upvote bonus of 0.1 per verified vote | `src/lib/forum/relevance.ts:47-51` |
| **17** | Relevance increased for verified commenters | ✅ | Verified commenter bonus of 0.05 per verified commenter | `src/lib/forum/relevance.ts:53-57` |
| **18** | Anonymous users can upvote, comment, and post | ❌ | All actions require authentication and verification checks | `src/lib/forum/actions.ts:34-41, 242-249` |
**Functionality Score: 14/18 (78%)**
---
## Usability Requirements
| # | Requirement | Status | Implementation Evidence | File References |
| ------ | ----------------------------------------------- | :----: | ------------------------------------------------------------------------- | ----------------------------------------------------------- |
| **1** | Users can see all topics through all cells | ✅ | Feed page aggregates posts from all cells | `src/pages/FeedPage.tsx:24-27` |
| **2** | Users can see active members per cell | 🟡 | Post count displayed, but active member calculation not fully implemented | `src/components/CellList.tsx:31-33` |
| **3** | Users can bookmark posts/topics (local only) | ❌ | No bookmarking functionality found in interfaces or components | _Not found_ |
| **4** | Users can sort topics by new or top | ✅ | Sorting controls with relevance and time options implemented | `src/pages/FeedPage.tsx:97-115`, `src/lib/forum/sorting.ts` |
| **5** | Ordinal picture and custom nickname for user ID | 🟡 | CypherImage generates avatars, but no nickname system | `src/components/ui/CypherImage.tsx` |
| **6** | Moderated content hidden from users | ✅ | Filtering logic hides moderated posts/comments from non-admins | `src/components/PostList.tsx:114-118` |
| **7** | Users don't need to sign every message | ✅ | Key delegation system with configurable duration | `src/lib/services/CryptoService.ts:250-274` |
| **8** | Only browser needed (no additional software) | ✅ | Web-based with optional wallet integration | _Architecture_ |
| **9** | Prototype UI for dogfooding | ✅ | Complete React UI with shadcn/ui components | `src/components/**` |
| **10** | Library with clear API for developers | ❌ | Internal services exist but no packaged library or external API | `src/lib/index.ts:8-249` |
| **11** | ENS holders can use ENS for display | ✅ | ENS names resolved and displayed throughout UI | `src/lib/identity/wallets/ReOwnWalletService.ts:232-236` |
| **12** | Relevance index used for content ranking | ✅ | Relevance-based sorting implemented as default option | `src/pages/FeedPage.tsx:21-27` |
**Usability Score: 8/12 (67%)**
---
## Reliability Requirements
| Requirement | Status | Implementation Evidence | File References |
| ------------------------------------------------ | :----: | ---------------------------------------------------------------------------------------------- | -------------------------------------------- |
| **Data is ephemeral; will disappear after time** | ✅ | Waku network inherently ephemeral; no permanent storage attempted | `src/lib/waku/core/WakuNodeManager.ts` |
| **End-to-end reliability for missing messages** | 🟡 | Basic health monitoring and reconnection logic, but no comprehensive missing message detection | `src/lib/waku/core/WakuNodeManager.ts:25-45` |
**Reliability Score: 1.5/2 (75%)**
---
## Performance Requirements
**No specific requirements defined** ✅
---
## Supportability Requirements
| Requirement | Status | Implementation Evidence | File References |
| -------------------------------------------- | :----: | --------------------------------------------------------------- | ---------------------------------- |
| **Web app; wallets optional** | ✅ | Read-only functionality without wallet connection | `src/pages/Index.tsx` |
| **Centralized API for Bitcoin ordinal info** | ✅ | OrdinalAPI class queries Logos dashboard API | `src/lib/identity/ordinal.ts:3-46` |
| **Uses Waku Network** | ✅ | Complete Waku integration with LightNode and reliable messaging | `src/lib/waku/core/*.ts` |
**Supportability Score: 3/3 (100%)**
---
## Privacy, Anonymity, Deployments
| Requirement | Status | Implementation Evidence | File References |
| --------------------------- | :----: | -------------------------------------------------- | ---------------------------------- |
| **Centralized ordinal API** | ✅ | Implemented with Logos dashboard integration | `src/lib/identity/ordinal.ts:3-46` |
| **Uses Waku Network** | ✅ | Decentralized messaging infrastructure implemented | `src/lib/waku/**` |
**Privacy/Anonymity Score: 2/2 (100%)**
---
## Key Implementation Strengths
### 🎯 **Sophisticated Relevance System**
- **Full scoring algorithm** with base scores, engagement metrics, verification bonuses
- **Time decay function** and moderation penalties properly implemented
- **Comprehensive test coverage** for relevance calculations
- **Integration** with sorting and UI display systems
### 🔐 **Robust Authentication Architecture**
- **Multi-wallet support** (Bitcoin via ReOwnWalletService, Ethereum via Wagmi)
- **Key delegation system** reducing wallet interaction friction
- **ENS integration** with proper resolution and display
- **Verification tiers** (unverified, basic, owner) properly implemented
### 🛡️ **Complete Moderation System**
- **Cell-level admin controls** for posts, comments, and users
- **Reason tracking** and timestamp recording
- **Visibility filtering** based on user roles
- **Integration** with relevance scoring for penalties
### 📡 **Solid Waku Integration**
- **Reliable messaging** with status callbacks
- **Health monitoring** and reconnection logic
- **Message caching** and transformation pipeline
- **Multi-channel support** for different message types
---
## Critical Implementation Gaps
### ❌ **Anonymous User Support (Requirement #18)**
**Impact:** High - Violates key accessibility requirement
**Current:** All actions require authentication (`isAuthenticated` checks)
**Files:** `src/lib/forum/actions.ts:34-41, 107-114, 242-249`
**Fix Required:** Remove authentication requirements for voting, posting, commenting
### ❌ **User Identity System (Requirement #9)**
**Impact:** Medium - Core user experience feature missing
**Current:** No nickname/call sign fields in User interface
**Files:** `src/types/forum.ts:5-24`
**Fix Required:** Add nickname field and call sign setup UI
### ❌ **Bookmarking System (Requirement #21)**
**Impact:** Medium - Important usability feature
**Current:** No local storage or bookmark functionality found
**Files:** None found
**Fix Required:** Implement local bookmark storage and UI
### ❌ **Developer Library (Requirement #28)**
**Impact:** Low - External integration capability
**Current:** Internal services only, no packaged library
**Files:** `src/lib/index.ts` exists but not exported as library
**Fix Required:** Create npm package with clear API documentation
---
## Technical Architecture Assessment
### **Message Flow & State Management** ⭐⭐⭐⭐⭐
- Clean separation between Waku messaging and forum domain logic
- Proper message transformation pipeline with verification
- Effective caching strategy with optimistic updates
### **Security & Cryptography** ⭐⭐⭐⭐⭐
- Proper message signing with delegation support
- Secure key management with expiration
- Protection against signature replay attacks
### **Error Handling & UX** ⭐⭐⭐⭐
- Comprehensive error messages with user-friendly descriptions
- Loading states and network status indicators
- Toast notifications for user feedback
### **Code Quality & Organization** ⭐⭐⭐⭐
- Well-structured TypeScript with proper type definitions
- Clear separation of concerns across modules
- Good test coverage for critical functions
---
## Priority Recommendations
### **P0 - Critical (Next Sprint)**
1. **Enable Anonymous Interactions** - Remove authentication requirements for basic actions
2. **Fix Ordinal Verification** - Implement real ordinal ownership checks for cell creation
3. **Add Missing User Identity** - Implement nickname/call sign system
### **P1 - High (Next Month)**
4. **Implement Bookmarking** - Add local bookmark functionality
5. **Enhance Active Member Tracking** - Calculate and display real active member counts
6. **Complete Icon Size Validation** - Add proper size restrictions for cell icons
### **P2 - Medium (Future Releases)**
7. **Create Developer Library** - Package services as distributable npm module
8. **Enhance Reliability** - Implement comprehensive missing message detection
9. **Add Advanced Sorting** - Extend sorting options beyond time/relevance
---
## Compliance Summary
| Category | Score | Status |
| --------------------- | :---------: | ---------------------------------------------- |
| **Functionality** | 14/18 (78%) | 🟡 Strong core, missing anonymous access |
| **Usability** | 8/12 (67%) | 🟡 Good UX foundation, needs identity features |
| **Reliability** | 1.5/2 (75%) | 🟡 Basic reliability, can be enhanced |
| **Performance** | N/A | ✅ No requirements specified |
| **Supportability** | 3/3 (100%) | ✅ Full compliance |
| **Privacy/Anonymity** | 2/2 (100%) | ✅ Full compliance |
**🎯 Overall FURPS Compliance: 72% (26/36 requirements fully implemented)**
---

View File

@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@ -10,11 +10,17 @@
<meta property="og:title" content="ordinal-echo-chamber" /> <meta property="og:title" content="ordinal-echo-chamber" />
<meta property="og:description" content="Lovable Generated Project" /> <meta property="og:description" content="Lovable Generated Project" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" /> <meta
property="og:image"
content="https://lovable.dev/opengraph-image-p98pqg.png"
/>
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@lovable_dev" /> <meta name="twitter:site" content="@lovable_dev" />
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" /> <meta
name="twitter:image"
content="https://lovable.dev/opengraph-image-p98pqg.png"
/>
</head> </head>
<body> <body>

17
package-lock.json generated
View File

@ -87,6 +87,7 @@
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"lovable-tagger": "^1.1.7", "lovable-tagger": "^1.1.7",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.11", "tailwindcss": "^3.4.11",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.0.1", "typescript-eslint": "^8.0.1",
@ -13664,6 +13665,22 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",

View File

@ -7,8 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"build:dev": "vite build --mode development", "build:dev": "vite build --mode development",
"check": "tsc --noEmit --strict && eslint . --fix", "check": "tsc --project tsconfig.app.json --noEmit && tsc --project tsconfig.node.json --noEmit && eslint . --fix && prettier --write .",
"lint": "eslint .", "fix": "prettier --write . && eslint . --fix",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
"test:ui": "vitest --ui" "test:ui": "vitest --ui"
@ -93,6 +93,7 @@
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"lovable-tagger": "^1.1.7", "lovable-tagger": "^1.1.7",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.11", "tailwindcss": "^3.4.11",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.0.1", "typescript-eslint": "^8.0.1",

View File

@ -3,4 +3,4 @@ export default {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} };

View File

@ -4,34 +4,33 @@
* Reference: * Reference:
* https://www.notion.so/Logos-Forum-PoC-Waku-Powered-Opchan-1968f96fb65c8078b343c43429d66d0a#1968f96fb65c8025a929c2c9255a57c4 * https://www.notion.so/Logos-Forum-PoC-Waku-Powered-Opchan-1968f96fb65c8078b343c43429d66d0a#1968f96fb65c8025a929c2c9255a57c4
* Also note that for UX purposes, **we should not ask a user to sign with their Bitcoin wallet for every action.** * Also note that for UX purposes, **we should not ask a user to sign with their Bitcoin wallet for every action.**
* *
* Instead, a key delegation system should be developed. * Instead, a key delegation system should be developed.
* *
* - User sign an in-browser key with their wallet and broadcast it * - User sign an in-browser key with their wallet and broadcast it
* - Browser uses in-browser key to sign messages moving forward * - Browser uses in-browser key to sign messages moving forward
*/ */
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from '@/components/ui/toaster';
import { Toaster as Sonner } from "@/components/ui/sonner"; import { Toaster as Sonner } from '@/components/ui/sonner';
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from '@/components/ui/tooltip';
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider } from "@/contexts/AuthContext"; import { AuthProvider } from '@/contexts/AuthContext';
import { ForumProvider } from "@/contexts/ForumContext"; import { ForumProvider } from '@/contexts/ForumContext';
import CellPage from "./pages/CellPage"; import CellPage from './pages/CellPage';
import PostPage from "./pages/PostPage"; import PostPage from './pages/PostPage';
import NotFound from "./pages/NotFound"; import NotFound from './pages/NotFound';
import Dashboard from "./pages/Dashboard"; import Dashboard from './pages/Dashboard';
import Index from "./pages/Index"; import Index from './pages/Index';
import { appkitConfig } from "./lib/identity/wallets/appkit"; import { appkitConfig } from './lib/identity/wallets/appkit';
import { WagmiProvider } from "wagmi"; import { WagmiProvider } from 'wagmi';
import { config } from "./lib/identity/wallets/appkit"; import { config } from './lib/identity/wallets/appkit';
import { AppKitProvider } from "@reown/appkit/react"; import { AppKitProvider } from '@reown/appkit/react';
// Create a client // Create a client
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const App = () => ( const App = () => (
<WagmiProvider config={config}> <WagmiProvider config={config}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>

View File

@ -11,8 +11,8 @@ interface FeedItemBase {
type: 'post' | 'comment'; type: 'post' | 'comment';
timestamp: number; timestamp: number;
ownerAddress: string; ownerAddress: string;
cellId?: string; cellId?: string;
postId?: string; postId?: string;
} }
interface PostFeedItem extends FeedItemBase { interface PostFeedItem extends FeedItemBase {
@ -20,70 +20,90 @@ interface PostFeedItem extends FeedItemBase {
title: string; title: string;
cellId: string; cellId: string;
postId: string; postId: string;
commentCount: number; commentCount: number;
voteCount: number; voteCount: number;
} }
interface CommentFeedItem extends FeedItemBase { interface CommentFeedItem extends FeedItemBase {
type: 'comment'; type: 'comment';
content: string; content: string;
postId: string; postId: string;
voteCount: number; voteCount: number;
} }
type FeedItem = PostFeedItem | CommentFeedItem; type FeedItem = PostFeedItem | CommentFeedItem;
const ActivityFeed: React.FC = () => { const ActivityFeed: React.FC = () => {
const { posts, comments, getCellById, isInitialLoading, userVerificationStatus } = useForum(); const {
posts,
comments,
getCellById,
isInitialLoading,
userVerificationStatus,
} = useForum();
const combinedFeed: FeedItem[] = [ const combinedFeed: FeedItem[] = [
...posts.map((post): PostFeedItem => ({ ...posts.map(
id: post.id, (post): PostFeedItem => ({
type: 'post', id: post.id,
timestamp: post.timestamp, type: 'post',
ownerAddress: post.authorAddress, timestamp: post.timestamp,
title: post.title, ownerAddress: post.authorAddress,
cellId: post.cellId, title: post.title,
postId: post.id, cellId: post.cellId,
commentCount: 0, postId: post.id,
voteCount: post.upvotes.length - post.downvotes.length, commentCount: 0,
})), voteCount: post.upvotes.length - post.downvotes.length,
...comments.map((comment): CommentFeedItem | null => { })
const parentPost = posts.find(p => p.id === comment.postId); ),
if (!parentPost) return null; ...comments
return { .map((comment): CommentFeedItem | null => {
id: comment.id, const parentPost = posts.find(p => p.id === comment.postId);
type: 'comment', if (!parentPost) return null;
timestamp: comment.timestamp, return {
ownerAddress: comment.authorAddress, id: comment.id,
content: comment.content, type: 'comment',
postId: comment.postId, timestamp: comment.timestamp,
cellId: parentPost.cellId, ownerAddress: comment.authorAddress,
voteCount: comment.upvotes.length - comment.downvotes.length, content: comment.content,
}; postId: comment.postId,
}) cellId: parentPost.cellId,
.filter((item): item is CommentFeedItem => item !== null), voteCount: comment.upvotes.length - comment.downvotes.length,
].sort((a, b) => b.timestamp - a.timestamp); };
})
.filter((item): item is CommentFeedItem => item !== null),
].sort((a, b) => b.timestamp - a.timestamp);
const renderFeedItem = (item: FeedItem) => { const renderFeedItem = (item: FeedItem) => {
const cell = item.cellId ? getCellById(item.cellId) : undefined; const cell = item.cellId ? getCellById(item.cellId) : undefined;
const timeAgo = formatDistanceToNow(new Date(item.timestamp), { addSuffix: true }); const timeAgo = formatDistanceToNow(new Date(item.timestamp), {
addSuffix: true,
});
const linkTarget = item.type === 'post' ? `/post/${item.postId}` : `/post/${item.postId}#comment-${item.id}`; const linkTarget =
item.type === 'post'
? `/post/${item.postId}`
: `/post/${item.postId}#comment-${item.id}`;
return ( return (
<Link <Link
to={linkTarget} to={linkTarget}
key={item.id} key={item.id}
className="block border border-muted hover:border-primary/50 hover:bg-secondary/30 rounded-sm p-3 mb-3 transition-colors duration-150" className="block border border-muted hover:border-primary/50 hover:bg-secondary/30 rounded-sm p-3 mb-3 transition-colors duration-150"
> >
<div className="flex items-center text-xs text-muted-foreground mb-1.5"> <div className="flex items-center text-xs text-muted-foreground mb-1.5">
{item.type === 'post' ? <Newspaper className="w-3.5 h-3.5 mr-1.5 text-primary/80" /> : <MessageSquareText className="w-3.5 h-3.5 mr-1.5 text-accent/80" />} {item.type === 'post' ? (
<Newspaper className="w-3.5 h-3.5 mr-1.5 text-primary/80" />
) : (
<MessageSquareText className="w-3.5 h-3.5 mr-1.5 text-accent/80" />
)}
<span className="font-medium text-foreground/90 mr-1"> <span className="font-medium text-foreground/90 mr-1">
{item.type === 'post' ? item.title : `Comment on: ${posts.find(p => p.id === item.postId)?.title || 'post'}`} {item.type === 'post'
? item.title
: `Comment on: ${posts.find(p => p.id === item.postId)?.title || 'post'}`}
</span> </span>
by by
<AuthorDisplay <AuthorDisplay
address={item.ownerAddress} address={item.ownerAddress}
userVerificationStatus={userVerificationStatus} userVerificationStatus={userVerificationStatus}
className="font-medium text-foreground/70 mx-1" className="font-medium text-foreground/70 mx-1"
@ -92,13 +112,15 @@ const ActivityFeed: React.FC = () => {
{cell && ( {cell && (
<> <>
in in
<span className="font-medium text-foreground/70 ml-1">/{cell.name}</span> <span className="font-medium text-foreground/70 ml-1">
/{cell.name}
</span>
</> </>
)} )}
<span className="ml-auto">{timeAgo}</span> <span className="ml-auto">{timeAgo}</span>
</div> </div>
{item.type === 'comment' && ( {item.type === 'comment' && (
<p className="text-sm text-foreground/80 pl-5 truncate"> <p className="text-sm text-foreground/80 pl-5 truncate">
{item.content} {item.content}
</p> </p>
)} )}
@ -109,7 +131,9 @@ const ActivityFeed: React.FC = () => {
if (isInitialLoading) { if (isInitialLoading) {
return ( return (
<div className="mb-6"> <div className="mb-6">
<h2 className="text-lg font-semibold mb-3 text-primary">Latest Activity</h2> <h2 className="text-lg font-semibold mb-3 text-primary">
Latest Activity
</h2>
{[...Array(5)].map((_, i) => ( {[...Array(5)].map((_, i) => (
<div key={i} className="border border-muted rounded-sm p-3 mb-3"> <div key={i} className="border border-muted rounded-sm p-3 mb-3">
<Skeleton className="h-4 w-3/4 mb-2" /> <Skeleton className="h-4 w-3/4 mb-2" />
@ -122,9 +146,13 @@ const ActivityFeed: React.FC = () => {
return ( return (
<div className="mb-6"> <div className="mb-6">
<h2 className="text-lg font-semibold mb-3 text-primary">Latest Activity</h2> <h2 className="text-lg font-semibold mb-3 text-primary">
Latest Activity
</h2>
{combinedFeed.length === 0 ? ( {combinedFeed.length === 0 ? (
<p className="text-muted-foreground text-sm">No activity yet. Be the first to post!</p> <p className="text-muted-foreground text-sm">
No activity yet. Be the first to post!
</p>
) : ( ) : (
combinedFeed.map(renderFeedItem) combinedFeed.map(renderFeedItem)
)} )}
@ -132,4 +160,4 @@ const ActivityFeed: React.FC = () => {
); );
}; };
export default ActivityFeed; export default ActivityFeed;

View File

@ -1,16 +1,30 @@
import React, { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useForum } from '@/contexts/useForum'; import { useForum } from '@/contexts/useForum';
import { Layout, MessageSquare, RefreshCw, Loader2, TrendingUp, Clock } from 'lucide-react'; import {
Layout,
MessageSquare,
RefreshCw,
Loader2,
TrendingUp,
Clock,
} from 'lucide-react';
import { CreateCellDialog } from './CreateCellDialog'; import { CreateCellDialog } from './CreateCellDialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { CypherImage } from './ui/CypherImage'; import { CypherImage } from './ui/CypherImage';
import { RelevanceIndicator } from './ui/relevance-indicator'; import { RelevanceIndicator } from './ui/relevance-indicator';
import { sortCells, SortOption } from '@/lib/forum/sorting'; import { sortCells, SortOption } from '@/lib/forum/sorting';
const CellList = () => { const CellList = () => {
const { cells, isInitialLoading, posts, refreshData, isRefreshing } = useForum(); const { cells, isInitialLoading, posts, refreshData, isRefreshing } =
useForum();
const [sortOption, setSortOption] = useState<SortOption>('relevance'); const [sortOption, setSortOption] = useState<SortOption>('relevance');
// Apply sorting to cells // Apply sorting to cells
@ -22,8 +36,12 @@ const CellList = () => {
return ( return (
<div className="container mx-auto px-4 pt-24 pb-16 text-center"> <div className="container mx-auto px-4 pt-24 pb-16 text-center">
<Loader2 className="w-8 h-8 mx-auto mb-4 animate-spin text-primary" /> <Loader2 className="w-8 h-8 mx-auto mb-4 animate-spin text-primary" />
<p className="text-lg font-medium text-muted-foreground">Loading Cells...</p> <p className="text-lg font-medium text-muted-foreground">
<p className="text-sm text-muted-foreground/70 mt-1">Connecting to the network and fetching data...</p> Loading Cells...
</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Connecting to the network and fetching data...
</p>
</div> </div>
); );
} }
@ -40,40 +58,45 @@ const CellList = () => {
<h1 className="text-2xl font-bold text-glow">Cells</h1> <h1 className="text-2xl font-bold text-glow">Cells</h1>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={sortOption} onValueChange={(value: SortOption) => setSortOption(value)}> <Select
value={sortOption}
onValueChange={(value: SortOption) => setSortOption(value)}
>
<SelectTrigger className="w-40"> <SelectTrigger className="w-40">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="relevance"> <SelectItem value="relevance">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4" /> <TrendingUp className="w-4 h-4" />
<span>Relevance</span> <span>Relevance</span>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value="time"> <SelectItem value="time">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
<span>Newest</span> <span>Newest</span>
</div> </div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={refreshData} onClick={refreshData}
disabled={isRefreshing} disabled={isRefreshing}
title="Refresh data" title="Refresh data"
className="px-3" className="px-3"
> >
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} /> <RefreshCw
className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`}
/>
</Button> </Button>
<CreateCellDialog /> <CreateCellDialog />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{cells.length === 0 ? ( {cells.length === 0 ? (
<div className="col-span-2 text-center py-12"> <div className="col-span-2 text-center py-12">
@ -82,26 +105,34 @@ const CellList = () => {
</div> </div>
</div> </div>
) : ( ) : (
sortedCells.map((cell) => ( sortedCells.map(cell => (
<Link to={`/cell/${cell.id}`} key={cell.id} className="board-card group"> <Link
to={`/cell/${cell.id}`}
key={cell.id}
className="board-card group"
>
<div className="flex gap-4 items-start"> <div className="flex gap-4 items-start">
<CypherImage <CypherImage
src={cell.icon} src={cell.icon}
alt={cell.name} alt={cell.name}
className="w-16 h-16 object-cover rounded-sm border border-cyber-muted group-hover:border-cyber-accent transition-colors" className="w-16 h-16 object-cover rounded-sm border border-cyber-muted group-hover:border-cyber-accent transition-colors"
generateUniqueFallback={true} generateUniqueFallback={true}
/> />
<div className="flex-1"> <div className="flex-1">
<h2 className="text-xl font-bold mb-1 group-hover:text-cyber-accent transition-colors">{cell.name}</h2> <h2 className="text-xl font-bold mb-1 group-hover:text-cyber-accent transition-colors">
<p className="text-sm text-cyber-neutral mb-2">{cell.description}</p> {cell.name}
</h2>
<p className="text-sm text-cyber-neutral mb-2">
{cell.description}
</p>
<div className="flex items-center text-xs text-cyber-neutral gap-2"> <div className="flex items-center text-xs text-cyber-neutral gap-2">
<div className="flex items-center"> <div className="flex items-center">
<MessageSquare className="w-3 h-3 mr-1" /> <MessageSquare className="w-3 h-3 mr-1" />
<span>{getPostCount(cell.id)} threads</span> <span>{getPostCount(cell.id)} threads</span>
</div> </div>
{cell.relevanceScore !== undefined && ( {cell.relevanceScore !== undefined && (
<RelevanceIndicator <RelevanceIndicator
score={cell.relevanceScore} score={cell.relevanceScore}
details={cell.relevanceDetails} details={cell.relevanceDetails}
type="cell" type="cell"
className="text-xs" className="text-xs"

View File

@ -1,127 +1,128 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react';
import { CypherImage } from './ui/CypherImage' import { CypherImage } from './ui/CypherImage';
// Mock external dependencies // Mock external dependencies
vi.mock('@/lib/utils', () => ({ vi.mock('@/lib/utils', () => ({
cn: (...classes: (string | undefined | null)[]) => classes.filter(Boolean).join(' ') cn: (...classes: (string | undefined | null)[]) =>
})) classes.filter(Boolean).join(' '),
}));
describe('Create Cell Without Icon - CypherImage Fallback', () => { describe('Create Cell Without Icon - CypherImage Fallback', () => {
it('shows fallback identicon when src is empty (simulating cell without icon)', () => { it('shows fallback identicon when src is empty (simulating cell without icon)', () => {
render( render(
<CypherImage <CypherImage
src="" src=""
alt="Test Cell" alt="Test Cell"
className="w-16 h-16" className="w-16 h-16"
generateUniqueFallback={true} generateUniqueFallback={true}
/> />
) );
// Verify that the fallback identicon is rendered instead of an img tag // Verify that the fallback identicon is rendered instead of an img tag
const identicon = screen.getByTitle('Test Cell') const identicon = screen.getByTitle('Test Cell');
expect(identicon).toBeInTheDocument() expect(identicon).toBeInTheDocument();
// Check for the fallback identicon characteristics // Check for the fallback identicon characteristics
expect(identicon).toHaveClass('flex', 'items-center', 'justify-center') expect(identicon).toHaveClass('flex', 'items-center', 'justify-center');
// The fallback should contain the first letter of the alt text (cell name) // The fallback should contain the first letter of the alt text (cell name)
const firstLetter = screen.getByText('T') // First letter of "Test Cell" const firstLetter = screen.getByText('T'); // First letter of "Test Cell"
expect(firstLetter).toBeInTheDocument() expect(firstLetter).toBeInTheDocument();
expect(firstLetter).toHaveClass('font-bold') expect(firstLetter).toHaveClass('font-bold');
// Should not render an img element when src is empty // Should not render an img element when src is empty
const imgElement = screen.queryByRole('img') const imgElement = screen.queryByRole('img');
expect(imgElement).not.toBeInTheDocument() expect(imgElement).not.toBeInTheDocument();
}) });
it('shows fallback identicon when src is undefined (simulating cell without icon)', () => { it('shows fallback identicon when src is undefined (simulating cell without icon)', () => {
render( render(
<CypherImage <CypherImage
src={undefined} src={undefined}
alt="Another Cell" alt="Another Cell"
className="w-16 h-16" className="w-16 h-16"
generateUniqueFallback={true} generateUniqueFallback={true}
/> />
) );
// Verify that the fallback identicon is rendered // Verify that the fallback identicon is rendered
const identicon = screen.getByTitle('Another Cell') const identicon = screen.getByTitle('Another Cell');
expect(identicon).toBeInTheDocument() expect(identicon).toBeInTheDocument();
// The fallback should contain the first letter of the alt text // The fallback should contain the first letter of the alt text
const firstLetter = screen.getByText('A') // First letter of "Another Cell" const firstLetter = screen.getByText('A'); // First letter of "Another Cell"
expect(firstLetter).toBeInTheDocument() expect(firstLetter).toBeInTheDocument();
// Should not render an img element when src is undefined // Should not render an img element when src is undefined
const imgElement = screen.queryByRole('img') const imgElement = screen.queryByRole('img');
expect(imgElement).not.toBeInTheDocument() expect(imgElement).not.toBeInTheDocument();
}) });
it('shows fallback identicon with correct cyberpunk styling', () => { it('shows fallback identicon with correct cyberpunk styling', () => {
render( render(
<CypherImage <CypherImage
src="" src=""
alt="Cyberpunk Cell" alt="Cyberpunk Cell"
className="w-16 h-16" className="w-16 h-16"
generateUniqueFallback={true} generateUniqueFallback={true}
/> />
) );
const identicon = screen.getByTitle('Cyberpunk Cell');
const identicon = screen.getByTitle('Cyberpunk Cell')
// Check for cyberpunk styling elements // Check for cyberpunk styling elements
expect(identicon).toHaveStyle({ backgroundColor: '#0a1119' }) expect(identicon).toHaveStyle({ backgroundColor: '#0a1119' });
// Check that the first letter is rendered with appropriate styling // Check that the first letter is rendered with appropriate styling
const firstLetter = screen.getByText('C') const firstLetter = screen.getByText('C');
expect(firstLetter).toHaveClass('relative', 'font-bold', 'cyberpunk-glow', 'z-10') expect(firstLetter).toHaveClass(
}) 'relative',
'font-bold',
'cyberpunk-glow',
'z-10'
);
});
it('renders normal img when src is provided (control test)', () => { it('renders normal img when src is provided (control test)', () => {
render( render(
<CypherImage <CypherImage
src="https://example.com/valid-image.jpg" src="https://example.com/valid-image.jpg"
alt="Valid Image Cell" alt="Valid Image Cell"
className="w-16 h-16" className="w-16 h-16"
/> />
) );
// Should render an img element when src is provided // Should render an img element when src is provided
const imgElement = screen.getByRole('img') const imgElement = screen.getByRole('img');
expect(imgElement).toBeInTheDocument() expect(imgElement).toBeInTheDocument();
expect(imgElement).toHaveAttribute('src', 'https://example.com/valid-image.jpg') expect(imgElement).toHaveAttribute(
expect(imgElement).toHaveAttribute('alt', 'Valid Image Cell') 'src',
'https://example.com/valid-image.jpg'
);
expect(imgElement).toHaveAttribute('alt', 'Valid Image Cell');
// Should not show fallback identicon when image src is provided // Should not show fallback identicon when image src is provided
const identicon = screen.queryByTitle('Valid Image Cell') const identicon = screen.queryByTitle('Valid Image Cell');
expect(identicon).not.toBeInTheDocument() expect(identicon).not.toBeInTheDocument();
}) });
it('generates unique fallbacks for different cell names', () => { it('generates unique fallbacks for different cell names', () => {
const { rerender } = render( const { rerender } = render(
<CypherImage <CypherImage src="" alt="Alpha Cell" generateUniqueFallback={true} />
src="" );
alt="Alpha Cell"
generateUniqueFallback={true}
/>
)
const alphaLetter = screen.getByText('A') const alphaLetter = screen.getByText('A');
expect(alphaLetter).toBeInTheDocument() expect(alphaLetter).toBeInTheDocument();
rerender( rerender(
<CypherImage <CypherImage src="" alt="Beta Cell" generateUniqueFallback={true} />
src="" );
alt="Beta Cell"
generateUniqueFallback={true} const betaLetter = screen.getByText('B');
/> expect(betaLetter).toBeInTheDocument();
)
const betaLetter = screen.getByText('B')
expect(betaLetter).toBeInTheDocument()
// Alpha should no longer be present // Alpha should no longer be present
expect(screen.queryByText('A')).not.toBeInTheDocument() expect(screen.queryByText('A')).not.toBeInTheDocument();
}) });
}) });

View File

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { useForm } from "react-hook-form"; import { useForm } from 'react-hook-form';
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from "zod"; import { z } from 'zod';
import { Loader2 } from "lucide-react"; import { Loader2 } from 'lucide-react';
import { useForum } from "@/contexts/useForum"; import { useForum } from '@/contexts/useForum';
import { useAuth } from "@/contexts/useAuth"; import { useAuth } from '@/contexts/useAuth';
import { import {
Form, Form,
FormControl, FormControl,
@ -12,28 +12,34 @@ import {
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from '@/components/ui/form';
import { Input } from "@/components/ui/input"; import { Input } from '@/components/ui/input';
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from '@/components/ui/textarea';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from '@/components/ui/dialog';
import { useToast } from "@/hooks/use-toast"; import { useToast } from '@/hooks/use-toast';
import { urlLoads } from "@/lib/utils/urlLoads"; import { urlLoads } from '@/lib/utils/urlLoads';
const formSchema = z.object({ const formSchema = z.object({
title: z.string().min(3, "Title must be at least 3 characters").max(50, "Title must be less than 50 characters"), title: z
description: z.string().min(10, "Description must be at least 10 characters").max(200, "Description must be less than 200 characters"), .string()
.min(3, 'Title must be at least 3 characters')
.max(50, 'Title must be less than 50 characters'),
description: z
.string()
.min(10, 'Description must be at least 10 characters')
.max(200, 'Description must be less than 200 characters'),
icon: z icon: z
.string() .string()
.optional() .optional()
.refine((val) => !val || val.length === 0 || URL.canParse(val), { .refine(val => !val || val.length === 0 || URL.canParse(val), {
message: "Must be a valid URL" message: 'Must be a valid URL',
}), }),
}); });
@ -42,20 +48,23 @@ interface CreateCellDialogProps {
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
} }
export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCellDialogProps = {}) { export function CreateCellDialog({
open: externalOpen,
onOpenChange,
}: CreateCellDialogProps = {}) {
const { createCell, isPostingCell } = useForum(); const { createCell, isPostingCell } = useForum();
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const [internalOpen, setInternalOpen] = React.useState(false); const [internalOpen, setInternalOpen] = React.useState(false);
const open = externalOpen ?? internalOpen; const open = externalOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen; const setOpen = onOpenChange ?? setInternalOpen;
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
title: "", title: '',
description: "", description: '',
icon: undefined, icon: undefined,
}, },
}); });
@ -66,15 +75,20 @@ export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCel
const ok = await urlLoads(values.icon, 5000); const ok = await urlLoads(values.icon, 5000);
if (!ok) { if (!ok) {
toast({ toast({
title: "Icon URL Error", title: 'Icon URL Error',
description: "Icon URL could not be loaded. Please check the URL and try again.", description:
variant: "destructive", 'Icon URL could not be loaded. Please check the URL and try again.',
variant: 'destructive',
}); });
return; return;
} }
} }
const cell = await createCell(values.title, values.description, values.icon || undefined); const cell = await createCell(
values.title,
values.description,
values.icon || undefined
);
if (cell) { if (cell) {
setOpen(false); setOpen(false);
form.reset(); form.reset();
@ -87,7 +101,9 @@ export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCel
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
{!onOpenChange && ( {!onOpenChange && (
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="w-full">Create New Cell</Button> <Button variant="outline" className="w-full">
Create New Cell
</Button>
</DialogTrigger> </DialogTrigger>
)} )}
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[425px]">
@ -103,7 +119,11 @@ export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCel
<FormItem> <FormItem>
<FormLabel>Title</FormLabel> <FormLabel>Title</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Enter cell title" {...field} disabled={isPostingCell} /> <Input
placeholder="Enter cell title"
{...field}
disabled={isPostingCell}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -116,7 +136,7 @@ export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCel
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="Enter cell description" placeholder="Enter cell description"
{...field} {...field}
disabled={isPostingCell} disabled={isPostingCell}
@ -133,11 +153,11 @@ export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCel
<FormItem> <FormItem>
<FormLabel>Icon URL (optional)</FormLabel> <FormLabel>Icon URL (optional)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="Enter icon URL (optional)" placeholder="Enter icon URL (optional)"
type="url" type="url"
{...field} {...field}
value={field.value || ""} value={field.value || ''}
disabled={isPostingCell} disabled={isPostingCell}
/> />
</FormControl> </FormControl>
@ -145,11 +165,7 @@ export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCel
</FormItem> </FormItem>
)} )}
/> />
<Button <Button type="submit" className="w-full" disabled={isPostingCell}>
type="submit"
className="w-full"
disabled={isPostingCell}
>
{isPostingCell && ( {isPostingCell && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
)} )}

View File

@ -7,7 +7,8 @@ import { Badge } from '@/components/ui/badge';
import { useForum } from '@/contexts/useForum'; import { useForum } from '@/contexts/useForum';
import { useAuth } from '@/contexts/useAuth'; import { useAuth } from '@/contexts/useAuth';
import { CypherImage } from '@/components/ui/CypherImage'; import { CypherImage } from '@/components/ui/CypherImage';
import {CreateCellDialog} from '@/components/CreateCellDialog'; import { CreateCellDialog } from '@/components/CreateCellDialog';
import { EVerificationStatus } from '@/types/identity';
const FeedSidebar: React.FC = () => { const FeedSidebar: React.FC = () => {
const { cells, posts } = useForum(); const { cells, posts } = useForum();
@ -18,19 +19,20 @@ const FeedSidebar: React.FC = () => {
const trendingCells = cells const trendingCells = cells
.map(cell => { .map(cell => {
const cellPosts = posts.filter(post => post.cellId === cell.id); const cellPosts = posts.filter(post => post.cellId === cell.id);
const recentPosts = cellPosts.filter(post => const recentPosts = cellPosts.filter(
Date.now() - post.timestamp < 24 * 60 * 60 * 1000 // Last 24 hours post => Date.now() - post.timestamp < 24 * 60 * 60 * 1000 // Last 24 hours
); );
const totalScore = cellPosts.reduce((sum, post) => const totalScore = cellPosts.reduce(
sum + (post.upvotes.length - post.downvotes.length), 0 (sum, post) => sum + (post.upvotes.length - post.downvotes.length),
0
); );
return { return {
...cell, ...cell,
postCount: cellPosts.length, postCount: cellPosts.length,
recentPostCount: recentPosts.length, recentPostCount: recentPosts.length,
totalScore, totalScore,
activity: recentPosts.length + (totalScore * 0.1) // Simple activity score activity: recentPosts.length + totalScore * 0.1, // Simple activity score
}; };
}) })
.sort((a, b) => b.activity - a.activity) .sort((a, b) => b.activity - a.activity)
@ -44,10 +46,22 @@ const FeedSidebar: React.FC = () => {
// Ethereum wallet with ENS // Ethereum wallet with ENS
if (currentUser.walletType === 'ethereum') { if (currentUser.walletType === 'ethereum') {
if (currentUser.ensName && (verificationStatus === 'verified-owner' || currentUser.ensOwnership)) { if (
return <Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> Owns ENS: {currentUser.ensName}</Badge>; currentUser.ensDetails?.ensName &&
(verificationStatus === EVerificationStatus.VERIFIED_OWNER ||
currentUser.ensDetails)
) {
return (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Owns ENS: {currentUser.ensDetails.ensName}
</Badge>
);
} else if (verificationStatus === 'verified-basic') { } else if (verificationStatus === 'verified-basic') {
return <Badge className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"> Connected Wallet</Badge>; return (
<Badge className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Connected Wallet
</Badge>
);
} else { } else {
return <Badge variant="outline">Read-only (No ENS detected)</Badge>; return <Badge variant="outline">Read-only (No ENS detected)</Badge>;
} }
@ -55,10 +69,21 @@ const FeedSidebar: React.FC = () => {
// Bitcoin wallet with Ordinal // Bitcoin wallet with Ordinal
if (currentUser.walletType === 'bitcoin') { if (currentUser.walletType === 'bitcoin') {
if (verificationStatus === 'verified-owner' || currentUser.ordinalOwnership) { if (
return <Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> Owns Ordinal</Badge>; verificationStatus === 'verified-owner' ||
currentUser.ordinalDetails?.ordinalId
) {
return (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Owns Ordinal
</Badge>
);
} else if (verificationStatus === 'verified-basic') { } else if (verificationStatus === 'verified-basic') {
return <Badge className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"> Connected Wallet</Badge>; return (
<Badge className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Connected Wallet
</Badge>
);
} else { } else {
return <Badge variant="outline">Read-only (No Ordinal detected)</Badge>; return <Badge variant="outline">Read-only (No Ordinal detected)</Badge>;
} }
@ -67,7 +92,11 @@ const FeedSidebar: React.FC = () => {
// Fallback cases // Fallback cases
switch (verificationStatus) { switch (verificationStatus) {
case 'verified-basic': case 'verified-basic':
return <Badge className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"> Connected Wallet</Badge>; return (
<Badge className="bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Connected Wallet
</Badge>
);
case 'verified-none': case 'verified-none':
return <Badge variant="outline">Read Only</Badge>; return <Badge variant="outline">Read Only</Badge>;
case 'verifying': case 'verifying':
@ -83,11 +112,14 @@ const FeedSidebar: React.FC = () => {
{currentUser && ( {currentUser && (
<Card className="bg-cyber-muted/20 border-cyber-muted"> <Card className="bg-cyber-muted/20 border-cyber-muted">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-cyber-accent">Your Account</CardTitle> <CardTitle className="text-sm font-medium text-cyber-accent">
Your Account
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<div className="text-xs text-cyber-neutral"> <div className="text-xs text-cyber-neutral">
{currentUser.address.slice(0, 8)}...{currentUser.address.slice(-6)} {currentUser.address.slice(0, 8)}...
{currentUser.address.slice(-6)}
</div> </div>
{getVerificationBadge()} {getVerificationBadge()}
</CardContent> </CardContent>
@ -97,7 +129,7 @@ const FeedSidebar: React.FC = () => {
{/* Create Cell */} {/* Create Cell */}
<Card className="bg-cyber-muted/20 border-cyber-muted"> <Card className="bg-cyber-muted/20 border-cyber-muted">
<CardContent className="p-4"> <CardContent className="p-4">
<Button <Button
onClick={() => setShowCreateCell(true)} onClick={() => setShowCreateCell(true)}
className="w-full" className="w-full"
disabled={verificationStatus !== 'verified-owner'} disabled={verificationStatus !== 'verified-owner'}
@ -107,10 +139,9 @@ const FeedSidebar: React.FC = () => {
</Button> </Button>
{verificationStatus !== 'verified-owner' && ( {verificationStatus !== 'verified-owner' && (
<p className="text-xs text-cyber-neutral mt-2 text-center"> <p className="text-xs text-cyber-neutral mt-2 text-center">
{currentUser?.walletType === 'ethereum' {currentUser?.walletType === 'ethereum'
? 'Own an ENS name to create cells' ? 'Own an ENS name to create cells'
: 'Own a Bitcoin Ordinal to create cells' : 'Own a Bitcoin Ordinal to create cells'}
}
</p> </p>
)} )}
</CardContent> </CardContent>
@ -129,8 +160,8 @@ const FeedSidebar: React.FC = () => {
<p className="text-xs text-cyber-neutral">No cells yet</p> <p className="text-xs text-cyber-neutral">No cells yet</p>
) : ( ) : (
trendingCells.map((cell, index) => ( trendingCells.map((cell, index) => (
<Link <Link
key={cell.id} key={cell.id}
to={`/cell/${cell.id}`} to={`/cell/${cell.id}`}
className="flex items-center space-x-3 p-2 rounded-sm hover:bg-cyber-muted/50 transition-colors" className="flex items-center space-x-3 p-2 rounded-sm hover:bg-cyber-muted/50 transition-colors"
> >
@ -138,8 +169,8 @@ const FeedSidebar: React.FC = () => {
<span className="text-xs font-medium text-cyber-neutral w-4"> <span className="text-xs font-medium text-cyber-neutral w-4">
{index + 1} {index + 1}
</span> </span>
<CypherImage <CypherImage
src={cell.icon} src={cell.icon}
alt={cell.name} alt={cell.name}
className="w-6 h-6 rounded-sm flex-shrink-0" className="w-6 h-6 rounded-sm flex-shrink-0"
generateUniqueFallback={true} generateUniqueFallback={true}
@ -178,7 +209,7 @@ const FeedSidebar: React.FC = () => {
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{cells.slice(0, 8).map(cell => ( {cells.slice(0, 8).map(cell => (
<Link <Link
key={cell.id} key={cell.id}
to={`/cell/${cell.id}`} to={`/cell/${cell.id}`}
className="block text-sm text-cyber-neutral hover:text-cyber-accent transition-colors" className="block text-sm text-cyber-neutral hover:text-cyber-accent transition-colors"
@ -187,7 +218,7 @@ const FeedSidebar: React.FC = () => {
</Link> </Link>
))} ))}
{cells.length > 8 && ( {cells.length > 8 && (
<Link <Link
to="/" to="/"
className="block text-xs text-cyber-neutral hover:text-cyber-accent transition-colors" className="block text-xs text-cyber-neutral hover:text-cyber-accent transition-colors"
> >
@ -214,12 +245,12 @@ const FeedSidebar: React.FC = () => {
</Card> </Card>
{/* Create Cell Dialog */} {/* Create Cell Dialog */}
<CreateCellDialog <CreateCellDialog
open={showCreateCell} open={showCreateCell}
onOpenChange={setShowCreateCell} onOpenChange={setShowCreateCell}
/> />
</div> </div>
); );
}; };
export default FeedSidebar; export default FeedSidebar;

View File

@ -4,34 +4,50 @@ import { useAuth } from '@/contexts/useAuth';
import { useForum } from '@/contexts/useForum'; import { useForum } from '@/contexts/useForum';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { LogOut, Terminal, Wifi, WifiOff, AlertTriangle, CheckCircle, Key, RefreshCw, CircleSlash, Home, Grid3X3} from 'lucide-react'; import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; LogOut,
Terminal,
Wifi,
WifiOff,
AlertTriangle,
CheckCircle,
Key,
RefreshCw,
CircleSlash,
Home,
Grid3X3,
} from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { useAppKitAccount, useDisconnect } from '@reown/appkit/react'; import { useAppKitAccount, useDisconnect } from '@reown/appkit/react';
import { WalletWizard } from '@/components/ui/wallet-wizard'; import { WalletWizard } from '@/components/ui/wallet-wizard';
const Header = () => { const Header = () => {
const { const { currentUser, verificationStatus, isDelegationValid } = useAuth();
currentUser,
verificationStatus,
isDelegationValid,
} = useAuth();
const { isNetworkConnected, isRefreshing } = useForum(); const { isNetworkConnected, isRefreshing } = useForum();
const location = useLocation(); const location = useLocation();
const { toast } = useToast(); const { toast } = useToast();
// Use AppKit hooks for multi-chain support // Use AppKit hooks for multi-chain support
const bitcoinAccount = useAppKitAccount({ namespace: "bip122" }); const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
const ethereumAccount = useAppKitAccount({ namespace: "eip155" }); const ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
const { disconnect } = useDisconnect(); const { disconnect } = useDisconnect();
// Determine which account is connected // Determine which account is connected
const isBitcoinConnected = bitcoinAccount.isConnected; const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected; const isEthereumConnected = ethereumAccount.isConnected;
const isConnected = isBitcoinConnected || isEthereumConnected; const isConnected = isBitcoinConnected || isEthereumConnected;
const address = isConnected ? (isBitcoinConnected ? bitcoinAccount.address : ethereumAccount.address) : undefined; const address = isConnected
? isBitcoinConnected
? bitcoinAccount.address
: ethereumAccount.address
: undefined;
const [walletWizardOpen, setWalletWizardOpen] = useState(false); const [walletWizardOpen, setWalletWizardOpen] = useState(false);
// Use sessionStorage to persist wizard state across navigation // Use sessionStorage to persist wizard state across navigation
const getHasShownWizard = () => { const getHasShownWizard = () => {
try { try {
@ -40,7 +56,7 @@ const Header = () => {
return false; return false;
} }
}; };
const setHasShownWizard = (value: boolean) => { const setHasShownWizard = (value: boolean) => {
try { try {
sessionStorage.setItem('hasShownWalletWizard', value.toString()); sessionStorage.setItem('hasShownWalletWizard', value.toString());
@ -48,7 +64,7 @@ const Header = () => {
// Fallback if sessionStorage is not available // Fallback if sessionStorage is not available
} }
}; };
// Auto-open wizard when wallet connects for the first time // Auto-open wizard when wallet connects for the first time
React.useEffect(() => { React.useEffect(() => {
if (isConnected && !getHasShownWizard()) { if (isConnected && !getHasShownWizard()) {
@ -56,21 +72,19 @@ const Header = () => {
setHasShownWizard(true); setHasShownWizard(true);
} }
}, [isConnected]); }, [isConnected]);
const handleConnect = async () => { const handleConnect = async () => {
setWalletWizardOpen(true); setWalletWizardOpen(true);
}; };
const handleDisconnect = async () => { const handleDisconnect = async () => {
await disconnect(); await disconnect();
setHasShownWizard(false); // Reset so wizard can show again on next connection setHasShownWizard(false); // Reset so wizard can show again on next connection
toast({ toast({
title: "Wallet Disconnected", title: 'Wallet Disconnected',
description: "Your wallet has been disconnected successfully.", description: 'Your wallet has been disconnected successfully.',
}); });
}; };
const getAccountStatusText = () => { const getAccountStatusText = () => {
switch (verificationStatus) { switch (verificationStatus) {
@ -98,9 +112,17 @@ const Header = () => {
case 'verified-none': case 'verified-none':
return <CircleSlash className="w-3 h-3" />; return <CircleSlash className="w-3 h-3" />;
case 'verified-basic': case 'verified-basic':
return isDelegationValid() ? <CheckCircle className="w-3 h-3" /> : <Key className="w-3 h-3" />; return isDelegationValid() ? (
<CheckCircle className="w-3 h-3" />
) : (
<Key className="w-3 h-3" />
);
case 'verified-owner': case 'verified-owner':
return isDelegationValid() ? <CheckCircle className="w-3 h-3" /> : <Key className="w-3 h-3" />; return isDelegationValid() ? (
<CheckCircle className="w-3 h-3" />
) : (
<Key className="w-3 h-3" />
);
default: default:
return <AlertTriangle className="w-3 h-3" />; return <AlertTriangle className="w-3 h-3" />;
} }
@ -122,26 +144,29 @@ const Header = () => {
return 'outline'; return 'outline';
} }
}; };
return ( return (
<> <>
<header className="border-b border-cyber-muted bg-cyber-dark fixed top-0 left-0 right-0 z-50 h-16"> <header className="border-b border-cyber-muted bg-cyber-dark fixed top-0 left-0 right-0 z-50 h-16">
<div className="container mx-auto px-4 h-full flex justify-between items-center"> <div className="container mx-auto px-4 h-full flex justify-between items-center">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Terminal className="text-cyber-accent w-6 h-6" /> <Terminal className="text-cyber-accent w-6 h-6" />
<Link to="/" className="text-xl font-bold text-glow text-cyber-accent"> <Link
to="/"
className="text-xl font-bold text-glow text-cyber-accent"
>
OpChan OpChan
</Link> </Link>
</div> </div>
{/* Navigation Tabs */} {/* Navigation Tabs */}
<nav className="hidden md:flex items-center space-x-1"> <nav className="hidden md:flex items-center space-x-1">
<Link <Link
to="/" to="/"
className={`flex items-center space-x-2 px-3 py-2 text-sm font-medium rounded-sm transition-colors ${ className={`flex items-center space-x-2 px-3 py-2 text-sm font-medium rounded-sm transition-colors ${
location.pathname === '/' location.pathname === '/'
? 'bg-cyber-accent/20 text-cyber-accent' ? 'bg-cyber-accent/20 text-cyber-accent'
: 'text-gray-300 hover:text-cyber-accent hover:bg-cyber-accent/10' : 'text-gray-300 hover:text-cyber-accent hover:bg-cyber-accent/10'
}`} }`}
> >
@ -151,8 +176,8 @@ const Header = () => {
<Link <Link
to="/cells" to="/cells"
className={`flex items-center space-x-2 px-3 py-2 text-sm font-medium rounded-sm transition-colors ${ className={`flex items-center space-x-2 px-3 py-2 text-sm font-medium rounded-sm transition-colors ${
location.pathname === '/cells' location.pathname === '/cells'
? 'bg-cyber-accent/20 text-cyber-accent' ? 'bg-cyber-accent/20 text-cyber-accent'
: 'text-gray-300 hover:text-cyber-accent hover:bg-cyber-accent/10' : 'text-gray-300 hover:text-cyber-accent hover:bg-cyber-accent/10'
}`} }`}
> >
@ -161,13 +186,13 @@ const Header = () => {
</Link> </Link>
</nav> </nav>
</div> </div>
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Badge <Badge
variant={isNetworkConnected ? "default" : "destructive"} variant={isNetworkConnected ? 'default' : 'destructive'}
className="flex items-center gap-1 text-xs px-2 h-7 cursor-help" className="flex items-center gap-1 text-xs px-2 h-7 cursor-help"
> >
{isNetworkConnected ? ( {isNetworkConnected ? (
<> <>
@ -183,29 +208,33 @@ const Header = () => {
</Badge> </Badge>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="text-sm"> <TooltipContent className="text-sm">
<p>{isNetworkConnected ? "Waku network connection active." : "Waku network connection lost."}</p> <p>
{isNetworkConnected
? 'Waku network connection active.'
: 'Waku network connection lost.'}
</p>
{isRefreshing && <p>Refreshing data...</p>} {isRefreshing && <p>Refreshing data...</p>}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{!isConnected ? ( {!isConnected ? (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleConnect} onClick={handleConnect}
className="text-xs px-2 h-7" className="text-xs px-2 h-7"
> >
Connect Wallet Connect Wallet
</Button> </Button>
) : ( ) : (
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant={getAccountStatusVariant()} variant={getAccountStatusVariant()}
size="sm" size="sm"
onClick={() => setWalletWizardOpen(true)} onClick={() => setWalletWizardOpen(true)}
className="flex items-center gap-1 text-xs px-2 h-7" className="flex items-center gap-1 text-xs px-2 h-7"
> >
{getAccountStatusIcon()} {getAccountStatusIcon()}
<span>{getAccountStatusText()}</span> <span>{getAccountStatusText()}</span>
@ -213,45 +242,55 @@ const Header = () => {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-[260px] text-sm"> <TooltipContent className="max-w-[260px] text-sm">
<p className="font-semibold mb-1">Account Setup</p> <p className="font-semibold mb-1">Account Setup</p>
<p>Click to view and manage your wallet connection, verification status, and key delegation.</p> <p>
Click to view and manage your wallet connection,
verification status, and key delegation.
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<span className="hidden md:flex items-center text-xs text-muted-foreground cursor-default px-2 h-7"> <span className="hidden md:flex items-center text-xs text-muted-foreground cursor-default px-2 h-7">
{currentUser?.ensName || `${address?.slice(0, 5)}...${address?.slice(-4)}`} {currentUser?.ensDetails?.ensName ||
`${address?.slice(0, 5)}...${address?.slice(-4)}`}
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="text-sm"> <TooltipContent className="text-sm">
<p>{currentUser?.ensName ? `${currentUser.ensName} (${address})` : address}</p> <p>
{currentUser?.ensDetails?.ensName
? `${currentUser.ensDetails.ensName} (${address})`
: address}
</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={handleDisconnect} onClick={handleDisconnect}
className="w-7 h-7" className="w-7 h-7"
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="text-sm">Disconnect Wallet</TooltipContent> <TooltipContent className="text-sm">
Disconnect Wallet
</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
)} )}
</div> </div>
</div> </div>
</header> </header>
<WalletWizard <WalletWizard
open={walletWizardOpen} open={walletWizardOpen}
onOpenChange={setWalletWizardOpen} onOpenChange={setWalletWizardOpen}
onComplete={() => { onComplete={() => {
toast({ toast({
title: "Setup Complete", title: 'Setup Complete',
description: "You can now use all OpChan features!", description: 'You can now use all OpChan features!',
}); });
}} }}
/> />

View File

@ -2,7 +2,7 @@ import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ArrowUp, ArrowDown, MessageSquare } from 'lucide-react'; import { ArrowUp, ArrowDown, MessageSquare } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { Post } from '@/types'; import { Post } from '@/types/forum';
import { useForum } from '@/contexts/useForum'; import { useForum } from '@/contexts/useForum';
import { useAuth } from '@/contexts/useAuth'; import { useAuth } from '@/contexts/useAuth';
import { RelevanceIndicator } from '@/components/ui/relevance-indicator'; import { RelevanceIndicator } from '@/components/ui/relevance-indicator';
@ -14,24 +14,30 @@ interface PostCardProps {
} }
const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => { const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
const { getCellById, votePost, isVoting, userVerificationStatus } = useForum(); const { getCellById, votePost, isVoting, userVerificationStatus } =
useForum();
const { isAuthenticated, currentUser } = useAuth(); const { isAuthenticated, currentUser } = useAuth();
const cell = getCellById(post.cellId); const cell = getCellById(post.cellId);
const cellName = cell?.name || 'unknown'; const cellName = cell?.name || 'unknown';
// Calculate vote score // Calculate vote score
const score = post.upvotes.length - post.downvotes.length; const score = post.upvotes.length - post.downvotes.length;
// Check user's vote status // Check user's vote status
const userUpvoted = currentUser ? post.upvotes.some(vote => vote.author === currentUser.address) : false; const userUpvoted = currentUser
const userDownvoted = currentUser ? post.downvotes.some(vote => vote.author === currentUser.address) : false; ? post.upvotes.some(vote => vote.author === currentUser.address)
: false;
const userDownvoted = currentUser
? post.downvotes.some(vote => vote.author === currentUser.address)
: false;
// Truncate content for preview // Truncate content for preview
const contentPreview = post.content.length > 200 const contentPreview =
? post.content.substring(0, 200) + '...' post.content.length > 200
: post.content; ? post.content.substring(0, 200) + '...'
: post.content;
const handleVote = async (e: React.MouseEvent, isUpvote: boolean) => { const handleVote = async (e: React.MouseEvent, isUpvote: boolean) => {
e.preventDefault(); // Prevent navigation when clicking vote buttons e.preventDefault(); // Prevent navigation when clicking vote buttons
if (!isAuthenticated) return; if (!isAuthenticated) return;
@ -43,32 +49,40 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
<div className="flex"> <div className="flex">
{/* Voting column */} {/* Voting column */}
<div className="flex flex-col items-center p-2 bg-cyber-muted/50 border-r border-cyber-muted"> <div className="flex flex-col items-center p-2 bg-cyber-muted/50 border-r border-cyber-muted">
<button <button
className={`p-1 rounded hover:bg-cyber-muted transition-colors ${ className={`p-1 rounded hover:bg-cyber-muted transition-colors ${
userUpvoted ? 'text-cyber-accent' : 'text-cyber-neutral hover:text-cyber-accent' userUpvoted
? 'text-cyber-accent'
: 'text-cyber-neutral hover:text-cyber-accent'
}`} }`}
onClick={(e) => handleVote(e, true)} onClick={e => handleVote(e, true)}
disabled={!isAuthenticated || isVoting} disabled={!isAuthenticated || isVoting}
title={isAuthenticated ? "Upvote" : "Connect wallet to vote"} title={isAuthenticated ? 'Upvote' : 'Connect wallet to vote'}
> >
<ArrowUp className="w-5 h-5" /> <ArrowUp className="w-5 h-5" />
</button> </button>
<span className={`text-sm font-medium px-1 ${ <span
score > 0 ? 'text-cyber-accent' : className={`text-sm font-medium px-1 ${
score < 0 ? 'text-blue-400' : score > 0
'text-cyber-neutral' ? 'text-cyber-accent'
}`}> : score < 0
? 'text-blue-400'
: 'text-cyber-neutral'
}`}
>
{score} {score}
</span> </span>
<button <button
className={`p-1 rounded hover:bg-cyber-muted transition-colors ${ className={`p-1 rounded hover:bg-cyber-muted transition-colors ${
userDownvoted ? 'text-blue-400' : 'text-cyber-neutral hover:text-blue-400' userDownvoted
? 'text-blue-400'
: 'text-cyber-neutral hover:text-blue-400'
}`} }`}
onClick={(e) => handleVote(e, false)} onClick={e => handleVote(e, false)}
disabled={!isAuthenticated || isVoting} disabled={!isAuthenticated || isVoting}
title={isAuthenticated ? "Downvote" : "Connect wallet to vote"} title={isAuthenticated ? 'Downvote' : 'Connect wallet to vote'}
> >
<ArrowDown className="w-5 h-5" /> <ArrowDown className="w-5 h-5" />
</button> </button>
@ -79,22 +93,28 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
<Link to={`/post/${post.id}`} className="block hover:opacity-80"> <Link to={`/post/${post.id}`} className="block hover:opacity-80">
{/* Post metadata */} {/* Post metadata */}
<div className="flex items-center text-xs text-cyber-neutral mb-2 space-x-2"> <div className="flex items-center text-xs text-cyber-neutral mb-2 space-x-2">
<span className="font-medium text-cyber-accent">r/{cellName}</span> <span className="font-medium text-cyber-accent">
r/{cellName}
</span>
<span></span> <span></span>
<span>Posted by u/</span> <span>Posted by u/</span>
<AuthorDisplay <AuthorDisplay
address={post.authorAddress} address={post.authorAddress}
userVerificationStatus={userVerificationStatus} userVerificationStatus={userVerificationStatus}
className="text-xs" className="text-xs"
showBadge={false} showBadge={false}
/> />
<span></span> <span></span>
<span>{formatDistanceToNow(new Date(post.timestamp), { addSuffix: true })}</span> <span>
{formatDistanceToNow(new Date(post.timestamp), {
addSuffix: true,
})}
</span>
{post.relevanceScore !== undefined && ( {post.relevanceScore !== undefined && (
<> <>
<span></span> <span></span>
<RelevanceIndicator <RelevanceIndicator
score={post.relevanceScore} score={post.relevanceScore}
details={post.relevanceDetails} details={post.relevanceDetails}
type="post" type="post"
className="text-xs" className="text-xs"
@ -134,4 +154,4 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
); );
}; };
export default PostCard; export default PostCard;

View File

@ -4,7 +4,16 @@ import { useForum } from '@/contexts/useForum';
import { useAuth } from '@/contexts/useAuth'; import { useAuth } from '@/contexts/useAuth';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { ArrowLeft, ArrowUp, ArrowDown, Clock, MessageCircle, Send, Eye, Loader2 } from 'lucide-react'; import {
ArrowLeft,
ArrowUp,
ArrowDown,
Clock,
MessageCircle,
Send,
Eye,
Loader2,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { Comment } from '@/types/forum'; import { Comment } from '@/types/forum';
import { CypherImage } from './ui/CypherImage'; import { CypherImage } from './ui/CypherImage';
@ -14,144 +23,181 @@ import { AuthorDisplay } from './ui/author-display';
const PostDetail = () => { const PostDetail = () => {
const { postId } = useParams<{ postId: string }>(); const { postId } = useParams<{ postId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { const {
posts, posts,
getCommentsByPost, getCommentsByPost,
createComment, createComment,
votePost, votePost,
voteComment, voteComment,
getCellById, getCellById,
isInitialLoading, isInitialLoading,
isPostingComment, isPostingComment,
isVoting, isVoting,
moderateComment, moderateComment,
moderateUser, moderateUser,
userVerificationStatus userVerificationStatus,
} = useForum(); } = useForum();
const { currentUser, verificationStatus } = useAuth(); const { currentUser, verificationStatus } = useAuth();
const [newComment, setNewComment] = useState(''); const [newComment, setNewComment] = useState('');
if (!postId) return <div>Invalid post ID</div>; if (!postId) return <div>Invalid post ID</div>;
if (isInitialLoading) { if (isInitialLoading) {
return ( return (
<div className="container mx-auto px-4 py-16 text-center"> <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" /> <Loader2 className="w-8 h-8 mx-auto mb-4 animate-spin text-primary" />
<p className="text-lg font-medium text-muted-foreground">Loading Post...</p> <p className="text-lg font-medium text-muted-foreground">
Loading Post...
</p>
</div> </div>
); );
} }
const post = posts.find(p => p.id === postId); const post = posts.find(p => p.id === postId);
if (!post) { if (!post) {
return ( return (
<div className="container mx-auto px-4 py-6 text-center"> <div className="container mx-auto px-4 py-6 text-center">
<h2 className="text-xl font-bold mb-4">Post not found</h2> <h2 className="text-xl font-bold mb-4">Post not found</h2>
<p className="mb-4">The post you're looking for doesn't exist or has been removed.</p> <p className="mb-4">
The post you're looking for doesn't exist or has been removed.
</p>
<Button asChild> <Button asChild>
<Link to="/">Go back home</Link> <Link to="/">Go back home</Link>
</Button> </Button>
</div> </div>
); );
} }
const cell = getCellById(post.cellId); const cell = getCellById(post.cellId);
const postComments = getCommentsByPost(post.id); const postComments = getCommentsByPost(post.id);
const isCellAdmin = currentUser && cell && currentUser.address === cell.signature; const isCellAdmin =
currentUser && cell && currentUser.address === cell.signature;
const visibleComments = isCellAdmin const visibleComments = isCellAdmin
? postComments ? postComments
: postComments.filter(comment => !comment.moderated); : postComments.filter(comment => !comment.moderated);
const handleCreateComment = async (e: React.FormEvent) => { const handleCreateComment = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newComment.trim()) return; if (!newComment.trim()) return;
try { try {
const result = await createComment(postId, newComment); const result = await createComment(postId, newComment);
if (result) { if (result) {
setNewComment(''); setNewComment('');
} }
} catch (error) { } catch (error) {
console.error("Error creating comment:", error); console.error('Error creating comment:', error);
} }
}; };
const handleVotePost = async (isUpvote: boolean) => { const handleVotePost = async (isUpvote: boolean) => {
if (verificationStatus !== 'verified-owner' && verificationStatus !== 'verified-basic' && !currentUser?.ensOwnership && !currentUser?.ordinalOwnership) return; if (
verificationStatus !== 'verified-owner' &&
verificationStatus !== 'verified-basic' &&
!currentUser?.ensDetails &&
!currentUser?.ordinalDetails
)
return;
await votePost(post.id, isUpvote); await votePost(post.id, isUpvote);
}; };
const handleVoteComment = async (commentId: string, isUpvote: boolean) => { const handleVoteComment = async (commentId: string, isUpvote: boolean) => {
if (verificationStatus !== 'verified-owner' && verificationStatus !== 'verified-basic' && !currentUser?.ensOwnership && !currentUser?.ordinalOwnership) return; if (
verificationStatus !== 'verified-owner' &&
verificationStatus !== 'verified-basic' &&
!currentUser?.ensDetails &&
!currentUser?.ordinalDetails
)
return;
await voteComment(commentId, isUpvote); await voteComment(commentId, isUpvote);
}; };
const isPostUpvoted = currentUser && post.upvotes.some(vote => vote.author === currentUser.address); const isPostUpvoted =
const isPostDownvoted = currentUser && post.downvotes.some(vote => vote.author === currentUser.address); currentUser &&
post.upvotes.some(vote => vote.author === currentUser.address);
const isPostDownvoted =
currentUser &&
post.downvotes.some(vote => vote.author === currentUser.address);
const isCommentVoted = (comment: Comment, isUpvote: boolean) => { const isCommentVoted = (comment: Comment, isUpvote: boolean) => {
if (!currentUser) return false; if (!currentUser) return false;
const votes = isUpvote ? comment.upvotes : comment.downvotes; const votes = isUpvote ? comment.upvotes : comment.downvotes;
return votes.some(vote => vote.author === currentUser.address); return votes.some(vote => vote.author === currentUser.address);
}; };
const getIdentityImageUrl = (address: string) => { const getIdentityImageUrl = (address: string) => {
return `https://api.dicebear.com/7.x/identicon/svg?seed=${address}`; return `https://api.dicebear.com/7.x/identicon/svg?seed=${address}`;
}; };
const handleModerateComment = async (commentId: string) => { const handleModerateComment = async (commentId: string) => {
const reason = window.prompt('Enter a reason for moderation (optional):') || undefined; const reason =
window.prompt('Enter a reason for moderation (optional):') || undefined;
if (!cell) return; if (!cell) return;
await moderateComment(cell.id, commentId, reason, cell.signature); await moderateComment(cell.id, commentId, reason, cell.signature);
}; };
const handleModerateUser = async (userAddress: string) => { const handleModerateUser = async (userAddress: string) => {
if (!cell) return; if (!cell) return;
const reason = window.prompt('Reason for moderating this user? (optional)') || undefined; const reason =
window.prompt('Reason for moderating this user? (optional)') || undefined;
await moderateUser(cell.id, userAddress, reason, cell.signature); await moderateUser(cell.id, userAddress, reason, cell.signature);
}; };
return ( return (
<div className="container mx-auto px-4 py-6"> <div className="container mx-auto px-4 py-6">
<div className="mb-6"> <div className="mb-6">
<Button <Button
onClick={() => navigate(`/cell/${post.cellId}`)} onClick={() => navigate(`/cell/${post.cellId}`)}
variant="ghost" variant="ghost"
size="sm" size="sm"
className="mb-4" className="mb-4"
> >
<ArrowLeft className="w-4 h-4 mr-1" /> <ArrowLeft className="w-4 h-4 mr-1" />
Back to /{cell?.name || 'cell'}/ Back to /{cell?.name || 'cell'}/
</Button> </Button>
<div className="border border-muted rounded-sm p-3 mb-6"> <div className="border border-muted rounded-sm p-3 mb-6">
<div className="flex gap-3 items-start"> <div className="flex gap-3 items-start">
<div className="flex flex-col items-center w-6 pt-1"> <div className="flex flex-col items-center w-6 pt-1">
<button <button
className={`p-1 rounded-sm hover:bg-secondary/50 disabled:opacity-50 ${isPostUpvoted ? 'text-primary' : ''}`} className={`p-1 rounded-sm hover:bg-secondary/50 disabled:opacity-50 ${isPostUpvoted ? 'text-primary' : ''}`}
onClick={() => handleVotePost(true)} onClick={() => handleVotePost(true)}
disabled={verificationStatus !== 'verified-owner' || isVoting} disabled={verificationStatus !== 'verified-owner' || isVoting}
title={verificationStatus === 'verified-owner' ? "Upvote" : "Full access required to vote"} title={
verificationStatus === 'verified-owner'
? 'Upvote'
: 'Full access required to vote'
}
> >
<ArrowUp className="w-5 h-5" /> <ArrowUp className="w-5 h-5" />
</button> </button>
<span className="text-sm font-medium py-1">{post.upvotes.length - post.downvotes.length}</span> <span className="text-sm font-medium py-1">
<button {post.upvotes.length - post.downvotes.length}
</span>
<button
className={`p-1 rounded-sm hover:bg-secondary/50 disabled:opacity-50 ${isPostDownvoted ? 'text-primary' : ''}`} className={`p-1 rounded-sm hover:bg-secondary/50 disabled:opacity-50 ${isPostDownvoted ? 'text-primary' : ''}`}
onClick={() => handleVotePost(false)} onClick={() => handleVotePost(false)}
disabled={verificationStatus !== 'verified-owner' || isVoting} disabled={verificationStatus !== 'verified-owner' || isVoting}
title={verificationStatus === 'verified-owner' ? "Downvote" : "Full access required to vote"} title={
verificationStatus === 'verified-owner'
? 'Downvote'
: 'Full access required to vote'
}
> >
<ArrowDown className="w-5 h-5" /> <ArrowDown className="w-5 h-5" />
</button> </button>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h2 className="text-xl font-bold mb-2 text-foreground">{post.title}</h2> <h2 className="text-xl font-bold mb-2 text-foreground">
<p className="text-base mb-4 text-foreground/90">{post.content}</p> {post.title}
</h2>
<p className="text-base mb-4 text-foreground/90">
{post.content}
</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground"> <div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center"> <span className="flex items-center">
<Clock className="w-3 h-3 mr-1" /> <Clock className="w-3 h-3 mr-1" />
@ -159,16 +205,17 @@ const PostDetail = () => {
</span> </span>
<span className="flex items-center"> <span className="flex items-center">
<MessageCircle className="w-3 h-3 mr-1" /> <MessageCircle className="w-3 h-3 mr-1" />
{postComments.length} {postComments.length === 1 ? 'comment' : 'comments'} {postComments.length}{' '}
{postComments.length === 1 ? 'comment' : 'comments'}
</span> </span>
<AuthorDisplay <AuthorDisplay
address={post.authorAddress} address={post.authorAddress}
userVerificationStatus={userVerificationStatus} userVerificationStatus={userVerificationStatus}
className="truncate max-w-[150px]" className="truncate max-w-[150px]"
/> />
{post.relevanceScore !== undefined && ( {post.relevanceScore !== undefined && (
<RelevanceIndicator <RelevanceIndicator
score={post.relevanceScore} score={post.relevanceScore}
details={post.relevanceDetails} details={post.relevanceDetails}
type="post" type="post"
className="text-xs" className="text-xs"
@ -180,20 +227,23 @@ const PostDetail = () => {
</div> </div>
</div> </div>
</div> </div>
{(verificationStatus === 'verified-owner' || verificationStatus === 'verified-basic' || currentUser?.ensOwnership || currentUser?.ordinalOwnership) ? ( {verificationStatus === 'verified-owner' ||
verificationStatus === 'verified-basic' ||
currentUser?.ensDetails ||
currentUser?.ordinalDetails ? (
<div className="mb-8"> <div className="mb-8">
<form onSubmit={handleCreateComment}> <form onSubmit={handleCreateComment}>
<div className="flex gap-2"> <div className="flex gap-2">
<Textarea <Textarea
placeholder="Add a comment..." placeholder="Add a comment..."
value={newComment} value={newComment}
onChange={(e) => setNewComment(e.target.value)} onChange={e => setNewComment(e.target.value)}
className="flex-1 bg-secondary/40 border-muted resize-none rounded-sm text-sm p-2" className="flex-1 bg-secondary/40 border-muted resize-none rounded-sm text-sm p-2"
disabled={isPostingComment} disabled={isPostingComment}
/> />
<Button <Button
type="submit" type="submit"
disabled={isPostingComment || !newComment.trim()} disabled={isPostingComment || !newComment.trim()}
size="icon" size="icon"
> >
@ -209,19 +259,21 @@ const PostDetail = () => {
<h3 className="font-medium">Read-Only Mode</h3> <h3 className="font-medium">Read-Only Mode</h3>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Your wallet has been verified but does not contain any Ordinal Operators. Your wallet has been verified but does not contain any Ordinal
You can browse threads but cannot comment or vote. Operators. You can browse threads but cannot comment or vote.
</p> </p>
</div> </div>
) : ( ) : (
<div className="mb-8 p-3 border border-muted rounded-sm bg-secondary/30 text-center"> <div className="mb-8 p-3 border border-muted rounded-sm bg-secondary/30 text-center">
<p className="text-sm mb-2">Connect wallet and verify ownership to comment</p> <p className="text-sm mb-2">
Connect wallet and verify ownership to comment
</p>
<Button asChild size="sm"> <Button asChild size="sm">
<Link to="/">Go to Home</Link> <Link to="/">Go to Home</Link>
</Button> </Button>
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
{postComments.length === 0 ? ( {postComments.length === 0 ? (
<div className="text-center py-6 text-muted-foreground"> <div className="text-center py-6 text-muted-foreground">
@ -229,23 +281,41 @@ const PostDetail = () => {
</div> </div>
) : ( ) : (
visibleComments.map(comment => ( visibleComments.map(comment => (
<div key={comment.id} className="comment-card" id={`comment-${comment.id}`}> <div
key={comment.id}
className="comment-card"
id={`comment-${comment.id}`}
>
<div className="flex gap-2 items-start"> <div className="flex gap-2 items-start">
<div className="flex flex-col items-center w-5 pt-0.5"> <div className="flex flex-col items-center w-5 pt-0.5">
<button <button
className={`p-0.5 rounded-sm hover:bg-secondary/50 disabled:opacity-50 ${isCommentVoted(comment, true) ? 'text-primary' : ''}`} className={`p-0.5 rounded-sm hover:bg-secondary/50 disabled:opacity-50 ${isCommentVoted(comment, true) ? 'text-primary' : ''}`}
onClick={() => handleVoteComment(comment.id, true)} onClick={() => handleVoteComment(comment.id, true)}
disabled={verificationStatus !== 'verified-owner' || isVoting} disabled={
title={verificationStatus === 'verified-owner' ? "Upvote" : "Full access required to vote"} verificationStatus !== 'verified-owner' || isVoting
}
title={
verificationStatus === 'verified-owner'
? 'Upvote'
: 'Full access required to vote'
}
> >
<ArrowUp className="w-4 h-4" /> <ArrowUp className="w-4 h-4" />
</button> </button>
<span className="text-xs font-medium py-0.5">{comment.upvotes.length - comment.downvotes.length}</span> <span className="text-xs font-medium py-0.5">
<button {comment.upvotes.length - comment.downvotes.length}
</span>
<button
className={`p-0.5 rounded-sm hover:bg-secondary/50 disabled:opacity-50 ${isCommentVoted(comment, false) ? 'text-primary' : ''}`} className={`p-0.5 rounded-sm hover:bg-secondary/50 disabled:opacity-50 ${isCommentVoted(comment, false) ? 'text-primary' : ''}`}
onClick={() => handleVoteComment(comment.id, false)} onClick={() => handleVoteComment(comment.id, false)}
disabled={verificationStatus !== 'verified-owner' || isVoting} disabled={
title={verificationStatus === 'verified-owner' ? "Downvote" : "Full access required to vote"} verificationStatus !== 'verified-owner' || isVoting
}
title={
verificationStatus === 'verified-owner'
? 'Downvote'
: 'Full access required to vote'
}
> >
<ArrowDown className="w-4 h-4" /> <ArrowDown className="w-4 h-4" />
</button> </button>
@ -253,12 +323,12 @@ const PostDetail = () => {
<div className="flex-1 pt-0.5"> <div className="flex-1 pt-0.5">
<div className="flex justify-between items-center mb-1.5"> <div className="flex justify-between items-center mb-1.5">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<CypherImage <CypherImage
src={getIdentityImageUrl(comment.authorAddress)} src={getIdentityImageUrl(comment.authorAddress)}
alt={comment.authorAddress.slice(0, 6)} alt={comment.authorAddress.slice(0, 6)}
className="rounded-sm w-5 h-5 bg-secondary" className="rounded-sm w-5 h-5 bg-secondary"
/> />
<AuthorDisplay <AuthorDisplay
address={comment.authorAddress} address={comment.authorAddress}
userVerificationStatus={userVerificationStatus} userVerificationStatus={userVerificationStatus}
className="text-xs" className="text-xs"
@ -266,8 +336,8 @@ const PostDetail = () => {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{comment.relevanceScore !== undefined && ( {comment.relevanceScore !== undefined && (
<RelevanceIndicator <RelevanceIndicator
score={comment.relevanceScore} score={comment.relevanceScore}
details={comment.relevanceDetails} details={comment.relevanceDetails}
type="comment" type="comment"
className="text-xs" className="text-xs"
@ -275,23 +345,37 @@ const PostDetail = () => {
/> />
)} )}
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDistanceToNow(comment.timestamp, { addSuffix: true })} {formatDistanceToNow(comment.timestamp, {
addSuffix: true,
})}
</span> </span>
</div> </div>
</div> </div>
<p className="text-sm break-words">{comment.content}</p> <p className="text-sm break-words">{comment.content}</p>
{isCellAdmin && !comment.moderated && ( {isCellAdmin && !comment.moderated && (
<Button size="sm" variant="destructive" className="ml-2" onClick={() => handleModerateComment(comment.id)}> <Button
size="sm"
variant="destructive"
className="ml-2"
onClick={() => handleModerateComment(comment.id)}
>
Moderate Moderate
</Button> </Button>
)} )}
{isCellAdmin && comment.authorAddress !== cell.signature && ( {isCellAdmin && comment.authorAddress !== cell.signature && (
<Button size="sm" variant="destructive" className="ml-2" onClick={() => handleModerateUser(comment.authorAddress)}> <Button
size="sm"
variant="destructive"
className="ml-2"
onClick={() => handleModerateUser(comment.authorAddress)}
>
Moderate User Moderate User
</Button> </Button>
)} )}
{comment.moderated && ( {comment.moderated && (
<span className="ml-2 text-xs text-red-500">[Moderated]</span> <span className="ml-2 text-xs text-red-500">
[Moderated]
</span>
)} )}
</div> </div>
</div> </div>

View File

@ -6,7 +6,15 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { ArrowLeft, MessageSquare, MessageCircle, ArrowUp, ArrowDown, RefreshCw, Eye } from 'lucide-react'; import {
ArrowLeft,
MessageSquare,
MessageCircle,
ArrowUp,
ArrowDown,
RefreshCw,
Eye,
} from 'lucide-react';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { CypherImage } from './ui/CypherImage'; import { CypherImage } from './ui/CypherImage';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -14,37 +22,40 @@ import { AuthorDisplay } from './ui/author-display';
const PostList = () => { const PostList = () => {
const { cellId } = useParams<{ cellId: string }>(); const { cellId } = useParams<{ cellId: string }>();
const { const {
getCellById, getCellById,
getPostsByCell, getPostsByCell,
createPost, createPost,
isInitialLoading, isInitialLoading,
isPostingPost, isPostingPost,
isRefreshing, isRefreshing,
refreshData, refreshData,
votePost, votePost,
isVoting, isVoting,
posts, posts,
moderatePost, moderatePost,
moderateUser, moderateUser,
userVerificationStatus userVerificationStatus,
} = useForum(); } = useForum();
const { isAuthenticated, currentUser, verificationStatus } = useAuth(); const { isAuthenticated, currentUser, verificationStatus } = useAuth();
const [newPostTitle, setNewPostTitle] = useState(''); const [newPostTitle, setNewPostTitle] = useState('');
const [newPostContent, setNewPostContent] = useState(''); const [newPostContent, setNewPostContent] = useState('');
if (!cellId || isInitialLoading) { if (!cellId || isInitialLoading) {
return ( return (
<div className="container mx-auto px-4 py-8 max-w-4xl"> <div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-6"> <div className="mb-6">
<Link to="/" className="text-cyber-accent hover:underline flex items-center gap-1 text-sm"> <Link
to="/"
className="text-cyber-accent hover:underline flex items-center gap-1 text-sm"
>
<ArrowLeft className="w-4 h-4" /> Back to Cells <ArrowLeft className="w-4 h-4" /> Back to Cells
</Link> </Link>
</div> </div>
<Skeleton className="h-8 w-32 mb-6 bg-cyber-muted" /> <Skeleton className="h-8 w-32 mb-6 bg-cyber-muted" />
<Skeleton className="h-6 w-64 mb-6 bg-cyber-muted" /> <Skeleton className="h-6 w-64 mb-6 bg-cyber-muted" />
<div className="space-y-4"> <div className="space-y-4">
{[...Array(3)].map((_, i) => ( {[...Array(3)].map((_, i) => (
<div key={i} className="border border-cyber-muted rounded-sm p-4"> <div key={i} className="border border-cyber-muted rounded-sm p-4">
@ -59,21 +70,26 @@ const PostList = () => {
</div> </div>
); );
} }
const cell = getCellById(cellId); const cell = getCellById(cellId);
const cellPosts = getPostsByCell(cellId); const cellPosts = getPostsByCell(cellId);
if (!cell) { if (!cell) {
return ( return (
<div className="container mx-auto px-4 py-8 max-w-4xl"> <div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-6"> <div className="mb-6">
<Link to="/" className="text-cyber-accent hover:underline flex items-center gap-1 text-sm"> <Link
to="/"
className="text-cyber-accent hover:underline flex items-center gap-1 text-sm"
>
<ArrowLeft className="w-4 h-4" /> Back to Cells <ArrowLeft className="w-4 h-4" /> Back to Cells
</Link> </Link>
</div> </div>
<div className="p-8 text-center"> <div className="p-8 text-center">
<h1 className="text-2xl font-bold mb-4">Cell Not Found</h1> <h1 className="text-2xl font-bold mb-4">Cell Not Found</h1>
<p className="text-cyber-neutral mb-6">The cell you're looking for doesn't exist.</p> <p className="text-cyber-neutral mb-6">
The cell you're looking for doesn't exist.
</p>
<Button asChild> <Button asChild>
<Link to="/">Return to Cells</Link> <Link to="/">Return to Cells</Link>
</Button> </Button>
@ -81,12 +97,12 @@ const PostList = () => {
</div> </div>
); );
} }
const handleCreatePost = async (e: React.FormEvent) => { const handleCreatePost = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newPostContent.trim()) return; if (!newPostContent.trim()) return;
try { try {
const post = await createPost(cellId, newPostTitle, newPostContent); const post = await createPost(cellId, newPostTitle, newPostContent);
if (post) { if (post) {
@ -94,15 +110,15 @@ const PostList = () => {
setNewPostContent(''); setNewPostContent('');
} }
} catch (error) { } catch (error) {
console.error("Error creating post:", error); console.error('Error creating post:', error);
} }
}; };
const handleVotePost = async (postId: string, isUpvote: boolean) => { const handleVotePost = async (postId: string, isUpvote: boolean) => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
await votePost(postId, isUpvote); await votePost(postId, isUpvote);
}; };
const isPostVoted = (postId: string, isUpvote: boolean) => { const isPostVoted = (postId: string, isUpvote: boolean) => {
if (!currentUser) return false; if (!currentUser) return false;
const post = posts.find(p => p.id === postId); const post = posts.find(p => p.id === postId);
@ -110,57 +126,65 @@ const PostList = () => {
const votes = isUpvote ? post.upvotes : post.downvotes; const votes = isUpvote ? post.upvotes : post.downvotes;
return votes.some(vote => vote.author === currentUser.address); return votes.some(vote => vote.author === currentUser.address);
}; };
// Only show unmoderated posts, or all if admin // Only show unmoderated posts, or all if admin
const isCellAdmin = currentUser && cell && currentUser.address === cell.signature; const isCellAdmin =
currentUser && cell && currentUser.address === cell.signature;
const visiblePosts = isCellAdmin const visiblePosts = isCellAdmin
? cellPosts ? cellPosts
: cellPosts.filter(post => !post.moderated); : cellPosts.filter(post => !post.moderated);
const handleModerate = async (postId: string) => { const handleModerate = async (postId: string) => {
const reason = window.prompt('Enter a reason for moderation (optional):') || undefined; const reason =
window.prompt('Enter a reason for moderation (optional):') || undefined;
if (!cell) return; if (!cell) return;
await moderatePost(cell.id, postId, reason, cell.signature); await moderatePost(cell.id, postId, reason, cell.signature);
}; };
const handleModerateUser = async (userAddress: string) => { const handleModerateUser = async (userAddress: string) => {
const reason = window.prompt('Reason for moderating this user? (optional)') || undefined; const reason =
window.prompt('Reason for moderating this user? (optional)') || undefined;
if (!cell) return; if (!cell) return;
await moderateUser(cell.id, userAddress, reason, cell.signature); await moderateUser(cell.id, userAddress, reason, cell.signature);
}; };
return ( return (
<div className="container mx-auto px-4 py-8 max-w-4xl"> <div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-6"> <div className="mb-6">
<Link to="/" className="text-cyber-accent hover:underline flex items-center gap-1 text-sm"> <Link
to="/"
className="text-cyber-accent hover:underline flex items-center gap-1 text-sm"
>
<ArrowLeft className="w-4 h-4" /> Back to Cells <ArrowLeft className="w-4 h-4" /> Back to Cells
</Link> </Link>
</div> </div>
<div className="flex gap-4 items-start mb-6"> <div className="flex gap-4 items-start mb-6">
<CypherImage <CypherImage
src={cell.icon} src={cell.icon}
alt={cell.name} alt={cell.name}
className="w-12 h-12 object-cover rounded-sm border border-cyber-muted" className="w-12 h-12 object-cover rounded-sm border border-cyber-muted"
generateUniqueFallback={true} generateUniqueFallback={true}
/> />
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-glow">{cell.name}</h1> <h1 className="text-2xl font-bold text-glow">{cell.name}</h1>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={refreshData} onClick={refreshData}
disabled={isRefreshing} disabled={isRefreshing}
title="Refresh data" title="Refresh data"
> >
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} /> <RefreshCw
className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`}
/>
</Button> </Button>
</div> </div>
<p className="text-cyber-neutral">{cell.description}</p> <p className="text-cyber-neutral">{cell.description}</p>
</div> </div>
</div> </div>
{verificationStatus === 'verified-owner' && ( {verificationStatus === 'verified-owner' && (
<div className="mb-8"> <div className="mb-8">
<form onSubmit={handleCreatePost}> <form onSubmit={handleCreatePost}>
@ -172,22 +196,26 @@ const PostList = () => {
<Input <Input
placeholder="Thread title" placeholder="Thread title"
value={newPostTitle} value={newPostTitle}
onChange={(e) => setNewPostTitle(e.target.value)} onChange={e => setNewPostTitle(e.target.value)}
className="mb-3 bg-cyber-muted/50 border-cyber-muted" className="mb-3 bg-cyber-muted/50 border-cyber-muted"
disabled={isPostingPost} disabled={isPostingPost}
/> />
<Textarea <Textarea
placeholder="What's on your mind?" placeholder="What's on your mind?"
value={newPostContent} value={newPostContent}
onChange={(e) => setNewPostContent(e.target.value)} onChange={e => setNewPostContent(e.target.value)}
className="bg-cyber-muted/50 border-cyber-muted resize-none" className="bg-cyber-muted/50 border-cyber-muted resize-none"
disabled={isPostingPost} disabled={isPostingPost}
/> />
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
type="submit" type="submit"
disabled={isPostingPost || !newPostContent.trim() || !newPostTitle.trim()} disabled={
isPostingPost ||
!newPostContent.trim() ||
!newPostTitle.trim()
}
> >
{isPostingPost ? 'Posting...' : 'Post Thread'} {isPostingPost ? 'Posting...' : 'Post Thread'}
</Button> </Button>
@ -195,7 +223,7 @@ const PostList = () => {
</form> </form>
</div> </div>
)} )}
{verificationStatus === 'verified-none' && ( {verificationStatus === 'verified-none' && (
<div className="mb-8 p-4 border border-cyber-muted rounded-sm bg-cyber-muted/20"> <div className="mb-8 p-4 border border-cyber-muted rounded-sm bg-cyber-muted/20">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
@ -203,83 +231,115 @@ const PostList = () => {
<h3 className="font-medium">Read-Only Mode</h3> <h3 className="font-medium">Read-Only Mode</h3>
</div> </div>
<p className="text-sm text-cyber-neutral mb-2"> <p className="text-sm text-cyber-neutral mb-2">
Your wallet does not contain any Ordinal Operators. You can browse threads but cannot post or interact. Your wallet does not contain any Ordinal Operators. You can browse
threads but cannot post or interact.
</p> </p>
<Badge variant="outline" className="text-xs">No Ordinals Found</Badge> <Badge variant="outline" className="text-xs">
No Ordinals Found
</Badge>
</div> </div>
)} )}
{!currentUser && ( {!currentUser && (
<div className="mb-8 p-4 border border-cyber-muted rounded-sm bg-cyber-muted/20 text-center"> <div className="mb-8 p-4 border border-cyber-muted rounded-sm bg-cyber-muted/20 text-center">
<p className="text-sm mb-3">Connect wallet and verify Ordinal ownership to post</p> <p className="text-sm mb-3">
Connect wallet and verify Ordinal ownership to post
</p>
<Button asChild size="sm"> <Button asChild size="sm">
<Link to="/">Connect Wallet</Link> <Link to="/">Connect Wallet</Link>
</Button> </Button>
</div> </div>
)} )}
<div className="space-y-4"> <div className="space-y-4">
{cellPosts.length === 0 ? ( {cellPosts.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<MessageCircle className="w-12 h-12 mx-auto mb-4 text-cyber-neutral opacity-50" /> <MessageCircle className="w-12 h-12 mx-auto mb-4 text-cyber-neutral opacity-50" />
<h2 className="text-xl font-bold mb-2">No Threads Yet</h2> <h2 className="text-xl font-bold mb-2">No Threads Yet</h2>
<p className="text-cyber-neutral"> <p className="text-cyber-neutral">
{isAuthenticated {isAuthenticated
? "Be the first to post in this cell!" ? 'Be the first to post in this cell!'
: "Connect your wallet and verify Ordinal ownership to start a thread."} : 'Connect your wallet and verify Ordinal ownership to start a thread.'}
</p> </p>
</div> </div>
) : ( ) : (
visiblePosts.map(post => ( visiblePosts.map(post => (
<div key={post.id} className="post-card p-4 border border-cyber-muted rounded-sm bg-cyber-muted/20 hover:bg-cyber-muted/30 transition duration-200"> <div
key={post.id}
className="post-card p-4 border border-cyber-muted rounded-sm bg-cyber-muted/20 hover:bg-cyber-muted/30 transition duration-200"
>
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<button <button
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${isPostVoted(post.id, true) ? 'text-cyber-accent' : ''}`} className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${isPostVoted(post.id, true) ? 'text-cyber-accent' : ''}`}
onClick={() => handleVotePost(post.id, true)} onClick={() => handleVotePost(post.id, true)}
disabled={!isAuthenticated || isVoting} disabled={!isAuthenticated || isVoting}
title={isAuthenticated ? "Upvote" : "Verify Ordinal to vote"} title={
isAuthenticated ? 'Upvote' : 'Verify Ordinal to vote'
}
> >
<ArrowUp className="w-4 h-4" /> <ArrowUp className="w-4 h-4" />
</button> </button>
<span className="text-sm py-1">{post.upvotes.length - post.downvotes.length}</span> <span className="text-sm py-1">
<button {post.upvotes.length - post.downvotes.length}
</span>
<button
className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${isPostVoted(post.id, false) ? 'text-cyber-accent' : ''}`} className={`p-1 rounded-sm hover:bg-cyber-muted/50 ${isPostVoted(post.id, false) ? 'text-cyber-accent' : ''}`}
onClick={() => handleVotePost(post.id, false)} onClick={() => handleVotePost(post.id, false)}
disabled={!isAuthenticated || isVoting} disabled={!isAuthenticated || isVoting}
title={isAuthenticated ? "Downvote" : "Verify Ordinal to vote"} title={
isAuthenticated ? 'Downvote' : 'Verify Ordinal to vote'
}
> >
<ArrowDown className="w-4 h-4" /> <ArrowDown className="w-4 h-4" />
</button> </button>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<Link to={`/post/${post.id}`} className="block"> <Link to={`/post/${post.id}`} className="block">
<h2 className="text-lg font-bold hover:text-cyber-accent">{post.title}</h2> <h2 className="text-lg font-bold hover:text-cyber-accent">
{post.title}
</h2>
<p className="line-clamp-2 text-sm mb-3">{post.content}</p> <p className="line-clamp-2 text-sm mb-3">{post.content}</p>
<div className="flex items-center gap-4 text-xs text-cyber-neutral"> <div className="flex items-center gap-4 text-xs text-cyber-neutral">
<span>{formatDistanceToNow(post.timestamp, { addSuffix: true })}</span> <span>
{formatDistanceToNow(post.timestamp, {
addSuffix: true,
})}
</span>
<span>by </span> <span>by </span>
<AuthorDisplay <AuthorDisplay
address={post.authorAddress} address={post.authorAddress}
userVerificationStatus={userVerificationStatus} userVerificationStatus={userVerificationStatus}
className="text-xs" className="text-xs"
showBadge={false} showBadge={false}
/> />
</div> </div>
</Link> </Link>
{isCellAdmin && !post.moderated && ( {isCellAdmin && !post.moderated && (
<Button size="sm" variant="destructive" className="ml-2" onClick={() => handleModerate(post.id)}> <Button
size="sm"
variant="destructive"
className="ml-2"
onClick={() => handleModerate(post.id)}
>
Moderate Moderate
</Button> </Button>
)} )}
{isCellAdmin && post.authorAddress !== cell.signature && ( {isCellAdmin && post.authorAddress !== cell.signature && (
<Button size="sm" variant="destructive" className="ml-2" onClick={() => handleModerateUser(post.authorAddress)}> <Button
size="sm"
variant="destructive"
className="ml-2"
onClick={() => handleModerateUser(post.authorAddress)}
>
Moderate User Moderate User
</Button> </Button>
)} )}
{post.moderated && ( {post.moderated && (
<span className="ml-2 text-xs text-red-500">[Moderated]</span> <span className="ml-2 text-xs text-red-500">
[Moderated]
</span>
)} )}
</div> </div>
</div> </div>

View File

@ -7,7 +7,10 @@ type CypherImageProps = {
className?: string; className?: string;
fallbackClassName?: string; fallbackClassName?: string;
generateUniqueFallback?: boolean; generateUniqueFallback?: boolean;
} & Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src' | 'alt' | 'className'>; } & Omit<
React.ImgHTMLAttributes<HTMLImageElement>,
'src' | 'alt' | 'className'
>;
/** /**
* CypherImage component that renders a cypherpunk-style fallback image * CypherImage component that renders a cypherpunk-style fallback image
@ -22,11 +25,14 @@ export function CypherImage({
...props ...props
}: CypherImageProps) { }: CypherImageProps) {
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
// Generate a seed based on the alt text or src to create consistent fallbacks for the same resource // Generate a seed based on the alt text or src to create consistent fallbacks for the same resource
const seed = generateUniqueFallback ? const seed = generateUniqueFallback
(alt || src || 'fallback').split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) : 0; ? (alt || src || 'fallback')
.split('')
.reduce((acc, char) => acc + char.charCodeAt(0), 0)
: 0;
// Handle image load error // Handle image load error
const handleError = () => { const handleError = () => {
setImageError(true); setImageError(true);
@ -38,9 +44,9 @@ export function CypherImage({
const gridSize = (seed % 8) + 8; // 8-16px const gridSize = (seed % 8) + 8; // 8-16px
const noiseIntensity = (seed % 30) + 5; // 5-35% const noiseIntensity = (seed % 30) + 5; // 5-35%
const scanlineOpacity = ((seed % 4) + 1) / 10; // 0.1-0.5 const scanlineOpacity = ((seed % 4) + 1) / 10; // 0.1-0.5
return ( return (
<div <div
className={cn( className={cn(
'flex items-center justify-center overflow-hidden relative', 'flex items-center justify-center overflow-hidden relative',
className, className,
@ -60,22 +66,22 @@ export function CypherImage({
rgba(${hue / 3}, ${hue}, ${hue}, 0.15) 75%) rgba(${hue / 3}, ${hue}, ${hue}, 0.15) 75%)
`, `,
backgroundSize: `${gridSize}px ${gridSize}px`, backgroundSize: `${gridSize}px ${gridSize}px`,
boxShadow: 'inset 0 0 30px rgba(0, 255, 170, 0.2)' boxShadow: 'inset 0 0 30px rgba(0, 255, 170, 0.2)',
}} }}
{...props} {...props}
> >
{/* Noise overlay */} {/* Noise overlay */}
<div <div
className="absolute inset-0 opacity-20" className="absolute inset-0 opacity-20"
style={{ style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='${noiseIntensity/100}'/%3E%3C/svg%3E")`, backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='${noiseIntensity / 100}'/%3E%3C/svg%3E")`,
mixBlendMode: 'overlay' mixBlendMode: 'overlay',
}} }}
/> />
{/* Scanlines */} {/* Scanlines */}
<div <div
className="absolute inset-0 pointer-events-none mix-blend-overlay" className="absolute inset-0 pointer-events-none mix-blend-overlay"
style={{ style={{
background: `repeating-linear-gradient( background: `repeating-linear-gradient(
to bottom, to bottom,
@ -83,38 +89,39 @@ export function CypherImage({
transparent 1px, transparent 1px,
rgba(0, 255, 170, ${scanlineOpacity}) 1px, rgba(0, 255, 170, ${scanlineOpacity}) 1px,
rgba(0, 255, 170, ${scanlineOpacity}) 2px rgba(0, 255, 170, ${scanlineOpacity}) 2px
)` )`,
}} }}
/> />
{/* CRT glow effect */} {/* CRT glow effect */}
<div className="absolute inset-0 opacity-10 bg-cyan-500 blur-xl"></div> <div className="absolute inset-0 opacity-10 bg-cyan-500 blur-xl"></div>
<div className="relative w-full h-full flex items-center justify-center"> <div className="relative w-full h-full flex items-center justify-center">
{/* Glitch effect lines */} {/* Glitch effect lines */}
<div <div
className="absolute w-full h-1 bg-gradient-to-r from-transparent via-cyan-400 to-transparent" className="absolute w-full h-1 bg-gradient-to-r from-transparent via-cyan-400 to-transparent"
style={{ style={{
top: `${(seed % 70) + 15}%`, top: `${(seed % 70) + 15}%`,
opacity: 0.4, opacity: 0.4,
boxShadow: '0 0 8px rgba(0, 255, 255, 0.8)', boxShadow: '0 0 8px rgba(0, 255, 255, 0.8)',
transform: `skewY(${(seed % 5) - 2.5}deg)` transform: `skewY(${(seed % 5) - 2.5}deg)`,
}} }}
/> />
{/* Main content container with glitch effect */} {/* Main content container with glitch effect */}
<div <div
className="relative flex items-center justify-center" className="relative flex items-center justify-center"
style={{ style={{
textShadow: '0 0 5px rgba(0, 255, 255, 0.8), 0 0 10px rgba(0, 255, 255, 0.5)' textShadow:
'0 0 5px rgba(0, 255, 255, 0.8), 0 0 10px rgba(0, 255, 255, 0.5)',
}} }}
> >
{/* Glitched text behind the main letter */} {/* Glitched text behind the main letter */}
<div <div
className="absolute font-mono opacity-70" className="absolute font-mono opacity-70"
style={{ style={{
color: `hsl(${hue}, 100%, 70%)`, color: `hsl(${hue}, 100%, 70%)`,
transform: `translate(${(seed % 5) - 2.5}px, ${(seed % 5) - 2.5}px)` transform: `translate(${(seed % 5) - 2.5}px, ${(seed % 5) - 2.5}px)`,
}} }}
> >
{Array.from({ length: 3 }, (_, i) => { {Array.from({ length: 3 }, (_, i) => {
@ -122,23 +129,23 @@ export function CypherImage({
return chars[(seed + i) % chars.length]; return chars[(seed + i) % chars.length];
}).join('')} }).join('')}
</div> </div>
{/* First letter of alt text in center */} {/* First letter of alt text in center */}
<div <div
className="relative font-bold text-2xl md:text-3xl cyberpunk-glow z-10" className="relative font-bold text-2xl md:text-3xl cyberpunk-glow z-10"
style={{ color: `hsl(${hue}, 100%, 80%)` }} style={{ color: `hsl(${hue}, 100%, 80%)` }}
> >
{alt.charAt(0).toUpperCase()} {alt.charAt(0).toUpperCase()}
</div> </div>
{/* Random characters that occasionally "glitch" in */} {/* Random characters that occasionally "glitch" in */}
<div <div
className="absolute font-mono text-xs text-cyan-400 opacity-80 z-0" className="absolute font-mono text-xs text-cyan-400 opacity-80 z-0"
style={{ style={{
bottom: '20%', bottom: '20%',
right: '20%', right: '20%',
transform: `rotate(${(seed % 20) - 10}deg)`, transform: `rotate(${(seed % 20) - 10}deg)`,
mixBlendMode: 'screen' mixBlendMode: 'screen',
}} }}
> >
{seed.toString(16).substring(0, 4)} {seed.toString(16).substring(0, 4)}
@ -158,4 +165,4 @@ export function CypherImage({
{...props} {...props}
/> />
); );
} }

View File

@ -1,10 +1,10 @@
import * as React from "react" import * as React from 'react';
import * as AccordionPrimitive from "@radix-ui/react-accordion" import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from "lucide-react" import { ChevronDown } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef< const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>, React.ElementRef<typeof AccordionPrimitive.Item>,
@ -12,11 +12,11 @@ const AccordionItem = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AccordionPrimitive.Item <AccordionPrimitive.Item
ref={ref} ref={ref}
className={cn("border-b", className)} className={cn('border-b', className)}
{...props} {...props}
/> />
)) ));
AccordionItem.displayName = "AccordionItem" AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef< const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>, React.ElementRef<typeof AccordionPrimitive.Trigger>,
@ -26,7 +26,7 @@ const AccordionTrigger = React.forwardRef<
<AccordionPrimitive.Trigger <AccordionPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180", 'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className className
)} )}
{...props} {...props}
@ -35,8 +35,8 @@ const AccordionTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" /> <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>
)) ));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef< const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>, React.ElementRef<typeof AccordionPrimitive.Content>,
@ -47,10 +47,10 @@ const AccordionContent = React.forwardRef<
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props} {...props}
> >
<div className={cn("pb-4 pt-0", className)}>{children}</div> <div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content> </AccordionPrimitive.Content>
)) ));
AccordionContent.displayName = AccordionPrimitive.Content.displayName AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -1,14 +1,14 @@
import * as React from "react" import * as React from 'react';
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from '@/components/ui/button-variants';
const AlertDialog = AlertDialogPrimitive.Root const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef< const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>, React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
@ -16,14 +16,14 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", 'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className className
)} )}
{...props} {...props}
ref={ref} ref={ref}
/> />
)) ));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef< const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>, React.ElementRef<typeof AlertDialogPrimitive.Content>,
@ -34,14 +34,14 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className className
)} )}
{...props} {...props}
/> />
</AlertDialogPortal> </AlertDialogPortal>
)) ));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ const AlertDialogHeader = ({
className, className,
@ -49,13 +49,13 @@ const AlertDialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col space-y-2 text-center sm:text-left", 'flex flex-col space-y-2 text-center sm:text-left',
className className
)} )}
{...props} {...props}
/> />
) );
AlertDialogHeader.displayName = "AlertDialogHeader" AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ const AlertDialogFooter = ({
className, className,
@ -63,13 +63,13 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className className
)} )}
{...props} {...props}
/> />
) );
AlertDialogFooter.displayName = "AlertDialogFooter" AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef< const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>, React.ElementRef<typeof AlertDialogPrimitive.Title>,
@ -77,11 +77,11 @@ const AlertDialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
ref={ref} ref={ref}
className={cn("text-lg font-semibold", className)} className={cn('text-lg font-semibold', className)}
{...props} {...props}
/> />
)) ));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef< const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>, React.ElementRef<typeof AlertDialogPrimitive.Description>,
@ -89,12 +89,12 @@ const AlertDialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn('text-sm text-muted-foreground', className)}
{...props} {...props}
/> />
)) ));
AlertDialogDescription.displayName = AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef< const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>, React.ElementRef<typeof AlertDialogPrimitive.Action>,
@ -105,8 +105,8 @@ const AlertDialogAction = React.forwardRef<
className={cn(buttonVariants(), className)} className={cn(buttonVariants(), className)}
{...props} {...props}
/> />
)) ));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef< const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>, React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
@ -115,14 +115,14 @@ const AlertDialogCancel = React.forwardRef<
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Cancel
ref={ref} ref={ref}
className={cn( className={cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: 'outline' }),
"mt-2 sm:mt-0", 'mt-2 sm:mt-0',
className className
)} )}
{...props} {...props}
/> />
)) ));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export { export {
AlertDialog, AlertDialog,
@ -136,4 +136,4 @@ export {
AlertDialogDescription, AlertDialogDescription,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
} };

View File

@ -1,23 +1,23 @@
import * as React from "react" import * as React from 'react';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{ {
variants: { variants: {
variant: { variant: {
default: "bg-background text-foreground", default: 'bg-background text-foreground',
destructive: destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
}, },
} }
) );
const Alert = React.forwardRef< const Alert = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -29,8 +29,8 @@ const Alert = React.forwardRef<
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant }), className)}
{...props} {...props}
/> />
)) ));
Alert.displayName = "Alert" Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef< const AlertTitle = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@ -38,11 +38,11 @@ const AlertTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<h5 <h5
ref={ref} ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)} className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props} {...props}
/> />
)) ));
AlertTitle.displayName = "AlertTitle" AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef< const AlertDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@ -50,10 +50,10 @@ const AlertDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)} className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props} {...props}
/> />
)) ));
AlertDescription.displayName = "AlertDescription" AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertTitle, AlertDescription };

View File

@ -1,5 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
const AspectRatio = AspectRatioPrimitive.Root const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio } export { AspectRatio };

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Shield, Crown } from 'lucide-react'; import { Shield, Crown } from 'lucide-react';
import { UserVerificationStatus } from '@/lib/forum/types'; import { UserVerificationStatus } from '@/types/forum';
import { getEnsName } from '@wagmi/core'; import { getEnsName } from '@wagmi/core';
import { config } from '@/lib/identity/wallets/appkit'; import { config } from '@/lib/identity/wallets/appkit';
import { OrdinalAPI } from '@/lib/identity/ordinal'; import { OrdinalAPI } from '@/lib/identity/ordinal';
@ -13,15 +13,19 @@ interface AuthorDisplayProps {
showBadge?: boolean; showBadge?: boolean;
} }
export function AuthorDisplay({ export function AuthorDisplay({
address, address,
userVerificationStatus, userVerificationStatus,
className = "", className = '',
showBadge = true showBadge = true,
}: AuthorDisplayProps) { }: AuthorDisplayProps) {
const userStatus = userVerificationStatus?.[address]; const userStatus = userVerificationStatus?.[address];
const [resolvedEns, setResolvedEns] = React.useState<string | undefined>(undefined); const [resolvedEns, setResolvedEns] = React.useState<string | undefined>(
const [resolvedOrdinal, setResolvedOrdinal] = React.useState<boolean | undefined>(undefined); undefined
);
const [resolvedOrdinal, setResolvedOrdinal] = React.useState<
boolean | undefined
>(undefined);
// Heuristics for address types // Heuristics for address types
const isEthereumAddress = address.startsWith('0x') && address.length === 42; const isEthereumAddress = address.startsWith('0x') && address.length === 42;
@ -32,13 +36,18 @@ export function AuthorDisplay({
let cancelled = false; let cancelled = false;
if (!userStatus?.ensName && isEthereumAddress) { if (!userStatus?.ensName && isEthereumAddress) {
getEnsName(config, { address: address as `0x${string}` }) getEnsName(config, { address: address as `0x${string}` })
.then((name) => { if (!cancelled) setResolvedEns(name || undefined); }) .then(name => {
.catch(() => { if (!cancelled) setResolvedEns(undefined); }); if (!cancelled) setResolvedEns(name || undefined);
})
.catch(() => {
if (!cancelled) setResolvedEns(undefined);
});
} else { } else {
setResolvedEns(userStatus?.ensName); setResolvedEns(userStatus?.ensName);
} }
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, [address, isEthereumAddress, userStatus?.ensName]); }, [address, isEthereumAddress, userStatus?.ensName]);
// Lazily check Ordinal ownership for Bitcoin addresses if not provided // Lazily check Ordinal ownership for Bitcoin addresses if not provided
@ -46,8 +55,9 @@ export function AuthorDisplay({
let cancelled = false; let cancelled = false;
const run = async () => { const run = async () => {
console.log({ console.log({
isBitcoinAddress, userStatus isBitcoinAddress,
}) userStatus,
});
if (isBitcoinAddress) { if (isBitcoinAddress) {
try { try {
const api = new OrdinalAPI(); const api = new OrdinalAPI();
@ -61,28 +71,33 @@ export function AuthorDisplay({
} }
}; };
run(); run();
return () => { cancelled = true; }; return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, isBitcoinAddress, userStatus?.hasOrdinal]); }, [address, isBitcoinAddress, userStatus?.hasOrdinal]);
const hasENS = Boolean(userStatus?.hasENS) || Boolean(resolvedEns) || Boolean(userStatus?.ensName); const hasENS =
const hasOrdinal = Boolean(userStatus?.hasOrdinal) || Boolean(resolvedOrdinal); Boolean(userStatus?.hasENS) ||
Boolean(resolvedEns) ||
Boolean(userStatus?.ensName);
const hasOrdinal =
Boolean(userStatus?.hasOrdinal) || Boolean(resolvedOrdinal);
// Only show a badge if the author has ENS or Ordinal ownership (not for basic verification) // Only show a badge if the author has ENS or Ordinal ownership (not for basic verification)
const shouldShowBadge = showBadge && (hasENS || hasOrdinal); const shouldShowBadge = showBadge && (hasENS || hasOrdinal);
const ensName = userStatus?.ensName || resolvedEns; const ensName = userStatus?.ensName || resolvedEns;
const displayName = ensName || `${address.slice(0, 6)}...${address.slice(-4)}`; const displayName =
ensName || `${address.slice(0, 6)}...${address.slice(-4)}`;
return ( return (
<div className={`flex items-center gap-1.5 ${className}`}> <div className={`flex items-center gap-1.5 ${className}`}>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">{displayName}</span>
{displayName}
</span>
{shouldShowBadge && ( {shouldShowBadge && (
<Badge <Badge
variant="secondary" variant="secondary"
className="text-xs px-1.5 py-0.5 h-auto bg-green-900/20 border-green-500/30 text-green-400" className="text-xs px-1.5 py-0.5 h-auto bg-green-900/20 border-green-500/30 text-green-400"
> >
{hasENS ? ( {hasENS ? (

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from 'react';
import * as AvatarPrimitive from "@radix-ui/react-avatar" import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Avatar = React.forwardRef< const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>, React.ElementRef<typeof AvatarPrimitive.Root>,
@ -10,13 +10,13 @@ const Avatar = React.forwardRef<
<AvatarPrimitive.Root <AvatarPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", 'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className className
)} )}
{...props} {...props}
/> />
)) ));
Avatar.displayName = AvatarPrimitive.Root.displayName Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef< const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>, React.ElementRef<typeof AvatarPrimitive.Image>,
@ -24,11 +24,11 @@ const AvatarImage = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AvatarPrimitive.Image <AvatarPrimitive.Image
ref={ref} ref={ref}
className={cn("aspect-square h-full w-full", className)} className={cn('aspect-square h-full w-full', className)}
{...props} {...props}
/> />
)) ));
AvatarImage.displayName = AvatarPrimitive.Image.displayName AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef< const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>, React.ElementRef<typeof AvatarPrimitive.Fallback>,
@ -37,12 +37,12 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback <AvatarPrimitive.Fallback
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted", 'flex h-full w-full items-center justify-center rounded-full bg-muted',
className className
)} )}
{...props} {...props}
/> />
)) ));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback } export { Avatar, AvatarImage, AvatarFallback };

View File

@ -1,27 +1,27 @@
import * as React from "react" import * as React from 'react';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const badgeVariants = cva( 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 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',
{ {
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: "text-foreground", outline: 'text-foreground',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
}, },
} }
) );
export interface BadgeProps export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
@ -35,9 +35,9 @@ const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} }
) );
Badge.displayName = "Badge" Badge.displayName = 'Badge';
export { Badge } export { Badge };

View File

@ -1,108 +1,108 @@
import * as React from "react" import * as React from 'react';
import { Slot } from "@radix-ui/react-slot" import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from "lucide-react" import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Breadcrumb = React.forwardRef< const Breadcrumb = React.forwardRef<
HTMLElement, HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & { React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode separator?: React.ReactNode;
} }
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />) >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb" Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList = React.forwardRef< const BreadcrumbList = React.forwardRef<
HTMLOListElement, HTMLOListElement,
React.ComponentPropsWithoutRef<"ol"> React.ComponentPropsWithoutRef<'ol'>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ol <ol
ref={ref} ref={ref}
className={cn( className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", 'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',
className className
)} )}
{...props} {...props}
/> />
)) ));
BreadcrumbList.displayName = "BreadcrumbList" BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem = React.forwardRef< const BreadcrumbItem = React.forwardRef<
HTMLLIElement, HTMLLIElement,
React.ComponentPropsWithoutRef<"li"> React.ComponentPropsWithoutRef<'li'>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<li <li
ref={ref} ref={ref}
className={cn("inline-flex items-center gap-1.5", className)} className={cn('inline-flex items-center gap-1.5', className)}
{...props} {...props}
/> />
)) ));
BreadcrumbItem.displayName = "BreadcrumbItem" BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink = React.forwardRef< const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement, HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & { React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean asChild?: boolean;
} }
>(({ asChild, className, ...props }, ref) => { >(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a" const Comp = asChild ? Slot : 'a';
return ( return (
<Comp <Comp
ref={ref} ref={ref}
className={cn("transition-colors hover:text-foreground", className)} className={cn('transition-colors hover:text-foreground', className)}
{...props} {...props}
/> />
) );
}) });
BreadcrumbLink.displayName = "BreadcrumbLink" BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage = React.forwardRef< const BreadcrumbPage = React.forwardRef<
HTMLSpanElement, HTMLSpanElement,
React.ComponentPropsWithoutRef<"span"> React.ComponentPropsWithoutRef<'span'>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<span <span
ref={ref} ref={ref}
role="link" role="link"
aria-disabled="true" aria-disabled="true"
aria-current="page" aria-current="page"
className={cn("font-normal text-foreground", className)} className={cn('font-normal text-foreground', className)}
{...props} {...props}
/> />
)) ));
BreadcrumbPage.displayName = "BreadcrumbPage" BreadcrumbPage.displayName = 'BreadcrumbPage';
const BreadcrumbSeparator = ({ const BreadcrumbSeparator = ({
children, children,
className, className,
...props ...props
}: React.ComponentProps<"li">) => ( }: React.ComponentProps<'li'>) => (
<li <li
role="presentation" role="presentation"
aria-hidden="true" aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)} className={cn('[&>svg]:size-3.5', className)}
{...props} {...props}
> >
{children ?? <ChevronRight />} {children ?? <ChevronRight />}
</li> </li>
) );
BreadcrumbSeparator.displayName = "BreadcrumbSeparator" BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({ const BreadcrumbEllipsis = ({
className, className,
...props ...props
}: React.ComponentProps<"span">) => ( }: React.ComponentProps<'span'>) => (
<span <span
role="presentation" role="presentation"
aria-hidden="true" aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)} className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props} {...props}
> >
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span> <span className="sr-only">More</span>
</span> </span>
) );
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis" BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export { export {
Breadcrumb, Breadcrumb,
@ -112,4 +112,4 @@ export {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
BreadcrumbEllipsis, BreadcrumbEllipsis,
} };

View File

@ -0,0 +1,30 @@
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',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-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',
},
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',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);

View File

@ -1,56 +1,28 @@
import * as React from "react" import * as React from 'react';
import { Slot } from "@radix-ui/react-slot" import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from "class-variance-authority" import { type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { buttonVariants } from './button-variants';
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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-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",
},
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",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean asChild?: boolean;
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : 'button';
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} }
) );
Button.displayName = "Button" Button.displayName = 'Button';
export { Button } export { Button };

View File

@ -1,9 +1,9 @@
import * as React from "react"; import * as React from 'react';
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from "react-day-picker"; import { DayPicker } from 'react-day-picker';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from '@/components/ui/button-variants';
export type CalendarProps = React.ComponentProps<typeof DayPicker>; export type CalendarProps = React.ComponentProps<typeof DayPicker>;
@ -16,49 +16,49 @@ function Calendar({
return ( return (
<DayPicker <DayPicker
showOutsideDays={showOutsideDays} showOutsideDays={showOutsideDays}
className={cn("p-3", className)} className={cn('p-3', className)}
classNames={{ classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: "space-y-4", month: 'space-y-4',
caption: "flex justify-center pt-1 relative items-center", caption: 'flex justify-center pt-1 relative items-center',
caption_label: "text-sm font-medium", caption_label: 'text-sm font-medium',
nav: "space-x-1 flex items-center", nav: 'space-x-1 flex items-center',
nav_button: cn( nav_button: cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: 'outline' }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100" 'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
), ),
nav_button_previous: "absolute left-1", nav_button_previous: 'absolute left-1',
nav_button_next: "absolute right-1", nav_button_next: 'absolute right-1',
table: "w-full border-collapse space-y-1", table: 'w-full border-collapse space-y-1',
head_row: "flex", head_row: 'flex',
head_cell: head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
row: "flex w-full mt-2", row: 'flex w-full mt-2',
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn( day: cn(
buttonVariants({ variant: "ghost" }), buttonVariants({ variant: 'ghost' }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100" 'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
), ),
day_range_end: "day-range-end", day_range_end: 'day-range-end',
day_selected: day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: "bg-accent text-accent-foreground", day_today: 'bg-accent text-accent-foreground',
day_outside: day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", 'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
day_disabled: "text-muted-foreground opacity-50", day_disabled: 'text-muted-foreground opacity-50',
day_range_middle: day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground", 'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: "invisible", day_hidden: 'invisible',
...classNames, ...classNames,
}} }}
components={{ components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />, IconLeft: () => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />, IconRight: () => <ChevronRight className="h-4 w-4" />,
}} }}
{...props} {...props}
/> />
); );
} }
Calendar.displayName = "Calendar"; Calendar.displayName = 'Calendar';
export { Calendar }; export { Calendar };

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Card = React.forwardRef< const Card = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -9,13 +9,13 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", 'rounded-lg border bg-card text-card-foreground shadow-sm',
className className
)} )}
{...props} {...props}
/> />
)) ));
Card.displayName = "Card" Card.displayName = 'Card';
const CardHeader = React.forwardRef< const CardHeader = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -23,11 +23,11 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)} className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props} {...props}
/> />
)) ));
CardHeader.displayName = "CardHeader" CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef< const CardTitle = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@ -36,13 +36,13 @@ const CardTitle = React.forwardRef<
<h3 <h3
ref={ref} ref={ref}
className={cn( className={cn(
"text-2xl font-semibold leading-none tracking-tight", 'text-2xl font-semibold leading-none tracking-tight',
className className
)} )}
{...props} {...props}
/> />
)) ));
CardTitle.displayName = "CardTitle" CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef< const CardDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
@ -50,19 +50,19 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<p <p
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn('text-sm text-muted-foreground', className)}
{...props} {...props}
/> />
)) ));
CardDescription.displayName = "CardDescription" CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef< const CardContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)) ));
CardContent.displayName = "CardContent" CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef< const CardFooter = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -70,10 +70,17 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn("flex items-center p-6 pt-0", className)} className={cn('flex items-center p-6 pt-0', className)}
{...props} {...props}
/> />
)) ));
CardFooter.displayName = "CardFooter" CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@ -1,43 +1,43 @@
import * as React from "react" import * as React from 'react';
import useEmblaCarousel, { import useEmblaCarousel, {
type UseEmblaCarouselType, type UseEmblaCarouselType,
} from "embla-carousel-react" } from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from "lucide-react" import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { Button } from "@/components/ui/button" import { Button } from '@/components/ui/button';
type CarouselApi = UseEmblaCarouselType[1] type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel> type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0] type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1] type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = { type CarouselProps = {
opts?: CarouselOptions opts?: CarouselOptions;
plugins?: CarouselPlugin plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical" orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void setApi?: (api: CarouselApi) => void;
} };
type CarouselContextProps = { type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0] carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1] api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void scrollPrev: () => void;
scrollNext: () => void scrollNext: () => void;
canScrollPrev: boolean canScrollPrev: boolean;
canScrollNext: boolean canScrollNext: boolean;
} & CarouselProps } & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null) const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() { function useCarousel() {
const context = React.useContext(CarouselContext) const context = React.useContext(CarouselContext);
if (!context) { if (!context) {
throw new Error("useCarousel must be used within a <Carousel />") throw new Error('useCarousel must be used within a <Carousel />');
} }
return context return context;
} }
const Carousel = React.forwardRef< const Carousel = React.forwardRef<
@ -46,7 +46,7 @@ const Carousel = React.forwardRef<
>( >(
( (
{ {
orientation = "horizontal", orientation = 'horizontal',
opts, opts,
setApi, setApi,
plugins, plugins,
@ -59,64 +59,64 @@ const Carousel = React.forwardRef<
const [carouselRef, api] = useEmblaCarousel( const [carouselRef, api] = useEmblaCarousel(
{ {
...opts, ...opts,
axis: orientation === "horizontal" ? "x" : "y", axis: orientation === 'horizontal' ? 'x' : 'y',
}, },
plugins plugins
) );
const [canScrollPrev, setCanScrollPrev] = React.useState(false) const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false) const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => { const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) { if (!api) {
return return;
} }
setCanScrollPrev(api.canScrollPrev()) setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext()) setCanScrollNext(api.canScrollNext());
}, []) }, []);
const scrollPrev = React.useCallback(() => { const scrollPrev = React.useCallback(() => {
api?.scrollPrev() api?.scrollPrev();
}, [api]) }, [api]);
const scrollNext = React.useCallback(() => { const scrollNext = React.useCallback(() => {
api?.scrollNext() api?.scrollNext();
}, [api]) }, [api]);
const handleKeyDown = React.useCallback( const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => { (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") { if (event.key === 'ArrowLeft') {
event.preventDefault() event.preventDefault();
scrollPrev() scrollPrev();
} else if (event.key === "ArrowRight") { } else if (event.key === 'ArrowRight') {
event.preventDefault() event.preventDefault();
scrollNext() scrollNext();
} }
}, },
[scrollPrev, scrollNext] [scrollPrev, scrollNext]
) );
React.useEffect(() => { React.useEffect(() => {
if (!api || !setApi) { if (!api || !setApi) {
return return;
} }
setApi(api) setApi(api);
}, [api, setApi]) }, [api, setApi]);
React.useEffect(() => { React.useEffect(() => {
if (!api) { if (!api) {
return return;
} }
onSelect(api) onSelect(api);
api.on("reInit", onSelect) api.on('reInit', onSelect);
api.on("select", onSelect) api.on('select', onSelect);
return () => { return () => {
api?.off("select", onSelect) api?.off('select', onSelect);
} };
}, [api, onSelect]) }, [api, onSelect]);
return ( return (
<CarouselContext.Provider <CarouselContext.Provider
@ -125,7 +125,7 @@ const Carousel = React.forwardRef<
api: api, api: api,
opts, opts,
orientation: orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev, scrollPrev,
scrollNext, scrollNext,
canScrollPrev, canScrollPrev,
@ -135,7 +135,7 @@ const Carousel = React.forwardRef<
<div <div
ref={ref} ref={ref}
onKeyDownCapture={handleKeyDown} onKeyDownCapture={handleKeyDown}
className={cn("relative", className)} className={cn('relative', className)}
role="region" role="region"
aria-roledescription="carousel" aria-roledescription="carousel"
{...props} {...props}
@ -143,38 +143,38 @@ const Carousel = React.forwardRef<
{children} {children}
</div> </div>
</CarouselContext.Provider> </CarouselContext.Provider>
) );
} }
) );
Carousel.displayName = "Carousel" Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef< const CarouselContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel() const { carouselRef, orientation } = useCarousel();
return ( return (
<div ref={carouselRef} className="overflow-hidden"> <div ref={carouselRef} className="overflow-hidden">
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"flex", 'flex',
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className className
)} )}
{...props} {...props}
/> />
</div> </div>
) );
}) });
CarouselContent.displayName = "CarouselContent" CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef< const CarouselItem = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { orientation } = useCarousel() const { orientation } = useCarousel();
return ( return (
<div <div
@ -182,21 +182,21 @@ const CarouselItem = React.forwardRef<
role="group" role="group"
aria-roledescription="slide" aria-roledescription="slide"
className={cn( className={cn(
"min-w-0 shrink-0 grow-0 basis-full", 'min-w-0 shrink-0 grow-0 basis-full',
orientation === "horizontal" ? "pl-4" : "pt-4", orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className className
)} )}
{...props} {...props}
/> />
) );
}) });
CarouselItem.displayName = "CarouselItem" CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef< const CarouselPrevious = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<typeof Button> React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => { >(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel() const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return ( return (
<Button <Button
@ -204,10 +204,10 @@ const CarouselPrevious = React.forwardRef<
variant={variant} variant={variant}
size={size} size={size}
className={cn( className={cn(
"absolute h-8 w-8 rounded-full", 'absolute h-8 w-8 rounded-full',
orientation === "horizontal" orientation === 'horizontal'
? "-left-12 top-1/2 -translate-y-1/2" ? '-left-12 top-1/2 -translate-y-1/2'
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", : '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className className
)} )}
disabled={!canScrollPrev} disabled={!canScrollPrev}
@ -217,15 +217,15 @@ const CarouselPrevious = React.forwardRef<
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span> <span className="sr-only">Previous slide</span>
</Button> </Button>
) );
}) });
CarouselPrevious.displayName = "CarouselPrevious" CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef< const CarouselNext = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<typeof Button> React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => { >(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel() const { orientation, scrollNext, canScrollNext } = useCarousel();
return ( return (
<Button <Button
@ -233,10 +233,10 @@ const CarouselNext = React.forwardRef<
variant={variant} variant={variant}
size={size} size={size}
className={cn( className={cn(
"absolute h-8 w-8 rounded-full", 'absolute h-8 w-8 rounded-full',
orientation === "horizontal" orientation === 'horizontal'
? "-right-12 top-1/2 -translate-y-1/2" ? '-right-12 top-1/2 -translate-y-1/2'
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className className
)} )}
disabled={!canScrollNext} disabled={!canScrollNext}
@ -246,9 +246,9 @@ const CarouselNext = React.forwardRef<
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span> <span className="sr-only">Next slide</span>
</Button> </Button>
) );
}) });
CarouselNext.displayName = "CarouselNext" CarouselNext.displayName = 'CarouselNext';
export { export {
type CarouselApi, type CarouselApi,
@ -257,4 +257,4 @@ export {
CarouselItem, CarouselItem,
CarouselPrevious, CarouselPrevious,
CarouselNext, CarouselNext,
} };

View File

@ -1,48 +1,48 @@
import * as React from "react" import * as React from 'react';
import * as RechartsPrimitive from "recharts" import * as RechartsPrimitive from 'recharts';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
// Format: { THEME_NAME: CSS_SELECTOR } // Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = { export type ChartConfig = {
[k in string]: { [k in string]: {
label?: React.ReactNode label?: React.ReactNode;
icon?: React.ComponentType icon?: React.ComponentType;
} & ( } & (
| { color?: string; theme?: never } | { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> } | { color?: never; theme: Record<keyof typeof THEMES, string> }
) );
} };
type ChartContextProps = { type ChartContextProps = {
config: ChartConfig config: ChartConfig;
} };
const ChartContext = React.createContext<ChartContextProps | null>(null) const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() { function useChart() {
const context = React.useContext(ChartContext) const context = React.useContext(ChartContext);
if (!context) { if (!context) {
throw new Error("useChart must be used within a <ChartContainer />") throw new Error('useChart must be used within a <ChartContainer />');
} }
return context return context;
} }
const ChartContainer = React.forwardRef< const ChartContainer = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & { React.ComponentProps<'div'> & {
config: ChartConfig config: ChartConfig;
children: React.ComponentProps< children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer typeof RechartsPrimitive.ResponsiveContainer
>["children"] >['children'];
} }
>(({ id, className, children, config, ...props }, ref) => { >(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId() const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`;
return ( return (
<ChartContext.Provider value={{ config }}> <ChartContext.Provider value={{ config }}>
@ -61,17 +61,17 @@ const ChartContainer = React.forwardRef<
</RechartsPrimitive.ResponsiveContainer> </RechartsPrimitive.ResponsiveContainer>
</div> </div>
</ChartContext.Provider> </ChartContext.Provider>
) );
}) });
ChartContainer.displayName = "Chart" ChartContainer.displayName = 'Chart';
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter( const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color ([_, config]) => config.theme || config.color
) );
if (!colorConfig.length) { if (!colorConfig.length) {
return null return null;
} }
return ( return (
@ -85,30 +85,30 @@ ${colorConfig
.map(([key, itemConfig]) => { .map(([key, itemConfig]) => {
const color = const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color itemConfig.color;
return color ? ` --color-${key}: ${color};` : null return color ? ` --color-${key}: ${color};` : null;
}) })
.join("\n")} .join('\n')}
} }
` `
) )
.join("\n"), .join('\n'),
}} }}
/> />
) );
} };
const ChartTooltip = RechartsPrimitive.Tooltip const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef< const ChartTooltipContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> & React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & { React.ComponentProps<'div'> & {
hideLabel?: boolean hideLabel?: boolean;
hideIndicator?: boolean hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed" indicator?: 'line' | 'dot' | 'dashed';
nameKey?: string nameKey?: string;
labelKey?: string labelKey?: string;
} }
>( >(
( (
@ -116,7 +116,7 @@ const ChartTooltipContent = React.forwardRef<
active, active,
payload, payload,
className, className,
indicator = "dot", indicator = 'dot',
hideLabel = false, hideLabel = false,
hideIndicator = false, hideIndicator = false,
label, label,
@ -129,34 +129,36 @@ const ChartTooltipContent = React.forwardRef<
}, },
ref ref
) => { ) => {
const { config } = useChart() const { config } = useChart();
const tooltipLabel = React.useMemo(() => { const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) { if (hideLabel || !payload?.length) {
return null return null;
} }
const [item] = payload const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}` if (!item) return null;
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const key = `${labelKey || item.dataKey || item.name || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value = const value =
!labelKey && typeof label === "string" !labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label ? config[label as keyof typeof config]?.label || label
: itemConfig?.label : itemConfig?.label;
if (labelFormatter) { if (labelFormatter) {
return ( return (
<div className={cn("font-medium", labelClassName)}> <div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)} {labelFormatter(value, payload)}
</div> </div>
) );
} }
if (!value) { if (!value) {
return null return null;
} }
return <div className={cn("font-medium", labelClassName)}>{value}</div> return <div className={cn('font-medium', labelClassName)}>{value}</div>;
}, [ }, [
label, label,
labelFormatter, labelFormatter,
@ -165,35 +167,35 @@ const ChartTooltipContent = React.forwardRef<
labelClassName, labelClassName,
config, config,
labelKey, labelKey,
]) ]);
if (!active || !payload?.length) { if (!active || !payload?.length) {
return null return null;
} }
const nestLabel = payload.length === 1 && indicator !== "dot" const nestLabel = payload.length === 1 && indicator !== 'dot';
return ( return (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", 'grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
className className
)} )}
> >
{!nestLabel ? tooltipLabel : null} {!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5"> <div className="grid gap-1.5">
{payload.map((item, index) => { {payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}` const key = `${nameKey || item.name || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color const indicatorColor = color || item.payload.fill || item.color;
return ( return (
<div <div
key={item.dataKey} key={item.dataKey}
className={cn( className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", 'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
indicator === "dot" && "items-center" indicator === 'dot' && 'items-center'
)} )}
> >
{formatter && item?.value !== undefined && item.name ? ( {formatter && item?.value !== undefined && item.name ? (
@ -206,19 +208,19 @@ const ChartTooltipContent = React.forwardRef<
!hideIndicator && ( !hideIndicator && (
<div <div
className={cn( className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", 'shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]',
{ {
"h-2.5 w-2.5": indicator === "dot", 'h-2.5 w-2.5': indicator === 'dot',
"w-1": indicator === "line", 'w-1': indicator === 'line',
"w-0 border-[1.5px] border-dashed bg-transparent": 'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === "dashed", indicator === 'dashed',
"my-0.5": nestLabel && indicator === "dashed", 'my-0.5': nestLabel && indicator === 'dashed',
} }
)} )}
style={ style={
{ {
"--color-bg": indicatorColor, '--color-bg': indicatorColor,
"--color-border": indicatorColor, '--color-border': indicatorColor,
} as React.CSSProperties } as React.CSSProperties
} }
/> />
@ -226,8 +228,8 @@ const ChartTooltipContent = React.forwardRef<
)} )}
<div <div
className={cn( className={cn(
"flex flex-1 justify-between leading-none", 'flex flex-1 justify-between leading-none',
nestLabel ? "items-end" : "items-center" nestLabel ? 'items-end' : 'items-center'
)} )}
> >
<div className="grid gap-1.5"> <div className="grid gap-1.5">
@ -245,53 +247,53 @@ const ChartTooltipContent = React.forwardRef<
</> </>
)} )}
</div> </div>
) );
})} })}
</div> </div>
</div> </div>
) );
} }
) );
ChartTooltipContent.displayName = "ChartTooltip" ChartTooltipContent.displayName = 'ChartTooltip';
const ChartLegend = RechartsPrimitive.Legend const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef< const ChartLegendContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean hideIcon?: boolean;
nameKey?: string nameKey?: string;
} }
>( >(
( (
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, { className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey },
ref ref
) => { ) => {
const { config } = useChart() const { config } = useChart();
if (!payload?.length) { if (!payload?.length) {
return null return null;
} }
return ( return (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"flex items-center justify-center gap-4", 'flex items-center justify-center gap-4',
verticalAlign === "top" ? "pb-3" : "pt-3", verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className className
)} )}
> >
{payload.map((item) => { {payload.map(item => {
const key = `${nameKey || item.dataKey || "value"}` const key = `${nameKey || item.dataKey || 'value'}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key);
return ( return (
<div <div
key={item.value} key={item.value}
className={cn( className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" 'flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground'
)} )}
> >
{itemConfig?.icon && !hideIcon ? ( {itemConfig?.icon && !hideIcon ? (
@ -306,13 +308,13 @@ const ChartLegendContent = React.forwardRef<
)} )}
{itemConfig?.label} {itemConfig?.label}
</div> </div>
) );
})} })}
</div> </div>
) );
} }
) );
ChartLegendContent.displayName = "ChartLegend" ChartLegendContent.displayName = 'ChartLegend';
// Helper to extract item config from a payload. // Helper to extract item config from a payload.
function getPayloadConfigFromPayload( function getPayloadConfigFromPayload(
@ -320,37 +322,37 @@ function getPayloadConfigFromPayload(
payload: unknown, payload: unknown,
key: string key: string
) { ) {
if (typeof payload !== "object" || payload === null) { if (typeof payload !== 'object' || payload === null) {
return undefined return undefined;
} }
const payloadPayload = const payloadPayload =
"payload" in payload && 'payload' in payload &&
typeof payload.payload === "object" && typeof payload.payload === 'object' &&
payload.payload !== null payload.payload !== null
? payload.payload ? payload.payload
: undefined : undefined;
let configLabelKey: string = key let configLabelKey: string = key;
if ( if (
key in payload && key in payload &&
typeof payload[key as keyof typeof payload] === "string" typeof payload[key as keyof typeof payload] === 'string'
) { ) {
configLabelKey = payload[key as keyof typeof payload] as string configLabelKey = payload[key as keyof typeof payload] as string;
} else if ( } else if (
payloadPayload && payloadPayload &&
key in payloadPayload && key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string" typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) { ) {
configLabelKey = payloadPayload[ configLabelKey = payloadPayload[
key as keyof typeof payloadPayload key as keyof typeof payloadPayload
] as string ] as string;
} }
return configLabelKey in config return configLabelKey in config
? config[configLabelKey] ? config[configLabelKey]
: config[key as keyof typeof config] : config[key as keyof typeof config];
} }
export { export {
@ -360,4 +362,4 @@ export {
ChartLegend, ChartLegend,
ChartLegendContent, ChartLegendContent,
ChartStyle, ChartStyle,
} };

View File

@ -1,8 +1,8 @@
import * as React from "react" import * as React from 'react';
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from "lucide-react" import { Check } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Checkbox = React.forwardRef< const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>, React.ElementRef<typeof CheckboxPrimitive.Root>,
@ -11,18 +11,18 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", 'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className className
)} )}
{...props} {...props}
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")} className={cn('flex items-center justify-center text-current')}
> >
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
)) ));
Checkbox.displayName = CheckboxPrimitive.Root.displayName Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox } export { Checkbox };

View File

@ -1,9 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent } export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -1,10 +1,10 @@
import * as React from "react" import * as React from 'react';
import { type DialogProps } from "@radix-ui/react-dialog" import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from "cmdk" import { Command as CommandPrimitive } from 'cmdk';
import { Search } from "lucide-react" import { Search } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from "@/components/ui/dialog" import { Dialog, DialogContent } from '@/components/ui/dialog';
const Command = React.forwardRef< const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>, React.ElementRef<typeof CommandPrimitive>,
@ -13,14 +13,13 @@ const Command = React.forwardRef<
<CommandPrimitive <CommandPrimitive
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", 'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className className
)} )}
{...props} {...props}
/> />
)) ));
Command.displayName = CommandPrimitive.displayName Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => { const CommandDialog = ({ children, ...props }: DialogProps) => {
return ( return (
@ -31,8 +30,8 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
</Command> </Command>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} };
const CommandInput = React.forwardRef< const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>, React.ElementRef<typeof CommandPrimitive.Input>,
@ -43,15 +42,15 @@ const CommandInput = React.forwardRef<
<CommandPrimitive.Input <CommandPrimitive.Input
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", 'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className className
)} )}
{...props} {...props}
/> />
</div> </div>
)) ));
CommandInput.displayName = CommandPrimitive.Input.displayName CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef< const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>, React.ElementRef<typeof CommandPrimitive.List>,
@ -59,12 +58,12 @@ const CommandList = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CommandPrimitive.List <CommandPrimitive.List
ref={ref} ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props} {...props}
/> />
)) ));
CommandList.displayName = CommandPrimitive.List.displayName CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef< const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>, React.ElementRef<typeof CommandPrimitive.Empty>,
@ -75,9 +74,9 @@ const CommandEmpty = React.forwardRef<
className="py-6 text-center text-sm" className="py-6 text-center text-sm"
{...props} {...props}
/> />
)) ));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef< const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>, React.ElementRef<typeof CommandPrimitive.Group>,
@ -86,14 +85,14 @@ const CommandGroup = React.forwardRef<
<CommandPrimitive.Group <CommandPrimitive.Group
ref={ref} ref={ref}
className={cn( className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", 'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className className
)} )}
{...props} {...props}
/> />
)) ));
CommandGroup.displayName = CommandPrimitive.Group.displayName CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef< const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>, React.ElementRef<typeof CommandPrimitive.Separator>,
@ -101,11 +100,11 @@ const CommandSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CommandPrimitive.Separator <CommandPrimitive.Separator
ref={ref} ref={ref}
className={cn("-mx-1 h-px bg-border", className)} className={cn('-mx-1 h-px bg-border', className)}
{...props} {...props}
/> />
)) ));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef< const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>, React.ElementRef<typeof CommandPrimitive.Item>,
@ -119,9 +118,9 @@ const CommandItem = React.forwardRef<
)} )}
{...props} {...props}
/> />
)) ));
CommandItem.displayName = CommandPrimitive.Item.displayName CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ const CommandShortcut = ({
className, className,
@ -130,14 +129,14 @@ const CommandShortcut = ({
return ( return (
<span <span
className={cn( className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground", 'ml-auto text-xs tracking-widest text-muted-foreground',
className className
)} )}
{...props} {...props}
/> />
) );
} };
CommandShortcut.displayName = "CommandShortcut" CommandShortcut.displayName = 'CommandShortcut';
export { export {
Command, Command,
@ -149,4 +148,4 @@ export {
CommandItem, CommandItem,
CommandShortcut, CommandShortcut,
CommandSeparator, CommandSeparator,
} };

View File

@ -1,32 +1,32 @@
import * as React from "react" import * as React from 'react';
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { Check, ChevronRight, Circle } from "lucide-react" import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const ContextMenu = ContextMenuPrimitive.Root const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef< const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>, React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, children, ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger <ContextMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", 'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && "pl-8", inset && 'pl-8',
className className
)} )}
{...props} {...props}
@ -34,8 +34,8 @@ const ContextMenuSubTrigger = React.forwardRef<
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger> </ContextMenuPrimitive.SubTrigger>
)) ));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef< const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>, React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
@ -44,13 +44,13 @@ const ContextMenuSubContent = React.forwardRef<
<ContextMenuPrimitive.SubContent <ContextMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className className
)} )}
{...props} {...props}
/> />
)) ));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef< const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>, React.ElementRef<typeof ContextMenuPrimitive.Content>,
@ -60,32 +60,32 @@ const ContextMenuContent = React.forwardRef<
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className className
)} )}
{...props} {...props}
/> />
</ContextMenuPrimitive.Portal> </ContextMenuPrimitive.Portal>
)) ));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef< const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>, React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item <ContextMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && "pl-8", inset && 'pl-8',
className className
)} )}
{...props} {...props}
/> />
)) ));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef< const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>, React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
@ -94,7 +94,7 @@ const ContextMenuCheckboxItem = React.forwardRef<
<ContextMenuPrimitive.CheckboxItem <ContextMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className className
)} )}
checked={checked} checked={checked}
@ -107,9 +107,9 @@ const ContextMenuCheckboxItem = React.forwardRef<
</span> </span>
{children} {children}
</ContextMenuPrimitive.CheckboxItem> </ContextMenuPrimitive.CheckboxItem>
)) ));
ContextMenuCheckboxItem.displayName = ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef< const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>, React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
@ -118,7 +118,7 @@ const ContextMenuRadioItem = React.forwardRef<
<ContextMenuPrimitive.RadioItem <ContextMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className className
)} )}
{...props} {...props}
@ -130,26 +130,26 @@ const ContextMenuRadioItem = React.forwardRef<
</span> </span>
{children} {children}
</ContextMenuPrimitive.RadioItem> </ContextMenuPrimitive.RadioItem>
)) ));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef< const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>, React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label <ContextMenuPrimitive.Label
ref={ref} ref={ref}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground", 'px-2 py-1.5 text-sm font-semibold text-foreground',
inset && "pl-8", inset && 'pl-8',
className className
)} )}
{...props} {...props}
/> />
)) ));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef< const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>, React.ElementRef<typeof ContextMenuPrimitive.Separator>,
@ -157,11 +157,11 @@ const ContextMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator <ContextMenuPrimitive.Separator
ref={ref} ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)} className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props} {...props}
/> />
)) ));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ const ContextMenuShortcut = ({
className, className,
@ -170,14 +170,14 @@ const ContextMenuShortcut = ({
return ( return (
<span <span
className={cn( className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground", 'ml-auto text-xs tracking-widest text-muted-foreground',
className className
)} )}
{...props} {...props}
/> />
) );
} };
ContextMenuShortcut.displayName = "ContextMenuShortcut" ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export { export {
ContextMenu, ContextMenu,
@ -195,4 +195,4 @@ export {
ContextMenuSubContent, ContextMenuSubContent,
ContextMenuSubTrigger, ContextMenuSubTrigger,
ContextMenuRadioGroup, ContextMenuRadioGroup,
} };

View File

@ -17,16 +17,17 @@ export function DelegationStep({
isLoading, isLoading,
setIsLoading, setIsLoading,
}: DelegationStepProps) { }: DelegationStepProps) {
const { const {
currentUser, currentUser,
delegateKey, delegateKey,
isDelegationValid, isDelegationValid,
delegationTimeRemaining, delegationTimeRemaining,
isAuthenticating, isAuthenticating,
clearDelegation clearDelegation,
} = useAuth(); } = useAuth();
const [selectedDuration, setSelectedDuration] = React.useState<DelegationDuration>('7days'); const [selectedDuration, setSelectedDuration] =
React.useState<DelegationDuration>('7days');
const [delegationResult, setDelegationResult] = React.useState<{ const [delegationResult, setDelegationResult] = React.useState<{
success: boolean; success: boolean;
message: string; message: string;
@ -35,33 +36,33 @@ export function DelegationStep({
const handleDelegate = async () => { const handleDelegate = async () => {
if (!currentUser) return; if (!currentUser) return;
setIsLoading(true); setIsLoading(true);
setDelegationResult(null); setDelegationResult(null);
try { try {
const success = await delegateKey(selectedDuration); const success = await delegateKey(selectedDuration);
if (success) { if (success) {
const expiryDate = currentUser.delegationExpiry const expiryDate = currentUser.delegationExpiry
? new Date(currentUser.delegationExpiry).toLocaleString() ? new Date(currentUser.delegationExpiry).toLocaleString()
: `${selectedDuration === '7days' ? '1 week' : '30 days'} from now`; : `${selectedDuration === '7days' ? '1 week' : '30 days'} from now`;
setDelegationResult({ setDelegationResult({
success: true, success: true,
message: "Key delegation successful!", message: 'Key delegation successful!',
expiry: expiryDate expiry: expiryDate,
}); });
} else { } else {
setDelegationResult({ setDelegationResult({
success: false, success: false,
message: "Key delegation failed." message: 'Key delegation failed.',
}); });
} }
} catch (error) { } catch (error) {
setDelegationResult({ setDelegationResult({
success: false, success: false,
message: `Delegation failed. Please try again: ${error}` message: `Delegation failed. Please try again: ${error}`,
}); });
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -77,21 +78,29 @@ export function DelegationStep({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex-1 space-y-4"> <div className="flex-1 space-y-4">
<div className={`p-4 rounded-lg border ${ <div
delegationResult.success className={`p-4 rounded-lg border ${
? 'bg-green-900/20 border-green-500/30' delegationResult.success
: 'bg-yellow-900/20 border-yellow-500/30' ? 'bg-green-900/20 border-green-500/30'
}`}> : 'bg-yellow-900/20 border-yellow-500/30'
}`}
>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
{delegationResult.success ? ( {delegationResult.success ? (
<CheckCircle className="h-5 w-5 text-green-500" /> <CheckCircle className="h-5 w-5 text-green-500" />
) : ( ) : (
<AlertCircle className="h-5 w-5 text-yellow-500" /> <AlertCircle className="h-5 w-5 text-yellow-500" />
)} )}
<span className={`font-medium ${ <span
delegationResult.success ? 'text-green-400' : 'text-yellow-400' className={`font-medium ${
}`}> delegationResult.success
{delegationResult.success ? 'Delegation Complete' : 'Delegation Result'} ? 'text-green-400'
: 'text-yellow-400'
}`}
>
{delegationResult.success
? 'Delegation Complete'
: 'Delegation Result'}
</span> </span>
</div> </div>
<p className="text-sm text-neutral-300 mb-2"> <p className="text-sm text-neutral-300 mb-2">
@ -104,7 +113,7 @@ export function DelegationStep({
)} )}
</div> </div>
</div> </div>
{/* Action Button */} {/* Action Button */}
<div className="mt-auto"> <div className="mt-auto">
<Button <Button
@ -126,17 +135,30 @@ export function DelegationStep({
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<div className="flex justify-center"> <div className="flex justify-center">
<div className="w-16 h-16 bg-neutral-800 rounded-full flex items-center justify-center"> <div className="w-16 h-16 bg-neutral-800 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-neutral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /> className="w-8 h-8 text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
/>
</svg> </svg>
</div> </div>
</div> </div>
<h3 className="text-lg font-semibold text-neutral-100">Key Delegation</h3> <h3 className="text-lg font-semibold text-neutral-100">
Key Delegation
</h3>
<p className="text-sm text-neutral-400"> <p className="text-sm text-neutral-400">
Delegate signing authority to your browser for convenient forum interactions Delegate signing authority to your browser for convenient forum
interactions
</p> </p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{/* Status */} {/* Status */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -145,22 +167,30 @@ export function DelegationStep({
) : ( ) : (
<AlertCircle className="h-4 w-4 text-yellow-500" /> <AlertCircle className="h-4 w-4 text-yellow-500" />
)} )}
<span className={`text-sm font-medium ${ <span
isDelegationValid() ? 'text-green-400' : 'text-yellow-400' className={`text-sm font-medium ${
}`}> isDelegationValid() ? 'text-green-400' : 'text-yellow-400'
}`}
>
{isDelegationValid() ? 'Delegated' : 'Required'} {isDelegationValid() ? 'Delegated' : 'Required'}
</span> </span>
{isDelegationValid() && ( {isDelegationValid() && (
<span className="text-xs text-neutral-400"> <span className="text-xs text-neutral-400">
{Math.floor(delegationTimeRemaining() / (1000 * 60 * 60))}h {Math.floor((delegationTimeRemaining() % (1000 * 60 * 60)) / (1000 * 60))}m remaining {Math.floor(delegationTimeRemaining() / (1000 * 60 * 60))}h{' '}
{Math.floor(
(delegationTimeRemaining() % (1000 * 60 * 60)) / (1000 * 60)
)}
m remaining
</span> </span>
)} )}
</div> </div>
{/* Duration Selection */} {/* Duration Selection */}
{!isDelegationValid() && ( {!isDelegationValid() && (
<div className="space-y-3"> <div className="space-y-3">
<label className="text-sm font-medium text-neutral-300">Delegation Duration:</label> <label className="text-sm font-medium text-neutral-300">
Delegation Duration:
</label>
<div className="space-y-2"> <div className="space-y-2">
<label className="flex items-center space-x-2 cursor-pointer"> <label className="flex items-center space-x-2 cursor-pointer">
<input <input
@ -168,7 +198,9 @@ export function DelegationStep({
name="duration" name="duration"
value="7days" value="7days"
checked={selectedDuration === '7days'} checked={selectedDuration === '7days'}
onChange={(e) => setSelectedDuration(e.target.value as DelegationDuration)} onChange={e =>
setSelectedDuration(e.target.value as DelegationDuration)
}
className="w-4 h-4 text-green-600 bg-neutral-800 border-neutral-600 focus:ring-green-500 focus:ring-2" className="w-4 h-4 text-green-600 bg-neutral-800 border-neutral-600 focus:ring-green-500 focus:ring-2"
/> />
<span className="text-sm text-neutral-300">1 Week</span> <span className="text-sm text-neutral-300">1 Week</span>
@ -179,7 +211,9 @@ export function DelegationStep({
name="duration" name="duration"
value="30days" value="30days"
checked={selectedDuration === '30days'} checked={selectedDuration === '30days'}
onChange={(e) => setSelectedDuration(e.target.value as DelegationDuration)} onChange={e =>
setSelectedDuration(e.target.value as DelegationDuration)
}
className="w-4 h-4 text-green-600 bg-neutral-800 border-neutral-600 focus:ring-green-500 focus:ring-2" className="w-4 h-4 text-green-600 bg-neutral-800 border-neutral-600 focus:ring-green-500 focus:ring-2"
/> />
<span className="text-sm text-neutral-300">30 Days</span> <span className="text-sm text-neutral-300">30 Days</span>
@ -187,7 +221,7 @@ export function DelegationStep({
</div> </div>
</div> </div>
)} )}
{/* Delegated Browser Public Key */} {/* Delegated Browser Public Key */}
{isDelegationValid() && currentUser?.browserPubKey && ( {isDelegationValid() && currentUser?.browserPubKey && (
<div className="text-xs text-neutral-400"> <div className="text-xs text-neutral-400">
@ -196,16 +230,14 @@ export function DelegationStep({
</div> </div>
</div> </div>
)} )}
{/* Wallet Address */} {/* Wallet Address */}
{currentUser && ( {currentUser && (
<div className="text-xs text-neutral-400"> <div className="text-xs text-neutral-400">
<div className="font-mono break-all"> <div className="font-mono break-all">{currentUser.address}</div>
{currentUser.address}
</div>
</div> </div>
)} )}
{/* Delete Button for Active Delegations */} {/* Delete Button for Active Delegations */}
{isDelegationValid() && ( {isDelegationValid() && (
<div className="flex justify-end"> <div className="flex justify-end">
@ -222,7 +254,7 @@ export function DelegationStep({
)} )}
</div> </div>
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="mt-auto space-y-2"> <div className="mt-auto space-y-2">
{isDelegationValid() ? ( {isDelegationValid() ? (
@ -242,7 +274,7 @@ export function DelegationStep({
{isAuthenticating ? 'Delegating...' : 'Delegate Key'} {isAuthenticating ? 'Delegating...' : 'Delegate Key'}
</Button> </Button>
)} )}
<Button <Button
onClick={onBack} onClick={onBack}
variant="outline" variant="outline"
@ -254,4 +286,4 @@ export function DelegationStep({
</div> </div>
</div> </div>
); );
} }

View File

@ -1,16 +1,16 @@
import * as React from "react" import * as React from 'react';
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from "lucide-react" import { X } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
@ -19,13 +19,13 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", 'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className className
)} )}
{...props} {...props}
/> />
)) ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className className
)} )}
{...props} {...props}
@ -48,8 +48,8 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)) ));
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ const DialogHeader = ({
className, className,
@ -57,13 +57,13 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", 'flex flex-col space-y-1.5 text-center sm:text-left',
className className
)} )}
{...props} {...props}
/> />
) );
DialogHeader.displayName = "DialogHeader" DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ const DialogFooter = ({
className, className,
@ -71,13 +71,13 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className className
)} )}
{...props} {...props}
/> />
) );
DialogFooter.displayName = "DialogFooter" DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef< const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
@ -86,13 +86,13 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", 'text-lg font-semibold leading-none tracking-tight',
className className
)} )}
{...props} {...props}
/> />
)) ));
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef< const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>, React.ElementRef<typeof DialogPrimitive.Description>,
@ -100,11 +100,11 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn('text-sm text-muted-foreground', className)}
{...props} {...props}
/> />
)) ));
DialogDescription.displayName = DialogPrimitive.Description.displayName DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { export {
Dialog, Dialog,
@ -117,4 +117,4 @@ export {
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
} };

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from 'react';
import { Drawer as DrawerPrimitive } from "vaul" import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Drawer = ({ const Drawer = ({
shouldScaleBackground = true, shouldScaleBackground = true,
@ -11,14 +11,14 @@ const Drawer = ({
shouldScaleBackground={shouldScaleBackground} shouldScaleBackground={shouldScaleBackground}
{...props} {...props}
/> />
) );
Drawer.displayName = "Drawer" Drawer.displayName = 'Drawer';
const DrawerTrigger = DrawerPrimitive.Trigger const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef< const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>, React.ElementRef<typeof DrawerPrimitive.Overlay>,
@ -26,11 +26,11 @@ const DrawerOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay <DrawerPrimitive.Overlay
ref={ref} ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)} className={cn('fixed inset-0 z-50 bg-black/80', className)}
{...props} {...props}
/> />
)) ));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef< const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>, React.ElementRef<typeof DrawerPrimitive.Content>,
@ -41,7 +41,7 @@ const DrawerContent = React.forwardRef<
<DrawerPrimitive.Content <DrawerPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", 'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background',
className className
)} )}
{...props} {...props}
@ -50,30 +50,30 @@ const DrawerContent = React.forwardRef<
{children} {children}
</DrawerPrimitive.Content> </DrawerPrimitive.Content>
</DrawerPortal> </DrawerPortal>
)) ));
DrawerContent.displayName = "DrawerContent" DrawerContent.displayName = 'DrawerContent';
const DrawerHeader = ({ const DrawerHeader = ({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)}
{...props} {...props}
/> />
) );
DrawerHeader.displayName = "DrawerHeader" DrawerHeader.displayName = 'DrawerHeader';
const DrawerFooter = ({ const DrawerFooter = ({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props} {...props}
/> />
) );
DrawerFooter.displayName = "DrawerFooter" DrawerFooter.displayName = 'DrawerFooter';
const DrawerTitle = React.forwardRef< const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>, React.ElementRef<typeof DrawerPrimitive.Title>,
@ -82,13 +82,13 @@ const DrawerTitle = React.forwardRef<
<DrawerPrimitive.Title <DrawerPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg font-semibold leading-none tracking-tight", 'text-lg font-semibold leading-none tracking-tight',
className className
)} )}
{...props} {...props}
/> />
)) ));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef< const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>, React.ElementRef<typeof DrawerPrimitive.Description>,
@ -96,11 +96,11 @@ const DrawerDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DrawerPrimitive.Description <DrawerPrimitive.Description
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn('text-sm text-muted-foreground', className)}
{...props} {...props}
/> />
)) ));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export { export {
Drawer, Drawer,
@ -113,4 +113,4 @@ export {
DrawerFooter, DrawerFooter,
DrawerTitle, DrawerTitle,
DrawerDescription, DrawerDescription,
} };

View File

@ -1,32 +1,32 @@
import * as React from "react" import * as React from 'react';
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from "lucide-react" import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef< const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, children, ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", 'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && "pl-8", inset && 'pl-8',
className className
)} )}
{...props} {...props}
@ -34,9 +34,9 @@ const DropdownMenuSubTrigger = React.forwardRef<
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
)) ));
DropdownMenuSubTrigger.displayName = DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef< const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -45,14 +45,14 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className className
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuSubContent.displayName = DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef< const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -63,32 +63,32 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className className
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
)) ));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef< const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && "pl-8", inset && 'pl-8',
className className
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef< const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -97,7 +97,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className className
)} )}
checked={checked} checked={checked}
@ -110,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
)) ));
DropdownMenuCheckboxItem.displayName = DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef< const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -121,7 +121,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className className
)} )}
{...props} {...props}
@ -133,26 +133,26 @@ const DropdownMenuRadioItem = React.forwardRef<
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
)) ));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef< const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>, React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} ref={ref}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-semibold", 'px-2 py-1.5 text-sm font-semibold',
inset && "pl-8", inset && 'pl-8',
className className
)} )}
{...props} {...props}
/> />
)) ));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef< const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -160,11 +160,11 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
ref={ref} ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props} {...props}
/> />
)) ));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ const DropdownMenuShortcut = ({
className, className,
@ -172,12 +172,12 @@ const DropdownMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => { }: React.HTMLAttributes<HTMLSpanElement>) => {
return ( return (
<span <span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)} className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props} {...props}
/> />
) );
} };
DropdownMenuShortcut.displayName = "DropdownMenuShortcut" DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export { export {
DropdownMenu, DropdownMenu,
@ -195,4 +195,4 @@ export {
DropdownMenuSubContent, DropdownMenuSubContent,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
} };

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from 'react';
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from "@radix-ui/react-slot" import { Slot } from '@radix-ui/react-slot';
import { import {
Controller, Controller,
ControllerProps, ControllerProps,
@ -8,27 +8,27 @@ import {
FieldValues, FieldValues,
FormProvider, FormProvider,
useFormContext, useFormContext,
} from "react-hook-form" } from 'react-hook-form';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { Label } from "@/components/ui/label" import { Label } from '@/components/ui/label';
const Form = FormProvider const Form = FormProvider;
type FormFieldContextValue< type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { > = {
name: TName name: TName;
} };
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue {} as FormFieldContextValue
) );
const FormField = < const FormField = <
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ >({
...props ...props
}: ControllerProps<TFieldValues, TName>) => { }: ControllerProps<TFieldValues, TName>) => {
@ -36,21 +36,21 @@ const FormField = <
<FormFieldContext.Provider value={{ name: props.name }}> <FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} /> <Controller {...props} />
</FormFieldContext.Provider> </FormFieldContext.Provider>
) );
} };
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext) const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext) const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext() const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState) const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) { if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>") throw new Error('useFormField should be used within <FormField>');
} }
const { id } = itemContext const { id } = itemContext;
return { return {
id, id,
@ -59,53 +59,54 @@ const useFormField = () => {
formDescriptionId: `${id}-form-item-description`, formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`, formMessageId: `${id}-form-item-message`,
...fieldState, ...fieldState,
} };
} };
type FormItemContextValue = { type FormItemContextValue = {
id: string id: string;
} };
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue {} as FormItemContextValue
) );
const FormItem = React.forwardRef< const FormItem = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const id = React.useId() const id = React.useId();
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} /> <div ref={ref} className={cn('space-y-2', className)} {...props} />
</FormItemContext.Provider> </FormItemContext.Provider>
) );
}) });
FormItem.displayName = "FormItem" FormItem.displayName = 'FormItem';
const FormLabel = React.forwardRef< const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField() const { error, formItemId } = useFormField();
return ( return (
<Label <Label
ref={ref} ref={ref}
className={cn(error && "text-destructive", className)} className={cn(error && 'text-destructive', className)}
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
) );
}) });
FormLabel.displayName = "FormLabel" FormLabel.displayName = 'FormLabel';
const FormControl = React.forwardRef< const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>, React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot> React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => { >(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField() const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return ( return (
<Slot <Slot
@ -119,50 +120,50 @@ const FormControl = React.forwardRef<
aria-invalid={!!error} aria-invalid={!!error}
{...props} {...props}
/> />
) );
}) });
FormControl.displayName = "FormControl" FormControl.displayName = 'FormControl';
const FormDescription = React.forwardRef< const FormDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField() const { formDescriptionId } = useFormField();
return ( return (
<p <p
ref={ref} ref={ref}
id={formDescriptionId} id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)} className={cn('text-sm text-muted-foreground', className)}
{...props} {...props}
/> />
) );
}) });
FormDescription.displayName = "FormDescription" FormDescription.displayName = 'FormDescription';
const FormMessage = React.forwardRef< const FormMessage = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => { >(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children const body = error ? String(error?.message) : children;
if (!body) { if (!body) {
return null return null;
} }
return ( return (
<p <p
ref={ref} ref={ref}
id={formMessageId} id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)} className={cn('text-sm font-medium text-destructive', className)}
{...props} {...props}
> >
{body} {body}
</p> </p>
) );
}) });
FormMessage.displayName = "FormMessage" FormMessage.displayName = 'FormMessage';
export { export {
Form, Form,
@ -172,4 +173,4 @@ export {
FormDescription, FormDescription,
FormMessage, FormMessage,
FormField, FormField,
} };

View File

@ -1,27 +1,27 @@
import * as React from "react" import * as React from 'react';
import * as HoverCardPrimitive from "@radix-ui/react-hover-card" import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const HoverCard = HoverCardPrimitive.Root const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef< const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>, React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content <HoverCardPrimitive.Content
ref={ref} ref={ref}
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 'z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className className
)} )}
{...props} {...props}
/> />
)) ));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent } export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@ -1,8 +1,8 @@
import * as React from "react" import * as React from 'react';
import { OTPInput, OTPInputContext } from "input-otp" import { OTPInput, OTPInputContext } from 'input-otp';
import { Dot } from "lucide-react" import { Dot } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const InputOTP = React.forwardRef< const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>, React.ElementRef<typeof OTPInput>,
@ -11,36 +11,40 @@ const InputOTP = React.forwardRef<
<OTPInput <OTPInput
ref={ref} ref={ref}
containerClassName={cn( containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50", 'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName containerClassName
)} )}
className={cn("disabled:cursor-not-allowed", className)} className={cn('disabled:cursor-not-allowed', className)}
{...props} {...props}
/> />
)) ));
InputOTP.displayName = "InputOTP" InputOTP.displayName = 'InputOTP';
const InputOTPGroup = React.forwardRef< const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">, React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<"div"> React.ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} /> <div ref={ref} className={cn('flex items-center', className)} {...props} />
)) ));
InputOTPGroup.displayName = "InputOTPGroup" InputOTPGroup.displayName = 'InputOTPGroup';
const InputOTPSlot = React.forwardRef< const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">, React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<"div"> & { index: number } React.ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => { >(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext) const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] || {
char: '',
hasFakeCaret: false,
isActive: false,
};
return ( return (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", 'relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md',
isActive && "z-10 ring-2 ring-ring ring-offset-background", isActive && 'z-10 ring-2 ring-ring ring-offset-background',
className className
)} )}
{...props} {...props}
@ -52,18 +56,18 @@ const InputOTPSlot = React.forwardRef<
</div> </div>
)} )}
</div> </div>
) );
}) });
InputOTPSlot.displayName = "InputOTPSlot" InputOTPSlot.displayName = 'InputOTPSlot';
const InputOTPSeparator = React.forwardRef< const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">, React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<"div"> React.ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => ( >(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}> <div ref={ref} role="separator" {...props}>
<Dot /> <Dot />
</div> </div>
)) ));
InputOTPSeparator.displayName = "InputOTPSeparator" InputOTPSeparator.displayName = 'InputOTPSeparator';
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@ -1,22 +1,22 @@
import * as React from "react" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
return ( return (
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className className
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
) );
} }
) );
Input.displayName = "Input" Input.displayName = 'Input';
export { Input } export { Input };

View File

@ -1,12 +1,12 @@
import * as React from "react" import * as React from 'react';
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const labelVariants = cva( const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
) );
const Label = React.forwardRef< const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
@ -18,7 +18,7 @@ const Label = React.forwardRef<
className={cn(labelVariants(), className)} className={cn(labelVariants(), className)}
{...props} {...props}
/> />
)) ));
Label.displayName = LabelPrimitive.Root.displayName Label.displayName = LabelPrimitive.Root.displayName;
export { Label } export { Label };

View File

@ -1,18 +1,18 @@
import * as React from "react" import * as React from 'react';
import * as MenubarPrimitive from "@radix-ui/react-menubar" import * as MenubarPrimitive from '@radix-ui/react-menubar';
import { Check, ChevronRight, Circle } from "lucide-react" import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const MenubarMenu = MenubarPrimitive.Menu const MenubarMenu = MenubarPrimitive.Menu;
const MenubarGroup = MenubarPrimitive.Group const MenubarGroup = MenubarPrimitive.Group;
const MenubarPortal = MenubarPrimitive.Portal const MenubarPortal = MenubarPrimitive.Portal;
const MenubarSub = MenubarPrimitive.Sub const MenubarSub = MenubarPrimitive.Sub;
const MenubarRadioGroup = MenubarPrimitive.RadioGroup const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
const Menubar = React.forwardRef< const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>, React.ElementRef<typeof MenubarPrimitive.Root>,
@ -21,13 +21,13 @@ const Menubar = React.forwardRef<
<MenubarPrimitive.Root <MenubarPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1", 'flex h-10 items-center space-x-1 rounded-md border bg-background p-1',
className className
)} )}
{...props} {...props}
/> />
)) ));
Menubar.displayName = MenubarPrimitive.Root.displayName Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef< const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>, React.ElementRef<typeof MenubarPrimitive.Trigger>,
@ -36,25 +36,25 @@ const MenubarTrigger = React.forwardRef<
<MenubarPrimitive.Trigger <MenubarPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", 'flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
className className
)} )}
{...props} {...props}
/> />
)) ));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef< const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>, React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & { React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, children, ...props }, ref) => ( >(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger <MenubarPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", 'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && "pl-8", inset && 'pl-8',
className className
)} )}
{...props} {...props}
@ -62,8 +62,8 @@ const MenubarSubTrigger = React.forwardRef<
{children} {children}
<ChevronRight className="ml-auto h-4 w-4" /> <ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger> </MenubarPrimitive.SubTrigger>
)) ));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef< const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>, React.ElementRef<typeof MenubarPrimitive.SubContent>,
@ -72,20 +72,20 @@ const MenubarSubContent = React.forwardRef<
<MenubarPrimitive.SubContent <MenubarPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className className
)} )}
{...props} {...props}
/> />
)) ));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef< const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>, React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content> React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>( >(
( (
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, { className, align = 'start', alignOffset = -4, sideOffset = 8, ...props },
ref ref
) => ( ) => (
<MenubarPrimitive.Portal> <MenubarPrimitive.Portal>
@ -95,33 +95,33 @@ const MenubarContent = React.forwardRef<
alignOffset={alignOffset} alignOffset={alignOffset}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className className
)} )}
{...props} {...props}
/> />
</MenubarPrimitive.Portal> </MenubarPrimitive.Portal>
) )
) );
MenubarContent.displayName = MenubarPrimitive.Content.displayName MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef< const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>, React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & { React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item <MenubarPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && "pl-8", inset && 'pl-8',
className className
)} )}
{...props} {...props}
/> />
)) ));
MenubarItem.displayName = MenubarPrimitive.Item.displayName MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef< const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>, React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
@ -130,7 +130,7 @@ const MenubarCheckboxItem = React.forwardRef<
<MenubarPrimitive.CheckboxItem <MenubarPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className className
)} )}
checked={checked} checked={checked}
@ -143,8 +143,8 @@ const MenubarCheckboxItem = React.forwardRef<
</span> </span>
{children} {children}
</MenubarPrimitive.CheckboxItem> </MenubarPrimitive.CheckboxItem>
)) ));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef< const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>, React.ElementRef<typeof MenubarPrimitive.RadioItem>,
@ -153,7 +153,7 @@ const MenubarRadioItem = React.forwardRef<
<MenubarPrimitive.RadioItem <MenubarPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className className
)} )}
{...props} {...props}
@ -165,26 +165,26 @@ const MenubarRadioItem = React.forwardRef<
</span> </span>
{children} {children}
</MenubarPrimitive.RadioItem> </MenubarPrimitive.RadioItem>
)) ));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef< const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>, React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & { React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean inset?: boolean;
} }
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label <MenubarPrimitive.Label
ref={ref} ref={ref}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-semibold", 'px-2 py-1.5 text-sm font-semibold',
inset && "pl-8", inset && 'pl-8',
className className
)} )}
{...props} {...props}
/> />
)) ));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef< const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>, React.ElementRef<typeof MenubarPrimitive.Separator>,
@ -192,11 +192,11 @@ const MenubarSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator <MenubarPrimitive.Separator
ref={ref} ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props} {...props}
/> />
)) ));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({ const MenubarShortcut = ({
className, className,
@ -205,14 +205,14 @@ const MenubarShortcut = ({
return ( return (
<span <span
className={cn( className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground", 'ml-auto text-xs tracking-widest text-muted-foreground',
className className
)} )}
{...props} {...props}
/> />
) );
} };
MenubarShortcut.displayname = "MenubarShortcut" MenubarShortcut.displayname = 'MenubarShortcut';
export { export {
Menubar, Menubar,
@ -231,4 +231,4 @@ export {
MenubarGroup, MenubarGroup,
MenubarSub, MenubarSub,
MenubarShortcut, MenubarShortcut,
} };

View File

@ -1,9 +1,9 @@
import * as React from "react" import * as React from 'react';
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import { cva } from "class-variance-authority" import { cva } from 'class-variance-authority';
import { ChevronDown } from "lucide-react" import { ChevronDown } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const NavigationMenu = React.forwardRef< const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>, React.ElementRef<typeof NavigationMenuPrimitive.Root>,
@ -12,7 +12,7 @@ const NavigationMenu = React.forwardRef<
<NavigationMenuPrimitive.Root <NavigationMenuPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center", 'relative z-10 flex max-w-max flex-1 items-center justify-center',
className className
)} )}
{...props} {...props}
@ -20,8 +20,8 @@ const NavigationMenu = React.forwardRef<
{children} {children}
<NavigationMenuViewport /> <NavigationMenuViewport />
</NavigationMenuPrimitive.Root> </NavigationMenuPrimitive.Root>
)) ));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef< const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>, React.ElementRef<typeof NavigationMenuPrimitive.List>,
@ -30,19 +30,19 @@ const NavigationMenuList = React.forwardRef<
<NavigationMenuPrimitive.List <NavigationMenuPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1", 'group flex flex-1 list-none items-center justify-center space-x-1',
className className
)} )}
{...props} {...props}
/> />
)) ));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva( const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50" 'group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50'
) );
const NavigationMenuTrigger = React.forwardRef< const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>, React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
@ -50,17 +50,17 @@ const NavigationMenuTrigger = React.forwardRef<
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger <NavigationMenuPrimitive.Trigger
ref={ref} ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)} className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props} {...props}
> >
{children}{" "} {children}{' '}
<ChevronDown <ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180" className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true" aria-hidden="true"
/> />
</NavigationMenuPrimitive.Trigger> </NavigationMenuPrimitive.Trigger>
)) ));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef< const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>, React.ElementRef<typeof NavigationMenuPrimitive.Content>,
@ -69,33 +69,33 @@ const NavigationMenuContent = React.forwardRef<
<NavigationMenuPrimitive.Content <NavigationMenuPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ", 'left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ',
className className
)} )}
{...props} {...props}
/> />
)) ));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef< const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>, React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport> React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}> <div className={cn('absolute left-0 top-full flex justify-center')}>
<NavigationMenuPrimitive.Viewport <NavigationMenuPrimitive.Viewport
className={cn( className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]", 'origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]',
className className
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
</div> </div>
)) ));
NavigationMenuViewport.displayName = NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef< const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>, React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
@ -104,16 +104,16 @@ const NavigationMenuIndicator = React.forwardRef<
<NavigationMenuPrimitive.Indicator <NavigationMenuPrimitive.Indicator
ref={ref} ref={ref}
className={cn( className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in", 'top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in',
className className
)} )}
{...props} {...props}
> >
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" /> <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator> </NavigationMenuPrimitive.Indicator>
)) ));
NavigationMenuIndicator.displayName = NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName NavigationMenuPrimitive.Indicator.displayName;
export { export {
NavigationMenu, NavigationMenu,
@ -124,4 +124,4 @@ export {
NavigationMenuLink, NavigationMenuLink,
NavigationMenuIndicator, NavigationMenuIndicator,
NavigationMenuViewport, NavigationMenuViewport,
} };

View File

@ -1,63 +1,64 @@
import * as React from "react" import * as React from 'react';
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { ButtonProps, buttonVariants } from "@/components/ui/button" import { ButtonProps } from '@/components/ui/button';
import { buttonVariants } from '@/components/ui/button-variants';
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav <nav
role="navigation" role="navigation"
aria-label="pagination" aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)} className={cn('mx-auto flex w-full justify-center', className)}
{...props} {...props}
/> />
) );
Pagination.displayName = "Pagination" Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef< const PaginationContent = React.forwardRef<
HTMLUListElement, HTMLUListElement,
React.ComponentProps<"ul"> React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ul <ul
ref={ref} ref={ref}
className={cn("flex flex-row items-center gap-1", className)} className={cn('flex flex-row items-center gap-1', className)}
{...props} {...props}
/> />
)) ));
PaginationContent.displayName = "PaginationContent" PaginationContent.displayName = 'PaginationContent';
const PaginationItem = React.forwardRef< const PaginationItem = React.forwardRef<
HTMLLIElement, HTMLLIElement,
React.ComponentProps<"li"> React.ComponentProps<'li'>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} /> <li ref={ref} className={cn('', className)} {...props} />
)) ));
PaginationItem.displayName = "PaginationItem" PaginationItem.displayName = 'PaginationItem';
type PaginationLinkProps = { type PaginationLinkProps = {
isActive?: boolean isActive?: boolean;
} & Pick<ButtonProps, "size"> & } & Pick<ButtonProps, 'size'> &
React.ComponentProps<"a"> React.ComponentProps<'a'>;
const PaginationLink = ({ const PaginationLink = ({
className, className,
isActive, isActive,
size = "icon", size = 'icon',
...props ...props
}: PaginationLinkProps) => ( }: PaginationLinkProps) => (
<a <a
aria-current={isActive ? "page" : undefined} aria-current={isActive ? 'page' : undefined}
className={cn( className={cn(
buttonVariants({ buttonVariants({
variant: isActive ? "outline" : "ghost", variant: isActive ? 'outline' : 'ghost',
size, size,
}), }),
className className
)} )}
{...props} {...props}
/> />
) );
PaginationLink.displayName = "PaginationLink" PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({ const PaginationPrevious = ({
className, className,
@ -66,14 +67,14 @@ const PaginationPrevious = ({
<PaginationLink <PaginationLink
aria-label="Go to previous page" aria-label="Go to previous page"
size="default" size="default"
className={cn("gap-1 pl-2.5", className)} className={cn('gap-1 pl-2.5', className)}
{...props} {...props}
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
<span>Previous</span> <span>Previous</span>
</PaginationLink> </PaginationLink>
) );
PaginationPrevious.displayName = "PaginationPrevious" PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({ const PaginationNext = ({
className, className,
@ -82,29 +83,29 @@ const PaginationNext = ({
<PaginationLink <PaginationLink
aria-label="Go to next page" aria-label="Go to next page"
size="default" size="default"
className={cn("gap-1 pr-2.5", className)} className={cn('gap-1 pr-2.5', className)}
{...props} {...props}
> >
<span>Next</span> <span>Next</span>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</PaginationLink> </PaginationLink>
) );
PaginationNext.displayName = "PaginationNext" PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({ const PaginationEllipsis = ({
className, className,
...props ...props
}: React.ComponentProps<"span">) => ( }: React.ComponentProps<'span'>) => (
<span <span
aria-hidden aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)} className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props} {...props}
> >
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span> <span className="sr-only">More pages</span>
</span> </span>
) );
PaginationEllipsis.displayName = "PaginationEllipsis" PaginationEllipsis.displayName = 'PaginationEllipsis';
export { export {
Pagination, Pagination,
@ -114,4 +115,4 @@ export {
PaginationLink, PaginationLink,
PaginationNext, PaginationNext,
PaginationPrevious, PaginationPrevious,
} };

View File

@ -1,29 +1,29 @@
import * as React from "react" import * as React from 'react';
import * as PopoverPrimitive from "@radix-ui/react-popover" import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef< const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal> <PopoverPrimitive.Portal>
<PopoverPrimitive.Content <PopoverPrimitive.Content
ref={ref} ref={ref}
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className className
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
)) ));
PopoverContent.displayName = PopoverPrimitive.Content.displayName PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent } export { Popover, PopoverTrigger, PopoverContent };

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from 'react';
import * as ProgressPrimitive from "@radix-ui/react-progress" import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Progress = React.forwardRef< const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>, React.ElementRef<typeof ProgressPrimitive.Root>,
@ -10,7 +10,7 @@ const Progress = React.forwardRef<
<ProgressPrimitive.Root <ProgressPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary", 'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className className
)} )}
{...props} {...props}
@ -20,7 +20,7 @@ const Progress = React.forwardRef<
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
)) ));
Progress.displayName = ProgressPrimitive.Root.displayName Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress } export { Progress };

View File

@ -1,8 +1,8 @@
import * as React from "react" import * as React from 'react';
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { Circle } from "lucide-react" import { Circle } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const RadioGroup = React.forwardRef< const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>, React.ElementRef<typeof RadioGroupPrimitive.Root>,
@ -10,13 +10,13 @@ const RadioGroup = React.forwardRef<
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
return ( return (
<RadioGroupPrimitive.Root <RadioGroupPrimitive.Root
className={cn("grid gap-2", className)} className={cn('grid gap-2', className)}
{...props} {...props}
ref={ref} ref={ref}
/> />
) );
}) });
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef< const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>, React.ElementRef<typeof RadioGroupPrimitive.Item>,
@ -26,7 +26,7 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item <RadioGroupPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", 'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className className
)} )}
{...props} {...props}
@ -35,8 +35,8 @@ const RadioGroupItem = React.forwardRef<
<Circle className="h-2.5 w-2.5 fill-current text-current" /> <Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
) );
}) });
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem } export { RadioGroup, RadioGroupItem };

View File

@ -1,11 +1,29 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import {
import { TrendingUp, Clock, Shield, UserCheck, MessageSquare, ThumbsUp } from 'lucide-react'; Tooltip,
import { RelevanceScoreDetails } from '@/lib/forum/relevance'; TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
TrendingUp,
Clock,
Shield,
UserCheck,
MessageSquare,
ThumbsUp,
} from 'lucide-react';
import { RelevanceScoreDetails } from '@/types/forum';
interface RelevanceIndicatorProps { interface RelevanceIndicatorProps {
score: number; score: number;
@ -15,7 +33,13 @@ interface RelevanceIndicatorProps {
showTooltip?: boolean; showTooltip?: boolean;
} }
export function RelevanceIndicator({ score, details, type, className, showTooltip = false }: RelevanceIndicatorProps) { export function RelevanceIndicator({
score,
details,
type,
className,
showTooltip = false,
}: RelevanceIndicatorProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const getScoreColor = (score: number) => { const getScoreColor = (score: number) => {
@ -31,20 +55,28 @@ export function RelevanceIndicator({ score, details, type, className, showToolti
const createTooltipContent = () => { const createTooltipContent = () => {
if (!details) return `Relevance Score: ${formatScore(score)}`; if (!details) return `Relevance Score: ${formatScore(score)}`;
return ( return (
<div className="text-xs space-y-1"> <div className="text-xs space-y-1">
<div className="font-semibold">Relevance Score: {formatScore(score)}</div> <div className="font-semibold">
Relevance Score: {formatScore(score)}
</div>
<div>Base: {formatScore(details.baseScore)}</div> <div>Base: {formatScore(details.baseScore)}</div>
<div>Engagement: +{formatScore(details.engagementScore)}</div> <div>Engagement: +{formatScore(details.engagementScore)}</div>
{details.authorVerificationBonus > 0 && ( {details.authorVerificationBonus > 0 && (
<div>Author Bonus: +{formatScore(details.authorVerificationBonus)}</div> <div>
Author Bonus: +{formatScore(details.authorVerificationBonus)}
</div>
)} )}
{details.verifiedUpvoteBonus > 0 && ( {details.verifiedUpvoteBonus > 0 && (
<div>Verified Upvotes: +{formatScore(details.verifiedUpvoteBonus)}</div> <div>
Verified Upvotes: +{formatScore(details.verifiedUpvoteBonus)}
</div>
)} )}
{details.verifiedCommenterBonus > 0 && ( {details.verifiedCommenterBonus > 0 && (
<div>Verified Commenters: +{formatScore(details.verifiedCommenterBonus)}</div> <div>
Verified Commenters: +{formatScore(details.verifiedCommenterBonus)}
</div>
)} )}
<div>Time Decay: ×{details.timeDecayMultiplier.toFixed(2)}</div> <div>Time Decay: ×{details.timeDecayMultiplier.toFixed(2)}</div>
{details.isModerated && ( {details.isModerated && (
@ -55,10 +87,10 @@ export function RelevanceIndicator({ score, details, type, className, showToolti
}; };
const badge = ( const badge = (
<Badge <Badge
variant="secondary" variant="secondary"
className={`cursor-pointer ${getScoreColor(score)} text-white ${className}`} className={`cursor-pointer ${getScoreColor(score)} text-white ${className}`}
title={showTooltip ? undefined : "Click to see relevance score details"} title={showTooltip ? undefined : 'Click to see relevance score details'}
> >
<TrendingUp className="w-3 h-3 mr-1" /> <TrendingUp className="w-3 h-3 mr-1" />
{formatScore(score)} {formatScore(score)}
@ -72,9 +104,7 @@ export function RelevanceIndicator({ score, details, type, className, showToolti
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<DialogTrigger asChild> <DialogTrigger asChild>{badge}</DialogTrigger>
{badge}
</DialogTrigger>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-xs"> <TooltipContent side="top" className="max-w-xs">
{createTooltipContent()} {createTooltipContent()}
@ -82,124 +112,157 @@ export function RelevanceIndicator({ score, details, type, className, showToolti
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) : ( ) : (
<DialogTrigger asChild> <DialogTrigger asChild>{badge}</DialogTrigger>
{badge}
</DialogTrigger>
)} )}
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5" />
Relevance Score Details
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Final Score: {formatScore(score)}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">
{type.charAt(0).toUpperCase() + type.slice(1)} relevance score
</div>
</CardContent>
</Card>
{details && ( <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<> <DialogHeader>
<Card> <DialogTitle className="flex items-center gap-2">
<CardHeader> <TrendingUp className="w-5 h-5" />
<CardTitle className="text-base">Score Breakdown</CardTitle> Relevance Score Details
</CardHeader> </DialogTitle>
<CardContent className="space-y-3"> </DialogHeader>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-blue-500 rounded"></div>
<span>Base Score</span>
</div>
<span className="font-mono">{formatScore(details.baseScore)}</span>
</div>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<ThumbsUp className="w-4 h-4 text-green-500" />
<span>Engagement ({details.upvotes} upvotes, {details.comments} comments)</span>
</div>
<span className="font-mono">+{formatScore(details.engagementScore)}</span>
</div>
{details.authorVerificationBonus > 0 && (
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<UserCheck className="w-4 h-4 text-purple-500" />
<span>Author Verification Bonus</span>
</div>
<span className="font-mono">+{formatScore(details.authorVerificationBonus)}</span>
</div>
)}
{details.verifiedUpvoteBonus > 0 && (
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-indigo-500" />
<span>Verified Upvotes ({details.verifiedUpvotes})</span>
</div>
<span className="font-mono">+{formatScore(details.verifiedUpvoteBonus)}</span>
</div>
)}
{details.verifiedCommenterBonus > 0 && (
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-teal-500" />
<span>Verified Commenters ({details.verifiedCommenters})</span>
</div>
<span className="font-mono">+{formatScore(details.verifiedCommenterBonus)}</span>
</div>
)}
<Separator />
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-orange-500" />
<span>Time Decay ({details.daysOld.toFixed(1)} days old)</span>
</div>
<span className="font-mono">×{details.timeDecayMultiplier.toFixed(3)}</span>
</div>
{details.isModerated && (
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-red-500" />
<span>Moderation Penalty</span>
</div>
<span className="font-mono">×{details.moderationPenalty.toFixed(1)}</span>
</div>
)}
</CardContent>
</Card>
<Card> <div className="space-y-4">
<CardHeader> <Card>
<CardTitle className="text-base">User Status</CardTitle> <CardHeader>
</CardHeader> <CardTitle className="text-lg">
<CardContent> Final Score: {formatScore(score)}
<div className="flex items-center gap-2"> </CardTitle>
<UserCheck className={`w-4 h-4 ${details.isVerified ? 'text-green-500' : 'text-gray-400'}`} /> </CardHeader>
<span className={details.isVerified ? 'text-green-600' : 'text-gray-500'}> <CardContent>
{details.isVerified ? 'Verified User' : 'Unverified User'} <div className="text-sm text-muted-foreground">
</span> {type.charAt(0).toUpperCase() + type.slice(1)} relevance score
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</>
)} {details && (
</div> <>
</DialogContent> <Card>
</Dialog> <CardHeader>
<CardTitle className="text-base">Score Breakdown</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-blue-500 rounded"></div>
<span>Base Score</span>
</div>
<span className="font-mono">
{formatScore(details.baseScore)}
</span>
</div>
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<ThumbsUp className="w-4 h-4 text-green-500" />
<span>
Engagement ({details.upvotes} upvotes,{' '}
{details.comments} comments)
</span>
</div>
<span className="font-mono">
+{formatScore(details.engagementScore)}
</span>
</div>
{details.authorVerificationBonus > 0 && (
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<UserCheck className="w-4 h-4 text-purple-500" />
<span>Author Verification Bonus</span>
</div>
<span className="font-mono">
+{formatScore(details.authorVerificationBonus)}
</span>
</div>
)}
{details.verifiedUpvoteBonus > 0 && (
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-indigo-500" />
<span>
Verified Upvotes ({details.verifiedUpvotes})
</span>
</div>
<span className="font-mono">
+{formatScore(details.verifiedUpvoteBonus)}
</span>
</div>
)}
{details.verifiedCommenterBonus > 0 && (
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-teal-500" />
<span>
Verified Commenters ({details.verifiedCommenters})
</span>
</div>
<span className="font-mono">
+{formatScore(details.verifiedCommenterBonus)}
</span>
</div>
)}
<Separator />
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-orange-500" />
<span>
Time Decay ({details.daysOld.toFixed(1)} days old)
</span>
</div>
<span className="font-mono">
×{details.timeDecayMultiplier.toFixed(3)}
</span>
</div>
{details.isModerated && (
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-red-500" />
<span>Moderation Penalty</span>
</div>
<span className="font-mono">
×{details.moderationPenalty.toFixed(1)}
</span>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">User Status</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<UserCheck
className={`w-4 h-4 ${details.isVerified ? 'text-green-500' : 'text-gray-400'}`}
/>
<span
className={
details.isVerified
? 'text-green-600'
: 'text-gray-500'
}
>
{details.isVerified
? 'Verified User'
: 'Unverified User'}
</span>
</div>
</CardContent>
</Card>
</>
)}
</div>
</DialogContent>
</Dialog>
</> </>
); );
} }

View File

@ -1,7 +1,7 @@
import { GripVertical } from "lucide-react" import { GripVertical } from 'lucide-react';
import * as ResizablePrimitive from "react-resizable-panels" import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const ResizablePanelGroup = ({ const ResizablePanelGroup = ({
className, className,
@ -9,25 +9,25 @@ const ResizablePanelGroup = ({
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => ( }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup <ResizablePrimitive.PanelGroup
className={cn( className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col", 'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className className
)} )}
{...props} {...props}
/> />
) );
const ResizablePanel = ResizablePrimitive.Panel const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({ const ResizableHandle = ({
withHandle, withHandle,
className, className,
...props ...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & { }: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean withHandle?: boolean;
}) => ( }) => (
<ResizablePrimitive.PanelResizeHandle <ResizablePrimitive.PanelResizeHandle
className={cn( className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", 'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className className
)} )}
{...props} {...props}
@ -38,6 +38,6 @@ const ResizableHandle = ({
</div> </div>
)} )}
</ResizablePrimitive.PanelResizeHandle> </ResizablePrimitive.PanelResizeHandle>
) );
export { ResizablePanelGroup, ResizablePanel, ResizableHandle } export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from 'react';
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef< const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>, React.ElementRef<typeof ScrollAreaPrimitive.Root>,
@ -9,7 +9,7 @@ const ScrollArea = React.forwardRef<
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root <ScrollAreaPrimitive.Root
ref={ref} ref={ref}
className={cn("relative overflow-hidden", className)} className={cn('relative overflow-hidden', className)}
{...props} {...props}
> >
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
@ -18,29 +18,29 @@ const ScrollArea = React.forwardRef<
<ScrollBar /> <ScrollBar />
<ScrollAreaPrimitive.Corner /> <ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root> </ScrollAreaPrimitive.Root>
)) ));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef< const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => ( >(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar <ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref} ref={ref}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"flex touch-none select-none transition-colors", 'flex touch-none select-none transition-colors',
orientation === "vertical" && orientation === 'vertical' &&
"h-full w-2.5 border-l border-l-transparent p-[1px]", 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === "horizontal" && orientation === 'horizontal' &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]", 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className className
)} )}
{...props} {...props}
> >
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar> </ScrollAreaPrimitive.ScrollAreaScrollbar>
)) ));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar } export { ScrollArea, ScrollBar };

View File

@ -1,14 +1,14 @@
import * as React from "react" import * as React from 'react';
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from "lucide-react" import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef< const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>, React.ElementRef<typeof SelectPrimitive.Trigger>,
@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className className
)} )}
{...props} {...props}
@ -27,8 +27,8 @@ const SelectTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 opacity-50" /> <ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)) ));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef< const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@ -37,15 +37,15 @@ const SelectScrollUpButton = React.forwardRef<
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", 'flex cursor-default items-center justify-center py-1',
className className
)} )}
{...props} {...props}
> >
<ChevronUp className="h-4 w-4" /> <ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
)) ));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef< const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@ -54,28 +54,28 @@ const SelectScrollDownButton = React.forwardRef<
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
ref={ref} ref={ref}
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", 'flex cursor-default items-center justify-center py-1',
className className
)} )}
{...props} {...props}
> >
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
)) ));
SelectScrollDownButton.displayName = SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef< const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>, React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => ( >(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === "popper" && position === 'popper' &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className className
)} )}
position={position} position={position}
@ -84,9 +84,9 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
"p-1", 'p-1',
position === "popper" && position === 'popper' &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)} )}
> >
{children} {children}
@ -94,8 +94,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
)) ));
SelectContent.displayName = SelectPrimitive.Content.displayName SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef< const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>, React.ElementRef<typeof SelectPrimitive.Label>,
@ -103,11 +103,11 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Label <SelectPrimitive.Label
ref={ref} ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props} {...props}
/> />
)) ));
SelectLabel.displayName = SelectPrimitive.Label.displayName SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef< const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>, React.ElementRef<typeof SelectPrimitive.Item>,
@ -116,7 +116,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className className
)} )}
{...props} {...props}
@ -129,8 +129,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
)) ));
SelectItem.displayName = SelectPrimitive.Item.displayName SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef< const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>, React.ElementRef<typeof SelectPrimitive.Separator>,
@ -138,11 +138,11 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Separator <SelectPrimitive.Separator
ref={ref} ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)} className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props} {...props}
/> />
)) ));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export { export {
Select, Select,
@ -155,4 +155,4 @@ export {
SelectSeparator, SelectSeparator,
SelectScrollUpButton, SelectScrollUpButton,
SelectScrollDownButton, SelectScrollDownButton,
} };

View File

@ -1,14 +1,14 @@
import * as React from "react" import * as React from 'react';
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Separator = React.forwardRef< const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>, React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>( >(
( (
{ className, orientation = "horizontal", decorative = true, ...props }, { className, orientation = 'horizontal', decorative = true, ...props },
ref ref
) => ( ) => (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
@ -16,14 +16,14 @@ const Separator = React.forwardRef<
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border", 'shrink-0 bg-border',
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className className
)} )}
{...props} {...props}
/> />
) )
) );
Separator.displayName = SeparatorPrimitive.Root.displayName Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator } export { Separator };

View File

@ -1,17 +1,17 @@
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { X } from "lucide-react" import { X } from 'lucide-react';
import * as React from "react" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Sheet = SheetPrimitive.Root const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef< const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>, React.ElementRef<typeof SheetPrimitive.Overlay>,
@ -19,42 +19,42 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay <SheetPrimitive.Overlay
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", 'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className className
)} )}
{...props} {...props}
ref={ref} ref={ref}
/> />
)) ));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva( const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500", 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{ {
variants: { variants: {
side: { side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom: bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right: right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
}, },
}, },
defaultVariants: { defaultVariants: {
side: "right", side: 'right',
}, },
} }
) );
interface SheetContentProps interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> { } VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef< const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>, React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => ( >(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
<SheetPrimitive.Content <SheetPrimitive.Content
@ -69,8 +69,8 @@ const SheetContent = React.forwardRef<
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
)) ));
SheetContent.displayName = SheetPrimitive.Content.displayName SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ const SheetHeader = ({
className, className,
@ -78,13 +78,13 @@ const SheetHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col space-y-2 text-center sm:text-left", 'flex flex-col space-y-2 text-center sm:text-left',
className className
)} )}
{...props} {...props}
/> />
) );
SheetHeader.displayName = "SheetHeader" SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({ const SheetFooter = ({
className, className,
@ -92,13 +92,13 @@ const SheetFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => ( }: React.HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className className
)} )}
{...props} {...props}
/> />
) );
SheetFooter.displayName = "SheetFooter" SheetFooter.displayName = 'SheetFooter';
const SheetTitle = React.forwardRef< const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>, React.ElementRef<typeof SheetPrimitive.Title>,
@ -106,11 +106,11 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Title <SheetPrimitive.Title
ref={ref} ref={ref}
className={cn("text-lg font-semibold text-foreground", className)} className={cn('text-lg font-semibold text-foreground', className)}
{...props} {...props}
/> />
)) ));
SheetTitle.displayName = SheetPrimitive.Title.displayName SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef< const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>, React.ElementRef<typeof SheetPrimitive.Description>,
@ -118,14 +118,21 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SheetPrimitive.Description <SheetPrimitive.Description
ref={ref} ref={ref}
className={cn("text-sm text-muted-foreground", className)} className={cn('text-sm text-muted-foreground', className)}
{...props} {...props}
/> />
)) ));
SheetDescription.displayName = SheetPrimitive.Description.displayName SheetDescription.displayName = SheetPrimitive.Description.displayName;
export { export {
Sheet, SheetClose, Sheet,
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger SheetClose,
} SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};

View File

@ -1,56 +1,56 @@
import * as React from "react" import * as React from 'react';
import { Slot } from "@radix-ui/react-slot" import { Slot } from '@radix-ui/react-slot';
import { VariantProps, cva } from "class-variance-authority" import { VariantProps, cva } from 'class-variance-authority';
import { PanelLeft } from "lucide-react" import { PanelLeft } from 'lucide-react';
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { Button } from "@/components/ui/button" import { Button } from '@/components/ui/button';
import { Input } from "@/components/ui/input" import { Input } from '@/components/ui/input';
import { Separator } from "@/components/ui/separator" import { Separator } from '@/components/ui/separator';
import { Sheet, SheetContent } from "@/components/ui/sheet" import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from '@/components/ui/skeleton';
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from '@/components/ui/tooltip';
const SIDEBAR_COOKIE_NAME = "sidebar:state" const SIDEBAR_COOKIE_NAME = 'sidebar:state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH = '16rem';
const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_WIDTH_ICON = '3rem';
const SIDEBAR_KEYBOARD_SHORTCUT = "b" const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
type SidebarContext = { type SidebarContext = {
state: "expanded" | "collapsed" state: 'expanded' | 'collapsed';
open: boolean open: boolean;
setOpen: (open: boolean) => void setOpen: (open: boolean) => void;
openMobile: boolean openMobile: boolean;
setOpenMobile: (open: boolean) => void setOpenMobile: (open: boolean) => void;
isMobile: boolean isMobile: boolean;
toggleSidebar: () => void toggleSidebar: () => void;
} };
const SidebarContext = React.createContext<SidebarContext | null>(null) const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() { function useSidebar() {
const context = React.useContext(SidebarContext) const context = React.useContext(SidebarContext);
if (!context) { if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.") throw new Error('useSidebar must be used within a SidebarProvider.');
} }
return context return context;
} }
const SidebarProvider = React.forwardRef< const SidebarProvider = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & { React.ComponentProps<'div'> & {
defaultOpen?: boolean defaultOpen?: boolean;
open?: boolean open?: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
} }
>( >(
( (
@ -65,34 +65,32 @@ const SidebarProvider = React.forwardRef<
}, },
ref ref
) => { ) => {
const isMobile = useIsMobile() const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false) const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar. // This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component. // We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen) const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open const open = openProp ?? _open;
const setOpen = React.useCallback( const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => { (value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) { if (setOpenProp) {
setOpenProp(openState) setOpenProp(openState);
} else { } else {
_setOpen(openState) _setOpen(openState);
} }
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, },
[setOpenProp, open] [setOpenProp, open]
) );
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => { const toggleSidebar = React.useCallback(() => {
return isMobile return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open);
? setOpenMobile((open) => !open) }, [isMobile, setOpen, setOpenMobile]);
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar. // Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => { React.useEffect(() => {
@ -101,18 +99,18 @@ const SidebarProvider = React.forwardRef<
event.key === SIDEBAR_KEYBOARD_SHORTCUT && event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey) (event.metaKey || event.ctrlKey)
) { ) {
event.preventDefault() event.preventDefault();
toggleSidebar() toggleSidebar();
} }
} };
window.addEventListener("keydown", handleKeyDown) window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]) }, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed". // We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes. // This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed" const state = open ? 'expanded' : 'collapsed';
const contextValue = React.useMemo<SidebarContext>( const contextValue = React.useMemo<SidebarContext>(
() => ({ () => ({
@ -125,7 +123,7 @@ const SidebarProvider = React.forwardRef<
toggleSidebar, toggleSidebar,
}), }),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
) );
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
@ -133,13 +131,13 @@ const SidebarProvider = React.forwardRef<
<div <div
style={ style={
{ {
"--sidebar-width": SIDEBAR_WIDTH, '--sidebar-width': SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON, '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style, ...style,
} as React.CSSProperties } as React.CSSProperties
} }
className={cn( className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", 'group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar',
className className
)} )}
ref={ref} ref={ref}
@ -149,37 +147,37 @@ const SidebarProvider = React.forwardRef<
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarContext.Provider> </SidebarContext.Provider>
) );
} }
) );
SidebarProvider.displayName = "SidebarProvider" SidebarProvider.displayName = 'SidebarProvider';
const Sidebar = React.forwardRef< const Sidebar = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & { React.ComponentProps<'div'> & {
side?: "left" | "right" side?: 'left' | 'right';
variant?: "sidebar" | "floating" | "inset" variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: "offcanvas" | "icon" | "none" collapsible?: 'offcanvas' | 'icon' | 'none';
} }
>( >(
( (
{ {
side = "left", side = 'left',
variant = "sidebar", variant = 'sidebar',
collapsible = "offcanvas", collapsible = 'offcanvas',
className, className,
children, children,
...props ...props
}, },
ref ref
) => { ) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") { if (collapsible === 'none') {
return ( return (
<div <div
className={cn( className={cn(
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", 'flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground',
className className
)} )}
ref={ref} ref={ref}
@ -187,7 +185,7 @@ const Sidebar = React.forwardRef<
> >
{children} {children}
</div> </div>
) );
} }
if (isMobile) { if (isMobile) {
@ -199,7 +197,7 @@ const Sidebar = React.forwardRef<
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={ style={
{ {
"--sidebar-width": SIDEBAR_WIDTH_MOBILE, '--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties } as React.CSSProperties
} }
side={side} side={side}
@ -207,7 +205,7 @@ const Sidebar = React.forwardRef<
<div className="flex h-full w-full flex-col">{children}</div> <div className="flex h-full w-full flex-col">{children}</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) );
} }
return ( return (
@ -215,31 +213,31 @@ const Sidebar = React.forwardRef<
ref={ref} ref={ref}
className="group peer hidden md:block text-sidebar-foreground" className="group peer hidden md:block text-sidebar-foreground"
data-state={state} data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""} data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant} data-variant={variant}
data-side={side} data-side={side}
> >
{/* This is what handles the sidebar gap on desktop */} {/* This is what handles the sidebar gap on desktop */}
<div <div
className={cn( className={cn(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear", 'duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear',
"group-data-[collapsible=offcanvas]:w-0", 'group-data-[collapsible=offcanvas]:w-0',
"group-data-[side=right]:rotate-180", 'group-data-[side=right]:rotate-180',
variant === "floating" || variant === "inset" variant === 'floating' || variant === 'inset'
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]" ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]'
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]" : 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]'
)} )}
/> />
<div <div
className={cn( className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex", 'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
side === "left" side === 'left'
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants. // Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset" variant === 'floating' || variant === 'inset'
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]" ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]'
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l", : 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l',
className className
)} )}
{...props} {...props}
@ -252,16 +250,16 @@ const Sidebar = React.forwardRef<
</div> </div>
</div> </div>
</div> </div>
) );
} }
) );
Sidebar.displayName = "Sidebar" Sidebar.displayName = 'Sidebar';
const SidebarTrigger = React.forwardRef< const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>, React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button> React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => { >(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<Button <Button
@ -269,25 +267,25 @@ const SidebarTrigger = React.forwardRef<
data-sidebar="trigger" data-sidebar="trigger"
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn("h-7 w-7", className)} className={cn('h-7 w-7', className)}
onClick={(event) => { onClick={event => {
onClick?.(event) onClick?.(event);
toggleSidebar() toggleSidebar();
}} }}
{...props} {...props}
> >
<PanelLeft /> <PanelLeft />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
) );
}) });
SidebarTrigger.displayName = "SidebarTrigger" SidebarTrigger.displayName = 'SidebarTrigger';
const SidebarRail = React.forwardRef< const SidebarRail = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<"button"> React.ComponentProps<'button'>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<button <button
@ -298,37 +296,37 @@ const SidebarRail = React.forwardRef<
onClick={toggleSidebar} onClick={toggleSidebar}
title="Toggle Sidebar" title="Toggle Sidebar"
className={cn( className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex", 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex',
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize", '[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize',
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar", 'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar',
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className className
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarRail.displayName = "SidebarRail" SidebarRail.displayName = 'SidebarRail';
const SidebarInset = React.forwardRef< const SidebarInset = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"main"> React.ComponentProps<'main'>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
return ( return (
<main <main
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background", 'relative flex min-h-svh flex-1 flex-col bg-background',
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow", 'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow',
className className
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarInset.displayName = "SidebarInset" SidebarInset.displayName = 'SidebarInset';
const SidebarInput = React.forwardRef< const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>, React.ElementRef<typeof Input>,
@ -339,44 +337,44 @@ const SidebarInput = React.forwardRef<
ref={ref} ref={ref}
data-sidebar="input" data-sidebar="input"
className={cn( className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", 'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring',
className className
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarInput.displayName = "SidebarInput" SidebarInput.displayName = 'SidebarInput';
const SidebarHeader = React.forwardRef< const SidebarHeader = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> React.ComponentProps<'div'>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
return ( return (
<div <div
ref={ref} ref={ref}
data-sidebar="header" data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)} className={cn('flex flex-col gap-2 p-2', className)}
{...props} {...props}
/> />
) );
}) });
SidebarHeader.displayName = "SidebarHeader" SidebarHeader.displayName = 'SidebarHeader';
const SidebarFooter = React.forwardRef< const SidebarFooter = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> React.ComponentProps<'div'>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
return ( return (
<div <div
ref={ref} ref={ref}
data-sidebar="footer" data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)} className={cn('flex flex-col gap-2 p-2', className)}
{...props} {...props}
/> />
) );
}) });
SidebarFooter.displayName = "SidebarFooter" SidebarFooter.displayName = 'SidebarFooter';
const SidebarSeparator = React.forwardRef< const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>, React.ElementRef<typeof Separator>,
@ -386,173 +384,173 @@ const SidebarSeparator = React.forwardRef<
<Separator <Separator
ref={ref} ref={ref}
data-sidebar="separator" data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)} className={cn('mx-2 w-auto bg-sidebar-border', className)}
{...props} {...props}
/> />
) );
}) });
SidebarSeparator.displayName = "SidebarSeparator" SidebarSeparator.displayName = 'SidebarSeparator';
const SidebarContent = React.forwardRef< const SidebarContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> React.ComponentProps<'div'>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
return ( return (
<div <div
ref={ref} ref={ref}
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className className
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarContent.displayName = "SidebarContent" SidebarContent.displayName = 'SidebarContent';
const SidebarGroup = React.forwardRef< const SidebarGroup = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> React.ComponentProps<'div'>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
return ( return (
<div <div
ref={ref} ref={ref}
data-sidebar="group" data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)} className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props} {...props}
/> />
) );
}) });
SidebarGroup.displayName = "SidebarGroup" SidebarGroup.displayName = 'SidebarGroup';
const SidebarGroupLabel = React.forwardRef< const SidebarGroupLabel = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean } React.ComponentProps<'div'> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => { >(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div" const Comp = asChild ? Slot : 'div';
return ( return (
<Comp <Comp
ref={ref} ref={ref}
data-sidebar="group-label" data-sidebar="group-label"
className={cn( className={cn(
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", 'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className className
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarGroupLabel.displayName = "SidebarGroupLabel" SidebarGroupLabel.displayName = 'SidebarGroupLabel';
const SidebarGroupAction = React.forwardRef< const SidebarGroupAction = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean } React.ComponentProps<'button'> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => { >(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : 'button';
return ( return (
<Comp <Comp
ref={ref} ref={ref}
data-sidebar="group-action" data-sidebar="group-action"
className={cn( className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", 'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden", 'after:absolute after:-inset-2 after:md:hidden',
"group-data-[collapsible=icon]:hidden", 'group-data-[collapsible=icon]:hidden',
className className
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarGroupAction.displayName = "SidebarGroupAction" SidebarGroupAction.displayName = 'SidebarGroupAction';
const SidebarGroupContent = React.forwardRef< const SidebarGroupContent = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> React.ComponentProps<'div'>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
data-sidebar="group-content" data-sidebar="group-content"
className={cn("w-full text-sm", className)} className={cn('w-full text-sm', className)}
{...props} {...props}
/> />
)) ));
SidebarGroupContent.displayName = "SidebarGroupContent" SidebarGroupContent.displayName = 'SidebarGroupContent';
const SidebarMenu = React.forwardRef< const SidebarMenu = React.forwardRef<
HTMLUListElement, HTMLUListElement,
React.ComponentProps<"ul"> React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ul <ul
ref={ref} ref={ref}
data-sidebar="menu" data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)} className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props} {...props}
/> />
)) ));
SidebarMenu.displayName = "SidebarMenu" SidebarMenu.displayName = 'SidebarMenu';
const SidebarMenuItem = React.forwardRef< const SidebarMenuItem = React.forwardRef<
HTMLLIElement, HTMLLIElement,
React.ComponentProps<"li"> React.ComponentProps<'li'>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<li <li
ref={ref} ref={ref}
data-sidebar="menu-item" data-sidebar="menu-item"
className={cn("group/menu-item relative", className)} className={cn('group/menu-item relative', className)}
{...props} {...props}
/> />
)) ));
SidebarMenuItem.displayName = "SidebarMenuItem" SidebarMenuItem.displayName = 'SidebarMenuItem';
const sidebarMenuButtonVariants = cva( const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{ {
variants: { variants: {
variant: { variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline: outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", 'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
}, },
size: { size: {
default: "h-8 text-sm", default: 'h-8 text-sm',
sm: "h-7 text-xs", sm: 'h-7 text-xs',
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} }
) );
const SidebarMenuButton = React.forwardRef< const SidebarMenuButton = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<"button"> & { React.ComponentProps<'button'> & {
asChild?: boolean asChild?: boolean;
isActive?: boolean isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent> tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants> } & VariantProps<typeof sidebarMenuButtonVariants>
>( >(
( (
{ {
asChild = false, asChild = false,
isActive = false, isActive = false,
variant = "default", variant = 'default',
size = "default", size = 'default',
tooltip, tooltip,
className, className,
...props ...props
}, },
ref ref
) => { ) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const button = ( const button = (
<Comp <Comp
@ -563,16 +561,16 @@ const SidebarMenuButton = React.forwardRef<
className={cn(sidebarMenuButtonVariants({ variant, size }), className)} className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} {...props}
/> />
) );
if (!tooltip) { if (!tooltip) {
return button return button;
} }
if (typeof tooltip === "string") { if (typeof tooltip === 'string') {
tooltip = { tooltip = {
children: tooltip, children: tooltip,
} };
} }
return ( return (
@ -581,83 +579,83 @@ const SidebarMenuButton = React.forwardRef<
<TooltipContent <TooltipContent
side="right" side="right"
align="center" align="center"
hidden={state !== "collapsed" || isMobile} hidden={state !== 'collapsed' || isMobile}
{...tooltip} {...tooltip}
/> />
</Tooltip> </Tooltip>
) );
} }
) );
SidebarMenuButton.displayName = "SidebarMenuButton" SidebarMenuButton.displayName = 'SidebarMenuButton';
const SidebarMenuAction = React.forwardRef< const SidebarMenuAction = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
React.ComponentProps<"button"> & { React.ComponentProps<'button'> & {
asChild?: boolean asChild?: boolean;
showOnHover?: boolean showOnHover?: boolean;
} }
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => { >(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : 'button';
return ( return (
<Comp <Comp
ref={ref} ref={ref}
data-sidebar="menu-action" data-sidebar="menu-action"
className={cn( className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0", 'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden", 'after:absolute after:-inset-2 after:md:hidden',
"peer-data-[size=sm]/menu-button:top-1", 'peer-data-[size=sm]/menu-button:top-1',
"peer-data-[size=default]/menu-button:top-1.5", 'peer-data-[size=default]/menu-button:top-1.5',
"peer-data-[size=lg]/menu-button:top-2.5", 'peer-data-[size=lg]/menu-button:top-2.5',
"group-data-[collapsible=icon]:hidden", 'group-data-[collapsible=icon]:hidden',
showOnHover && showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0',
className className
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarMenuAction.displayName = "SidebarMenuAction" SidebarMenuAction.displayName = 'SidebarMenuAction';
const SidebarMenuBadge = React.forwardRef< const SidebarMenuBadge = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> React.ComponentProps<'div'>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
data-sidebar="menu-badge" data-sidebar="menu-badge"
className={cn( className={cn(
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none", 'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none',
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
"peer-data-[size=sm]/menu-button:top-1", 'peer-data-[size=sm]/menu-button:top-1',
"peer-data-[size=default]/menu-button:top-1.5", 'peer-data-[size=default]/menu-button:top-1.5',
"peer-data-[size=lg]/menu-button:top-2.5", 'peer-data-[size=lg]/menu-button:top-2.5',
"group-data-[collapsible=icon]:hidden", 'group-data-[collapsible=icon]:hidden',
className className
)} )}
{...props} {...props}
/> />
)) ));
SidebarMenuBadge.displayName = "SidebarMenuBadge" SidebarMenuBadge.displayName = 'SidebarMenuBadge';
const SidebarMenuSkeleton = React.forwardRef< const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.ComponentProps<"div"> & { React.ComponentProps<'div'> & {
showIcon?: boolean showIcon?: boolean;
} }
>(({ className, showIcon = false, ...props }, ref) => { >(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%. // Random width between 50 to 90%.
const width = React.useMemo(() => { const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%` return `${Math.floor(Math.random() * 40) + 50}%`;
}, []) }, []);
return ( return (
<div <div
ref={ref} ref={ref}
data-sidebar="menu-skeleton" data-sidebar="menu-skeleton"
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)} className={cn('rounded-md h-8 flex gap-2 px-2 items-center', className)}
{...props} {...props}
> >
{showIcon && ( {showIcon && (
@ -671,47 +669,47 @@ const SidebarMenuSkeleton = React.forwardRef<
data-sidebar="menu-skeleton-text" data-sidebar="menu-skeleton-text"
style={ style={
{ {
"--skeleton-width": width, '--skeleton-width': width,
} as React.CSSProperties } as React.CSSProperties
} }
/> />
</div> </div>
) );
}) });
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton" SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton';
const SidebarMenuSub = React.forwardRef< const SidebarMenuSub = React.forwardRef<
HTMLUListElement, HTMLUListElement,
React.ComponentProps<"ul"> React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ul <ul
ref={ref} ref={ref}
data-sidebar="menu-sub" data-sidebar="menu-sub"
className={cn( className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5", 'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5',
"group-data-[collapsible=icon]:hidden", 'group-data-[collapsible=icon]:hidden',
className className
)} )}
{...props} {...props}
/> />
)) ));
SidebarMenuSub.displayName = "SidebarMenuSub" SidebarMenuSub.displayName = 'SidebarMenuSub';
const SidebarMenuSubItem = React.forwardRef< const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement, HTMLLIElement,
React.ComponentProps<"li"> React.ComponentProps<'li'>
>(({ ...props }, ref) => <li ref={ref} {...props} />) >(({ ...props }, ref) => <li ref={ref} {...props} />);
SidebarMenuSubItem.displayName = "SidebarMenuSubItem" SidebarMenuSubItem.displayName = 'SidebarMenuSubItem';
const SidebarMenuSubButton = React.forwardRef< const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement, HTMLAnchorElement,
React.ComponentProps<"a"> & { React.ComponentProps<'a'> & {
asChild?: boolean asChild?: boolean;
size?: "sm" | "md" size?: 'sm' | 'md';
isActive?: boolean isActive?: boolean;
} }
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => { >(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a" const Comp = asChild ? Slot : 'a';
return ( return (
<Comp <Comp
@ -720,18 +718,18 @@ const SidebarMenuSubButton = React.forwardRef<
data-size={size} data-size={size}
data-active={isActive} data-active={isActive}
className={cn( className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", 'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === "sm" && "text-xs", size === 'sm' && 'text-xs',
size === "md" && "text-sm", size === 'md' && 'text-sm',
"group-data-[collapsible=icon]:hidden", 'group-data-[collapsible=icon]:hidden',
className className
)} )}
{...props} {...props}
/> />
) );
}) });
SidebarMenuSubButton.displayName = "SidebarMenuSubButton" SidebarMenuSubButton.displayName = 'SidebarMenuSubButton';
export { export {
Sidebar, Sidebar,
@ -757,4 +755,4 @@ export {
SidebarRail, SidebarRail,
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
} };

View File

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
function Skeleton({ function Skeleton({
className, className,
@ -6,10 +6,10 @@ function Skeleton({
}: React.HTMLAttributes<HTMLDivElement>) { }: React.HTMLAttributes<HTMLDivElement>) {
return ( return (
<div <div
className={cn("animate-pulse rounded-md bg-muted", className)} className={cn('animate-pulse rounded-md bg-muted', className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton } export { Skeleton };

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from 'react';
import * as SliderPrimitive from "@radix-ui/react-slider" import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Slider = React.forwardRef< const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>, React.ElementRef<typeof SliderPrimitive.Root>,
@ -10,7 +10,7 @@ const Slider = React.forwardRef<
<SliderPrimitive.Root <SliderPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"relative flex w-full touch-none select-none items-center", 'relative flex w-full touch-none select-none items-center',
className className
)} )}
{...props} {...props}
@ -20,7 +20,7 @@ const Slider = React.forwardRef<
</SliderPrimitive.Track> </SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background 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" /> <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background 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" />
</SliderPrimitive.Root> </SliderPrimitive.Root>
)) ));
Slider.displayName = SliderPrimitive.Root.displayName Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider } export { Slider };

View File

@ -1,29 +1,29 @@
import { useTheme } from "next-themes" import { useTheme } from 'next-themes';
import { Toaster as Sonner } from "sonner" import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner> type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = 'system' } = useTheme();
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps['theme']}
className="toaster group" className="toaster group"
toastOptions={{ toastOptions={{
classNames: { classNames: {
toast: toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: "group-[.toast]:text-muted-foreground", description: 'group-[.toast]:text-muted-foreground',
actionButton: actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
}, },
}} }}
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster };

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from 'react';
import * as SwitchPrimitives from "@radix-ui/react-switch" import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Switch = React.forwardRef< const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>, React.ElementRef<typeof SwitchPrimitives.Root>,
@ -9,7 +9,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SwitchPrimitives.Root <SwitchPrimitives.Root
className={cn( className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", 'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className className
)} )}
{...props} {...props}
@ -17,11 +17,11 @@ const Switch = React.forwardRef<
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0" 'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)} )}
/> />
</SwitchPrimitives.Root> </SwitchPrimitives.Root>
)) ));
Switch.displayName = SwitchPrimitives.Root.displayName Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch } export { Switch };

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Table = React.forwardRef< const Table = React.forwardRef<
HTMLTableElement, HTMLTableElement,
@ -9,20 +9,20 @@ const Table = React.forwardRef<
<div className="relative w-full overflow-auto"> <div className="relative w-full overflow-auto">
<table <table
ref={ref} ref={ref}
className={cn("w-full caption-bottom text-sm", className)} className={cn('w-full caption-bottom text-sm', className)}
{...props} {...props}
/> />
</div> </div>
)) ));
Table.displayName = "Table" Table.displayName = 'Table';
const TableHeader = React.forwardRef< const TableHeader = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
)) ));
TableHeader.displayName = "TableHeader" TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef< const TableBody = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
@ -30,11 +30,11 @@ const TableBody = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<tbody <tbody
ref={ref} ref={ref}
className={cn("[&_tr:last-child]:border-0", className)} className={cn('[&_tr:last-child]:border-0', className)}
{...props} {...props}
/> />
)) ));
TableBody.displayName = "TableBody" TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef< const TableFooter = React.forwardRef<
HTMLTableSectionElement, HTMLTableSectionElement,
@ -43,13 +43,13 @@ const TableFooter = React.forwardRef<
<tfoot <tfoot
ref={ref} ref={ref}
className={cn( className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", 'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className className
)} )}
{...props} {...props}
/> />
)) ));
TableFooter.displayName = "TableFooter" TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef< const TableRow = React.forwardRef<
HTMLTableRowElement, HTMLTableRowElement,
@ -58,13 +58,13 @@ const TableRow = React.forwardRef<
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", 'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className className
)} )}
{...props} {...props}
/> />
)) ));
TableRow.displayName = "TableRow" TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef< const TableHead = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@ -73,13 +73,13 @@ const TableHead = React.forwardRef<
<th <th
ref={ref} ref={ref}
className={cn( className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0", 'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className className
)} )}
{...props} {...props}
/> />
)) ));
TableHead.displayName = "TableHead" TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef< const TableCell = React.forwardRef<
HTMLTableCellElement, HTMLTableCellElement,
@ -87,11 +87,11 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<td <td
ref={ref} ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props} {...props}
/> />
)) ));
TableCell.displayName = "TableCell" TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef< const TableCaption = React.forwardRef<
HTMLTableCaptionElement, HTMLTableCaptionElement,
@ -99,11 +99,11 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<caption <caption
ref={ref} ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props} {...props}
/> />
)) ));
TableCaption.displayName = "TableCaption" TableCaption.displayName = 'TableCaption';
export { export {
Table, Table,
@ -114,4 +114,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} };

View File

@ -1,9 +1,9 @@
import * as React from "react" import * as React from 'react';
import * as TabsPrimitive from "@radix-ui/react-tabs" import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef< const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>, React.ElementRef<typeof TabsPrimitive.List>,
@ -12,13 +12,13 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground", 'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className className
)} )}
{...props} {...props}
/> />
)) ));
TabsList.displayName = TabsPrimitive.List.displayName TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef< const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>, React.ElementRef<typeof TabsPrimitive.Trigger>,
@ -27,13 +27,13 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm", 'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className className
)} )}
{...props} {...props}
/> />
)) ));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef< const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>, React.ElementRef<typeof TabsPrimitive.Content>,
@ -42,12 +42,12 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content <TabsPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", 'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className className
)} )}
{...props} {...props}
/> />
)) ));
TabsContent.displayName = TabsPrimitive.Content.displayName TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent } export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -1,21 +1,22 @@
import * as React from "react" import * as React from 'react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>( const Textarea = React.forwardRef<
({ className, ...props }, ref) => { HTMLTextAreaElement,
return ( React.TextareaHTMLAttributes<HTMLTextAreaElement>
<textarea >(({ className, ...props }, ref) => {
className={cn( return (
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", <textarea
className className={cn(
)} 'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
ref={ref} className
{...props} )}
/> ref={ref}
) {...props}
} />
) );
Textarea.displayName = "Textarea" });
Textarea.displayName = 'Textarea';
export { Textarea } export { Textarea };

View File

@ -1,11 +1,11 @@
import * as React from "react" import * as React from 'react';
import * as ToastPrimitives from "@radix-ui/react-toast" import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority';
import { X } from "lucide-react" import { X } from 'lucide-react';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const ToastProvider = ToastPrimitives.Provider const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef< const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>, React.ElementRef<typeof ToastPrimitives.Viewport>,
@ -14,29 +14,29 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", 'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className className
)} )}
{...props} {...props}
/> />
)) ));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva( const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{ {
variants: { variants: {
variant: { variant: {
default: "border bg-background text-foreground", default: 'border bg-background text-foreground',
destructive: destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground", 'destructive group border-destructive bg-destructive text-destructive-foreground',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
}, },
} }
) );
const Toast = React.forwardRef< const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>, React.ElementRef<typeof ToastPrimitives.Root>,
@ -49,9 +49,9 @@ const Toast = React.forwardRef<
className={cn(toastVariants({ variant }), className)} className={cn(toastVariants({ variant }), className)}
{...props} {...props}
/> />
) );
}) });
Toast.displayName = ToastPrimitives.Root.displayName Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef< const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>, React.ElementRef<typeof ToastPrimitives.Action>,
@ -60,13 +60,13 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action <ToastPrimitives.Action
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", 'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className className
)} )}
{...props} {...props}
/> />
)) ));
ToastAction.displayName = ToastPrimitives.Action.displayName ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef< const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>, React.ElementRef<typeof ToastPrimitives.Close>,
@ -75,7 +75,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close <ToastPrimitives.Close
ref={ref} ref={ref}
className={cn( className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", 'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className className
)} )}
toast-close="" toast-close=""
@ -83,8 +83,8 @@ const ToastClose = React.forwardRef<
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</ToastPrimitives.Close> </ToastPrimitives.Close>
)) ));
ToastClose.displayName = ToastPrimitives.Close.displayName ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef< const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>, React.ElementRef<typeof ToastPrimitives.Title>,
@ -92,11 +92,11 @@ const ToastTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Title <ToastPrimitives.Title
ref={ref} ref={ref}
className={cn("text-sm font-semibold", className)} className={cn('text-sm font-semibold', className)}
{...props} {...props}
/> />
)) ));
ToastTitle.displayName = ToastPrimitives.Title.displayName ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef< const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>, React.ElementRef<typeof ToastPrimitives.Description>,
@ -104,15 +104,15 @@ const ToastDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ToastPrimitives.Description <ToastPrimitives.Description
ref={ref} ref={ref}
className={cn("text-sm opacity-90", className)} className={cn('text-sm opacity-90', className)}
{...props} {...props}
/> />
)) ));
ToastDescription.displayName = ToastPrimitives.Description.displayName ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction> type ToastActionElement = React.ReactElement<typeof ToastAction>;
export { export {
type ToastProps, type ToastProps,
@ -124,4 +124,4 @@ export {
ToastDescription, ToastDescription,
ToastClose, ToastClose,
ToastAction, ToastAction,
} };

View File

@ -1,4 +1,4 @@
import { useToast } from "@/hooks/use-toast" import { useToast } from '@/hooks/use-toast';
import { import {
Toast, Toast,
ToastClose, ToastClose,
@ -6,10 +6,10 @@ import {
ToastProvider, ToastProvider,
ToastTitle, ToastTitle,
ToastViewport, ToastViewport,
} from "@/components/ui/toast" } from '@/components/ui/toast';
export function Toaster() { export function Toaster() {
const { toasts } = useToast() const { toasts } = useToast();
return ( return (
<ToastProvider> <ToastProvider>
@ -25,9 +25,9 @@ export function Toaster() {
{action} {action}
<ToastClose /> <ToastClose />
</Toast> </Toast>
) );
})} })}
<ToastViewport /> <ToastViewport />
</ToastProvider> </ToastProvider>
) );
} }

View File

@ -1,16 +1,16 @@
import * as React from "react" import * as React from 'react';
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { type VariantProps } from "class-variance-authority" import { type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { toggleVariants } from "@/components/ui/toggle" import { toggleVariants } from '@/components/ui/toggle-variants';
const ToggleGroupContext = React.createContext< const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> VariantProps<typeof toggleVariants>
>({ >({
size: "default", size: 'default',
variant: "default", variant: 'default',
}) });
const ToggleGroup = React.forwardRef< const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>, React.ElementRef<typeof ToggleGroupPrimitive.Root>,
@ -19,23 +19,23 @@ const ToggleGroup = React.forwardRef<
>(({ className, variant, size, children, ...props }, ref) => ( >(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root <ToggleGroupPrimitive.Root
ref={ref} ref={ref}
className={cn("flex items-center justify-center gap-1", className)} className={cn('flex items-center justify-center gap-1', className)}
{...props} {...props}
> >
<ToggleGroupContext.Provider value={{ variant, size }}> <ToggleGroupContext.Provider value={{ variant, size }}>
{children} {children}
</ToggleGroupContext.Provider> </ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root> </ToggleGroupPrimitive.Root>
)) ));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef< const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>, React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants> VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => { >(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext) const context = React.useContext(ToggleGroupContext);
return ( return (
<ToggleGroupPrimitive.Item <ToggleGroupPrimitive.Item
@ -51,9 +51,9 @@ const ToggleGroupItem = React.forwardRef<
> >
{children} {children}
</ToggleGroupPrimitive.Item> </ToggleGroupPrimitive.Item>
) );
}) });
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem } export { ToggleGroup, ToggleGroupItem };

View File

@ -1,23 +1,23 @@
import { cva } from "class-variance-authority" import { cva } from 'class-variance-authority';
export const toggleVariants = cva( export const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
{ {
variants: { variants: {
variant: { variant: {
default: "bg-transparent", default: 'bg-transparent',
outline: outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", 'border border-input bg-transparent hover:bg-accent hover:text-accent-foreground',
}, },
size: { size: {
default: "h-10 px-3", default: 'h-10 px-3',
sm: "h-9 px-2.5", sm: 'h-9 px-2.5',
lg: "h-11 px-5", lg: 'h-11 px-5',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} }
) );

View File

@ -1,9 +1,9 @@
import * as React from "react" import * as React from 'react';
import * as TogglePrimitive from "@radix-ui/react-toggle" import * as TogglePrimitive from '@radix-ui/react-toggle';
import { type VariantProps } from "class-variance-authority" import { type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
import { toggleVariants } from "./toggle-variants" import { toggleVariants } from './toggle-variants';
const Toggle = React.forwardRef< const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>, React.ElementRef<typeof TogglePrimitive.Root>,
@ -15,8 +15,8 @@ const Toggle = React.forwardRef<
className={cn(toggleVariants({ variant, size, className }))} className={cn(toggleVariants({ variant, size, className }))}
{...props} {...props}
/> />
)) ));
Toggle.displayName = TogglePrimitive.Root.displayName Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle } export { Toggle };

View File

@ -1,13 +1,13 @@
import * as React from "react" import * as React from 'react';
import * as TooltipPrimitive from "@radix-ui/react-tooltip" import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef< const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>, React.ElementRef<typeof TooltipPrimitive.Content>,
@ -17,12 +17,12 @@ const TooltipContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className className
)} )}
{...props} {...props}
/> />
)) ));
TooltipContent.displayName = TooltipPrimitive.Content.displayName TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -1,3 +1,3 @@
import { useToast, toast } from "@/hooks/use-toast"; import { useToast, toast } from '@/hooks/use-toast';
export { useToast, toast }; export { useToast, toast };

View File

@ -1,8 +1,16 @@
import * as React from "react"; import * as React from 'react';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { Bitcoin, Coins, Shield, ShieldCheck, Loader2, AlertCircle } from "lucide-react"; import {
import { useAuth } from "@/contexts/useAuth"; Bitcoin,
import { useAppKitAccount } from "@reown/appkit/react"; Coins,
Shield,
ShieldCheck,
Loader2,
AlertCircle,
} from 'lucide-react';
import { useAuth } from '@/contexts/useAuth';
import { useAppKitAccount } from '@reown/appkit/react';
import { OrdinalDetails, EnsDetails } from '@/types/identity';
interface VerificationStepProps { interface VerificationStepProps {
onComplete: () => void; onComplete: () => void;
@ -17,58 +25,61 @@ export function VerificationStep({
isLoading, isLoading,
setIsLoading, setIsLoading,
}: VerificationStepProps) { }: VerificationStepProps) {
const { const { currentUser, verificationStatus, verifyOwnership, isAuthenticating } =
currentUser, useAuth();
verificationStatus,
verifyOwnership,
isAuthenticating
} = useAuth();
// Get account info to determine wallet type // Get account info to determine wallet type
const bitcoinAccount = useAppKitAccount({ namespace: "bip122" }); const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
const ethereumAccount = useAppKitAccount({ namespace: "eip155" }); const ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
const isBitcoinConnected = bitcoinAccount.isConnected; const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected; const isEthereumConnected = ethereumAccount.isConnected;
const walletType = isBitcoinConnected ? 'bitcoin' : isEthereumConnected ? 'ethereum' : undefined; const walletType = isBitcoinConnected
? 'bitcoin'
: isEthereumConnected
? 'ethereum'
: undefined;
const [verificationResult, setVerificationResult] = React.useState<{ const [verificationResult, setVerificationResult] = React.useState<{
success: boolean; success: boolean;
message: string; message: string;
details?: { ensName?: string; ensAvatar?: string } | boolean | { id: string; details: string }; details?: OrdinalDetails | EnsDetails;
} | null>(null); } | null>(null);
const handleVerify = async () => { const handleVerify = async () => {
if (!currentUser) return; if (!currentUser) return;
setIsLoading(true); setIsLoading(true);
setVerificationResult(null); setVerificationResult(null);
try { try {
const success = await verifyOwnership(); const success = await verifyOwnership();
if (success) { if (success) {
setVerificationResult({ setVerificationResult({
success: true, success: true,
message: walletType === 'bitcoin' message:
? "Ordinal ownership verified successfully!" walletType === 'bitcoin'
: "ENS ownership verified successfully!", ? 'Ordinal ownership verified successfully!'
details: walletType === 'bitcoin' : 'ENS ownership verified successfully!',
? currentUser.ordinalOwnership details:
: { ensName: currentUser.ensName, ensAvatar: currentUser.ensAvatar } walletType === 'bitcoin'
? currentUser.ordinalDetails
: currentUser.ensDetails,
}); });
} else { } else {
setVerificationResult({ setVerificationResult({
success: false, success: false,
message: walletType === 'bitcoin' message:
? "No Ordinal ownership found. You can still participate in the forum with your connected wallet!" walletType === 'bitcoin'
: "No ENS ownership found. You can still participate in the forum with your connected wallet!" ? 'No Ordinal ownership found. You can still participate in the forum with your connected wallet!'
: 'No ENS ownership found. You can still participate in the forum with your connected wallet!',
}); });
} }
} catch (error) { } catch (error) {
setVerificationResult({ setVerificationResult({
success: false, success: false,
message: `Verification failed. Please try again: ${error}` message: `Verification failed. Please try again: ${error}`,
}); });
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -104,21 +115,29 @@ export function VerificationStep({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="flex-1 space-y-4"> <div className="flex-1 space-y-4">
<div className={`p-4 rounded-lg border ${ <div
verificationResult.success className={`p-4 rounded-lg border ${
? 'bg-green-900/20 border-green-500/30' verificationResult.success
: 'bg-yellow-900/20 border-yellow-500/30' ? 'bg-green-900/20 border-green-500/30'
}`}> : 'bg-yellow-900/20 border-yellow-500/30'
}`}
>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
{verificationResult.success ? ( {verificationResult.success ? (
<ShieldCheck className="h-5 w-5 text-green-500" /> <ShieldCheck className="h-5 w-5 text-green-500" />
) : ( ) : (
<AlertCircle className="h-5 w-5 text-yellow-500" /> <AlertCircle className="h-5 w-5 text-yellow-500" />
)} )}
<span className={`font-medium ${ <span
verificationResult.success ? 'text-green-400' : 'text-yellow-400' className={`font-medium ${
}`}> verificationResult.success
{verificationResult.success ? 'Verification Complete' : 'Verification Result'} ? 'text-green-400'
: 'text-yellow-400'
}`}
>
{verificationResult.success
? 'Verification Complete'
: 'Verification Result'}
</span> </span>
</div> </div>
<p className="text-sm text-neutral-300 mb-2"> <p className="text-sm text-neutral-300 mb-2">
@ -127,15 +146,27 @@ export function VerificationStep({
{verificationResult.details && ( {verificationResult.details && (
<div className="text-xs text-neutral-400"> <div className="text-xs text-neutral-400">
{walletType === 'bitcoin' ? ( {walletType === 'bitcoin' ? (
<p>Ordinal ID: {typeof verificationResult.details === 'object' && 'id' in verificationResult.details ? verificationResult.details.id : 'Verified'}</p> <p>
Ordinal ID:{' '}
{typeof verificationResult.details === 'object' &&
'ordinalId' in verificationResult.details
? verificationResult.details.ordinalId
: 'Verified'}
</p>
) : ( ) : (
<p>ENS Name: {typeof verificationResult.details === 'object' && 'ensName' in verificationResult.details ? verificationResult.details.ensName : 'Verified'}</p> <p>
ENS Name:{' '}
{typeof verificationResult.details === 'object' &&
'ensName' in verificationResult.details
? verificationResult.details.ensName
: 'Verified'}
</p>
)} )}
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* Action Button */} {/* Action Button */}
<div className="mt-auto"> <div className="mt-auto">
<Button <Button
@ -158,24 +189,32 @@ export function VerificationStep({
<div className="p-4 bg-green-900/20 border border-green-500/30 rounded-lg"> <div className="p-4 bg-green-900/20 border border-green-500/30 rounded-lg">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<ShieldCheck className="h-5 w-5 text-green-500" /> <ShieldCheck className="h-5 w-5 text-green-500" />
<span className="text-green-400 font-medium">Already Verified</span> <span className="text-green-400 font-medium">
Already Verified
</span>
</div> </div>
<p className="text-sm text-neutral-300 mb-2"> <p className="text-sm text-neutral-300 mb-2">
Your {getVerificationType()} ownership has been verified. Your {getVerificationType()} ownership has been verified.
</p> </p>
{currentUser && ( {currentUser && (
<div className="text-xs text-neutral-400"> <div className="text-xs text-neutral-400">
{walletType === 'bitcoin' && currentUser.ordinalOwnership && ( {walletType === 'bitcoin' && currentUser.ordinalDetails && (
<p>Ordinal ID: {typeof currentUser.ordinalOwnership === 'object' ? currentUser.ordinalOwnership.id : 'Verified'}</p> <p>
)} Ordinal ID:{' '}
{walletType === 'ethereum' && currentUser.ensName && ( {typeof currentUser.ordinalDetails === 'object'
<p>ENS Name: {currentUser.ensName}</p> ? currentUser.ordinalDetails.ordinalId
: 'Verified'}
</p>
)} )}
{walletType === 'ethereum' &&
currentUser.ensDetails?.ensName && (
<p>ENS Name: {currentUser.ensDetails.ensName}</p>
)}
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* Action Button */} {/* Action Button */}
<div className="mt-auto"> <div className="mt-auto">
<Button <Button
@ -196,8 +235,8 @@ export function VerificationStep({
<div className="flex-1 space-y-4"> <div className="flex-1 space-y-4">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<div className="flex justify-center"> <div className="flex justify-center">
{React.createElement(getVerificationIcon(), { {React.createElement(getVerificationIcon(), {
className: `h-8 w-8 ${getVerificationColor()}` className: `h-8 w-8 ${getVerificationColor()}`,
})} })}
</div> </div>
<h3 className="text-lg font-semibold text-white"> <h3 className="text-lg font-semibold text-white">
@ -211,7 +250,9 @@ export function VerificationStep({
<div className="p-4 bg-neutral-900/50 border border-neutral-700 rounded-lg"> <div className="p-4 bg-neutral-900/50 border border-neutral-700 rounded-lg">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Shield className="h-4 w-4 text-blue-500" /> <Shield className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium text-white">What happens during verification?</span> <span className="text-sm font-medium text-white">
What happens during verification?
</span>
</div> </div>
<ul className="text-xs text-neutral-400 space-y-1"> <ul className="text-xs text-neutral-400 space-y-1">
{walletType === 'bitcoin' ? ( {walletType === 'bitcoin' ? (
@ -251,7 +292,7 @@ export function VerificationStep({
`Verify ${getVerificationType()} Ownership` `Verify ${getVerificationType()} Ownership`
)} )}
</Button> </Button>
<Button <Button
onClick={onBack} onClick={onBack}
variant="outline" variant="outline"
@ -263,4 +304,4 @@ export function VerificationStep({
</div> </div>
</div> </div>
); );
} }

View File

@ -1,11 +1,11 @@
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { Badge } from "@/components/ui/badge"; import { Badge } from '@/components/ui/badge';
import { Bitcoin, Coins, Loader2 } from "lucide-react"; import { Bitcoin, Coins, Loader2 } from 'lucide-react';
import { import {
useAppKit, useAppKit,
useAppKitAccount, useAppKitAccount,
useAppKitState useAppKitState,
} from "@reown/appkit/react"; } from '@reown/appkit/react';
interface WalletConnectionStepProps { interface WalletConnectionStepProps {
onComplete: () => void; onComplete: () => void;
@ -20,32 +20,32 @@ export function WalletConnectionStep({
}: WalletConnectionStepProps) { }: WalletConnectionStepProps) {
const { initialized } = useAppKitState(); const { initialized } = useAppKitState();
const appKit = useAppKit(); const appKit = useAppKit();
// Get account info for different chains // Get account info for different chains
const bitcoinAccount = useAppKitAccount({ namespace: "bip122" }); const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
const ethereumAccount = useAppKitAccount({ namespace: "eip155" }); const ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
// Determine which account is connected // Determine which account is connected
const isBitcoinConnected = bitcoinAccount.isConnected; const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected; const isEthereumConnected = ethereumAccount.isConnected;
const isConnected = isBitcoinConnected || isEthereumConnected; const isConnected = isBitcoinConnected || isEthereumConnected;
// Get the active account info // Get the active account info
const activeAccount = isBitcoinConnected ? bitcoinAccount : ethereumAccount; const activeAccount = isBitcoinConnected ? bitcoinAccount : ethereumAccount;
const activeAddress = activeAccount.address; const activeAddress = activeAccount.address;
const activeChain = isBitcoinConnected ? "Bitcoin" : "Ethereum"; const activeChain = isBitcoinConnected ? 'Bitcoin' : 'Ethereum';
const handleBitcoinConnect = async () => { const handleBitcoinConnect = async () => {
if (!initialized || !appKit) { if (!initialized || !appKit) {
console.error('AppKit not initialized'); console.error('AppKit not initialized');
return; return;
} }
setIsLoading(true); setIsLoading(true);
try { try {
await appKit.open({ await appKit.open({
view: "Connect", view: 'Connect',
namespace: "bip122" namespace: 'bip122',
}); });
} catch (error) { } catch (error) {
console.error('Error connecting Bitcoin wallet:', error); console.error('Error connecting Bitcoin wallet:', error);
@ -59,12 +59,12 @@ export function WalletConnectionStep({
console.error('AppKit not initialized'); console.error('AppKit not initialized');
return; return;
} }
setIsLoading(true); setIsLoading(true);
try { try {
await appKit.open({ await appKit.open({
view: "Connect", view: 'Connect',
namespace: "eip155" namespace: 'eip155',
}); });
} catch (error) { } catch (error) {
console.error('Error connecting Ethereum wallet:', error); console.error('Error connecting Ethereum wallet:', error);
@ -97,14 +97,17 @@ export function WalletConnectionStep({
<div className="p-4 bg-green-900/20 border border-green-500/30 rounded-lg"> <div className="p-4 bg-green-900/20 border border-green-500/30 rounded-lg">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div> <div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-green-400 font-medium">Wallet Connected</span> <span className="text-green-400 font-medium">
Wallet Connected
</span>
</div> </div>
<p className="text-sm text-neutral-300 mb-2"> <p className="text-sm text-neutral-300 mb-2">
Connected to {activeChain} with {activeAddress?.slice(0, 6)}...{activeAddress?.slice(-4)} Connected to {activeChain} with {activeAddress?.slice(0, 6)}...
{activeAddress?.slice(-4)}
</p> </p>
</div> </div>
</div> </div>
{/* Action Button */} {/* Action Button */}
<div className="mt-auto"> <div className="mt-auto">
<Button <Button
@ -149,7 +152,7 @@ export function WalletConnectionStep({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: '8px' gap: '8px',
}} }}
> >
{isLoading ? ( {isLoading ? (
@ -158,7 +161,7 @@ export function WalletConnectionStep({
Connecting... Connecting...
</> </>
) : ( ) : (
"Connect Bitcoin Wallet" 'Connect Bitcoin Wallet'
)} )}
</Button> </Button>
</div> </div>
@ -195,7 +198,7 @@ export function WalletConnectionStep({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: '8px' gap: '8px',
}} }}
> >
{isLoading ? ( {isLoading ? (
@ -204,7 +207,7 @@ export function WalletConnectionStep({
Connecting... Connecting...
</> </>
) : ( ) : (
"Connect Ethereum Wallet" 'Connect Ethereum Wallet'
)} )}
</Button> </Button>
</div> </div>
@ -215,4 +218,4 @@ export function WalletConnectionStep({
</div> </div>
</div> </div>
); );
} }

View File

@ -1,4 +1,3 @@
import * as React from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -6,16 +5,16 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from '@/components/ui/dialog';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { Badge } from "@/components/ui/badge"; import { Badge } from '@/components/ui/badge';
import { Bitcoin, Coins } from "lucide-react"; import { Bitcoin, Coins } from 'lucide-react';
import { import {
useAppKit, useAppKit,
useAppKitAccount, useAppKitAccount,
useDisconnect, useDisconnect,
useAppKitState useAppKitState,
} from "@reown/appkit/react"; } from '@reown/appkit/react';
interface WalletDialogProps { interface WalletDialogProps {
open: boolean; open: boolean;
@ -32,20 +31,20 @@ export function WalletConnectionDialog({
const { initialized } = useAppKitState(); const { initialized } = useAppKitState();
const appKit = useAppKit(); const appKit = useAppKit();
const { disconnect } = useDisconnect(); const { disconnect } = useDisconnect();
// Get account info for different chains // Get account info for different chains
const bitcoinAccount = useAppKitAccount({ namespace: "bip122" }); const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
const ethereumAccount = useAppKitAccount({ namespace: "eip155" }); const ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
// Determine which account is connected // Determine which account is connected
const isBitcoinConnected = bitcoinAccount.isConnected; const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected; const isEthereumConnected = ethereumAccount.isConnected;
const isConnected = isBitcoinConnected || isEthereumConnected; const isConnected = isBitcoinConnected || isEthereumConnected;
// Get the active account info // Get the active account info
const activeAccount = isBitcoinConnected ? bitcoinAccount : ethereumAccount; const activeAccount = isBitcoinConnected ? bitcoinAccount : ethereumAccount;
const activeAddress = activeAccount.address; const activeAddress = activeAccount.address;
const activeChain = isBitcoinConnected ? "Bitcoin" : "Ethereum"; const activeChain = isBitcoinConnected ? 'Bitcoin' : 'Ethereum';
const handleDisconnect = async () => { const handleDisconnect = async () => {
await disconnect(); await disconnect();
@ -57,10 +56,10 @@ export function WalletConnectionDialog({
console.error('AppKit not initialized'); console.error('AppKit not initialized');
return; return;
} }
appKit.open({ appKit.open({
view: "Connect", view: 'Connect',
namespace: "bip122" namespace: 'bip122',
}); });
onConnect(); onConnect();
onOpenChange(false); onOpenChange(false);
@ -71,10 +70,10 @@ export function WalletConnectionDialog({
console.error('AppKit not initialized'); console.error('AppKit not initialized');
return; return;
} }
appKit.open({ appKit.open({
view: "Connect", view: 'Connect',
namespace: "eip155" namespace: 'eip155',
}); });
onConnect(); onConnect();
onOpenChange(false); onOpenChange(false);
@ -105,13 +104,12 @@ export function WalletConnectionDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl">Connect Wallet</DialogTitle> <DialogTitle className="text-xl">Connect Wallet</DialogTitle>
<DialogDescription className="text-neutral-400"> <DialogDescription className="text-neutral-400">
{isConnected {isConnected
? `Connected to ${activeChain} with ${activeAddress?.slice(0, 6)}...${activeAddress?.slice(-4)}` ? `Connected to ${activeChain} with ${activeAddress?.slice(0, 6)}...${activeAddress?.slice(-4)}`
: "Choose a network and wallet to connect to OpChan" : 'Choose a network and wallet to connect to OpChan'}
}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
{!isConnected ? ( {!isConnected ? (
<div className="space-y-4"> <div className="space-y-4">
@ -137,7 +135,7 @@ export function WalletConnectionDialog({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: '8px' gap: '8px',
}} }}
> >
Connect Bitcoin Wallet Connect Bitcoin Wallet
@ -177,7 +175,7 @@ export function WalletConnectionDialog({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
gap: '8px' gap: '8px',
}} }}
> >
Connect Ethereum Wallet Connect Ethereum Wallet
@ -188,14 +186,18 @@ export function WalletConnectionDialog({
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<div className="p-3 bg-neutral-900 rounded-lg border border-neutral-700"> <div className="p-3 bg-neutral-900 rounded-lg border border-neutral-700">
<p className="text-sm text-neutral-300 mb-2">Connected Network:</p> <p className="text-sm text-neutral-300 mb-2">
<p className="text-sm font-semibold text-white mb-2">{activeChain}</p> Connected Network:
</p>
<p className="text-sm font-semibold text-white mb-2">
{activeChain}
</p>
<p className="text-sm text-neutral-300 mb-2">Address:</p> <p className="text-sm text-neutral-300 mb-2">Address:</p>
<p className="text-xs font-mono text-neutral-400 break-all"> <p className="text-xs font-mono text-neutral-400 break-all">
{activeAddress} {activeAddress}
</p> </p>
</div> </div>
<Button <Button
onClick={handleDisconnect} onClick={handleDisconnect}
variant="outline" variant="outline"
@ -206,14 +208,16 @@ export function WalletConnectionDialog({
</div> </div>
)} )}
</div> </div>
<DialogFooter className="flex flex-col sm:flex-row sm:justify-between text-xs text-neutral-500"> <DialogFooter className="flex flex-col sm:flex-row sm:justify-between text-xs text-neutral-500">
<p>Connect your wallet to use OpChan's features</p> <p>Connect your wallet to use OpChan's features</p>
{isConnected && ( {isConnected && (
<p className="text-green-400"> Wallet connected to {activeChain}</p> <p className="text-green-400">
Wallet connected to {activeChain}
</p>
)} )}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }

View File

@ -1,17 +1,17 @@
import * as React from "react"; import * as React from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from '@/components/ui/dialog';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { CheckCircle, Circle, Loader2 } from "lucide-react"; import { CheckCircle, Circle, Loader2 } from 'lucide-react';
import { useAuth } from "@/contexts/useAuth"; import { useAuth } from '@/contexts/useAuth';
import { WalletConnectionStep } from "./wallet-connection-step"; import { WalletConnectionStep } from './wallet-connection-step';
import { VerificationStep } from "./verification-step"; import { VerificationStep } from './verification-step';
import { DelegationStep } from "./delegation-step"; import { DelegationStep } from './delegation-step';
interface WalletWizardProps { interface WalletWizardProps {
open: boolean; open: boolean;
@ -28,7 +28,7 @@ export function WalletWizard({
}: WalletWizardProps) { }: WalletWizardProps) {
const [currentStep, setCurrentStep] = React.useState<WizardStep>(1); const [currentStep, setCurrentStep] = React.useState<WizardStep>(1);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const { isAuthenticated, verificationStatus, isDelegationValid } = useAuth(); const { isAuthenticated, verificationStatus, isDelegationValid } = useAuth();
const hasInitialized = React.useRef(false); const hasInitialized = React.useRef(false);
// Reset wizard when opened and determine starting step // Reset wizard when opened and determine starting step
@ -37,9 +37,19 @@ export function WalletWizard({
// Determine the appropriate starting step based on current state // Determine the appropriate starting step based on current state
if (!isAuthenticated) { if (!isAuthenticated) {
setCurrentStep(1); // Start at connection step if not authenticated setCurrentStep(1); // Start at connection step if not authenticated
} else if (isAuthenticated && (verificationStatus === 'unverified' || verificationStatus === 'verifying')) { } else if (
isAuthenticated &&
(verificationStatus === 'unverified' ||
verificationStatus === 'verifying')
) {
setCurrentStep(2); // Start at verification step if authenticated but not verified setCurrentStep(2); // Start at verification step if authenticated but not verified
} else if (isAuthenticated && (verificationStatus === 'verified-owner' || verificationStatus === 'verified-basic' || verificationStatus === 'verified-none') && !isDelegationValid()) { } else if (
isAuthenticated &&
(verificationStatus === 'verified-owner' ||
verificationStatus === 'verified-basic' ||
verificationStatus === 'verified-none') &&
!isDelegationValid()
) {
setCurrentStep(3); // Start at delegation step if verified but no valid delegation setCurrentStep(3); // Start at delegation step if verified but no valid delegation
} else { } else {
setCurrentStep(3); // Default to step 3 if everything is complete setCurrentStep(3); // Default to step 3 if everything is complete
@ -70,11 +80,26 @@ export function WalletWizard({
return isAuthenticated ? 'complete' : 'current'; return isAuthenticated ? 'complete' : 'current';
} else if (step === 2) { } else if (step === 2) {
if (!isAuthenticated) return 'disabled'; if (!isAuthenticated) return 'disabled';
if (verificationStatus === 'unverified' || verificationStatus === 'verifying') return 'current'; if (
if (verificationStatus === 'verified-owner' || verificationStatus === 'verified-basic' || verificationStatus === 'verified-none') return 'complete'; verificationStatus === 'unverified' ||
verificationStatus === 'verifying'
)
return 'current';
if (
verificationStatus === 'verified-owner' ||
verificationStatus === 'verified-basic' ||
verificationStatus === 'verified-none'
)
return 'complete';
return 'disabled'; return 'disabled';
} else if (step === 3) { } else if (step === 3) {
if (!isAuthenticated || (verificationStatus !== 'verified-owner' && verificationStatus !== 'verified-basic' && verificationStatus !== 'verified-none')) return 'disabled'; if (
!isAuthenticated ||
(verificationStatus !== 'verified-owner' &&
verificationStatus !== 'verified-basic' &&
verificationStatus !== 'verified-none')
)
return 'disabled';
if (isDelegationValid()) return 'complete'; if (isDelegationValid()) return 'complete';
return 'current'; return 'current';
} }
@ -83,10 +108,10 @@ export function WalletWizard({
const renderStepIcon = (step: WizardStep) => { const renderStepIcon = (step: WizardStep) => {
const status = getStepStatus(step); const status = getStepStatus(step);
if (status === "complete") { if (status === 'complete') {
return <CheckCircle className="h-5 w-5 text-green-500" />; return <CheckCircle className="h-5 w-5 text-green-500" />;
} else if (status === "current") { } else if (status === 'current') {
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />; return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
} else { } else {
return <Circle className="h-5 w-5 text-gray-400" />; return <Circle className="h-5 w-5 text-gray-400" />;
@ -95,10 +120,14 @@ export function WalletWizard({
const getStepTitle = (step: WizardStep) => { const getStepTitle = (step: WizardStep) => {
switch (step) { switch (step) {
case 1: return "Connect Wallet"; case 1:
case 2: return "Verify Ownership"; return 'Connect Wallet';
case 3: return "Delegate Key"; case 2:
default: return ""; return 'Verify Ownership';
case 3:
return 'Delegate Key';
default:
return '';
} }
}; };
@ -114,26 +143,30 @@ export function WalletWizard({
{/* Progress Indicator */} {/* Progress Indicator */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
{[1, 2, 3].map((step) => ( {[1, 2, 3].map(step => (
<div key={step} className="flex items-center"> <div key={step} className="flex items-center">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{renderStepIcon(step as WizardStep)} {renderStepIcon(step as WizardStep)}
<span className={`text-sm ${ <span
getStepStatus(step as WizardStep) === "current" className={`text-sm ${
? "text-blue-500 font-medium" getStepStatus(step as WizardStep) === 'current'
: getStepStatus(step as WizardStep) === "complete" ? 'text-blue-500 font-medium'
? "text-green-500" : getStepStatus(step as WizardStep) === 'complete'
: "text-gray-400" ? 'text-green-500'
}`}> : 'text-gray-400'
}`}
>
{getStepTitle(step as WizardStep)} {getStepTitle(step as WizardStep)}
</span> </span>
</div> </div>
{step < 3 && ( {step < 3 && (
<div className={`w-8 h-px mx-2 ${ <div
getStepStatus(step as WizardStep) === "complete" className={`w-8 h-px mx-2 ${
? "bg-green-500" getStepStatus(step as WizardStep) === 'complete'
: "bg-gray-600" ? 'bg-green-500'
}`} /> : 'bg-gray-600'
}`}
/>
)} )}
</div> </div>
))} ))}
@ -148,7 +181,7 @@ export function WalletWizard({
setIsLoading={setIsLoading} setIsLoading={setIsLoading}
/> />
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<VerificationStep <VerificationStep
onComplete={() => handleStepComplete(2)} onComplete={() => handleStepComplete(2)}
@ -157,7 +190,7 @@ export function WalletWizard({
setIsLoading={setIsLoading} setIsLoading={setIsLoading}
/> />
)} )}
{currentStep === 3 && ( {currentStep === 3 && (
<DelegationStep <DelegationStep
onComplete={() => handleStepComplete(3)} onComplete={() => handleStepComplete(3)}
@ -170,9 +203,7 @@ export function WalletWizard({
{/* Footer */} {/* Footer */}
<div className="flex justify-between items-center pt-4 border-t border-neutral-700"> <div className="flex justify-between items-center pt-4 border-t border-neutral-700">
<p className="text-xs text-neutral-500"> <p className="text-xs text-neutral-500">Step {currentStep} of 3</p>
Step {currentStep} of 3
</p>
{currentStep > 1 && ( {currentStep > 1 && (
<Button <Button
variant="ghost" variant="ghost"
@ -188,4 +219,4 @@ export function WalletWizard({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }

View File

@ -1,11 +1,17 @@
import React, { createContext, useState, useEffect, useRef } from 'react'; import React, { createContext, useState, useEffect, useRef } from 'react';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { User, OpchanMessage, EVerificationStatus } from '@/types/forum'; import { OpchanMessage } from '@/types/forum';
import { User, EVerificationStatus, DisplayPreference } from '@/types/identity';
import { AuthService, CryptoService, DelegationDuration } from '@/lib/services'; import { AuthService, CryptoService, DelegationDuration } from '@/lib/services';
import { AuthResult } from '@/lib/services/AuthService'; import { AuthResult } from '@/lib/services/AuthService';
import { useAppKitAccount, useDisconnect, modal } from '@reown/appkit/react'; import { useAppKitAccount, useDisconnect, modal } from '@reown/appkit/react';
export type VerificationStatus = 'unverified' | 'verified-none' | 'verified-basic' | 'verified-owner' | 'verifying'; export type VerificationStatus =
| 'unverified'
| 'verified-none'
| 'verified-basic'
| 'verified-owner'
| 'verifying';
interface AuthContextType { interface AuthContextType {
currentUser: User | null; currentUser: User | null;
@ -30,43 +36,44 @@ export { AuthContext };
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [currentUser, setCurrentUser] = useState<User | null>(null); const [currentUser, setCurrentUser] = useState<User | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState(false); const [isAuthenticating, setIsAuthenticating] = useState(false);
const [verificationStatus, setVerificationStatus] = useState<VerificationStatus>('unverified'); const [verificationStatus, setVerificationStatus] =
useState<VerificationStatus>('unverified');
const { toast } = useToast(); const { toast } = useToast();
// Use AppKit hooks for multi-chain support // Use AppKit hooks for multi-chain support
const bitcoinAccount = useAppKitAccount({ namespace: "bip122" }); const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
const ethereumAccount = useAppKitAccount({ namespace: "eip155" }); const ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
// Determine which account is connected // Determine which account is connected
const isBitcoinConnected = bitcoinAccount.isConnected; const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected; const isEthereumConnected = ethereumAccount.isConnected;
const isConnected = isBitcoinConnected || isEthereumConnected; const isConnected = isBitcoinConnected || isEthereumConnected;
// Get the active account info // Get the active account info
const activeAccount = isBitcoinConnected ? bitcoinAccount : ethereumAccount; const activeAccount = isBitcoinConnected ? bitcoinAccount : ethereumAccount;
const address = activeAccount.address; const address = activeAccount.address;
// Create service instances that persist between renders // Create service instances that persist between renders
const cryptoServiceRef = useRef(new CryptoService()); const cryptoServiceRef = useRef(new CryptoService());
const authServiceRef = useRef(new AuthService(cryptoServiceRef.current)); const authServiceRef = useRef(new AuthService(cryptoServiceRef.current));
// Set AppKit instance and accounts in AuthService // Set AppKit instance and accounts in AuthService
useEffect(() => { useEffect(() => {
if (modal) { if (modal) {
authServiceRef.current.setAppKit(modal); authServiceRef.current.setAppKit(modal);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
authServiceRef.current.setAccounts(bitcoinAccount, ethereumAccount); authServiceRef.current.setAccounts(bitcoinAccount, ethereumAccount);
}, [bitcoinAccount, ethereumAccount]); }, [bitcoinAccount, ethereumAccount]);
// Sync with AppKit wallet state // Sync with AppKit wallet state
useEffect(() => { useEffect(() => {
if (isConnected && address) { if (isConnected && address) {
// Check if we have a stored user for this address // Check if we have a stored user for this address
const storedUser = authServiceRef.current.loadStoredUser(); const storedUser = authServiceRef.current.loadStoredUser();
if (storedUser && storedUser.address === address) { if (storedUser && storedUser.address === address) {
// Use stored user data // Use stored user data
setCurrentUser(storedUser); setCurrentUser(storedUser);
@ -77,50 +84,56 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
address, address,
walletType: isBitcoinConnected ? 'bitcoin' : 'ethereum', walletType: isBitcoinConnected ? 'bitcoin' : 'ethereum',
verificationStatus: EVerificationStatus.VERIFIED_BASIC, // Connected wallets get basic verification by default verificationStatus: EVerificationStatus.VERIFIED_BASIC, // Connected wallets get basic verification by default
displayPreference: DisplayPreference.WALLET_ADDRESS,
lastChecked: Date.now(), lastChecked: Date.now(),
}; };
// For Ethereum wallets, try to check ENS ownership immediately // For Ethereum wallets, try to check ENS ownership immediately
if (isEthereumConnected) { if (isEthereumConnected) {
authServiceRef.current.getWalletInfo().then((walletInfo) => { authServiceRef.current
if (walletInfo?.ensName) { .getWalletInfo()
const updatedUser = { .then(walletInfo => {
...newUser, if (walletInfo?.ensName) {
ensOwnership: true, const updatedUser = {
ensName: walletInfo.ensName, ...newUser,
verificationStatus: EVerificationStatus.VERIFIED_OWNER, ensOwnership: true,
}; ensName: walletInfo.ensName,
setCurrentUser(updatedUser); verificationStatus: EVerificationStatus.VERIFIED_OWNER,
setVerificationStatus('verified-owner'); };
authServiceRef.current.saveUser(updatedUser); setCurrentUser(updatedUser);
} else { setVerificationStatus('verified-owner');
authServiceRef.current.saveUser(updatedUser);
} else {
setCurrentUser(newUser);
setVerificationStatus('verified-basic');
authServiceRef.current.saveUser(newUser);
}
})
.catch(() => {
// Fallback to basic verification if ENS check fails
setCurrentUser(newUser); setCurrentUser(newUser);
setVerificationStatus('verified-basic'); setVerificationStatus('verified-basic');
authServiceRef.current.saveUser(newUser); authServiceRef.current.saveUser(newUser);
} });
}).catch(() => {
// Fallback to basic verification if ENS check fails
setCurrentUser(newUser);
setVerificationStatus('verified-basic');
authServiceRef.current.saveUser(newUser);
});
} else { } else {
setCurrentUser(newUser); setCurrentUser(newUser);
setVerificationStatus('verified-basic'); setVerificationStatus('verified-basic');
authServiceRef.current.saveUser(newUser); authServiceRef.current.saveUser(newUser);
} }
const chainName = isBitcoinConnected ? 'Bitcoin' : 'Ethereum'; const chainName = isBitcoinConnected ? 'Bitcoin' : 'Ethereum';
const displayName = `${address.slice(0, 6)}...${address.slice(-4)}`; const displayName = `${address.slice(0, 6)}...${address.slice(-4)}`;
toast({ toast({
title: "Wallet Connected", title: 'Wallet Connected',
description: `Connected to ${chainName} with ${displayName}`, description: `Connected to ${chainName} with ${displayName}`,
}); });
const verificationType = isBitcoinConnected ? 'Ordinal ownership' : 'ENS ownership'; const verificationType = isBitcoinConnected
? 'Ordinal ownership'
: 'ENS ownership';
toast({ toast({
title: "Action Required", title: 'Action Required',
description: `You can participate in the forum now! Verify your ${verificationType} for premium features and delegate a signing key for better UX.`, description: `You can participate in the forum now! Verify your ${verificationType} for premium features and delegate a signing key for better UX.`,
}); });
} }
@ -152,9 +165,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const getVerificationStatus = (user: User): VerificationStatus => { const getVerificationStatus = (user: User): VerificationStatus => {
if (user.walletType === 'bitcoin') { if (user.walletType === 'bitcoin') {
return user.ordinalOwnership ? 'verified-owner' : 'verified-basic'; return user.ordinalDetails
? EVerificationStatus.VERIFIED_OWNER
: EVerificationStatus.VERIFIED_BASIC;
} else if (user.walletType === 'ethereum') { } else if (user.walletType === 'ethereum') {
return user.ensOwnership ? 'verified-owner' : 'verified-basic'; return user.ensDetails
? EVerificationStatus.VERIFIED_OWNER
: EVerificationStatus.VERIFIED_BASIC;
} }
return 'unverified'; return 'unverified';
}; };
@ -162,133 +179,148 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const verifyOwnership = async (): Promise<boolean> => { const verifyOwnership = async (): Promise<boolean> => {
if (!currentUser || !currentUser.address) { if (!currentUser || !currentUser.address) {
toast({ toast({
title: "Not Connected", title: 'Not Connected',
description: "Please connect your wallet first.", description: 'Please connect your wallet first.',
variant: "destructive", variant: 'destructive',
}); });
return false; return false;
} }
setIsAuthenticating(true); setIsAuthenticating(true);
setVerificationStatus('verifying'); setVerificationStatus('verifying');
try { try {
const verificationType = currentUser.walletType === 'bitcoin' ? 'Ordinal' : 'ENS'; const verificationType =
toast({ currentUser.walletType === 'bitcoin' ? 'Ordinal' : 'ENS';
title: `Verifying ${verificationType}`, toast({
description: `Checking your wallet for ${verificationType} ownership...` title: `Verifying ${verificationType}`,
description: `Checking your wallet for ${verificationType} ownership...`,
}); });
const result: AuthResult = await authServiceRef.current.verifyOwnership(currentUser); const result: AuthResult =
await authServiceRef.current.verifyOwnership(currentUser);
if (!result.success) { if (!result.success) {
throw new Error(result.error); throw new Error(result.error);
} }
const updatedUser = result.user!; const updatedUser = result.user!;
setCurrentUser(updatedUser); setCurrentUser(updatedUser);
authServiceRef.current.saveUser(updatedUser); authServiceRef.current.saveUser(updatedUser);
// Update verification status // Update verification status
setVerificationStatus(getVerificationStatus(updatedUser)); setVerificationStatus(getVerificationStatus(updatedUser));
if (updatedUser.walletType === 'bitcoin' && updatedUser.ordinalOwnership) { if (updatedUser.walletType === 'bitcoin' && updatedUser.ordinalDetails) {
toast({ toast({
title: "Ordinal Verified", title: 'Ordinal Verified',
description: "You now have premium access with higher relevance bonuses. We recommend delegating a key for better UX.", description:
'You now have premium access with higher relevance bonuses. We recommend delegating a key for better UX.',
}); });
} else if (updatedUser.walletType === 'ethereum' && updatedUser.ensOwnership) { } else if (
updatedUser.walletType === 'ethereum' &&
updatedUser.ensDetails
) {
toast({ toast({
title: "ENS Verified", title: 'ENS Verified',
description: "You now have premium access with higher relevance bonuses. We recommend delegating a key for better UX.", description:
'You now have premium access with higher relevance bonuses. We recommend delegating a key for better UX.',
}); });
} else { } else {
const verificationType = updatedUser.walletType === 'bitcoin' ? 'Ordinal Operators' : 'ENS domain'; const verificationType =
updatedUser.walletType === 'bitcoin'
? 'Ordinal Operators'
: 'ENS domain';
toast({ toast({
title: "Basic Access Granted", title: 'Basic Access Granted',
description: `No ${verificationType} found, but you can still participate in the forum with your connected wallet.`, description: `No ${verificationType} found, but you can still participate in the forum with your connected wallet.`,
variant: "default", variant: 'default',
}); });
} }
return Boolean( return Boolean(
(updatedUser.walletType === 'bitcoin' && updatedUser.ordinalOwnership) || (updatedUser.walletType === 'bitcoin' && updatedUser.ordinalDetails) ||
(updatedUser.walletType === 'ethereum' && updatedUser.ensOwnership) (updatedUser.walletType === 'ethereum' && updatedUser.ensDetails)
); );
} catch (error) { } catch (error) {
console.error("Error verifying ownership:", error); console.error('Error verifying ownership:', error);
setVerificationStatus('unverified'); setVerificationStatus('unverified');
let errorMessage = "Failed to verify ownership. Please try again."; let errorMessage = 'Failed to verify ownership. Please try again.';
if (error instanceof Error) { if (error instanceof Error) {
errorMessage = error.message; errorMessage = error.message;
} }
toast({ toast({
title: "Verification Error", title: 'Verification Error',
description: errorMessage, description: errorMessage,
variant: "destructive", variant: 'destructive',
}); });
return false; return false;
} finally { } finally {
setIsAuthenticating(false); setIsAuthenticating(false);
} }
}; };
const delegateKey = async (duration: DelegationDuration = '7days'): Promise<boolean> => { const delegateKey = async (
duration: DelegationDuration = '7days'
): Promise<boolean> => {
if (!currentUser) { if (!currentUser) {
toast({ toast({
title: "No User Found", title: 'No User Found',
description: "Please connect your wallet first.", description: 'Please connect your wallet first.',
variant: "destructive", variant: 'destructive',
}); });
return false; return false;
} }
setIsAuthenticating(true); setIsAuthenticating(true);
try { try {
const durationText = duration === '7days' ? '1 week' : '30 days'; const durationText = duration === '7days' ? '1 week' : '30 days';
toast({ toast({
title: "Starting Key Delegation", title: 'Starting Key Delegation',
description: `This will let you post, comment, and vote without approving each action for ${durationText}.`, description: `This will let you post, comment, and vote without approving each action for ${durationText}.`,
}); });
const result: AuthResult = await authServiceRef.current.delegateKey(currentUser, duration); const result: AuthResult = await authServiceRef.current.delegateKey(
currentUser,
duration
);
if (!result.success) { if (!result.success) {
throw new Error(result.error); throw new Error(result.error);
} }
const updatedUser = result.user!; const updatedUser = result.user!;
setCurrentUser(updatedUser); setCurrentUser(updatedUser);
authServiceRef.current.saveUser(updatedUser); authServiceRef.current.saveUser(updatedUser);
// Format date for user-friendly display // Format date for user-friendly display
const expiryDate = new Date(updatedUser.delegationExpiry!); const expiryDate = new Date(updatedUser.delegationExpiry!);
const formattedExpiry = expiryDate.toLocaleString(); const formattedExpiry = expiryDate.toLocaleString();
toast({ toast({
title: "Key Delegation Successful", title: 'Key Delegation Successful',
description: `You can now interact with the forum without additional wallet approvals until ${formattedExpiry}.`, description: `You can now interact with the forum without additional wallet approvals until ${formattedExpiry}.`,
}); });
return true; return true;
} catch (error) { } catch (error) {
console.error("Error delegating key:", error); console.error('Error delegating key:', error);
let errorMessage = "Failed to delegate key. Please try again."; let errorMessage = 'Failed to delegate key. Please try again.';
if (error instanceof Error) { if (error instanceof Error) {
errorMessage = error.message; errorMessage = error.message;
} }
toast({ toast({
title: "Delegation Error", title: 'Delegation Error',
description: errorMessage, description: errorMessage,
variant: "destructive", variant: 'destructive',
}); });
return false; return false;
} finally { } finally {
setIsAuthenticating(false); setIsAuthenticating(false);
@ -305,31 +337,34 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const clearDelegation = (): void => { const clearDelegation = (): void => {
cryptoServiceRef.current.clearDelegation(); cryptoServiceRef.current.clearDelegation();
// Update the current user to remove delegation info // Update the current user to remove delegation info
if (currentUser) { if (currentUser) {
const updatedUser = { const updatedUser = {
...currentUser, ...currentUser,
delegationExpiry: undefined, delegationExpiry: undefined,
browserPublicKey: undefined browserPublicKey: undefined,
}; };
setCurrentUser(updatedUser); setCurrentUser(updatedUser);
authServiceRef.current.saveUser(updatedUser); authServiceRef.current.saveUser(updatedUser);
} }
toast({ toast({
title: "Delegation Cleared", title: 'Delegation Cleared',
description: "Your delegated signing key has been removed. You'll need to delegate a new key to continue posting and voting.", description:
"Your delegated signing key has been removed. You'll need to delegate a new key to continue posting and voting.",
}); });
}; };
const messageSigning = { const messageSigning = {
signMessage: async (message: OpchanMessage): Promise<OpchanMessage | null> => { signMessage: async (
message: OpchanMessage
): Promise<OpchanMessage | null> => {
return cryptoServiceRef.current.signMessage(message); return cryptoServiceRef.current.signMessage(message);
}, },
verifyMessage: (message: OpchanMessage): boolean => { verifyMessage: (message: OpchanMessage): boolean => {
return cryptoServiceRef.current.verifyMessage(message); return cryptoServiceRef.current.verifyMessage(message);
} },
}; };
const value: AuthContextType = { const value: AuthContextType = {
@ -345,14 +380,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
delegationTimeRemaining, delegationTimeRemaining,
clearDelegation, clearDelegation,
signMessage: messageSigning.signMessage, signMessage: messageSigning.signMessage,
verifyMessage: messageSigning.verifyMessage verifyMessage: messageSigning.verifyMessage,
}; };
return ( return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
} }

View File

@ -1,26 +1,33 @@
import React, { createContext, useState, useEffect, useCallback, useMemo } from 'react'; import React, {
import { Cell, Post, Comment, OpchanMessage, User, EVerificationStatus } from '@/types/forum'; createContext,
useState,
useEffect,
useCallback,
useMemo,
} from 'react';
import { Cell, Post, Comment, OpchanMessage } from '@/types/forum';
import { User, EVerificationStatus, DisplayPreference } from '@/types/identity';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { useAuth } from '@/contexts/useAuth'; import { useAuth } from '@/contexts/useAuth';
import { import {
createPost, createPost,
createComment, createComment,
vote, vote,
createCell, createCell,
moderatePost, moderatePost,
moderateComment, moderateComment,
moderateUser moderateUser,
} from '@/lib/forum/actions'; } from '@/lib/forum/actions';
import { import {
setupPeriodicQueries, setupPeriodicQueries,
monitorNetworkHealth, monitorNetworkHealth,
initializeNetwork initializeNetwork,
} from '@/lib/waku/network'; } from '@/lib/waku/network';
import messageManager from '@/lib/waku'; import messageManager from '@/lib/waku';
import { getDataFromCache } from '@/lib/forum/transformers'; import { getDataFromCache } from '@/lib/forum/transformers';
import { RelevanceCalculator } from '@/lib/forum/relevance'; import { RelevanceCalculator } from '@/lib/forum/relevance';
import { UserVerificationStatus } from '@/types/forum'; import { UserVerificationStatus } from '@/types/forum';
import { CryptoService, AuthService } from '@/lib/services'; import { CryptoService } from '@/lib/services';
import { getEnsName } from '@wagmi/core'; import { getEnsName } from '@wagmi/core';
import { config } from '@/lib/identity/wallets/appkit'; import { config } from '@/lib/identity/wallets/appkit';
@ -43,11 +50,19 @@ interface ForumContextType {
getCellById: (id: string) => Cell | undefined; getCellById: (id: string) => Cell | undefined;
getPostsByCell: (cellId: string) => Post[]; getPostsByCell: (cellId: string) => Post[];
getCommentsByPost: (postId: string) => Comment[]; getCommentsByPost: (postId: string) => Comment[];
createPost: (cellId: string, title: string, content: string) => Promise<Post | null>; createPost: (
cellId: string,
title: string,
content: string
) => Promise<Post | null>;
createComment: (postId: string, content: string) => Promise<Comment | null>; createComment: (postId: string, content: string) => Promise<Comment | null>;
votePost: (postId: string, isUpvote: boolean) => Promise<boolean>; votePost: (postId: string, isUpvote: boolean) => Promise<boolean>;
voteComment: (commentId: string, isUpvote: boolean) => Promise<boolean>; voteComment: (commentId: string, isUpvote: boolean) => Promise<boolean>;
createCell: (name: string, description: string, icon?: string) => Promise<Cell | null>; createCell: (
name: string,
description: string,
icon?: string
) => Promise<Cell | null>;
refreshData: () => Promise<void>; refreshData: () => Promise<void>;
moderatePost: ( moderatePost: (
cellId: string, cellId: string,
@ -85,43 +100,43 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [isNetworkConnected, setIsNetworkConnected] = useState(false); const [isNetworkConnected, setIsNetworkConnected] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [userVerificationStatus, setUserVerificationStatus] = useState<UserVerificationStatus>({}); const [userVerificationStatus, setUserVerificationStatus] =
useState<UserVerificationStatus>({});
const { toast } = useToast(); const { toast } = useToast();
const { currentUser, isAuthenticated } = useAuth(); const { currentUser, isAuthenticated } = useAuth();
const cryptoService = useMemo(() => new CryptoService(), []); const cryptoService = useMemo(() => new CryptoService(), []);
const authService = useMemo(() => new AuthService(cryptoService), [cryptoService]);
// Transform message cache data to the expected types // Transform message cache data to the expected types
const updateStateFromCache = useCallback(() => { const updateStateFromCache = useCallback(() => {
// Use the verifyMessage function from cryptoService if available // Use the verifyMessage function from cryptoService if available
const verifyFn = isAuthenticated ? const verifyFn = isAuthenticated
(message: OpchanMessage) => cryptoService.verifyMessage(message) : ? (message: OpchanMessage) => cryptoService.verifyMessage(message)
undefined; : undefined;
// Build user verification status for relevance calculation // Build user verification status for relevance calculation
const relevanceCalculator = new RelevanceCalculator(); const relevanceCalculator = new RelevanceCalculator();
const allUsers: User[] = []; const allUsers: User[] = [];
// Collect all unique users from posts, comments, and votes // Collect all unique users from posts, comments, and votes
const userAddresses = new Set<string>(); const userAddresses = new Set<string>();
// Add users from posts // Add users from posts
Object.values(messageManager.messageCache.posts).forEach(post => { Object.values(messageManager.messageCache.posts).forEach(post => {
userAddresses.add(post.author); userAddresses.add(post.author);
}); });
// Add users from comments // Add users from comments
Object.values(messageManager.messageCache.comments).forEach(comment => { Object.values(messageManager.messageCache.comments).forEach(comment => {
userAddresses.add(comment.author); userAddresses.add(comment.author);
}); });
// Add users from votes // Add users from votes
Object.values(messageManager.messageCache.votes).forEach(vote => { Object.values(messageManager.messageCache.votes).forEach(vote => {
userAddresses.add(vote.author); userAddresses.add(vote.author);
}); });
// Create user objects for verification status // Create user objects for verification status
Array.from(userAddresses).forEach(address => { Array.from(userAddresses).forEach(address => {
// Check if this address matches the current user's address // Check if this address matches the current user's address
@ -131,27 +146,31 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
address, address,
walletType: currentUser.walletType, walletType: currentUser.walletType,
verificationStatus: currentUser.verificationStatus, verificationStatus: currentUser.verificationStatus,
ensOwnership: currentUser.ensOwnership, displayPreference: currentUser.displayPreference,
ensName: currentUser.ensName, ensDetails: currentUser.ensDetails,
ensAvatar: currentUser.ensAvatar, ordinalDetails: currentUser.ordinalDetails,
ordinalOwnership: currentUser.ordinalOwnership, lastChecked: currentUser.lastChecked,
lastChecked: currentUser.lastChecked
}); });
} else { } else {
// Create generic user object for other addresses // Create generic user object for other addresses
allUsers.push({ allUsers.push({
address, address,
walletType: address.startsWith('0x') ? 'ethereum' : 'bitcoin', walletType: address.startsWith('0x') ? 'ethereum' : 'bitcoin',
verificationStatus: EVerificationStatus.UNVERIFIED verificationStatus: EVerificationStatus.UNVERIFIED,
displayPreference: DisplayPreference.WALLET_ADDRESS,
}); });
} }
}); });
const initialStatus = relevanceCalculator.buildUserVerificationStatus(allUsers); const initialStatus =
relevanceCalculator.buildUserVerificationStatus(allUsers);
// Transform data with relevance calculation (initial pass) // Transform data with relevance calculation (initial pass)
const { cells, posts, comments } = getDataFromCache(verifyFn, initialStatus); const { cells, posts, comments } = getDataFromCache(
verifyFn,
initialStatus
);
setCells(cells); setCells(cells);
setPosts(posts); setPosts(posts);
setComments(comments); setComments(comments);
@ -159,17 +178,25 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
// Enrich: resolve ENS for ethereum addresses asynchronously and update // Enrich: resolve ENS for ethereum addresses asynchronously and update
(async () => { (async () => {
const targets = allUsers.filter(u => u.walletType === 'ethereum' && !u.ensOwnership); const targets = allUsers.filter(
u => u.walletType === 'ethereum' && !u.ensDetails
);
if (targets.length === 0) return; if (targets.length === 0) return;
const lookups = await Promise.all(targets.map(async (u) => { const lookups = await Promise.all(
try { targets.map(async u => {
const name = await getEnsName(config, { address: u.address as `0x${string}` }); try {
return { address: u.address, ensName: name || undefined }; const name = await getEnsName(config, {
} catch { address: u.address as `0x${string}`,
return { address: u.address, ensName: undefined }; });
} return { address: u.address, ensName: name || undefined };
})); } catch {
const ensByAddress = new Map<string, string | undefined>(lookups.map(l => [l.address, l.ensName])); return { address: u.address, ensName: undefined };
}
})
);
const ensByAddress = new Map<string, string | undefined>(
lookups.map(l => [l.address, l.ensName])
);
const enrichedUsers: User[] = allUsers.map(u => { const enrichedUsers: User[] = allUsers.map(u => {
const ensName = ensByAddress.get(u.address); const ensName = ensByAddress.get(u.address);
if (ensName) { if (ensName) {
@ -178,12 +205,13 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
walletType: 'ethereum', walletType: 'ethereum',
ensOwnership: true, ensOwnership: true,
ensName, ensName,
verificationStatus: 'verified-owner' verificationStatus: 'verified-owner',
} as User; } as User;
} }
return u; return u;
}); });
const enrichedStatus = relevanceCalculator.buildUserVerificationStatus(enrichedUsers); const enrichedStatus =
relevanceCalculator.buildUserVerificationStatus(enrichedUsers);
const transformed = getDataFromCache(verifyFn, enrichedStatus); const transformed = getDataFromCache(verifyFn, enrichedStatus);
setCells(transformed.cells); setCells(transformed.cells);
setPosts(transformed.posts); setPosts(transformed.posts);
@ -191,22 +219,22 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
setUserVerificationStatus(enrichedStatus); setUserVerificationStatus(enrichedStatus);
})(); })();
}, [cryptoService, isAuthenticated, currentUser]); }, [cryptoService, isAuthenticated, currentUser]);
const handleRefreshData = async () => { const handleRefreshData = async () => {
setIsRefreshing(true); setIsRefreshing(true);
try { try {
// SDS handles message syncing automatically, just update UI // SDS handles message syncing automatically, just update UI
updateStateFromCache(); updateStateFromCache();
toast({ toast({
title: "Data Refreshed", title: 'Data Refreshed',
description: "Your view has been updated.", description: 'Your view has been updated.',
}); });
} catch (error) { } catch (error) {
console.error("Error refreshing data:", error); console.error('Error refreshing data:', error);
toast({ toast({
title: "Refresh Failed", title: 'Refresh Failed',
description: "Could not update the view. Please try again.", description: 'Could not update the view. Please try again.',
variant: "destructive", variant: 'destructive',
}); });
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
@ -239,87 +267,101 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
}; };
const getPostsByCell = (cellId: string): Post[] => { const getPostsByCell = (cellId: string): Post[] => {
return posts.filter(post => post.cellId === cellId) return posts
.filter(post => post.cellId === cellId)
.sort((a, b) => b.timestamp - a.timestamp); .sort((a, b) => b.timestamp - a.timestamp);
}; };
const getCommentsByPost = (postId: string): Comment[] => { const getCommentsByPost = (postId: string): Comment[] => {
return comments.filter(comment => comment.postId === postId) return comments
.filter(comment => comment.postId === postId)
.sort((a, b) => a.timestamp - b.timestamp); .sort((a, b) => a.timestamp - b.timestamp);
}; };
const handleCreatePost = async (cellId: string, title: string, content: string): Promise<Post | null> => { const handleCreatePost = async (
cellId: string,
title: string,
content: string
): Promise<Post | null> => {
setIsPostingPost(true); setIsPostingPost(true);
const result = await createPost( const result = await createPost(
cellId, cellId,
title, title,
content, content,
currentUser, currentUser,
isAuthenticated, isAuthenticated,
toast, toast,
updateStateFromCache, updateStateFromCache
authService
); );
setIsPostingPost(false); setIsPostingPost(false);
return result; return result;
}; };
const handleCreateComment = async (postId: string, content: string): Promise<Comment | null> => { const handleCreateComment = async (
postId: string,
content: string
): Promise<Comment | null> => {
setIsPostingComment(true); setIsPostingComment(true);
const result = await createComment( const result = await createComment(
postId, postId,
content, content,
currentUser, currentUser,
isAuthenticated, isAuthenticated,
toast, toast,
updateStateFromCache, updateStateFromCache
authService
); );
setIsPostingComment(false); setIsPostingComment(false);
return result; return result;
}; };
const handleVotePost = async (postId: string, isUpvote: boolean): Promise<boolean> => { const handleVotePost = async (
postId: string,
isUpvote: boolean
): Promise<boolean> => {
setIsVoting(true); setIsVoting(true);
const result = await vote( const result = await vote(
postId, postId,
isUpvote, isUpvote,
currentUser, currentUser,
isAuthenticated, isAuthenticated,
toast, toast,
updateStateFromCache, updateStateFromCache
authService
); );
setIsVoting(false); setIsVoting(false);
return result; return result;
}; };
const handleVoteComment = async (commentId: string, isUpvote: boolean): Promise<boolean> => { const handleVoteComment = async (
commentId: string,
isUpvote: boolean
): Promise<boolean> => {
setIsVoting(true); setIsVoting(true);
const result = await vote( const result = await vote(
commentId, commentId,
isUpvote, isUpvote,
currentUser, currentUser,
isAuthenticated, isAuthenticated,
toast, toast,
updateStateFromCache, updateStateFromCache
authService
); );
setIsVoting(false); setIsVoting(false);
return result; return result;
}; };
const handleCreateCell = async (name: string, description: string, icon?: string): Promise<Cell | null> => { const handleCreateCell = async (
name: string,
description: string,
icon?: string
): Promise<Cell | null> => {
setIsPostingCell(true); setIsPostingCell(true);
const result = await createCell( const result = await createCell(
name, name,
description, description,
icon, icon,
currentUser, currentUser,
isAuthenticated, isAuthenticated,
toast, toast,
updateStateFromCache, updateStateFromCache
authService
); );
setIsPostingCell(false); setIsPostingCell(false);
return result; return result;
@ -339,8 +381,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
isAuthenticated, isAuthenticated,
cellOwner, cellOwner,
toast, toast,
updateStateFromCache, updateStateFromCache
authService
); );
}; };
@ -358,8 +399,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
isAuthenticated, isAuthenticated,
cellOwner, cellOwner,
toast, toast,
updateStateFromCache, updateStateFromCache
authService
); );
}; };
@ -377,8 +417,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
isAuthenticated, isAuthenticated,
cellOwner, cellOwner,
toast, toast,
updateStateFromCache, updateStateFromCache
authService
); );
}; };
@ -408,12 +447,10 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
refreshData: handleRefreshData, refreshData: handleRefreshData,
moderatePost: handleModeratePost, moderatePost: handleModeratePost,
moderateComment: handleModerateComment, moderateComment: handleModerateComment,
moderateUser: handleModerateUser moderateUser: handleModerateUser,
}} }}
> >
{children} {children}
</ForumContext.Provider> </ForumContext.Provider>
); );
} }

View File

@ -4,7 +4,7 @@ import { AuthContext } from './AuthContext';
export const useAuth = () => { export const useAuth = () => {
const context = useContext(AuthContext); const context = useContext(AuthContext);
if (context === undefined) { if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider"); throw new Error('useAuth must be used within an AuthProvider');
} }
return context; return context;
}; };

View File

@ -7,4 +7,4 @@ export const useForum = () => {
throw new Error('useForum must be used within a ForumProvider'); throw new Error('useForum must be used within a ForumProvider');
} }
return context; return context;
}; };

View File

@ -1,19 +1,21 @@
import * as React from "react" import * as React from 'react';
const MOBILE_BREAKPOINT = 768 const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined) const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined
);
React.useEffect(() => { React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => { const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
} };
mql.addEventListener("change", onChange) mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange) return () => mql.removeEventListener('change', onChange);
}, []) }, []);
return !!isMobile return !!isMobile;
} }

View File

@ -1,74 +1,70 @@
import * as React from "react" import * as React from 'react';
import type { import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1 const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000 const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & { type ToasterToast = ToastProps & {
id: string id: string;
title?: React.ReactNode title?: React.ReactNode;
description?: React.ReactNode description?: React.ReactNode;
action?: ToastActionElement action?: ToastActionElement;
};
enum ActionTypes {
ADD_TOAST = 'ADD_TOAST',
UPDATE_TOAST = 'UPDATE_TOAST',
DISMISS_TOAST = 'DISMISS_TOAST',
REMOVE_TOAST = 'REMOVE_TOAST',
} }
enum ActionTypes { let count = 0;
ADD_TOAST = "ADD_TOAST",
UPDATE_TOAST = "UPDATE_TOAST",
DISMISS_TOAST = "DISMISS_TOAST",
REMOVE_TOAST = "REMOVE_TOAST",
}
let count = 0
function genId() { function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString() return count.toString();
} }
type Action = type Action =
| { | {
type: ActionTypes.ADD_TOAST type: ActionTypes.ADD_TOAST;
toast: ToasterToast toast: ToasterToast;
} }
| { | {
type: ActionTypes.UPDATE_TOAST type: ActionTypes.UPDATE_TOAST;
toast: Partial<ToasterToast> toast: Partial<ToasterToast>;
} }
| { | {
type: ActionTypes.DISMISS_TOAST type: ActionTypes.DISMISS_TOAST;
toastId?: ToasterToast["id"] toastId?: ToasterToast['id'];
} }
| { | {
type: ActionTypes.REMOVE_TOAST type: ActionTypes.REMOVE_TOAST;
toastId?: ToasterToast["id"] toastId?: ToasterToast['id'];
} };
interface State { interface State {
toasts: ToasterToast[] toasts: ToasterToast[];
} }
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => { const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) { if (toastTimeouts.has(toastId)) {
return return;
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
toastTimeouts.delete(toastId) toastTimeouts.delete(toastId);
dispatch({ dispatch({
type: ActionTypes.REMOVE_TOAST, type: ActionTypes.REMOVE_TOAST,
toastId: toastId, toastId: toastId,
}) });
}, TOAST_REMOVE_DELAY) }, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout) toastTimeouts.set(toastId, timeout);
} };
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
@ -76,32 +72,32 @@ export const reducer = (state: State, action: Action): State => {
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
} };
case ActionTypes.UPDATE_TOAST: case ActionTypes.UPDATE_TOAST:
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map(t =>
t.id === action.toast.id ? { ...t, ...action.toast } : t t.id === action.toast.id ? { ...t, ...action.toast } : t
), ),
} };
case ActionTypes.DISMISS_TOAST: { case ActionTypes.DISMISS_TOAST: {
const { toastId } = action const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action, // ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity // but I'll keep it here for simplicity
if (toastId) { if (toastId) {
addToRemoveQueue(toastId) addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { state.toasts.forEach(toast => {
addToRemoveQueue(toast.id) addToRemoveQueue(toast.id);
}) });
} }
return { return {
...state, ...state,
toasts: state.toasts.map((t) => toasts: state.toasts.map(t =>
t.id === toastId || toastId === undefined t.id === toastId || toastId === undefined
? { ? {
...t, ...t,
@ -109,44 +105,45 @@ export const reducer = (state: State, action: Action): State => {
} }
: t : t
), ),
} };
} }
case ActionTypes.REMOVE_TOAST: case ActionTypes.REMOVE_TOAST:
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
toasts: [], toasts: [],
} };
} }
return { return {
...state, ...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId), toasts: state.toasts.filter(t => t.id !== action.toastId),
} };
} }
} };
const listeners: Array<(state: State) => void> = [] const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] } let memoryState: State = { toasts: [] };
function dispatch(action: Action) { function dispatch(action: Action) {
memoryState = reducer(memoryState, action) memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { listeners.forEach(listener => {
listener(memoryState) listener(memoryState);
}) });
} }
type Toast = Omit<ToasterToast, "id"> type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) { function toast({ ...props }: Toast) {
const id = genId() const id = genId();
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
dispatch({ dispatch({
type: ActionTypes.UPDATE_TOAST, type: ActionTypes.UPDATE_TOAST,
toast: { ...props, id }, toast: { ...props, id },
}) });
const dismiss = () => dispatch({ type: ActionTypes.DISMISS_TOAST, toastId: id }) const dismiss = () =>
dispatch({ type: ActionTypes.DISMISS_TOAST, toastId: id });
dispatch({ dispatch({
type: ActionTypes.ADD_TOAST, type: ActionTypes.ADD_TOAST,
@ -154,37 +151,38 @@ function toast({ ...props }: Toast) {
...props, ...props,
id, id,
open: true, open: true,
onOpenChange: (open) => { onOpenChange: open => {
if (!open) dismiss() if (!open) dismiss();
}, },
}, },
}) });
return { return {
id: id, id: id,
dismiss, dismiss,
update, update,
} };
} }
function useToast() { function useToast() {
const [state, setState] = React.useState<State>(memoryState) const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => { React.useEffect(() => {
listeners.push(setState) listeners.push(setState);
return () => { return () => {
const index = listeners.indexOf(setState) const index = listeners.indexOf(setState);
if (index > -1) { if (index > -1) {
listeners.splice(index, 1) listeners.splice(index, 1);
} }
} };
}, [state]) }, [state]);
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: ActionTypes.DISMISS_TOAST, toastId }), dismiss: (toastId?: string) =>
} dispatch({ type: ActionTypes.DISMISS_TOAST, toastId }),
};
} }
export { useToast, toast } export { useToast, toast };

View File

@ -6,35 +6,35 @@
@layer base { @layer base {
:root { :root {
--background: 226 20% 12%; --background: 226 20% 12%;
--foreground: 0 0% 95%; --foreground: 0 0% 95%;
--card: 226 20% 12%; --card: 226 20% 12%;
--card-foreground: 0 0% 94%; --card-foreground: 0 0% 94%;
--popover: 226 20% 18%; --popover: 226 20% 18%;
--popover-foreground: 0 0% 94%; --popover-foreground: 0 0% 94%;
--primary: 195 82% 42%; --primary: 195 82% 42%;
--primary-foreground: 0 0% 98%; --primary-foreground: 0 0% 98%;
--secondary: 226 20% 18%; --secondary: 226 20% 18%;
--secondary-foreground: 0 0% 94%; --secondary-foreground: 0 0% 94%;
--muted: 226 13% 27%; --muted: 226 13% 27%;
--muted-foreground: 225 6% 57%; --muted-foreground: 225 6% 57%;
--accent: 195 82% 42%; --accent: 195 82% 42%;
--accent-foreground: 0 0% 98%; --accent-foreground: 0 0% 98%;
--destructive: 0 84% 60%; --destructive: 0 84% 60%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 226 13% 27%; --border: 226 13% 27%;
--input: 226 13% 27%; --input: 226 13% 27%;
--ring: 195 82% 42%; --ring: 195 82% 42%;
--radius: 0.25rem; --radius: 0.25rem;
--sidebar-background: 226 20% 14%; --sidebar-background: 226 20% 14%;
--sidebar-foreground: 0 0% 94%; --sidebar-foreground: 0 0% 94%;
@ -54,7 +54,20 @@
body { body {
@apply bg-background text-foreground font-mono; @apply bg-background text-foreground font-mono;
font-family: 'IBM Plex Mono', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; font-family:
'IBM Plex Mono',
'Inter',
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Open Sans',
'Helvetica Neue',
sans-serif;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
@ -81,11 +94,19 @@
*:focus-visible { *:focus-visible {
@apply outline-none ring-2 ring-primary/70 ring-offset-1 ring-offset-background; @apply outline-none ring-2 ring-primary/70 ring-offset-1 ring-offset-background;
} }
h1, h2, h3, h4, h5, h6, button, input, textarea { h1,
h2,
h3,
h4,
h5,
h6,
button,
input,
textarea {
@apply font-mono; @apply font-mono;
} }
.btn { .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-center rounded-sm px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none disabled:opacity-50;
} }
@ -95,26 +116,29 @@
.thread-card { .thread-card {
@apply border border-cyber-muted bg-cyber-dark hover:bg-cyber-muted/20 transition-colors rounded-sm p-4 mb-4; @apply border border-cyber-muted bg-cyber-dark hover:bg-cyber-muted/20 transition-colors rounded-sm p-4 mb-4;
} }
.board-card { .board-card {
@apply border border-cyber-muted bg-cyber-dark hover:bg-cyber-muted/20 transition-colors rounded-sm p-3 mb-3; @apply border border-cyber-muted bg-cyber-dark hover:bg-cyber-muted/20 transition-colors rounded-sm p-3 mb-3;
} }
.comment-card { .comment-card {
@apply border-l-2 border-muted pl-3 py-2 my-2 hover:border-primary transition-colors; @apply border-l-2 border-muted pl-3 py-2 my-2 hover:border-primary transition-colors;
} }
} }
@keyframes cyber-flicker { @keyframes cyber-flicker {
0%, 100% { 0%,
100% {
opacity: 1; opacity: 1;
filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.8)); filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.8));
} }
8%, 10% { 8%,
10% {
opacity: 0.8; opacity: 0.8;
filter: drop-shadow(0 0 5px rgba(0, 255, 255, 0.8)); filter: drop-shadow(0 0 5px rgba(0, 255, 255, 0.8));
} }
20%, 25% { 20%,
25% {
opacity: 1; opacity: 1;
filter: drop-shadow(0 0 1px rgba(0, 255, 255, 0.5)); filter: drop-shadow(0 0 1px rgba(0, 255, 255, 0.5));
} }
@ -122,15 +146,18 @@
opacity: 0.6; opacity: 0.6;
filter: drop-shadow(0 0 8px rgba(0, 255, 255, 1)); filter: drop-shadow(0 0 8px rgba(0, 255, 255, 1));
} }
40%, 45% { 40%,
45% {
opacity: 1; opacity: 1;
filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.6)); filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.6));
} }
50%, 55% { 50%,
55% {
opacity: 0.9; opacity: 0.9;
filter: drop-shadow(0 0 3px rgba(0, 255, 255, 0.8)); filter: drop-shadow(0 0 3px rgba(0, 255, 255, 0.8));
} }
60%, 100% { 60%,
100% {
opacity: 1; opacity: 1;
filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.6)); filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.6));
} }

View File

@ -1,5 +1,6 @@
import { RelevanceCalculator } from '../relevance'; import { RelevanceCalculator } from '../relevance';
import { Post, Comment, User, UserVerificationStatus, EVerificationStatus } from '@/types/forum'; import { Post, Comment, UserVerificationStatus } from '@/types/forum';
import { User, EVerificationStatus, DisplayPreference } from '@/types/identity';
import { VoteMessage, MessageType } from '@/types/waku'; import { VoteMessage, MessageType } from '@/types/waku';
import { expect, describe, beforeEach, it } from 'vitest'; import { expect, describe, beforeEach, it } from 'vitest';
@ -10,9 +11,9 @@ describe('RelevanceCalculator', () => {
beforeEach(() => { beforeEach(() => {
calculator = new RelevanceCalculator(); calculator = new RelevanceCalculator();
mockUserVerificationStatus = { mockUserVerificationStatus = {
'user1': { isVerified: true, hasENS: true, hasOrdinal: false }, user1: { isVerified: true, hasENS: true, hasOrdinal: false },
'user2': { isVerified: false, hasENS: false, hasOrdinal: false }, user2: { isVerified: false, hasENS: false, hasOrdinal: false },
'user3': { isVerified: true, hasENS: false, hasOrdinal: true } user3: { isVerified: true, hasENS: false, hasOrdinal: true },
}; };
}); });
@ -20,17 +21,26 @@ describe('RelevanceCalculator', () => {
it('should calculate base score for a new post', () => { it('should calculate base score for a new post', () => {
const post: Post = { const post: Post = {
id: '1', id: '1',
type: MessageType.POST,
author: 'user2',
cellId: 'cell1', cellId: 'cell1',
authorAddress: 'user2', authorAddress: 'user2',
title: 'Test Post', title: 'Test Post',
content: 'Test content', content: 'Test content',
timestamp: Date.now(), timestamp: Date.now(),
upvotes: [], upvotes: [],
downvotes: [] downvotes: [],
signature: 'test',
browserPubKey: 'test',
}; };
const result = calculator.calculatePostScore(post, [], [], mockUserVerificationStatus); const result = calculator.calculatePostScore(
post,
[],
[],
mockUserVerificationStatus
);
expect(result.score).toBeGreaterThan(0); expect(result.score).toBeGreaterThan(0);
expect(result.details.baseScore).toBe(10); expect(result.details.baseScore).toBe(10);
expect(result.details.isVerified).toBe(false); expect(result.details.isVerified).toBe(false);
@ -39,17 +49,26 @@ describe('RelevanceCalculator', () => {
it('should apply verification bonus for verified author', () => { it('should apply verification bonus for verified author', () => {
const post: Post = { const post: Post = {
id: '1', id: '1',
type: MessageType.POST,
author: 'user1',
cellId: 'cell1', cellId: 'cell1',
authorAddress: 'user1', authorAddress: 'user1',
title: 'Test Post', title: 'Test Post',
content: 'Test content', content: 'Test content',
timestamp: Date.now(), timestamp: Date.now(),
upvotes: [], upvotes: [],
downvotes: [] downvotes: [],
signature: 'test',
browserPubKey: 'test',
}; };
const result = calculator.calculatePostScore(post, [], [], mockUserVerificationStatus); const result = calculator.calculatePostScore(
post,
[],
[],
mockUserVerificationStatus
);
expect(result.details.isVerified).toBe(true); expect(result.details.isVerified).toBe(true);
expect(result.details.authorVerificationBonus).toBeGreaterThan(0); expect(result.details.authorVerificationBonus).toBeGreaterThan(0);
}); });
@ -59,9 +78,12 @@ describe('RelevanceCalculator', () => {
address: 'user1', address: 'user1',
walletType: 'ethereum', walletType: 'ethereum',
verificationStatus: EVerificationStatus.VERIFIED_OWNER, verificationStatus: EVerificationStatus.VERIFIED_OWNER,
ensOwnership: true, displayPreference: DisplayPreference.WALLET_ADDRESS,
ensName: 'test.eth', ensDetails: {
lastChecked: Date.now() ensName: 'test.eth',
},
ordinalDetails: undefined,
lastChecked: Date.now(),
}; };
const isVerified = calculator.isUserVerified(verifiedUser); const isVerified = calculator.isUserVerified(verifiedUser);
@ -73,8 +95,12 @@ describe('RelevanceCalculator', () => {
address: 'user3', address: 'user3',
walletType: 'bitcoin', walletType: 'bitcoin',
verificationStatus: EVerificationStatus.VERIFIED_OWNER, verificationStatus: EVerificationStatus.VERIFIED_OWNER,
ordinalOwnership: true, displayPreference: DisplayPreference.WALLET_ADDRESS,
lastChecked: Date.now() ordinalDetails: {
ordinalId: '1',
ordinalDetails: 'test',
},
lastChecked: Date.now(),
}; };
const isVerified = calculator.isUserVerified(verifiedUser); const isVerified = calculator.isUserVerified(verifiedUser);
@ -86,8 +112,10 @@ describe('RelevanceCalculator', () => {
address: 'user2', address: 'user2',
walletType: 'ethereum', walletType: 'ethereum',
verificationStatus: EVerificationStatus.UNVERIFIED, verificationStatus: EVerificationStatus.UNVERIFIED,
ensOwnership: false, displayPreference: DisplayPreference.WALLET_ADDRESS,
lastChecked: Date.now() ensDetails: undefined,
ordinalDetails: undefined,
lastChecked: Date.now(),
}; };
const isVerified = calculator.isUserVerified(unverifiedUser); const isVerified = calculator.isUserVerified(unverifiedUser);
@ -97,6 +125,8 @@ describe('RelevanceCalculator', () => {
it('should apply moderation penalty', () => { it('should apply moderation penalty', () => {
const post: Post = { const post: Post = {
id: '1', id: '1',
type: MessageType.POST,
author: 'user2',
cellId: 'cell1', cellId: 'cell1',
authorAddress: 'user2', authorAddress: 'user2',
title: 'Test Post', title: 'Test Post',
@ -104,11 +134,18 @@ describe('RelevanceCalculator', () => {
timestamp: Date.now(), timestamp: Date.now(),
upvotes: [], upvotes: [],
downvotes: [], downvotes: [],
moderated: true moderated: true,
signature: 'test',
browserPubKey: 'test',
}; };
const result = calculator.calculatePostScore(post, [], [], mockUserVerificationStatus); const result = calculator.calculatePostScore(
post,
[],
[],
mockUserVerificationStatus
);
expect(result.details.isModerated).toBe(true); expect(result.details.isModerated).toBe(true);
expect(result.details.moderationPenalty).toBe(0.5); expect(result.details.moderationPenalty).toBe(0.5);
}); });
@ -116,26 +153,65 @@ describe('RelevanceCalculator', () => {
it('should calculate engagement bonuses', () => { it('should calculate engagement bonuses', () => {
const post: Post = { const post: Post = {
id: '1', id: '1',
type: MessageType.POST,
author: 'user2',
cellId: 'cell1', cellId: 'cell1',
authorAddress: 'user2', authorAddress: 'user2',
title: 'Test Post', title: 'Test Post',
content: 'Test content', content: 'Test content',
timestamp: Date.now(), timestamp: Date.now(),
upvotes: [], upvotes: [],
downvotes: [] downvotes: [],
signature: 'test',
browserPubKey: 'test',
}; };
const votes: VoteMessage[] = [ const votes: VoteMessage[] = [
{ id: 'vote1', targetId: '1', value: 1, author: 'user1', timestamp: Date.now(), type: MessageType.VOTE }, {
{ id: 'vote2', targetId: '1', value: 1, author: 'user3', timestamp: Date.now(), type: MessageType.VOTE } id: 'vote1',
targetId: '1',
value: 1,
author: 'user1',
timestamp: Date.now(),
type: MessageType.VOTE,
signature: 'test',
browserPubKey: 'test',
},
{
id: 'vote2',
targetId: '1',
value: 1,
author: 'user3',
timestamp: Date.now(),
type: MessageType.VOTE,
signature: 'test',
browserPubKey: 'test',
},
]; ];
const comments: Comment[] = [ const comments: Comment[] = [
{ id: 'comment1', postId: '1', authorAddress: 'user1', content: 'Test comment', timestamp: Date.now(), upvotes: [], downvotes: [] } {
id: 'comment1',
postId: '1',
authorAddress: 'user1',
content: 'Test comment',
timestamp: Date.now(),
upvotes: [],
downvotes: [],
type: MessageType.COMMENT,
author: 'user1',
signature: 'test',
browserPubKey: 'test',
},
]; ];
const result = calculator.calculatePostScore(post, votes, comments, mockUserVerificationStatus); const result = calculator.calculatePostScore(
post,
votes,
comments,
mockUserVerificationStatus
);
expect(result.details.upvotes).toBe(2); expect(result.details.upvotes).toBe(2);
expect(result.details.comments).toBe(1); expect(result.details.comments).toBe(1);
expect(result.details.verifiedUpvotes).toBe(2); expect(result.details.verifiedUpvotes).toBe(2);
@ -146,32 +222,50 @@ describe('RelevanceCalculator', () => {
describe('timeDecay', () => { describe('timeDecay', () => {
it('should apply time decay to older posts', () => { it('should apply time decay to older posts', () => {
const now = Date.now(); const now = Date.now();
const oneWeekAgo = now - (7 * 24 * 60 * 60 * 1000); const oneWeekAgo = now - 7 * 24 * 60 * 60 * 1000;
const recentPost: Post = { const recentPost: Post = {
id: '1', id: '1',
cellId: 'cell1', cellId: 'cell1',
authorAddress: 'user2', authorAddress: 'user2',
type: MessageType.POST,
author: 'user2',
title: 'Recent Post', title: 'Recent Post',
content: 'Recent content', content: 'Recent content',
timestamp: now, timestamp: now,
upvotes: [], upvotes: [],
downvotes: [] downvotes: [],
signature: 'test',
browserPubKey: 'test',
}; };
const oldPost: Post = { const oldPost: Post = {
id: '2', id: '2',
type: MessageType.POST,
author: 'user2',
cellId: 'cell1', cellId: 'cell1',
authorAddress: 'user2', authorAddress: 'user2',
title: 'Old Post', title: 'Old Post',
content: 'Old content', content: 'Old content',
timestamp: oneWeekAgo, timestamp: oneWeekAgo,
upvotes: [], upvotes: [],
downvotes: [] downvotes: [],
signature: 'test',
browserPubKey: 'test',
}; };
const recentResult = calculator.calculatePostScore(recentPost, [], [], mockUserVerificationStatus); const recentResult = calculator.calculatePostScore(
const oldResult = calculator.calculatePostScore(oldPost, [], [], mockUserVerificationStatus); recentPost,
[],
[],
mockUserVerificationStatus
);
const oldResult = calculator.calculatePostScore(
oldPost,
[],
[],
mockUserVerificationStatus
);
expect(recentResult.score).toBeGreaterThan(oldResult.score); expect(recentResult.score).toBeGreaterThan(oldResult.score);
}); });
@ -184,28 +278,33 @@ describe('RelevanceCalculator', () => {
address: 'user1', address: 'user1',
walletType: 'ethereum', walletType: 'ethereum',
verificationStatus: EVerificationStatus.VERIFIED_OWNER, verificationStatus: EVerificationStatus.VERIFIED_OWNER,
ensOwnership: true, displayPreference: DisplayPreference.WALLET_ADDRESS,
ensName: 'test.eth', ensDetails: {
lastChecked: Date.now() ensName: 'test.eth',
},
ordinalDetails: undefined,
lastChecked: Date.now(),
}, },
{ {
address: 'user2', address: 'user2',
walletType: 'bitcoin', walletType: 'bitcoin',
verificationStatus: EVerificationStatus.UNVERIFIED, verificationStatus: EVerificationStatus.UNVERIFIED,
ordinalOwnership: false, displayPreference: DisplayPreference.WALLET_ADDRESS,
lastChecked: Date.now() ensDetails: undefined,
} ordinalDetails: undefined,
lastChecked: Date.now(),
},
]; ];
const status = calculator.buildUserVerificationStatus(users); const status = calculator.buildUserVerificationStatus(users);
expect(status['user1'].isVerified).toBe(true); expect(status['user1']?.isVerified).toBe(true);
expect(status['user1'].hasENS).toBe(true); expect(status['user1']?.hasENS).toBe(true);
expect(status['user1'].hasOrdinal).toBe(false); expect(status['user1']?.hasOrdinal).toBe(false);
expect(status['user2'].isVerified).toBe(false); expect(status['user2']?.isVerified).toBe(false);
expect(status['user2'].hasENS).toBe(false); expect(status['user2']?.hasENS).toBe(false);
expect(status['user2'].hasOrdinal).toBe(false); expect(status['user2']?.hasOrdinal).toBe(false);
}); });
}); });
}); });

View File

@ -1,15 +1,19 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { import {
MessageType,
UnsignedCellMessage,
UnsignedCommentMessage,
UnsignedPostMessage,
UnsignedVoteMessage,
UnsignedModerateMessage,
CellMessage, CellMessage,
CommentMessage, CommentMessage,
MessageType,
PostMessage, PostMessage,
VoteMessage,
ModerateMessage,
} from '@/types/waku'; } from '@/types/waku';
import { Cell, Comment, Post, User } from '@/types/forum'; import { Cell, Comment, Post } from '@/types/forum';
import { User } from '@/types/identity';
import { transformCell, transformComment, transformPost } from './transformers'; import { transformCell, transformComment, transformPost } from './transformers';
import { MessageService, AuthService, CryptoService } from '@/lib/services'; import { MessageService, CryptoService } from '@/lib/services';
type ToastFunction = (props: { type ToastFunction = (props: {
title: string; title: string;
@ -28,8 +32,7 @@ export const createPost = async (
currentUser: User | null, currentUser: User | null,
isAuthenticated: boolean, isAuthenticated: boolean,
toast: ToastFunction, toast: ToastFunction,
updateStateFromCache: () => void, updateStateFromCache: () => void
authService?: AuthService,
): Promise<Post | null> => { ): Promise<Post | null> => {
if (!isAuthenticated || !currentUser) { if (!isAuthenticated || !currentUser) {
toast({ toast({
@ -41,12 +44,19 @@ export const createPost = async (
} }
// Check if user has basic verification or better, or owns ENS/Ordinal // Check if user has basic verification or better, or owns ENS/Ordinal
const hasENSOrOrdinal = !!(currentUser.ensOwnership || currentUser.ordinalOwnership); const hasENSOrOrdinal = !!(
const isVerified = currentUser.verificationStatus === 'verified-owner' || currentUser.ensDetails || currentUser.ordinalDetails
currentUser.verificationStatus === 'verified-basic' || );
hasENSOrOrdinal; const isVerified =
currentUser.verificationStatus === 'verified-owner' ||
if (!isVerified && (currentUser.verificationStatus === 'unverified' || currentUser.verificationStatus === 'verifying')) { currentUser.verificationStatus === 'verified-basic' ||
hasENSOrOrdinal;
if (
!isVerified &&
(currentUser.verificationStatus === 'unverified' ||
currentUser.verificationStatus === 'verifying')
) {
toast({ toast({
title: 'Verification Required', title: 'Verification Required',
description: 'Please complete wallet verification to post.', description: 'Please complete wallet verification to post.',
@ -56,10 +66,13 @@ export const createPost = async (
} }
try { try {
toast({ title: 'Creating post', description: 'Sending your post to the network...' }); toast({
title: 'Creating post',
description: 'Sending your post to the network...',
});
const postId = uuidv4(); const postId = uuidv4();
const postMessage: PostMessage = { const postMessage: UnsignedPostMessage = {
type: MessageType.POST, type: MessageType.POST,
id: postId, id: postId,
cellId, cellId,
@ -70,7 +83,7 @@ export const createPost = async (
}; };
const cryptoService = new CryptoService(); const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService); const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(postMessage); const result = await messageService.sendMessage(postMessage);
if (!result.success) { if (!result.success) {
toast({ toast({
@ -82,7 +95,10 @@ export const createPost = async (
} }
updateStateFromCache(); updateStateFromCache();
toast({ title: 'Post Created', description: 'Your post has been published successfully.' }); toast({
title: 'Post Created',
description: 'Your post has been published successfully.',
});
return transformPost(result.message! as PostMessage); return transformPost(result.message! as PostMessage);
} catch (error) { } catch (error) {
console.error('Error creating post:', error); console.error('Error creating post:', error);
@ -101,8 +117,7 @@ export const createComment = async (
currentUser: User | null, currentUser: User | null,
isAuthenticated: boolean, isAuthenticated: boolean,
toast: ToastFunction, toast: ToastFunction,
updateStateFromCache: () => void, updateStateFromCache: () => void
authService?: AuthService,
): Promise<Comment | null> => { ): Promise<Comment | null> => {
if (!isAuthenticated || !currentUser) { if (!isAuthenticated || !currentUser) {
toast({ toast({
@ -114,12 +129,19 @@ export const createComment = async (
} }
// Check if user has basic verification or better, or owns ENS/Ordinal // Check if user has basic verification or better, or owns ENS/Ordinal
const hasENSOrOrdinal = !!(currentUser.ensOwnership || currentUser.ordinalOwnership); const hasENSOrOrdinal = !!(
const isVerified = currentUser.verificationStatus === 'verified-owner' || currentUser.ensDetails || currentUser.ordinalDetails
currentUser.verificationStatus === 'verified-basic' || );
hasENSOrOrdinal; const isVerified =
currentUser.verificationStatus === 'verified-owner' ||
if (!isVerified && (currentUser.verificationStatus === 'unverified' || currentUser.verificationStatus === 'verifying')) { currentUser.verificationStatus === 'verified-basic' ||
hasENSOrOrdinal;
if (
!isVerified &&
(currentUser.verificationStatus === 'unverified' ||
currentUser.verificationStatus === 'verifying')
) {
toast({ toast({
title: 'Verification Required', title: 'Verification Required',
description: 'Please complete wallet verification to comment.', description: 'Please complete wallet verification to comment.',
@ -129,10 +151,13 @@ export const createComment = async (
} }
try { try {
toast({ title: 'Posting comment', description: 'Sending your comment to the network...' }); toast({
title: 'Posting comment',
description: 'Sending your comment to the network...',
});
const commentId = uuidv4(); const commentId = uuidv4();
const commentMessage: CommentMessage = { const commentMessage: UnsignedCommentMessage = {
type: MessageType.COMMENT, type: MessageType.COMMENT,
id: commentId, id: commentId,
postId, postId,
@ -142,7 +167,7 @@ export const createComment = async (
}; };
const cryptoService = new CryptoService(); const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService); const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(commentMessage); const result = await messageService.sendMessage(commentMessage);
if (!result.success) { if (!result.success) {
toast({ toast({
@ -154,7 +179,10 @@ export const createComment = async (
} }
updateStateFromCache(); updateStateFromCache();
toast({ title: 'Comment Added', description: 'Your comment has been published.' }); toast({
title: 'Comment Added',
description: 'Your comment has been published.',
});
return transformComment(result.message! as CommentMessage); return transformComment(result.message! as CommentMessage);
} catch (error) { } catch (error) {
console.error('Error creating comment:', error); console.error('Error creating comment:', error);
@ -174,8 +202,7 @@ export const createCell = async (
currentUser: User | null, currentUser: User | null,
isAuthenticated: boolean, isAuthenticated: boolean,
toast: ToastFunction, toast: ToastFunction,
updateStateFromCache: () => void, updateStateFromCache: () => void
authService?: AuthService,
): Promise<Cell | null> => { ): Promise<Cell | null> => {
if (!isAuthenticated || !currentUser) { if (!isAuthenticated || !currentUser) {
toast({ toast({
@ -187,10 +214,13 @@ export const createCell = async (
} }
try { try {
toast({ title: 'Creating cell', description: 'Sending your cell to the network...' }); toast({
title: 'Creating cell',
description: 'Sending your cell to the network...',
});
const cellId = uuidv4(); const cellId = uuidv4();
const cellMessage: CellMessage = { const cellMessage: UnsignedCellMessage = {
type: MessageType.CELL, type: MessageType.CELL,
id: cellId, id: cellId,
name, name,
@ -201,7 +231,7 @@ export const createCell = async (
}; };
const cryptoService = new CryptoService(); const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService); const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(cellMessage); const result = await messageService.sendMessage(cellMessage);
if (!result.success) { if (!result.success) {
toast({ toast({
@ -213,7 +243,10 @@ export const createCell = async (
} }
updateStateFromCache(); updateStateFromCache();
toast({ title: 'Cell Created', description: 'Your cell has been published.' }); toast({
title: 'Cell Created',
description: 'Your cell has been published.',
});
return transformCell(result.message! as CellMessage); return transformCell(result.message! as CellMessage);
} catch (error) { } catch (error) {
console.error('Error creating cell:', error); console.error('Error creating cell:', error);
@ -236,8 +269,7 @@ export const vote = async (
currentUser: User | null, currentUser: User | null,
isAuthenticated: boolean, isAuthenticated: boolean,
toast: ToastFunction, toast: ToastFunction,
updateStateFromCache: () => void, updateStateFromCache: () => void
authService?: AuthService,
): Promise<boolean> => { ): Promise<boolean> => {
if (!isAuthenticated || !currentUser) { if (!isAuthenticated || !currentUser) {
toast({ toast({
@ -249,12 +281,19 @@ export const vote = async (
} }
// Check if user has basic verification or better, or owns ENS/Ordinal // Check if user has basic verification or better, or owns ENS/Ordinal
const hasENSOrOrdinal = !!(currentUser.ensOwnership || currentUser.ordinalOwnership); const hasENSOrOrdinal = !!(
const isVerified = currentUser.verificationStatus === 'verified-owner' || currentUser.ensDetails || currentUser.ordinalDetails
currentUser.verificationStatus === 'verified-basic' || );
hasENSOrOrdinal; const isVerified =
currentUser.verificationStatus === 'verified-owner' ||
if (!isVerified && (currentUser.verificationStatus === 'unverified' || currentUser.verificationStatus === 'verifying')) { currentUser.verificationStatus === 'verified-basic' ||
hasENSOrOrdinal;
if (
!isVerified &&
(currentUser.verificationStatus === 'unverified' ||
currentUser.verificationStatus === 'verifying')
) {
toast({ toast({
title: 'Verification Required', title: 'Verification Required',
description: 'Please complete wallet verification to vote.', description: 'Please complete wallet verification to vote.',
@ -265,10 +304,13 @@ export const vote = async (
try { try {
const voteType = isUpvote ? 'upvote' : 'downvote'; const voteType = isUpvote ? 'upvote' : 'downvote';
toast({ title: `Sending ${voteType}`, description: 'Recording your vote on the network...' }); toast({
title: `Sending ${voteType}`,
description: 'Recording your vote on the network...',
});
const voteId = uuidv4(); const voteId = uuidv4();
const voteMessage: VoteMessage = { const voteMessage: UnsignedVoteMessage = {
type: MessageType.VOTE, type: MessageType.VOTE,
id: voteId, id: voteId,
targetId, targetId,
@ -278,19 +320,23 @@ export const vote = async (
}; };
const cryptoService = new CryptoService(); const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService); const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(voteMessage); const result = await messageService.sendMessage(voteMessage);
if (!result.success) { if (!result.success) {
toast({ toast({
title: 'Vote Failed', title: 'Vote Failed',
description: result.error || 'Failed to register your vote. Please try again.', description:
result.error || 'Failed to register your vote. Please try again.',
variant: 'destructive', variant: 'destructive',
}); });
return false; return false;
} }
updateStateFromCache(); updateStateFromCache();
toast({ title: 'Vote Recorded', description: `Your ${voteType} has been registered.` }); toast({
title: 'Vote Recorded',
description: `Your ${voteType} has been registered.`,
});
return true; return true;
} catch (error) { } catch (error) {
console.error('Error voting:', error); console.error('Error voting:', error);
@ -315,8 +361,7 @@ export const moderatePost = async (
isAuthenticated: boolean, isAuthenticated: boolean,
cellOwner: string, cellOwner: string,
toast: ToastFunction, toast: ToastFunction,
updateStateFromCache: () => void, updateStateFromCache: () => void
authService?: AuthService,
): Promise<boolean> => { ): Promise<boolean> => {
if (!isAuthenticated || !currentUser) { if (!isAuthenticated || !currentUser) {
toast({ toast({
@ -327,14 +372,21 @@ export const moderatePost = async (
return false; return false;
} }
if (currentUser.address !== cellOwner) { if (currentUser.address !== cellOwner) {
toast({ title: 'Not Authorized', description: 'Only the cell admin can moderate posts.', variant: 'destructive' }); toast({
title: 'Not Authorized',
description: 'Only the cell admin can moderate posts.',
variant: 'destructive',
});
return false; return false;
} }
try { try {
toast({ title: 'Moderating Post', description: 'Sending moderation message to the network...' }); toast({
title: 'Moderating Post',
description: 'Sending moderation message to the network...',
});
const modMsg: ModerateMessage = { const modMsg: UnsignedModerateMessage = {
type: MessageType.MODERATE, type: MessageType.MODERATE,
id: uuidv4(), id: uuidv4(),
cellId, cellId,
@ -345,19 +397,31 @@ export const moderatePost = async (
author: currentUser.address, author: currentUser.address,
}; };
const cryptoService = new CryptoService(); const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService); const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(modMsg); const result = await messageService.sendMessage(modMsg);
if (!result.success) { if (!result.success) {
toast({ title: 'Moderation Failed', description: result.error || 'Failed to moderate post. Please try again.', variant: 'destructive' }); toast({
title: 'Moderation Failed',
description:
result.error || 'Failed to moderate post. Please try again.',
variant: 'destructive',
});
return false; return false;
} }
updateStateFromCache(); updateStateFromCache();
toast({ title: 'Post Moderated', description: 'The post has been marked as moderated.' }); toast({
title: 'Post Moderated',
description: 'The post has been marked as moderated.',
});
return true; return true;
} catch (error) { } catch (error) {
console.error('Error moderating post:', error); console.error('Error moderating post:', error);
toast({ title: 'Moderation Failed', description: 'Failed to moderate post. Please try again.', variant: 'destructive' }); toast({
title: 'Moderation Failed',
description: 'Failed to moderate post. Please try again.',
variant: 'destructive',
});
return false; return false;
} }
}; };
@ -370,22 +434,32 @@ export const moderateComment = async (
isAuthenticated: boolean, isAuthenticated: boolean,
cellOwner: string, cellOwner: string,
toast: ToastFunction, toast: ToastFunction,
updateStateFromCache: () => void, updateStateFromCache: () => void
authService?: AuthService,
): Promise<boolean> => { ): Promise<boolean> => {
if (!isAuthenticated || !currentUser) { if (!isAuthenticated || !currentUser) {
toast({ title: 'Authentication Required', description: 'You need to verify Ordinal ownership to moderate comments.', variant: 'destructive' }); toast({
title: 'Authentication Required',
description: 'You need to verify Ordinal ownership to moderate comments.',
variant: 'destructive',
});
return false; return false;
} }
if (currentUser.address !== cellOwner) { if (currentUser.address !== cellOwner) {
toast({ title: 'Not Authorized', description: 'Only the cell admin can moderate comments.', variant: 'destructive' }); toast({
title: 'Not Authorized',
description: 'Only the cell admin can moderate comments.',
variant: 'destructive',
});
return false; return false;
} }
try { try {
toast({ title: 'Moderating Comment', description: 'Sending moderation message to the network...' }); toast({
title: 'Moderating Comment',
description: 'Sending moderation message to the network...',
});
const modMsg: ModerateMessage = { const modMsg: UnsignedModerateMessage = {
type: MessageType.MODERATE, type: MessageType.MODERATE,
id: uuidv4(), id: uuidv4(),
cellId, cellId,
@ -396,19 +470,31 @@ export const moderateComment = async (
author: currentUser.address, author: currentUser.address,
}; };
const cryptoService = new CryptoService(); const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService); const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(modMsg); const result = await messageService.sendMessage(modMsg);
if (!result.success) { if (!result.success) {
toast({ title: 'Moderation Failed', description: result.error || 'Failed to moderate comment. Please try again.', variant: 'destructive' }); toast({
title: 'Moderation Failed',
description:
result.error || 'Failed to moderate comment. Please try again.',
variant: 'destructive',
});
return false; return false;
} }
updateStateFromCache(); updateStateFromCache();
toast({ title: 'Comment Moderated', description: 'The comment has been marked as moderated.' }); toast({
title: 'Comment Moderated',
description: 'The comment has been marked as moderated.',
});
return true; return true;
} catch (error) { } catch (error) {
console.error('Error moderating comment:', error); console.error('Error moderating comment:', error);
toast({ title: 'Moderation Failed', description: 'Failed to moderate comment. Please try again.', variant: 'destructive' }); toast({
title: 'Moderation Failed',
description: 'Failed to moderate comment. Please try again.',
variant: 'destructive',
});
return false; return false;
} }
}; };
@ -421,19 +507,26 @@ export const moderateUser = async (
isAuthenticated: boolean, isAuthenticated: boolean,
cellOwner: string, cellOwner: string,
toast: ToastFunction, toast: ToastFunction,
updateStateFromCache: () => void, updateStateFromCache: () => void
authService?: AuthService,
): Promise<boolean> => { ): Promise<boolean> => {
if (!isAuthenticated || !currentUser) { if (!isAuthenticated || !currentUser) {
toast({ title: 'Authentication Required', description: 'You need to verify Ordinal ownership to moderate users.', variant: 'destructive' }); toast({
title: 'Authentication Required',
description: 'You need to verify Ordinal ownership to moderate users.',
variant: 'destructive',
});
return false; return false;
} }
if (currentUser.address !== cellOwner) { if (currentUser.address !== cellOwner) {
toast({ title: 'Not Authorized', description: 'Only the cell admin can moderate users.', variant: 'destructive' }); toast({
title: 'Not Authorized',
description: 'Only the cell admin can moderate users.',
variant: 'destructive',
});
return false; return false;
} }
const modMsg: ModerateMessage = { const modMsg: UnsignedModerateMessage = {
type: MessageType.MODERATE, type: MessageType.MODERATE,
id: uuidv4(), id: uuidv4(),
cellId, cellId,
@ -442,18 +535,23 @@ export const moderateUser = async (
reason, reason,
author: currentUser.address, author: currentUser.address,
timestamp: Date.now(), timestamp: Date.now(),
signature: '',
browserPubKey: currentUser.browserPubKey,
}; };
const cryptoService = new CryptoService(); const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService); const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(modMsg); const result = await messageService.sendMessage(modMsg);
if (!result.success) { if (!result.success) {
toast({ title: 'Moderation Failed', description: result.error || 'Failed to moderate user. Please try again.', variant: 'destructive' }); toast({
title: 'Moderation Failed',
description: result.error || 'Failed to moderate user. Please try again.',
variant: 'destructive',
});
return false; return false;
} }
updateStateFromCache(); updateStateFromCache();
toast({ title: 'User Moderated', description: `User ${userAddress} has been moderated in this cell.` }); toast({
title: 'User Moderated',
description: `User ${userAddress} has been moderated in this cell.`,
});
return true; return true;
}; };

View File

@ -1,23 +1,30 @@
import { Post, Comment, Cell, User, RelevanceScoreDetails, UserVerificationStatus } from '@/types/forum'; import {
Post,
Comment,
Cell,
RelevanceScoreDetails,
UserVerificationStatus,
} from '@/types/forum';
import { User } from '@/types/identity';
import { VoteMessage } from '@/types/waku'; import { VoteMessage } from '@/types/waku';
export class RelevanceCalculator { export class RelevanceCalculator {
private static readonly BASE_SCORES = { private static readonly BASE_SCORES = {
POST: 10, POST: 10,
COMMENT: 5, COMMENT: 5,
CELL: 15 CELL: 15,
}; };
private static readonly ENGAGEMENT_SCORES = { private static readonly ENGAGEMENT_SCORES = {
UPVOTE: 1, UPVOTE: 1,
COMMENT: 0.5 COMMENT: 0.5,
}; };
private static readonly VERIFICATION_BONUS = 1.25; // 25% increase for ENS/Ordinal owners private static readonly VERIFICATION_BONUS = 1.25; // 25% increase for ENS/Ordinal owners
private static readonly BASIC_VERIFICATION_BONUS = 1.1; // 10% increase for basic verified users private static readonly BASIC_VERIFICATION_BONUS = 1.1; // 10% increase for basic verified users
private static readonly VERIFIED_UPVOTE_BONUS = 0.1; private static readonly VERIFIED_UPVOTE_BONUS = 0.1;
private static readonly VERIFIED_COMMENTER_BONUS = 0.05; private static readonly VERIFIED_COMMENTER_BONUS = 0.05;
private static readonly DECAY_RATE = 0.1; // λ = 0.1 private static readonly DECAY_RATE = 0.1; // λ = 0.1
private static readonly MODERATION_PENALTY = 0.5; // 50% reduction private static readonly MODERATION_PENALTY = 0.5; // 50% reduction
@ -37,29 +44,31 @@ export class RelevanceCalculator {
const engagementScore = this.applyEngagementScore(upvotes, comments); const engagementScore = this.applyEngagementScore(upvotes, comments);
score += engagementScore; score += engagementScore;
const { bonus: authorVerificationBonus, isVerified } = this.applyAuthorVerificationBonus( const { bonus: authorVerificationBonus, isVerified } =
score, this.applyAuthorVerificationBonus(
post.authorAddress, score,
userVerificationStatus post.authorAddress,
); userVerificationStatus
);
score += authorVerificationBonus; score += authorVerificationBonus;
const { bonus: verifiedUpvoteBonus, verifiedUpvotes } = this.applyVerifiedUpvoteBonus( const { bonus: verifiedUpvoteBonus, verifiedUpvotes } =
upvotes, this.applyVerifiedUpvoteBonus(upvotes, userVerificationStatus);
userVerificationStatus
);
score += verifiedUpvoteBonus; score += verifiedUpvoteBonus;
const { bonus: verifiedCommenterBonus, verifiedCommenters } = this.applyVerifiedCommenterBonus( const { bonus: verifiedCommenterBonus, verifiedCommenters } =
comments, this.applyVerifiedCommenterBonus(comments, userVerificationStatus);
userVerificationStatus
);
score += verifiedCommenterBonus; score += verifiedCommenterBonus;
const { decayedScore, multiplier: timeDecayMultiplier, daysOld } = this.applyTimeDecay(score, post.timestamp); const {
decayedScore,
multiplier: timeDecayMultiplier,
daysOld,
} = this.applyTimeDecay(score, post.timestamp);
score = decayedScore; score = decayedScore;
const { penalizedScore, penalty: moderationPenalty } = this.applyModerationPenalty(score, post.moderated || false); const { penalizedScore, penalty: moderationPenalty } =
this.applyModerationPenalty(score, post.moderated || false);
score = penalizedScore; score = penalizedScore;
const finalScore = Math.max(0, score); // Ensure non-negative score const finalScore = Math.max(0, score); // Ensure non-negative score
@ -81,8 +90,8 @@ export class RelevanceCalculator {
verifiedUpvotes, verifiedUpvotes,
verifiedCommenters, verifiedCommenters,
daysOld, daysOld,
isModerated: post.moderated || false isModerated: post.moderated || false,
} },
}; };
} }
@ -102,23 +111,27 @@ export class RelevanceCalculator {
const engagementScore = this.applyEngagementScore(upvotes, []); const engagementScore = this.applyEngagementScore(upvotes, []);
score += engagementScore; score += engagementScore;
const { bonus: authorVerificationBonus, isVerified } = this.applyAuthorVerificationBonus( const { bonus: authorVerificationBonus, isVerified } =
score, this.applyAuthorVerificationBonus(
comment.authorAddress, score,
userVerificationStatus comment.authorAddress,
); userVerificationStatus
);
score += authorVerificationBonus; score += authorVerificationBonus;
const { bonus: verifiedUpvoteBonus, verifiedUpvotes } = this.applyVerifiedUpvoteBonus( const { bonus: verifiedUpvoteBonus, verifiedUpvotes } =
upvotes, this.applyVerifiedUpvoteBonus(upvotes, userVerificationStatus);
userVerificationStatus
);
score += verifiedUpvoteBonus; score += verifiedUpvoteBonus;
const { decayedScore, multiplier: timeDecayMultiplier, daysOld } = this.applyTimeDecay(score, comment.timestamp); const {
decayedScore,
multiplier: timeDecayMultiplier,
daysOld,
} = this.applyTimeDecay(score, comment.timestamp);
score = decayedScore; score = decayedScore;
const { penalizedScore, penalty: moderationPenalty } = this.applyModerationPenalty(score, comment.moderated || false); const { penalizedScore, penalty: moderationPenalty } =
this.applyModerationPenalty(score, comment.moderated || false);
score = penalizedScore; score = penalizedScore;
const finalScore = Math.max(0, score); // Ensure non-negative score const finalScore = Math.max(0, score); // Ensure non-negative score
@ -140,8 +153,8 @@ export class RelevanceCalculator {
verifiedUpvotes, verifiedUpvotes,
verifiedCommenters: 0, verifiedCommenters: 0,
daysOld, daysOld,
isModerated: comment.moderated || false isModerated: comment.moderated || false,
} },
}; };
} }
@ -150,7 +163,7 @@ export class RelevanceCalculator {
*/ */
calculateCellScore( calculateCellScore(
cell: Cell, cell: Cell,
posts: Post[], posts: Post[]
): { score: number; details: RelevanceScoreDetails } { ): { score: number; details: RelevanceScoreDetails } {
// Apply base score // Apply base score
let score = this.applyBaseScore('CELL'); let score = this.applyBaseScore('CELL');
@ -161,16 +174,24 @@ export class RelevanceCalculator {
const totalUpvotes = cellPosts.reduce((sum, post) => { const totalUpvotes = cellPosts.reduce((sum, post) => {
return sum + (post.upvotes?.length || 0); return sum + (post.upvotes?.length || 0);
}, 0); }, 0);
const activityScore = cellPosts.length * RelevanceCalculator.ENGAGEMENT_SCORES.COMMENT; const activityScore =
cellPosts.length * RelevanceCalculator.ENGAGEMENT_SCORES.COMMENT;
const engagementBonus = totalUpvotes * 0.1; // Small bonus for cell activity const engagementBonus = totalUpvotes * 0.1; // Small bonus for cell activity
const engagementScore = activityScore + engagementBonus; const engagementScore = activityScore + engagementBonus;
score += engagementScore; score += engagementScore;
const mostRecentPost = cellPosts.reduce((latest, post) => { const mostRecentPost = cellPosts.reduce(
return post.timestamp > latest.timestamp ? post : latest; (latest, post) => {
}, { timestamp: Date.now() }); return post.timestamp > latest.timestamp ? post : latest;
const { decayedScore, multiplier: timeDecayMultiplier, daysOld } = this.applyTimeDecay(score, mostRecentPost.timestamp); },
{ timestamp: Date.now() }
);
const {
decayedScore,
multiplier: timeDecayMultiplier,
daysOld,
} = this.applyTimeDecay(score, mostRecentPost.timestamp);
score = decayedScore; score = decayedScore;
const finalScore = Math.max(0, score); // Ensure non-negative score const finalScore = Math.max(0, score); // Ensure non-negative score
@ -192,18 +213,20 @@ export class RelevanceCalculator {
verifiedUpvotes: 0, verifiedUpvotes: 0,
verifiedCommenters: 0, verifiedCommenters: 0,
daysOld, daysOld,
isModerated: false isModerated: false,
} },
}; };
} }
/** /**
* Check if a user is verified (has ENS or ordinal ownership, or basic verification) * Check if a user is verified (has ENS or ordinal ownership, or basic verification)
*/ */
isUserVerified(user: User): boolean { isUserVerified(user: User): boolean {
return !!(user.ensOwnership || user.ordinalOwnership || user.verificationStatus === 'verified-basic'); return !!(
user.ensDetails ||
user.ordinalDetails ||
user.verificationStatus === 'verified-basic'
);
} }
/** /**
@ -211,130 +234,140 @@ export class RelevanceCalculator {
*/ */
buildUserVerificationStatus(users: User[]): UserVerificationStatus { buildUserVerificationStatus(users: User[]): UserVerificationStatus {
const status: UserVerificationStatus = {}; const status: UserVerificationStatus = {};
users.forEach(user => { users.forEach(user => {
status[user.address] = { status[user.address] = {
isVerified: this.isUserVerified(user), isVerified: this.isUserVerified(user),
hasENS: !!user.ensOwnership, hasENS: !!user.ensDetails,
hasOrdinal: !!user.ordinalOwnership, hasOrdinal: !!user.ordinalDetails,
ensName: user.ensName, ensName: user.ensDetails?.ensName,
verificationStatus: user.verificationStatus verificationStatus: user.verificationStatus,
}; };
}); });
return status; return status;
} }
/** /**
* Apply base score to the current score * Apply base score to the current score
*/ */
private applyBaseScore(type: 'POST' | 'COMMENT' | 'CELL'): number { private applyBaseScore(type: 'POST' | 'COMMENT' | 'CELL'): number {
return RelevanceCalculator.BASE_SCORES[type]; return RelevanceCalculator.BASE_SCORES[type];
} }
/**
* Apply engagement score based on upvotes and comments
*/
private applyEngagementScore(
upvotes: VoteMessage[],
comments: Comment[] = []
): number {
const upvoteScore = upvotes.length * RelevanceCalculator.ENGAGEMENT_SCORES.UPVOTE;
const commentScore = comments.length * RelevanceCalculator.ENGAGEMENT_SCORES.COMMENT;
return upvoteScore + commentScore;
}
/**
* Apply verification bonus for verified authors
*/
private applyAuthorVerificationBonus(
score: number,
authorAddress: string,
userVerificationStatus: UserVerificationStatus
): { bonus: number; isVerified: boolean } {
const authorStatus = userVerificationStatus[authorAddress];
const isVerified = authorStatus?.isVerified || false;
if (!isVerified) {
return { bonus: 0, isVerified: false };
}
// Apply different bonuses based on verification level /**
let bonus = 0; * Apply engagement score based on upvotes and comments
if (authorStatus?.verificationStatus === 'verified-owner') { */
// Full bonus for ENS/Ordinal owners private applyEngagementScore(
bonus = score * (RelevanceCalculator.VERIFICATION_BONUS - 1); upvotes: VoteMessage[],
} else if (authorStatus?.verificationStatus === 'verified-basic') { comments: Comment[] = []
// Lower bonus for basic verified users ): number {
bonus = score * (RelevanceCalculator.BASIC_VERIFICATION_BONUS - 1); const upvoteScore =
} upvotes.length * RelevanceCalculator.ENGAGEMENT_SCORES.UPVOTE;
const commentScore =
comments.length * RelevanceCalculator.ENGAGEMENT_SCORES.COMMENT;
return upvoteScore + commentScore;
}
return { bonus, isVerified: true }; /**
} * Apply verification bonus for verified authors
*/
/** private applyAuthorVerificationBonus(
* Apply verified upvote bonus score: number,
*/ authorAddress: string,
private applyVerifiedUpvoteBonus( userVerificationStatus: UserVerificationStatus
upvotes: VoteMessage[], ): { bonus: number; isVerified: boolean } {
userVerificationStatus: UserVerificationStatus const authorStatus = userVerificationStatus[authorAddress];
): { bonus: number; verifiedUpvotes: number } { const isVerified = authorStatus?.isVerified || false;
const verifiedUpvotes = upvotes.filter(vote => {
const voterStatus = userVerificationStatus[vote.author]; if (!isVerified) {
return voterStatus?.isVerified; return { bonus: 0, isVerified: false };
}); }
const bonus = verifiedUpvotes.length * RelevanceCalculator.VERIFIED_UPVOTE_BONUS; // Apply different bonuses based on verification level
return { bonus, verifiedUpvotes: verifiedUpvotes.length }; let bonus = 0;
} if (authorStatus?.verificationStatus === 'verified-owner') {
// Full bonus for ENS/Ordinal owners
/** bonus = score * (RelevanceCalculator.VERIFICATION_BONUS - 1);
* Apply verified commenter bonus } else if (authorStatus?.verificationStatus === 'verified-basic') {
*/ // Lower bonus for basic verified users
private applyVerifiedCommenterBonus( bonus = score * (RelevanceCalculator.BASIC_VERIFICATION_BONUS - 1);
comments: Comment[], }
userVerificationStatus: UserVerificationStatus
): { bonus: number; verifiedCommenters: number } { return { bonus, isVerified: true };
const verifiedCommenters = new Set<string>(); }
comments.forEach(comment => { /**
const commenterStatus = userVerificationStatus[comment.authorAddress]; * Apply verified upvote bonus
if (commenterStatus?.isVerified) { */
verifiedCommenters.add(comment.authorAddress); private applyVerifiedUpvoteBonus(
} upvotes: VoteMessage[],
}); userVerificationStatus: UserVerificationStatus
): { bonus: number; verifiedUpvotes: number } {
const bonus = verifiedCommenters.size * RelevanceCalculator.VERIFIED_COMMENTER_BONUS; const verifiedUpvotes = upvotes.filter(vote => {
return { bonus, verifiedCommenters: verifiedCommenters.size }; const voterStatus = userVerificationStatus[vote.author];
} return voterStatus?.isVerified;
});
/**
* Apply time decay to a score const bonus =
*/ verifiedUpvotes.length * RelevanceCalculator.VERIFIED_UPVOTE_BONUS;
private applyTimeDecay(score: number, timestamp: number): { return { bonus, verifiedUpvotes: verifiedUpvotes.length };
decayedScore: number; }
multiplier: number;
daysOld: number /**
} { * Apply verified commenter bonus
const daysOld = (Date.now() - timestamp) / (1000 * 60 * 60 * 24); */
const multiplier = Math.exp(-RelevanceCalculator.DECAY_RATE * daysOld); private applyVerifiedCommenterBonus(
const decayedScore = score * multiplier; comments: Comment[],
userVerificationStatus: UserVerificationStatus
return { decayedScore, multiplier, daysOld }; ): { bonus: number; verifiedCommenters: number } {
} const verifiedCommenters = new Set<string>();
/** comments.forEach(comment => {
* Apply moderation penalty const commenterStatus = userVerificationStatus[comment.authorAddress];
*/ if (commenterStatus?.isVerified) {
private applyModerationPenalty( verifiedCommenters.add(comment.authorAddress);
score: number,
isModerated: boolean
): { penalizedScore: number; penalty: number } {
if (isModerated) {
const penalizedScore = score * RelevanceCalculator.MODERATION_PENALTY;
return { penalizedScore, penalty: RelevanceCalculator.MODERATION_PENALTY };
}
return { penalizedScore: score, penalty: 1 };
} }
});
const bonus =
verifiedCommenters.size * RelevanceCalculator.VERIFIED_COMMENTER_BONUS;
return { bonus, verifiedCommenters: verifiedCommenters.size };
}
/**
* Apply time decay to a score
*/
private applyTimeDecay(
score: number,
timestamp: number
): {
decayedScore: number;
multiplier: number;
daysOld: number;
} {
const daysOld = (Date.now() - timestamp) / (1000 * 60 * 60 * 24);
const multiplier = Math.exp(-RelevanceCalculator.DECAY_RATE * daysOld);
const decayedScore = score * multiplier;
return { decayedScore, multiplier, daysOld };
}
/**
* Apply moderation penalty
*/
private applyModerationPenalty(
score: number,
isModerated: boolean
): { penalizedScore: number; penalty: number } {
if (isModerated) {
const penalizedScore = score * RelevanceCalculator.MODERATION_PENALTY;
return {
penalizedScore,
penalty: RelevanceCalculator.MODERATION_PENALTY,
};
}
return { penalizedScore: score, penalty: 1 };
}
} }

View File

@ -6,7 +6,9 @@ export type SortOption = 'relevance' | 'time';
* Sort posts by relevance score (highest first) * Sort posts by relevance score (highest first)
*/ */
export const sortByRelevance = (items: Post[] | Comment[] | Cell[]) => { export const sortByRelevance = (items: Post[] | Comment[] | Cell[]) => {
return items.sort((a, b) => (b.relevanceScore || 0) - (a.relevanceScore || 0)); return items.sort(
(a, b) => (b.relevanceScore || 0) - (a.relevanceScore || 0)
);
}; };
/** /**
@ -33,7 +35,10 @@ export const sortPosts = (posts: Post[], option: SortOption): Post[] => {
/** /**
* Sort comments with a specific option * Sort comments with a specific option
*/ */
export const sortComments = (comments: Comment[], option: SortOption): Comment[] => { export const sortComments = (
comments: Comment[],
option: SortOption
): Comment[] => {
switch (option) { switch (option) {
case 'relevance': case 'relevance':
return sortByRelevance(comments) as Comment[]; return sortByRelevance(comments) as Comment[];

View File

@ -1,27 +1,52 @@
import { Cell, Post, Comment, OpchanMessage } from '@/types/forum'; import { Cell, Post, Comment } from '@/types/forum';
import { CellMessage, CommentMessage, PostMessage, VoteMessage } from '@/types/waku'; import {
CellMessage,
CommentMessage,
PostMessage,
VoteMessage,
} from '@/types/waku';
import messageManager from '@/lib/waku'; import messageManager from '@/lib/waku';
import { RelevanceCalculator } from './relevance'; import { RelevanceCalculator } from './relevance';
import { UserVerificationStatus } from '@/types/forum'; import { UserVerificationStatus } from '@/types/forum';
import { MessageValidator } from '@/lib/utils/MessageValidator';
type VerifyFunction = (message: OpchanMessage) => boolean; // Global validator instance for transformers
const messageValidator = new MessageValidator();
export const transformCell = ( export const transformCell = (
cellMessage: CellMessage, cellMessage: CellMessage,
verifyMessage?: VerifyFunction, _verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
userVerificationStatus?: UserVerificationStatus, userVerificationStatus?: UserVerificationStatus,
posts?: Post[] posts?: Post[]
): Cell | null => { ): Cell | null => {
if (verifyMessage && !verifyMessage(cellMessage)) { // MANDATORY: All messages must have valid signatures
console.warn(`Cell message ${cellMessage.id} failed verification`); // Since CellMessage extends BaseMessage, it already has required signature fields
// But we still need to verify the signature cryptographically
if (!cellMessage.signature || !cellMessage.browserPubKey) {
console.warn(
`Cell message ${cellMessage.id} missing required signature fields`
);
return null; return null;
} }
const transformedCell = { // Verify signature using the message validator's crypto service
const validationReport = messageValidator.getValidationReport(cellMessage);
if (!validationReport.hasValidSignature) {
console.warn(
`Cell message ${cellMessage.id} failed signature validation:`,
validationReport.errors
);
return null;
}
const transformedCell: Cell = {
id: cellMessage.id, id: cellMessage.id,
type: cellMessage.type,
author: cellMessage.author,
name: cellMessage.name, name: cellMessage.name,
description: cellMessage.description, description: cellMessage.description,
icon: cellMessage.icon || '', icon: cellMessage.icon || '',
timestamp: cellMessage.timestamp,
signature: cellMessage.signature, signature: cellMessage.signature,
browserPubKey: cellMessage.browserPubKey, browserPubKey: cellMessage.browserPubKey,
}; };
@ -29,11 +54,10 @@ export const transformCell = (
// Calculate relevance score if user verification status and posts are provided // Calculate relevance score if user verification status and posts are provided
if (userVerificationStatus && posts) { if (userVerificationStatus && posts) {
const relevanceCalculator = new RelevanceCalculator(); const relevanceCalculator = new RelevanceCalculator();
const relevanceResult = relevanceCalculator.calculateCellScore( const relevanceResult = relevanceCalculator.calculateCellScore(
transformedCell, transformedCell,
posts, posts
userVerificationStatus
); );
// Calculate active member count // Calculate active member count
@ -47,7 +71,7 @@ export const transformCell = (
...transformedCell, ...transformedCell,
relevanceScore: relevanceResult.score, relevanceScore: relevanceResult.score,
activeMemberCount: activeMembers.size, activeMemberCount: activeMembers.size,
relevanceDetails: relevanceResult.details relevanceDetails: relevanceResult.details,
}; };
} }
@ -56,72 +80,121 @@ export const transformCell = (
export const transformPost = ( export const transformPost = (
postMessage: PostMessage, postMessage: PostMessage,
verifyMessage?: VerifyFunction, _verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
userVerificationStatus?: UserVerificationStatus userVerificationStatus?: UserVerificationStatus
): Post | null => { ): Post | null => {
if (verifyMessage && !verifyMessage(postMessage)) { // MANDATORY: All messages must have valid signatures
console.warn(`Post message ${postMessage.id} failed verification`); if (!postMessage.signature || !postMessage.browserPubKey) {
console.warn(
`Post message ${postMessage.id} missing required signature fields`
);
return null;
}
// Verify signature using the message validator's crypto service
const validationReport = messageValidator.getValidationReport(postMessage);
if (!validationReport.hasValidSignature) {
console.warn(
`Post message ${postMessage.id} failed signature validation:`,
validationReport.errors
);
return null; return null;
} }
const votes = Object.values(messageManager.messageCache.votes).filter( const votes = Object.values(messageManager.messageCache.votes).filter(
(vote) => vote.targetId === postMessage.id, vote => vote.targetId === postMessage.id
); );
const filteredVotes = verifyMessage // MANDATORY: Filter out votes with invalid signatures
? votes.filter((vote) => verifyMessage(vote)) const filteredVotes = votes.filter(vote => {
: votes; if (!vote.signature || !vote.browserPubKey) {
const upvotes = filteredVotes.filter((vote) => vote.value === 1); console.warn(`Vote ${vote.id} missing signature fields`);
const downvotes = filteredVotes.filter((vote) => vote.value === -1); return false;
}
const voteValidation = messageValidator.getValidationReport(vote);
if (!voteValidation.hasValidSignature) {
console.warn(
`Vote ${vote.id} failed signature validation:`,
voteValidation.errors
);
return false;
}
return true;
});
const upvotes = filteredVotes.filter(vote => vote.value === 1);
const downvotes = filteredVotes.filter(vote => vote.value === -1);
const modMsg = messageManager.messageCache.moderations[postMessage.id]; const modMsg = messageManager.messageCache.moderations[postMessage.id];
const isPostModerated = !!modMsg && modMsg.targetType === 'post'; const isPostModerated = !!modMsg && modMsg.targetType === 'post';
const userModMsg = Object.values(messageManager.messageCache.moderations).find( const userModMsg = Object.values(
(m) => m.targetType === 'user' && m.cellId === postMessage.cellId && m.targetId === postMessage.author, messageManager.messageCache.moderations
).find(
m =>
m.targetType === 'user' &&
m.cellId === postMessage.cellId &&
m.targetId === postMessage.author
); );
const isUserModerated = !!userModMsg; const isUserModerated = !!userModMsg;
const transformedPost = { const transformedPost: Post = {
id: postMessage.id, id: postMessage.id,
type: postMessage.type,
author: postMessage.author,
cellId: postMessage.cellId, cellId: postMessage.cellId,
authorAddress: postMessage.author, authorAddress: postMessage.author,
title: postMessage.title, title: postMessage.title,
content: postMessage.content, content: postMessage.content,
timestamp: postMessage.timestamp, timestamp: postMessage.timestamp,
upvotes,
downvotes,
signature: postMessage.signature, signature: postMessage.signature,
browserPubKey: postMessage.browserPubKey, browserPubKey: postMessage.browserPubKey,
upvotes,
downvotes,
moderated: isPostModerated || isUserModerated, moderated: isPostModerated || isUserModerated,
moderatedBy: isPostModerated ? modMsg.author : isUserModerated ? userModMsg!.author : undefined, moderatedBy: isPostModerated
moderationReason: isPostModerated ? modMsg.reason : isUserModerated ? userModMsg!.reason : undefined, ? modMsg.author
moderationTimestamp: isPostModerated ? modMsg.timestamp : isUserModerated ? userModMsg!.timestamp : undefined, : isUserModerated
? userModMsg!.author
: undefined,
moderationReason: isPostModerated
? modMsg.reason
: isUserModerated
? userModMsg!.reason
: undefined,
moderationTimestamp: isPostModerated
? modMsg.timestamp
: isUserModerated
? userModMsg!.timestamp
: undefined,
}; };
// Calculate relevance score if user verification status is provided // Calculate relevance score if user verification status is provided
if (userVerificationStatus) { if (userVerificationStatus) {
const relevanceCalculator = new RelevanceCalculator(); const relevanceCalculator = new RelevanceCalculator();
// Get comments for this post // Get comments for this post
const comments = Object.values(messageManager.messageCache.comments) const comments = Object.values(messageManager.messageCache.comments)
.map((comment) => transformComment(comment, verifyMessage, userVerificationStatus)) .map(comment =>
transformComment(comment, undefined, userVerificationStatus)
)
.filter(Boolean) as Comment[]; .filter(Boolean) as Comment[];
const postComments = comments.filter(comment => comment.postId === postMessage.id); const postComments = comments.filter(
comment => comment.postId === postMessage.id
);
const relevanceResult = relevanceCalculator.calculatePostScore( const relevanceResult = relevanceCalculator.calculatePostScore(
transformedPost, transformedPost,
filteredVotes, filteredVotes,
postComments, postComments,
userVerificationStatus userVerificationStatus
); );
const relevanceScore = relevanceResult.score; const relevanceScore = relevanceResult.score;
// Calculate verified upvotes and commenters // Calculate verified upvotes and commenters
const verifiedUpvotes = upvotes.filter(vote => { const verifiedUpvotes = upvotes.filter(vote => {
const voterStatus = userVerificationStatus[vote.author]; const voterStatus = userVerificationStatus[vote.author];
return voterStatus?.isVerified; return voterStatus?.isVerified;
}).length; }).length;
const verifiedCommenters = new Set<string>(); const verifiedCommenters = new Set<string>();
postComments.forEach(comment => { postComments.forEach(comment => {
const commenterStatus = userVerificationStatus[comment.authorAddress]; const commenterStatus = userVerificationStatus[comment.authorAddress];
@ -135,7 +208,7 @@ export const transformPost = (
relevanceScore, relevanceScore,
verifiedUpvotes, verifiedUpvotes,
verifiedCommenters: Array.from(verifiedCommenters), verifiedCommenters: Array.from(verifiedCommenters),
relevanceDetails: relevanceResult.details relevanceDetails: relevanceResult.details,
}; };
} }
@ -144,52 +217,94 @@ export const transformPost = (
export const transformComment = ( export const transformComment = (
commentMessage: CommentMessage, commentMessage: CommentMessage,
verifyMessage?: VerifyFunction, _verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
userVerificationStatus?: UserVerificationStatus userVerificationStatus?: UserVerificationStatus
): Comment | null => { ): Comment | null => {
if (verifyMessage && !verifyMessage(commentMessage)) { // MANDATORY: All messages must have valid signatures
console.warn(`Comment message ${commentMessage.id} failed verification`); if (!commentMessage.signature || !commentMessage.browserPubKey) {
console.warn(
`Comment message ${commentMessage.id} missing required signature fields`
);
return null;
}
// Verify signature using the message validator's crypto service
const validationReport = messageValidator.getValidationReport(commentMessage);
if (!validationReport.hasValidSignature) {
console.warn(
`Comment message ${commentMessage.id} failed signature validation:`,
validationReport.errors
);
return null; return null;
} }
const votes = Object.values(messageManager.messageCache.votes).filter( const votes = Object.values(messageManager.messageCache.votes).filter(
(vote) => vote.targetId === commentMessage.id, vote => vote.targetId === commentMessage.id
); );
const filteredVotes = verifyMessage // MANDATORY: Filter out votes with invalid signatures
? votes.filter((vote) => verifyMessage(vote)) const filteredVotes = votes.filter(vote => {
: votes; if (!vote.signature || !vote.browserPubKey) {
const upvotes = filteredVotes.filter((vote) => vote.value === 1); console.warn(`Vote ${vote.id} missing signature fields`);
const downvotes = filteredVotes.filter((vote) => vote.value === -1); return false;
}
const voteValidation = messageValidator.getValidationReport(vote);
if (!voteValidation.hasValidSignature) {
console.warn(
`Vote ${vote.id} failed signature validation:`,
voteValidation.errors
);
return false;
}
return true;
});
const upvotes = filteredVotes.filter(vote => vote.value === 1);
const downvotes = filteredVotes.filter(vote => vote.value === -1);
const modMsg = messageManager.messageCache.moderations[commentMessage.id]; const modMsg = messageManager.messageCache.moderations[commentMessage.id];
const isCommentModerated = !!modMsg && modMsg.targetType === 'comment'; const isCommentModerated = !!modMsg && modMsg.targetType === 'comment';
const userModMsg = Object.values(messageManager.messageCache.moderations).find( const userModMsg = Object.values(
(m) => messageManager.messageCache.moderations
).find(
m =>
m.targetType === 'user' && m.targetType === 'user' &&
m.cellId === commentMessage.postId.split('-')[0] && m.cellId === commentMessage.postId.split('-')[0] &&
m.targetId === commentMessage.author, m.targetId === commentMessage.author
); );
const isUserModerated = !!userModMsg; const isUserModerated = !!userModMsg;
const transformedComment = { const transformedComment: Comment = {
id: commentMessage.id, id: commentMessage.id,
type: commentMessage.type,
author: commentMessage.author,
postId: commentMessage.postId, postId: commentMessage.postId,
authorAddress: commentMessage.author, authorAddress: commentMessage.author,
content: commentMessage.content, content: commentMessage.content,
timestamp: commentMessage.timestamp, timestamp: commentMessage.timestamp,
upvotes,
downvotes,
signature: commentMessage.signature, signature: commentMessage.signature,
browserPubKey: commentMessage.browserPubKey, browserPubKey: commentMessage.browserPubKey,
upvotes,
downvotes,
moderated: isCommentModerated || isUserModerated, moderated: isCommentModerated || isUserModerated,
moderatedBy: isCommentModerated ? modMsg.author : isUserModerated ? userModMsg!.author : undefined, moderatedBy: isCommentModerated
moderationReason: isCommentModerated ? modMsg.reason : isUserModerated ? userModMsg!.reason : undefined, ? modMsg.author
moderationTimestamp: isCommentModerated ? modMsg.timestamp : isUserModerated ? userModMsg!.timestamp : undefined, : isUserModerated
? userModMsg!.author
: undefined,
moderationReason: isCommentModerated
? modMsg.reason
: isUserModerated
? userModMsg!.reason
: undefined,
moderationTimestamp: isCommentModerated
? modMsg.timestamp
: isUserModerated
? userModMsg!.timestamp
: undefined,
}; };
// Calculate relevance score if user verification status is provided // Calculate relevance score if user verification status is provided
if (userVerificationStatus) { if (userVerificationStatus) {
const relevanceCalculator = new RelevanceCalculator(); const relevanceCalculator = new RelevanceCalculator();
const relevanceResult = relevanceCalculator.calculateCommentScore( const relevanceResult = relevanceCalculator.calculateCommentScore(
transformedComment, transformedComment,
filteredVotes, filteredVotes,
@ -199,7 +314,7 @@ export const transformComment = (
return { return {
...transformedComment, ...transformedComment,
relevanceScore: relevanceResult.score, relevanceScore: relevanceResult.score,
relevanceDetails: relevanceResult.details relevanceDetails: relevanceResult.details,
}; };
} }
@ -208,32 +323,47 @@ export const transformComment = (
export const transformVote = ( export const transformVote = (
voteMessage: VoteMessage, voteMessage: VoteMessage,
verifyMessage?: VerifyFunction, _verifyMessage?: unknown // Deprecated parameter, kept for compatibility
): VoteMessage | null => { ): VoteMessage | null => {
if (verifyMessage && !verifyMessage(voteMessage)) { // MANDATORY: All messages must have valid signatures
console.warn(`Vote message ${voteMessage.id} failed verification`); if (!voteMessage.signature || !voteMessage.browserPubKey) {
console.warn(
`Vote message ${voteMessage.id} missing required signature fields`
);
return null; return null;
} }
// Verify signature using the message validator's crypto service
const validationReport = messageValidator.getValidationReport(voteMessage);
if (!validationReport.hasValidSignature) {
console.warn(
`Vote message ${voteMessage.id} failed signature validation:`,
validationReport.errors
);
return null;
}
return voteMessage; return voteMessage;
}; };
export const getDataFromCache = ( export const getDataFromCache = (
verifyMessage?: VerifyFunction, _verifyMessage?: unknown, // Deprecated parameter, kept for compatibility
userVerificationStatus?: UserVerificationStatus userVerificationStatus?: UserVerificationStatus
) => { ) => {
// First transform posts and comments to get relevance scores // First transform posts and comments to get relevance scores
// All validation is now handled internally by the transform functions
const posts = Object.values(messageManager.messageCache.posts) const posts = Object.values(messageManager.messageCache.posts)
.map((post) => transformPost(post, verifyMessage, userVerificationStatus)) .map(post => transformPost(post, undefined, userVerificationStatus))
.filter(Boolean) as Post[]; .filter(Boolean) as Post[];
const comments = Object.values(messageManager.messageCache.comments) const comments = Object.values(messageManager.messageCache.comments)
.map((c) => transformComment(c, verifyMessage, userVerificationStatus)) .map(c => transformComment(c, undefined, userVerificationStatus))
.filter(Boolean) as Comment[]; .filter(Boolean) as Comment[];
// Then transform cells with posts for relevance calculation // Then transform cells with posts for relevance calculation
const cells = Object.values(messageManager.messageCache.cells) const cells = Object.values(messageManager.messageCache.cells)
.map((cell) => transformCell(cell, verifyMessage, userVerificationStatus, posts)) .map(cell => transformCell(cell, undefined, userVerificationStatus, posts))
.filter(Boolean) as Cell[]; .filter(Boolean) as Cell[];
return { cells, posts, comments }; return { cells, posts, comments };
}; };

View File

@ -9,38 +9,46 @@ export class OrdinalAPI {
* @returns A promise that resolves with the API response. * @returns A promise that resolves with the API response.
*/ */
async getOperatorDetails(address: string): Promise<OrdinalApiResponse> { async getOperatorDetails(address: string): Promise<OrdinalApiResponse> {
if (import.meta.env.VITE_OPCHAN_MOCK_ORDINAL_CHECK === 'true') { if (import.meta.env.VITE_OPCHAN_MOCK_ORDINAL_CHECK === 'true') {
console.log(`[DEV] Bypassing ordinal verification for address: ${address}`); console.log(
`[DEV] Bypassing ordinal verification for address: ${address}`
);
return { return {
has_operators: true, has_operators: true,
error_message: '', error_message: '',
data: [] data: [],
}; };
} }
const url = `${BASE_URL}/${address}/detail/`; const url = `${BASE_URL}/${address}/detail/`;
try { try {
const response = await fetch(url, { const response = await fetch(url, {
method: 'GET', method: 'GET',
headers: { 'Accept': 'application/json' }, headers: { Accept: 'application/json' },
}); });
if (!response.ok) { if (!response.ok) {
const errorBody = await response.text().catch(() => ''); const errorBody = await response.text().catch(() => '');
throw new Error(`HTTP error! status: ${response.status}, message: ${errorBody || response.statusText}`); throw new Error(
`HTTP error! status: ${response.status}, message: ${errorBody || response.statusText}`
);
} }
const data: OrdinalApiResponse = await response.json(); const data: OrdinalApiResponse = await response.json();
if (data.error_message) { if (data.error_message) {
console.warn(`API returned an error message for address ${address}: ${data.error_message}`); console.warn(
`API returned an error message for address ${address}: ${data.error_message}`
);
} }
return data; return data;
} catch (error) { } catch (error) {
console.error(`Failed to fetch ordinal details for address ${address}:`, error); console.error(
`Failed to fetch ordinal details for address ${address}:`,
error
);
throw error; throw error;
} }
} }
} }

View File

@ -22,4 +22,4 @@ export interface OrdinalApiResponse {
has_operators: boolean; has_operators: boolean;
error_message: string; error_message: string;
data: OrdinalDetail[]; data: OrdinalDetail[];
} }

View File

@ -1,10 +1,13 @@
import { UseAppKitAccountReturn } from '@reown/appkit/react'; import { UseAppKitAccountReturn } from '@reown/appkit/react';
import { CryptoService, DelegationDuration } from '../../services/CryptoService'; import {
CryptoService,
DelegationDuration,
} from '../../services/CryptoService';
import { AppKit } from '@reown/appkit'; import { AppKit } from '@reown/appkit';
import { getEnsName } from '@wagmi/core'; import { getEnsName } from '@wagmi/core';
import { ChainNamespace } from '@reown/appkit-common'; import { ChainNamespace } from '@reown/appkit-common';
import { config } from './appkit'; import { config } from './appkit';
import { Provider} from '@reown/appkit-controllers'; import { Provider } from '@reown/appkit-controllers';
export interface WalletInfo { export interface WalletInfo {
address: string; address: string;
@ -26,7 +29,10 @@ export class ReOwnWalletService {
/** /**
* Set account references from AppKit hooks * Set account references from AppKit hooks
*/ */
setAccounts(bitcoinAccount: UseAppKitAccountReturn, ethereumAccount: UseAppKitAccountReturn) { setAccounts(
bitcoinAccount: UseAppKitAccountReturn,
ethereumAccount: UseAppKitAccountReturn
) {
this.bitcoinAccount = bitcoinAccount; this.bitcoinAccount = bitcoinAccount;
this.ethereumAccount = ethereumAccount; this.ethereumAccount = ethereumAccount;
} }
@ -52,8 +58,12 @@ export class ReOwnWalletService {
/** /**
* Get the active account based on wallet type * Get the active account based on wallet type
*/ */
private getActiveAccount(walletType: 'bitcoin' | 'ethereum'): UseAppKitAccountReturn | undefined { private getActiveAccount(
return walletType === 'bitcoin' ? this.bitcoinAccount : this.ethereumAccount; walletType: 'bitcoin' | 'ethereum'
): UseAppKitAccountReturn | undefined {
return walletType === 'bitcoin'
? this.bitcoinAccount
: this.ethereumAccount;
} }
/** /**
@ -74,7 +84,10 @@ export class ReOwnWalletService {
/** /**
* Sign a message using the appropriate adapter * Sign a message using the appropriate adapter
*/ */
async signMessage(messageBytes: Uint8Array, walletType: 'bitcoin' | 'ethereum'): Promise<string> { async signMessage(
messageBytes: Uint8Array,
walletType: 'bitcoin' | 'ethereum'
): Promise<string> {
if (!this.appKit) { if (!this.appKit) {
throw new Error('AppKit instance not set. Call setAppKit() first.'); throw new Error('AppKit instance not set. Call setAppKit() first.');
} }
@ -85,7 +98,7 @@ export class ReOwnWalletService {
} }
const namespace = this.getNamespace(walletType); const namespace = this.getNamespace(walletType);
// Convert message bytes to string for signing // Convert message bytes to string for signing
const messageString = new TextDecoder().decode(messageBytes); const messageString = new TextDecoder().decode(messageBytes);
@ -93,14 +106,14 @@ export class ReOwnWalletService {
// Access the adapter through the appKit instance // Access the adapter through the appKit instance
// The adapter is available through the appKit's chainAdapters property // The adapter is available through the appKit's chainAdapters property
const adapter = this.appKit.chainAdapters?.[namespace]; const adapter = this.appKit.chainAdapters?.[namespace];
if (!adapter) { if (!adapter) {
throw new Error(`No adapter found for namespace: ${namespace}`); throw new Error(`No adapter found for namespace: ${namespace}`);
} }
// Get the provider for the current connection // Get the provider for the current connection
const provider = this.appKit.getProvider(namespace); const provider = this.appKit.getProvider(namespace);
if (!provider) { if (!provider) {
throw new Error(`No provider found for namespace: ${namespace}`); throw new Error(`No provider found for namespace: ${namespace}`);
} }
@ -109,20 +122,25 @@ export class ReOwnWalletService {
const result = await adapter.signMessage({ const result = await adapter.signMessage({
message: messageString, message: messageString,
address: account.address, address: account.address,
provider: provider as Provider provider: provider as Provider,
}); });
return result.signature; return result.signature;
} catch (error) { } catch (error) {
console.error(`Error signing message with ${walletType} wallet:`, error); console.error(`Error signing message with ${walletType} wallet:`, error);
throw new Error(`Failed to sign message with ${walletType} wallet: ${error instanceof Error ? error.message : 'Unknown error'}`); throw new Error(
`Failed to sign message with ${walletType} wallet: ${error instanceof Error ? error.message : 'Unknown error'}`
);
} }
} }
/** /**
* Create a key delegation for the connected wallet * Create a key delegation for the connected wallet
*/ */
async createKeyDelegation(walletType: 'bitcoin' | 'ethereum', duration: DelegationDuration = '7days'): Promise<boolean> { async createKeyDelegation(
walletType: 'bitcoin' | 'ethereum',
duration: DelegationDuration = '7days'
): Promise<boolean> {
try { try {
const account = this.getActiveAccount(walletType); const account = this.getActiveAccount(walletType);
if (!account?.address) { if (!account?.address) {
@ -131,16 +149,16 @@ export class ReOwnWalletService {
// Generate a new browser keypair // Generate a new browser keypair
const keypair = this.cryptoService.generateKeypair(); const keypair = this.cryptoService.generateKeypair();
// Create delegation message with expiry // Create delegation message with expiry
const expiryHours = CryptoService.getDurationHours(duration); const expiryHours = CryptoService.getDurationHours(duration);
const expiryTimestamp = Date.now() + (expiryHours * 60 * 60 * 1000); const expiryTimestamp = Date.now() + expiryHours * 60 * 60 * 1000;
const delegationMessage = this.cryptoService.createDelegationMessage( const delegationMessage = this.cryptoService.createDelegationMessage(
keypair.publicKey, keypair.publicKey,
account.address, account.address,
expiryTimestamp expiryTimestamp
); );
const messageBytes = new TextEncoder().encode(delegationMessage); const messageBytes = new TextEncoder().encode(delegationMessage);
// Sign the delegation message // Sign the delegation message
@ -166,7 +184,10 @@ export class ReOwnWalletService {
/** /**
* Sign a message using the delegated key (if available) or fall back to wallet signing * Sign a message using the delegated key (if available) or fall back to wallet signing
*/ */
async signMessageWithDelegation(messageBytes: Uint8Array, walletType: 'bitcoin' | 'ethereum'): Promise<string> { async signMessageWithDelegation(
messageBytes: Uint8Array,
walletType: 'bitcoin' | 'ethereum'
): Promise<string> {
const account = this.getActiveAccount(walletType); const account = this.getActiveAccount(walletType);
if (!account?.address) { if (!account?.address) {
throw new Error(`No ${walletType} wallet connected`); throw new Error(`No ${walletType} wallet connected`);
@ -177,12 +198,12 @@ export class ReOwnWalletService {
// Use delegated key for signing // Use delegated key for signing
const messageString = new TextDecoder().decode(messageBytes); const messageString = new TextDecoder().decode(messageBytes);
const signature = this.cryptoService.signRawMessage(messageString); const signature = this.cryptoService.signRawMessage(messageString);
if (signature) { if (signature) {
return signature; return signature;
} }
} }
// Fall back to wallet signing // Fall back to wallet signing
return this.signMessage(messageBytes, walletType); return this.signMessage(messageBytes, walletType);
} }
@ -197,15 +218,18 @@ export class ReOwnWalletService {
} { } {
const account = this.getActiveAccount(walletType); const account = this.getActiveAccount(walletType);
const currentAddress = account?.address; const currentAddress = account?.address;
const hasDelegation = this.cryptoService.getBrowserPublicKey() !== null; const hasDelegation = this.cryptoService.getBrowserPublicKey() !== null;
const isValid = this.cryptoService.isDelegationValid(currentAddress, walletType); const isValid = this.cryptoService.isDelegationValid(
currentAddress,
walletType
);
const timeRemaining = this.cryptoService.getDelegationTimeRemaining(); const timeRemaining = this.cryptoService.getDelegationTimeRemaining();
return { return {
hasDelegation, hasDelegation,
isValid, isValid,
timeRemaining: timeRemaining > 0 ? timeRemaining : undefined timeRemaining: timeRemaining > 0 ? timeRemaining : undefined,
}; };
} }
@ -224,14 +248,14 @@ export class ReOwnWalletService {
return { return {
address: this.bitcoinAccount.address as string, address: this.bitcoinAccount.address as string,
walletType: 'bitcoin', walletType: 'bitcoin',
isConnected: true isConnected: true,
}; };
} else if (this.ethereumAccount?.isConnected) { } else if (this.ethereumAccount?.isConnected) {
// Use Wagmi to resolve ENS name // Use Wagmi to resolve ENS name
let ensName: string | undefined; let ensName: string | undefined;
try { try {
const resolvedName = await getEnsName(config, { const resolvedName = await getEnsName(config, {
address: this.ethereumAccount.address as `0x${string}` address: this.ethereumAccount.address as `0x${string}`,
}); });
ensName = resolvedName || undefined; ensName = resolvedName || undefined;
} catch (error) { } catch (error) {
@ -243,12 +267,10 @@ export class ReOwnWalletService {
address: this.ethereumAccount.address as string, address: this.ethereumAccount.address as string,
walletType: 'ethereum', walletType: 'ethereum',
ensName, ensName,
isConnected: true isConnected: true,
}; };
} }
return null; return null;
} }
}
}

View File

@ -1,38 +1,43 @@
import { AppKitOptions } from '@reown/appkit' import { AppKitOptions } from '@reown/appkit';
import { BitcoinAdapter } from '@reown/appkit-adapter-bitcoin' import { BitcoinAdapter } from '@reown/appkit-adapter-bitcoin';
import { WagmiAdapter } from '@reown/appkit-adapter-wagmi' import { WagmiAdapter } from '@reown/appkit-adapter-wagmi';
import { createStorage } from 'wagmi' import { createStorage } from 'wagmi';
import { mainnet, bitcoin, AppKitNetwork } from '@reown/appkit/networks' import { mainnet, bitcoin, AppKitNetwork } from '@reown/appkit/networks';
const networks: [AppKitNetwork, ...AppKitNetwork[]] = [mainnet, bitcoin] const networks: [AppKitNetwork, ...AppKitNetwork[]] = [mainnet, bitcoin];
const projectId = process.env.VITE_REOWN_SECRET || '2ead96ea166a03e5ab50e5c190532e72' const projectId =
process.env.VITE_REOWN_SECRET || '2ead96ea166a03e5ab50e5c190532e72';
if (!projectId) { if (!projectId) {
throw new Error('VITE_REOWN_SECRET is not defined. Please set it in your .env file') throw new Error(
'VITE_REOWN_SECRET is not defined. Please set it in your .env file'
);
} }
export const wagmiAdapter = new WagmiAdapter({ export const wagmiAdapter = new WagmiAdapter({
storage: createStorage({ storage: localStorage }), storage: createStorage({ storage: localStorage }),
ssr: false, // Set to false for Vite/React apps ssr: false, // Set to false for Vite/React apps
projectId, projectId,
networks networks,
}) });
// Export the Wagmi config for the provider // Export the Wagmi config for the provider
export const config = wagmiAdapter.wagmiConfig export const config = wagmiAdapter.wagmiConfig;
const bitcoinAdapter = new BitcoinAdapter({ const bitcoinAdapter = new BitcoinAdapter({
projectId projectId,
});
})
const metadata = { const metadata = {
name: 'OpChan', name: 'OpChan',
description: 'Decentralized forum powered by Bitcoin Ordinals', description: 'Decentralized forum powered by Bitcoin Ordinals',
url: process.env.NODE_ENV === 'production' ? 'https://opchan.app' : 'http://localhost:8080', url:
icons: ['https://opchan.com/logo.png'] process.env.NODE_ENV === 'production'
} ? 'https://opchan.app'
: 'http://localhost:8080',
icons: ['https://opchan.com/logo.png'],
};
export const appkitConfig: AppKitOptions = { export const appkitConfig: AppKitOptions = {
adapters: [wagmiAdapter, bitcoinAdapter], adapters: [wagmiAdapter, bitcoinAdapter],
@ -42,8 +47,7 @@ export const appkitConfig: AppKitOptions = {
features: { features: {
analytics: false, analytics: false,
socials: false, socials: false,
allWallets: false, allWallets: false,
}, },
enableWalletConnect: false enableWalletConnect: false,
};
}

View File

@ -1 +1 @@
export { ReOwnWalletService as WalletService } from './ReOwnWalletService'; export { ReOwnWalletService as WalletService } from './ReOwnWalletService';

View File

@ -1,9 +1,8 @@
import { WalletService } from '../identity/wallets/index'; import { WalletService } from '../identity/wallets/index';
import { UseAppKitAccountReturn } from '@reown/appkit/react'; import { UseAppKitAccountReturn } from '@reown/appkit/react';
import { AppKit } from '@reown/appkit'; import { AppKit } from '@reown/appkit';
import { OrdinalAPI } from '../identity/ordinal';
import { CryptoService, DelegationDuration } from './CryptoService'; import { CryptoService, DelegationDuration } from './CryptoService';
import { EVerificationStatus, User } from '@/types/forum'; import { EVerificationStatus, User, DisplayPreference } from '@/types/identity';
import { WalletInfo } from '../identity/wallets/ReOwnWalletService'; import { WalletInfo } from '../identity/wallets/ReOwnWalletService';
export interface AuthResult { export interface AuthResult {
@ -14,41 +13,45 @@ export interface AuthResult {
export interface AuthServiceInterface { export interface AuthServiceInterface {
// Wallet operations // Wallet operations
setAccounts(bitcoinAccount: UseAppKitAccountReturn, ethereumAccount: UseAppKitAccountReturn): void; setAccounts(
bitcoinAccount: UseAppKitAccountReturn,
ethereumAccount: UseAppKitAccountReturn
): void;
setAppKit(appKit: AppKit): void; setAppKit(appKit: AppKit): void;
connectWallet(): Promise<AuthResult>; connectWallet(): Promise<AuthResult>;
disconnectWallet(): Promise<void>; disconnectWallet(): Promise<void>;
// Verification // Verification
verifyOwnership(user: User): Promise<AuthResult>; verifyOwnership(user: User): Promise<AuthResult>;
// Delegation setup // Delegation setup
delegateKey(user: User, duration?: DelegationDuration): Promise<AuthResult>; delegateKey(user: User, duration?: DelegationDuration): Promise<AuthResult>;
// User persistence // User persistence
loadStoredUser(): User | null; loadStoredUser(): User | null;
saveUser(user: User): void; saveUser(user: User): void;
clearStoredUser(): void; clearStoredUser(): void;
// Wallet info // Wallet info
getWalletInfo(): Promise<WalletInfo | null>; getWalletInfo(): Promise<WalletInfo | null>;
} }
export class AuthService implements AuthServiceInterface { export class AuthService implements AuthServiceInterface {
private walletService: WalletService; private walletService: WalletService;
private ordinalApi: OrdinalAPI;
private cryptoService: CryptoService; private cryptoService: CryptoService;
constructor(cryptoService: CryptoService) { constructor(cryptoService: CryptoService) {
this.walletService = new WalletService(); this.walletService = new WalletService();
this.ordinalApi = new OrdinalAPI();
this.cryptoService = cryptoService; this.cryptoService = cryptoService;
} }
/** /**
* Set AppKit accounts for wallet service * Set AppKit accounts for wallet service
*/ */
setAccounts(bitcoinAccount: UseAppKitAccountReturn, ethereumAccount: UseAppKitAccountReturn) { setAccounts(
bitcoinAccount: UseAppKitAccountReturn,
ethereumAccount: UseAppKitAccountReturn
) {
this.walletService.setAccounts(bitcoinAccount, ethereumAccount); this.walletService.setAccounts(bitcoinAccount, ethereumAccount);
} }
@ -64,26 +67,15 @@ export class AuthService implements AuthServiceInterface {
*/ */
private getActiveAddress(): string | null { private getActiveAddress(): string | null {
const isBitcoinConnected = this.walletService.isWalletAvailable('bitcoin'); const isBitcoinConnected = this.walletService.isWalletAvailable('bitcoin');
const isEthereumConnected = this.walletService.isWalletAvailable('ethereum'); const isEthereumConnected =
this.walletService.isWalletAvailable('ethereum');
if (isBitcoinConnected) { if (isBitcoinConnected) {
return this.walletService.getActiveAddress('bitcoin') || null; return this.walletService.getActiveAddress('bitcoin') || null;
} else if (isEthereumConnected) { } else if (isEthereumConnected) {
return this.walletService.getActiveAddress('ethereum') || null; return this.walletService.getActiveAddress('ethereum') || null;
} }
return null;
}
/**
* Get the active wallet type
*/
private getActiveWalletType(): 'bitcoin' | 'ethereum' | null {
if (this.walletService.isWalletAvailable('bitcoin')) {
return 'bitcoin';
} else if (this.walletService.isWalletAvailable('ethereum')) {
return 'ethereum';
}
return null; return null;
} }
@ -93,13 +85,15 @@ export class AuthService implements AuthServiceInterface {
async connectWallet(): Promise<AuthResult> { async connectWallet(): Promise<AuthResult> {
try { try {
// Check which wallet is connected // Check which wallet is connected
const isBitcoinConnected = this.walletService.isWalletAvailable('bitcoin'); const isBitcoinConnected =
const isEthereumConnected = this.walletService.isWalletAvailable('ethereum'); this.walletService.isWalletAvailable('bitcoin');
const isEthereumConnected =
this.walletService.isWalletAvailable('ethereum');
if (!isBitcoinConnected && !isEthereumConnected) { if (!isBitcoinConnected && !isEthereumConnected) {
return { return {
success: false, success: false,
error: 'No wallet connected' error: 'No wallet connected',
}; };
} }
@ -110,7 +104,7 @@ export class AuthService implements AuthServiceInterface {
if (!address) { if (!address) {
return { return {
success: false, success: false,
error: 'No wallet address available' error: 'No wallet address available',
}; };
} }
@ -118,6 +112,7 @@ export class AuthService implements AuthServiceInterface {
address: address, address: address,
walletType: walletType, walletType: walletType,
verificationStatus: EVerificationStatus.UNVERIFIED, verificationStatus: EVerificationStatus.UNVERIFIED,
displayPreference: DisplayPreference.WALLET_ADDRESS,
lastChecked: Date.now(), lastChecked: Date.now(),
}; };
@ -125,23 +120,29 @@ export class AuthService implements AuthServiceInterface {
if (walletType === 'ethereum') { if (walletType === 'ethereum') {
try { try {
const walletInfo = await this.walletService.getWalletInfo(); const walletInfo = await this.walletService.getWalletInfo();
user.ensName = walletInfo?.ensName; if (walletInfo?.ensName) {
user.ensOwnership = !!(walletInfo?.ensName); user.ensDetails = {
ensName: walletInfo.ensName,
};
}
} catch (error) { } catch (error) {
console.warn('Failed to resolve ENS during wallet connection:', error); console.warn(
user.ensName = undefined; 'Failed to resolve ENS during wallet connection:',
user.ensOwnership = false; error
);
user.ensDetails = undefined;
} }
} }
return { return {
success: true, success: true,
user user,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to connect wallet' error:
error instanceof Error ? error.message : 'Failed to connect wallet',
}; };
} }
} }
@ -152,7 +153,7 @@ export class AuthService implements AuthServiceInterface {
async disconnectWallet(): Promise<void> { async disconnectWallet(): Promise<void> {
// Clear any existing delegations when disconnecting // Clear any existing delegations when disconnecting
this.cryptoService.clearDelegation(); this.cryptoService.clearDelegation();
// Clear stored user data // Clear stored user data
this.clearStoredUser(); this.clearStoredUser();
} }
@ -169,13 +170,14 @@ export class AuthService implements AuthServiceInterface {
} else { } else {
return { return {
success: false, success: false,
error: 'Unknown wallet type' error: 'Unknown wallet type',
}; };
} }
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to verify ownership' error:
error instanceof Error ? error.message : 'Failed to verify ownership',
}; };
} }
} }
@ -192,13 +194,15 @@ export class AuthService implements AuthServiceInterface {
const updatedUser = { const updatedUser = {
...user, ...user,
ordinalOwnership: hasOperators, ordinalOwnership: hasOperators,
verificationStatus: hasOperators ? EVerificationStatus.VERIFIED_OWNER : EVerificationStatus.VERIFIED_BASIC, verificationStatus: hasOperators
? EVerificationStatus.VERIFIED_OWNER
: EVerificationStatus.VERIFIED_BASIC,
lastChecked: Date.now(), lastChecked: Date.now(),
}; };
return { return {
success: true, success: true,
user: updatedUser user: updatedUser,
}; };
} }
@ -209,25 +213,27 @@ export class AuthService implements AuthServiceInterface {
try { try {
// Get wallet info with ENS resolution // Get wallet info with ENS resolution
const walletInfo = await this.walletService.getWalletInfo(); const walletInfo = await this.walletService.getWalletInfo();
const hasENS = !!(walletInfo?.ensName); const hasENS = !!walletInfo?.ensName;
const ensName = walletInfo?.ensName; const ensName = walletInfo?.ensName;
const updatedUser = { const updatedUser = {
...user, ...user,
ensOwnership: hasENS, ensOwnership: hasENS,
ensName: ensName, ensName: ensName,
verificationStatus: hasENS ? EVerificationStatus.VERIFIED_OWNER : EVerificationStatus.VERIFIED_BASIC, verificationStatus: hasENS
? EVerificationStatus.VERIFIED_OWNER
: EVerificationStatus.VERIFIED_BASIC,
lastChecked: Date.now(), lastChecked: Date.now(),
}; };
return { return {
success: true, success: true,
user: updatedUser user: updatedUser,
}; };
} catch (error) { } catch (error) {
console.error('Error verifying ENS ownership:', error); console.error('Error verifying ENS ownership:', error);
// Fall back to basic verification on error // Fall back to basic verification on error
const updatedUser = { const updatedUser = {
...user, ...user,
@ -239,7 +245,7 @@ export class AuthService implements AuthServiceInterface {
return { return {
success: true, success: true,
user: updatedUser user: updatedUser,
}; };
} }
} }
@ -247,48 +253,58 @@ export class AuthService implements AuthServiceInterface {
/** /**
* Set up key delegation for the user * Set up key delegation for the user
*/ */
async delegateKey(user: User, duration: DelegationDuration = '7days'): Promise<AuthResult> { async delegateKey(
user: User,
duration: DelegationDuration = '7days'
): Promise<AuthResult> {
try { try {
const walletType = user.walletType; const walletType = user.walletType;
const isAvailable = this.walletService.isWalletAvailable(walletType); const isAvailable = this.walletService.isWalletAvailable(walletType);
if (!isAvailable) { if (!isAvailable) {
return { return {
success: false, success: false,
error: `${walletType} wallet is not available or connected. Please ensure it is connected.` error: `${walletType} wallet is not available or connected. Please ensure it is connected.`,
}; };
} }
const success = await this.walletService.createKeyDelegation(walletType, duration); const success = await this.walletService.createKeyDelegation(
walletType,
duration
);
if (!success) { if (!success) {
return { return {
success: false, success: false,
error: 'Failed to create key delegation' error: 'Failed to create key delegation',
}; };
} }
// Get delegation status to update user // Get delegation status to update user
const delegationStatus = this.walletService.getDelegationStatus(walletType); const delegationStatus =
this.walletService.getDelegationStatus(walletType);
// Get the actual browser public key from the delegation // Get the actual browser public key from the delegation
const browserPublicKey = this.cryptoService.getBrowserPublicKey(); const browserPublicKey = this.cryptoService.getBrowserPublicKey();
const updatedUser = { const updatedUser = {
...user, ...user,
browserPubKey: browserPublicKey || undefined, browserPubKey: browserPublicKey || undefined,
delegationSignature: delegationStatus.isValid ? 'valid' : undefined, delegationSignature: delegationStatus.isValid ? 'valid' : undefined,
delegationExpiry: delegationStatus.timeRemaining ? Date.now() + delegationStatus.timeRemaining : undefined, delegationExpiry: delegationStatus.timeRemaining
? Date.now() + delegationStatus.timeRemaining
: undefined,
}; };
return { return {
success: true, success: true,
user: updatedUser user: updatedUser,
}; };
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: error instanceof Error ? error.message : 'Failed to delegate key' error:
error instanceof Error ? error.message : 'Failed to delegate key',
}; };
} }
} }
@ -312,7 +328,7 @@ export class AuthService implements AuthServiceInterface {
const user = JSON.parse(storedUser); const user = JSON.parse(storedUser);
const lastChecked = user.lastChecked || 0; const lastChecked = user.lastChecked || 0;
const expiryTime = 24 * 60 * 60 * 1000; const expiryTime = 24 * 60 * 60 * 1000;
if (Date.now() - lastChecked < expiryTime) { if (Date.now() - lastChecked < expiryTime) {
return user; return user;
} else { } else {
@ -320,7 +336,7 @@ export class AuthService implements AuthServiceInterface {
return null; return null;
} }
} catch (e) { } catch (e) {
console.error("Failed to parse stored user data", e); console.error('Failed to parse stored user data', e);
localStorage.removeItem('opchan-user'); localStorage.removeItem('opchan-user');
return null; return null;
} }
@ -339,4 +355,4 @@ export class AuthService implements AuthServiceInterface {
clearStoredUser(): void { clearStoredUser(): void {
localStorage.removeItem('opchan-user'); localStorage.removeItem('opchan-user');
} }
} }

View File

@ -1,6 +1,6 @@
/** /**
* CryptoService - Unified cryptographic operations * CryptoService - Unified cryptographic operations
* *
* Combines key delegation and message signing functionality into a single, * Combines key delegation and message signing functionality into a single,
* cohesive service focused on all cryptographic operations. * cohesive service focused on all cryptographic operations.
*/ */
@ -10,18 +10,19 @@ import { sha512 } from '@noble/hashes/sha512';
import { bytesToHex, hexToBytes } from '@/lib/utils'; import { bytesToHex, hexToBytes } from '@/lib/utils';
import { LOCAL_STORAGE_KEYS } from '@/lib/waku/constants'; import { LOCAL_STORAGE_KEYS } from '@/lib/waku/constants';
import { OpchanMessage } from '@/types/forum'; import { OpchanMessage } from '@/types/forum';
import { UnsignedMessage } from '@/types/waku';
export interface DelegationSignature { export interface DelegationSignature {
signature: string; // Signature from wallet signature: string; // Signature from wallet
expiryTimestamp: number; // When this delegation expires expiryTimestamp: number; // When this delegation expires
browserPublicKey: string; // Browser-generated public key that was delegated to browserPublicKey: string; // Browser-generated public key that was delegated to
walletAddress: string; // Wallet address that signed the delegation walletAddress: string; // Wallet address that signed the delegation
walletType: 'bitcoin' | 'ethereum'; // Type of wallet that created the delegation walletType: 'bitcoin' | 'ethereum'; // Type of wallet that created the delegation
} }
export interface DelegationInfo extends DelegationSignature { export interface DelegationInfo extends DelegationSignature {
browserPrivateKey: string; browserPrivateKey: string;
} }
ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m)); ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m));
@ -37,27 +38,34 @@ export interface CryptoServiceInterface {
duration: DelegationDuration, duration: DelegationDuration,
walletType: 'bitcoin' | 'ethereum' walletType: 'bitcoin' | 'ethereum'
): void; ): void;
isDelegationValid(currentAddress?: string, currentWalletType?: 'bitcoin' | 'ethereum'): boolean; isDelegationValid(
currentAddress?: string,
currentWalletType?: 'bitcoin' | 'ethereum'
): boolean;
getDelegationTimeRemaining(): number; getDelegationTimeRemaining(): number;
getBrowserPublicKey(): string | null; getBrowserPublicKey(): string | null;
clearDelegation(): void; clearDelegation(): void;
// Keypair generation // Keypair generation
generateKeypair(): { publicKey: string; privateKey: string }; generateKeypair(): { publicKey: string; privateKey: string };
createDelegationMessage(browserPublicKey: string, walletAddress: string, expiryTimestamp: number): string; createDelegationMessage(
browserPublicKey: string,
walletAddress: string,
expiryTimestamp: number
): string;
// Message operations // Message operations
signMessage<T extends OpchanMessage>(message: T): T | null; signMessage(message: UnsignedMessage): OpchanMessage | null;
verifyMessage(message: OpchanMessage): boolean; verifyMessage(message: OpchanMessage): boolean;
} }
export class CryptoService implements CryptoServiceInterface { export class CryptoService implements CryptoServiceInterface {
private static readonly STORAGE_KEY = LOCAL_STORAGE_KEYS.KEY_DELEGATION; private static readonly STORAGE_KEY = LOCAL_STORAGE_KEYS.KEY_DELEGATION;
// Duration options in hours // Duration options in hours
private static readonly DURATION_HOURS = { private static readonly DURATION_HOURS = {
'7days': 24 * 7, // 168 hours '7days': 24 * 7, // 168 hours
'30days': 24 * 30 // 720 hours '30days': 24 * 30, // 720 hours
} as const; } as const;
/** /**
@ -84,13 +92,13 @@ export class CryptoService implements CryptoServiceInterface {
generateKeypair(): { publicKey: string; privateKey: string } { generateKeypair(): { publicKey: string; privateKey: string } {
const privateKey = ed.utils.randomPrivateKey(); const privateKey = ed.utils.randomPrivateKey();
const privateKeyHex = bytesToHex(privateKey); const privateKeyHex = bytesToHex(privateKey);
const publicKey = ed.getPublicKey(privateKey); const publicKey = ed.getPublicKey(privateKey);
const publicKeyHex = bytesToHex(publicKey); const publicKeyHex = bytesToHex(publicKey);
return { return {
privateKey: privateKeyHex, privateKey: privateKeyHex,
publicKey: publicKeyHex publicKey: publicKeyHex,
}; };
} }
@ -121,18 +129,21 @@ export class CryptoService implements CryptoServiceInterface {
walletType: 'bitcoin' | 'ethereum' walletType: 'bitcoin' | 'ethereum'
): void { ): void {
const expiryHours = CryptoService.getDurationHours(duration); const expiryHours = CryptoService.getDurationHours(duration);
const expiryTimestamp = Date.now() + (expiryHours * 60 * 60 * 1000); const expiryTimestamp = Date.now() + expiryHours * 60 * 60 * 1000;
const delegationInfo: DelegationInfo = { const delegationInfo: DelegationInfo = {
signature, signature,
expiryTimestamp, expiryTimestamp,
browserPublicKey, browserPublicKey,
browserPrivateKey, browserPrivateKey,
walletAddress, walletAddress,
walletType walletType,
}; };
localStorage.setItem(CryptoService.STORAGE_KEY, JSON.stringify(delegationInfo)); localStorage.setItem(
CryptoService.STORAGE_KEY,
JSON.stringify(delegationInfo)
);
} }
/** /**
@ -141,7 +152,7 @@ export class CryptoService implements CryptoServiceInterface {
private retrieveDelegation(): DelegationInfo | null { private retrieveDelegation(): DelegationInfo | null {
const delegationJson = localStorage.getItem(CryptoService.STORAGE_KEY); const delegationJson = localStorage.getItem(CryptoService.STORAGE_KEY);
if (!delegationJson) return null; if (!delegationJson) return null;
try { try {
return JSON.parse(delegationJson); return JSON.parse(delegationJson);
} catch (e) { } catch (e) {
@ -153,24 +164,27 @@ export class CryptoService implements CryptoServiceInterface {
/** /**
* Checks if a delegation is valid * Checks if a delegation is valid
*/ */
isDelegationValid(currentAddress?: string, currentWalletType?: 'bitcoin' | 'ethereum'): boolean { isDelegationValid(
currentAddress?: string,
currentWalletType?: 'bitcoin' | 'ethereum'
): boolean {
const delegation = this.retrieveDelegation(); const delegation = this.retrieveDelegation();
if (!delegation) return false; if (!delegation) return false;
// Check if delegation has expired // Check if delegation has expired
const now = Date.now(); const now = Date.now();
if (now >= delegation.expiryTimestamp) return false; if (now >= delegation.expiryTimestamp) return false;
// If a current address is provided, validate it matches the delegation // If a current address is provided, validate it matches the delegation
if (currentAddress && delegation.walletAddress !== currentAddress) { if (currentAddress && delegation.walletAddress !== currentAddress) {
return false; return false;
} }
// If a current wallet type is provided, validate it matches the delegation // If a current wallet type is provided, validate it matches the delegation
if (currentWalletType && delegation.walletType !== currentWalletType) { if (currentWalletType && delegation.walletType !== currentWalletType) {
return false; return false;
} }
return true; return true;
} }
@ -180,7 +194,7 @@ export class CryptoService implements CryptoServiceInterface {
getDelegationTimeRemaining(): number { getDelegationTimeRemaining(): number {
const delegation = this.retrieveDelegation(); const delegation = this.retrieveDelegation();
if (!delegation) return 0; if (!delegation) return 0;
const now = Date.now(); const now = Date.now();
return Math.max(0, delegation.expiryTimestamp - now); return Math.max(0, delegation.expiryTimestamp - now);
} }
@ -211,11 +225,11 @@ export class CryptoService implements CryptoServiceInterface {
signRawMessage(message: string): string | null { signRawMessage(message: string): string | null {
const delegation = this.retrieveDelegation(); const delegation = this.retrieveDelegation();
if (!delegation || !this.isDelegationValid()) return null; if (!delegation || !this.isDelegationValid()) return null;
try { try {
const privateKeyBytes = hexToBytes(delegation.browserPrivateKey); const privateKeyBytes = hexToBytes(delegation.browserPrivateKey);
const messageBytes = new TextEncoder().encode(message); const messageBytes = new TextEncoder().encode(message);
const signature = ed.sign(messageBytes, privateKeyBytes); const signature = ed.sign(messageBytes, privateKeyBytes);
return bytesToHex(signature); return bytesToHex(signature);
} catch (error) { } catch (error) {
@ -236,7 +250,7 @@ export class CryptoService implements CryptoServiceInterface {
const messageBytes = new TextEncoder().encode(message); const messageBytes = new TextEncoder().encode(message);
const signatureBytes = hexToBytes(signature); const signatureBytes = hexToBytes(signature);
const publicKeyBytes = hexToBytes(publicKey); const publicKeyBytes = hexToBytes(publicKey);
return ed.verify(signatureBytes, messageBytes, publicKeyBytes); return ed.verify(signatureBytes, messageBytes, publicKeyBytes);
} catch (error) { } catch (error) {
console.error('Error verifying signature:', error); console.error('Error verifying signature:', error);
@ -245,32 +259,32 @@ export class CryptoService implements CryptoServiceInterface {
} }
/** /**
* Signs an OpchanMessage with the delegated browser key * Signs an unsigned message with the delegated browser key
*/ */
signMessage<T extends OpchanMessage>(message: T): T | null { signMessage(message: UnsignedMessage): OpchanMessage | null {
if (!this.isDelegationValid()) { if (!this.isDelegationValid()) {
console.error('No valid key delegation found. Cannot sign message.'); console.error('No valid key delegation found. Cannot sign message.');
return null; return null;
} }
const delegation = this.retrieveDelegation(); const delegation = this.retrieveDelegation();
if (!delegation) return null; if (!delegation) return null;
// Create the message content to sign (without signature fields) // Create the message content to sign (without signature fields)
const messageToSign = JSON.stringify({ const messageToSign = JSON.stringify({
...message, ...message,
signature: undefined, signature: undefined,
browserPubKey: undefined browserPubKey: undefined,
}); });
const signature = this.signRawMessage(messageToSign); const signature = this.signRawMessage(messageToSign);
if (!signature) return null; if (!signature) return null;
return { return {
...message, ...message,
signature, signature,
browserPubKey: delegation.browserPublicKey browserPubKey: delegation.browserPublicKey,
}; } as OpchanMessage;
} }
/** /**
@ -279,30 +293,30 @@ export class CryptoService implements CryptoServiceInterface {
verifyMessage(message: OpchanMessage): boolean { verifyMessage(message: OpchanMessage): boolean {
// Check for required signature fields // Check for required signature fields
if (!message.signature || !message.browserPubKey) { if (!message.signature || !message.browserPubKey) {
const messageId = 'id' in message ? message.id : `${message.type}-${message.timestamp}`; const messageId = message.id || `${message.type}-${message.timestamp}`;
console.warn('Message is missing signature information', messageId); console.warn('Message is missing signature information', messageId);
return false; return false;
} }
// Reconstruct the original signed content // Reconstruct the original signed content
const signedContent = JSON.stringify({ const signedContent = JSON.stringify({
...message, ...message,
signature: undefined, signature: undefined,
browserPubKey: undefined browserPubKey: undefined,
}); });
// Verify the signature // Verify the signature
const isValid = this.verifyRawSignature( const isValid = this.verifyRawSignature(
signedContent, signedContent,
message.signature, message.signature,
message.browserPubKey message.browserPubKey
); );
if (!isValid) { if (!isValid) {
const messageId = 'id' in message ? message.id : `${message.type}-${message.timestamp}`; const messageId = message.id || `${message.type}-${message.timestamp}`;
console.warn(`Invalid signature for message ${messageId}`); console.warn(`Invalid signature for message ${messageId}`);
} }
return isValid; return isValid;
} }
} }

Some files were not shown because too many files have changed in this diff Show More