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
# Mock/bypass settings for development
VITE_OPCHAN_MOCK_ORDINAL_CHECK=false
VITE_REOWN_SECRET=
VITE_OPCHAN_MOCK_ORDINAL_CHECK=

3
.gitignore vendored
View File

@ -1,8 +1,7 @@
.cursorrules
comparison.md
.giga/
furps.md
furps-comparison.md
README-task-master.md
.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
1. **Clone the repository**
```bash
git clone https://github.com/waku-org/OpChan.git
cd OpChan
```
2. **Install dependencies**
```bash
npm install
```
3. **Setup environment variables**
```bash
cp .env.example .env
```
Edit `.env` to configure development settings:
```env
# Set to 'true' to bypass verification in development
VITE_OPCHAN_MOCK_ORDINAL_CHECK=false
@ -101,6 +105,7 @@ OpChan uses a two-tier authentication system:
7. Open a Pull Request
## TODOs
- [x] replace mock wallet connection/disconnection
- supports Phantom
- [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
- **Moderation Layer**: Cell-based moderation without global censorship
## Support
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",
"hooks": "@/hooks"
}
}
}

View File

@ -1,32 +1,35 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ["dist"] },
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
"@typescript-eslint/no-unused-vars": ["error", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}],
'@typescript-eslint/no-unused-vars': [
'error',
{
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">
<head>
<meta charset="UTF-8" />
@ -10,11 +10,17 @@
<meta property="og:title" content="ordinal-echo-chamber" />
<meta property="og:description" content="Lovable Generated Project" />
<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: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>
<body>

17
package-lock.json generated
View File

@ -87,6 +87,7 @@
"jsdom": "^26.1.0",
"lovable-tagger": "^1.1.7",
"postcss": "^8.4.47",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.11",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
@ -13664,6 +13665,22 @@
"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": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",

View File

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

View File

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

View File

@ -4,34 +4,33 @@
* Reference:
* 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.**
*
*
* Instead, a key delegation system should be developed.
*
*
* - User sign an in-browser key with their wallet and broadcast it
* - Browser uses in-browser key to sign messages moving forward
*/
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import { AuthProvider } from "@/contexts/AuthContext";
import { ForumProvider } from "@/contexts/ForumContext";
import CellPage from "./pages/CellPage";
import PostPage from "./pages/PostPage";
import NotFound from "./pages/NotFound";
import Dashboard from "./pages/Dashboard";
import Index from "./pages/Index";
import { appkitConfig } from "./lib/identity/wallets/appkit";
import { WagmiProvider } from "wagmi";
import { config } from "./lib/identity/wallets/appkit";
import { AppKitProvider } from "@reown/appkit/react";
import { Toaster } from '@/components/ui/toaster';
import { Toaster as Sonner } from '@/components/ui/sonner';
import { TooltipProvider } from '@/components/ui/tooltip';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider } from '@/contexts/AuthContext';
import { ForumProvider } from '@/contexts/ForumContext';
import CellPage from './pages/CellPage';
import PostPage from './pages/PostPage';
import NotFound from './pages/NotFound';
import Dashboard from './pages/Dashboard';
import Index from './pages/Index';
import { appkitConfig } from './lib/identity/wallets/appkit';
import { WagmiProvider } from 'wagmi';
import { config } from './lib/identity/wallets/appkit';
import { AppKitProvider } from '@reown/appkit/react';
// Create a client
const queryClient = new QueryClient();
const App = () => (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>

View File

@ -11,8 +11,8 @@ interface FeedItemBase {
type: 'post' | 'comment';
timestamp: number;
ownerAddress: string;
cellId?: string;
postId?: string;
cellId?: string;
postId?: string;
}
interface PostFeedItem extends FeedItemBase {
@ -20,70 +20,90 @@ interface PostFeedItem extends FeedItemBase {
title: string;
cellId: string;
postId: string;
commentCount: number;
voteCount: number;
commentCount: number;
voteCount: number;
}
interface CommentFeedItem extends FeedItemBase {
type: 'comment';
content: string;
postId: string;
voteCount: number;
voteCount: number;
}
type FeedItem = PostFeedItem | CommentFeedItem;
const ActivityFeed: React.FC = () => {
const { posts, comments, getCellById, isInitialLoading, userVerificationStatus } = useForum();
const {
posts,
comments,
getCellById,
isInitialLoading,
userVerificationStatus,
} = useForum();
const combinedFeed: FeedItem[] = [
...posts.map((post): PostFeedItem => ({
id: post.id,
type: 'post',
timestamp: post.timestamp,
ownerAddress: post.authorAddress,
title: post.title,
cellId: post.cellId,
postId: post.id,
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;
return {
id: comment.id,
type: 'comment',
timestamp: comment.timestamp,
ownerAddress: comment.authorAddress,
content: comment.content,
postId: comment.postId,
cellId: parentPost.cellId,
voteCount: comment.upvotes.length - comment.downvotes.length,
};
})
.filter((item): item is CommentFeedItem => item !== null),
].sort((a, b) => b.timestamp - a.timestamp);
...posts.map(
(post): PostFeedItem => ({
id: post.id,
type: 'post',
timestamp: post.timestamp,
ownerAddress: post.authorAddress,
title: post.title,
cellId: post.cellId,
postId: post.id,
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;
return {
id: comment.id,
type: 'comment',
timestamp: comment.timestamp,
ownerAddress: comment.authorAddress,
content: comment.content,
postId: comment.postId,
cellId: parentPost.cellId,
voteCount: comment.upvotes.length - comment.downvotes.length,
};
})
.filter((item): item is CommentFeedItem => item !== null),
].sort((a, b) => b.timestamp - a.timestamp);
const renderFeedItem = (item: FeedItem) => {
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 (
<Link
to={linkTarget}
key={item.id}
<Link
to={linkTarget}
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"
>
<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">
{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>
by
<AuthorDisplay
by
<AuthorDisplay
address={item.ownerAddress}
userVerificationStatus={userVerificationStatus}
className="font-medium text-foreground/70 mx-1"
@ -92,13 +112,15 @@ const ActivityFeed: React.FC = () => {
{cell && (
<>
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>
</div>
{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}
</p>
)}
@ -109,7 +131,9 @@ const ActivityFeed: React.FC = () => {
if (isInitialLoading) {
return (
<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) => (
<div key={i} className="border border-muted rounded-sm p-3 mb-3">
<Skeleton className="h-4 w-3/4 mb-2" />
@ -122,9 +146,13 @@ const ActivityFeed: React.FC = () => {
return (
<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 ? (
<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)
)}
@ -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 { 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 { 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 { RelevanceIndicator } from './ui/relevance-indicator';
import { sortCells, SortOption } from '@/lib/forum/sorting';
const CellList = () => {
const { cells, isInitialLoading, posts, refreshData, isRefreshing } = useForum();
const { cells, isInitialLoading, posts, refreshData, isRefreshing } =
useForum();
const [sortOption, setSortOption] = useState<SortOption>('relevance');
// Apply sorting to cells
@ -22,8 +36,12 @@ const CellList = () => {
return (
<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" />
<p className="text-lg font-medium text-muted-foreground">Loading Cells...</p>
<p className="text-sm text-muted-foreground/70 mt-1">Connecting to the network and fetching data...</p>
<p className="text-lg font-medium text-muted-foreground">
Loading Cells...
</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Connecting to the network and fetching data...
</p>
</div>
);
}
@ -40,40 +58,45 @@ const CellList = () => {
<h1 className="text-2xl font-bold text-glow">Cells</h1>
</div>
<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">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="relevance">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
<span>Relevance</span>
</div>
</SelectItem>
<SelectItem value="time">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>Newest</span>
</div>
</SelectItem>
</SelectContent>
<SelectContent>
<SelectItem value="relevance">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
<span>Relevance</span>
</div>
</SelectItem>
<SelectItem value="time">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>Newest</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
<Button
variant="outline"
size="icon"
onClick={refreshData}
onClick={refreshData}
disabled={isRefreshing}
title="Refresh data"
className="px-3"
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
<RefreshCw
className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`}
/>
</Button>
<CreateCellDialog />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{cells.length === 0 ? (
<div className="col-span-2 text-center py-12">
@ -82,26 +105,34 @@ const CellList = () => {
</div>
</div>
) : (
sortedCells.map((cell) => (
<Link to={`/cell/${cell.id}`} key={cell.id} className="board-card group">
sortedCells.map(cell => (
<Link
to={`/cell/${cell.id}`}
key={cell.id}
className="board-card group"
>
<div className="flex gap-4 items-start">
<CypherImage
src={cell.icon}
alt={cell.name}
<CypherImage
src={cell.icon}
alt={cell.name}
className="w-16 h-16 object-cover rounded-sm border border-cyber-muted group-hover:border-cyber-accent transition-colors"
generateUniqueFallback={true}
/>
<div className="flex-1">
<h2 className="text-xl font-bold mb-1 group-hover:text-cyber-accent transition-colors">{cell.name}</h2>
<p className="text-sm text-cyber-neutral mb-2">{cell.description}</p>
<h2 className="text-xl font-bold mb-1 group-hover:text-cyber-accent transition-colors">
{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">
<MessageSquare className="w-3 h-3 mr-1" />
<span>{getPostCount(cell.id)} threads</span>
</div>
{cell.relevanceScore !== undefined && (
<RelevanceIndicator
score={cell.relevanceScore}
<RelevanceIndicator
score={cell.relevanceScore}
details={cell.relevanceDetails}
type="cell"
className="text-xs"

View File

@ -1,127 +1,128 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { CypherImage } from './ui/CypherImage'
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { CypherImage } from './ui/CypherImage';
// Mock external dependencies
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', () => {
it('shows fallback identicon when src is empty (simulating cell without icon)', () => {
render(
<CypherImage
src=""
alt="Test Cell"
<CypherImage
src=""
alt="Test Cell"
className="w-16 h-16"
generateUniqueFallback={true}
/>
)
);
// Verify that the fallback identicon is rendered instead of an img tag
const identicon = screen.getByTitle('Test Cell')
expect(identicon).toBeInTheDocument()
const identicon = screen.getByTitle('Test Cell');
expect(identicon).toBeInTheDocument();
// 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)
const firstLetter = screen.getByText('T') // First letter of "Test Cell"
expect(firstLetter).toBeInTheDocument()
expect(firstLetter).toHaveClass('font-bold')
const firstLetter = screen.getByText('T'); // First letter of "Test Cell"
expect(firstLetter).toBeInTheDocument();
expect(firstLetter).toHaveClass('font-bold');
// Should not render an img element when src is empty
const imgElement = screen.queryByRole('img')
expect(imgElement).not.toBeInTheDocument()
})
const imgElement = screen.queryByRole('img');
expect(imgElement).not.toBeInTheDocument();
});
it('shows fallback identicon when src is undefined (simulating cell without icon)', () => {
render(
<CypherImage
src={undefined}
alt="Another Cell"
<CypherImage
src={undefined}
alt="Another Cell"
className="w-16 h-16"
generateUniqueFallback={true}
/>
)
);
// Verify that the fallback identicon is rendered
const identicon = screen.getByTitle('Another Cell')
expect(identicon).toBeInTheDocument()
const identicon = screen.getByTitle('Another Cell');
expect(identicon).toBeInTheDocument();
// The fallback should contain the first letter of the alt text
const firstLetter = screen.getByText('A') // First letter of "Another Cell"
expect(firstLetter).toBeInTheDocument()
const firstLetter = screen.getByText('A'); // First letter of "Another Cell"
expect(firstLetter).toBeInTheDocument();
// Should not render an img element when src is undefined
const imgElement = screen.queryByRole('img')
expect(imgElement).not.toBeInTheDocument()
})
const imgElement = screen.queryByRole('img');
expect(imgElement).not.toBeInTheDocument();
});
it('shows fallback identicon with correct cyberpunk styling', () => {
render(
<CypherImage
src=""
alt="Cyberpunk Cell"
<CypherImage
src=""
alt="Cyberpunk Cell"
className="w-16 h-16"
generateUniqueFallback={true}
/>
)
);
const identicon = screen.getByTitle('Cyberpunk Cell');
const identicon = screen.getByTitle('Cyberpunk Cell')
// 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
const firstLetter = screen.getByText('C')
expect(firstLetter).toHaveClass('relative', 'font-bold', 'cyberpunk-glow', 'z-10')
})
const firstLetter = screen.getByText('C');
expect(firstLetter).toHaveClass(
'relative',
'font-bold',
'cyberpunk-glow',
'z-10'
);
});
it('renders normal img when src is provided (control test)', () => {
render(
<CypherImage
src="https://example.com/valid-image.jpg"
alt="Valid Image Cell"
<CypherImage
src="https://example.com/valid-image.jpg"
alt="Valid Image Cell"
className="w-16 h-16"
/>
)
);
// Should render an img element when src is provided
const imgElement = screen.getByRole('img')
expect(imgElement).toBeInTheDocument()
expect(imgElement).toHaveAttribute('src', 'https://example.com/valid-image.jpg')
expect(imgElement).toHaveAttribute('alt', 'Valid Image Cell')
const imgElement = screen.getByRole('img');
expect(imgElement).toBeInTheDocument();
expect(imgElement).toHaveAttribute(
'src',
'https://example.com/valid-image.jpg'
);
expect(imgElement).toHaveAttribute('alt', 'Valid Image Cell');
// Should not show fallback identicon when image src is provided
const identicon = screen.queryByTitle('Valid Image Cell')
expect(identicon).not.toBeInTheDocument()
})
const identicon = screen.queryByTitle('Valid Image Cell');
expect(identicon).not.toBeInTheDocument();
});
it('generates unique fallbacks for different cell names', () => {
const { rerender } = render(
<CypherImage
src=""
alt="Alpha Cell"
generateUniqueFallback={true}
/>
)
<CypherImage src="" alt="Alpha Cell" generateUniqueFallback={true} />
);
const alphaLetter = screen.getByText('A')
expect(alphaLetter).toBeInTheDocument()
const alphaLetter = screen.getByText('A');
expect(alphaLetter).toBeInTheDocument();
rerender(
<CypherImage
src=""
alt="Beta Cell"
generateUniqueFallback={true}
/>
)
<CypherImage 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
expect(screen.queryByText('A')).not.toBeInTheDocument()
})
})
expect(screen.queryByText('A')).not.toBeInTheDocument();
});
});

