Compare commits
31 Commits
1b67a2fe7f
...
ed61902fab
| Author | SHA1 | Date |
|---|---|---|
|
|
ed61902fab | |
|
|
4974c0e303 | |
|
|
53d3620cff | |
|
|
1dc8f4f1b8 | |
|
|
06f41e8fec | |
|
|
313033d83e | |
|
|
00aa0828c4 | |
|
|
486e75d02a | |
|
|
28ab62f645 | |
|
|
5db25f3ac1 | |
|
|
7debeb598f | |
|
|
156c402169 | |
|
|
73d186e8e8 | |
|
|
b8f179c9c1 | |
|
|
80f457f615 | |
|
|
9410961486 | |
|
|
f15b137686 | |
|
|
95d7f9631c | |
|
|
33fa5c9395 | |
|
|
1d9e58651e | |
|
|
15f19a0450 | |
|
|
cfbe900f06 | |
|
|
75384d8612 | |
|
|
8a4cc5dfae | |
|
|
2783def139 | |
|
|
7d6d084815 | |
|
|
f17d6dea17 | |
|
|
a45ad2844d | |
|
|
e891f8dd33 | |
|
|
7dd03b6f6f | |
|
|
0677ad3b5d |
|
|
@ -16,6 +16,10 @@ VITE_RUNPOD_IMAGE_ENDPOINT_ID='your_image_endpoint_id' # Automatic1111/SD
|
||||||
VITE_RUNPOD_VIDEO_ENDPOINT_ID='your_video_endpoint_id' # Wan2.2
|
VITE_RUNPOD_VIDEO_ENDPOINT_ID='your_video_endpoint_id' # Wan2.2
|
||||||
VITE_RUNPOD_WHISPER_ENDPOINT_ID='your_whisper_endpoint_id' # WhisperX
|
VITE_RUNPOD_WHISPER_ENDPOINT_ID='your_whisper_endpoint_id' # WhisperX
|
||||||
|
|
||||||
|
# WalletConnect (Web3 wallet integration)
|
||||||
|
# Get your project ID at https://cloud.walletconnect.com/
|
||||||
|
VITE_WALLETCONNECT_PROJECT_ID='your_walletconnect_project_id'
|
||||||
|
|
||||||
# Worker-only Variables (Do not prefix with VITE_)
|
# Worker-only Variables (Do not prefix with VITE_)
|
||||||
CLOUDFLARE_API_TOKEN='your_cloudflare_token'
|
CLOUDFLARE_API_TOKEN='your_cloudflare_token'
|
||||||
CLOUDFLARE_ACCOUNT_ID='your_account_id'
|
CLOUDFLARE_ACCOUNT_ID='your_account_id'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
Activity log of changes to canvas boards, organized by contributor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-06
|
||||||
|
|
||||||
|
### Claude
|
||||||
|
- Added per-board Activity Logger feature
|
||||||
|
- Automatically tracks shape creates, deletes, and updates
|
||||||
|
- Collapsible sidebar panel showing activity timeline
|
||||||
|
- Groups activities by date (Today, Yesterday, etc.)
|
||||||
|
- Debounces updates to avoid logging tiny movements
|
||||||
|
- Toggle button in top-right corner
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-05
|
||||||
|
|
||||||
|
### Jeff
|
||||||
|
- Added embed shape linking to MycoFi whitepaper
|
||||||
|
- Deleted old map shape from planning board
|
||||||
|
- Added shared piano shape to music-collab board
|
||||||
|
- Moved token diagram to center of canvas
|
||||||
|
- Created new markdown note with meeting summary
|
||||||
|
|
||||||
|
### Claude
|
||||||
|
- Added "Last Visited" canvases feature to Dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-04
|
||||||
|
|
||||||
|
### Jeff
|
||||||
|
- Created new board `/hyperindex-planning`
|
||||||
|
- Added 3 holon shapes for system architecture
|
||||||
|
- Uploaded screenshot of database schema
|
||||||
|
- Added arrow connectors between components
|
||||||
|
- Renamed board title to "Hyperindex Architecture"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-01-03
|
||||||
|
|
||||||
|
### Jeff
|
||||||
|
- Deleted duplicate image shapes from mycofi board
|
||||||
|
- Added video chat shape for team standup
|
||||||
|
- Created slide deck with 5 slides for presentation
|
||||||
|
- Added sticky notes with action items
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Legend
|
||||||
|
|
||||||
|
| User | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Jeff | Project Owner |
|
||||||
|
| Claude | AI Assistant |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This log tracks user actions on canvas boards (shape additions, deletions, moves, etc.)*
|
||||||
|
|
@ -0,0 +1,665 @@
|
||||||
|
---
|
||||||
|
id: doc-001
|
||||||
|
title: Web3 Wallet Integration Architecture
|
||||||
|
type: other
|
||||||
|
created_date: '2026-01-02 16:07'
|
||||||
|
---
|
||||||
|
# Web3 Wallet Integration Architecture
|
||||||
|
|
||||||
|
**Status:** Planning
|
||||||
|
**Created:** 2026-01-02
|
||||||
|
**Related Task:** task-007
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
This document outlines the architecture for integrating Web3 wallet capabilities into the canvas-website, enabling CryptID users to link Ethereum wallets for on-chain transactions, voting, and token-gated features.
|
||||||
|
|
||||||
|
### Key Constraint: Cryptographic Curve Mismatch
|
||||||
|
|
||||||
|
| System | Curve | Usage |
|
||||||
|
|--------|-------|-------|
|
||||||
|
| **CryptID (WebCrypto)** | ECDSA P-256 (NIST) | Authentication, passwordless login |
|
||||||
|
| **Ethereum** | ECDSA secp256k1 | Transactions, message signing |
|
||||||
|
|
||||||
|
These curves are **incompatible**. A CryptID key cannot sign Ethereum transactions. Therefore, we use a **wallet linking** approach where:
|
||||||
|
1. CryptID handles authentication (who you are)
|
||||||
|
2. Linked wallet handles on-chain actions (what you can do)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Database Schema
|
||||||
|
|
||||||
|
### Migration: `002_linked_wallets.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Migration: Add Linked Wallets for Web3 Integration
|
||||||
|
-- Date: 2026-01-02
|
||||||
|
-- Description: Enables CryptID users to link Ethereum wallets for
|
||||||
|
-- on-chain transactions, voting, and token-gated features.
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- LINKED WALLETS TABLE
|
||||||
|
-- =============================================================================
|
||||||
|
-- Each CryptID user can link multiple Ethereum wallets (EOA, Safe, hardware)
|
||||||
|
-- Linking requires signature verification to prove wallet ownership
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS linked_wallets (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID for the link record
|
||||||
|
user_id TEXT NOT NULL, -- References users.id (CryptID account)
|
||||||
|
wallet_address TEXT NOT NULL, -- Ethereum address (checksummed, 0x-prefixed)
|
||||||
|
|
||||||
|
-- Wallet metadata
|
||||||
|
wallet_type TEXT DEFAULT 'eoa' CHECK (wallet_type IN ('eoa', 'safe', 'hardware', 'contract')),
|
||||||
|
chain_id INTEGER DEFAULT 1, -- Primary chain (1 = Ethereum mainnet)
|
||||||
|
label TEXT, -- User-provided label (e.g., "Main Wallet")
|
||||||
|
|
||||||
|
-- Verification proof
|
||||||
|
signature_message TEXT NOT NULL, -- The message that was signed
|
||||||
|
signature TEXT NOT NULL, -- EIP-191 personal_sign signature
|
||||||
|
verified_at TEXT NOT NULL, -- When signature was verified
|
||||||
|
|
||||||
|
-- ENS integration
|
||||||
|
ens_name TEXT, -- Resolved ENS name (if any)
|
||||||
|
ens_avatar TEXT, -- ENS avatar URL (if any)
|
||||||
|
ens_resolved_at TEXT, -- When ENS was last resolved
|
||||||
|
|
||||||
|
-- Flags
|
||||||
|
is_primary INTEGER DEFAULT 0, -- 1 = primary wallet for this user
|
||||||
|
is_active INTEGER DEFAULT 1, -- 0 = soft-deleted
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
last_used_at TEXT, -- Last time wallet was used for action
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(user_id, wallet_address) -- Can't link same wallet twice
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for efficient lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_user ON linked_wallets(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_address ON linked_wallets(wallet_address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_active ON linked_wallets(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_primary ON linked_wallets(user_id, is_primary);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- WALLET LINKING TOKENS TABLE (for Safe/multisig delayed verification)
|
||||||
|
-- =============================================================================
|
||||||
|
-- For contract wallets that require on-chain signature verification
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wallet_link_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
wallet_address TEXT NOT NULL,
|
||||||
|
nonce TEXT NOT NULL, -- Random nonce for signature message
|
||||||
|
token TEXT NOT NULL UNIQUE, -- Secret token for verification callback
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wallet_link_tokens_token ON wallet_link_tokens(token);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- TOKEN BALANCES CACHE (optional, for token-gating)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Cache of token balances for faster permission checks
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wallet_token_balances (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
wallet_address TEXT NOT NULL,
|
||||||
|
token_address TEXT NOT NULL, -- ERC-20/721/1155 contract address
|
||||||
|
token_type TEXT CHECK (token_type IN ('erc20', 'erc721', 'erc1155')),
|
||||||
|
chain_id INTEGER NOT NULL,
|
||||||
|
balance TEXT NOT NULL, -- String to handle big numbers
|
||||||
|
last_updated TEXT DEFAULT (datetime('now')),
|
||||||
|
|
||||||
|
UNIQUE(wallet_address, token_address, chain_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_balances_wallet ON wallet_token_balances(wallet_address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_balances_token ON wallet_token_balances(token_address);
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript Types
|
||||||
|
|
||||||
|
Add to `worker/types.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// =============================================================================
|
||||||
|
// Linked Wallet Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type WalletType = 'eoa' | 'safe' | 'hardware' | 'contract';
|
||||||
|
|
||||||
|
export interface LinkedWallet {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
wallet_address: string;
|
||||||
|
wallet_type: WalletType;
|
||||||
|
chain_id: number;
|
||||||
|
label: string | null;
|
||||||
|
signature_message: string;
|
||||||
|
signature: string;
|
||||||
|
verified_at: string;
|
||||||
|
ens_name: string | null;
|
||||||
|
ens_avatar: string | null;
|
||||||
|
ens_resolved_at: string | null;
|
||||||
|
is_primary: number; // SQLite boolean
|
||||||
|
is_active: number; // SQLite boolean
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletLinkToken {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
wallet_address: string;
|
||||||
|
nonce: string;
|
||||||
|
token: string;
|
||||||
|
expires_at: string;
|
||||||
|
used: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletTokenBalance {
|
||||||
|
id: string;
|
||||||
|
wallet_address: string;
|
||||||
|
token_address: string;
|
||||||
|
token_type: 'erc20' | 'erc721' | 'erc1155';
|
||||||
|
chain_id: number;
|
||||||
|
balance: string;
|
||||||
|
last_updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response types
|
||||||
|
export interface LinkedWalletResponse {
|
||||||
|
id: string;
|
||||||
|
address: string;
|
||||||
|
type: WalletType;
|
||||||
|
chainId: number;
|
||||||
|
label: string | null;
|
||||||
|
ensName: string | null;
|
||||||
|
ensAvatar: string | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
linkedAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletLinkRequest {
|
||||||
|
walletAddress: string;
|
||||||
|
signature: string;
|
||||||
|
message: string;
|
||||||
|
walletType?: WalletType;
|
||||||
|
chainId?: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API Endpoints
|
||||||
|
|
||||||
|
### Base Path: `/api/wallet`
|
||||||
|
|
||||||
|
All endpoints require CryptID authentication via `X-CryptID-PublicKey` header.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/wallet/link`
|
||||||
|
|
||||||
|
Link a new wallet to the authenticated CryptID account.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
walletAddress: string; // 0x-prefixed Ethereum address
|
||||||
|
signature: string; // EIP-191 signature of the message
|
||||||
|
message: string; // Must match server-generated format
|
||||||
|
walletType?: 'eoa' | 'safe' | 'hardware' | 'contract';
|
||||||
|
chainId?: number; // Default: 1 (mainnet)
|
||||||
|
label?: string; // Optional user label
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Message Format (must be signed):**
|
||||||
|
```
|
||||||
|
Link wallet to CryptID
|
||||||
|
|
||||||
|
Account: ${cryptidUsername}
|
||||||
|
Wallet: ${walletAddress}
|
||||||
|
Timestamp: ${isoTimestamp}
|
||||||
|
Nonce: ${randomNonce}
|
||||||
|
|
||||||
|
This signature proves you own this wallet.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (201 Created):**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true;
|
||||||
|
wallet: LinkedWalletResponse;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
- `400` - Invalid request body or signature
|
||||||
|
- `401` - Not authenticated
|
||||||
|
- `409` - Wallet already linked to this account
|
||||||
|
- `422` - Signature verification failed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/wallet/list`
|
||||||
|
|
||||||
|
Get all wallets linked to the authenticated user.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
wallets: LinkedWalletResponse[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/wallet/:address`
|
||||||
|
|
||||||
|
Get details for a specific linked wallet.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
wallet: LinkedWalletResponse;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `PATCH /api/wallet/:address`
|
||||||
|
|
||||||
|
Update a linked wallet (label, primary status).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
label?: string;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true;
|
||||||
|
wallet: LinkedWalletResponse;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `DELETE /api/wallet/:address`
|
||||||
|
|
||||||
|
Unlink a wallet from the account.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
success: true;
|
||||||
|
message: 'Wallet unlinked';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/wallet/verify/:address`
|
||||||
|
|
||||||
|
Check if a wallet address is linked to any CryptID account.
|
||||||
|
(Public endpoint - no auth required)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
linked: boolean;
|
||||||
|
cryptidUsername?: string; // Only if user allows public display
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/wallet/refresh-ens`
|
||||||
|
|
||||||
|
Refresh ENS name resolution for a linked wallet.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
walletAddress: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
ensName: string | null;
|
||||||
|
ensAvatar: string | null;
|
||||||
|
resolvedAt: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Signature Verification Implementation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// worker/walletAuth.ts
|
||||||
|
|
||||||
|
import { verifyMessage, getAddress } from 'viem';
|
||||||
|
|
||||||
|
export function generateLinkMessage(
|
||||||
|
username: string,
|
||||||
|
address: string,
|
||||||
|
timestamp: string,
|
||||||
|
nonce: string
|
||||||
|
): string {
|
||||||
|
return `Link wallet to CryptID
|
||||||
|
|
||||||
|
Account: ${username}
|
||||||
|
Wallet: ${address}
|
||||||
|
Timestamp: ${timestamp}
|
||||||
|
Nonce: ${nonce}
|
||||||
|
|
||||||
|
This signature proves you own this wallet.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyWalletSignature(
|
||||||
|
address: string,
|
||||||
|
message: string,
|
||||||
|
signature: `0x${string}`
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Normalize address
|
||||||
|
const checksumAddress = getAddress(address);
|
||||||
|
|
||||||
|
// Verify EIP-191 personal_sign signature
|
||||||
|
const valid = await verifyMessage({
|
||||||
|
address: checksumAddress,
|
||||||
|
message,
|
||||||
|
signature,
|
||||||
|
});
|
||||||
|
|
||||||
|
return valid;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Signature verification error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For ERC-1271 contract wallet verification (Safe, etc.)
|
||||||
|
export async function verifyContractSignature(
|
||||||
|
address: string,
|
||||||
|
message: string,
|
||||||
|
signature: string,
|
||||||
|
rpcUrl: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
// ERC-1271 magic value: 0x1626ba7e
|
||||||
|
// Implementation needed for Safe/contract wallet support
|
||||||
|
// Uses eth_call to isValidSignature(bytes32,bytes)
|
||||||
|
throw new Error('Contract signature verification not yet implemented');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Library Comparison
|
||||||
|
|
||||||
|
### Recommendation: **wagmi v2 + viem**
|
||||||
|
|
||||||
|
| Library | Bundle Size | Type Safety | React Hooks | Maintenance | Recommendation |
|
||||||
|
|---------|-------------|-------------|-------------|-------------|----------------|
|
||||||
|
| **wagmi v2** | ~40KB | Excellent | Native | Active (wevm team) | ✅ **Best for React** |
|
||||||
|
| **viem** | ~25KB | Excellent | N/A | Active (wevm team) | ✅ **Best for worker** |
|
||||||
|
| **ethers v6** | ~120KB | Good | None | Active | ⚠️ Larger bundle |
|
||||||
|
| **web3.js** | ~400KB | Poor | None | Declining | ❌ Avoid |
|
||||||
|
|
||||||
|
### Why wagmi + viem?
|
||||||
|
|
||||||
|
1. **Same team** - wagmi and viem are both from wevm, designed to work together
|
||||||
|
2. **Tree-shakeable** - Only import what you use
|
||||||
|
3. **TypeScript-first** - Excellent type inference and autocomplete
|
||||||
|
4. **Modern React** - Hooks-based, works with React 18+ and Suspense
|
||||||
|
5. **WalletConnect v2** - Built-in support via Web3Modal
|
||||||
|
6. **No ethers dependency** - Pure viem underneath
|
||||||
|
|
||||||
|
### Package Configuration
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"wagmi": "^2.12.0",
|
||||||
|
"viem": "^2.19.0",
|
||||||
|
"@tanstack/react-query": "^5.45.0",
|
||||||
|
"@web3modal/wagmi": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supported Wallets (via Web3Modal)
|
||||||
|
|
||||||
|
- MetaMask (injected)
|
||||||
|
- WalletConnect v2 (mobile wallets)
|
||||||
|
- Coinbase Wallet
|
||||||
|
- Rainbow
|
||||||
|
- Safe (via WalletConnect)
|
||||||
|
- Hardware wallets (via MetaMask bridge)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Frontend Architecture
|
||||||
|
|
||||||
|
### Provider Setup (`src/providers/Web3Provider.tsx`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { WagmiProvider, createConfig, http } from 'wagmi';
|
||||||
|
import { mainnet, optimism, arbitrum, base } from 'wagmi/chains';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { createWeb3Modal } from '@web3modal/wagmi/react';
|
||||||
|
|
||||||
|
// Configure chains
|
||||||
|
const chains = [mainnet, optimism, arbitrum, base] as const;
|
||||||
|
|
||||||
|
// Create wagmi config
|
||||||
|
const config = createConfig({
|
||||||
|
chains,
|
||||||
|
transports: {
|
||||||
|
[mainnet.id]: http(),
|
||||||
|
[optimism.id]: http(),
|
||||||
|
[arbitrum.id]: http(),
|
||||||
|
[base.id]: http(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Web3Modal
|
||||||
|
const projectId = process.env.WALLETCONNECT_PROJECT_ID!;
|
||||||
|
|
||||||
|
createWeb3Modal({
|
||||||
|
wagmiConfig: config,
|
||||||
|
projectId,
|
||||||
|
chains,
|
||||||
|
themeMode: 'dark',
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
export function Web3Provider({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<WagmiProvider config={config}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</WagmiProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wallet Link Hook (`src/hooks/useWalletLink.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useAccount, useSignMessage, useDisconnect } from 'wagmi';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
export function useWalletLink() {
|
||||||
|
const { address, isConnected } = useAccount();
|
||||||
|
const { signMessageAsync } = useSignMessage();
|
||||||
|
const { disconnect } = useDisconnect();
|
||||||
|
const { session } = useAuth();
|
||||||
|
const [isLinking, setIsLinking] = useState(false);
|
||||||
|
|
||||||
|
const linkWallet = async (label?: string) => {
|
||||||
|
if (!address || !session.username) return;
|
||||||
|
|
||||||
|
setIsLinking(true);
|
||||||
|
try {
|
||||||
|
// Generate link message
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const nonce = crypto.randomUUID();
|
||||||
|
const message = generateLinkMessage(
|
||||||
|
session.username,
|
||||||
|
address,
|
||||||
|
timestamp,
|
||||||
|
nonce
|
||||||
|
);
|
||||||
|
|
||||||
|
// Request signature from wallet
|
||||||
|
const signature = await signMessageAsync({ message });
|
||||||
|
|
||||||
|
// Send to backend for verification
|
||||||
|
const response = await fetch('/api/wallet/link', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CryptID-PublicKey': session.publicKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
walletAddress: address,
|
||||||
|
signature,
|
||||||
|
message,
|
||||||
|
label,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to link wallet');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} finally {
|
||||||
|
setIsLinking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
isConnected,
|
||||||
|
isLinking,
|
||||||
|
linkWallet,
|
||||||
|
disconnect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Integration Points
|
||||||
|
|
||||||
|
### A. AuthContext Extension
|
||||||
|
|
||||||
|
Add to `Session` type:
|
||||||
|
```typescript
|
||||||
|
interface Session {
|
||||||
|
// ... existing fields
|
||||||
|
linkedWallets?: LinkedWalletResponse[];
|
||||||
|
primaryWallet?: LinkedWalletResponse;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. Token-Gated Features
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Check if user holds specific tokens
|
||||||
|
async function checkTokenGate(
|
||||||
|
walletAddress: string,
|
||||||
|
requirement: {
|
||||||
|
tokenAddress: string;
|
||||||
|
minBalance: string;
|
||||||
|
chainId: number;
|
||||||
|
}
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Query on-chain balance or use cached value
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### C. Snapshot Voting (Future)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Vote on Snapshot proposal
|
||||||
|
async function voteOnProposal(
|
||||||
|
space: string,
|
||||||
|
proposal: string,
|
||||||
|
choice: number,
|
||||||
|
walletAddress: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Use Snapshot.js SDK with linked wallet
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Security Considerations
|
||||||
|
|
||||||
|
1. **Signature Replay Prevention**
|
||||||
|
- Include timestamp and nonce in message
|
||||||
|
- Server validates timestamp is recent (within 5 minutes)
|
||||||
|
- Nonces are single-use
|
||||||
|
|
||||||
|
2. **Address Validation**
|
||||||
|
- Always checksum addresses before storing/comparing
|
||||||
|
- Validate address format (0x + 40 hex chars)
|
||||||
|
|
||||||
|
3. **Rate Limiting**
|
||||||
|
- Limit link attempts per user (e.g., 5/hour)
|
||||||
|
- Limit total wallets per user (e.g., 10)
|
||||||
|
|
||||||
|
4. **Wallet Verification**
|
||||||
|
- EOA: EIP-191 personal_sign
|
||||||
|
- Safe: ERC-1271 isValidSignature
|
||||||
|
- Hardware: Same as EOA (via MetaMask bridge)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Next Steps
|
||||||
|
|
||||||
|
1. **Phase 1 (This Sprint)**
|
||||||
|
- [ ] Add migration file
|
||||||
|
- [ ] Install wagmi/viem dependencies
|
||||||
|
- [ ] Implement link/list/unlink endpoints
|
||||||
|
- [ ] Create WalletLinkPanel UI
|
||||||
|
- [ ] Add wallet section to settings
|
||||||
|
|
||||||
|
2. **Phase 2 (Next Sprint)**
|
||||||
|
- [ ] Snapshot.js integration
|
||||||
|
- [ ] VotingShape for canvas
|
||||||
|
- [ ] Token balance caching
|
||||||
|
|
||||||
|
3. **Phase 3 (Future)**
|
||||||
|
- [ ] Safe SDK integration
|
||||||
|
- [ ] TransactionBuilderShape
|
||||||
|
- [ ] Account Abstraction exploration
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
---
|
||||||
|
id: task-007
|
||||||
|
title: Web3 Wallet Linking & Blockchain Integration
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2025-12-03'
|
||||||
|
updated_date: '2026-01-02 17:05'
|
||||||
|
labels:
|
||||||
|
- feature
|
||||||
|
- web3
|
||||||
|
- blockchain
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Integrate Web3 wallet capabilities to enable CryptID users to link EOA wallets and Safe multisigs for on-chain transactions, voting (Snapshot), and token-gated features.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
CryptID uses ECDSA P-256 (WebCrypto), while Ethereum uses secp256k1. These curves are incompatible, so we use a **wallet linking** approach rather than key reuse.
|
||||||
|
|
||||||
|
### Core Concept
|
||||||
|
1. CryptID remains the primary authentication layer (passwordless)
|
||||||
|
2. Users can link one or more Ethereum wallets to their CryptID
|
||||||
|
3. Linking requires signing a verification message with the wallet
|
||||||
|
4. Linked wallets enable: transactions, voting, token-gating, NFT features
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
- **wagmi v2** + **viem** - Modern React hooks for wallet connection
|
||||||
|
- **WalletConnect v2** - Multi-wallet support (MetaMask, Rainbow, etc.)
|
||||||
|
- **Safe SDK** - Multisig wallet integration
|
||||||
|
- **Snapshot.js** - Off-chain governance voting
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Wallet Linking Foundation (This Task)
|
||||||
|
- Add wagmi/viem/walletconnect dependencies
|
||||||
|
- Create linked_wallets D1 table
|
||||||
|
- Implement wallet linking API endpoints
|
||||||
|
- Build WalletLinkPanel UI component
|
||||||
|
- Display linked wallets in user settings
|
||||||
|
|
||||||
|
### Phase 2: Snapshot Voting (Future Task)
|
||||||
|
- Integrate Snapshot.js SDK
|
||||||
|
- Create VotingShape for canvas visualization
|
||||||
|
- Implement vote signing flow
|
||||||
|
|
||||||
|
### Phase 3: Safe Multisig (Future Task)
|
||||||
|
- Safe SDK integration
|
||||||
|
- TransactionBuilderShape for visual tx composition
|
||||||
|
- Collaborative signing UI
|
||||||
|
|
||||||
|
### Phase 4: Account Abstraction (Future Task)
|
||||||
|
- ERC-4337 smart wallet with P-256 signature validation
|
||||||
|
- Gasless transactions via paymaster
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Install and configure wagmi v2, viem, and @walletconnect/web3modal
|
||||||
|
- [x] #2 Create linked_wallets table in Cloudflare D1 with proper schema
|
||||||
|
- [x] #3 Implement POST /api/wallet/link endpoint with signature verification
|
||||||
|
- [ ] #4 Implement GET /api/wallet/list endpoint to retrieve linked wallets
|
||||||
|
- [ ] #5 Implement DELETE /api/wallet/unlink endpoint to remove wallet links
|
||||||
|
- [ ] #6 Create WalletConnectButton component using wagmi hooks
|
||||||
|
- [ ] #7 Create WalletLinkPanel component for linking flow UI
|
||||||
|
- [ ] #8 Add wallet section to user settings/profile panel
|
||||||
|
- [ ] #9 Display linked wallet addresses with ENS resolution
|
||||||
|
- [ ] #10 Support multiple wallet types: EOA, Safe, Hardware
|
||||||
|
- [ ] #11 Add wallet connection state to AuthContext
|
||||||
|
- [ ] #12 Write tests for wallet linking flow
|
||||||
|
- [ ] #13 Update CLAUDE.md with Web3 architecture documentation
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Step 1: Dependencies & Configuration
|
||||||
|
```bash
|
||||||
|
npm install wagmi viem @tanstack/react-query @walletconnect/web3modal
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure wagmi with WalletConnect projectId and supported chains.
|
||||||
|
|
||||||
|
### Step 2: Database Schema
|
||||||
|
Add to D1 migration:
|
||||||
|
- linked_wallets table (user_id, wallet_address, wallet_type, chain_id, verified_at, signature_proof, ens_name, is_primary)
|
||||||
|
|
||||||
|
### Step 3: API Endpoints
|
||||||
|
Worker routes:
|
||||||
|
- POST /api/wallet/link - Verify signature, create link
|
||||||
|
- GET /api/wallet/list - List user's linked wallets
|
||||||
|
- DELETE /api/wallet/unlink - Remove a linked wallet
|
||||||
|
- GET /api/wallet/verify/:address - Check if address is linked to any CryptID
|
||||||
|
|
||||||
|
### Step 4: Frontend Components
|
||||||
|
- WagmiProvider wrapper in App.tsx
|
||||||
|
- WalletConnectButton - Connect/disconnect wallet
|
||||||
|
- WalletLinkPanel - Full linking flow with signature
|
||||||
|
- WalletBadge - Display linked wallet in UI
|
||||||
|
|
||||||
|
### Step 5: Integration
|
||||||
|
- Add linkedWallets to Session type
|
||||||
|
- Update AuthContext with wallet state
|
||||||
|
- Add wallet section to settings panel
|
||||||
|
|
||||||
|
### Step 6: Testing
|
||||||
|
- Unit tests for signature verification
|
||||||
|
- Integration tests for linking flow
|
||||||
|
- E2E test for full wallet link journey
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
## Planning Complete (2026-01-02)
|
||||||
|
|
||||||
|
Comprehensive planning phase completed:
|
||||||
|
|
||||||
|
### Created Architecture Document (doc-001)
|
||||||
|
- Full technical architecture for wallet linking
|
||||||
|
- Database schema design
|
||||||
|
- API endpoint specifications
|
||||||
|
- Library comparison (wagmi/viem recommended)
|
||||||
|
- Security considerations
|
||||||
|
- Frontend component designs
|
||||||
|
|
||||||
|
### Created Migration File
|
||||||
|
- `worker/migrations/002_linked_wallets.sql`
|
||||||
|
- Tables: linked_wallets, wallet_link_tokens, wallet_token_balances
|
||||||
|
- Proper indexes and foreign keys
|
||||||
|
|
||||||
|
### Created Follow-up Tasks
|
||||||
|
- task-060: Snapshot Voting Integration
|
||||||
|
- task-061: Safe Multisig Integration
|
||||||
|
- task-062: Account Abstraction Exploration
|
||||||
|
|
||||||
|
### Key Architecture Decisions
|
||||||
|
1. **Wallet Linking** approach (not key reuse) due to P-256/secp256k1 incompatibility
|
||||||
|
2. **wagmi v2 + viem** for frontend (React hooks, tree-shakeable)
|
||||||
|
3. **viem** for worker (signature verification)
|
||||||
|
4. **EIP-191 personal_sign** for EOA verification
|
||||||
|
5. **ERC-1271** for Safe/contract wallet verification (future)
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
1. Install dependencies: wagmi, viem, @tanstack/react-query, @web3modal/wagmi
|
||||||
|
2. Run migration on D1
|
||||||
|
3. Implement API endpoints in worker
|
||||||
|
4. Build WalletLinkPanel UI component
|
||||||
|
|
||||||
|
## Implementation Complete (Phase 1: Wallet Linking)
|
||||||
|
|
||||||
|
### Files Created:
|
||||||
|
- `src/providers/Web3Provider.tsx` - Wagmi v2 config with WalletConnect
|
||||||
|
- `src/hooks/useWallet.ts` - React hooks for wallet connection/linking
|
||||||
|
- `src/components/WalletLinkPanel.tsx` - UI component for wallet management
|
||||||
|
- `worker/walletAuth.ts` - Backend signature verification and API handlers
|
||||||
|
- `worker/migrations/002_linked_wallets.sql` - Database schema
|
||||||
|
|
||||||
|
### Files Modified:
|
||||||
|
- `worker/types.ts` - Added wallet types
|
||||||
|
- `worker/worker.ts` - Added wallet API routes
|
||||||
|
- `src/App.tsx` - Integrated Web3Provider
|
||||||
|
- `src/ui/UserSettingsModal.tsx` - Added wallet section to Integrations tab
|
||||||
|
|
||||||
|
### Features:
|
||||||
|
- Connect wallets via MetaMask, WalletConnect, Coinbase Wallet
|
||||||
|
- Link wallets to CryptID accounts via EIP-191 signature
|
||||||
|
- View/manage linked wallets
|
||||||
|
- Set primary wallet, unlink wallets
|
||||||
|
- Supports mainnet, Optimism, Arbitrum, Base, Polygon
|
||||||
|
|
||||||
|
### Remaining Work:
|
||||||
|
- Add @noble/hashes for proper keccak256/ecrecover (placeholder functions)
|
||||||
|
- Run D1 migration on production
|
||||||
|
- Get WalletConnect Project ID from cloud.walletconnect.com
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
---
|
|
||||||
id: task-007
|
|
||||||
title: Web3 Integration
|
|
||||||
status: To Do
|
|
||||||
assignee: []
|
|
||||||
created_date: '2025-12-03'
|
|
||||||
labels: [feature, web3, blockchain]
|
|
||||||
priority: low
|
|
||||||
branch: web3-integration
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
Integrate Web3 capabilities for blockchain-based features (wallet connect, NFT canvas elements, etc.).
|
|
||||||
|
|
||||||
## Branch Info
|
|
||||||
- **Branch**: `web3-integration`
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
- [ ] Add wallet connection
|
|
||||||
- [ ] Enable NFT minting of canvas elements
|
|
||||||
- [ ] Blockchain-based ownership verification
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
---
|
||||||
|
id: task-060
|
||||||
|
title: Snapshot Voting Integration
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-01-02 16:08'
|
||||||
|
labels:
|
||||||
|
- feature
|
||||||
|
- web3
|
||||||
|
- governance
|
||||||
|
- voting
|
||||||
|
dependencies:
|
||||||
|
- task-007
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Integrate Snapshot.js SDK for off-chain governance voting through the canvas interface.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Enable CryptID users with linked wallets to participate in Snapshot governance votes directly from the canvas. Proposals and voting can be visualized as shapes on the canvas.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Requires task-007 (Web3 Wallet Linking) to be completed first
|
||||||
|
- User must have at least one linked wallet with voting power
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
- Use Snapshot.js SDK for proposal fetching and vote submission
|
||||||
|
- Create VotingShape to visualize proposals on canvas
|
||||||
|
- Support EIP-712 signature-based voting via linked wallet
|
||||||
|
- Cache voting power from linked wallets
|
||||||
|
|
||||||
|
## Features
|
||||||
|
1. **Proposal Browser** - List active proposals from configured spaces
|
||||||
|
2. **VotingShape** - Canvas shape to display proposal details and vote
|
||||||
|
3. **Vote Signing** - Use wagmi's signTypedData for EIP-712 votes
|
||||||
|
4. **Voting Power Display** - Show user's voting power per space
|
||||||
|
5. **Vote History** - Track user's past votes
|
||||||
|
|
||||||
|
## Spaces to Support Initially
|
||||||
|
- mycofi.eth (MycoFi DAO)
|
||||||
|
- Add configuration for additional spaces
|
||||||
|
|
||||||
|
## References
|
||||||
|
- Snapshot.js: https://docs.snapshot.org/tools/snapshot.js
|
||||||
|
- Snapshot API: https://docs.snapshot.org/tools/api
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 Install and configure Snapshot.js SDK
|
||||||
|
- [ ] #2 Create VotingShape with proposal details display
|
||||||
|
- [ ] #3 Implement vote signing flow with EIP-712
|
||||||
|
- [ ] #4 Add proposal browser panel to canvas UI
|
||||||
|
- [ ] #5 Display voting power from linked wallets
|
||||||
|
- [ ] #6 Support multiple Snapshot spaces via configuration
|
||||||
|
- [ ] #7 Cache and display vote history
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
---
|
||||||
|
id: task-061
|
||||||
|
title: Safe Multisig Integration for Collaborative Transactions
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-01-02 16:08'
|
||||||
|
labels:
|
||||||
|
- feature
|
||||||
|
- web3
|
||||||
|
- multisig
|
||||||
|
- safe
|
||||||
|
- governance
|
||||||
|
dependencies:
|
||||||
|
- task-007
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Integrate Safe (Gnosis Safe) SDK to enable collaborative transaction building and signing through the canvas interface.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Allow CryptID users to create, propose, and sign Safe multisig transactions visually on the canvas. Multiple signers can collaborate in real-time to approve transactions.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Requires task-007 (Web3 Wallet Linking) to be completed first
|
||||||
|
- Users must link their Safe wallet or EOA that is a Safe signer
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
- Use Safe{Core} SDK for transaction building and signing
|
||||||
|
- Create TransactionBuilderShape for visual tx composition
|
||||||
|
- Use Safe Transaction Service API for proposal queue
|
||||||
|
- Real-time signature collection via canvas collaboration
|
||||||
|
|
||||||
|
## Features
|
||||||
|
1. **Safe Linking** - Link Safe addresses (detect via ERC-1271)
|
||||||
|
2. **TransactionBuilderShape** - Visual transaction composer
|
||||||
|
3. **Signature Collection UI** - See who has signed, who is pending
|
||||||
|
4. **Transaction Queue** - View pending transactions for linked Safes
|
||||||
|
5. **Execution** - Execute transactions when threshold is met
|
||||||
|
|
||||||
|
## Visual Transaction Builder Capabilities
|
||||||
|
- Transfer ETH/tokens
|
||||||
|
- Contract interactions (with ABI import)
|
||||||
|
- Batch transactions
|
||||||
|
- Scheduled transactions (via delay module)
|
||||||
|
|
||||||
|
## Collaboration Features
|
||||||
|
- Real-time signature status on canvas
|
||||||
|
- Notifications when signatures are needed
|
||||||
|
- Discussion threads on pending transactions
|
||||||
|
|
||||||
|
## References
|
||||||
|
- Safe{Core} SDK: https://docs.safe.global/sdk/overview
|
||||||
|
- Safe Transaction Service API: https://docs.safe.global/core-api/transaction-service-overview
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 Install and configure Safe{Core} SDK
|
||||||
|
- [ ] #2 Implement ERC-1271 signature verification for Safe linking
|
||||||
|
- [ ] #3 Create TransactionBuilderShape for visual tx composition
|
||||||
|
- [ ] #4 Build signature collection UI with real-time updates
|
||||||
|
- [ ] #5 Display pending transaction queue for linked Safes
|
||||||
|
- [ ] #6 Enable transaction execution when threshold is met
|
||||||
|
- [ ] #7 Support basic transfer and contract interaction transactions
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
---
|
||||||
|
id: task-062
|
||||||
|
title: Account Abstraction (ERC-4337) Exploration
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-01-02 16:08'
|
||||||
|
labels:
|
||||||
|
- research
|
||||||
|
- web3
|
||||||
|
- account-abstraction
|
||||||
|
- erc-4337
|
||||||
|
dependencies:
|
||||||
|
- task-007
|
||||||
|
priority: low
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Research and prototype using ERC-4337 Account Abstraction to enable CryptID's P-256 keys to directly control smart contract wallets.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Explore the possibility of using Account Abstraction (ERC-4337) to bridge CryptID's WebCrypto P-256 keys with Ethereum transactions. This would eliminate the need for wallet linking by allowing CryptID keys to directly sign UserOperations that control a smart wallet.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
- CryptID uses ECDSA P-256 (NIST curve) via WebCrypto API
|
||||||
|
- Ethereum uses ECDSA secp256k1
|
||||||
|
- These curves are incompatible for direct signing
|
||||||
|
- ERC-4337 allows any signature scheme via custom validation logic
|
||||||
|
|
||||||
|
## Research Questions
|
||||||
|
1. Is P-256 signature verification gas-efficient on-chain?
|
||||||
|
2. What existing implementations exist? (Clave, Daimo)
|
||||||
|
3. What are the wallet deployment costs per user?
|
||||||
|
4. How do we handle gas sponsorship (paymaster)?
|
||||||
|
5. Which bundler/paymaster providers support this?
|
||||||
|
|
||||||
|
## Potential Benefits
|
||||||
|
- Single key for auth AND transactions
|
||||||
|
- Gasless transactions via paymaster
|
||||||
|
- Social recovery using CryptID email
|
||||||
|
- No MetaMask/wallet app needed
|
||||||
|
- True passwordless Web3
|
||||||
|
|
||||||
|
## Risks & Challenges
|
||||||
|
- Complex implementation
|
||||||
|
- Gas costs for P-256 verification (~100k gas)
|
||||||
|
- Not all L2s support ERC-4337 yet
|
||||||
|
- User education on new paradigm
|
||||||
|
|
||||||
|
## Providers to Evaluate
|
||||||
|
- Pimlico (bundler + paymaster)
|
||||||
|
- Alchemy Account Kit
|
||||||
|
- Stackup
|
||||||
|
- Biconomy
|
||||||
|
|
||||||
|
## References
|
||||||
|
- ERC-4337 Spec: https://eips.ethereum.org/EIPS/eip-4337
|
||||||
|
- Clave (P-256 wallet): https://getclave.io/
|
||||||
|
- Daimo (P-256 wallet): https://daimo.com/
|
||||||
|
- viem Account Abstraction: https://viem.sh/account-abstraction
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 Research P-256 on-chain verification gas costs
|
||||||
|
- [ ] #2 Evaluate existing P-256 wallet implementations (Clave, Daimo)
|
||||||
|
- [ ] #3 Prototype UserOperation signing with CryptID keys
|
||||||
|
- [ ] #4 Evaluate bundler/paymaster providers
|
||||||
|
- [ ] #5 Document architecture proposal if viable
|
||||||
|
- [ ] #6 Estimate implementation timeline and costs
|
||||||
|
<!-- AC:END -->
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -48,8 +48,11 @@
|
||||||
"@daily-co/daily-react": "^0.20.0",
|
"@daily-co/daily-react": "^0.20.0",
|
||||||
"@fal-ai/client": "^1.7.2",
|
"@fal-ai/client": "^1.7.2",
|
||||||
"@mdxeditor/editor": "^3.51.0",
|
"@mdxeditor/editor": "^3.51.0",
|
||||||
|
"@noble/hashes": "^2.0.1",
|
||||||
|
"@noble/secp256k1": "^3.0.0",
|
||||||
"@react-three/drei": "^9.114.3",
|
"@react-three/drei": "^9.114.3",
|
||||||
"@react-three/fiber": "^8.17.10",
|
"@react-three/fiber": "^8.17.10",
|
||||||
|
"@tanstack/react-query": "^5.90.16",
|
||||||
"@tldraw/assets": "^3.15.4",
|
"@tldraw/assets": "^3.15.4",
|
||||||
"@tldraw/tldraw": "^3.15.4",
|
"@tldraw/tldraw": "^3.15.4",
|
||||||
"@tldraw/tlschema": "^3.15.4",
|
"@tldraw/tlschema": "^3.15.4",
|
||||||
|
|
@ -57,6 +60,7 @@
|
||||||
"@types/markdown-it": "^14.1.1",
|
"@types/markdown-it": "^14.1.1",
|
||||||
"@types/marked": "^5.0.2",
|
"@types/marked": "^5.0.2",
|
||||||
"@uiw/react-md-editor": "^4.0.5",
|
"@uiw/react-md-editor": "^4.0.5",
|
||||||
|
"@web3modal/wagmi": "^5.1.11",
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
|
@ -89,6 +93,8 @@
|
||||||
"three": "^0.168.0",
|
"three": "^0.168.0",
|
||||||
"tldraw": "^3.15.4",
|
"tldraw": "^3.15.4",
|
||||||
"use-whisper": "^0.0.1",
|
"use-whisper": "^0.0.1",
|
||||||
|
"viem": "^2.43.4",
|
||||||
|
"wagmi": "^3.1.4",
|
||||||
"webcola": "^3.4.0"
|
"webcola": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
51
src/App.tsx
51
src/App.tsx
|
|
@ -28,6 +28,9 @@ import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import CryptID from './components/auth/CryptID';
|
import CryptID from './components/auth/CryptID';
|
||||||
import CryptoDebug from './components/auth/CryptoDebug';
|
import CryptoDebug from './components/auth/CryptoDebug';
|
||||||
|
|
||||||
|
// Import Web3 provider for wallet integration
|
||||||
|
import { Web3Provider } from './providers/Web3Provider';
|
||||||
|
|
||||||
// Import Google Data test component
|
// Import Google Data test component
|
||||||
import { GoogleDataTest } from './components/GoogleDataTest';
|
import { GoogleDataTest } from './components/GoogleDataTest';
|
||||||
|
|
||||||
|
|
@ -109,13 +112,15 @@ const OptionalAuthRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component to redirect board URLs without trailing slashes
|
* Component to redirect /board/:slug URLs to clean /:slug/ URLs
|
||||||
|
* Used on staging to support both old and new URL patterns
|
||||||
*/
|
*/
|
||||||
const RedirectBoardSlug = () => {
|
const RedirectBoardSlug = () => {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
return <Navigate to={`/board/${slug}/`} replace />;
|
return <Navigate to={`/${slug}/`} replace />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main App with context providers
|
* Main App with context providers
|
||||||
*/
|
*/
|
||||||
|
|
@ -142,11 +147,12 @@ const AppWithProviders = () => {
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<FileSystemProvider>
|
<Web3Provider>
|
||||||
<NotificationProvider>
|
<FileSystemProvider>
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<NotificationProvider>
|
||||||
<DailyProvider callObject={null}>
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<BrowserRouter>
|
<DailyProvider callObject={null}>
|
||||||
|
<BrowserRouter>
|
||||||
{/* Display notifications */}
|
{/* Display notifications */}
|
||||||
<NotificationsDisplay />
|
<NotificationsDisplay />
|
||||||
|
|
||||||
|
|
@ -176,11 +182,7 @@ const AppWithProviders = () => {
|
||||||
<Contact />
|
<Contact />
|
||||||
</OptionalAuthRoute>
|
</OptionalAuthRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/board/:slug/" element={
|
<Route path="/board/:slug/" element={<RedirectBoardSlug />} />
|
||||||
<OptionalAuthRoute>
|
|
||||||
<Board />
|
|
||||||
</OptionalAuthRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/inbox/" element={
|
<Route path="/inbox/" element={
|
||||||
<OptionalAuthRoute>
|
<OptionalAuthRoute>
|
||||||
<Inbox />
|
<Inbox />
|
||||||
|
|
@ -209,13 +211,28 @@ const AppWithProviders = () => {
|
||||||
{/* Google Data routes */}
|
{/* Google Data routes */}
|
||||||
<Route path="/google" element={<GoogleDataTest />} />
|
<Route path="/google" element={<GoogleDataTest />} />
|
||||||
<Route path="/oauth/google/callback" element={<GoogleDataTest />} />
|
<Route path="/oauth/google/callback" element={<GoogleDataTest />} />
|
||||||
|
|
||||||
|
{/* Catch-all: Direct slug URLs serve board directly */}
|
||||||
|
{/* e.g., canvas.jeffemmett.com/ccc → shows board "ccc" */}
|
||||||
|
{/* Must be LAST to not interfere with other routes */}
|
||||||
|
<Route path="/:slug" element={
|
||||||
|
<OptionalAuthRoute>
|
||||||
|
<Board />
|
||||||
|
</OptionalAuthRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/:slug/" element={
|
||||||
|
<OptionalAuthRoute>
|
||||||
|
<Board />
|
||||||
|
</OptionalAuthRoute>
|
||||||
|
} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</DailyProvider>
|
</DailyProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</FileSystemProvider>
|
</FileSystemProvider>
|
||||||
|
</Web3Provider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
getActivityLog,
|
||||||
|
ActivityEntry,
|
||||||
|
formatActivityTime,
|
||||||
|
getShapeDisplayName,
|
||||||
|
groupActivitiesByDate,
|
||||||
|
} from '../lib/activityLogger';
|
||||||
|
import '../css/activity-panel.css';
|
||||||
|
|
||||||
|
interface ActivityPanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityPanel({ isOpen, onClose }: ActivityPanelProps) {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const [activities, setActivities] = useState<ActivityEntry[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Load activities and refresh periodically
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug || !isOpen) return;
|
||||||
|
|
||||||
|
const loadActivities = () => {
|
||||||
|
const log = getActivityLog(slug, 50);
|
||||||
|
setActivities(log);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadActivities();
|
||||||
|
|
||||||
|
// Refresh every 5 seconds when panel is open
|
||||||
|
const interval = setInterval(loadActivities, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [slug, isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const groupedActivities = groupActivitiesByDate(activities);
|
||||||
|
|
||||||
|
const getActionIcon = (action: string) => {
|
||||||
|
switch (action) {
|
||||||
|
case 'created': return '+';
|
||||||
|
case 'deleted': return '-';
|
||||||
|
case 'updated': return '~';
|
||||||
|
default: return '?';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionClass = (action: string) => {
|
||||||
|
switch (action) {
|
||||||
|
case 'created': return 'activity-action-created';
|
||||||
|
case 'deleted': return 'activity-action-deleted';
|
||||||
|
case 'updated': return 'activity-action-updated';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="activity-panel">
|
||||||
|
<div className="activity-panel-header">
|
||||||
|
<h3>Activity</h3>
|
||||||
|
<button className="activity-panel-close" onClick={onClose} title="Close">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="activity-panel-content">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="activity-loading">Loading...</div>
|
||||||
|
) : activities.length === 0 ? (
|
||||||
|
<div className="activity-empty">
|
||||||
|
<div className="activity-empty-icon">~</div>
|
||||||
|
<p>No activity yet</p>
|
||||||
|
<p className="activity-empty-hint">Actions will appear here as you work</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="activity-list">
|
||||||
|
{Array.from(groupedActivities.entries()).map(([dateGroup, entries]) => (
|
||||||
|
<div key={dateGroup} className="activity-group">
|
||||||
|
<div className="activity-group-header">{dateGroup}</div>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<div key={entry.id} className="activity-item">
|
||||||
|
<span className={`activity-icon ${getActionClass(entry.action)}`}>
|
||||||
|
{getActionIcon(entry.action)}
|
||||||
|
</span>
|
||||||
|
<div className="activity-details">
|
||||||
|
<span className="activity-text">
|
||||||
|
<span className="activity-user">{entry.user}</span>
|
||||||
|
{' '}
|
||||||
|
{entry.action === 'created' ? 'added' :
|
||||||
|
entry.action === 'deleted' ? 'deleted' : 'updated'}
|
||||||
|
{' '}
|
||||||
|
<span className="activity-shape">{getShapeDisplayName(entry.shapeType)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="activity-time">{formatActivityTime(entry.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle button component for the toolbar
|
||||||
|
export function ActivityToggleButton({ onClick, isActive }: { onClick: () => void; isActive: boolean }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`activity-toggle-btn ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
title="Activity Log"
|
||||||
|
>
|
||||||
|
<span className="activity-toggle-icon">~</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -325,44 +325,74 @@ const BoardSettingsDropdown: React.FC<BoardSettingsDropdownProps> = ({ className
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
|
||||||
{/* Board Info */}
|
{/* Board Info Section */}
|
||||||
<div>
|
<div style={{ padding: '14px', background: 'var(--color-muted-2)', borderBottom: '1px solid var(--color-panel-contrast)' }}>
|
||||||
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-3)', marginBottom: '8px', textTransform: 'uppercase' }}>
|
<div style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
marginBottom: '10px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||||
|
</svg>
|
||||||
Board Info
|
Board Info
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '12px', color: 'var(--color-text)' }}>
|
<div style={{ fontSize: '12px', color: 'var(--color-text-1)' }}>
|
||||||
<div style={{ marginBottom: '4px' }}>
|
<div style={{ marginBottom: '6px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<strong>ID:</strong> {boardId}
|
<span style={{ color: 'var(--color-text-3)', minWidth: '50px' }}>ID:</span>
|
||||||
|
<span style={{ fontFamily: 'monospace', fontSize: '11px' }}>{boardId}</span>
|
||||||
</div>
|
</div>
|
||||||
{boardInfo?.ownerUsername && (
|
{boardInfo?.ownerUsername && (
|
||||||
<div style={{ marginBottom: '4px' }}>
|
<div style={{ marginBottom: '6px', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<strong>Owner:</strong> @{boardInfo.ownerUsername}
|
<span style={{ color: 'var(--color-text-3)', minWidth: '50px' }}>Owner:</span>
|
||||||
|
<span>@{boardInfo.ownerUsername}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<strong>Status:</strong>
|
<span style={{ color: 'var(--color-text-3)', minWidth: '50px' }}>Status:</span>
|
||||||
<span style={{
|
<span style={{
|
||||||
padding: '2px 8px',
|
padding: '3px 10px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
background: boardInfo?.isProtected ? '#fef3c7' : '#d1fae5',
|
background: boardInfo?.isProtected ? '#fef3c7' : '#d1fae5',
|
||||||
color: boardInfo?.isProtected ? '#92400e' : '#065f46',
|
color: boardInfo?.isProtected ? '#92400e' : '#065f46',
|
||||||
}}>
|
}}>
|
||||||
{boardInfo?.isProtected ? 'Protected (View-only)' : 'Open (Anyone can edit)'}
|
{boardInfo?.isProtected ? 'Protected' : 'Open'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin Section */}
|
{/* Admin Section - Protection Settings */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<div style={{ borderTop: '1px solid var(--color-panel-contrast)', paddingTop: '12px' }}>
|
<div style={{ padding: '14px', background: 'var(--color-panel)', borderBottom: '1px solid var(--color-panel-contrast)' }}>
|
||||||
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-3)', marginBottom: '8px', textTransform: 'uppercase' }}>
|
<div style={{
|
||||||
Protection Settings {isGlobalAdmin && <span style={{ color: '#3b82f6' }}>(Global Admin)</span>}
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
marginBottom: '10px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
</svg>
|
||||||
|
Protection {isGlobalAdmin && <span style={{ color: '#3b82f6', fontWeight: 500, fontSize: '10px' }}>(Global Admin)</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Protection Toggle */}
|
{/* Protection Toggle */}
|
||||||
|
|
@ -413,8 +443,24 @@ const BoardSettingsDropdown: React.FC<BoardSettingsDropdownProps> = ({ className
|
||||||
|
|
||||||
{/* Editor Management (only when protected) */}
|
{/* Editor Management (only when protected) */}
|
||||||
{boardInfo?.isProtected && (
|
{boardInfo?.isProtected && (
|
||||||
<div>
|
<div style={{ padding: '14px', background: 'var(--color-muted-2)', borderBottom: '1px solid var(--color-panel-contrast)' }}>
|
||||||
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--color-text-3)', marginBottom: '8px', textTransform: 'uppercase' }}>
|
<div style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
marginBottom: '10px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="9" cy="7" r="4"/>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
|
</svg>
|
||||||
Editors ({editors.length})
|
Editors ({editors.length})
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -475,10 +521,10 @@ const BoardSettingsDropdown: React.FC<BoardSettingsDropdownProps> = ({ className
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
padding: '8px',
|
padding: '8px 10px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
marginBottom: '4px',
|
marginBottom: '4px',
|
||||||
background: 'var(--color-muted-2)',
|
background: 'var(--color-panel)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -514,7 +560,26 @@ const BoardSettingsDropdown: React.FC<BoardSettingsDropdownProps> = ({ className
|
||||||
|
|
||||||
{/* Request Admin Access (for non-admins) */}
|
{/* Request Admin Access (for non-admins) */}
|
||||||
{!isAdmin && session.authed && (
|
{!isAdmin && session.authed && (
|
||||||
<div style={{ borderTop: '1px solid var(--color-panel-contrast)', paddingTop: '12px' }}>
|
<div style={{ padding: '14px', background: 'var(--color-panel)', borderBottom: '1px solid var(--color-panel-contrast)' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--color-text)',
|
||||||
|
marginBottom: '10px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
}}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||||
|
<circle cx="8.5" cy="7" r="4"/>
|
||||||
|
<line x1="20" y1="8" x2="20" y2="14"/>
|
||||||
|
<line x1="23" y1="11" x2="17" y2="11"/>
|
||||||
|
</svg>
|
||||||
|
Admin Access
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={requestAdminAccess}
|
onClick={requestAdminAccess}
|
||||||
disabled={requestingAdmin || adminRequestSent}
|
disabled={requestingAdmin || adminRequestSent}
|
||||||
|
|
@ -546,8 +611,10 @@ const BoardSettingsDropdown: React.FC<BoardSettingsDropdownProps> = ({ className
|
||||||
|
|
||||||
{/* Sign in prompt for anonymous users */}
|
{/* Sign in prompt for anonymous users */}
|
||||||
{!session.authed && (
|
{!session.authed && (
|
||||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', textAlign: 'center', padding: '10px' }}>
|
<div style={{ padding: '14px', background: 'var(--color-muted-2)', textAlign: 'center' }}>
|
||||||
Sign in to access board settings
|
<div style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
|
||||||
|
Sign in to access board settings
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,521 @@
|
||||||
|
/**
|
||||||
|
* WalletLinkPanel - UI for connecting and linking Web3 wallets to enCryptID
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Connect wallet (MetaMask, WalletConnect, etc.)
|
||||||
|
* - Link wallet to enCryptID account via signature
|
||||||
|
* - View and manage linked wallets
|
||||||
|
* - Set primary wallet
|
||||||
|
* - Unlink wallets
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
useWalletConnection,
|
||||||
|
useWalletLink,
|
||||||
|
useLinkedWallets,
|
||||||
|
formatAddress,
|
||||||
|
LinkedWallet,
|
||||||
|
} from '../hooks/useWallet';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Styles (inline for simplicity - can be moved to CSS/Tailwind)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
padding: '16px',
|
||||||
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
section: {
|
||||||
|
marginBottom: '24px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
marginBottom: '12px',
|
||||||
|
color: '#374151',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
card: {
|
||||||
|
background: '#f9fafb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
button: {
|
||||||
|
background: '#4f46e5',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
} as React.CSSProperties,
|
||||||
|
buttonSecondary: {
|
||||||
|
background: '#e5e7eb',
|
||||||
|
color: '#374151',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
} as React.CSSProperties,
|
||||||
|
buttonDanger: {
|
||||||
|
background: '#ef4444',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
} as React.CSSProperties,
|
||||||
|
buttonSmall: {
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: '12px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
flexRow: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
flexBetween: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
address: {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#6b7280',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
badge: {
|
||||||
|
background: '#dbeafe',
|
||||||
|
color: '#1d4ed8',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
} as React.CSSProperties,
|
||||||
|
badgePrimary: {
|
||||||
|
background: '#dcfce7',
|
||||||
|
color: '#166534',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
error: {
|
||||||
|
color: '#ef4444',
|
||||||
|
fontSize: '13px',
|
||||||
|
marginTop: '8px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
success: {
|
||||||
|
color: '#22c55e',
|
||||||
|
fontSize: '13px',
|
||||||
|
marginTop: '8px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
input: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #d1d5db',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
connectorButton: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '8px',
|
||||||
|
transition: 'border-color 0.2s',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
walletIcon: {
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: '#f3f4f6',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '14px',
|
||||||
|
} as React.CSSProperties,
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Sub-components
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface ConnectWalletSectionProps {
|
||||||
|
onConnect: (connectorId?: string) => void;
|
||||||
|
connectors: Array<{ id: string; name: string; type: string }>;
|
||||||
|
isConnecting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectWalletSection({ onConnect, connectors, isConnecting }: ConnectWalletSectionProps) {
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Connect Wallet</div>
|
||||||
|
{connectors.map((connector) => (
|
||||||
|
<button
|
||||||
|
key={connector.id}
|
||||||
|
onClick={() => onConnect(connector.id)}
|
||||||
|
disabled={isConnecting}
|
||||||
|
style={styles.connectorButton}
|
||||||
|
>
|
||||||
|
<div style={styles.walletIcon}>
|
||||||
|
{connector.name === 'MetaMask' ? '🦊' :
|
||||||
|
connector.name === 'WalletConnect' ? '🔗' :
|
||||||
|
connector.name === 'Coinbase Wallet' ? '🔵' : '👛'}
|
||||||
|
</div>
|
||||||
|
<span>{connector.name}</span>
|
||||||
|
{isConnecting && <span style={{ marginLeft: 'auto', color: '#9ca3af' }}>Connecting...</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectedWalletSectionProps {
|
||||||
|
address: string;
|
||||||
|
ensName: string | null;
|
||||||
|
chainId: number;
|
||||||
|
connectorName: string | undefined;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
isDisconnecting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectedWalletSection({
|
||||||
|
address,
|
||||||
|
ensName,
|
||||||
|
chainId,
|
||||||
|
connectorName,
|
||||||
|
onDisconnect,
|
||||||
|
isDisconnecting,
|
||||||
|
}: ConnectedWalletSectionProps) {
|
||||||
|
const chainNames: Record<number, string> = {
|
||||||
|
1: 'Ethereum',
|
||||||
|
10: 'Optimism',
|
||||||
|
137: 'Polygon',
|
||||||
|
42161: 'Arbitrum',
|
||||||
|
8453: 'Base',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Connected Wallet</div>
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.flexBetween}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500, marginBottom: '4px' }}>
|
||||||
|
{ensName || formatAddress(address)}
|
||||||
|
</div>
|
||||||
|
<div style={styles.address}>{formatAddress(address, 6)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div style={styles.badge}>{chainNames[chainId] || `Chain ${chainId}`}</div>
|
||||||
|
{connectorName && (
|
||||||
|
<div style={{ fontSize: '11px', color: '#9ca3af', marginTop: '4px' }}>
|
||||||
|
via {connectorName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={onDisconnect}
|
||||||
|
disabled={isDisconnecting}
|
||||||
|
style={styles.buttonSecondary}
|
||||||
|
>
|
||||||
|
{isDisconnecting ? 'Disconnecting...' : 'Disconnect'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkWalletSectionProps {
|
||||||
|
address: string;
|
||||||
|
isLinking: boolean;
|
||||||
|
linkError: string | null;
|
||||||
|
onLink: (label?: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkWalletSection({
|
||||||
|
address: _address,
|
||||||
|
isLinking,
|
||||||
|
linkError,
|
||||||
|
onLink,
|
||||||
|
isAuthenticated,
|
||||||
|
}: LinkWalletSectionProps) {
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const handleLink = async () => {
|
||||||
|
setSuccess(false);
|
||||||
|
const result = await onLink(label || undefined);
|
||||||
|
if (result.success) {
|
||||||
|
setSuccess(true);
|
||||||
|
setLabel('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Link to enCryptID</div>
|
||||||
|
<div style={{ ...styles.card, color: '#6b7280' }}>
|
||||||
|
Please sign in with enCryptID to link your wallet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Link to enCryptID</div>
|
||||||
|
<div style={styles.card}>
|
||||||
|
<p style={{ fontSize: '13px', color: '#6b7280', marginBottom: '12px' }}>
|
||||||
|
Link this wallet to your enCryptID account. You'll be asked to sign a message
|
||||||
|
to prove ownership.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Label (optional, e.g., 'Main Wallet')"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleLink}
|
||||||
|
disabled={isLinking}
|
||||||
|
style={styles.button}
|
||||||
|
>
|
||||||
|
{isLinking ? 'Signing...' : 'Link Wallet'}
|
||||||
|
</button>
|
||||||
|
{linkError && <div style={styles.error}>{linkError}</div>}
|
||||||
|
{success && <div style={styles.success}>Wallet linked successfully!</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkedWalletItemProps {
|
||||||
|
wallet: LinkedWallet;
|
||||||
|
onSetPrimary: () => Promise<boolean>;
|
||||||
|
onUnlink: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkedWalletItem({ wallet, onSetPrimary, onUnlink }: LinkedWalletItemProps) {
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
|
||||||
|
const handleSetPrimary = async () => {
|
||||||
|
setIsUpdating(true);
|
||||||
|
await onSetPrimary();
|
||||||
|
setIsUpdating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnlink = async () => {
|
||||||
|
if (!confirm('Are you sure you want to unlink this wallet?')) return;
|
||||||
|
setIsUpdating(true);
|
||||||
|
await onUnlink();
|
||||||
|
setIsUpdating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.flexBetween}>
|
||||||
|
<div>
|
||||||
|
<div style={styles.flexRow}>
|
||||||
|
<span style={{ fontWeight: 500 }}>
|
||||||
|
{wallet.ensName || wallet.label || formatAddress(wallet.address)}
|
||||||
|
</span>
|
||||||
|
{wallet.isPrimary && (
|
||||||
|
<span style={{ ...styles.badge, ...styles.badgePrimary }}>Primary</span>
|
||||||
|
)}
|
||||||
|
<span style={styles.badge}>{wallet.type.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...styles.address, marginTop: '4px' }}>
|
||||||
|
{formatAddress(wallet.address, 8)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...styles.flexRow, gap: '4px' }}>
|
||||||
|
{!wallet.isPrimary && (
|
||||||
|
<button
|
||||||
|
onClick={handleSetPrimary}
|
||||||
|
disabled={isUpdating}
|
||||||
|
style={{ ...styles.buttonSecondary, ...styles.buttonSmall }}
|
||||||
|
>
|
||||||
|
Set Primary
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleUnlink}
|
||||||
|
disabled={isUpdating}
|
||||||
|
style={{ ...styles.buttonDanger, ...styles.buttonSmall }}
|
||||||
|
>
|
||||||
|
Unlink
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkedWalletsSectionProps {
|
||||||
|
wallets: LinkedWallet[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onUpdateWallet: (address: string, updates: { isPrimary?: boolean }) => Promise<boolean>;
|
||||||
|
onUnlinkWallet: (address: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkedWalletsSection({
|
||||||
|
wallets,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
onUpdateWallet,
|
||||||
|
onUnlinkWallet,
|
||||||
|
}: LinkedWalletsSectionProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Linked Wallets</div>
|
||||||
|
<div style={{ color: '#9ca3af', fontSize: '13px' }}>Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Linked Wallets</div>
|
||||||
|
<div style={styles.error}>{error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wallets.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Linked Wallets</div>
|
||||||
|
<div style={{ color: '#9ca3af', fontSize: '13px' }}>
|
||||||
|
No wallets linked yet. Connect a wallet and link it above.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.section}>
|
||||||
|
<div style={styles.sectionTitle}>Linked Wallets ({wallets.length})</div>
|
||||||
|
{wallets.map((wallet) => (
|
||||||
|
<LinkedWalletItem
|
||||||
|
key={wallet.id}
|
||||||
|
wallet={wallet}
|
||||||
|
onSetPrimary={() => onUpdateWallet(wallet.address, { isPrimary: true })}
|
||||||
|
onUnlink={() => onUnlinkWallet(wallet.address)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Main Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function WalletLinkPanel() {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const {
|
||||||
|
address,
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
chainId,
|
||||||
|
connectorName,
|
||||||
|
ensName,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
isDisconnecting,
|
||||||
|
connectors,
|
||||||
|
} = useWalletConnection();
|
||||||
|
|
||||||
|
const { isLinking, linkError, linkWallet, clearError } = useWalletLink();
|
||||||
|
|
||||||
|
const {
|
||||||
|
wallets,
|
||||||
|
isLoading: isLoadingWallets,
|
||||||
|
error: walletsError,
|
||||||
|
updateWallet,
|
||||||
|
unlinkWallet,
|
||||||
|
refetch: refetchWallets,
|
||||||
|
} = useLinkedWallets();
|
||||||
|
|
||||||
|
// Check if the connected wallet is already linked
|
||||||
|
const isCurrentWalletLinked = address
|
||||||
|
? wallets.some(w => w.address.toLowerCase() === address.toLowerCase())
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const handleLink = async (label?: string) => {
|
||||||
|
clearError();
|
||||||
|
const result = await linkWallet(label);
|
||||||
|
if (result.success) {
|
||||||
|
await refetchWallets();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600 }}>
|
||||||
|
Web3 Wallet
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{!isConnected ? (
|
||||||
|
<ConnectWalletSection
|
||||||
|
onConnect={connect}
|
||||||
|
connectors={connectors}
|
||||||
|
isConnecting={isConnecting}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ConnectedWalletSection
|
||||||
|
address={address!}
|
||||||
|
ensName={ensName}
|
||||||
|
chainId={chainId}
|
||||||
|
connectorName={connectorName}
|
||||||
|
onDisconnect={disconnect}
|
||||||
|
isDisconnecting={isDisconnecting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!isCurrentWalletLinked && (
|
||||||
|
<LinkWalletSection
|
||||||
|
address={address!}
|
||||||
|
isLinking={isLinking}
|
||||||
|
linkError={linkError}
|
||||||
|
onLink={handleLink}
|
||||||
|
isAuthenticated={session.authed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LinkedWalletsSection
|
||||||
|
wallets={wallets}
|
||||||
|
isLoading={isLoadingWallets}
|
||||||
|
error={walletsError}
|
||||||
|
onUpdateWallet={updateWallet}
|
||||||
|
onUnlinkWallet={unlinkWallet}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WalletLinkPanel;
|
||||||
|
|
@ -98,7 +98,7 @@ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="banner-summary">
|
<p className="banner-summary">
|
||||||
Sign in with CryptID to edit
|
Sign in with enCryptID to edit
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ interface CryptIDProps {
|
||||||
type RegistrationStep = 'welcome' | 'username' | 'email' | 'success';
|
type RegistrationStep = 'welcome' | 'username' | 'email' | 'success';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CryptID - WebCryptoAPI-based authentication component
|
* enCryptID - WebCryptoAPI-based authentication component
|
||||||
* Enhanced with multi-step registration and email backup
|
* Enhanced with multi-step registration and email backup
|
||||||
*/
|
*/
|
||||||
const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
|
|
@ -235,7 +235,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
<p style={styles.description}>
|
<p style={styles.description}>
|
||||||
{!browserSupport.supported
|
{!browserSupport.supported
|
||||||
? 'Your browser does not support the required features for cryptographic authentication. Please use a modern browser.'
|
? 'Your browser does not support the required features for cryptographic authentication. Please use a modern browser.'
|
||||||
: 'CryptID requires a secure connection (HTTPS) to protect your cryptographic keys.'}
|
: 'enCryptID requires a secure connection (HTTPS) to protect your cryptographic keys.'}
|
||||||
</p>
|
</p>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<button onClick={onCancel} style={styles.secondaryButton}>
|
<button onClick={onCancel} style={styles.secondaryButton}>
|
||||||
|
|
@ -272,7 +272,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
{registrationStep === 'welcome' && (
|
{registrationStep === 'welcome' && (
|
||||||
<div style={styles.card}>
|
<div style={styles.card}>
|
||||||
<div style={styles.iconLarge}>🔐</div>
|
<div style={styles.iconLarge}>🔐</div>
|
||||||
<h2 style={styles.title}>Welcome to CryptID</h2>
|
<h2 style={styles.title}>Welcome to enCryptID</h2>
|
||||||
<p style={styles.subtitle}>Passwordless, secure authentication</p>
|
<p style={styles.subtitle}>Passwordless, secure authentication</p>
|
||||||
|
|
||||||
<div style={styles.explainerBox}>
|
<div style={styles.explainerBox}>
|
||||||
|
|
@ -531,7 +531,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
<div style={styles.card}>
|
<div style={styles.card}>
|
||||||
<div style={styles.successIcon}>✓</div>
|
<div style={styles.successIcon}>✓</div>
|
||||||
<h2 style={styles.title}>Welcome, {username}!</h2>
|
<h2 style={styles.title}>Welcome, {username}!</h2>
|
||||||
<p style={styles.subtitle}>Your CryptID account is ready</p>
|
<p style={styles.subtitle}>Your enCryptID account is ready</p>
|
||||||
|
|
||||||
<div style={styles.successBox}>
|
<div style={styles.successBox}>
|
||||||
<div style={styles.successItem}>
|
<div style={styles.successItem}>
|
||||||
|
|
@ -578,7 +578,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<div style={styles.card}>
|
<div style={styles.card}>
|
||||||
<div style={styles.iconLarge}>🔐</div>
|
<div style={styles.iconLarge}>🔐</div>
|
||||||
<h2 style={styles.title}>Sign In with CryptID</h2>
|
<h2 style={styles.title}>Sign In with enCryptID</h2>
|
||||||
|
|
||||||
{existingUsers.length > 0 ? (
|
{existingUsers.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -624,7 +624,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
No accounts found on this device.
|
No accounts found on this device.
|
||||||
</p>
|
</p>
|
||||||
<p style={{ ...styles.hint, marginBottom: '20px' }}>
|
<p style={{ ...styles.hint, marginBottom: '20px' }}>
|
||||||
Create a new CryptID or use a backup link from another device to sign in here.
|
Create a new enCryptID or use a backup link from another device to sign in here.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -639,7 +639,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
||||||
style={existingUsers.length > 0 ? { ...styles.linkButton, marginTop: '20px' } : styles.primaryButton}
|
style={existingUsers.length > 0 ? { ...styles.linkButton, marginTop: '20px' } : styles.primaryButton}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{existingUsers.length > 0 ? 'Need an account? Create one' : 'Create a CryptID'}
|
{existingUsers.length > 0 ? 'Need an account? Create one' : 'Create an enCryptID'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { GoogleExportBrowser } from '../GoogleExportBrowser';
|
||||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from '../../lib/fathomApiKey';
|
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from '../../lib/fathomApiKey';
|
||||||
import { isMiroApiKeyConfigured } from '../../lib/miroApiKey';
|
import { isMiroApiKeyConfigured } from '../../lib/miroApiKey';
|
||||||
import { MiroIntegrationModal } from '../MiroIntegrationModal';
|
import { MiroIntegrationModal } from '../MiroIntegrationModal';
|
||||||
|
import { WalletLinkPanel } from '../WalletLinkPanel';
|
||||||
import { getMyConnections, createConnection, removeConnection, updateTrustLevel, updateEdgeMetadata } from '../../lib/networking/connectionService';
|
import { getMyConnections, createConnection, removeConnection, updateTrustLevel, updateEdgeMetadata } from '../../lib/networking/connectionService';
|
||||||
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from '../../lib/networking/types';
|
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from '../../lib/networking/types';
|
||||||
|
|
||||||
|
|
@ -26,6 +27,7 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
const [showGoogleBrowser, setShowGoogleBrowser] = useState(false);
|
const [showGoogleBrowser, setShowGoogleBrowser] = useState(false);
|
||||||
const [showObsidianModal, setShowObsidianModal] = useState(false);
|
const [showObsidianModal, setShowObsidianModal] = useState(false);
|
||||||
const [showMiroModal, setShowMiroModal] = useState(false);
|
const [showMiroModal, setShowMiroModal] = useState(false);
|
||||||
|
const [showWalletModal, setShowWalletModal] = useState(false);
|
||||||
const [obsidianVaultUrl, setObsidianVaultUrl] = useState('');
|
const [obsidianVaultUrl, setObsidianVaultUrl] = useState('');
|
||||||
const [googleConnected, setGoogleConnected] = useState(false);
|
const [googleConnected, setGoogleConnected] = useState(false);
|
||||||
const [googleLoading, setGoogleLoading] = useState(false);
|
const [googleLoading, setGoogleLoading] = useState(false);
|
||||||
|
|
@ -335,7 +337,7 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
transition: 'background 0.15s',
|
transition: 'background 0.15s',
|
||||||
pointerEvents: 'all',
|
pointerEvents: 'all',
|
||||||
}}
|
}}
|
||||||
title={session.authed ? session.username : 'Sign in with CryptID'}
|
title={session.authed ? session.username : 'Sign in with enCryptID'}
|
||||||
>
|
>
|
||||||
{session.authed ? (
|
{session.authed ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -377,9 +379,9 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
maxHeight: 'calc(100vh - 100px)',
|
maxHeight: 'calc(100vh - 100px)',
|
||||||
background: 'var(--color-background, #ffffff)',
|
background: 'var(--color-background, #ffffff)',
|
||||||
backgroundColor: 'var(--color-background, #ffffff)',
|
backgroundColor: 'var(--color-background, #ffffff)',
|
||||||
border: '1px solid var(--color-grid)',
|
border: '2px solid #64748b',
|
||||||
borderRadius: '8px',
|
borderRadius: '12px',
|
||||||
boxShadow: '0 4px 16px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.08)',
|
boxShadow: '0 4px 16px rgba(0,0,0,0.15), 0 0 0 1px rgba(100, 116, 139, 0.2), 0 0 20px rgba(100, 116, 139, 0.1)',
|
||||||
zIndex: 100000,
|
zIndex: 100000,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
overflowX: 'hidden',
|
overflowX: 'hidden',
|
||||||
|
|
@ -401,6 +403,39 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
>
|
>
|
||||||
{session.authed ? (
|
{session.authed ? (
|
||||||
<>
|
<>
|
||||||
|
{/* Security header with lock icon */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 14px',
|
||||||
|
background: 'linear-gradient(135deg, rgba(100, 116, 139, 0.1) 0%, rgba(100, 116, 139, 0.05) 100%)',
|
||||||
|
borderBottom: '1px solid rgba(100, 116, 139, 0.2)',
|
||||||
|
cursor: 'help',
|
||||||
|
}}
|
||||||
|
title="All data in this menu is protected with end-to-end encryption. Your keys never leave your browser - only you can decrypt your data."
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="#64748b" stroke="#64748b" strokeWidth="1.5">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#64748b',
|
||||||
|
letterSpacing: '0.3px',
|
||||||
|
}}>
|
||||||
|
ENCRYPTED & SECURE
|
||||||
|
</span>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#64748b" strokeWidth="2" style={{ opacity: 0.7 }}>
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M12 16v-4"/>
|
||||||
|
<path d="M12 8h.01"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Account section */}
|
{/* Account section */}
|
||||||
<div style={{ padding: '12px 14px', borderBottom: '1px solid var(--color-grid)' }}>
|
<div style={{ padding: '12px 14px', borderBottom: '1px solid var(--color-grid)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
|
@ -425,11 +460,11 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
{session.username}
|
{session.username}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '11px', color: 'var(--color-text-2)', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<div style={{ fontSize: '11px', color: 'var(--color-text-2)', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="#22c55e" stroke="#22c55e" strokeWidth="2">
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="#64748b" stroke="#64748b" strokeWidth="2">
|
||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
</svg>
|
</svg>
|
||||||
CryptID secured
|
enCryptID secured
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -491,8 +526,65 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
Integrations
|
Integrations
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Web3 Wallet - First integration */}
|
||||||
|
<div style={{ padding: '6px 10px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: 'linear-gradient(135deg, #627eea 0%, #3b82f6 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}>
|
||||||
|
👛
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
|
||||||
|
Web3 Wallet
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
|
||||||
|
Link wallet to enCryptID
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowWalletModal(true);
|
||||||
|
setShowDropdown(false);
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'linear-gradient(135deg, #627eea 0%, #3b82f6 100%)',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
pointerEvents: 'all',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
boxShadow: '0 2px 4px rgba(99, 102, 241, 0.3)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'linear-gradient(135deg, #4f46e5 0%, #2563eb 100%)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 8px rgba(99, 102, 241, 0.4)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'linear-gradient(135deg, #627eea 0%, #3b82f6 100%)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 2px 4px rgba(99, 102, 241, 0.3)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage Wallets
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Google Workspace - Coming Soon */}
|
{/* Google Workspace - Coming Soon */}
|
||||||
<div style={{ padding: '6px 10px', opacity: 0.6 }}>
|
<div style={{ padding: '6px 10px', borderTop: '1px solid var(--color-grid)', opacity: 0.6 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '24px',
|
width: '24px',
|
||||||
|
|
@ -1160,6 +1252,107 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Web3 Wallet Modal */}
|
||||||
|
{showWalletModal && createPortal(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 100001,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
setShowWalletModal(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '0',
|
||||||
|
width: '420px',
|
||||||
|
maxWidth: '95vw',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||||
|
overflow: 'auto',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '16px 20px',
|
||||||
|
borderBottom: `1px solid ${isDarkMode ? '#333' : '#e5e7eb'}`,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'linear-gradient(135deg, #627eea 0%, #3b82f6 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '18px',
|
||||||
|
}}>
|
||||||
|
👛
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: isDarkMode ? '#f4f4f5' : '#1f2937',
|
||||||
|
}}>
|
||||||
|
Web3 Wallet
|
||||||
|
</h3>
|
||||||
|
<p style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '12px',
|
||||||
|
color: isDarkMode ? '#a1a1aa' : '#6b7280',
|
||||||
|
}}>
|
||||||
|
Connect & link to enCryptID
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowWalletModal(false)}
|
||||||
|
style={{
|
||||||
|
background: isDarkMode ? '#333' : '#f3f4f6',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: isDarkMode ? '#a1a1aa' : '#6b7280',
|
||||||
|
fontSize: '18px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* WalletLinkPanel content */}
|
||||||
|
<WalletLinkPanel />
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
{/* CryptID Sign In Modal - rendered via portal */}
|
{/* CryptID Sign In Modal - rendered via portal */}
|
||||||
{showCryptIDModal && createPortal(
|
{showCryptIDModal && createPortal(
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export const Profile: React.FC<ProfileProps> = ({ onLogout, onOpenVaultBrowser }
|
||||||
return (
|
return (
|
||||||
<div className="profile-container">
|
<div className="profile-container">
|
||||||
<div className="profile-header">
|
<div className="profile-header">
|
||||||
<h3>CryptID: {session.username}</h3>
|
<h3>enCryptID: {session.username}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="profile-settings">
|
<div className="profile-settings">
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ const WORKER_ENV = import.meta.env.VITE_WORKER_ENV || 'production' // Default to
|
||||||
|
|
||||||
const WORKER_URLS = {
|
const WORKER_URLS = {
|
||||||
local: `http://${window.location.hostname}:5172`,
|
local: `http://${window.location.hostname}:5172`,
|
||||||
dev: "https://jeffemmett-canvas-dev.jeffemmett.workers.dev",
|
dev: "https://jeffemmett-canvas-automerge-dev.jeffemmett.workers.dev",
|
||||||
staging: "https://jeffemmett-canvas-dev.jeffemmett.workers.dev",
|
staging: "https://jeffemmett-canvas-automerge-dev.jeffemmett.workers.dev",
|
||||||
production: "https://jeffemmett-canvas.jeffemmett.workers.dev"
|
production: "https://jeffemmett-canvas.jeffemmett.workers.dev"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
/* Activity Panel Styles */
|
||||||
|
|
||||||
|
.activity-panel {
|
||||||
|
position: fixed;
|
||||||
|
top: 60px;
|
||||||
|
right: 12px;
|
||||||
|
width: 280px;
|
||||||
|
max-height: calc(100vh - 80px);
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-panel-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-panel-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-panel-close:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-panel-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 16px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-empty-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-empty p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-empty-hint {
|
||||||
|
margin-top: 4px !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-list {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-group {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-group-header {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6c757d;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 8px 16px 4px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 8px 16px;
|
||||||
|
gap: 10px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-item:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: monospace;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-action-created {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-action-deleted {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-action-updated {
|
||||||
|
background: #d1ecf1;
|
||||||
|
color: #0c5460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #212529;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-user {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-shape {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-time {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle Button */
|
||||||
|
.activity-toggle-btn {
|
||||||
|
background: var(--tool-bg, #f8f9fa);
|
||||||
|
border: 1px solid var(--tool-border, #dee2e6);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-toggle-btn:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-toggle-btn.active {
|
||||||
|
background: #007bff;
|
||||||
|
border-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-toggle-icon {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Mode */
|
||||||
|
html.dark .activity-panel {
|
||||||
|
background: #2d2d2d;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-panel-header {
|
||||||
|
background: #3a3a3a;
|
||||||
|
border-bottom-color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-panel-header h3 {
|
||||||
|
color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-panel-close {
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-panel-close:hover {
|
||||||
|
background: #495057;
|
||||||
|
color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-group-header {
|
||||||
|
background: #3a3a3a;
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-item:hover {
|
||||||
|
background: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-text {
|
||||||
|
color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-shape {
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-time {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-empty {
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-action-created {
|
||||||
|
background: #1e4d2b;
|
||||||
|
color: #d4edda;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-action-deleted {
|
||||||
|
background: #4a1e1e;
|
||||||
|
color: #f8d7da;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-action-updated {
|
||||||
|
background: #1e4a4a;
|
||||||
|
color: #d1ecf1;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-toggle-btn {
|
||||||
|
background: #3a3a3a;
|
||||||
|
border-color: #495057;
|
||||||
|
color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-toggle-btn:hover {
|
||||||
|
background: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .activity-toggle-btn.active {
|
||||||
|
background: #0d6efd;
|
||||||
|
border-color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.activity-panel {
|
||||||
|
width: calc(100vw - 24px);
|
||||||
|
right: 12px;
|
||||||
|
left: 12px;
|
||||||
|
max-height: 50vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -136,6 +136,122 @@
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Recent Boards Section - Horizontal Scroll */
|
||||||
|
.recent-boards-section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-boards-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for recent boards */
|
||||||
|
.recent-boards-row::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-boards-row::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-boards-row::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-boards-row::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-board-card {
|
||||||
|
flex: 0 0 200px;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-board-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
border-color: #dee2e6;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-board-screenshot {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
background: #e9ecef;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-board-screenshot img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-board-screenshot .placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #adb5bd;
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-board-info {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-board-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #212529;
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-board-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6c757d;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-boards-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-boards-empty-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -438,6 +554,7 @@ html.dark .dashboard-container {
|
||||||
|
|
||||||
.dashboard-header,
|
.dashboard-header,
|
||||||
.starred-boards-section,
|
.starred-boards-section,
|
||||||
|
.recent-boards-section,
|
||||||
.quick-actions-section,
|
.quick-actions-section,
|
||||||
html.dark .auth-required {
|
html.dark .auth-required {
|
||||||
background: #2d2d2d;
|
background: #2d2d2d;
|
||||||
|
|
@ -460,15 +577,46 @@ html.dark .action-card p {
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-card,
|
.board-card,
|
||||||
|
.recent-board-card,
|
||||||
html.dark .action-card {
|
html.dark .action-card {
|
||||||
background: #3a3a3a;
|
background: #3a3a3a;
|
||||||
border-color: #495057;
|
border-color: #495057;
|
||||||
}
|
}
|
||||||
|
|
||||||
.board-card:hover,
|
.board-card:hover,
|
||||||
|
.recent-board-card:hover,
|
||||||
html.dark .action-card:hover {
|
html.dark .action-card:hover {
|
||||||
border-color: #6c757d;
|
border-color: #6c757d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.dark .recent-board-screenshot {
|
||||||
|
background: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .recent-board-screenshot .placeholder {
|
||||||
|
background: linear-gradient(135deg, #3a3a3a 0%, #495057 100%);
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .recent-board-title {
|
||||||
|
color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .recent-board-time {
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .recent-boards-row::-webkit-scrollbar-track {
|
||||||
|
background: #2d2d2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .recent-boards-row::-webkit-scrollbar-thumb {
|
||||||
|
background: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .recent-boards-row::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
html.dark .board-slug {
|
html.dark .board-slug {
|
||||||
background: #495057;
|
background: #495057;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,346 @@
|
||||||
|
/**
|
||||||
|
* useWallet - Hooks for Web3 wallet integration
|
||||||
|
*
|
||||||
|
* Provides functionality for:
|
||||||
|
* - Connecting/disconnecting wallets
|
||||||
|
* - Linking wallets to enCryptID accounts
|
||||||
|
* - Managing linked wallets
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
useAccount,
|
||||||
|
useConnect,
|
||||||
|
useDisconnect,
|
||||||
|
useSignMessage,
|
||||||
|
useEnsName,
|
||||||
|
useEnsAvatar,
|
||||||
|
useChainId,
|
||||||
|
} from 'wagmi';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { WORKER_URL } from '../constants/workerUrl';
|
||||||
|
import * as crypto from '../lib/auth/crypto';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type WalletType = 'eoa' | 'safe' | 'hardware' | 'contract';
|
||||||
|
|
||||||
|
export interface LinkedWallet {
|
||||||
|
id: string;
|
||||||
|
address: string;
|
||||||
|
type: WalletType;
|
||||||
|
chainId: number;
|
||||||
|
label: string | null;
|
||||||
|
ensName: string | null;
|
||||||
|
ensAvatar: string | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
linkedAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkWalletResult {
|
||||||
|
success: boolean;
|
||||||
|
wallet?: LinkedWallet;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Message Generation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the message that must be signed to link a wallet
|
||||||
|
*/
|
||||||
|
function generateLinkMessage(
|
||||||
|
username: string,
|
||||||
|
address: string,
|
||||||
|
timestamp: string,
|
||||||
|
nonce: string
|
||||||
|
): string {
|
||||||
|
return `Link wallet to enCryptID
|
||||||
|
|
||||||
|
Account: ${username}
|
||||||
|
Wallet: ${address}
|
||||||
|
Timestamp: ${timestamp}
|
||||||
|
Nonce: ${nonce}
|
||||||
|
|
||||||
|
This signature proves you own this wallet.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// useWalletConnection - Basic wallet connection
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useWalletConnection() {
|
||||||
|
const { address, isConnected, isConnecting, connector } = useAccount();
|
||||||
|
const { connect, connectors, isPending: isConnectPending } = useConnect();
|
||||||
|
const { disconnect, isPending: isDisconnectPending } = useDisconnect();
|
||||||
|
const chainId = useChainId();
|
||||||
|
|
||||||
|
// ENS data for connected wallet
|
||||||
|
const { data: ensName } = useEnsName({ address });
|
||||||
|
const { data: ensAvatar } = useEnsAvatar({ name: ensName || undefined });
|
||||||
|
|
||||||
|
const connectWallet = useCallback((connectorId?: string) => {
|
||||||
|
const targetConnector = connectorId
|
||||||
|
? connectors.find(c => c.id === connectorId)
|
||||||
|
: connectors[0]; // Default to first connector (usually injected)
|
||||||
|
|
||||||
|
if (targetConnector) {
|
||||||
|
connect({ connector: targetConnector });
|
||||||
|
}
|
||||||
|
}, [connect, connectors]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Connection state
|
||||||
|
address,
|
||||||
|
isConnected,
|
||||||
|
isConnecting: isConnecting || isConnectPending,
|
||||||
|
chainId,
|
||||||
|
connectorName: connector?.name,
|
||||||
|
|
||||||
|
// ENS data
|
||||||
|
ensName: ensName || null,
|
||||||
|
ensAvatar: ensAvatar || null,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
connect: connectWallet,
|
||||||
|
disconnect,
|
||||||
|
isDisconnecting: isDisconnectPending,
|
||||||
|
|
||||||
|
// Available connectors
|
||||||
|
connectors: connectors.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
type: c.type,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// useWalletLink - Link wallet to enCryptID
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useWalletLink() {
|
||||||
|
const { address, isConnected } = useAccount();
|
||||||
|
const { signMessageAsync } = useSignMessage();
|
||||||
|
const { session } = useAuth();
|
||||||
|
const [isLinking, setIsLinking] = useState(false);
|
||||||
|
const [linkError, setLinkError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const linkWallet = useCallback(async (label?: string): Promise<LinkWalletResult> => {
|
||||||
|
if (!address) {
|
||||||
|
return { success: false, error: 'No wallet connected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.authed || !session.username) {
|
||||||
|
return { success: false, error: 'Not authenticated with enCryptID' };
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLinking(true);
|
||||||
|
setLinkError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate the message to sign
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const nonce = globalThis.crypto.randomUUID();
|
||||||
|
const message = generateLinkMessage(
|
||||||
|
session.username,
|
||||||
|
address,
|
||||||
|
timestamp,
|
||||||
|
nonce
|
||||||
|
);
|
||||||
|
|
||||||
|
// Request signature from wallet
|
||||||
|
const signature = await signMessageAsync({ message });
|
||||||
|
|
||||||
|
// Get public key for auth header
|
||||||
|
const publicKey = crypto.getPublicKey(session.username);
|
||||||
|
if (!publicKey) {
|
||||||
|
throw new Error('Could not get enCryptID public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to backend for verification
|
||||||
|
const response = await fetch(`${WORKER_URL}/api/wallet/link`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CryptID-PublicKey': publicKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
walletAddress: address,
|
||||||
|
signature,
|
||||||
|
message,
|
||||||
|
label,
|
||||||
|
walletType: 'eoa',
|
||||||
|
chainId: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json() as { error?: string; wallet?: LinkedWallet };
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to link wallet');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
wallet: data.wallet,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
setLinkError(errorMessage);
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
} finally {
|
||||||
|
setIsLinking(false);
|
||||||
|
}
|
||||||
|
}, [address, session.authed, session.username, signMessageAsync]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
isConnected,
|
||||||
|
isLinking,
|
||||||
|
linkError,
|
||||||
|
linkWallet,
|
||||||
|
clearError: () => setLinkError(null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// useLinkedWallets - Manage linked wallets
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export function useLinkedWallets() {
|
||||||
|
const { session } = useAuth();
|
||||||
|
const [wallets, setWallets] = useState<LinkedWallet[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch linked wallets
|
||||||
|
const fetchWallets = useCallback(async () => {
|
||||||
|
if (!session.authed || !session.username) {
|
||||||
|
setWallets([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = crypto.getPublicKey(session.username);
|
||||||
|
if (!publicKey) {
|
||||||
|
setError('Could not get enCryptID public key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WORKER_URL}/api/wallet/list`, {
|
||||||
|
headers: {
|
||||||
|
'X-CryptID-PublicKey': publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json() as { error?: string; wallets?: LinkedWallet[] };
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to fetch wallets');
|
||||||
|
}
|
||||||
|
|
||||||
|
setWallets(data.wallets || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [session.authed, session.username]);
|
||||||
|
|
||||||
|
// Fetch on mount and when session changes
|
||||||
|
useEffect(() => {
|
||||||
|
fetchWallets();
|
||||||
|
}, [fetchWallets]);
|
||||||
|
|
||||||
|
// Update a wallet
|
||||||
|
const updateWallet = useCallback(async (
|
||||||
|
address: string,
|
||||||
|
updates: { label?: string; isPrimary?: boolean }
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!session.username) return false;
|
||||||
|
|
||||||
|
const publicKey = crypto.getPublicKey(session.username);
|
||||||
|
if (!publicKey) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WORKER_URL}/api/wallet/${address}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CryptID-PublicKey': publicKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchWallets(); // Refresh list
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [session.username, fetchWallets]);
|
||||||
|
|
||||||
|
// Unlink a wallet
|
||||||
|
const unlinkWallet = useCallback(async (address: string): Promise<boolean> => {
|
||||||
|
if (!session.username) return false;
|
||||||
|
|
||||||
|
const publicKey = crypto.getPublicKey(session.username);
|
||||||
|
if (!publicKey) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WORKER_URL}/api/wallet/${address}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'X-CryptID-PublicKey': publicKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await fetchWallets(); // Refresh list
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [session.username, fetchWallets]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
wallets,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch: fetchWallets,
|
||||||
|
updateWallet,
|
||||||
|
unlinkWallet,
|
||||||
|
primaryWallet: wallets.find(w => w.isPrimary) || wallets[0] || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Utility functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an address for display (0x1234...5678)
|
||||||
|
*/
|
||||||
|
export function formatAddress(address: string, chars = 4): string {
|
||||||
|
if (!address) return '';
|
||||||
|
return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an address is valid
|
||||||
|
*/
|
||||||
|
export function isValidAddress(address: string): boolean {
|
||||||
|
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
// Service for per-board activity logging
|
||||||
|
|
||||||
|
export interface ActivityEntry {
|
||||||
|
id: string;
|
||||||
|
action: 'created' | 'deleted' | 'updated';
|
||||||
|
shapeType: string;
|
||||||
|
shapeId: string;
|
||||||
|
user: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardActivity {
|
||||||
|
slug: string;
|
||||||
|
entries: ActivityEntry[];
|
||||||
|
lastUpdated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ENTRIES = 100;
|
||||||
|
|
||||||
|
// Map internal shape types to friendly display names
|
||||||
|
const SHAPE_DISPLAY_NAMES: Record<string, string> = {
|
||||||
|
// Default tldraw shapes
|
||||||
|
'text': 'text',
|
||||||
|
'geo': 'shape',
|
||||||
|
'draw': 'drawing',
|
||||||
|
'arrow': 'arrow',
|
||||||
|
'note': 'sticky note',
|
||||||
|
'image': 'image',
|
||||||
|
'video': 'video',
|
||||||
|
'embed': 'embed',
|
||||||
|
'frame': 'frame',
|
||||||
|
'line': 'line',
|
||||||
|
'highlight': 'highlight',
|
||||||
|
'bookmark': 'bookmark',
|
||||||
|
'group': 'group',
|
||||||
|
// Custom shapes
|
||||||
|
'ChatBox': 'chat box',
|
||||||
|
'VideoChat': 'video chat',
|
||||||
|
'Embed': 'embed',
|
||||||
|
'Markdown': 'markdown note',
|
||||||
|
'Slide': 'slide',
|
||||||
|
'MycrozineTemplate': 'zine template',
|
||||||
|
'MycroZineGenerator': 'zine generator',
|
||||||
|
'Prompt': 'prompt',
|
||||||
|
'ObsNote': 'Obsidian note',
|
||||||
|
'Transcription': 'transcription',
|
||||||
|
'Holon': 'holon',
|
||||||
|
'HolonBrowser': 'holon browser',
|
||||||
|
'ObsidianBrowser': 'Obsidian browser',
|
||||||
|
'FathomMeetingsBrowser': 'Fathom browser',
|
||||||
|
'FathomNote': 'Fathom note',
|
||||||
|
'ImageGen': 'AI image',
|
||||||
|
'VideoGen': 'AI video',
|
||||||
|
'BlenderGen': '3D model',
|
||||||
|
'Drawfast': 'drawfast',
|
||||||
|
'Multmux': 'multmux',
|
||||||
|
'MycelialIntelligence': 'mycelial AI',
|
||||||
|
'PrivateWorkspace': 'private workspace',
|
||||||
|
'GoogleItem': 'Google item',
|
||||||
|
'Map': 'map',
|
||||||
|
'WorkflowBlock': 'workflow block',
|
||||||
|
'Calendar': 'calendar',
|
||||||
|
'CalendarEvent': 'calendar event',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get action icons
|
||||||
|
const ACTION_ICONS: Record<string, string> = {
|
||||||
|
'created': '+',
|
||||||
|
'deleted': '-',
|
||||||
|
'updated': '~',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the activity log for a board
|
||||||
|
*/
|
||||||
|
export const getActivityLog = (slug: string, limit: number = 50): ActivityEntry[] => {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(`board_activity_${slug}`);
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
const parsed: BoardActivity = JSON.parse(data);
|
||||||
|
return (parsed.entries || []).slice(0, limit);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting activity log:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an activity entry for a board
|
||||||
|
*/
|
||||||
|
export const logActivity = (
|
||||||
|
slug: string,
|
||||||
|
entry: Omit<ActivityEntry, 'id' | 'timestamp'>
|
||||||
|
): void => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = getActivityLog(slug, MAX_ENTRIES - 1);
|
||||||
|
|
||||||
|
const newEntry: ActivityEntry = {
|
||||||
|
...entry,
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add new entry at the beginning
|
||||||
|
entries.unshift(newEntry);
|
||||||
|
|
||||||
|
// Prune to max size
|
||||||
|
const prunedEntries = entries.slice(0, MAX_ENTRIES);
|
||||||
|
|
||||||
|
const data: BoardActivity = {
|
||||||
|
slug,
|
||||||
|
entries: prunedEntries,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(`board_activity_${slug}`, JSON.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error logging activity:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all activity for a board
|
||||||
|
*/
|
||||||
|
export const clearActivityLog = (slug: string): void => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(`board_activity_${slug}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing activity log:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for a shape type
|
||||||
|
*/
|
||||||
|
export const getShapeDisplayName = (shapeType: string): string => {
|
||||||
|
return SHAPE_DISPLAY_NAMES[shapeType] || shapeType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon for an action
|
||||||
|
*/
|
||||||
|
export const getActionIcon = (action: string): string => {
|
||||||
|
return ACTION_ICONS[action] || '?';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format timestamp as relative time
|
||||||
|
*/
|
||||||
|
export const formatActivityTime = (timestamp: number): string => {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (seconds < 60) {
|
||||||
|
return 'Just now';
|
||||||
|
} else if (minutes < 60) {
|
||||||
|
return `${minutes}m ago`;
|
||||||
|
} else if (hours < 24) {
|
||||||
|
return `${hours}h ago`;
|
||||||
|
} else if (days === 1) {
|
||||||
|
return 'Yesterday';
|
||||||
|
} else if (days < 7) {
|
||||||
|
return `${days}d ago`;
|
||||||
|
} else {
|
||||||
|
return new Date(timestamp).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an activity entry as a human-readable string
|
||||||
|
*/
|
||||||
|
export const formatActivityEntry = (entry: ActivityEntry): string => {
|
||||||
|
const shapeName = getShapeDisplayName(entry.shapeType);
|
||||||
|
const action = entry.action === 'created' ? 'added' :
|
||||||
|
entry.action === 'deleted' ? 'deleted' :
|
||||||
|
'updated';
|
||||||
|
|
||||||
|
return `${entry.user} ${action} ${shapeName}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group activity entries by date
|
||||||
|
*/
|
||||||
|
export const groupActivitiesByDate = (entries: ActivityEntry[]): Map<string, ActivityEntry[]> => {
|
||||||
|
const groups = new Map<string, ActivityEntry[]>();
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryDate = new Date(entry.timestamp);
|
||||||
|
entryDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
let groupKey: string;
|
||||||
|
if (entryDate.getTime() === today.getTime()) {
|
||||||
|
groupKey = 'Today';
|
||||||
|
} else if (entryDate.getTime() === yesterday.getTime()) {
|
||||||
|
groupKey = 'Yesterday';
|
||||||
|
} else {
|
||||||
|
groupKey = entryDate.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groups.has(groupKey)) {
|
||||||
|
groups.set(groupKey, []);
|
||||||
|
}
|
||||||
|
groups.get(groupKey)!.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
|
@ -107,8 +107,11 @@ export function getClientConfig(): ClientConfig {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the worker API URL for proxied requests
|
* Get the worker API URL for proxied requests
|
||||||
* In production, this will be the same origin as the app
|
* Uses centralized WORKER_URL configuration based on VITE_WORKER_ENV:
|
||||||
* In development, we need to use the worker's dev port
|
* - local: localhost:5172
|
||||||
|
* - dev: jeffemmett-canvas-dev.jeffemmett.workers.dev
|
||||||
|
* - staging: jeffemmett-canvas-dev.jeffemmett.workers.dev
|
||||||
|
* - production: jeffemmett-canvas.jeffemmett.workers.dev
|
||||||
*/
|
*/
|
||||||
export function getWorkerApiUrl(): string {
|
export function getWorkerApiUrl(): string {
|
||||||
// Check for explicit worker URL override (useful for development)
|
// Check for explicit worker URL override (useful for development)
|
||||||
|
|
@ -117,14 +120,18 @@ export function getWorkerApiUrl(): string {
|
||||||
return workerUrl
|
return workerUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, use same origin (worker is served from same domain)
|
// Determine worker URL based on VITE_WORKER_ENV
|
||||||
if (typeof window !== 'undefined' && window.location.hostname !== 'localhost') {
|
// This mirrors the logic in src/constants/workerUrl.ts
|
||||||
return '' // Empty string = same origin
|
const workerEnv = import.meta.env.VITE_WORKER_ENV || 'production'
|
||||||
|
|
||||||
|
const workerUrls: Record<string, string> = {
|
||||||
|
local: typeof window !== 'undefined' ? `http://${window.location.hostname}:5172` : 'http://localhost:5172',
|
||||||
|
dev: 'https://jeffemmett-canvas-automerge-dev.jeffemmett.workers.dev',
|
||||||
|
staging: 'https://jeffemmett-canvas-automerge-dev.jeffemmett.workers.dev',
|
||||||
|
production: 'https://jeffemmett-canvas.jeffemmett.workers.dev'
|
||||||
}
|
}
|
||||||
|
|
||||||
// In development, use the worker dev server
|
return workerUrls[workerEnv] || workerUrls.production
|
||||||
// Default to port 5172 as configured in wrangler.toml
|
|
||||||
return 'http://localhost:5172'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
// Service for managing visited boards history
|
||||||
|
|
||||||
|
export interface VisitedBoard {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
visitedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VisitedBoardsData {
|
||||||
|
boards: VisitedBoard[];
|
||||||
|
lastUpdated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_HISTORY_SIZE = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get visited boards for a user
|
||||||
|
*/
|
||||||
|
export const getVisitedBoards = (username: string): VisitedBoard[] => {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = localStorage.getItem(`visited_boards_${username}`);
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
const parsed: VisitedBoardsData = JSON.parse(data);
|
||||||
|
return parsed.boards || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting visited boards:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a board visit - adds or updates the visit timestamp
|
||||||
|
*/
|
||||||
|
export const recordBoardVisit = (username: string, slug: string, title?: string): void => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let boards = getVisitedBoards(username);
|
||||||
|
|
||||||
|
// Remove existing entry if present (we'll re-add at the front)
|
||||||
|
boards = boards.filter(board => board.slug !== slug);
|
||||||
|
|
||||||
|
// Add new visit at the beginning
|
||||||
|
const newVisit: VisitedBoard = {
|
||||||
|
slug,
|
||||||
|
title: title || slug,
|
||||||
|
visitedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
boards.unshift(newVisit);
|
||||||
|
|
||||||
|
// Prune to max size
|
||||||
|
if (boards.length > MAX_HISTORY_SIZE) {
|
||||||
|
boards = boards.slice(0, MAX_HISTORY_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
const data: VisitedBoardsData = {
|
||||||
|
boards,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(`visited_boards_${username}`, JSON.stringify(data));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error recording board visit:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recently visited boards sorted by visit time (most recent first)
|
||||||
|
*/
|
||||||
|
export const getRecentlyVisitedBoards = (username: string, limit: number = 10): VisitedBoard[] => {
|
||||||
|
const boards = getVisitedBoards(username);
|
||||||
|
return boards
|
||||||
|
.sort((a, b) => b.visitedAt - a.visitedAt)
|
||||||
|
.slice(0, limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a single board from visit history
|
||||||
|
*/
|
||||||
|
export const removeFromHistory = (username: string, slug: string): boolean => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const boards = getVisitedBoards(username);
|
||||||
|
const filteredBoards = boards.filter(board => board.slug !== slug);
|
||||||
|
|
||||||
|
if (filteredBoards.length === boards.length) {
|
||||||
|
return false; // Board wasn't in history
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
const data: VisitedBoardsData = {
|
||||||
|
boards: filteredBoards,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(`visited_boards_${username}`, JSON.stringify(data));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing from history:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the title for a visited board (useful when board title is loaded later)
|
||||||
|
*/
|
||||||
|
export const updateVisitedBoardTitle = (username: string, slug: string, title: string): void => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const boards = getVisitedBoards(username);
|
||||||
|
const boardIndex = boards.findIndex(board => board.slug === slug);
|
||||||
|
|
||||||
|
if (boardIndex !== -1) {
|
||||||
|
boards[boardIndex].title = title;
|
||||||
|
|
||||||
|
const data: VisitedBoardsData = {
|
||||||
|
boards,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem(`visited_boards_${username}`, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating visited board title:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a timestamp as relative time (e.g., "2 hours ago", "Yesterday")
|
||||||
|
*/
|
||||||
|
export const formatRelativeTime = (timestamp: number): string => {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (seconds < 60) {
|
||||||
|
return 'Just now';
|
||||||
|
} else if (minutes < 60) {
|
||||||
|
return `${minutes}m ago`;
|
||||||
|
} else if (hours < 24) {
|
||||||
|
return `${hours}h ago`;
|
||||||
|
} else if (days === 1) {
|
||||||
|
return 'Yesterday';
|
||||||
|
} else if (days < 7) {
|
||||||
|
return `${days}d ago`;
|
||||||
|
} else {
|
||||||
|
return new Date(timestamp).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
/**
|
||||||
|
* Web3Provider - Wagmi + WalletConnect configuration
|
||||||
|
*
|
||||||
|
* Provides wallet connection capabilities to the application.
|
||||||
|
* Wraps wagmi's WagmiProvider with React Query for data fetching.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import { WagmiProvider, createConfig, http } from 'wagmi';
|
||||||
|
import { mainnet, optimism, arbitrum, base, polygon } from 'wagmi/chains';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { injected } from 'wagmi/connectors';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Configuration
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// WalletConnect Project ID - get one at https://cloud.walletconnect.com/
|
||||||
|
// Only include WalletConnect if a valid project ID is provided
|
||||||
|
const WALLETCONNECT_PROJECT_ID = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID as string | undefined;
|
||||||
|
const hasValidWalletConnectId = WALLETCONNECT_PROJECT_ID &&
|
||||||
|
WALLETCONNECT_PROJECT_ID !== 'YOUR_PROJECT_ID' &&
|
||||||
|
WALLETCONNECT_PROJECT_ID.length > 10;
|
||||||
|
|
||||||
|
// Log WalletConnect status in dev
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
if (hasValidWalletConnectId) {
|
||||||
|
console.log('[Web3Provider] WalletConnect enabled');
|
||||||
|
} else {
|
||||||
|
console.warn('[Web3Provider] WalletConnect disabled - no valid VITE_WALLETCONNECT_PROJECT_ID');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supported chains
|
||||||
|
const chains = [mainnet, optimism, arbitrum, base, polygon] as const;
|
||||||
|
|
||||||
|
// Create wagmi config - only include injected wallet connector
|
||||||
|
// WalletConnect is disabled until a valid project ID is configured
|
||||||
|
const config = createConfig({
|
||||||
|
chains,
|
||||||
|
connectors: [
|
||||||
|
// Injected wallets (MetaMask, Coinbase Wallet, etc.) - always available
|
||||||
|
injected({
|
||||||
|
shimDisconnect: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
transports: {
|
||||||
|
[mainnet.id]: http(),
|
||||||
|
[optimism.id]: http(),
|
||||||
|
[arbitrum.id]: http(),
|
||||||
|
[base.id]: http(),
|
||||||
|
[polygon.id]: http(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create React Query client
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
// Don't refetch on window focus for wallet data
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
// Cache wallet data for 30 seconds
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Provider Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface Web3ProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Web3Provider({ children }: Web3ProviderProps) {
|
||||||
|
return (
|
||||||
|
<WagmiProvider config={config}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</WagmiProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Exports
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Export config for use in hooks
|
||||||
|
export { config };
|
||||||
|
|
||||||
|
// Re-export chains for convenience
|
||||||
|
export { mainnet, optimism, arbitrum, base, polygon };
|
||||||
|
|
@ -47,6 +47,9 @@ import { ImageGenShape } from "@/shapes/ImageGenShapeUtil"
|
||||||
import { ImageGenTool } from "@/tools/ImageGenTool"
|
import { ImageGenTool } from "@/tools/ImageGenTool"
|
||||||
import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
|
import { VideoGenShape } from "@/shapes/VideoGenShapeUtil"
|
||||||
import { VideoGenTool } from "@/tools/VideoGenTool"
|
import { VideoGenTool } from "@/tools/VideoGenTool"
|
||||||
|
// Blender 3D generation
|
||||||
|
import { BlenderGenShape } from "@/shapes/BlenderGenShapeUtil"
|
||||||
|
import { BlenderGenTool } from "@/tools/BlenderGenTool"
|
||||||
// Drawfast - dev only
|
// Drawfast - dev only
|
||||||
import { DrawfastShape } from "@/shapes/DrawfastShapeUtil"
|
import { DrawfastShape } from "@/shapes/DrawfastShapeUtil"
|
||||||
import { DrawfastTool } from "@/tools/DrawfastTool"
|
import { DrawfastTool } from "@/tools/DrawfastTool"
|
||||||
|
|
@ -154,7 +157,10 @@ function sanitizeIndex(index: any): IndexKey {
|
||||||
const collections: Collection[] = [GraphLayoutCollection]
|
const collections: Collection[] = [GraphLayoutCollection]
|
||||||
import { useAuth } from "../context/AuthContext"
|
import { useAuth } from "../context/AuthContext"
|
||||||
import { updateLastVisited } from "../lib/starredBoards"
|
import { updateLastVisited } from "../lib/starredBoards"
|
||||||
|
import { recordBoardVisit } from "../lib/visitedBoards"
|
||||||
import { captureBoardScreenshot } from "../lib/screenshotService"
|
import { captureBoardScreenshot } from "../lib/screenshotService"
|
||||||
|
import { logActivity } from "../lib/activityLogger"
|
||||||
|
import { ActivityPanel, ActivityToggleButton } from "../components/ActivityPanel"
|
||||||
|
|
||||||
import { WORKER_URL } from "../constants/workerUrl"
|
import { WORKER_URL } from "../constants/workerUrl"
|
||||||
|
|
||||||
|
|
@ -176,6 +182,7 @@ const customShapeUtils = [
|
||||||
FathomNoteShape, // Individual Fathom meeting notes created from FathomMeetingsBrowser
|
FathomNoteShape, // Individual Fathom meeting notes created from FathomMeetingsBrowser
|
||||||
ImageGenShape,
|
ImageGenShape,
|
||||||
VideoGenShape,
|
VideoGenShape,
|
||||||
|
BlenderGenShape, // Blender 3D procedural generation
|
||||||
...(ENABLE_DRAWFAST ? [DrawfastShape] : []), // Drawfast - dev only
|
...(ENABLE_DRAWFAST ? [DrawfastShape] : []), // Drawfast - dev only
|
||||||
MultmuxShape,
|
MultmuxShape,
|
||||||
MycelialIntelligenceShape, // AI-powered collaborative intelligence shape
|
MycelialIntelligenceShape, // AI-powered collaborative intelligence shape
|
||||||
|
|
@ -202,6 +209,7 @@ const customTools = [
|
||||||
FathomMeetingsTool,
|
FathomMeetingsTool,
|
||||||
ImageGenTool,
|
ImageGenTool,
|
||||||
VideoGenTool,
|
VideoGenTool,
|
||||||
|
BlenderGenTool, // Blender 3D procedural generation
|
||||||
...(ENABLE_DRAWFAST ? [DrawfastTool] : []), // Drawfast - dev only
|
...(ENABLE_DRAWFAST ? [DrawfastTool] : []), // Drawfast - dev only
|
||||||
MultmuxTool,
|
MultmuxTool,
|
||||||
PrivateWorkspaceTool,
|
PrivateWorkspaceTool,
|
||||||
|
|
@ -537,6 +545,7 @@ export function Board() {
|
||||||
const automergeHandle = (storeWithHandle as any).handle
|
const automergeHandle = (storeWithHandle as any).handle
|
||||||
const { connectionState, isNetworkOnline } = storeWithHandle
|
const { connectionState, isNetworkOnline } = storeWithHandle
|
||||||
const [editor, setEditor] = useState<Editor | null>(null)
|
const [editor, setEditor] = useState<Editor | null>(null)
|
||||||
|
const [isActivityPanelOpen, setIsActivityPanelOpen] = useState(false)
|
||||||
|
|
||||||
// Update read-only state when permission changes after editor is mounted
|
// Update read-only state when permission changes after editor is mounted
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1050,10 +1059,11 @@ export function Board() {
|
||||||
};
|
};
|
||||||
}, [editor, session.authed, session.username]);
|
}, [editor, session.authed, session.username]);
|
||||||
|
|
||||||
// Track board visit for starred boards
|
// Track board visit for starred boards and visit history
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session.authed && session.username && roomId) {
|
if (session.authed && session.username && roomId) {
|
||||||
updateLastVisited(session.username, roomId);
|
updateLastVisited(session.username, roomId);
|
||||||
|
recordBoardVisit(session.username, roomId);
|
||||||
}
|
}
|
||||||
}, [session.authed, session.username, roomId]);
|
}, [session.authed, session.username, roomId]);
|
||||||
|
|
||||||
|
|
@ -1118,6 +1128,80 @@ export function Board() {
|
||||||
};
|
};
|
||||||
}, [editor, roomId, store.store]);
|
}, [editor, roomId, store.store]);
|
||||||
|
|
||||||
|
// Activity logging - track shape creates, deletes, and significant updates
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || !roomId || !store.store) return;
|
||||||
|
|
||||||
|
const username = session.username || 'Anonymous';
|
||||||
|
|
||||||
|
// Track which shapes we've logged updates for (to debounce)
|
||||||
|
const recentUpdates = new Map<string, number>();
|
||||||
|
const UPDATE_DEBOUNCE_MS = 2000;
|
||||||
|
|
||||||
|
const unsubscribe = store.store.listen(({ changes, source }) => {
|
||||||
|
// Only track user actions, not remote sync
|
||||||
|
if (source !== 'user') return;
|
||||||
|
|
||||||
|
// Log created shapes
|
||||||
|
for (const record of Object.values(changes.added)) {
|
||||||
|
if (record.typeName === 'shape') {
|
||||||
|
logActivity(roomId, {
|
||||||
|
action: 'created',
|
||||||
|
shapeType: (record as any).type,
|
||||||
|
shapeId: record.id,
|
||||||
|
user: username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log deleted shapes
|
||||||
|
for (const record of Object.values(changes.removed)) {
|
||||||
|
if (record.typeName === 'shape') {
|
||||||
|
logActivity(roomId, {
|
||||||
|
action: 'deleted',
|
||||||
|
shapeType: (record as any).type,
|
||||||
|
shapeId: record.id,
|
||||||
|
user: username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log significant updates (debounced to avoid logging every tiny movement)
|
||||||
|
for (const [before, after] of Object.values(changes.updated)) {
|
||||||
|
if (before.typeName === 'shape' && after.typeName === 'shape') {
|
||||||
|
const shapeId = after.id;
|
||||||
|
const now = Date.now();
|
||||||
|
const lastUpdate = recentUpdates.get(shapeId) || 0;
|
||||||
|
|
||||||
|
// Only log if we haven't logged this shape recently
|
||||||
|
if (now - lastUpdate > UPDATE_DEBOUNCE_MS) {
|
||||||
|
// Check if this is a significant update (not just position)
|
||||||
|
const beforeShape = before as any;
|
||||||
|
const afterShape = after as any;
|
||||||
|
|
||||||
|
// Compare props (content changes) - skip if only x/y/rotation changed
|
||||||
|
const beforeProps = JSON.stringify(beforeShape.props || {});
|
||||||
|
const afterProps = JSON.stringify(afterShape.props || {});
|
||||||
|
|
||||||
|
if (beforeProps !== afterProps) {
|
||||||
|
recentUpdates.set(shapeId, now);
|
||||||
|
logActivity(roomId, {
|
||||||
|
action: 'updated',
|
||||||
|
shapeType: afterShape.type,
|
||||||
|
shapeId: shapeId,
|
||||||
|
user: username,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { source: "user", scope: "document" });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [editor, roomId, store.store, session.username]);
|
||||||
|
|
||||||
// TLDraw has built-in undo/redo that works with the store
|
// TLDraw has built-in undo/redo that works with the store
|
||||||
// No need for custom undo/redo manager - TLDraw handles it automatically
|
// No need for custom undo/redo manager - TLDraw handles it automatically
|
||||||
|
|
||||||
|
|
@ -1409,6 +1493,18 @@ export function Board() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
*/}
|
*/}
|
||||||
|
{/* Activity Panel Toggle Button */}
|
||||||
|
<div style={{ position: 'fixed', top: 12, right: 12, zIndex: 999 }}>
|
||||||
|
<ActivityToggleButton
|
||||||
|
onClick={() => setIsActivityPanelOpen(!isActivityPanelOpen)}
|
||||||
|
isActive={isActivityPanelOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Activity Panel */}
|
||||||
|
<ActivityPanel
|
||||||
|
isOpen={isActivityPanelOpen}
|
||||||
|
onClose={() => setIsActivityPanelOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</LiveImageProvider>
|
</LiveImageProvider>
|
||||||
</ConnectionProvider>
|
</ConnectionProvider>
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,27 @@ import { Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useNotifications } from '../context/NotificationContext';
|
import { useNotifications } from '../context/NotificationContext';
|
||||||
import { getStarredBoards, unstarBoard, StarredBoard } from '../lib/starredBoards';
|
import { getStarredBoards, unstarBoard, StarredBoard } from '../lib/starredBoards';
|
||||||
|
import { getRecentlyVisitedBoards, VisitedBoard, formatRelativeTime } from '../lib/visitedBoards';
|
||||||
import { getBoardScreenshot, removeBoardScreenshot } from '../lib/screenshotService';
|
import { getBoardScreenshot, removeBoardScreenshot } from '../lib/screenshotService';
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const { session } = useAuth();
|
const { session } = useAuth();
|
||||||
const { addNotification } = useNotifications();
|
const { addNotification } = useNotifications();
|
||||||
const [starredBoards, setStarredBoards] = useState<StarredBoard[]>([]);
|
const [starredBoards, setStarredBoards] = useState<StarredBoard[]>([]);
|
||||||
|
const [recentBoards, setRecentBoards] = useState<VisitedBoard[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Note: We don't redirect automatically - let the component show auth required message
|
// Note: We don't redirect automatically - let the component show auth required message
|
||||||
|
|
||||||
// Load starred boards
|
// Load starred boards and recent visits
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session.authed && session.username) {
|
if (session.authed && session.username) {
|
||||||
const boards = getStarredBoards(session.username);
|
const starred = getStarredBoards(session.username);
|
||||||
setStarredBoards(boards);
|
setStarredBoards(starred);
|
||||||
|
|
||||||
|
const recent = getRecentlyVisitedBoards(session.username, 10);
|
||||||
|
setRecentBoards(recent);
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [session.authed, session.username]);
|
}, [session.authed, session.username]);
|
||||||
|
|
@ -73,6 +79,52 @@ export function Dashboard() {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="dashboard-content">
|
<div className="dashboard-content">
|
||||||
|
{/* Last Visited Section */}
|
||||||
|
<section className="recent-boards-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<h2>Last Visited</h2>
|
||||||
|
<span className="board-count">{recentBoards.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="loading">Loading...</div>
|
||||||
|
) : recentBoards.length === 0 ? (
|
||||||
|
<div className="recent-boards-empty">
|
||||||
|
<div className="recent-boards-empty-icon">🕐</div>
|
||||||
|
<p>No recently visited boards yet. Start exploring!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="recent-boards-row">
|
||||||
|
{recentBoards.map((board) => {
|
||||||
|
const screenshot = getBoardScreenshot(board.slug);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={board.slug}
|
||||||
|
to={`/board/${board.slug}/`}
|
||||||
|
className="recent-board-card"
|
||||||
|
>
|
||||||
|
<div className="recent-board-screenshot">
|
||||||
|
{screenshot ? (
|
||||||
|
<img
|
||||||
|
src={screenshot.dataUrl}
|
||||||
|
alt={`Screenshot of ${board.title}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="placeholder">📋</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="recent-board-info">
|
||||||
|
<h4 className="recent-board-title">{board.title}</h4>
|
||||||
|
<p className="recent-board-time">{formatRelativeTime(board.visitedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Starred Boards Section */}
|
||||||
<section className="starred-boards-section">
|
<section className="starred-boards-section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Starred Boards</h2>
|
<h2>Starred Boards</h2>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export const LinkDevice: React.FC = () => {
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setStatus('success');
|
setStatus('success');
|
||||||
setCryptidUsername(result.cryptidUsername || '');
|
setCryptidUsername(result.cryptidUsername || '');
|
||||||
setMessage('This device has been linked to your CryptID account!');
|
setMessage('This device has been linked to your enCryptID account!');
|
||||||
|
|
||||||
// Set the session - user is now logged in
|
// Set the session - user is now logged in
|
||||||
if (result.cryptidUsername) {
|
if (result.cryptidUsername) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,693 @@
|
||||||
|
import {
|
||||||
|
BaseBoxShapeUtil,
|
||||||
|
Geometry2d,
|
||||||
|
HTMLContainer,
|
||||||
|
Rectangle2d,
|
||||||
|
TLBaseShape,
|
||||||
|
} from "tldraw"
|
||||||
|
import React, { useState } from "react"
|
||||||
|
import { getWorkerApiUrl } from "@/lib/clientConfig"
|
||||||
|
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
|
||||||
|
import { usePinnedToView } from "@/hooks/usePinnedToView"
|
||||||
|
import { useMaximize } from "@/hooks/useMaximize"
|
||||||
|
|
||||||
|
// Blender render presets
|
||||||
|
type BlenderPreset = 'abstract' | 'geometric' | 'landscape' | 'text3d' | 'particles'
|
||||||
|
|
||||||
|
// Individual render entry in the history
|
||||||
|
interface GeneratedRender {
|
||||||
|
id: string
|
||||||
|
prompt: string
|
||||||
|
preset: BlenderPreset
|
||||||
|
imageUrl: string
|
||||||
|
timestamp: number
|
||||||
|
renderTime?: number
|
||||||
|
seed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type IBlenderGen = TLBaseShape<
|
||||||
|
"BlenderGen",
|
||||||
|
{
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
prompt: string
|
||||||
|
preset: BlenderPreset
|
||||||
|
complexity: number // 1-10
|
||||||
|
text3dContent: string // For text3d preset
|
||||||
|
seed: number | null // For reproducibility
|
||||||
|
renderHistory: GeneratedRender[]
|
||||||
|
isLoading: boolean
|
||||||
|
loadingPrompt: string | null
|
||||||
|
progress: number // 0-100
|
||||||
|
error: string | null
|
||||||
|
tags: string[]
|
||||||
|
pinnedToView: boolean
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
export class BlenderGenShape extends BaseBoxShapeUtil<IBlenderGen> {
|
||||||
|
static override type = "BlenderGen" as const
|
||||||
|
|
||||||
|
// Blender theme color: Orange/3D
|
||||||
|
static readonly PRIMARY_COLOR = "#E87D0D"
|
||||||
|
|
||||||
|
MIN_WIDTH = 320 as const
|
||||||
|
MIN_HEIGHT = 400 as const
|
||||||
|
DEFAULT_WIDTH = 420 as const
|
||||||
|
DEFAULT_HEIGHT = 500 as const
|
||||||
|
|
||||||
|
getDefaultProps(): IBlenderGen["props"] {
|
||||||
|
return {
|
||||||
|
w: this.DEFAULT_WIDTH,
|
||||||
|
h: this.DEFAULT_HEIGHT,
|
||||||
|
prompt: "",
|
||||||
|
preset: "abstract",
|
||||||
|
complexity: 5,
|
||||||
|
text3dContent: "",
|
||||||
|
seed: null,
|
||||||
|
renderHistory: [],
|
||||||
|
isLoading: false,
|
||||||
|
loadingPrompt: null,
|
||||||
|
progress: 0,
|
||||||
|
error: null,
|
||||||
|
tags: ['3d', 'blender', 'render'],
|
||||||
|
pinnedToView: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getGeometry(shape: IBlenderGen): Geometry2d {
|
||||||
|
return new Rectangle2d({
|
||||||
|
width: Math.max(shape.props.w, 1),
|
||||||
|
height: Math.max(shape.props.h, 1),
|
||||||
|
isFilled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
component(shape: IBlenderGen) {
|
||||||
|
const editor = this.editor
|
||||||
|
const isSelected = editor.getSelectedShapeIds().includes(shape.id)
|
||||||
|
|
||||||
|
usePinnedToView(editor, shape.id, shape.props.pinnedToView)
|
||||||
|
|
||||||
|
const { isMaximized, toggleMaximize } = useMaximize({
|
||||||
|
editor: editor,
|
||||||
|
shapeId: shape.id,
|
||||||
|
currentW: shape.props.w,
|
||||||
|
currentH: shape.props.h,
|
||||||
|
shapeType: 'BlenderGen',
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePinToggle = () => {
|
||||||
|
editor.updateShape<IBlenderGen>({
|
||||||
|
id: shape.id,
|
||||||
|
type: "BlenderGen",
|
||||||
|
props: { pinnedToView: !shape.props.pinnedToView },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets: { id: BlenderPreset; label: string; icon: string; description: string }[] = [
|
||||||
|
{ id: 'abstract', label: 'Abstract', icon: '🔮', description: 'Random geometric shapes' },
|
||||||
|
{ id: 'geometric', label: 'Geometric', icon: '📐', description: 'Grid-based patterns' },
|
||||||
|
{ id: 'landscape', label: 'Landscape', icon: '🏔️', description: 'Procedural terrain' },
|
||||||
|
{ id: 'text3d', label: '3D Text', icon: 'Aa', description: 'Metallic 3D text' },
|
||||||
|
{ id: 'particles', label: 'Particles', icon: '✨', description: 'Particle effects' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const generateRender = async () => {
|
||||||
|
const prompt = shape.props.preset === 'text3d'
|
||||||
|
? shape.props.text3dContent || 'BLENDER'
|
||||||
|
: shape.props.prompt || shape.props.preset
|
||||||
|
|
||||||
|
editor.updateShape<IBlenderGen>({
|
||||||
|
id: shape.id,
|
||||||
|
type: "BlenderGen",
|
||||||
|
props: {
|
||||||
|
error: null,
|
||||||
|
isLoading: true,
|
||||||
|
loadingPrompt: prompt,
|
||||||
|
progress: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workerUrl = getWorkerApiUrl()
|
||||||
|
const url = `${workerUrl}/api/blender/render`
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
preset: shape.props.preset,
|
||||||
|
text: shape.props.preset === 'text3d' ? (shape.props.text3dContent || 'BLENDER') : undefined,
|
||||||
|
complexity: shape.props.complexity,
|
||||||
|
seed: shape.props.seed,
|
||||||
|
resolution: "1920x1080",
|
||||||
|
samples: 64,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(`HTTP error! status: ${response.status} - ${errorText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { imageUrl?: string; renderTime?: number; seed?: number; error?: string }
|
||||||
|
|
||||||
|
if (data.imageUrl) {
|
||||||
|
const currentShape = editor.getShape<IBlenderGen>(shape.id)
|
||||||
|
const currentHistory = currentShape?.props.renderHistory || []
|
||||||
|
|
||||||
|
const newRender: GeneratedRender = {
|
||||||
|
id: `render-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
prompt: prompt,
|
||||||
|
preset: shape.props.preset,
|
||||||
|
imageUrl: data.imageUrl,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
renderTime: data.renderTime,
|
||||||
|
seed: data.seed,
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.updateShape<IBlenderGen>({
|
||||||
|
id: shape.id,
|
||||||
|
type: "BlenderGen",
|
||||||
|
props: {
|
||||||
|
renderHistory: [newRender, ...currentHistory],
|
||||||
|
isLoading: false,
|
||||||
|
loadingPrompt: null,
|
||||||
|
progress: 100,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (data.error) {
|
||||||
|
throw new Error(data.error)
|
||||||
|
} else {
|
||||||
|
throw new Error("No image returned from Blender API")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error("BlenderGen: Error:", errorMessage)
|
||||||
|
|
||||||
|
editor.updateShape<IBlenderGen>({
|
||||||
|
id: shape.id,
|
||||||
|
type: "BlenderGen",
|
||||||
|
props: {
|
||||||
|
isLoading: false,
|
||||||
|
loadingPrompt: null,
|
||||||
|
progress: 0,
|
||||||
|
error: `Render failed: ${errorMessage}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
if (!shape.props.isLoading) {
|
||||||
|
generateRender()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [isMinimized, setIsMinimized] = useState(false)
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
editor.deleteShape(shape.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMinimize = () => {
|
||||||
|
setIsMinimized(!isMinimized)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTagsChange = (newTags: string[]) => {
|
||||||
|
editor.updateShape<IBlenderGen>({
|
||||||
|
id: shape.id,
|
||||||
|
type: "BlenderGen",
|
||||||
|
props: { tags: newTags },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HTMLContainer id={shape.id}>
|
||||||
|
<StandardizedToolWrapper
|
||||||
|
title="🎬 Blender 3D"
|
||||||
|
primaryColor={BlenderGenShape.PRIMARY_COLOR}
|
||||||
|
isSelected={isSelected}
|
||||||
|
width={shape.props.w}
|
||||||
|
height={shape.props.h}
|
||||||
|
onClose={handleClose}
|
||||||
|
onMinimize={handleMinimize}
|
||||||
|
isMinimized={isMinimized}
|
||||||
|
onMaximize={toggleMaximize}
|
||||||
|
isMaximized={isMaximized}
|
||||||
|
editor={editor}
|
||||||
|
shapeId={shape.id}
|
||||||
|
tags={shape.props.tags || []}
|
||||||
|
onTagsChange={handleTagsChange}
|
||||||
|
tagsEditable={true}
|
||||||
|
isPinnedToView={shape.props.pinnedToView}
|
||||||
|
onPinToggle={handlePinToggle}
|
||||||
|
headerContent={
|
||||||
|
shape.props.isLoading ? (
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
🎬 Blender 3D
|
||||||
|
<span style={{
|
||||||
|
marginLeft: 'auto',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: BlenderGenShape.PRIMARY_COLOR,
|
||||||
|
animation: 'pulse 1.5s ease-in-out infinite'
|
||||||
|
}}>
|
||||||
|
Rendering...
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: '12px',
|
||||||
|
gap: '12px',
|
||||||
|
overflow: 'auto',
|
||||||
|
backgroundColor: '#1a1a1a'
|
||||||
|
}}>
|
||||||
|
{/* Preset Selection */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '6px',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
{presets.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
editor.updateShape<IBlenderGen>({
|
||||||
|
id: shape.id,
|
||||||
|
type: "BlenderGen",
|
||||||
|
props: { preset: preset.id },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
flex: '1 1 auto',
|
||||||
|
minWidth: '70px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
backgroundColor: shape.props.preset === preset.id ? BlenderGenShape.PRIMARY_COLOR : '#2a2a2a',
|
||||||
|
border: shape.props.preset === preset.id ? `2px solid ${BlenderGenShape.PRIMARY_COLOR}` : '2px solid #3a3a3a',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: shape.props.preset === preset.id ? '#fff' : '#aaa',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '2px',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
title={preset.description}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '16px' }}>{preset.icon}</span>
|
||||||
|
<span>{preset.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text3D Input (shown only for text3d preset) */}
|
||||||
|
{shape.props.preset === 'text3d' && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
<label style={{ fontSize: '11px', color: '#888', fontWeight: 500 }}>
|
||||||
|
3D Text Content
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#2a2a2a',
|
||||||
|
border: '1px solid #3a3a3a',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#fff',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
placeholder="Enter text to render in 3D..."
|
||||||
|
value={shape.props.text3dContent}
|
||||||
|
onChange={(e) => {
|
||||||
|
editor.updateShape<IBlenderGen>({
|
||||||
|
id: shape.id,
|
||||||
|
type: "BlenderGen",
|
||||||
|
props: { text3dContent: e.target.value },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.key === 'Enter' && !shape.props.isLoading) {
|
||||||
|
handleGenerate()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Complexity Slider */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<label style={{ fontSize: '11px', color: '#888', fontWeight: 500 }}>
|
||||||
|
Complexity
|
||||||
|
</label>
|
||||||
|
<span style={{ fontSize: '11px', color: '#666' }}>
|
||||||
|
{shape.props.complexity}/10
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={shape.props.complexity}
|
||||||
|
onChange={(e) => {
|
||||||
|
editor.updateShape<IBlenderGen>({
|
||||||
|
id: shape.id,
|
||||||
|
type: "BlenderGen",
|
||||||
|
props: { complexity: parseInt(e.target.value) },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
accentColor: BlenderGenShape.PRIMARY_COLOR,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Render Button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleGenerate()
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
disabled={shape.props.isLoading}
|
||||||
|
style={{
|
||||||
|
padding: '12px 20px',
|
||||||
|
backgroundColor: shape.props.isLoading ? '#444' : BlenderGenShape.PRIMARY_COLOR,
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: shape.props.isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#fff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
opacity: shape.props.isLoading ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{shape.props.isLoading ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
border: "2px solid rgba(255,255,255,0.3)",
|
||||||
|
borderTop: "2px solid #fff",
|
||||||
|
borderRadius: "50%",
|
||||||
|
animation: "spin 1s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Rendering...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>🎬</span>
|
||||||
|
Render 3D Scene
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Render History */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '12px',
|
||||||
|
overflow: 'auto',
|
||||||
|
minHeight: 0,
|
||||||
|
}}>
|
||||||
|
{/* Loading State */}
|
||||||
|
{shape.props.isLoading && (
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: '1px solid #3a3a3a',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: '24px',
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
border: "4px solid #3a3a3a",
|
||||||
|
borderTop: `4px solid ${BlenderGenShape.PRIMARY_COLOR}`,
|
||||||
|
borderRadius: "50%",
|
||||||
|
animation: "spin 1s linear infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: "#aaa", fontSize: "14px" }}>
|
||||||
|
Rendering 3D scene...
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#666", fontSize: "11px" }}>
|
||||||
|
This may take 30-60 seconds
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{shape.props.loadingPrompt && (
|
||||||
|
<div style={{
|
||||||
|
borderTop: '1px solid #3a3a3a',
|
||||||
|
padding: '8px 10px',
|
||||||
|
backgroundColor: '#222',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#888',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontWeight: 500, color: '#666' }}>Preset: </span>
|
||||||
|
{shape.props.preset}
|
||||||
|
{shape.props.preset === 'text3d' && ` - "${shape.props.loadingPrompt}"`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Render History */}
|
||||||
|
{shape.props.renderHistory.map((render, index) => (
|
||||||
|
<div
|
||||||
|
key={render.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
borderRadius: "6px",
|
||||||
|
overflow: "hidden",
|
||||||
|
border: index === 0 && !shape.props.isLoading ? `2px solid ${BlenderGenShape.PRIMARY_COLOR}` : '1px solid #3a3a3a',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
maxHeight: index === 0 ? '250px' : '120px',
|
||||||
|
backgroundColor: '#1a1a1a',
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
src={render.imageUrl}
|
||||||
|
alt={render.prompt}
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
maxHeight: "100%",
|
||||||
|
objectFit: "contain",
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
const newHistory = shape.props.renderHistory.filter(r => r.id !== render.id)
|
||||||
|
editor.updateShape<IBlenderGen>({
|
||||||
|
id: shape.id,
|
||||||
|
type: "BlenderGen",
|
||||||
|
props: { renderHistory: newHistory },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
borderTop: '1px solid #3a3a3a',
|
||||||
|
padding: '8px 10px',
|
||||||
|
backgroundColor: '#222',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '6px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#888',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
<span><strong>Preset:</strong> {render.preset}</span>
|
||||||
|
{render.seed && <span><strong>Seed:</strong> {render.seed}</span>}
|
||||||
|
{render.renderTime && <span><strong>Time:</strong> {render.renderTime}s</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '6px' }}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = render.imageUrl
|
||||||
|
link.download = `blender-${render.preset}-${render.timestamp}.png`
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '6px 10px',
|
||||||
|
backgroundColor: BlenderGenShape.PRIMARY_COLOR,
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: '#fff',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>⬇️</span> Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const newHistory = shape.props.renderHistory.filter(r => r.id !== render.id)
|
||||||
|
editor.updateShape<IBlenderGen>({
|
||||||
|
id: shape.id,
|
||||||
|
type: "BlenderGen",
|
||||||
|
props: { renderHistory: newHistory },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
backgroundColor: '#333',
|
||||||
|
border: '1px solid #444',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#999',
|
||||||
|
}}
|
||||||
|
title="Remove from history"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{shape.props.renderHistory.length === 0 && !shape.props.isLoading && !shape.props.error && (
|
||||||
|
<div style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#666",
|
||||||
|
fontSize: "13px",
|
||||||
|
border: '1px solid #3a3a3a',
|
||||||
|
minHeight: '120px',
|
||||||
|
gap: '8px',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '32px' }}>🎬</span>
|
||||||
|
<span>Select a preset and click Render</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{shape.props.error && (
|
||||||
|
<div style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
backgroundColor: "#3a2020",
|
||||||
|
border: "1px solid #5a3030",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#f88",
|
||||||
|
fontSize: "12px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: "8px",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: "14px" }}>⚠️</span>
|
||||||
|
<span style={{ flex: 1 }}>{shape.props.error}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
editor.updateShape<IBlenderGen>({
|
||||||
|
id: shape.id,
|
||||||
|
type: "BlenderGen",
|
||||||
|
props: { error: null },
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
padding: "2px 6px",
|
||||||
|
backgroundColor: "#5a3030",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "10px",
|
||||||
|
color: "#f88",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</StandardizedToolWrapper>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override indicator(shape: IBlenderGen) {
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
width={shape.props.w}
|
||||||
|
height={shape.props.h}
|
||||||
|
rx={6}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { BaseBoxShapeTool, TLEventHandlers } from 'tldraw'
|
||||||
|
|
||||||
|
export class BlenderGenTool extends BaseBoxShapeTool {
|
||||||
|
static override id = 'BlenderGen'
|
||||||
|
static override initial = 'idle'
|
||||||
|
override shapeType = 'BlenderGen'
|
||||||
|
|
||||||
|
override onComplete: TLEventHandlers["onComplete"] = () => {
|
||||||
|
this.editor.setCurrentTool('select')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,11 +15,13 @@ import {
|
||||||
cameraHistory,
|
cameraHistory,
|
||||||
} from "./cameraUtils"
|
} from "./cameraUtils"
|
||||||
|
|
||||||
// Feature flags - disable experimental features in production
|
// Feature flags - enable experimental features in dev/staging, disable in production
|
||||||
const IS_PRODUCTION = import.meta.env.PROD
|
// Use VITE_WORKER_ENV to determine environment (staging is NOT production)
|
||||||
const ENABLE_DRAWFAST = !IS_PRODUCTION // Drawfast - dev only
|
const WORKER_ENV = import.meta.env.VITE_WORKER_ENV || 'production'
|
||||||
const ENABLE_CALENDAR = !IS_PRODUCTION // Calendar - dev only
|
const IS_PRODUCTION_ONLY = WORKER_ENV === 'production' // Only true for actual production
|
||||||
const ENABLE_WORKFLOW = !IS_PRODUCTION // Workflow - dev only
|
const ENABLE_DRAWFAST = !IS_PRODUCTION_ONLY // Drawfast - dev/staging only
|
||||||
|
const ENABLE_CALENDAR = !IS_PRODUCTION_ONLY // Calendar - dev/staging only
|
||||||
|
const ENABLE_WORKFLOW = !IS_PRODUCTION_ONLY // Workflow - dev/staging only
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { saveToPdf } from "../utils/pdfUtils"
|
import { saveToPdf } from "../utils/pdfUtils"
|
||||||
import { TLFrameShape } from "tldraw"
|
import { TLFrameShape } from "tldraw"
|
||||||
|
|
@ -135,6 +137,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
|
||||||
<TldrawUiMenuItem {...tools.ChatBox} />
|
<TldrawUiMenuItem {...tools.ChatBox} />
|
||||||
<TldrawUiMenuItem {...tools.ImageGen} />
|
<TldrawUiMenuItem {...tools.ImageGen} />
|
||||||
<TldrawUiMenuItem {...tools.VideoGen} />
|
<TldrawUiMenuItem {...tools.VideoGen} />
|
||||||
|
<TldrawUiMenuItem {...tools.BlenderGen} />
|
||||||
{ENABLE_DRAWFAST && <TldrawUiMenuItem {...tools.Drawfast} />}
|
{ENABLE_DRAWFAST && <TldrawUiMenuItem {...tools.Drawfast} />}
|
||||||
<TldrawUiMenuItem {...tools.Markdown} />
|
<TldrawUiMenuItem {...tools.Markdown} />
|
||||||
<TldrawUiMenuItem {...tools.ObsidianNote} />
|
<TldrawUiMenuItem {...tools.ObsidianNote} />
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,13 @@ import { FathomMeetingsPanel } from "../components/FathomMeetingsPanel"
|
||||||
// Workflow Builder palette
|
// Workflow Builder palette
|
||||||
import WorkflowPalette from "../components/workflow/WorkflowPalette"
|
import WorkflowPalette from "../components/workflow/WorkflowPalette"
|
||||||
|
|
||||||
// Feature flags - disable experimental features in production
|
// Feature flags - enable experimental features in dev/staging, disable in production
|
||||||
const IS_PRODUCTION = import.meta.env.PROD
|
// Use VITE_WORKER_ENV to determine environment (staging is NOT production)
|
||||||
const ENABLE_WORKFLOW = !IS_PRODUCTION // Workflow blocks - dev only
|
const WORKER_ENV = import.meta.env.VITE_WORKER_ENV || 'production'
|
||||||
const ENABLE_CALENDAR = !IS_PRODUCTION // Calendar - dev only
|
const IS_PRODUCTION_ONLY = WORKER_ENV === 'production' // Only true for actual production
|
||||||
const ENABLE_DRAWFAST = !IS_PRODUCTION // Drawfast - dev only
|
const ENABLE_WORKFLOW = !IS_PRODUCTION_ONLY // Workflow blocks - dev/staging only
|
||||||
|
const ENABLE_CALENDAR = !IS_PRODUCTION_ONLY // Calendar - dev/staging only
|
||||||
|
const ENABLE_DRAWFAST = !IS_PRODUCTION_ONLY // Drawfast - dev/staging only
|
||||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from "../lib/fathomApiKey"
|
||||||
import { getMyConnections, updateEdgeMetadata, createConnection, removeConnection, updateTrustLevel } from "../lib/networking/connectionService"
|
import { getMyConnections, updateEdgeMetadata, createConnection, removeConnection, updateTrustLevel } from "../lib/networking/connectionService"
|
||||||
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from "../lib/networking/types"
|
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from "../lib/networking/types"
|
||||||
|
|
@ -771,6 +773,14 @@ export function CustomToolbar() {
|
||||||
isSelected={tools["VideoGen"].id === editor.getCurrentToolId()}
|
isSelected={tools["VideoGen"].id === editor.getCurrentToolId()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{tools["BlenderGen"] && (
|
||||||
|
<TldrawUiMenuItem
|
||||||
|
{...tools["BlenderGen"]}
|
||||||
|
icon="box"
|
||||||
|
label="Blender 3D"
|
||||||
|
isSelected={tools["BlenderGen"].id === editor.getCurrentToolId()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{ENABLE_DRAWFAST && tools["Drawfast"] && (
|
{ENABLE_DRAWFAST && tools["Drawfast"] && (
|
||||||
<TldrawUiMenuItem
|
<TldrawUiMenuItem
|
||||||
{...tools["Drawfast"]}
|
{...tools["Drawfast"]}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export const TOUR_STEPS: TourStep[] = [
|
||||||
{
|
{
|
||||||
id: 'cryptid-login',
|
id: 'cryptid-login',
|
||||||
title: 'Encrypted Identity',
|
title: 'Encrypted Identity',
|
||||||
content: 'Sign in with CryptID for end-to-end encrypted sync across devices. Your password never leaves your browser - we use cryptographic keys instead.',
|
content: 'Sign in with enCryptID for end-to-end encrypted sync across devices. Your password never leaves your browser - we use cryptographic keys instead.',
|
||||||
targetSelector: '.cryptid-dropdown-trigger',
|
targetSelector: '.cryptid-dropdown-trigger',
|
||||||
fallbackPosition: { top: 60, left: window.innerWidth - 200 },
|
fallbackPosition: { top: 60, left: window.innerWidth - 200 },
|
||||||
placement: 'bottom-left',
|
placement: 'bottom-left',
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyCo
|
||||||
import { linkEmailToAccount, checkEmailStatus, type LookupResult } from "../lib/auth/cryptidEmailService"
|
import { linkEmailToAccount, checkEmailStatus, type LookupResult } from "../lib/auth/cryptidEmailService"
|
||||||
import { GoogleDataService, type GoogleService, type ShareableItem } from "../lib/google"
|
import { GoogleDataService, type GoogleService, type ShareableItem } from "../lib/google"
|
||||||
import { GoogleExportBrowser } from "../components/GoogleExportBrowser"
|
import { GoogleExportBrowser } from "../components/GoogleExportBrowser"
|
||||||
|
import { WalletLinkPanel } from "../components/WalletLinkPanel"
|
||||||
|
|
||||||
// AI tool model configurations
|
// AI tool model configurations
|
||||||
const AI_TOOLS = [
|
const AI_TOOLS = [
|
||||||
|
|
@ -411,9 +412,9 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use
|
||||||
|
|
||||||
<div className="settings-divider" />
|
<div className="settings-divider" />
|
||||||
|
|
||||||
{/* CryptID Account Section */}
|
{/* enCryptID Account Section */}
|
||||||
<h3 style={{ fontSize: '14px', fontWeight: '600', marginBottom: '12px', color: colors.text }}>
|
<h3 style={{ fontSize: '14px', fontWeight: '600', marginBottom: '12px', color: colors.text }}>
|
||||||
CryptID Account
|
enCryptID Account
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{session.authed && session.username ? (
|
{session.authed && session.username ? (
|
||||||
|
|
@ -432,7 +433,7 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use
|
||||||
{session.username}
|
{session.username}
|
||||||
</span>
|
</span>
|
||||||
<p style={{ fontSize: '11px', color: colors.textMuted, marginTop: '2px' }}>
|
<p style={{ fontSize: '11px', color: colors.textMuted, marginTop: '2px' }}>
|
||||||
Your CryptID username - cryptographically secured
|
Your enCryptID username - cryptographically secured
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -540,7 +541,7 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p style={{ fontSize: '12px', color: colors.warningText }}>
|
<p style={{ fontSize: '12px', color: colors.warningText }}>
|
||||||
Sign in to manage your CryptID account settings
|
Sign in to manage your enCryptID account settings
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -977,6 +978,25 @@ export function UserSettingsModal({ onClose, isDarkMode, onToggleDarkMode }: Use
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="settings-divider" />
|
||||||
|
|
||||||
|
{/* Web3 Wallet Section */}
|
||||||
|
<h3 style={{ fontSize: '14px', fontWeight: '600', marginBottom: '12px', marginTop: '8px', color: colors.text }}>
|
||||||
|
Web3 Wallets
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0',
|
||||||
|
backgroundColor: colors.cardBg,
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: `1px solid ${colors.cardBorder}`,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WalletLinkPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Future Integrations Placeholder */}
|
{/* Future Integrations Placeholder */}
|
||||||
<div style={{ marginTop: '16px', padding: '12px', backgroundColor: colors.legendBg, borderRadius: '6px', border: `1px dashed ${colors.cardBorder}` }}>
|
<div style={{ marginTop: '16px', padding: '12px', backgroundColor: colors.legendBg, borderRadius: '6px', border: `1px dashed ${colors.cardBorder}` }}>
|
||||||
<p style={{ fontSize: '12px', color: colors.textMuted, textAlign: 'center' }}>
|
<p style={{ fontSize: '12px', color: colors.textMuted, textAlign: 'center' }}>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,12 @@ import { ISlideShape } from "@/shapes/SlideShapeUtil"
|
||||||
import { getEdge } from "@/propagators/tlgraph"
|
import { getEdge } from "@/propagators/tlgraph"
|
||||||
import { llm, getApiKey } from "@/utils/llmUtils"
|
import { llm, getApiKey } from "@/utils/llmUtils"
|
||||||
|
|
||||||
|
// Feature flags - must match Board.tsx to prevent tool registration mismatch
|
||||||
|
const IS_PRODUCTION = import.meta.env.PROD
|
||||||
|
const ENABLE_WORKFLOW = !IS_PRODUCTION // Workflow blocks - dev only
|
||||||
|
const ENABLE_CALENDAR = !IS_PRODUCTION // Calendar - dev only
|
||||||
|
const ENABLE_DRAWFAST = !IS_PRODUCTION // Drawfast - dev only
|
||||||
|
|
||||||
export const overrides: TLUiOverrides = {
|
export const overrides: TLUiOverrides = {
|
||||||
tools(editor, tools) {
|
tools(editor, tools) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -222,14 +228,17 @@ export const overrides: TLUiOverrides = {
|
||||||
readonlyOk: true,
|
readonlyOk: true,
|
||||||
onSelect: () => editor.setCurrentTool("VideoGen"),
|
onSelect: () => editor.setCurrentTool("VideoGen"),
|
||||||
},
|
},
|
||||||
Drawfast: {
|
// Drawfast - only available in dev (must match ENABLE_DRAWFAST flag in Board.tsx)
|
||||||
id: "Drawfast",
|
...(ENABLE_DRAWFAST ? {
|
||||||
icon: "tool-pencil",
|
Drawfast: {
|
||||||
label: "Drawfast (AI Sketch)",
|
id: "Drawfast",
|
||||||
kbd: "ctrl+shift+d",
|
icon: "tool-pencil",
|
||||||
readonlyOk: true,
|
label: "Drawfast (AI Sketch)",
|
||||||
onSelect: () => editor.setCurrentTool("Drawfast"),
|
kbd: "ctrl+shift+d",
|
||||||
},
|
readonlyOk: true,
|
||||||
|
onSelect: () => editor.setCurrentTool("Drawfast"),
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
Multmux: {
|
Multmux: {
|
||||||
id: "Multmux",
|
id: "Multmux",
|
||||||
icon: "terminal",
|
icon: "terminal",
|
||||||
|
|
@ -246,21 +255,27 @@ export const overrides: TLUiOverrides = {
|
||||||
readonlyOk: true,
|
readonlyOk: true,
|
||||||
onSelect: () => editor.setCurrentTool("map"),
|
onSelect: () => editor.setCurrentTool("map"),
|
||||||
},
|
},
|
||||||
calendar: {
|
// Calendar - only available in dev (must match ENABLE_CALENDAR flag in Board.tsx)
|
||||||
id: "calendar",
|
...(ENABLE_CALENDAR ? {
|
||||||
icon: "calendar",
|
calendar: {
|
||||||
label: "Calendar",
|
id: "calendar",
|
||||||
kbd: "ctrl+alt+k",
|
icon: "calendar",
|
||||||
readonlyOk: true,
|
label: "Calendar",
|
||||||
onSelect: () => editor.setCurrentTool("calendar"),
|
kbd: "ctrl+alt+k",
|
||||||
},
|
readonlyOk: true,
|
||||||
WorkflowBlock: {
|
onSelect: () => editor.setCurrentTool("calendar"),
|
||||||
id: "WorkflowBlock",
|
},
|
||||||
icon: "sticker",
|
} : {}),
|
||||||
label: "Workflow Block",
|
// WorkflowBlock - only available in dev (must match ENABLE_WORKFLOW flag in Board.tsx)
|
||||||
readonlyOk: true,
|
...(ENABLE_WORKFLOW ? {
|
||||||
onSelect: () => editor.setCurrentTool("WorkflowBlock"),
|
WorkflowBlock: {
|
||||||
},
|
id: "WorkflowBlock",
|
||||||
|
icon: "sticker",
|
||||||
|
label: "Workflow Block",
|
||||||
|
readonlyOk: true,
|
||||||
|
onSelect: () => editor.setCurrentTool("WorkflowBlock"),
|
||||||
|
},
|
||||||
|
} : {}),
|
||||||
// MycelialIntelligence removed - now a permanent UI bar (MycelialIntelligenceBar.tsx)
|
// MycelialIntelligence removed - now a permanent UI bar (MycelialIntelligenceBar.tsx)
|
||||||
hand: {
|
hand: {
|
||||||
...tools.hand,
|
...tools.hand,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,11 @@ export class AutomergeDurableObject {
|
||||||
// Flag to enable/disable CRDT sync (for gradual rollout)
|
// Flag to enable/disable CRDT sync (for gradual rollout)
|
||||||
// ENABLED: Automerge WASM now works with fixed import path
|
// ENABLED: Automerge WASM now works with fixed import path
|
||||||
private useCrdtSync: boolean = true
|
private useCrdtSync: boolean = true
|
||||||
|
// Maximum shape count for CRDT sync - documents larger than this use JSON sync
|
||||||
|
// to avoid CPU timeout during Automerge binary conversion
|
||||||
|
// With Automerge.from() optimization, testing higher threshold
|
||||||
|
// 7,495 shapes caused CPU timeout with init()+change(), trying 5000 with from()
|
||||||
|
private static readonly CRDT_SYNC_MAX_SHAPES = 5000
|
||||||
// Tombstone tracking - keeps track of deleted shape IDs to prevent resurrection
|
// Tombstone tracking - keeps track of deleted shape IDs to prevent resurrection
|
||||||
// When a shape is deleted, its ID is added here and persisted to R2
|
// When a shape is deleted, its ID is added here and persisted to R2
|
||||||
// This prevents offline clients from resurrecting deleted shapes
|
// This prevents offline clients from resurrecting deleted shapes
|
||||||
|
|
@ -358,17 +363,25 @@ export class AutomergeDurableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize CRDT sync manager if not already done
|
// Initialize CRDT sync manager if not already done
|
||||||
|
// First, check document size - large documents use JSON sync to avoid CPU timeout
|
||||||
if (this.useCrdtSync && !this.syncManager) {
|
if (this.useCrdtSync && !this.syncManager) {
|
||||||
console.log(`🔧 Initializing CRDT sync manager for room ${this.roomId}`)
|
// Quick check: estimate document size from legacy JSON before initializing CRDT
|
||||||
this.syncManager = new AutomergeSyncManager(this.r2, this.roomId)
|
const docEstimate = await this.estimateDocumentSize()
|
||||||
try {
|
if (docEstimate.shapeCount > AutomergeDurableObject.CRDT_SYNC_MAX_SHAPES) {
|
||||||
await this.syncManager.initialize()
|
console.log(`⚠️ Document too large for CRDT sync (${docEstimate.shapeCount} shapes > ${AutomergeDurableObject.CRDT_SYNC_MAX_SHAPES} max), using JSON sync`)
|
||||||
console.log(`✅ CRDT sync manager initialized (${this.syncManager.getShapeCount()} shapes)`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ Failed to initialize CRDT sync manager:`, error)
|
|
||||||
// Disable CRDT sync on initialization failure
|
|
||||||
this.useCrdtSync = false
|
this.useCrdtSync = false
|
||||||
this.syncManager = null
|
} else {
|
||||||
|
console.log(`🔧 Initializing CRDT sync manager for room ${this.roomId} (${docEstimate.shapeCount} shapes)`)
|
||||||
|
this.syncManager = new AutomergeSyncManager(this.r2, this.roomId)
|
||||||
|
try {
|
||||||
|
await this.syncManager.initialize()
|
||||||
|
console.log(`✅ CRDT sync manager initialized (${this.syncManager.getShapeCount()} shapes)`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Failed to initialize CRDT sync manager:`, error)
|
||||||
|
// Disable CRDT sync on initialization failure
|
||||||
|
this.useCrdtSync = false
|
||||||
|
this.syncManager = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -852,6 +865,55 @@ export class AutomergeDurableObject {
|
||||||
this.schedulePersistToR2()
|
this.schedulePersistToR2()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick estimate of document size without full CRDT conversion
|
||||||
|
* Used to decide whether to use CRDT sync or fall back to JSON sync
|
||||||
|
*/
|
||||||
|
private async estimateDocumentSize(): Promise<{ shapeCount: number; recordCount: number }> {
|
||||||
|
if (!this.roomId) {
|
||||||
|
return { shapeCount: 0, recordCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try legacy JSON first (faster to check)
|
||||||
|
const legacyObject = await this.r2.get(`rooms/${this.roomId}`)
|
||||||
|
if (legacyObject) {
|
||||||
|
const text = await legacyObject.text()
|
||||||
|
const doc = JSON.parse(text)
|
||||||
|
|
||||||
|
// Handle different document formats
|
||||||
|
let store: Record<string, any> = {}
|
||||||
|
if (doc.store) {
|
||||||
|
store = doc.store
|
||||||
|
} else if (Array.isArray(doc) && doc[0]?.type === 'store') {
|
||||||
|
// Array format with store in first element
|
||||||
|
store = doc[0]?.value || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordCount = Object.keys(store).length
|
||||||
|
const shapeCount = Object.values(store).filter((r: any) => r?.typeName === 'shape').length
|
||||||
|
|
||||||
|
console.log(`📊 Document size estimate: ${shapeCount} shapes, ${recordCount} records`)
|
||||||
|
return { shapeCount, recordCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no legacy JSON, check automerge metadata without loading full binary
|
||||||
|
const metadataObject = await this.r2.get(`rooms/${this.roomId}/metadata.json`)
|
||||||
|
if (metadataObject) {
|
||||||
|
const text = await metadataObject.text()
|
||||||
|
const metadata = JSON.parse(text)
|
||||||
|
const shapeCount = parseInt(metadata.shapeCount || '0', 10)
|
||||||
|
const recordCount = parseInt(metadata.recordCount || '0', 10)
|
||||||
|
console.log(`📊 Document size from metadata: ${shapeCount} shapes, ${recordCount} records`)
|
||||||
|
return { shapeCount, recordCount }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error estimating document size:`, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { shapeCount: 0, recordCount: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
async getDocument() {
|
async getDocument() {
|
||||||
if (!this.roomId) throw new Error("Missing roomId")
|
if (!this.roomId) throw new Error("Missing roomId")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
import { Automerge, initializeAutomerge } from './automerge-init'
|
import { Automerge, initializeAutomerge } from './automerge-init'
|
||||||
|
|
||||||
// TLDraw store snapshot type (simplified - actual type is more complex)
|
// TLDraw store snapshot type (simplified - actual type is more complex)
|
||||||
|
// Index signature required for Automerge.Doc generic constraint
|
||||||
export interface TLStoreSnapshot {
|
export interface TLStoreSnapshot {
|
||||||
store: Record<string, any>
|
store: Record<string, any>
|
||||||
schema?: {
|
schema?: {
|
||||||
|
|
@ -21,6 +22,7 @@ export interface TLStoreSnapshot {
|
||||||
storeVersion: number
|
storeVersion: number
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -136,31 +138,43 @@ export class AutomergeR2Storage {
|
||||||
/**
|
/**
|
||||||
* Migrate a JSON document to Automerge format
|
* Migrate a JSON document to Automerge format
|
||||||
* Used for upgrading existing rooms from JSON to Automerge
|
* Used for upgrading existing rooms from JSON to Automerge
|
||||||
|
*
|
||||||
|
* OPTIMIZATION: Uses Automerge.from() instead of init() + change()
|
||||||
|
* For large documents, batches records to avoid CPU timeout
|
||||||
*/
|
*/
|
||||||
async migrateFromJson(roomId: string, jsonDoc: TLStoreSnapshot): Promise<Automerge.Doc<TLStoreSnapshot> | null> {
|
async migrateFromJson(roomId: string, jsonDoc: TLStoreSnapshot): Promise<Automerge.Doc<TLStoreSnapshot> | null> {
|
||||||
await initializeAutomerge()
|
await initializeAutomerge()
|
||||||
|
|
||||||
console.log(`🔄 Migrating room ${roomId} from JSON to Automerge format`)
|
const recordCount = jsonDoc.store ? Object.keys(jsonDoc.store).length : 0
|
||||||
|
console.log(`🔄 Migrating room ${roomId} from JSON to Automerge format (${recordCount} records)`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a new Automerge document
|
const startTime = Date.now()
|
||||||
let doc = Automerge.init<TLStoreSnapshot>()
|
|
||||||
|
|
||||||
// Apply the JSON data as a change
|
// Use Automerge.from() for direct initialization - more efficient than init() + change()
|
||||||
doc = Automerge.change(doc, 'Migrate from JSON', (d) => {
|
// This creates the document with initial state in one operation
|
||||||
d.store = jsonDoc.store || {}
|
const initialState: TLStoreSnapshot = {
|
||||||
if (jsonDoc.schema) {
|
store: jsonDoc.store || {},
|
||||||
d.schema = jsonDoc.schema
|
...(jsonDoc.schema && { schema: jsonDoc.schema })
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
// Automerge.from() is optimized for creating documents from existing state
|
||||||
|
// Type assertion needed because TLStoreSnapshot doesn't have index signature
|
||||||
|
const doc = Automerge.from(initialState as unknown as Record<string, unknown>) as Automerge.Doc<TLStoreSnapshot>
|
||||||
|
|
||||||
|
const conversionTime = Date.now() - startTime
|
||||||
|
console.log(`⏱️ Automerge conversion took ${conversionTime}ms for ${recordCount} records`)
|
||||||
|
|
||||||
// Save to R2
|
// Save to R2
|
||||||
|
const saveStart = Date.now()
|
||||||
const saved = await this.saveDocument(roomId, doc)
|
const saved = await this.saveDocument(roomId, doc)
|
||||||
|
const saveTime = Date.now() - saveStart
|
||||||
|
|
||||||
if (!saved) {
|
if (!saved) {
|
||||||
throw new Error('Failed to save migrated document')
|
throw new Error('Failed to save migrated document')
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ Successfully migrated room ${roomId} to Automerge format`)
|
console.log(`✅ Successfully migrated room ${roomId} to Automerge format (conversion: ${conversionTime}ms, save: ${saveTime}ms)`)
|
||||||
return doc
|
return doc
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ Error migrating room ${roomId} to Automerge:`, error)
|
console.error(`❌ Error migrating room ${roomId} to Automerge:`, error)
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ async function sendEmail(
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
from: env.CRYPTID_EMAIL_FROM || 'CryptID <noreply@jeffemmett.com>',
|
from: env.CRYPTID_EMAIL_FROM || 'enCryptID <noreply@jeffemmett.com>',
|
||||||
to: [to],
|
to: [to],
|
||||||
subject,
|
subject,
|
||||||
html: htmlContent,
|
html: htmlContent,
|
||||||
|
|
@ -115,7 +115,7 @@ export async function handleLinkEmail(
|
||||||
|
|
||||||
if (existingUser && existingUser.cryptid_username !== cryptidUsername) {
|
if (existingUser && existingUser.cryptid_username !== cryptidUsername) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
error: 'Email already linked to a different CryptID account'
|
error: 'Email already linked to a different enCryptID account'
|
||||||
}), {
|
}), {
|
||||||
status: 409,
|
status: 409,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -175,10 +175,10 @@ export async function handleLinkEmail(
|
||||||
const emailSent = await sendEmail(
|
const emailSent = await sendEmail(
|
||||||
env,
|
env,
|
||||||
email,
|
email,
|
||||||
'Verify your CryptID email',
|
'Verify your enCryptID email',
|
||||||
`
|
`
|
||||||
<h2>Verify your CryptID email</h2>
|
<h2>Verify your enCryptID email</h2>
|
||||||
<p>Click the link below to verify your email address for CryptID: <strong>${cryptidUsername}</strong></p>
|
<p>Click the link below to verify your email address for enCryptID: <strong>${cryptidUsername}</strong></p>
|
||||||
<p><a href="${verifyUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Verify Email</a></p>
|
<p><a href="${verifyUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Verify Email</a></p>
|
||||||
<p>Or copy this link: ${verifyUrl}</p>
|
<p>Or copy this link: ${verifyUrl}</p>
|
||||||
<p>This link expires in 24 hours.</p>
|
<p>This link expires in 24 hours.</p>
|
||||||
|
|
@ -310,7 +310,7 @@ export async function handleRequestDeviceLink(
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
error: 'No verified CryptID account found for this email'
|
error: 'No verified enCryptID account found for this email'
|
||||||
}), {
|
}), {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -351,10 +351,10 @@ export async function handleRequestDeviceLink(
|
||||||
const emailSent = await sendEmail(
|
const emailSent = await sendEmail(
|
||||||
env,
|
env,
|
||||||
email,
|
email,
|
||||||
'Link new device to your CryptID',
|
'Link new device to your enCryptID',
|
||||||
`
|
`
|
||||||
<h2>New Device Link Request</h2>
|
<h2>New Device Link Request</h2>
|
||||||
<p>Someone is trying to link a new device to your CryptID: <strong>${user.cryptid_username}</strong></p>
|
<p>Someone is trying to link a new device to your enCryptID: <strong>${user.cryptid_username}</strong></p>
|
||||||
<p><strong>Device:</strong> ${deviceName || 'New Device'}</p>
|
<p><strong>Device:</strong> ${deviceName || 'New Device'}</p>
|
||||||
<p>If this was you, click the button below to approve:</p>
|
<p>If this was you, click the button below to approve:</p>
|
||||||
<p><a href="${linkUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Approve Device</a></p>
|
<p><a href="${linkUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Approve Device</a></p>
|
||||||
|
|
@ -527,7 +527,7 @@ export async function handleSendBackupEmail(
|
||||||
const emailSent = await sendEmail(
|
const emailSent = await sendEmail(
|
||||||
env,
|
env,
|
||||||
email,
|
email,
|
||||||
`Set up CryptID "${username}" on another device`,
|
`Set up enCryptID "${username}" on another device`,
|
||||||
`
|
`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -549,13 +549,13 @@ export async function handleSendBackupEmail(
|
||||||
<body>
|
<body>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="icon">🔐</div>
|
<div class="icon">🔐</div>
|
||||||
<h1>Welcome to CryptID</h1>
|
<h1>Welcome to enCryptID</h1>
|
||||||
<p>Your passwordless account is ready!</p>
|
<p>Your passwordless account is ready!</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>Hi <strong>${username}</strong>,</p>
|
<p>Hi <strong>${username}</strong>,</p>
|
||||||
|
|
||||||
<p>Your CryptID account has been created on your current device. To access your account from another device (like your phone), follow these steps:</p>
|
<p>Your enCryptID account has been created on your current device. To access your account from another device (like your phone), follow these steps:</p>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="step">
|
<div class="step">
|
||||||
|
|
@ -590,9 +590,9 @@ export async function handleSendBackupEmail(
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>This email was sent because you created a CryptID account and opted for multi-device backup.</p>
|
<p>This email was sent because you created an enCryptID account and opted for multi-device backup.</p>
|
||||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||||
<p style="margin-top: 16px;">CryptID by <a href="${appUrl}" style="color: #8b5cf6;">jeffemmett.com</a></p>
|
<p style="margin-top: 16px;">enCryptID by <a href="${appUrl}" style="color: #8b5cf6;">jeffemmett.com</a></p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
-- Migration: Add Linked Wallets for Web3 Integration
|
||||||
|
-- Date: 2026-01-02
|
||||||
|
-- Description: Enables CryptID users to link Ethereum wallets for
|
||||||
|
-- on-chain transactions, voting, and token-gated features.
|
||||||
|
-- Related: task-007, doc-001
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- LINKED WALLETS TABLE
|
||||||
|
-- =============================================================================
|
||||||
|
-- Each CryptID user can link multiple Ethereum wallets (EOA, Safe, hardware)
|
||||||
|
-- Linking requires signature verification to prove wallet ownership
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS linked_wallets (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID for the link record
|
||||||
|
user_id TEXT NOT NULL, -- References users.id (CryptID account)
|
||||||
|
wallet_address TEXT NOT NULL, -- Ethereum address (checksummed, 0x-prefixed)
|
||||||
|
|
||||||
|
-- Wallet metadata
|
||||||
|
wallet_type TEXT DEFAULT 'eoa' CHECK (wallet_type IN ('eoa', 'safe', 'hardware', 'contract')),
|
||||||
|
chain_id INTEGER DEFAULT 1, -- Primary chain (1 = Ethereum mainnet)
|
||||||
|
label TEXT, -- User-provided label (e.g., "Main Wallet")
|
||||||
|
|
||||||
|
-- Verification proof
|
||||||
|
signature_message TEXT NOT NULL, -- The message that was signed
|
||||||
|
signature TEXT NOT NULL, -- EIP-191 personal_sign signature
|
||||||
|
verified_at TEXT NOT NULL, -- When signature was verified
|
||||||
|
|
||||||
|
-- ENS integration
|
||||||
|
ens_name TEXT, -- Resolved ENS name (if any)
|
||||||
|
ens_avatar TEXT, -- ENS avatar URL (if any)
|
||||||
|
ens_resolved_at TEXT, -- When ENS was last resolved
|
||||||
|
|
||||||
|
-- Flags
|
||||||
|
is_primary INTEGER DEFAULT 0, -- 1 = primary wallet for this user
|
||||||
|
is_active INTEGER DEFAULT 1, -- 0 = soft-deleted
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
last_used_at TEXT, -- Last time wallet was used for action
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(user_id, wallet_address) -- Can't link same wallet twice
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for efficient lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_user ON linked_wallets(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_address ON linked_wallets(wallet_address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_active ON linked_wallets(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_linked_wallets_primary ON linked_wallets(user_id, is_primary);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- WALLET LINKING TOKENS TABLE (for Safe/multisig delayed verification)
|
||||||
|
-- =============================================================================
|
||||||
|
-- For contract wallets that require on-chain signature verification
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wallet_link_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
wallet_address TEXT NOT NULL,
|
||||||
|
nonce TEXT NOT NULL, -- Random nonce for signature message
|
||||||
|
token TEXT NOT NULL UNIQUE, -- Secret token for verification callback
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wallet_link_tokens_token ON wallet_link_tokens(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wallet_link_tokens_user ON wallet_link_tokens(user_id);
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- TOKEN BALANCES CACHE (optional, for token-gating)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Cache of token balances for faster permission checks
|
||||||
|
-- Updated periodically or on-demand
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wallet_token_balances (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
wallet_address TEXT NOT NULL,
|
||||||
|
token_address TEXT NOT NULL, -- ERC-20/721/1155 contract address
|
||||||
|
token_type TEXT CHECK (token_type IN ('erc20', 'erc721', 'erc1155')),
|
||||||
|
chain_id INTEGER NOT NULL,
|
||||||
|
balance TEXT NOT NULL, -- String to handle big numbers
|
||||||
|
last_updated TEXT DEFAULT (datetime('now')),
|
||||||
|
|
||||||
|
UNIQUE(wallet_address, token_address, chain_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_balances_wallet ON wallet_token_balances(wallet_address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_balances_token ON wallet_token_balances(token_address);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_balances_chain ON wallet_token_balances(chain_id);
|
||||||
|
|
@ -23,6 +23,8 @@ export interface Environment {
|
||||||
RUNPOD_VIDEO_ENDPOINT_ID?: string;
|
RUNPOD_VIDEO_ENDPOINT_ID?: string;
|
||||||
RUNPOD_TEXT_ENDPOINT_ID?: string;
|
RUNPOD_TEXT_ENDPOINT_ID?: string;
|
||||||
RUNPOD_WHISPER_ENDPOINT_ID?: string;
|
RUNPOD_WHISPER_ENDPOINT_ID?: string;
|
||||||
|
// Blender render server URL
|
||||||
|
BLENDER_API_URL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CryptID types for auth
|
// CryptID types for auth
|
||||||
|
|
@ -230,4 +232,95 @@ export interface NetworkGraph {
|
||||||
edges: GraphEdge[];
|
edges: GraphEdge[];
|
||||||
// Current user's connections (for filtering)
|
// Current user's connections (for filtering)
|
||||||
myConnections: string[]; // User IDs I'm connected to
|
myConnections: string[]; // User IDs I'm connected to
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Linked Wallet Types (Web3 Integration)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wallet types supported for linking
|
||||||
|
* - 'eoa': Externally Owned Account (standard wallet)
|
||||||
|
* - 'safe': Safe (Gnosis Safe) multisig
|
||||||
|
* - 'hardware': Hardware wallet (via MetaMask bridge)
|
||||||
|
* - 'contract': Other smart contract wallet
|
||||||
|
*/
|
||||||
|
export type WalletType = 'eoa' | 'safe' | 'hardware' | 'contract';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linked wallet record in the database
|
||||||
|
*/
|
||||||
|
export interface LinkedWallet {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
wallet_address: string;
|
||||||
|
wallet_type: WalletType;
|
||||||
|
chain_id: number;
|
||||||
|
label: string | null;
|
||||||
|
signature_message: string;
|
||||||
|
signature: string;
|
||||||
|
verified_at: string;
|
||||||
|
ens_name: string | null;
|
||||||
|
ens_avatar: string | null;
|
||||||
|
ens_resolved_at: string | null;
|
||||||
|
is_primary: number; // SQLite boolean (0 or 1)
|
||||||
|
is_active: number; // SQLite boolean (0 or 1)
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wallet link token for delayed verification (Safe/contract wallets)
|
||||||
|
*/
|
||||||
|
export interface WalletLinkToken {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
wallet_address: string;
|
||||||
|
nonce: string;
|
||||||
|
token: string;
|
||||||
|
expires_at: string;
|
||||||
|
used: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached token balance for a wallet
|
||||||
|
*/
|
||||||
|
export interface WalletTokenBalance {
|
||||||
|
id: string;
|
||||||
|
wallet_address: string;
|
||||||
|
token_address: string;
|
||||||
|
token_type: 'erc20' | 'erc721' | 'erc1155';
|
||||||
|
chain_id: number;
|
||||||
|
balance: string; // String to handle big numbers
|
||||||
|
last_updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API response format for linked wallets
|
||||||
|
*/
|
||||||
|
export interface LinkedWalletResponse {
|
||||||
|
id: string;
|
||||||
|
address: string;
|
||||||
|
type: WalletType;
|
||||||
|
chainId: number;
|
||||||
|
label: string | null;
|
||||||
|
ensName: string | null;
|
||||||
|
ensAvatar: string | null;
|
||||||
|
isPrimary: boolean;
|
||||||
|
linkedAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for linking a wallet
|
||||||
|
*/
|
||||||
|
export interface WalletLinkRequest {
|
||||||
|
walletAddress: string;
|
||||||
|
signature: string;
|
||||||
|
message: string;
|
||||||
|
walletType?: WalletType;
|
||||||
|
chainId?: number;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,769 @@
|
||||||
|
/**
|
||||||
|
* Wallet Authentication Module
|
||||||
|
*
|
||||||
|
* Handles signature verification for linking Ethereum wallets to CryptID accounts.
|
||||||
|
* Uses EIP-191 personal_sign for EOA wallets.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Environment,
|
||||||
|
User,
|
||||||
|
LinkedWallet,
|
||||||
|
LinkedWalletResponse,
|
||||||
|
WalletLinkRequest,
|
||||||
|
WalletType
|
||||||
|
} from './types';
|
||||||
|
// @ts-ignore - noble packages have proper ESM exports
|
||||||
|
import { keccak_256 } from '@noble/hashes/sha3.js';
|
||||||
|
// @ts-ignore - noble packages have proper ESM exports
|
||||||
|
import { recoverPublicKey, Signature } from '@noble/secp256k1';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Constants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Maximum age for signature timestamps (5 minutes)
|
||||||
|
const MAX_SIGNATURE_AGE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Maximum wallets per user
|
||||||
|
const MAX_WALLETS_PER_USER = 10;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Message Generation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the message that must be signed to link a wallet
|
||||||
|
*/
|
||||||
|
export function generateLinkMessage(
|
||||||
|
username: string,
|
||||||
|
address: string,
|
||||||
|
timestamp: string,
|
||||||
|
nonce: string
|
||||||
|
): string {
|
||||||
|
return `Link wallet to enCryptID
|
||||||
|
|
||||||
|
Account: ${username}
|
||||||
|
Wallet: ${address}
|
||||||
|
Timestamp: ${timestamp}
|
||||||
|
Nonce: ${nonce}
|
||||||
|
|
||||||
|
This signature proves you own this wallet.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate a link message
|
||||||
|
*/
|
||||||
|
export function parseLinkMessage(message: string): {
|
||||||
|
username: string;
|
||||||
|
address: string;
|
||||||
|
timestamp: string;
|
||||||
|
nonce: string;
|
||||||
|
} | null {
|
||||||
|
try {
|
||||||
|
const lines = message.split('\n');
|
||||||
|
|
||||||
|
// Find the relevant lines
|
||||||
|
const accountLine = lines.find(l => l.startsWith('Account: '));
|
||||||
|
const walletLine = lines.find(l => l.startsWith('Wallet: '));
|
||||||
|
const timestampLine = lines.find(l => l.startsWith('Timestamp: '));
|
||||||
|
const nonceLine = lines.find(l => l.startsWith('Nonce: '));
|
||||||
|
|
||||||
|
if (!accountLine || !walletLine || !timestampLine || !nonceLine) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: accountLine.replace('Account: ', '').trim(),
|
||||||
|
address: walletLine.replace('Wallet: ', '').trim(),
|
||||||
|
timestamp: timestampLine.replace('Timestamp: ', '').trim(),
|
||||||
|
nonce: nonceLine.replace('Nonce: ', '').trim(),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Address Utilities
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an Ethereum address format
|
||||||
|
*/
|
||||||
|
export function isValidAddress(address: string): boolean {
|
||||||
|
return /^0x[a-fA-F0-9]{40}$/.test(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checksum an Ethereum address (EIP-55)
|
||||||
|
* This is a simplified version - in production use viem's getAddress
|
||||||
|
*/
|
||||||
|
export function checksumAddress(address: string): string {
|
||||||
|
// For now, just return lowercase - viem will handle proper checksumming
|
||||||
|
return address.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Signature Verification
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an EIP-191 personal_sign signature
|
||||||
|
*
|
||||||
|
* This uses the ecrecover approach to recover the signer address from the signature.
|
||||||
|
* For Cloudflare Workers, we need to implement this without ethers/viem dependencies
|
||||||
|
* in the worker bundle (they're too large).
|
||||||
|
*
|
||||||
|
* Instead, we use a lightweight approach with the Web Crypto API.
|
||||||
|
*/
|
||||||
|
export function verifyPersonalSignature(
|
||||||
|
address: string,
|
||||||
|
message: string,
|
||||||
|
signature: string
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
// The signature should be 65 bytes (130 hex chars + 0x prefix)
|
||||||
|
if (!signature.startsWith('0x') || signature.length !== 132) {
|
||||||
|
console.error('Invalid signature format');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For EIP-191 personal_sign, the message is prefixed with:
|
||||||
|
// "\x19Ethereum Signed Message:\n" + message.length + message
|
||||||
|
const prefix = `\x19Ethereum Signed Message:\n${message.length}`;
|
||||||
|
const prefixedMessage = prefix + message;
|
||||||
|
|
||||||
|
// Hash the prefixed message with keccak256
|
||||||
|
const messageHash = keccak256(prefixedMessage);
|
||||||
|
|
||||||
|
// Parse signature components
|
||||||
|
const r = signature.slice(2, 66);
|
||||||
|
const s = signature.slice(66, 130);
|
||||||
|
let v = parseInt(signature.slice(130, 132), 16);
|
||||||
|
|
||||||
|
// Normalize v value (some wallets use 0/1, others use 27/28)
|
||||||
|
if (v < 27) {
|
||||||
|
v += 27;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover the public key and derive the address
|
||||||
|
const recoveredAddress = ecrecover(messageHash, v, r, s);
|
||||||
|
|
||||||
|
if (!recoveredAddress) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare addresses (case-insensitive)
|
||||||
|
return recoveredAddress.toLowerCase() === address.toLowerCase();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Signature verification error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keccak256 hash implementation using @noble/hashes
|
||||||
|
*/
|
||||||
|
function keccak256(message: string | Uint8Array): Uint8Array {
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
return keccak_256(encoder.encode(message));
|
||||||
|
}
|
||||||
|
return keccak_256(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hex string to Uint8Array
|
||||||
|
*/
|
||||||
|
function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex;
|
||||||
|
const bytes = new Uint8Array(cleanHex.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = parseInt(cleanHex.substr(i * 2, 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Uint8Array to hex string
|
||||||
|
*/
|
||||||
|
function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover address from signature using @noble/secp256k1
|
||||||
|
*/
|
||||||
|
function ecrecover(
|
||||||
|
messageHash: Uint8Array,
|
||||||
|
v: number,
|
||||||
|
r: string,
|
||||||
|
s: string
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
// Convert r and s to bytes
|
||||||
|
const rBytes = hexToBytes(r);
|
||||||
|
const sBytes = hexToBytes(s);
|
||||||
|
|
||||||
|
// Combine r and s into signature (64 bytes compact format)
|
||||||
|
const signature = new Uint8Array(64);
|
||||||
|
signature.set(rBytes, 0);
|
||||||
|
signature.set(sBytes, 32);
|
||||||
|
|
||||||
|
// Recovery id is v - 27 (for non-EIP-155 signatures)
|
||||||
|
const recoveryId = v - 27;
|
||||||
|
|
||||||
|
if (recoveryId !== 0 && recoveryId !== 1) {
|
||||||
|
console.error('Invalid recovery id:', recoveryId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover the public key using @noble/secp256k1 v3 API
|
||||||
|
// The signature needs to include the recovery bit (65 bytes: r || s || v)
|
||||||
|
const recoveredSig = new Uint8Array(65);
|
||||||
|
recoveredSig.set(signature, 0);
|
||||||
|
recoveredSig[64] = recoveryId;
|
||||||
|
|
||||||
|
// prehash: false because we already hashed the message with keccak256
|
||||||
|
const pubKeyBytes = recoverPublicKey(recoveredSig, messageHash, {
|
||||||
|
prehash: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pubKeyBytes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address is last 20 bytes of keccak256(pubkey without 0x04 prefix)
|
||||||
|
// For uncompressed key (65 bytes), skip the first byte (0x04 prefix)
|
||||||
|
const pubKeyHash = keccak256(pubKeyBytes.slice(1));
|
||||||
|
const addressBytes = pubKeyHash.slice(-20);
|
||||||
|
|
||||||
|
return '0x' + bytesToHex(addressBytes);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ecrecover error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Database Operations
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a LinkedWallet database record to API response format
|
||||||
|
*/
|
||||||
|
export function walletToResponse(wallet: LinkedWallet): LinkedWalletResponse {
|
||||||
|
return {
|
||||||
|
id: wallet.id,
|
||||||
|
address: wallet.wallet_address,
|
||||||
|
type: wallet.wallet_type,
|
||||||
|
chainId: wallet.chain_id,
|
||||||
|
label: wallet.label,
|
||||||
|
ensName: wallet.ens_name,
|
||||||
|
ensAvatar: wallet.ens_avatar,
|
||||||
|
isPrimary: wallet.is_primary === 1,
|
||||||
|
linkedAt: wallet.verified_at,
|
||||||
|
lastUsedAt: wallet.last_used_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by public key (from CryptID auth header)
|
||||||
|
*/
|
||||||
|
export async function getUserByPublicKey(
|
||||||
|
db: D1Database,
|
||||||
|
publicKey: string
|
||||||
|
): Promise<User | null> {
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT u.* FROM users u
|
||||||
|
JOIN device_keys dk ON u.id = dk.user_id
|
||||||
|
WHERE dk.public_key = ?
|
||||||
|
`).bind(publicKey).first<User>();
|
||||||
|
|
||||||
|
return result || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all linked wallets for a user
|
||||||
|
*/
|
||||||
|
export async function getLinkedWallets(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string
|
||||||
|
): Promise<LinkedWallet[]> {
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT * FROM linked_wallets
|
||||||
|
WHERE user_id = ? AND is_active = 1
|
||||||
|
ORDER BY is_primary DESC, created_at ASC
|
||||||
|
`).bind(userId).all<LinkedWallet>();
|
||||||
|
|
||||||
|
return result.results || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific linked wallet
|
||||||
|
*/
|
||||||
|
export async function getLinkedWallet(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
walletAddress: string
|
||||||
|
): Promise<LinkedWallet | null> {
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT * FROM linked_wallets
|
||||||
|
WHERE user_id = ? AND wallet_address = ? AND is_active = 1
|
||||||
|
`).bind(userId, walletAddress.toLowerCase()).first<LinkedWallet>();
|
||||||
|
|
||||||
|
return result || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a wallet is already linked to any account
|
||||||
|
*/
|
||||||
|
export async function isWalletLinked(
|
||||||
|
db: D1Database,
|
||||||
|
walletAddress: string
|
||||||
|
): Promise<{ linked: boolean; userId?: string; username?: string }> {
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT lw.user_id, u.cryptid_username
|
||||||
|
FROM linked_wallets lw
|
||||||
|
JOIN users u ON lw.user_id = u.id
|
||||||
|
WHERE lw.wallet_address = ? AND lw.is_active = 1
|
||||||
|
`).bind(walletAddress.toLowerCase()).first<{ user_id: string; cryptid_username: string }>();
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return {
|
||||||
|
linked: true,
|
||||||
|
userId: result.user_id,
|
||||||
|
username: result.cryptid_username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { linked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count wallets linked to a user
|
||||||
|
*/
|
||||||
|
export async function countUserWallets(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await db.prepare(`
|
||||||
|
SELECT COUNT(*) as count FROM linked_wallets
|
||||||
|
WHERE user_id = ? AND is_active = 1
|
||||||
|
`).bind(userId).first<{ count: number }>();
|
||||||
|
|
||||||
|
return result?.count || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a new wallet to a user
|
||||||
|
*/
|
||||||
|
export async function linkWallet(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
request: WalletLinkRequest
|
||||||
|
): Promise<LinkedWallet> {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const walletAddress = request.walletAddress.toLowerCase();
|
||||||
|
|
||||||
|
// Check if this is the first wallet (make it primary)
|
||||||
|
const existingCount = await countUserWallets(db, userId);
|
||||||
|
const isPrimary = existingCount === 0 ? 1 : 0;
|
||||||
|
|
||||||
|
await db.prepare(`
|
||||||
|
INSERT INTO linked_wallets (
|
||||||
|
id, user_id, wallet_address, wallet_type, chain_id, label,
|
||||||
|
signature_message, signature, verified_at, is_primary, is_active,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||||
|
`).bind(
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
walletAddress,
|
||||||
|
request.walletType || 'eoa',
|
||||||
|
request.chainId || 1,
|
||||||
|
request.label || null,
|
||||||
|
request.message,
|
||||||
|
request.signature,
|
||||||
|
now,
|
||||||
|
isPrimary,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
).run();
|
||||||
|
|
||||||
|
// Fetch and return the created wallet
|
||||||
|
const wallet = await db.prepare(`
|
||||||
|
SELECT * FROM linked_wallets WHERE id = ?
|
||||||
|
`).bind(id).first<LinkedWallet>();
|
||||||
|
|
||||||
|
return wallet!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a linked wallet
|
||||||
|
*/
|
||||||
|
export async function updateWallet(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
walletAddress: string,
|
||||||
|
updates: { label?: string; isPrimary?: boolean }
|
||||||
|
): Promise<LinkedWallet | null> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const address = walletAddress.toLowerCase();
|
||||||
|
|
||||||
|
// If setting as primary, unset other primaries first
|
||||||
|
if (updates.isPrimary) {
|
||||||
|
await db.prepare(`
|
||||||
|
UPDATE linked_wallets SET is_primary = 0, updated_at = ?
|
||||||
|
WHERE user_id = ? AND is_active = 1
|
||||||
|
`).bind(now, userId).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update query
|
||||||
|
const setClauses: string[] = ['updated_at = ?'];
|
||||||
|
const values: (string | number)[] = [now];
|
||||||
|
|
||||||
|
if (updates.label !== undefined) {
|
||||||
|
setClauses.push('label = ?');
|
||||||
|
values.push(updates.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.isPrimary !== undefined) {
|
||||||
|
setClauses.push('is_primary = ?');
|
||||||
|
values.push(updates.isPrimary ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(userId, address);
|
||||||
|
|
||||||
|
await db.prepare(`
|
||||||
|
UPDATE linked_wallets SET ${setClauses.join(', ')}
|
||||||
|
WHERE user_id = ? AND wallet_address = ? AND is_active = 1
|
||||||
|
`).bind(...values).run();
|
||||||
|
|
||||||
|
return getLinkedWallet(db, userId, address);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink (soft-delete) a wallet
|
||||||
|
*/
|
||||||
|
export async function unlinkWallet(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
walletAddress: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const address = walletAddress.toLowerCase();
|
||||||
|
|
||||||
|
const result = await db.prepare(`
|
||||||
|
UPDATE linked_wallets SET is_active = 0, updated_at = ?
|
||||||
|
WHERE user_id = ? AND wallet_address = ? AND is_active = 1
|
||||||
|
`).bind(now, userId, address).run();
|
||||||
|
|
||||||
|
return (result.meta?.changes || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Request Handlers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle POST /api/wallet/link
|
||||||
|
*/
|
||||||
|
export async function handleLinkWallet(
|
||||||
|
request: Request,
|
||||||
|
env: Environment
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const db = env.CRYPTID_DB;
|
||||||
|
if (!db) {
|
||||||
|
return jsonResponse({ error: 'Database not configured' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get authenticated user from public key header
|
||||||
|
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||||
|
if (!publicKey) {
|
||||||
|
return jsonResponse({ error: 'Authentication required' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByPublicKey(db, publicKey);
|
||||||
|
if (!user) {
|
||||||
|
return jsonResponse({ error: 'User not found' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
const body = await request.json() as WalletLinkRequest;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!body.walletAddress || !body.signature || !body.message) {
|
||||||
|
return jsonResponse({ error: 'Missing required fields: walletAddress, signature, message' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate address format
|
||||||
|
if (!isValidAddress(body.walletAddress)) {
|
||||||
|
return jsonResponse({ error: 'Invalid wallet address format' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check wallet count limit
|
||||||
|
const walletCount = await countUserWallets(db, user.id);
|
||||||
|
if (walletCount >= MAX_WALLETS_PER_USER) {
|
||||||
|
return jsonResponse({ error: `Maximum ${MAX_WALLETS_PER_USER} wallets allowed per account` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if wallet is already linked
|
||||||
|
const existingLink = await isWalletLinked(db, body.walletAddress);
|
||||||
|
if (existingLink.linked) {
|
||||||
|
if (existingLink.userId === user.id) {
|
||||||
|
return jsonResponse({ error: 'Wallet already linked to your account' }, 409);
|
||||||
|
}
|
||||||
|
return jsonResponse({ error: 'Wallet is linked to another account' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate the message
|
||||||
|
const parsedMessage = parseLinkMessage(body.message);
|
||||||
|
if (!parsedMessage) {
|
||||||
|
return jsonResponse({ error: 'Invalid message format' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message matches request
|
||||||
|
if (parsedMessage.address.toLowerCase() !== body.walletAddress.toLowerCase()) {
|
||||||
|
return jsonResponse({ error: 'Message wallet address does not match request' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedMessage.username !== user.cryptid_username) {
|
||||||
|
return jsonResponse({ error: 'Message username does not match authenticated user' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate timestamp is recent
|
||||||
|
const messageTime = new Date(parsedMessage.timestamp).getTime();
|
||||||
|
const now = Date.now();
|
||||||
|
if (isNaN(messageTime) || now - messageTime > MAX_SIGNATURE_AGE_MS) {
|
||||||
|
return jsonResponse({ error: 'Signature expired. Please sign a new message.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature using proper ecrecover
|
||||||
|
const signatureValid = verifyPersonalSignature(
|
||||||
|
body.walletAddress,
|
||||||
|
body.message,
|
||||||
|
body.signature
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!signatureValid) {
|
||||||
|
return jsonResponse({ error: 'Signature verification failed' }, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link the wallet
|
||||||
|
const wallet = await linkWallet(db, user.id, body);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
wallet: walletToResponse(wallet),
|
||||||
|
}, 201);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Link wallet error:', error);
|
||||||
|
return jsonResponse({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle GET /api/wallet/list
|
||||||
|
*/
|
||||||
|
export async function handleListWallets(
|
||||||
|
request: Request,
|
||||||
|
env: Environment
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const db = env.CRYPTID_DB;
|
||||||
|
if (!db) {
|
||||||
|
return jsonResponse({ error: 'Database not configured' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||||
|
if (!publicKey) {
|
||||||
|
return jsonResponse({ error: 'Authentication required' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByPublicKey(db, publicKey);
|
||||||
|
if (!user) {
|
||||||
|
return jsonResponse({ error: 'User not found' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallets = await getLinkedWallets(db, user.id);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
wallets: wallets.map(walletToResponse),
|
||||||
|
count: wallets.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('List wallets error:', error);
|
||||||
|
return jsonResponse({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle GET /api/wallet/:address
|
||||||
|
*/
|
||||||
|
export async function handleGetWallet(
|
||||||
|
request: Request,
|
||||||
|
env: Environment,
|
||||||
|
address: string
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const db = env.CRYPTID_DB;
|
||||||
|
if (!db) {
|
||||||
|
return jsonResponse({ error: 'Database not configured' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||||
|
if (!publicKey) {
|
||||||
|
return jsonResponse({ error: 'Authentication required' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByPublicKey(db, publicKey);
|
||||||
|
if (!user) {
|
||||||
|
return jsonResponse({ error: 'User not found' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wallet = await getLinkedWallet(db, user.id, address);
|
||||||
|
if (!wallet) {
|
||||||
|
return jsonResponse({ error: 'Wallet not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
wallet: walletToResponse(wallet),
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get wallet error:', error);
|
||||||
|
return jsonResponse({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle PATCH /api/wallet/:address
|
||||||
|
*/
|
||||||
|
export async function handleUpdateWallet(
|
||||||
|
request: Request,
|
||||||
|
env: Environment,
|
||||||
|
address: string
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const db = env.CRYPTID_DB;
|
||||||
|
if (!db) {
|
||||||
|
return jsonResponse({ error: 'Database not configured' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||||
|
if (!publicKey) {
|
||||||
|
return jsonResponse({ error: 'Authentication required' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByPublicKey(db, publicKey);
|
||||||
|
if (!user) {
|
||||||
|
return jsonResponse({ error: 'User not found' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json() as { label?: string; isPrimary?: boolean };
|
||||||
|
|
||||||
|
const wallet = await updateWallet(db, user.id, address, body);
|
||||||
|
if (!wallet) {
|
||||||
|
return jsonResponse({ error: 'Wallet not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
wallet: walletToResponse(wallet),
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update wallet error:', error);
|
||||||
|
return jsonResponse({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle DELETE /api/wallet/:address
|
||||||
|
*/
|
||||||
|
export async function handleUnlinkWallet(
|
||||||
|
request: Request,
|
||||||
|
env: Environment,
|
||||||
|
address: string
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const db = env.CRYPTID_DB;
|
||||||
|
if (!db) {
|
||||||
|
return jsonResponse({ error: 'Database not configured' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||||
|
if (!publicKey) {
|
||||||
|
return jsonResponse({ error: 'Authentication required' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByPublicKey(db, publicKey);
|
||||||
|
if (!user) {
|
||||||
|
return jsonResponse({ error: 'User not found' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await unlinkWallet(db, user.id, address);
|
||||||
|
if (!success) {
|
||||||
|
return jsonResponse({ error: 'Wallet not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: 'Wallet unlinked',
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unlink wallet error:', error);
|
||||||
|
return jsonResponse({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle GET /api/wallet/verify/:address (public endpoint)
|
||||||
|
*/
|
||||||
|
export async function handleVerifyWallet(
|
||||||
|
_request: Request,
|
||||||
|
env: Environment,
|
||||||
|
address: string
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const db = env.CRYPTID_DB;
|
||||||
|
if (!db) {
|
||||||
|
return jsonResponse({ error: 'Database not configured' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidAddress(address)) {
|
||||||
|
return jsonResponse({ error: 'Invalid wallet address format' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkStatus = await isWalletLinked(db, address);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
linked: linkStatus.linked,
|
||||||
|
cryptidUsername: linkStatus.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Verify wallet error:', error);
|
||||||
|
return jsonResponse({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Utilities
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
function jsonResponse(data: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
303
worker/worker.ts
303
worker/worker.ts
|
|
@ -37,6 +37,14 @@ import {
|
||||||
handleListAllUsers,
|
handleListAllUsers,
|
||||||
handleCheckUsername,
|
handleCheckUsername,
|
||||||
} from "./cryptidAuth"
|
} from "./cryptidAuth"
|
||||||
|
import {
|
||||||
|
handleLinkWallet,
|
||||||
|
handleListWallets,
|
||||||
|
handleGetWallet,
|
||||||
|
handleUpdateWallet,
|
||||||
|
handleUnlinkWallet,
|
||||||
|
handleVerifyWallet,
|
||||||
|
} from "./walletAuth"
|
||||||
|
|
||||||
// make sure our sync durable objects are made available to cloudflare
|
// make sure our sync durable objects are made available to cloudflare
|
||||||
export { AutomergeDurableObject } from "./AutomergeDurableObject"
|
export { AutomergeDurableObject } from "./AutomergeDurableObject"
|
||||||
|
|
@ -995,6 +1003,32 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||||
// List all CryptID users (admin only, requires X-Admin-Secret header)
|
// List all CryptID users (admin only, requires X-Admin-Secret header)
|
||||||
.get("/admin/users", handleListAllUsers)
|
.get("/admin/users", handleListAllUsers)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Wallet Linking API (Web3 Integration)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Link a new wallet to the authenticated user's CryptID account
|
||||||
|
.post("/api/wallet/link", handleLinkWallet)
|
||||||
|
|
||||||
|
// List all wallets linked to the authenticated user
|
||||||
|
.get("/api/wallet/list", handleListWallets)
|
||||||
|
|
||||||
|
// Check if a wallet address is linked to any CryptID account (public)
|
||||||
|
.get("/api/wallet/verify/:address", (req, env) =>
|
||||||
|
handleVerifyWallet(req, env, req.params.address))
|
||||||
|
|
||||||
|
// Get details for a specific linked wallet
|
||||||
|
.get("/api/wallet/:address", (req, env) =>
|
||||||
|
handleGetWallet(req, env, req.params.address))
|
||||||
|
|
||||||
|
// Update a linked wallet (label, primary status)
|
||||||
|
.patch("/api/wallet/:address", (req, env) =>
|
||||||
|
handleUpdateWallet(req, env, req.params.address))
|
||||||
|
|
||||||
|
// Unlink a wallet from the account
|
||||||
|
.delete("/api/wallet/:address", (req, env) =>
|
||||||
|
handleUnlinkWallet(req, env, req.params.address))
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// User Networking / Social Graph API
|
// User Networking / Social Graph API
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -1085,7 +1119,8 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
// Fal.ai proxy - submit job to queue
|
// Fal.ai proxy - submit job to queue
|
||||||
.post("/api/fal/queue/:endpoint(*)", async (req, env) => {
|
// Use :endpoint+ for greedy named wildcard that captures multiple path segments
|
||||||
|
.post("/api/fal/queue/:endpoint+", async (req, env) => {
|
||||||
if (!env.FAL_API_KEY) {
|
if (!env.FAL_API_KEY) {
|
||||||
return new Response(JSON.stringify({ error: 'FAL_API_KEY not configured' }), {
|
return new Response(JSON.stringify({ error: 'FAL_API_KEY not configured' }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
|
|
@ -1131,7 +1166,7 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fal.ai proxy - check job status
|
// Fal.ai proxy - check job status
|
||||||
.get("/api/fal/queue/:endpoint(*)/status/:requestId", async (req, env) => {
|
.get("/api/fal/queue/:endpoint+/status/:requestId", async (req, env) => {
|
||||||
if (!env.FAL_API_KEY) {
|
if (!env.FAL_API_KEY) {
|
||||||
return new Response(JSON.stringify({ error: 'FAL_API_KEY not configured' }), {
|
return new Response(JSON.stringify({ error: 'FAL_API_KEY not configured' }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
|
|
@ -1171,7 +1206,7 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fal.ai proxy - get job result
|
// Fal.ai proxy - get job result
|
||||||
.get("/api/fal/queue/:endpoint(*)/result/:requestId", async (req, env) => {
|
.get("/api/fal/queue/:endpoint+/result/:requestId", async (req, env) => {
|
||||||
if (!env.FAL_API_KEY) {
|
if (!env.FAL_API_KEY) {
|
||||||
return new Response(JSON.stringify({ error: 'FAL_API_KEY not configured' }), {
|
return new Response(JSON.stringify({ error: 'FAL_API_KEY not configured' }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
|
|
@ -1211,7 +1246,7 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fal.ai subscribe (synchronous generation) - used by LiveImage
|
// Fal.ai subscribe (synchronous generation) - used by LiveImage
|
||||||
.post("/api/fal/subscribe/:endpoint(*)", async (req, env) => {
|
.post("/api/fal/subscribe/:endpoint+", async (req, env) => {
|
||||||
if (!env.FAL_API_KEY) {
|
if (!env.FAL_API_KEY) {
|
||||||
return new Response(JSON.stringify({ error: 'FAL_API_KEY not configured' }), {
|
return new Response(JSON.stringify({ error: 'FAL_API_KEY not configured' }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
|
|
@ -1439,6 +1474,95 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Blender 3D Render API
|
||||||
|
// Proxies render requests to blender-automation server on Netcup RS 8000
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
.post("/api/blender/render", async (req, env) => {
|
||||||
|
// Blender render server URL - hosted on Netcup RS 8000
|
||||||
|
const BLENDER_API_URL = env.BLENDER_API_URL || 'https://blender.jeffemmett.com'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json() as {
|
||||||
|
preset: string
|
||||||
|
text?: string
|
||||||
|
complexity?: number
|
||||||
|
seed?: number
|
||||||
|
resolution?: string
|
||||||
|
samples?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Blender render request:', body)
|
||||||
|
|
||||||
|
const response = await fetch(`${BLENDER_API_URL}/render`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error('Blender API error:', response.status, errorText)
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: `Blender API error: ${response.status}`,
|
||||||
|
details: errorText
|
||||||
|
}), {
|
||||||
|
status: response.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Blender render proxy error:', error)
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Blender render failed',
|
||||||
|
details: (error as Error).message
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get render job status
|
||||||
|
.get("/api/blender/status/:jobId", async (req, env) => {
|
||||||
|
const BLENDER_API_URL = env.BLENDER_API_URL || 'https://blender.jeffemmett.com'
|
||||||
|
const { jobId } = req.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BLENDER_API_URL}/status/${jobId}`)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: `Blender status error: ${response.status}`,
|
||||||
|
details: errorText
|
||||||
|
}), {
|
||||||
|
status: response.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Blender status proxy error:', error)
|
||||||
|
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute SHA-256 hash of content for change detection
|
* Compute SHA-256 hash of content for change detection
|
||||||
*/
|
*/
|
||||||
|
|
@ -1624,6 +1748,177 @@ router
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Migration endpoints - for batch converting legacy JSON to Automerge format
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// List all rooms with their size and migration status
|
||||||
|
.get("/migrate/list", async (_, env) => {
|
||||||
|
try {
|
||||||
|
const rooms: Array<{
|
||||||
|
roomId: string
|
||||||
|
hasJson: boolean
|
||||||
|
hasAutomerge: boolean
|
||||||
|
shapeCount: number
|
||||||
|
recordCount: number
|
||||||
|
needsMigration: boolean
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
// List all rooms in R2
|
||||||
|
let cursor: string | undefined = undefined
|
||||||
|
do {
|
||||||
|
const result = await env.TLDRAW_BUCKET.list({
|
||||||
|
prefix: 'rooms/',
|
||||||
|
cursor,
|
||||||
|
delimiter: '/'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Process each room prefix
|
||||||
|
for (const prefix of result.delimitedPrefixes || []) {
|
||||||
|
const roomId = prefix.replace('rooms/', '').replace('/', '')
|
||||||
|
if (!roomId) continue
|
||||||
|
|
||||||
|
// Check if JSON exists
|
||||||
|
const jsonObject = await env.TLDRAW_BUCKET.head(`rooms/${roomId}`)
|
||||||
|
const hasJson = !!jsonObject
|
||||||
|
|
||||||
|
// Check if Automerge binary exists
|
||||||
|
const automergeObject = await env.TLDRAW_BUCKET.head(`rooms/${roomId}/automerge.bin`)
|
||||||
|
const hasAutomerge = !!automergeObject
|
||||||
|
|
||||||
|
// Get shape count from JSON if it exists
|
||||||
|
let shapeCount = 0
|
||||||
|
let recordCount = 0
|
||||||
|
if (hasJson) {
|
||||||
|
try {
|
||||||
|
const jsonData = await env.TLDRAW_BUCKET.get(`rooms/${roomId}`)
|
||||||
|
if (jsonData) {
|
||||||
|
const doc = await jsonData.json() as { store?: Record<string, any> }
|
||||||
|
if (doc.store) {
|
||||||
|
recordCount = Object.keys(doc.store).length
|
||||||
|
shapeCount = Object.values(doc.store).filter((r: any) => r?.typeName === 'shape').length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error reading room ${roomId}:`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rooms.push({
|
||||||
|
roomId,
|
||||||
|
hasJson,
|
||||||
|
hasAutomerge,
|
||||||
|
shapeCount,
|
||||||
|
recordCount,
|
||||||
|
needsMigration: hasJson && !hasAutomerge
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = result.truncated ? result.cursor : undefined
|
||||||
|
} while (cursor)
|
||||||
|
|
||||||
|
// Sort by shape count descending (largest first)
|
||||||
|
rooms.sort((a, b) => b.shapeCount - a.shapeCount)
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
total: rooms.length,
|
||||||
|
needsMigration: rooms.filter(r => r.needsMigration).length,
|
||||||
|
alreadyMigrated: rooms.filter(r => r.hasAutomerge).length,
|
||||||
|
largeRooms: rooms.filter(r => r.shapeCount > 5000).length
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ summary, rooms }, null, 2), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Migration list failed:', error)
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Failed to list rooms',
|
||||||
|
message: (error as Error).message
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Migrate a single room to Automerge format
|
||||||
|
.post("/migrate/:roomId", async (request, env) => {
|
||||||
|
const roomId = request.params.roomId
|
||||||
|
if (!roomId) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Room ID is required' }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Import the storage module dynamically
|
||||||
|
const { AutomergeR2Storage } = await import('./automerge-r2-storage')
|
||||||
|
const storage = new AutomergeR2Storage(env.TLDRAW_BUCKET)
|
||||||
|
|
||||||
|
// Check if already migrated
|
||||||
|
const isAutomerge = await storage.isAutomergeFormat(roomId)
|
||||||
|
if (isAutomerge) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: 'Room already migrated to Automerge format',
|
||||||
|
roomId
|
||||||
|
}), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load legacy JSON
|
||||||
|
const jsonObject = await env.TLDRAW_BUCKET.get(`rooms/${roomId}`)
|
||||||
|
if (!jsonObject) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Room not found',
|
||||||
|
roomId
|
||||||
|
}), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonDoc = await jsonObject.json() as { store?: Record<string, any>; schema?: any }
|
||||||
|
const recordCount = jsonDoc.store ? Object.keys(jsonDoc.store).length : 0
|
||||||
|
const shapeCount = jsonDoc.store ? Object.values(jsonDoc.store).filter((r: any) => r?.typeName === 'shape').length : 0
|
||||||
|
|
||||||
|
console.log(`🔄 Starting migration for room ${roomId}: ${shapeCount} shapes, ${recordCount} records`)
|
||||||
|
|
||||||
|
// Perform migration
|
||||||
|
const startTime = Date.now()
|
||||||
|
const doc = await storage.migrateFromJson(roomId, jsonDoc as any)
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
throw new Error('Migration returned null')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: 'Room migrated successfully',
|
||||||
|
roomId,
|
||||||
|
shapeCount,
|
||||||
|
recordCount,
|
||||||
|
durationMs: duration
|
||||||
|
}), {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Migration failed for room ${roomId}:`, error)
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Migration failed',
|
||||||
|
roomId,
|
||||||
|
message: (error as Error).message
|
||||||
|
}), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Handle scheduled events (cron jobs)
|
// Handle scheduled events (cron jobs)
|
||||||
export async function scheduled(_event: ScheduledEvent, env: Environment, _ctx: ExecutionContext) {
|
export async function scheduled(_event: ScheduledEvent, env: Environment, _ctx: ExecutionContext) {
|
||||||
// Cron job triggered
|
// Cron job triggered
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
main = "worker/worker.ts"
|
main = "worker/worker.ts"
|
||||||
compatibility_date = "2024-07-01"
|
compatibility_date = "2024-07-01"
|
||||||
name = "jeffemmett-canvas-dev"
|
name = "jeffemmett-canvas-automerge-dev"
|
||||||
account_id = "0e7b3338d5278ed1b148e6456b940913"
|
account_id = "0e7b3338d5278ed1b148e6456b940913"
|
||||||
|
|
||||||
[vars]
|
[vars]
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,8 @@ bucket_name = 'board-backups-preview'
|
||||||
|
|
||||||
[[env.dev.d1_databases]]
|
[[env.dev.d1_databases]]
|
||||||
binding = "CRYPTID_DB"
|
binding = "CRYPTID_DB"
|
||||||
database_name = "cryptid-auth-dev"
|
database_name = "cryptid-auth"
|
||||||
database_id = "placeholder-will-be-created-dev"
|
database_id = "35fbe755-0e7c-4b9a-a454-34f945e5f7cc"
|
||||||
|
|
||||||
[env.dev.triggers]
|
[env.dev.triggers]
|
||||||
crons = ["0 0 * * *"] # Run at midnight UTC every day
|
crons = ["0 0 * * *"] # Run at midnight UTC every day
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue