mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-02 12:53:10 +00:00
chore: linting + types + refactor
This commit is contained in:
parent
14d333ff0d
commit
55ba3f374e
@ -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
3
.gitignore
vendored
@ -1,8 +1,7 @@
|
||||
.cursorrules
|
||||
comparison.md
|
||||
.giga/
|
||||
|
||||
furps.md
|
||||
furps-comparison.md
|
||||
|
||||
README-task-master.md
|
||||
.cursor
|
||||
|
||||
14
.prettierignore
Normal file
14
.prettierignore
Normal 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
10
.prettierrc
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"printWidth": 80,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
10
README.md
10
README.md
@ -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.
|
||||
|
||||
@ -17,4 +17,4 @@
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
233
furps-report.md
Normal 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)**
|
||||
|
||||
---
|
||||
12
index.html
12
index.html
@ -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
17
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -3,4 +3,4 @@ export default {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
37
src/App.tsx
37
src/App.tsx
@ -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}>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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" />
|
||||
)}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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!',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
30
src/components/ui/button-variants.ts
Normal file
30
src/components/ui/button-variants.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
import { useToast, toast } from "@/hooks/use-toast";
|
||||
import { useToast, toast } from '@/hooks/use-toast';
|
||||
|
||||
export { useToast, toast };
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@ -7,4 +7,4 @@ export const useForum = () => {
|
||||
throw new Error('useForum must be used within a ForumProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,4 +22,4 @@ export interface OrdinalApiResponse {
|
||||
has_operators: boolean;
|
||||
error_message: string;
|
||||
data: OrdinalDetail[];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -1 +1 @@
|
||||
export { ReOwnWalletService as WalletService } from './ReOwnWalletService';
|
||||
export { ReOwnWalletService as WalletService } from './ReOwnWalletService';
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user