View File

@ -1,10 +1,10 @@
import React from 'react';
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Loader2 } from "lucide-react";
import { useForum } from "@/contexts/useForum";
import { useAuth } from "@/contexts/useAuth";
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { useForum } from '@/contexts/useForum';
import { useAuth } from '@/contexts/useAuth';
import {
Form,
FormControl,
@ -12,28 +12,34 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { urlLoads } from "@/lib/utils/urlLoads";
} from '@/components/ui/dialog';
import { useToast } from '@/hooks/use-toast';
import { urlLoads } from '@/lib/utils/urlLoads';
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"),
description: z.string().min(10, "Description must be at least 10 characters").max(200, "Description must be less than 200 characters"),
title: z
.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
.string()
.optional()
.refine((val) => !val || val.length === 0 || URL.canParse(val), {
message: "Must be a valid URL"
.refine(val => !val || val.length === 0 || URL.canParse(val), {
message: 'Must be a valid URL',
}),
});
@ -42,20 +48,23 @@ interface CreateCellDialogProps {
onOpenChange?: (open: boolean) => void;
}
export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCellDialogProps = {}) {
export function CreateCellDialog({
open: externalOpen,
onOpenChange,
}: CreateCellDialogProps = {}) {
const { createCell, isPostingCell } = useForum();
const { isAuthenticated } = useAuth();
const { toast } = useToast();
const [internalOpen, setInternalOpen] = React.useState(false);
const open = externalOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
description: "",
title: '',
description: '',
icon: undefined,
},
});
@ -66,15 +75,20 @@ export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCel
const ok = await urlLoads(values.icon, 5000);
if (!ok) {
toast({
title: "Icon URL Error",
description: "Icon URL could not be loaded. Please check the URL and try again.",
variant: "destructive",
title: 'Icon URL Error',
description:
'Icon URL could not be loaded. Please check the URL and try again.',
variant: 'destructive',
});
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) {
setOpen(false);
form.reset();
@ -87,7 +101,9 @@ export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCel
<Dialog open={open} onOpenChange={setOpen}>
{!onOpenChange && (
<DialogTrigger asChild>
<Button variant="outline" className="w-full">Create New Cell</Button>
<Button variant="outline" className="w-full">
Create New Cell
</Button>
</DialogTrigger>
)}
<DialogContent className="sm:max-w-[425px]">
@ -103,7 +119,11 @@ export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCel
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Enter cell title" {...field} disabled={isPostingCell} />
<Input
placeholder="Enter cell title"
{...field}
disabled={isPostingCell}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -116,7 +136,7 @@ export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCel
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
<Textarea
placeholder="Enter cell description"
{...field}
disabled={isPostingCell}
@ -133,11 +153,11 @@ export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCel
<FormItem>
<FormLabel>Icon URL (optional)</FormLabel>
<FormControl>
<Input
<Input
placeholder="Enter icon URL (optional)"
type="url"
{...field}
value={field.value || ""}
value={field.value || ''}
disabled={isPostingCell}
/>
</FormControl>
@ -145,11 +165,7 @@ export function CreateCellDialog({ open: externalOpen, onOpenChange }: CreateCel
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isPostingCell}
>
<Button type="submit" className="w-full" disabled={isPostingCell}>
{isPostingCell && (
<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 { useAuth } from '@/contexts/useAuth';
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 { cells, posts } = useForum();
@ -18,19 +19,20 @@ const FeedSidebar: React.FC = () => {
const trendingCells = cells
.map(cell => {
const cellPosts = posts.filter(post => post.cellId === cell.id);
const recentPosts = cellPosts.filter(post =>
Date.now() - post.timestamp < 24 * 60 * 60 * 1000 // Last 24 hours
const recentPosts = cellPosts.filter(
post => Date.now() - post.timestamp < 24 * 60 * 60 * 1000 // Last 24 hours
);
const totalScore = cellPosts.reduce((sum, post) =>
sum + (post.upvotes.length - post.downvotes.length), 0
const totalScore = cellPosts.reduce(
(sum, post) => sum + (post.upvotes.length - post.downvotes.length),
0
);
return {
...cell,
postCount: cellPosts.length,
recentPostCount: recentPosts.length,
totalScore,
activity: recentPosts.length + (totalScore * 0.1) // Simple activity score
activity: recentPosts.length + totalScore * 0.1, // Simple activity score
};
})
.sort((a, b) => b.activity - a.activity)
@ -44,10 +46,22 @@ const FeedSidebar: React.FC = () => {
// Ethereum wallet with ENS
if (currentUser.walletType === 'ethereum') {
if (currentUser.ensName && (verificationStatus === 'verified-owner' || currentUser.ensOwnership)) {
return <Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> Owns ENS: {currentUser.ensName}</Badge>;
if (
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') {
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 {
return <Badge variant="outline">Read-only (No ENS detected)</Badge>;
}
@ -55,10 +69,21 @@ const FeedSidebar: React.FC = () => {
// Bitcoin wallet with Ordinal
if (currentUser.walletType === 'bitcoin') {
if (verificationStatus === 'verified-owner' || currentUser.ordinalOwnership) {
return <Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> Owns Ordinal</Badge>;
if (
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') {
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 {
return <Badge variant="outline">Read-only (No Ordinal detected)</Badge>;
}
@ -67,7 +92,11 @@ const FeedSidebar: React.FC = () => {
// Fallback cases
switch (verificationStatus) {
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':
return <Badge variant="outline">Read Only</Badge>;
case 'verifying':
@ -83,11 +112,14 @@ const FeedSidebar: React.FC = () => {
{currentUser && (
<Card className="bg-cyber-muted/20 border-cyber-muted">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-cyber-accent">Your Account</CardTitle>
<CardTitle className="text-sm font-medium text-cyber-accent">
Your Account
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="text-xs text-cyber-neutral">
{currentUser.address.slice(0, 8)}...{currentUser.address.slice(-6)}
{currentUser.address.slice(0, 8)}...
{currentUser.address.slice(-6)}
</div>
{getVerificationBadge()}
</CardContent>
@ -97,7 +129,7 @@ const FeedSidebar: React.FC = () => {
{/* Create Cell */}
<Card className="bg-cyber-muted/20 border-cyber-muted">
<CardContent className="p-4">
<Button
<Button
onClick={() => setShowCreateCell(true)}
className="w-full"
disabled={verificationStatus !== 'verified-owner'}
@ -107,10 +139,9 @@ const FeedSidebar: React.FC = () => {
</Button>
{verificationStatus !== 'verified-owner' && (
<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 a Bitcoin Ordinal to create cells'
}
: 'Own a Bitcoin Ordinal to create cells'}
</p>
)}
</CardContent>
@ -129,8 +160,8 @@ const FeedSidebar: React.FC = () => {
<p className="text-xs text-cyber-neutral">No cells yet</p>
) : (
trendingCells.map((cell, index) => (
<Link
key={cell.id}
<Link
key={cell.id}
to={`/cell/${cell.id}`}
className="flex items-center space-x-3 p-2 rounded-sm hover:bg-cyber-muted/50 transition-colors"
>
@ -138,8 +169,8 @@ const FeedSidebar: React.FC = () => {
<span className="text-xs font-medium text-cyber-neutral w-4">
{index + 1}
</span>
<CypherImage
src={cell.icon}
<CypherImage
src={cell.icon}
alt={cell.name}
className="w-6 h-6 rounded-sm flex-shrink-0"
generateUniqueFallback={true}
@ -178,7 +209,7 @@ const FeedSidebar: React.FC = () => {
) : (
<div className="space-y-1">
{cells.slice(0, 8).map(cell => (
<Link
<Link
key={cell.id}
to={`/cell/${cell.id}`}
className="block text-sm text-cyber-neutral hover:text-cyber-accent transition-colors"
@ -187,7 +218,7 @@ const FeedSidebar: React.FC = () => {
</Link>
))}
{cells.length > 8 && (
<Link
<Link
to="/"
className="block text-xs text-cyber-neutral hover:text-cyber-accent transition-colors"
>
@ -214,12 +245,12 @@ const FeedSidebar: React.FC = () => {
</Card>
{/* Create Cell Dialog */}
<CreateCellDialog
open={showCreateCell}
<CreateCellDialog
open={showCreateCell}
onOpenChange={setShowCreateCell}
/>
</div>
);
};
export default FeedSidebar;
export default FeedSidebar;

View File

@ -4,34 +4,50 @@ import { useAuth } from '@/contexts/useAuth';
import { useForum } from '@/contexts/useForum';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { LogOut, Terminal, Wifi, WifiOff, AlertTriangle, CheckCircle, Key, RefreshCw, CircleSlash, Home, Grid3X3} from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
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 { useAppKitAccount, useDisconnect } from '@reown/appkit/react';
import { WalletWizard } from '@/components/ui/wallet-wizard';
const Header = () => {
const {
currentUser,
verificationStatus,
isDelegationValid,
} = useAuth();
const { currentUser, verificationStatus, isDelegationValid } = useAuth();
const { isNetworkConnected, isRefreshing } = useForum();
const location = useLocation();
const { toast } = useToast();
// Use AppKit hooks for multi-chain support
const bitcoinAccount = useAppKitAccount({ namespace: "bip122" });
const ethereumAccount = useAppKitAccount({ namespace: "eip155" });
const bitcoinAccount = useAppKitAccount({ namespace: 'bip122' });
const ethereumAccount = useAppKitAccount({ namespace: 'eip155' });
const { disconnect } = useDisconnect();
// Determine which account is connected
const isBitcoinConnected = bitcoinAccount.isConnected;
const isEthereumConnected = ethereumAccount.isConnected;
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);
// Use sessionStorage to persist wizard state across navigation
const getHasShownWizard = () => {
try {
@ -40,7 +56,7 @@ const Header = () => {
return false;
}
};
const setHasShownWizard = (value: boolean) => {
try {
sessionStorage.setItem('hasShownWalletWizard', value.toString());
@ -48,7 +64,7 @@ const Header = () => {
// Fallback if sessionStorage is not available
}
};
// Auto-open wizard when wallet connects for the first time
React.useEffect(() => {
if (isConnected && !getHasShownWizard()) {
@ -56,21 +72,19 @@ const Header = () => {
setHasShownWizard(true);
}
}, [isConnected]);
const handleConnect = async () => {
setWalletWizardOpen(true);
};
const handleDisconnect = async () => {
await disconnect();
setHasShownWizard(false); // Reset so wizard can show again on next connection
toast({
title: "Wallet Disconnected",
description: "Your wallet has been disconnected successfully.",
title: 'Wallet Disconnected',
description: 'Your wallet has been disconnected successfully.',
});
};
const getAccountStatusText = () => {
switch (verificationStatus) {
@ -98,9 +112,17 @@ const Header = () => {
case 'verified-none':
return <CircleSlash className="w-3 h-3" />;
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':
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:
return <AlertTriangle className="w-3 h-3" />;
}
@ -122,26 +144,29 @@ const Header = () => {
return 'outline';
}
};
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="flex items-center gap-6">
<div className="flex items-center gap-2">
<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
</Link>
</div>
{/* Navigation Tabs */}
<nav className="hidden md:flex items-center space-x-1">
<Link
to="/"
className={`flex items-center space-x-2 px-3 py-2 text-sm font-medium rounded-sm transition-colors ${
location.pathname === '/'
? 'bg-cyber-accent/20 text-cyber-accent'
location.pathname === '/'
? 'bg-cyber-accent/20 text-cyber-accent'
: 'text-gray-300 hover:text-cyber-accent hover:bg-cyber-accent/10'
}`}
>
@ -151,8 +176,8 @@ const Header = () => {
<Link
to="/cells"
className={`flex items-center space-x-2 px-3 py-2 text-sm font-medium rounded-sm transition-colors ${
location.pathname === '/cells'
? 'bg-cyber-accent/20 text-cyber-accent'
location.pathname === '/cells'
? 'bg-cyber-accent/20 text-cyber-accent'
: 'text-gray-300 hover:text-cyber-accent hover:bg-cyber-accent/10'
}`}
>
@ -161,13 +186,13 @@ const Header = () => {
</Link>
</nav>
</div>
<div className="flex gap-3 items-center">
<div className="flex gap-3 items-center">
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={isNetworkConnected ? "default" : "destructive"}
className="flex items-center gap-1 text-xs px-2 h-7 cursor-help"
<Badge
variant={isNetworkConnected ? 'default' : 'destructive'}
className="flex items-center gap-1 text-xs px-2 h-7 cursor-help"
>
{isNetworkConnected ? (
<>
@ -183,29 +208,33 @@ const Header = () => {
</Badge>
</TooltipTrigger>
<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>}
</TooltipContent>
</Tooltip>
{!isConnected ? (
<Button
variant="outline"
<Button
variant="outline"
size="sm"
onClick={handleConnect}
className="text-xs px-2 h-7"
className="text-xs px-2 h-7"
>
Connect Wallet
</Button>
) : (
<div className="flex gap-2 items-center">
<div className="flex gap-2 items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={getAccountStatusVariant()}
size="sm"
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()}
<span>{getAccountStatusText()}</span>
@ -213,45 +242,55 @@ const Header = () => {
</TooltipTrigger>
<TooltipContent className="max-w-[260px] text-sm">
<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>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<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)}`}
<span className="hidden md:flex items-center text-xs text-muted-foreground cursor-default px-2 h-7">
{currentUser?.ensDetails?.ensName ||
`${address?.slice(0, 5)}...${address?.slice(-4)}`}
</span>
</TooltipTrigger>
<TooltipContent className="text-sm">
<p>{currentUser?.ensName ? `${currentUser.ensName} (${address})` : address}</p>
<p>
{currentUser?.ensDetails?.ensName
? `${currentUser.ensDetails.ensName} (${address})`
: address}
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleDisconnect}
className="w-7 h-7"
className="w-7 h-7"
>
<LogOut className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent className="text-sm">Disconnect Wallet</TooltipContent>
</TooltipTrigger>
<TooltipContent className="text-sm">
Disconnect Wallet
</TooltipContent>
</Tooltip>
</div>
)}
</div>
</div>
</header>
<WalletWizard
open={walletWizardOpen}
onOpenChange={setWalletWizardOpen}
onComplete={() => {
toast({
title: "Setup Complete",
description: "You can now use all OpChan features!",
title: 'Setup Complete',
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 { ArrowUp, ArrowDown, MessageSquare } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { Post } from '@/types';
import { Post } from '@/types/forum';
import { useForum } from '@/contexts/useForum';
import { useAuth } from '@/contexts/useAuth';
import { RelevanceIndicator } from '@/components/ui/relevance-indicator';
@ -14,24 +14,30 @@ interface PostCardProps {
}
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 cell = getCellById(post.cellId);
const cellName = cell?.name || 'unknown';
// Calculate vote score
const score = post.upvotes.length - post.downvotes.length;
// Check user's vote status
const userUpvoted = currentUser ? post.upvotes.some(vote => vote.author === currentUser.address) : false;
const userDownvoted = currentUser ? post.downvotes.some(vote => vote.author === currentUser.address) : false;
const userUpvoted = currentUser
? post.upvotes.some(vote => vote.author === currentUser.address)
: false;
const userDownvoted = currentUser
? post.downvotes.some(vote => vote.author === currentUser.address)
: false;
// Truncate content for preview
const contentPreview = post.content.length > 200
? post.content.substring(0, 200) + '...'
: post.content;
const contentPreview =
post.content.length > 200
? post.content.substring(0, 200) + '...'
: post.content;
const handleVote = async (e: React.MouseEvent, isUpvote: boolean) => {
e.preventDefault(); // Prevent navigation when clicking vote buttons
if (!isAuthenticated) return;
@ -43,32 +49,40 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
<div className="flex">
{/* Voting column */}
<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 ${
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}
title={isAuthenticated ? "Upvote" : "Connect wallet to vote"}
title={isAuthenticated ? 'Upvote' : 'Connect wallet to vote'}
>
<ArrowUp className="w-5 h-5" />
</button>
<span className={`text-sm font-medium px-1 ${
score > 0 ? 'text-cyber-accent' :
score < 0 ? 'text-blue-400' :
'text-cyber-neutral'
}`}>
<span
className={`text-sm font-medium px-1 ${
score > 0
? 'text-cyber-accent'
: score < 0
? 'text-blue-400'
: 'text-cyber-neutral'
}`}
>
{score}
</span>
<button
<button
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}
title={isAuthenticated ? "Downvote" : "Connect wallet to vote"}
title={isAuthenticated ? 'Downvote' : 'Connect wallet to vote'}
>
<ArrowDown className="w-5 h-5" />
</button>
@ -79,22 +93,28 @@ const PostCard: React.FC<PostCardProps> = ({ post, commentCount = 0 }) => {
<Link to={`/post/${post.id}`} className="block hover:opacity-80">
{/* Post metadata */}
<div className="flex items-center text-xs text-cyber-neutral mb-2 space-x-2">
<span className="font-medium text-cyber-accent">r/{cellName}</span>
<span className="font-medium text-cyber-accent">
r/{cellName}
</span>
<span></span>
<span>Posted by u/</span>
<AuthorDisplay
<AuthorDisplay
address={post.authorAddress}
userVerificationStatus={userVerificationStatus}
className="text-xs"
showBadge={false}
/>
<span></span>
<span>{formatDistanceToNow(new Date(post.timestamp), { addSuffix: true })}</span>
<span>
{formatDistanceToNow(new Date(post.timestamp), {
addSuffix: true,
})}
</span>
{post.relevanceScore !== undefined && (
<>
<span></span>
<RelevanceIndicator
score={post.relevanceScore}
<RelevanceIndicator
score={post.relevanceScore}
details={post.relevanceDetails}
type="post"
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 { Button } from '@/components/ui/button';
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 { Comment } from '@/types/forum';
import { CypherImage } from './ui/CypherImage';
@ -14,144 +23,181 @@ import { AuthorDisplay } from './ui/author-display';
const PostDetail = () => {
const { postId } = useParams<{ postId: string }>();
const navigate = useNavigate();
const {
posts,
getCommentsByPost,
createComment,
votePost,
voteComment,
getCellById,
isInitialLoading,
const {
posts,
getCommentsByPost,
createComment,
votePost,
voteComment,
getCellById,
isInitialLoading,
isPostingComment,
isVoting,
moderateComment,
moderateUser,
userVerificationStatus
userVerificationStatus,
} = useForum();
const { currentUser, verificationStatus } = useAuth();
const { currentUser, verificationStatus } = useAuth();
const [newComment, setNewComment] = useState('');
if (!postId) return <div>Invalid post ID</div>;
if (isInitialLoading) {
return (
<div className="container mx-auto px-4 py-16 text-center">
<Loader2 className="w-8 h-8 mx-auto mb-4 animate-spin text-primary" />
<p className="text-lg font-medium text-muted-foreground">Loading Post...</p>
<p className="text-lg font-medium text-muted-foreground">
Loading Post...
</p>
</div>
);
}
const post = posts.find(p => p.id === postId);
if (!post) {
return (
<div className="container mx-auto px-4 py-6 text-center">
<h2 className="text-xl font-bold mb-4">Post not found</h2>
<p className="mb-4">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>
<Link to="/">Go back home</Link>
</Button>
</div>
);
}
const cell = getCellById(post.cellId);
const postComments = getCommentsByPost(post.id);
const isCellAdmin = currentUser && cell && currentUser.address === cell.signature;
const isCellAdmin =
currentUser && cell && currentUser.address === cell.signature;
const visibleComments = isCellAdmin
? postComments
: postComments.filter(comment => !comment.moderated);
const handleCreateComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim()) return;
try {
const result = await createComment(postId, newComment);
if (result) {
setNewComment('');
}
} catch (error) {
console.error("Error creating comment:", error);
console.error('Error creating comment:', error);
}
};
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);
};
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);
};
const isPostUpvoted = currentUser && post.upvotes.some(vote => vote.author === currentUser.address);
const isPostDownvoted = currentUser && post.downvotes.some(vote => vote.author === currentUser.address);
const isPostUpvoted =
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) => {
if (!currentUser) return false;
const votes = isUpvote ? comment.upvotes : comment.downvotes;
return votes.some(vote => vote.author === currentUser.address);
};
const getIdentityImageUrl = (address: string) => {
return `https://api.dicebear.com/7.x/identicon/svg?seed=${address}`;
};
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;
await moderateComment(cell.id, commentId, reason, cell.signature);
};
const handleModerateUser = async (userAddress: string) => {
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);
};
return (
<div className="container mx-auto px-4 py-6">
<div className="mb-6">
<Button
onClick={() => navigate(`/cell/${post.cellId}`)}
variant="ghost"
<Button
onClick={() => navigate(`/cell/${post.cellId}`)}
variant="ghost"
size="sm"
className="mb-4"
>
<ArrowLeft className="w-4 h-4 mr-1" />
Back to /{cell?.name || 'cell'}/
</Button>
<div className="border border-muted rounded-sm p-3 mb-6">
<div className="flex gap-3 items-start">
<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' : ''}`}
onClick={() => handleVotePost(true)}
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" />
</button>
<span className="text-sm font-medium py-1">{post.upvotes.length - post.downvotes.length}</span>
<button
<span className="text-sm font-medium py-1">
{post.upvotes.length - post.downvotes.length}
</span>
<button
className={`p-1 rounded-sm hover:bg-secondary/50 disabled:opacity-50 ${isPostDownvoted ? 'text-primary' : ''}`}
onClick={() => handleVotePost(false)}
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" />
</button>
</div>
<div className="flex-1">
<h2 className="text-xl font-bold mb-2 text-foreground">{post.title}</h2>
<p className="text-base mb-4 text-foreground/90">{post.content}</p>
<h2 className="text-xl font-bold mb-2 text-foreground">
{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">
<span className="flex items-center">
<Clock className="w-3 h-3 mr-1" />
@ -159,16 +205,17 @@ const PostDetail = () => {
</span>
<span className="flex items-center">
<MessageCircle className="w-3 h-3 mr-1" />
{postComments.length} {postComments.length === 1 ? 'comment' : 'comments'}
{postComments.length}{' '}
{postComments.length === 1 ? 'comment' : 'comments'}
</span>
<AuthorDisplay
<AuthorDisplay
address={post.authorAddress}
userVerificationStatus={userVerificationStatus}
className="truncate max-w-[150px]"
/>
{post.relevanceScore !== undefined && (
<RelevanceIndicator
score={post.relevanceScore}
<RelevanceIndicator
score={post.relevanceScore}
details={post.relevanceDetails}
type="post"
className="text-xs"
@ -180,20 +227,23 @@ const PostDetail = () => {
</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">
<form onSubmit={handleCreateComment}>
<div className="flex gap-2">
<Textarea
placeholder="Add a comment..."
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"
disabled={isPostingComment}
/>
<Button
type="submit"
<Button
type="submit"
disabled={isPostingComment || !newComment.trim()}
size="icon"
>
@ -209,19 +259,21 @@ const PostDetail = () => {
<h3 className="font-medium">Read-Only Mode</h3>
</div>
<p className="text-sm text-muted-foreground">
Your wallet has been verified but does not contain any Ordinal Operators.
You can browse threads but cannot comment or vote.
Your wallet has been verified but does not contain any Ordinal
Operators. You can browse threads but cannot comment or vote.
</p>
</div>
) : (
<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">
<Link to="/">Go to Home</Link>
</Button>
</div>
)}
<div className="space-y-2">
{postComments.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
@ -229,23 +281,41 @@ const PostDetail = () => {
</div>
) : (
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 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' : ''}`}
onClick={() => handleVoteComment(comment.id, true)}
disabled={verificationStatus !== 'verified-owner' || isVoting}
title={verificationStatus === 'verified-owner' ? "Upvote" : "Full access required to vote"}
disabled={
verificationStatus !== 'verified-owner' || isVoting
}
title={
verificationStatus === 'verified-owner'
? 'Upvote'
: 'Full access required to vote'
}
>
<ArrowUp className="w-4 h-4" />
</button>
<span className="text-xs font-medium py-0.5">{comment.upvotes.length - comment.downvotes.length}</span>
<button
<span className="text-xs font-medium py-0.5">
{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' : ''}`}
onClick={() => handleVoteComment(comment.id, false)}
disabled={verificationStatus !== 'verified-owner' || isVoting}
title={verificationStatus === 'verified-owner' ? "Downvote" : "Full access required to vote"}
disabled={
verificationStatus !== 'verified-owner' || isVoting
}
title={
verificationStatus === 'verified-owner'
? 'Downvote'
: 'Full access required to vote'
}
>
<ArrowDown className="w-4 h-4" />
</button>
@ -253,12 +323,12 @@ const PostDetail = () => {
<div className="flex-1 pt-0.5">
<div className="flex justify-between items-center mb-1.5">
<div className="flex items-center gap-1.5">
<CypherImage
<CypherImage
src={getIdentityImageUrl(comment.authorAddress)}
alt={comment.authorAddress.slice(0, 6)}
className="rounded-sm w-5 h-5 bg-secondary"
/>
<AuthorDisplay
<AuthorDisplay
address={comment.authorAddress}
userVerificationStatus={userVerificationStatus}
className="text-xs"
@ -266,8 +336,8 @@ const PostDetail = () => {
</div>
<div className="flex items-center gap-2">
{comment.relevanceScore !== undefined && (
<RelevanceIndicator
score={comment.relevanceScore}
<RelevanceIndicator
score={comment.relevanceScore}
details={comment.relevanceDetails}
type="comment"
className="text-xs"
@ -275,23 +345,37 @@ const PostDetail = () => {
/>
)}
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(comment.timestamp, { addSuffix: true })}
{formatDistanceToNow(comment.timestamp, {
addSuffix: true,
})}
</span>
</div>
</div>
<p className="text-sm break-words">{comment.content}</p>
{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
</Button>
)}
{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
</Button>
)}
{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>

View File

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

View File

@ -7,7 +7,10 @@ type CypherImageProps = {
className?: string;
fallbackClassName?: string;
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
@ -22,11 +25,14 @@ export function CypherImage({
...props
}: CypherImageProps) {
const [imageError, setImageError] = useState(false);
// Generate a seed based on the alt text or src to create consistent fallbacks for the same resource
const seed = generateUniqueFallback ?
(alt || src || 'fallback').split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) : 0;
const seed = generateUniqueFallback
? (alt || src || 'fallback')
.split('')
.reduce((acc, char) => acc + char.charCodeAt(0), 0)
: 0;
// Handle image load error
const handleError = () => {
setImageError(true);
@ -38,9 +44,9 @@ export function CypherImage({
const gridSize = (seed % 8) + 8; // 8-16px
const noiseIntensity = (seed % 30) + 5; // 5-35%
const scanlineOpacity = ((seed % 4) + 1) / 10; // 0.1-0.5
return (
<div
<div
className={cn(
'flex items-center justify-center overflow-hidden relative',
className,
@ -60,22 +66,22 @@ export function CypherImage({
rgba(${hue / 3}, ${hue}, ${hue}, 0.15) 75%)
`,
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}
>
{/* Noise overlay */}
<div
className="absolute inset-0 opacity-20"
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")`,
mixBlendMode: 'overlay'
<div
className="absolute inset-0 opacity-20"
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")`,
mixBlendMode: 'overlay',
}}
/>
{/* Scanlines */}
<div
className="absolute inset-0 pointer-events-none mix-blend-overlay"
<div
className="absolute inset-0 pointer-events-none mix-blend-overlay"
style={{
background: `repeating-linear-gradient(
to bottom,
@ -83,38 +89,39 @@ export function CypherImage({
transparent 1px,
rgba(0, 255, 170, ${scanlineOpacity}) 1px,
rgba(0, 255, 170, ${scanlineOpacity}) 2px
)`
)`,
}}
/>
{/* CRT glow effect */}
<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">
{/* Glitch effect lines */}
<div
className="absolute w-full h-1 bg-gradient-to-r from-transparent via-cyan-400 to-transparent"
style={{
<div
className="absolute w-full h-1 bg-gradient-to-r from-transparent via-cyan-400 to-transparent"
style={{
top: `${(seed % 70) + 15}%`,
opacity: 0.4,
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 */}
<div
<div
className="relative flex items-center justify-center"
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 */}
<div
<div
className="absolute font-mono opacity-70"
style={{
style={{
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) => {
@ -122,23 +129,23 @@ export function CypherImage({
return chars[(seed + i) % chars.length];
}).join('')}
</div>
{/* First letter of alt text in center */}
<div
<div
className="relative font-bold text-2xl md:text-3xl cyberpunk-glow z-10"
style={{ color: `hsl(${hue}, 100%, 80%)` }}
>
{alt.charAt(0).toUpperCase()}
</div>
{/* Random characters that occasionally "glitch" in */}
<div
<div
className="absolute font-mono text-xs text-cyan-400 opacity-80 z-0"
style={{
bottom: '20%',
right: '20%',
transform: `rotate(${(seed % 20) - 10}deg)`,
mixBlendMode: 'screen'
mixBlendMode: 'screen',
}}
>
{seed.toString(16).substring(0, 4)}
@ -158,4 +165,4 @@ export function CypherImage({
{...props}
/>
);
}
}

View File

@ -1,10 +1,10 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import * as React from 'react';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
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<
React.ElementRef<typeof AccordionPrimitive.Item>,
@ -12,11 +12,11 @@ const AccordionItem = React.forwardRef<
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
className={cn('border-b', className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
@ -26,7 +26,7 @@ const AccordionTrigger = React.forwardRef<
<AccordionPrimitive.Trigger
ref={ref}
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
)}
{...props}
@ -35,8 +35,8 @@ const AccordionTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
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"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</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 AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { cn } from '@/lib/utils';
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<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
@ -16,14 +16,14 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
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
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
@ -34,14 +34,14 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
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
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
@ -49,13 +49,13 @@ const AlertDialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({
className,
@ -63,13 +63,13 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
@ -77,11 +77,11 @@ const AlertDialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
className={cn('text-lg font-semibold', className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
@ -89,12 +89,12 @@ const AlertDialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
@ -105,8 +105,8 @@ const AlertDialogAction = React.forwardRef<
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
@ -115,14 +115,14 @@ const AlertDialogCancel = React.forwardRef<
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
@ -136,4 +136,4 @@ export {
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
};

View File

@ -1,23 +1,23 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
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: {
variant: {
default: "bg-background text-foreground",
default: 'bg-background text-foreground',
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
}
)
);
const Alert = React.forwardRef<
HTMLDivElement,
@ -29,8 +29,8 @@ const Alert = React.forwardRef<
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
@ -38,11 +38,11 @@ const AlertTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<h5
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}
/>
))
AlertTitle.displayName = "AlertTitle"
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
@ -50,10 +50,10 @@ const AlertDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...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 { Badge } from '@/components/ui/badge';
import { Shield, Crown } from 'lucide-react';
import { UserVerificationStatus } from '@/lib/forum/types';
import { UserVerificationStatus } from '@/types/forum';
import { getEnsName } from '@wagmi/core';
import { config } from '@/lib/identity/wallets/appkit';
import { OrdinalAPI } from '@/lib/identity/ordinal';
@ -13,15 +13,19 @@ interface AuthorDisplayProps {
showBadge?: boolean;
}
export function AuthorDisplay({
address,
userVerificationStatus,
className = "",
showBadge = true
export function AuthorDisplay({
address,
userVerificationStatus,
className = '',
showBadge = true,
}: AuthorDisplayProps) {
const userStatus = userVerificationStatus?.[address];
const [resolvedEns, setResolvedEns] = React.useState<string | undefined>(undefined);
const [resolvedOrdinal, setResolvedOrdinal] = React.useState<boolean | undefined>(undefined);
const [resolvedEns, setResolvedEns] = React.useState<string | undefined>(
undefined
);
const [resolvedOrdinal, setResolvedOrdinal] = React.useState<
boolean | undefined
>(undefined);
// Heuristics for address types
const isEthereumAddress = address.startsWith('0x') && address.length === 42;
@ -32,13 +36,18 @@ export function AuthorDisplay({
let cancelled = false;
if (!userStatus?.ensName && isEthereumAddress) {
getEnsName(config, { address: address as `0x${string}` })
.then((name) => { if (!cancelled) setResolvedEns(name || undefined); })
.catch(() => { if (!cancelled) setResolvedEns(undefined); });
.then(name => {
if (!cancelled) setResolvedEns(name || undefined);
})
.catch(() => {
if (!cancelled) setResolvedEns(undefined);
});
} else {
setResolvedEns(userStatus?.ensName);
}
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [address, isEthereumAddress, userStatus?.ensName]);
// Lazily check Ordinal ownership for Bitcoin addresses if not provided
@ -46,8 +55,9 @@ export function AuthorDisplay({
let cancelled = false;
const run = async () => {
console.log({
isBitcoinAddress, userStatus
})
isBitcoinAddress,
userStatus,
});
if (isBitcoinAddress) {
try {
const api = new OrdinalAPI();
@ -61,28 +71,33 @@ export function AuthorDisplay({
}
};
run();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [address, isBitcoinAddress, userStatus?.hasOrdinal]);
const hasENS = Boolean(userStatus?.hasENS) || Boolean(resolvedEns) || Boolean(userStatus?.ensName);
const hasOrdinal = Boolean(userStatus?.hasOrdinal) || Boolean(resolvedOrdinal);
const hasENS =
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)
const shouldShowBadge = showBadge && (hasENS || hasOrdinal);
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 (
<div className={`flex items-center gap-1.5 ${className}`}>
<span className="text-xs text-muted-foreground">
{displayName}
</span>
<span className="text-xs text-muted-foreground">{displayName}</span>
{shouldShowBadge && (
<Badge
variant="secondary"
<Badge
variant="secondary"
className="text-xs px-1.5 py-0.5 h-auto bg-green-900/20 border-green-500/30 text-green-400"
>
{hasENS ? (

View File

@ -1,7 +1,7 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
@ -10,13 +10,13 @@ const Avatar = React.forwardRef<
<AvatarPrimitive.Root
ref={ref}
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
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
@ -24,11 +24,11 @@ const AvatarImage = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
@ -37,12 +37,12 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback
ref={ref}
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
)}
{...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 { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
'inline-flex items-center 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: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
}
)
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
@ -35,9 +35,9 @@ const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
ref={ref}
{...props}
/>
)
);
}
)
Badge.displayName = "Badge"
);
Badge.displayName = 'Badge';
export { Badge }
export { Badge };

View File

@ -1,108 +1,108 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
React.ComponentPropsWithoutRef<'nav'> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = 'Breadcrumb';
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
React.ComponentPropsWithoutRef<'ol'>
>(({ className, ...props }, ref) => (
<ol
ref={ref}
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
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
));
BreadcrumbList.displayName = 'BreadcrumbList';
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
React.ComponentPropsWithoutRef<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
));
BreadcrumbItem.displayName = 'BreadcrumbItem';
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
React.ComponentPropsWithoutRef<'a'> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
const Comp = asChild ? Slot : 'a';
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
className={cn('transition-colors hover:text-foreground', className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
);
});
BreadcrumbLink.displayName = 'BreadcrumbLink';
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
React.ComponentPropsWithoutRef<'span'>
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
className={cn('font-normal text-foreground', className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
));
BreadcrumbPage.displayName = 'BreadcrumbPage';
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
}: React.ComponentProps<'li'>) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
);
BreadcrumbSeparator.displayName = 'BreadcrumbSeparator';
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
}: React.ComponentProps<'span'>) => (
<span
role="presentation"
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}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
);
BreadcrumbEllipsis.displayName = 'BreadcrumbElipssis';
export {
Breadcrumb,
@ -112,4 +112,4 @@ export {
BreadcrumbPage,
BreadcrumbSeparator,
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 { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
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",
},
}
)
import { cn } from '@/lib/utils';
import { buttonVariants } from './button-variants';
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
);
}
)
Button.displayName = "Button"
);
Button.displayName = 'Button';
export { Button }
export { Button };

View File

@ -1,9 +1,9 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import * as React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker } from 'react-day-picker';
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button-variants';
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
@ -16,49 +16,49 @@ function Calendar({
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
className={cn('p-3', className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
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",
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
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',
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
buttonVariants({ variant: 'ghost' }),
'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:
"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",
'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_outside:
"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-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_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
IconRight: () => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
Calendar.displayName = '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<
HTMLDivElement,
@ -9,13 +9,13 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
))
Card.displayName = "Card"
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
@ -23,11 +23,11 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLParagraphElement,
@ -36,13 +36,13 @@ const CardTitle = React.forwardRef<
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
@ -50,19 +50,19 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
@ -70,10 +70,17 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
className={cn('flex items-center p-6 pt-0', className)}
{...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, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
} from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext)
const context = React.useContext(CarouselContext);
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<
@ -46,7 +46,7 @@ const Carousel = React.forwardRef<
>(
(
{
orientation = "horizontal",
orientation = 'horizontal',
opts,
setApi,
plugins,
@ -59,64 +59,64 @@ const Carousel = React.forwardRef<
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
return;
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
)
);
React.useEffect(() => {
if (!api || !setApi) {
return
return;
}
setApi(api)
}, [api, setApi])
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return
return;
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
@ -125,7 +125,7 @@ const Carousel = React.forwardRef<
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
@ -135,7 +135,7 @@ const Carousel = React.forwardRef<
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
{...props}
@ -143,38 +143,38 @@ const Carousel = React.forwardRef<
{children}
</div>
</CarouselContext.Provider>
)
);
}
)
Carousel.displayName = "Carousel"
);
Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
);
});
CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
const { orientation } = useCarousel();
return (
<div
@ -182,21 +182,21 @@ const CarouselItem = React.forwardRef<
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
);
});
CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
@ -204,10 +204,10 @@ const CarouselPrevious = React.forwardRef<
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-left-12 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollPrev}
@ -217,15 +217,15 @@ const CarouselPrevious = React.forwardRef<
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
);
});
CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
@ -233,10 +233,10 @@ const CarouselNext = React.forwardRef<
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
'absolute h-8 w-8 rounded-full',
orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollNext}
@ -246,9 +246,9 @@ const CarouselNext = React.forwardRef<
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
);
});
CarouselNext.displayName = 'CarouselNext';
export {
type CarouselApi,
@ -257,4 +257,4 @@ export {
CarouselItem,
CarouselPrevious,
CarouselNext,
}
};

View File

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

View File

@ -1,8 +1,8 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
@ -11,18 +11,18 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
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
)}
{...props}
>
<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" />
</CheckboxPrimitive.Indicator>
</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 { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
@ -13,14 +13,13 @@ const Command = React.forwardRef<
<CommandPrimitive
ref={ref}
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
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
@ -31,8 +30,8 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
</Command>
</DialogContent>
</Dialog>
)
}
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
@ -43,15 +42,15 @@ const CommandInput = React.forwardRef<
<CommandPrimitive.Input
ref={ref}
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
)}
{...props}
/>
</div>
))
));
CommandInput.displayName = CommandPrimitive.Input.displayName
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
@ -59,12 +58,12 @@ const CommandList = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
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}
/>
))
));
CommandList.displayName = CommandPrimitive.List.displayName
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
@ -75,9 +74,9 @@ const CommandEmpty = React.forwardRef<
className="py-6 text-center text-sm"
{...props}
/>
))
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
@ -86,14 +85,14 @@ const CommandGroup = React.forwardRef<
<CommandPrimitive.Group
ref={ref}
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
)}
{...props}
/>
))
));
CommandGroup.displayName = CommandPrimitive.Group.displayName
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
@ -101,11 +100,11 @@ const CommandSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
@ -119,9 +118,9 @@ const CommandItem = React.forwardRef<
)}
{...props}
/>
))
));
CommandItem.displayName = CommandPrimitive.Item.displayName
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({
className,
@ -130,14 +129,14 @@ const CommandShortcut = ({
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
);
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
@ -149,4 +148,4 @@ export {
CommandItem,
CommandShortcut,
CommandSeparator,
}
};

View File

@ -1,32 +1,32 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
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<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
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",
inset && "pl-8",
'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',
className
)}
{...props}
@ -34,8 +34,8 @@ const ContextMenuSubTrigger = React.forwardRef<
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
@ -44,13 +44,13 @@ const ContextMenuSubContent = React.forwardRef<
<ContextMenuPrimitive.SubContent
ref={ref}
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
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
@ -60,32 +60,32 @@ const ContextMenuContent = React.forwardRef<
<ContextMenuPrimitive.Content
ref={ref}
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
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
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",
inset && "pl-8",
'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',
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
@ -94,7 +94,7 @@ const ContextMenuCheckboxItem = React.forwardRef<
<ContextMenuPrimitive.CheckboxItem
ref={ref}
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
)}
checked={checked}
@ -107,9 +107,9 @@ const ContextMenuCheckboxItem = React.forwardRef<
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
@ -118,7 +118,7 @@ const ContextMenuRadioItem = React.forwardRef<
<ContextMenuPrimitive.RadioItem
ref={ref}
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
)}
{...props}
@ -130,26 +130,26 @@ const ContextMenuRadioItem = React.forwardRef<
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
'px-2 py-1.5 text-sm font-semibold text-foreground',
inset && 'pl-8',
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
@ -157,11 +157,11 @@ const ContextMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
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}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
@ -170,14 +170,14 @@ const ContextMenuShortcut = ({
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
);
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
export {
ContextMenu,
@ -195,4 +195,4 @@ export {
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
};

View File

@ -17,16 +17,17 @@ export function DelegationStep({
isLoading,
setIsLoading,
}: DelegationStepProps) {
const {
currentUser,
delegateKey,
isDelegationValid,
const {
currentUser,
delegateKey,
isDelegationValid,
delegationTimeRemaining,
isAuthenticating,
clearDelegation
clearDelegation,
} = useAuth();
const [selectedDuration, setSelectedDuration] = React.useState<DelegationDuration>('7days');
const [selectedDuration, setSelectedDuration] =
React.useState<DelegationDuration>('7days');
const [delegationResult, setDelegationResult] = React.useState<{
success: boolean;
message: string;
@ -35,33 +36,33 @@ export function DelegationStep({
const handleDelegate = async () => {
if (!currentUser) return;
setIsLoading(true);
setDelegationResult(null);
try {
const success = await delegateKey(selectedDuration);
if (success) {
const expiryDate = currentUser.delegationExpiry
const expiryDate = currentUser.delegationExpiry
? new Date(currentUser.delegationExpiry).toLocaleString()
: `${selectedDuration === '7days' ? '1 week' : '30 days'} from now`;
setDelegationResult({
success: true,
message: "Key delegation successful!",
expiry: expiryDate
message: 'Key delegation successful!',
expiry: expiryDate,
});
} else {
setDelegationResult({
success: false,
message: "Key delegation failed."
message: 'Key delegation failed.',
});
}
} catch (error) {
setDelegationResult({
success: false,
message: `Delegation failed. Please try again: ${error}`
message: `Delegation failed. Please try again: ${error}`,
});
} finally {
setIsLoading(false);
@ -77,21 +78,29 @@ export function DelegationStep({
return (
<div className="flex flex-col h-full">
<div className="flex-1 space-y-4">
<div className={`p-4 rounded-lg border ${
delegationResult.success
? 'bg-green-900/20 border-green-500/30'
: 'bg-yellow-900/20 border-yellow-500/30'
}`}>
<div
className={`p-4 rounded-lg border ${
delegationResult.success
? '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">
{delegationResult.success ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<AlertCircle className="h-5 w-5 text-yellow-500" />
)}
<span className={`font-medium ${
delegationResult.success ? 'text-green-400' : 'text-yellow-400'
}`}>
{delegationResult.success ? 'Delegation Complete' : 'Delegation Result'}
<span
className={`font-medium ${
delegationResult.success
? 'text-green-400'
: 'text-yellow-400'
}`}
>
{delegationResult.success
? 'Delegation Complete'
: 'Delegation Result'}
</span>
</div>
<p className="text-sm text-neutral-300 mb-2">
@ -104,7 +113,7 @@ export function DelegationStep({
)}
</div>
</div>
{/* Action Button */}
<div className="mt-auto">
<Button
@ -126,17 +135,30 @@ export function DelegationStep({
<div className="text-center space-y-2">
<div className="flex 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">
<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
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>
</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">
Delegate signing authority to your browser for convenient forum interactions
Delegate signing authority to your browser for convenient forum
interactions
</p>
</div>
<div className="space-y-3">
{/* Status */}
<div className="flex items-center gap-2">
@ -145,22 +167,30 @@ export function DelegationStep({
) : (
<AlertCircle className="h-4 w-4 text-yellow-500" />
)}
<span className={`text-sm font-medium ${
isDelegationValid() ? 'text-green-400' : 'text-yellow-400'
}`}>
<span
className={`text-sm font-medium ${
isDelegationValid() ? 'text-green-400' : 'text-yellow-400'
}`}
>
{isDelegationValid() ? 'Delegated' : 'Required'}
</span>
{isDelegationValid() && (
<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>
)}
</div>
{/* Duration Selection */}
{!isDelegationValid() && (
<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">
<label className="flex items-center space-x-2 cursor-pointer">
<input
@ -168,7 +198,9 @@ export function DelegationStep({
name="duration"
value="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"
/>
<span className="text-sm text-neutral-300">1 Week</span>
@ -179,7 +211,9 @@ export function DelegationStep({
name="duration"
value="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"
/>
<span className="text-sm text-neutral-300">30 Days</span>
@ -187,7 +221,7 @@ export function DelegationStep({
</div>
</div>
)}
{/* Delegated Browser Public Key */}
{isDelegationValid() && currentUser?.browserPubKey && (
<div className="text-xs text-neutral-400">
@ -196,16 +230,14 @@ export function DelegationStep({
</div>
</div>
)}
{/* Wallet Address */}
{currentUser && (
<div className="text-xs text-neutral-400">
<div className="font-mono break-all">
{currentUser.address}
</div>
<div className="font-mono break-all">{currentUser.address}</div>
</div>
)}
{/* Delete Button for Active Delegations */}
{isDelegationValid() && (
<div className="flex justify-end">
@ -222,7 +254,7 @@ export function DelegationStep({
)}
</div>
</div>
{/* Action Buttons */}
<div className="mt-auto space-y-2">
{isDelegationValid() ? (
@ -242,7 +274,7 @@ export function DelegationStep({
{isAuthenticating ? 'Delegating...' : 'Delegate Key'}
</Button>
)}
<Button
onClick={onBack}
variant="outline"
@ -254,4 +286,4 @@ export function DelegationStep({
</div>
</div>
);
}
}

View File

@ -1,16 +1,16 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
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<
React.ElementRef<typeof DialogPrimitive.Overlay>,
@ -19,13 +19,13 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
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
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
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
)}
{...props}
@ -48,8 +48,8 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
@ -57,13 +57,13 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({
className,
@ -71,13 +71,13 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
@ -86,13 +86,13 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
@ -100,11 +100,11 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
@ -117,4 +117,4 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
}
};

View File

@ -1,7 +1,7 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Drawer = ({
shouldScaleBackground = true,
@ -11,14 +11,14 @@ const Drawer = ({
shouldScaleBackground={shouldScaleBackground}
{...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<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
@ -26,11 +26,11 @@ const DrawerOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
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}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
@ -41,7 +41,7 @@ const DrawerContent = React.forwardRef<
<DrawerPrimitive.Content
ref={ref}
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
)}
{...props}
@ -50,30 +50,30 @@ const DrawerContent = React.forwardRef<
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
));
DrawerContent.displayName = 'DrawerContent';
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<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}
/>
)
DrawerHeader.displayName = "DrawerHeader"
);
DrawerHeader.displayName = 'DrawerHeader';
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<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}
/>
)
DrawerFooter.displayName = "DrawerFooter"
);
DrawerFooter.displayName = 'DrawerFooter';
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
@ -82,13 +82,13 @@ const DrawerTitle = React.forwardRef<
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
@ -96,11 +96,11 @@ const DrawerDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
@ -113,4 +113,4 @@ export {
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
};

View File

@ -1,32 +1,32 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
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<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
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",
inset && "pl-8",
'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',
className
)}
{...props}
@ -34,9 +34,9 @@ const DropdownMenuSubTrigger = React.forwardRef<
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -45,14 +45,14 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
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
)}
{...props}
/>
))
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -63,32 +63,32 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
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
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
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",
inset && "pl-8",
'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',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -97,7 +97,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
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
)}
checked={checked}
@ -110,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -121,7 +121,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
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
)}
{...props}
@ -133,26 +133,26 @@ const DropdownMenuRadioItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -160,11 +160,11 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
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}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
@ -172,12 +172,12 @@ const DropdownMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
@ -195,4 +195,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
};

View File

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

View File

@ -1,27 +1,27 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import * as React from 'react';
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<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
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
)}
{...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 { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { Dot } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
@ -11,36 +11,40 @@ const InputOTP = React.forwardRef<
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
'flex items-center gap-2 has-[:disabled]:opacity-50',
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
));
InputOTP.displayName = 'InputOTP';
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
<div ref={ref} className={cn('flex items-center', className)} {...props} />
));
InputOTPGroup.displayName = 'InputOTPGroup';
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] || {
char: '',
hasFakeCaret: false,
isActive: false,
};
return (
<div
ref={ref}
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",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
'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',
className
)}
{...props}
@ -52,18 +56,18 @@ const InputOTPSlot = React.forwardRef<
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
);
});
InputOTPSlot.displayName = 'InputOTPSlot';
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
React.ElementRef<'div'>,
React.ComponentPropsWithoutRef<'div'>
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</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) => {
return (
<input
type={type}
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
)}
ref={ref}
{...props}
/>
)
);
}
)
Input.displayName = "Input"
);
Input.displayName = 'Input';
export { Input }
export { Input };

View File

@ -1,12 +1,12 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
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<
React.ElementRef<typeof LabelPrimitive.Root>,
@ -18,7 +18,7 @@ const Label = React.forwardRef<
className={cn(labelVariants(), className)}
{...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 MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from 'react';
import * as MenubarPrimitive from '@radix-ui/react-menubar';
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<
React.ElementRef<typeof MenubarPrimitive.Root>,
@ -21,13 +21,13 @@ const Menubar = React.forwardRef<
<MenubarPrimitive.Root
ref={ref}
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
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
@ -36,25 +36,25 @@ const MenubarTrigger = React.forwardRef<
<MenubarPrimitive.Trigger
ref={ref}
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
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
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",
inset && "pl-8",
'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',
className
)}
{...props}
@ -62,8 +62,8 @@ const MenubarSubTrigger = React.forwardRef<
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
@ -72,20 +72,20 @@ const MenubarSubContent = React.forwardRef<
<MenubarPrimitive.SubContent
ref={ref}
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
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<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
) => (
<MenubarPrimitive.Portal>
@ -95,33 +95,33 @@ const MenubarContent = React.forwardRef<
alignOffset={alignOffset}
sideOffset={sideOffset}
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
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
);
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
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",
inset && "pl-8",
'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',
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
@ -130,7 +130,7 @@ const MenubarCheckboxItem = React.forwardRef<
<MenubarPrimitive.CheckboxItem
ref={ref}
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
)}
checked={checked}
@ -143,8 +143,8 @@ const MenubarCheckboxItem = React.forwardRef<
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
@ -153,7 +153,7 @@ const MenubarRadioItem = React.forwardRef<
<MenubarPrimitive.RadioItem
ref={ref}
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
)}
{...props}
@ -165,26 +165,26 @@ const MenubarRadioItem = React.forwardRef<
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
@ -192,11 +192,11 @@ const MenubarSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
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}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({
className,
@ -205,14 +205,14 @@ const MenubarShortcut = ({
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
);
};
MenubarShortcut.displayname = 'MenubarShortcut';
export {
Menubar,
@ -231,4 +231,4 @@ export {
MenubarGroup,
MenubarSub,
MenubarShortcut,
}
};

View File

@ -1,9 +1,9 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import * as React from 'react';
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import { cva } from 'class-variance-authority';
import { ChevronDown } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
@ -12,7 +12,7 @@ const NavigationMenu = React.forwardRef<
<NavigationMenuPrimitive.Root
ref={ref}
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
)}
{...props}
@ -20,8 +20,8 @@ const NavigationMenu = React.forwardRef<
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
));
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
@ -30,19 +30,19 @@ const NavigationMenuList = React.forwardRef<
<NavigationMenuPrimitive.List
ref={ref}
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
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
));
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item
const NavigationMenuItem = NavigationMenuPrimitive.Item;
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<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
@ -50,17 +50,17 @@ const NavigationMenuTrigger = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{" "}
{children}{' '}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
));
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
@ -69,33 +69,33 @@ const NavigationMenuContent = React.forwardRef<
<NavigationMenuPrimitive.Content
ref={ref}
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
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
));
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuLink = NavigationMenuPrimitive.Link;
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ 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
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
)}
ref={ref}
{...props}
/>
</div>
))
));
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
NavigationMenuPrimitive.Viewport.displayName;
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
@ -104,16 +104,16 @@ const NavigationMenuIndicator = React.forwardRef<
<NavigationMenuPrimitive.Indicator
ref={ref}
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
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
));
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
NavigationMenuPrimitive.Indicator.displayName;
export {
NavigationMenu,
@ -124,4 +124,4 @@ export {
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}
};

View File

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

View File

@ -1,29 +1,29 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from 'react';
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<
React.ElementRef<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.Content
ref={ref}
align={align}
sideOffset={sideOffset}
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
)}
{...props}
/>
</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 ProgressPrimitive from "@radix-ui/react-progress"
import * as React from 'react';
import * as ProgressPrimitive from '@radix-ui/react-progress';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
@ -10,7 +10,7 @@ const Progress = React.forwardRef<
<ProgressPrimitive.Root
ref={ref}
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
)}
{...props}
@ -20,7 +20,7 @@ const Progress = React.forwardRef<
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</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 RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import * as React from 'react';
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
import { Circle } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
@ -10,13 +10,13 @@ const RadioGroup = React.forwardRef<
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
className={cn('grid gap-2', className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
);
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
@ -26,7 +26,7 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
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
)}
{...props}
@ -35,8 +35,8 @@ const RadioGroupItem = React.forwardRef<
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</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 { 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 { Separator } from '@/components/ui/separator';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { TrendingUp, Clock, Shield, UserCheck, MessageSquare, ThumbsUp } from 'lucide-react';
import { RelevanceScoreDetails } from '@/lib/forum/relevance';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
TrendingUp,
Clock,
Shield,
UserCheck,
MessageSquare,
ThumbsUp,
} from 'lucide-react';
import { RelevanceScoreDetails } from '@/types/forum';
interface RelevanceIndicatorProps {
score: number;
@ -15,7 +33,13 @@ interface RelevanceIndicatorProps {
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 getScoreColor = (score: number) => {
@ -31,20 +55,28 @@ export function RelevanceIndicator({ score, details, type, className, showToolti
const createTooltipContent = () => {
if (!details) return `Relevance Score: ${formatScore(score)}`;
return (
<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>Engagement: +{formatScore(details.engagementScore)}</div>
{details.authorVerificationBonus > 0 && (
<div>Author Bonus: +{formatScore(details.authorVerificationBonus)}</div>
<div>
Author Bonus: +{formatScore(details.authorVerificationBonus)}
</div>
)}
{details.verifiedUpvoteBonus > 0 && (
<div>Verified Upvotes: +{formatScore(details.verifiedUpvoteBonus)}</div>
<div>
Verified Upvotes: +{formatScore(details.verifiedUpvoteBonus)}
</div>
)}
{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>
{details.isModerated && (
@ -55,10 +87,10 @@ export function RelevanceIndicator({ score, details, type, className, showToolti
};
const badge = (
<Badge
variant="secondary"
<Badge
variant="secondary"
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" />
{formatScore(score)}
@ -72,9 +104,7 @@ export function RelevanceIndicator({ score, details, type, className, showToolti
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
{badge}
</DialogTrigger>
<DialogTrigger asChild>{badge}</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{createTooltipContent()}
@ -82,124 +112,157 @@ export function RelevanceIndicator({ score, details, type, className, showToolti
</Tooltip>
</TooltipProvider>
) : (
<DialogTrigger asChild>
{badge}
</DialogTrigger>
<DialogTrigger asChild>{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 && (
<>
<Card>
<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>
<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>
<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>
<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 && (
<>
<Card>
<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 * as ResizablePrimitive from "react-resizable-panels"
import { GripVertical } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const ResizablePanelGroup = ({
className,
@ -9,25 +9,25 @@ const ResizablePanelGroup = ({
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
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
)}
{...props}
/>
)
);
const ResizablePanel = ResizablePrimitive.Panel
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
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
)}
{...props}
@ -38,6 +38,6 @@ const ResizableHandle = ({
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@ -1,7 +1,7 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
@ -9,7 +9,7 @@ const ScrollArea = React.forwardRef<
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
@ -18,29 +18,29 @@ const ScrollArea = React.forwardRef<
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
'flex touch-none select-none transition-colors',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</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 SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
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<
React.ElementRef<typeof SelectPrimitive.Trigger>,
@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
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
)}
{...props}
@ -27,8 +27,8 @@ const SelectTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@ -37,15 +37,15 @@ const SelectScrollUpButton = React.forwardRef<
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@ -54,28 +54,28 @@ const SelectScrollDownButton = React.forwardRef<
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
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",
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",
'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' &&
'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
)}
position={position}
@ -84,9 +84,9 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
@ -94,8 +94,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
@ -103,11 +103,11 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
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}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
@ -116,7 +116,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
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
)}
{...props}
@ -129,8 +129,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
@ -138,11 +138,11 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
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}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
@ -155,4 +155,4 @@ export {
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
};

View File

@ -1,14 +1,14 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
@ -16,14 +16,14 @@ const Separator = React.forwardRef<
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...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 { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from "react"
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-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<
React.ElementRef<typeof SheetPrimitive.Overlay>,
@ -19,42 +19,42 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
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
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
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: {
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:
"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",
'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',
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: {
side: "right",
side: 'right',
},
}
)
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> { }
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
@ -69,8 +69,8 @@ const SheetContent = React.forwardRef<
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
@ -78,13 +78,13 @@ const SheetHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({
className,
@ -92,13 +92,13 @@ const SheetFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
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
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
);
SheetFooter.displayName = 'SheetFooter';
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
@ -106,11 +106,11 @@ const SheetTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
@ -118,14 +118,21 @@ const SheetDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet, SheetClose,
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger
}
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};

View File

@ -1,56 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { VariantProps, cva } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { VariantProps, cva } from 'class-variance-authority';
import { PanelLeft } from 'lucide-react';
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { useIsMobile } from '@/hooks/use-mobile';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { Skeleton } from '@/components/ui/skeleton';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
} from '@/components/ui/tooltip';
const SIDEBAR_COOKIE_NAME = "sidebar:state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SIDEBAR_COOKIE_NAME = 'sidebar:state';
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = '16rem';
const SIDEBAR_WIDTH_MOBILE = '18rem';
const SIDEBAR_WIDTH_ICON = '3rem';
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
type SidebarContext = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
state: 'expanded' | 'collapsed';
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null)
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext)
const context = React.useContext(SidebarContext);
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<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
React.ComponentProps<'div'> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
@ -65,34 +65,32 @@ const SidebarProvider = React.forwardRef<
},
ref
) => {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
const openState = typeof value === 'function' ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState)
setOpenProp(openState);
} else {
_setOpen(openState)
_setOpen(openState);
}
// 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]
)
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile
? setOpenMobile((open) => !open)
: setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@ -101,18 +99,18 @@ const SidebarProvider = React.forwardRef<
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
event.preventDefault();
toggleSidebar();
}
}
};
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [toggleSidebar]);
// 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.
const state = open ? "expanded" : "collapsed"
const state = open ? 'expanded' : 'collapsed';
const contextValue = React.useMemo<SidebarContext>(
() => ({
@ -125,7 +123,7 @@ const SidebarProvider = React.forwardRef<
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
);
return (
<SidebarContext.Provider value={contextValue}>
@ -133,13 +131,13 @@ const SidebarProvider = React.forwardRef<
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
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
)}
ref={ref}
@ -149,37 +147,37 @@ const SidebarProvider = React.forwardRef<
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
);
}
)
SidebarProvider.displayName = "SidebarProvider"
);
SidebarProvider.displayName = 'SidebarProvider';
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
React.ComponentProps<'div'> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
},
ref
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
if (collapsible === 'none') {
return (
<div
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
)}
ref={ref}
@ -187,7 +185,7 @@ const Sidebar = React.forwardRef<
>
{children}
</div>
)
);
}
if (isMobile) {
@ -199,7 +197,7 @@ const Sidebar = React.forwardRef<
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
@ -207,7 +205,7 @@ const Sidebar = React.forwardRef<
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
);
}
return (
@ -215,31 +213,31 @@ const Sidebar = React.forwardRef<
ref={ref}
className="group peer hidden md:block text-sidebar-foreground"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
'duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]'
: 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]'
)}
/>
<div
className={cn(
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[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.
variant === "floating" || variant === "inset"
? "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",
variant === 'floating' || variant === 'inset'
? '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',
className
)}
{...props}
@ -252,16 +250,16 @@ const Sidebar = React.forwardRef<
</div>
</div>
</div>
)
);
}
)
Sidebar.displayName = "Sidebar"
);
Sidebar.displayName = 'Sidebar';
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
const { toggleSidebar } = useSidebar();
return (
<Button
@ -269,25 +267,25 @@ const SidebarTrigger = React.forwardRef<
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
className={cn('h-7 w-7', className)}
onClick={event => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
})
SidebarTrigger.displayName = "SidebarTrigger"
);
});
SidebarTrigger.displayName = 'SidebarTrigger';
const SidebarRail = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button">
React.ComponentProps<'button'>
>(({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar()
const { toggleSidebar } = useSidebar();
return (
<button
@ -298,37 +296,37 @@ const SidebarRail = React.forwardRef<
onClick={toggleSidebar}
title="Toggle Sidebar"
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",
"[[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",
"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=right][data-collapsible=offcanvas]_&]:-left-2",
'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][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',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
{...props}
/>
)
})
SidebarRail.displayName = "SidebarRail"
);
});
SidebarRail.displayName = 'SidebarRail';
const SidebarInset = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"main">
React.ComponentProps<'main'>
>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"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",
'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',
className
)}
{...props}
/>
)
})
SidebarInset.displayName = "SidebarInset"
);
});
SidebarInset.displayName = 'SidebarInset';
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
@ -339,44 +337,44 @@ const SidebarInput = React.forwardRef<
ref={ref}
data-sidebar="input"
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
)}
{...props}
/>
)
})
SidebarInput.displayName = "SidebarInput"
);
});
SidebarInput.displayName = 'SidebarInput';
const SidebarHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
})
SidebarHeader.displayName = "SidebarHeader"
);
});
SidebarHeader.displayName = 'SidebarHeader';
const SidebarFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
})
SidebarFooter.displayName = "SidebarFooter"
);
});
SidebarFooter.displayName = 'SidebarFooter';
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
@ -386,173 +384,173 @@ const SidebarSeparator = React.forwardRef<
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
className={cn('mx-2 w-auto bg-sidebar-border', className)}
{...props}
/>
)
})
SidebarSeparator.displayName = "SidebarSeparator"
);
});
SidebarSeparator.displayName = 'SidebarSeparator';
const SidebarContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
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
)}
{...props}
/>
)
})
SidebarContent.displayName = "SidebarContent"
);
});
SidebarContent.displayName = 'SidebarContent';
const SidebarGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
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}
/>
)
})
SidebarGroup.displayName = "SidebarGroup"
);
});
SidebarGroup.displayName = 'SidebarGroup';
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
React.ComponentProps<'div'> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div"
const Comp = asChild ? Slot : 'div';
return (
<Comp
ref={ref}
data-sidebar="group-label"
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",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-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',
className
)}
{...props}
/>
)
})
SidebarGroupLabel.displayName = "SidebarGroupLabel"
);
});
SidebarGroupLabel.displayName = 'SidebarGroupLabel';
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
React.ComponentProps<'button'> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
data-sidebar="group-action"
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.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
'after:absolute after:-inset-2 after:md:hidden',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
)
})
SidebarGroupAction.displayName = "SidebarGroupAction"
);
});
SidebarGroupAction.displayName = 'SidebarGroupAction';
const SidebarGroupContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
className={cn('w-full text-sm', className)}
{...props}
/>
))
SidebarGroupContent.displayName = "SidebarGroupContent"
));
SidebarGroupContent.displayName = 'SidebarGroupContent';
const SidebarMenu = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
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}
/>
))
SidebarMenu.displayName = "SidebarMenu"
));
SidebarMenu.displayName = 'SidebarMenu';
const SidebarMenuItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
className={cn('group/menu-item relative', className)}
{...props}
/>
))
SidebarMenuItem.displayName = "SidebarMenuItem"
));
SidebarMenuItem.displayName = 'SidebarMenuItem';
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: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
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: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
)
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
React.ComponentProps<'button'> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
variant = 'default',
size = 'default',
tooltip,
className,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const Comp = asChild ? Slot : 'button';
const { isMobile, state } = useSidebar();
const button = (
<Comp
@ -563,16 +561,16 @@ const SidebarMenuButton = React.forwardRef<
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
);
if (!tooltip) {
return button
return button;
}
if (typeof tooltip === "string") {
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
}
};
}
return (
@ -581,83 +579,83 @@ const SidebarMenuButton = React.forwardRef<
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
)
);
}
)
SidebarMenuButton.displayName = "SidebarMenuButton"
);
SidebarMenuButton.displayName = 'SidebarMenuButton';
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
React.ComponentProps<'button'> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
data-sidebar="menu-action"
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.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
'after:absolute after:-inset-2 after:md:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
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
)}
{...props}
/>
)
})
SidebarMenuAction.displayName = "SidebarMenuAction"
);
});
SidebarMenuAction.displayName = 'SidebarMenuAction';
const SidebarMenuBadge = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
React.ComponentProps<'div'>
>(({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
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",
"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=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
'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-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
))
SidebarMenuBadge.displayName = "SidebarMenuBadge"
));
SidebarMenuBadge.displayName = 'SidebarMenuBadge';
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean
React.ComponentProps<'div'> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
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}
>
{showIcon && (
@ -671,47 +669,47 @@ const SidebarMenuSkeleton = React.forwardRef<
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
)
})
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
);
});
SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton';
const SidebarMenuSub = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
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",
"group-data-[collapsible=icon]:hidden",
'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',
className
)}
{...props}
/>
))
SidebarMenuSub.displayName = "SidebarMenuSub"
));
SidebarMenuSub.displayName = 'SidebarMenuSub';
const SidebarMenuSubItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ ...props }, ref) => <li ref={ref} {...props} />)
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
React.ComponentProps<'li'>
>(({ ...props }, ref) => <li ref={ref} {...props} />);
SidebarMenuSubItem.displayName = 'SidebarMenuSubItem';
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
React.ComponentProps<'a'> & {
asChild?: boolean;
size?: 'sm' | 'md';
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : 'a';
return (
<Comp
@ -720,18 +718,18 @@ const SidebarMenuSubButton = React.forwardRef<
data-size={size}
data-active={isActive}
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",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
'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',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className
)}
{...props}
/>
)
})
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
);
});
SidebarMenuSubButton.displayName = 'SidebarMenuSubButton';
export {
Sidebar,
@ -757,4 +755,4 @@ export {
SidebarRail,
SidebarSeparator,
SidebarTrigger,
}
};

View File

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

View File

@ -1,7 +1,7 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import * as React from 'react';
import * as SliderPrimitive from '@radix-ui/react-slider';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
@ -10,7 +10,7 @@ const Slider = React.forwardRef<
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
'relative flex w-full touch-none select-none items-center',
className
)}
{...props}
@ -20,7 +20,7 @@ const Slider = React.forwardRef<
</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.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 { Toaster as Sonner } from "sonner"
import { useTheme } from 'next-themes';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
)
}
);
};
export { Toaster }
export { Toaster };

View File

@ -1,7 +1,7 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
@ -9,7 +9,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
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
)}
{...props}
@ -17,11 +17,11 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
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>
))
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<
HTMLTableElement,
@ -9,20 +9,20 @@ const Table = React.forwardRef<
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
));
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
@ -30,11 +30,11 @@ const TableBody = React.forwardRef<
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
@ -43,13 +43,13 @@ const TableFooter = React.forwardRef<
<tfoot
ref={ref}
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
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<
HTMLTableRowElement,
@ -58,13 +58,13 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
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
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
));
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
@ -73,13 +73,13 @@ const TableHead = React.forwardRef<
<th
ref={ref}
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
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
@ -87,11 +87,11 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
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}
/>
))
TableCell.displayName = "TableCell"
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
@ -99,11 +99,11 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
));
TableCaption.displayName = 'TableCaption';
export {
Table,
@ -114,4 +114,4 @@ export {
TableRow,
TableCell,
TableCaption,
}
};

View File

@ -1,9 +1,9 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from 'react';
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<
React.ElementRef<typeof TabsPrimitive.List>,
@ -12,13 +12,13 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
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
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
@ -27,13 +27,13 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
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
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
@ -42,12 +42,12 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
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
)}
{...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>>(
({ className, ...props }, ref) => {
return (
<textarea
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",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => {
return (
<textarea
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',
className
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = 'Textarea';
export { Textarea }
export { Textarea };

View File

@ -1,11 +1,11 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
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<
React.ElementRef<typeof ToastPrimitives.Viewport>,
@ -14,29 +14,29 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport
ref={ref}
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
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
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: {
variant: {
default: "border bg-background text-foreground",
default: 'border bg-background text-foreground',
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
}
)
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
@ -49,9 +49,9 @@ const Toast = React.forwardRef<
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
@ -60,13 +60,13 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action
ref={ref}
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
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
@ -75,7 +75,7 @@ const ToastClose = React.forwardRef<
<ToastPrimitives.Close
ref={ref}
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
)}
toast-close=""
@ -83,8 +83,8 @@ const ToastClose = React.forwardRef<
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
@ -92,11 +92,11 @@ const ToastTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
className={cn('text-sm font-semibold', className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
@ -104,15 +104,15 @@ const ToastDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
className={cn('text-sm opacity-90', className)}
{...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 {
type ToastProps,
@ -124,4 +124,4 @@ export {
ToastDescription,
ToastClose,
ToastAction,
}
};

View File

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

View File

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

View File

@ -1,9 +1,9 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { type VariantProps } from "class-variance-authority"
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { toggleVariants } from "./toggle-variants"
import { cn } from '@/lib/utils';
import { toggleVariants } from './toggle-variants';
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
@ -15,8 +15,8 @@ const Toggle = React.forwardRef<
className={cn(toggleVariants({ variant, size, className }))}
{...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 TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from 'react';
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<
React.ElementRef<typeof TooltipPrimitive.Content>,
@ -17,12 +17,12 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
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
)}
{...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 };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { AuthContext } from './AuthContext';
export const useAuth = () => {
const context = useContext(AuthContext);
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;
};
};

View File

@ -7,4 +7,4 @@ export const useForum = () => {
throw new Error('useForum must be used within a ForumProvider');
}
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() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener('change', onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
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 {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
enum ActionTypes {
ADD_TOAST = 'ADD_TOAST',
UPDATE_TOAST = 'UPDATE_TOAST',
DISMISS_TOAST = 'DISMISS_TOAST',
REMOVE_TOAST = 'REMOVE_TOAST',
}
enum ActionTypes {
ADD_TOAST = "ADD_TOAST",
UPDATE_TOAST = "UPDATE_TOAST",
DISMISS_TOAST = "DISMISS_TOAST",
REMOVE_TOAST = "REMOVE_TOAST",
}
let count = 0
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type Action =
| {
type: ActionTypes.ADD_TOAST
toast: ToasterToast
type: ActionTypes.ADD_TOAST;
toast: ToasterToast;
}
| {
type: ActionTypes.UPDATE_TOAST
toast: Partial<ToasterToast>
type: ActionTypes.UPDATE_TOAST;
toast: Partial<ToasterToast>;
}
| {
type: ActionTypes.DISMISS_TOAST
toastId?: ToasterToast["id"]
type: ActionTypes.DISMISS_TOAST;
toastId?: ToasterToast['id'];
}
| {
type: ActionTypes.REMOVE_TOAST
toastId?: ToasterToast["id"]
}
type: ActionTypes.REMOVE_TOAST;
toastId?: ToasterToast['id'];
};
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) => {
if (toastTimeouts.has(toastId)) {
return
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
toastTimeouts.delete(toastId);
dispatch({
type: ActionTypes.REMOVE_TOAST,
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout)
}
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
@ -76,32 +72,32 @@ export const reducer = (state: State, action: Action): State => {
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
};
case ActionTypes.UPDATE_TOAST:
return {
...state,
toasts: state.toasts.map((t) =>
toasts: state.toasts.map(t =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
};
case ActionTypes.DISMISS_TOAST: {
const { toastId } = action
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
state.toasts.forEach(toast => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
toasts: state.toasts.map(t =>
t.id === toastId || toastId === undefined
? {
...t,
@ -109,44 +105,45 @@ export const reducer = (state: State, action: Action): State => {
}
: t
),
}
};
}
case ActionTypes.REMOVE_TOAST:
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
};
}
return {
...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) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
memoryState = reducer(memoryState, action);
listeners.forEach(listener => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId()
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: ActionTypes.UPDATE_TOAST,
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: ActionTypes.DISMISS_TOAST, toastId: id })
});
const dismiss = () =>
dispatch({ type: ActionTypes.DISMISS_TOAST, toastId: id });
dispatch({
type: ActionTypes.ADD_TOAST,
@ -154,37 +151,38 @@ function toast({ ...props }: Toast) {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
onOpenChange: open => {
if (!open) dismiss();
},
},
})
});
return {
id: id,
dismiss,
update,
}
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState)
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState)
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1)
listeners.splice(index, 1);
}
}
}, [state])
};
}, [state]);
return {
...state,
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 {
:root {
--background: 226 20% 12%;
--foreground: 0 0% 95%;
--background: 226 20% 12%;
--foreground: 0 0% 95%;
--card: 226 20% 12%;
--card: 226 20% 12%;
--card-foreground: 0 0% 94%;
--popover: 226 20% 18%;
--popover-foreground: 0 0% 94%;
--primary: 195 82% 42%;
--primary: 195 82% 42%;
--primary-foreground: 0 0% 98%;
--secondary: 226 20% 18%;
--secondary: 226 20% 18%;
--secondary-foreground: 0 0% 94%;
--muted: 226 13% 27%;
--muted-foreground: 225 6% 57%;
--muted: 226 13% 27%;
--muted-foreground: 225 6% 57%;
--accent: 195 82% 42%;
--accent: 195 82% 42%;
--accent-foreground: 0 0% 98%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 98%;
--border: 226 13% 27%;
--border: 226 13% 27%;
--input: 226 13% 27%;
--ring: 195 82% 42%;
--radius: 0.25rem;
--radius: 0.25rem;
--sidebar-background: 226 20% 14%;
--sidebar-foreground: 0 0% 94%;
@ -54,7 +54,20 @@
body {
@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 {
@ -81,11 +94,19 @@
*:focus-visible {
@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;
}
.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;
}
@ -95,26 +116,29 @@
.thread-card {
@apply border border-cyber-muted bg-cyber-dark hover:bg-cyber-muted/20 transition-colors rounded-sm p-4 mb-4;
}
.board-card {
@apply border border-cyber-muted bg-cyber-dark hover:bg-cyber-muted/20 transition-colors rounded-sm p-3 mb-3;
}
.comment-card {
@apply border-l-2 border-muted pl-3 py-2 my-2 hover:border-primary transition-colors;
}
}
@keyframes cyber-flicker {
0%, 100% {
0%,
100% {
opacity: 1;
filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.8));
}
8%, 10% {
8%,
10% {
opacity: 0.8;
filter: drop-shadow(0 0 5px rgba(0, 255, 255, 0.8));
}
20%, 25% {
20%,
25% {
opacity: 1;
filter: drop-shadow(0 0 1px rgba(0, 255, 255, 0.5));
}
@ -122,15 +146,18 @@
opacity: 0.6;
filter: drop-shadow(0 0 8px rgba(0, 255, 255, 1));
}
40%, 45% {
40%,
45% {
opacity: 1;
filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.6));
}
50%, 55% {
50%,
55% {
opacity: 0.9;
filter: drop-shadow(0 0 3px rgba(0, 255, 255, 0.8));
}
60%, 100% {
60%,
100% {
opacity: 1;
filter: drop-shadow(0 0 2px rgba(0, 255, 255, 0.6));
}

View File

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

View File

@ -1,15 +1,19 @@
import { v4 as uuidv4 } from 'uuid';
import {
MessageType,
UnsignedCellMessage,
UnsignedCommentMessage,
UnsignedPostMessage,
UnsignedVoteMessage,
UnsignedModerateMessage,
CellMessage,
CommentMessage,
MessageType,
PostMessage,
VoteMessage,
ModerateMessage,
} 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 { MessageService, AuthService, CryptoService } from '@/lib/services';
import { MessageService, CryptoService } from '@/lib/services';
type ToastFunction = (props: {
title: string;
@ -28,8 +32,7 @@ export const createPost = async (
currentUser: User | null,
isAuthenticated: boolean,
toast: ToastFunction,
updateStateFromCache: () => void,
authService?: AuthService,
updateStateFromCache: () => void
): Promise<Post | null> => {
if (!isAuthenticated || !currentUser) {
toast({
@ -41,12 +44,19 @@ export const createPost = async (
}
// Check if user has basic verification or better, or owns ENS/Ordinal
const hasENSOrOrdinal = !!(currentUser.ensOwnership || currentUser.ordinalOwnership);
const isVerified = currentUser.verificationStatus === 'verified-owner' ||
currentUser.verificationStatus === 'verified-basic' ||
hasENSOrOrdinal;
if (!isVerified && (currentUser.verificationStatus === 'unverified' || currentUser.verificationStatus === 'verifying')) {
const hasENSOrOrdinal = !!(
currentUser.ensDetails || currentUser.ordinalDetails
);
const isVerified =
currentUser.verificationStatus === 'verified-owner' ||
currentUser.verificationStatus === 'verified-basic' ||
hasENSOrOrdinal;
if (
!isVerified &&
(currentUser.verificationStatus === 'unverified' ||
currentUser.verificationStatus === 'verifying')
) {
toast({
title: 'Verification Required',
description: 'Please complete wallet verification to post.',
@ -56,10 +66,13 @@ export const createPost = async (
}
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 postMessage: PostMessage = {
const postMessage: UnsignedPostMessage = {
type: MessageType.POST,
id: postId,
cellId,
@ -70,7 +83,7 @@ export const createPost = async (
};
const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService);
const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(postMessage);
if (!result.success) {
toast({
@ -82,7 +95,10 @@ export const createPost = async (
}
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);
} catch (error) {
console.error('Error creating post:', error);
@ -101,8 +117,7 @@ export const createComment = async (
currentUser: User | null,
isAuthenticated: boolean,
toast: ToastFunction,
updateStateFromCache: () => void,
authService?: AuthService,
updateStateFromCache: () => void
): Promise<Comment | null> => {
if (!isAuthenticated || !currentUser) {
toast({
@ -114,12 +129,19 @@ export const createComment = async (
}
// Check if user has basic verification or better, or owns ENS/Ordinal
const hasENSOrOrdinal = !!(currentUser.ensOwnership || currentUser.ordinalOwnership);
const isVerified = currentUser.verificationStatus === 'verified-owner' ||
currentUser.verificationStatus === 'verified-basic' ||
hasENSOrOrdinal;
if (!isVerified && (currentUser.verificationStatus === 'unverified' || currentUser.verificationStatus === 'verifying')) {
const hasENSOrOrdinal = !!(
currentUser.ensDetails || currentUser.ordinalDetails
);
const isVerified =
currentUser.verificationStatus === 'verified-owner' ||
currentUser.verificationStatus === 'verified-basic' ||
hasENSOrOrdinal;
if (
!isVerified &&
(currentUser.verificationStatus === 'unverified' ||
currentUser.verificationStatus === 'verifying')
) {
toast({
title: 'Verification Required',
description: 'Please complete wallet verification to comment.',
@ -129,10 +151,13 @@ export const createComment = async (
}
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 commentMessage: CommentMessage = {
const commentMessage: UnsignedCommentMessage = {
type: MessageType.COMMENT,
id: commentId,
postId,
@ -142,7 +167,7 @@ export const createComment = async (
};
const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService);
const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(commentMessage);
if (!result.success) {
toast({
@ -154,7 +179,10 @@ export const createComment = async (
}
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);
} catch (error) {
console.error('Error creating comment:', error);
@ -174,8 +202,7 @@ export const createCell = async (
currentUser: User | null,
isAuthenticated: boolean,
toast: ToastFunction,
updateStateFromCache: () => void,
authService?: AuthService,
updateStateFromCache: () => void
): Promise<Cell | null> => {
if (!isAuthenticated || !currentUser) {
toast({
@ -187,10 +214,13 @@ export const createCell = async (
}
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 cellMessage: CellMessage = {
const cellMessage: UnsignedCellMessage = {
type: MessageType.CELL,
id: cellId,
name,
@ -201,7 +231,7 @@ export const createCell = async (
};
const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService);
const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(cellMessage);
if (!result.success) {
toast({
@ -213,7 +243,10 @@ export const createCell = async (
}
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);
} catch (error) {
console.error('Error creating cell:', error);
@ -236,8 +269,7 @@ export const vote = async (
currentUser: User | null,
isAuthenticated: boolean,
toast: ToastFunction,
updateStateFromCache: () => void,
authService?: AuthService,
updateStateFromCache: () => void
): Promise<boolean> => {
if (!isAuthenticated || !currentUser) {
toast({
@ -249,12 +281,19 @@ export const vote = async (
}
// Check if user has basic verification or better, or owns ENS/Ordinal
const hasENSOrOrdinal = !!(currentUser.ensOwnership || currentUser.ordinalOwnership);
const isVerified = currentUser.verificationStatus === 'verified-owner' ||
currentUser.verificationStatus === 'verified-basic' ||
hasENSOrOrdinal;
if (!isVerified && (currentUser.verificationStatus === 'unverified' || currentUser.verificationStatus === 'verifying')) {
const hasENSOrOrdinal = !!(
currentUser.ensDetails || currentUser.ordinalDetails
);
const isVerified =
currentUser.verificationStatus === 'verified-owner' ||
currentUser.verificationStatus === 'verified-basic' ||
hasENSOrOrdinal;
if (
!isVerified &&
(currentUser.verificationStatus === 'unverified' ||
currentUser.verificationStatus === 'verifying')
) {
toast({
title: 'Verification Required',
description: 'Please complete wallet verification to vote.',
@ -265,10 +304,13 @@ export const vote = async (
try {
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 voteMessage: VoteMessage = {
const voteMessage: UnsignedVoteMessage = {
type: MessageType.VOTE,
id: voteId,
targetId,
@ -278,19 +320,23 @@ export const vote = async (
};
const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService);
const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(voteMessage);
if (!result.success) {
toast({
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',
});
return false;
}
updateStateFromCache();
toast({ title: 'Vote Recorded', description: `Your ${voteType} has been registered.` });
toast({
title: 'Vote Recorded',
description: `Your ${voteType} has been registered.`,
});
return true;
} catch (error) {
console.error('Error voting:', error);
@ -315,8 +361,7 @@ export const moderatePost = async (
isAuthenticated: boolean,
cellOwner: string,
toast: ToastFunction,
updateStateFromCache: () => void,
authService?: AuthService,
updateStateFromCache: () => void
): Promise<boolean> => {
if (!isAuthenticated || !currentUser) {
toast({
@ -327,14 +372,21 @@ export const moderatePost = async (
return false;
}
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;
}
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,
id: uuidv4(),
cellId,
@ -345,19 +397,31 @@ export const moderatePost = async (
author: currentUser.address,
};
const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService);
const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(modMsg);
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;
}
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;
} catch (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;
}
};
@ -370,22 +434,32 @@ export const moderateComment = async (
isAuthenticated: boolean,
cellOwner: string,
toast: ToastFunction,
updateStateFromCache: () => void,
authService?: AuthService,
updateStateFromCache: () => void
): Promise<boolean> => {
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;
}
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;
}
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,
id: uuidv4(),
cellId,
@ -396,19 +470,31 @@ export const moderateComment = async (
author: currentUser.address,
};
const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService);
const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(modMsg);
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;
}
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;
} catch (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;
}
};
@ -421,19 +507,26 @@ export const moderateUser = async (
isAuthenticated: boolean,
cellOwner: string,
toast: ToastFunction,
updateStateFromCache: () => void,
authService?: AuthService,
updateStateFromCache: () => void
): Promise<boolean> => {
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;
}
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;
}
const modMsg: ModerateMessage = {
const modMsg: UnsignedModerateMessage = {
type: MessageType.MODERATE,
id: uuidv4(),
cellId,
@ -442,18 +535,23 @@ export const moderateUser = async (
reason,
author: currentUser.address,
timestamp: Date.now(),
signature: '',
browserPubKey: currentUser.browserPubKey,
};
const cryptoService = new CryptoService();
const messageService = new MessageService(authService!, cryptoService);
const messageService = new MessageService(cryptoService);
const result = await messageService.sendMessage(modMsg);
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;
}
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;
};

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

View File

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

View File

@ -9,38 +9,46 @@ export class OrdinalAPI {
* @returns A promise that resolves with the API response.
*/
async getOperatorDetails(address: string): Promise<OrdinalApiResponse> {
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 {
has_operators: true,
error_message: '',
data: []
data: [],
};
}
const url = `${BASE_URL}/${address}/detail/`;
try {
const response = await fetch(url, {
method: 'GET',
headers: { 'Accept': 'application/json' },
headers: { Accept: 'application/json' },
});
if (!response.ok) {
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();
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;
} 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;
}
}
}
}

View File

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

View File

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

View File

@ -1,38 +1,43 @@
import { AppKitOptions } from '@reown/appkit'
import { BitcoinAdapter } from '@reown/appkit-adapter-bitcoin'
import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'
import { createStorage } from 'wagmi'
import { mainnet, bitcoin, AppKitNetwork } from '@reown/appkit/networks'
import { AppKitOptions } from '@reown/appkit';
import { BitcoinAdapter } from '@reown/appkit-adapter-bitcoin';
import { WagmiAdapter } from '@reown/appkit-adapter-wagmi';
import { createStorage } from 'wagmi';
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) {
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({
storage: createStorage({ storage: localStorage }),
ssr: false, // Set to false for Vite/React apps
projectId,
networks
})
networks,
});
// Export the Wagmi config for the provider
export const config = wagmiAdapter.wagmiConfig
export const config = wagmiAdapter.wagmiConfig;
const bitcoinAdapter = new BitcoinAdapter({
projectId
})
projectId,
});
const metadata = {
name: 'OpChan',
description: 'Decentralized forum powered by Bitcoin Ordinals',
url: process.env.NODE_ENV === 'production' ? 'https://opchan.app' : 'http://localhost:8080',
icons: ['https://opchan.com/logo.png']
}
url:
process.env.NODE_ENV === 'production'
? 'https://opchan.app'
: 'http://localhost:8080',
icons: ['https://opchan.com/logo.png'],
};
export const appkitConfig: AppKitOptions = {
adapters: [wagmiAdapter, bitcoinAdapter],
@ -42,8 +47,7 @@ export const appkitConfig: AppKitOptions = {
features: {
analytics: 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 { UseAppKitAccountReturn } from '@reown/appkit/react';
import { AppKit } from '@reown/appkit';
import { OrdinalAPI } from '../identity/ordinal';
import { CryptoService, DelegationDuration } from './CryptoService';
import { EVerificationStatus, User } from '@/types/forum';
import { EVerificationStatus, User, DisplayPreference } from '@/types/identity';
import { WalletInfo } from '../identity/wallets/ReOwnWalletService';
export interface AuthResult {
@ -14,41 +13,45 @@ export interface AuthResult {
export interface AuthServiceInterface {
// Wallet operations
setAccounts(bitcoinAccount: UseAppKitAccountReturn, ethereumAccount: UseAppKitAccountReturn): void;
setAccounts(
bitcoinAccount: UseAppKitAccountReturn,
ethereumAccount: UseAppKitAccountReturn
): void;
setAppKit(appKit: AppKit): void;
connectWallet(): Promise<AuthResult>;
disconnectWallet(): Promise<void>;
// Verification
verifyOwnership(user: User): Promise<AuthResult>;
// Delegation setup
delegateKey(user: User, duration?: DelegationDuration): Promise<AuthResult>;
// User persistence
loadStoredUser(): User | null;
saveUser(user: User): void;
clearStoredUser(): void;
// Wallet info
getWalletInfo(): Promise<WalletInfo | null>;
}
export class AuthService implements AuthServiceInterface {
private walletService: WalletService;
private ordinalApi: OrdinalAPI;
private cryptoService: CryptoService;
constructor(cryptoService: CryptoService) {
this.walletService = new WalletService();
this.ordinalApi = new OrdinalAPI();
this.cryptoService = cryptoService;
}
/**
* Set AppKit accounts for wallet service
*/
setAccounts(bitcoinAccount: UseAppKitAccountReturn, ethereumAccount: UseAppKitAccountReturn) {
setAccounts(
bitcoinAccount: UseAppKitAccountReturn,
ethereumAccount: UseAppKitAccountReturn
) {
this.walletService.setAccounts(bitcoinAccount, ethereumAccount);
}
@ -64,26 +67,15 @@ export class AuthService implements AuthServiceInterface {
*/
private getActiveAddress(): string | null {
const isBitcoinConnected = this.walletService.isWalletAvailable('bitcoin');
const isEthereumConnected = this.walletService.isWalletAvailable('ethereum');
const isEthereumConnected =
this.walletService.isWalletAvailable('ethereum');
if (isBitcoinConnected) {
return this.walletService.getActiveAddress('bitcoin') || null;
} else if (isEthereumConnected) {
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;
}
@ -93,13 +85,15 @@ export class AuthService implements AuthServiceInterface {
async connectWallet(): Promise<AuthResult> {
try {
// Check which wallet is connected
const isBitcoinConnected = this.walletService.isWalletAvailable('bitcoin');
const isEthereumConnected = this.walletService.isWalletAvailable('ethereum');
const isBitcoinConnected =
this.walletService.isWalletAvailable('bitcoin');
const isEthereumConnected =
this.walletService.isWalletAvailable('ethereum');
if (!isBitcoinConnected && !isEthereumConnected) {
return {
success: false,
error: 'No wallet connected'
error: 'No wallet connected',
};
}
@ -110,7 +104,7 @@ export class AuthService implements AuthServiceInterface {
if (!address) {
return {
success: false,
error: 'No wallet address available'
error: 'No wallet address available',
};
}
@ -118,6 +112,7 @@ export class AuthService implements AuthServiceInterface {
address: address,
walletType: walletType,
verificationStatus: EVerificationStatus.UNVERIFIED,
displayPreference: DisplayPreference.WALLET_ADDRESS,
lastChecked: Date.now(),
};
@ -125,23 +120,29 @@ export class AuthService implements AuthServiceInterface {
if (walletType === 'ethereum') {
try {
const walletInfo = await this.walletService.getWalletInfo();
user.ensName = walletInfo?.ensName;
user.ensOwnership = !!(walletInfo?.ensName);
if (walletInfo?.ensName) {
user.ensDetails = {
ensName: walletInfo.ensName,
};
}
} catch (error) {
console.warn('Failed to resolve ENS during wallet connection:', error);
user.ensName = undefined;
user.ensOwnership = false;
console.warn(
'Failed to resolve ENS during wallet connection:',
error
);
user.ensDetails = undefined;
}
}
return {
success: true,
user
user,
};
} catch (error) {
return {
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> {
// Clear any existing delegations when disconnecting
this.cryptoService.clearDelegation();
// Clear stored user data
this.clearStoredUser();
}
@ -169,13 +170,14 @@ export class AuthService implements AuthServiceInterface {
} else {
return {
success: false,
error: 'Unknown wallet type'
error: 'Unknown wallet type',
};
}
} catch (error) {
return {
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 = {
...user,
ordinalOwnership: hasOperators,
verificationStatus: hasOperators ? EVerificationStatus.VERIFIED_OWNER : EVerificationStatus.VERIFIED_BASIC,
verificationStatus: hasOperators
? EVerificationStatus.VERIFIED_OWNER
: EVerificationStatus.VERIFIED_BASIC,
lastChecked: Date.now(),
};
return {
success: true,
user: updatedUser
user: updatedUser,
};
}
@ -209,25 +213,27 @@ export class AuthService implements AuthServiceInterface {
try {
// Get wallet info with ENS resolution
const walletInfo = await this.walletService.getWalletInfo();
const hasENS = !!(walletInfo?.ensName);
const hasENS = !!walletInfo?.ensName;
const ensName = walletInfo?.ensName;
const updatedUser = {
...user,
ensOwnership: hasENS,
ensName: ensName,
verificationStatus: hasENS ? EVerificationStatus.VERIFIED_OWNER : EVerificationStatus.VERIFIED_BASIC,
verificationStatus: hasENS
? EVerificationStatus.VERIFIED_OWNER
: EVerificationStatus.VERIFIED_BASIC,
lastChecked: Date.now(),
};
return {
success: true,
user: updatedUser
user: updatedUser,
};
} catch (error) {
console.error('Error verifying ENS ownership:', error);
// Fall back to basic verification on error
const updatedUser = {
...user,
@ -239,7 +245,7 @@ export class AuthService implements AuthServiceInterface {
return {
success: true,
user: updatedUser
user: updatedUser,
};
}
}
@ -247,48 +253,58 @@ export class AuthService implements AuthServiceInterface {
/**
* 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 {
const walletType = user.walletType;
const isAvailable = this.walletService.isWalletAvailable(walletType);
if (!isAvailable) {
return {
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) {
return {
success: false,
error: 'Failed to create key delegation'
error: 'Failed to create key delegation',
};
}
// 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
const browserPublicKey = this.cryptoService.getBrowserPublicKey();
const updatedUser = {
...user,
browserPubKey: browserPublicKey || undefined,
delegationSignature: delegationStatus.isValid ? 'valid' : undefined,
delegationExpiry: delegationStatus.timeRemaining ? Date.now() + delegationStatus.timeRemaining : undefined,
delegationExpiry: delegationStatus.timeRemaining
? Date.now() + delegationStatus.timeRemaining
: undefined,
};
return {
success: true,
user: updatedUser
user: updatedUser,
};
} catch (error) {
return {
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 lastChecked = user.lastChecked || 0;
const expiryTime = 24 * 60 * 60 * 1000;
if (Date.now() - lastChecked < expiryTime) {
return user;
} else {
@ -320,7 +336,7 @@ export class AuthService implements AuthServiceInterface {
return null;
}
} catch (e) {
console.error("Failed to parse stored user data", e);
console.error('Failed to parse stored user data', e);
localStorage.removeItem('opchan-user');
return null;
}
@ -339,4 +355,4 @@ export class AuthService implements AuthServiceInterface {
clearStoredUser(): void {
localStorage.removeItem('opchan-user');
}
}
}

View File

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

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