| … | … | @@ -0,0 +1,1178 @@ |
| 1 | # Wirecore Relay Bridge Architecture | |
| 2 | ||
| 3 | **Date**: 2026-01-22 | |
| 4 | **Status**: Design Phase - Bridge Pattern | |
| 5 | **Goal**: Separate relay integration into its own bridge CLI/daemon | |
| 6 | ||
| 7 | --- | |
| 8 | ||
| 9 | ## 💡 CONCEPT: Bridge CLI as Local Relay | |
| 10 | ||
| 11 | Instead of integrating relay directly into the pear-app, create a **separate bridge CLI** that: | |
| 12 | ||
| 13 | 1. Reads from local Hypercore storage (same Corestore as pear-app) | |
| 14 | 2. Watches for new posts in feeds | |
| 15 | 3. Pushes to 7rnx.net/relay when new content appears | |
| 16 | 4. Pulls from relay and stores in local Hyperbee cache | |
| 17 | 5. Acts as intermediary between P2P network and relay network | |
| 18 | ||
| 19 | --- | |
| 20 | ||
| 21 | ## 🏗️ ARCHITECTURE | |
| 22 | ||
| 23 | ``` | |
| 24 | ┌────────────────────────────────────────────────────────┐ | |
| 25 | │ Pear App (P2P Focused) │ | |
| 26 | │ │ | |
| 27 | │ Commands: │ | |
| 28 | │ • pear run . cli init │ | |
| 29 | │ • pear run . cli post "message" │ | |
| 30 | │ • pear run . cli timeline │ | |
| 31 | │ • pear run . cli follow <pubkey> │ | |
| 32 | │ │ | |
| 33 | │ Storage: ~/.config/pear/app-storage/.../ │ | |
| 34 | │ └─ Corestore (Hypercore feeds + Hyperbee) │ | |
| 35 | └────────────┬───────────────────────────────────────────┘ | |
| 36 | │ | |
| 37 | │ Shared Storage Access | |
| 38 | │ | |
| 39 | ┌────────────▼───────────────────────────────────────────┐ | |
| 40 | │ Bridge CLI (Relay Integration) │ | |
| 41 | │ │ | |
| 42 | │ Commands: │ | |
| 43 | │ • wirecore-bridge start (daemon mode) │ | |
| 44 | │ • wirecore-bridge sync (one-time sync) │ | |
| 45 | │ • wirecore-bridge push (push all local posts) │ | |
| 46 | │ • wirecore-bridge pull (pull relay posts) │ | |
| 47 | │ • wirecore-bridge watch (monitor for new posts) │ | |
| 48 | │ │ | |
| 49 | │ Functions: │ | |
| 50 | │ • Read from Corestore (own feed + followed feeds) │ | |
| 51 | │ • Watch for new posts via feed.on('append') │ | |
| 52 | │ • Push YAML + signatures to relay │ | |
| 53 | │ • Pull relay posts and cache in Hyperbee │ | |
| 54 | │ • Maintain sync state (last synced index) │ | |
| 55 | └────────────┬───────────────────────────────────────────┘ | |
| 56 | │ | |
| 57 | │ HTTPS/WebSocket | |
| 58 | │ | |
| 59 | ┌────────────▼───────────────────────────────────────────┐ | |
| 60 | │ 7rnx.net/relay │ | |
| 61 | │ │ | |
| 62 | │ • Receives posts via WebSocket │ | |
| 63 | │ • Stores in APDS (Hypercore backend) │ | |
| 64 | │ • Bridges to pub.wiredove.net │ | |
| 65 | │ • Provides HTTP API for queries │ | |
| 66 | └────────────┬───────────────────────────────────────────┘ | |
| 67 | │ | |
| 68 | │ WebSocket Bridge | |
| 69 | │ | |
| 70 | ┌────────────▼───────────────────────────────────────────┐ | |
| 71 | │ pub.wiredove.net │ | |
| 72 | │ │ | |
| 73 | │ • Browser clients (Trystero P2P) │ | |
| 74 | │ • APDS relay network │ | |
| 75 | │ • Wiredove users │ | |
| 76 | └────────────────────────────────────────────────────────┘ | |
| 77 | ``` | |
| 78 | ||
| 79 | --- | |
| 80 | ||
| 81 | ## 🎯 WHY THIS DESIGN IS BETTER | |
| 82 | ||
| 83 | ### Separation of Concerns | |
| 84 | ||
| 85 | **Pear App CLI**: | |
| 86 | - ✅ Focused on P2P operations | |
| 87 | - ✅ No relay dependencies | |
| 88 | - ✅ Simple, clean code | |
| 89 | - ✅ Works offline | |
| 90 | - ✅ Fast (no relay latency) | |
| 91 | ||
| 92 | **Bridge CLI**: | |
| 93 | - ✅ Handles all relay complexity | |
| 94 | - ✅ Optional service (P2P works without it) | |
| 95 | - ✅ Can run as daemon | |
| 96 | - ✅ Can run on different machine | |
| 97 | - ✅ Isolated failure domain | |
| 98 | ||
| 99 | ### Unix Philosophy | |
| 100 | ||
| 101 | - **Do one thing well**: Each CLI has clear purpose | |
| 102 | - **Composability**: Bridge is optional addon | |
| 103 | - **Modularity**: Can replace bridge without touching pear-app | |
| 104 | - **Transparency**: Bridge reads from shared storage | |
| 105 | ||
| 106 | ### Operational Benefits | |
| 107 | ||
| 108 | 1. **Run bridge as systemd service** (like wirecore-relay) | |
| 109 | 2. **Bridge can sync multiple users** on same machine | |
| 110 | 3. **Bridge failure doesn't break P2P** (graceful degradation) | |
| 111 | 4. **Can run bridge remotely** (access via network) | |
| 112 | 5. **Easy to debug** (separate logs, separate process) | |
| 113 | ||
| 114 | --- | |
| 115 | ||
| 116 | ## 📐 DETAILED DESIGN | |
| 117 | ||
| 118 | ### Component 1: Pear App CLI (Unchanged) | |
| 119 | ||
| 120 | **Location**: `pear-app/index.js` | |
| 121 | ||
| 122 | **Purpose**: Pure P2P social network client | |
| 123 | ||
| 124 | **No changes needed!** The pear-app continues to: | |
| 125 | - Manage own Hypercore feed | |
| 126 | - Follow other users via Hyperswarm | |
| 127 | - Store posts in local feed | |
| 128 | - Display timeline from followed feeds | |
| 129 | ||
| 130 | **Storage**: | |
| 131 | ``` | |
| 132 | ~/.config/pear/app-storage/by-dkey/<hash>/ | |
| 133 | ├── corestore/ | |
| 134 | │ ├── own-feed (Hypercore) | |
| 135 | │ ├── followed-feeds (Hypercore) | |
| 136 | │ └── metadata (Hyperbee) | |
| 137 | ``` | |
| 138 | ||
| 139 | --- | |
| 140 | ||
| 141 | ### Component 2: Bridge CLI (New) | |
| 142 | ||
| 143 | **Location**: `pear-app/bridge/wirecore-bridge.js` | |
| 144 | ||
| 145 | **Purpose**: Sync local Hypercore storage with relay | |
| 146 | ||
| 147 | **Commands**: | |
| 148 | ||
| 149 | ```bash | |
| 150 | # Start bridge daemon (monitors for new posts) | |
| 151 | pear run . bridge start --relay https://7rnx.net/relay | |
| 152 | ||
| 153 | # One-time push of all local posts to relay | |
| 154 | pear run . bridge push | |
| 155 | ||
| 156 | # One-time pull from relay to local cache | |
| 157 | pear run . bridge pull | |
| 158 | ||
| 159 | # Sync both directions | |
| 160 | pear run . bridge sync | |
| 161 | ||
| 162 | # Watch for new posts and auto-push | |
| 163 | pear run . bridge watch | |
| 164 | ||
| 165 | # Status check | |
| 166 | pear run . bridge status | |
| 167 | ``` | |
| 168 | ||
| 169 | **Architecture**: | |
| 170 | ||
| 171 | ```javascript | |
| 172 | /** | |
| 173 | * Wirecore Bridge - Local relay for P2P ↔ Relay sync | |
| 174 | * | |
| 175 | * Responsibilities: | |
| 176 | * 1. Read from local Corestore (shared with pear-app) | |
| 177 | * 2. Watch for new posts in own feed | |
| 178 | * 3. Push new posts to relay (YAML + signature) | |
| 179 | * 4. Pull relay posts and cache locally | |
| 180 | * 5. Maintain sync state | |
| 181 | */ | |
| 182 | ||
| 183 | import Corestore from 'corestore' | |
| 184 | import Hyperbee from 'hyperbee' | |
| 185 | import { RelayClient } from '../lib/relay-client.js' | |
| 186 | ||
| 187 | export class WirecoreBridge { | |
| 188 | constructor(storagePath, relayUrl) { | |
| 189 | this.storagePath = storagePath | |
| 190 | this.relayUrl = relayUrl | |
| 191 | this.store = null | |
| 192 | this.metaBee = null | |
| 193 | this.syncBee = null // Track sync state | |
| 194 | this.relayClient = null | |
| 195 | this.myFeed = null | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Initialize bridge (read-only access to pear-app storage) | |
| 200 | */ | |
| 201 | async init() { | |
| 202 | // Open same Corestore as pear-app (read-only for followed feeds) | |
| 203 | this.store = new Corestore(this.storagePath) | |
| 204 | await this.store.ready() | |
| 205 | ||
| 206 | // Load metadata (keypair, etc.) | |
| 207 | this.metaBee = new Hyperbee(this.store.get({ name: 'metadata' }), { | |
| 208 | keyEncoding: 'utf-8', | |
| 209 | valueEncoding: 'json' | |
| 210 | }) | |
| 211 | await this.metaBee.ready() | |
| 212 | ||
| 213 | // Create sync state tracker | |
| 214 | this.syncBee = new Hyperbee(this.store.get({ name: 'bridge-sync' }), { | |
| 215 | keyEncoding: 'utf-8', | |
| 216 | valueEncoding: 'json' | |
| 217 | }) | |
| 218 | await this.syncBee.ready() | |
| 219 | ||
| 220 | // Load ANProto keypair | |
| 221 | const keypairNode = await this.metaBee.get('anproto-keypair') | |
| 222 | if (!keypairNode) { | |
| 223 | throw new Error('No identity found. Run: pear run . cli init') | |
| 224 | } | |
| 225 | ||
| 226 | const { publicKey, secretKey } = ANProto.loadKeypair(keypairNode.value) | |
| 227 | this.anprotoKeypair = { publicKey, secretKey } | |
| 228 | ||
| 229 | // Open own feed | |
| 230 | this.myFeed = this.store.get({ | |
| 231 | publicKey: this.anprotoKeypair.publicKey, | |
| 232 | secretKey: this.anprotoKeypair.secretKey | |
| 233 | }) | |
| 234 | await this.myFeed.ready() | |
| 235 | ||
| 236 | // Initialize relay client | |
| 237 | this.relayClient = new RelayClient(this.relayUrl) | |
| 238 | ||
| 239 | console.log('🌉 Bridge initialized') | |
| 240 | console.log(' Storage:', this.storagePath) | |
| 241 | console.log(' Relay:', this.relayUrl) | |
| 242 | console.log(' Feed length:', this.myFeed.length) | |
| 243 | } | |
| 244 | ||
| 245 | /** | |
| 246 | * Get last synced index for own feed | |
| 247 | */ | |
| 248 | async getLastSyncedIndex() { | |
| 249 | const node = await this.syncBee.get('last-synced-index') | |
| 250 | return node ? node.value : 0 | |
| 251 | } | |
| 252 | ||
| 253 | /** | |
| 254 | * Set last synced index | |
| 255 | */ | |
| 256 | async setLastSyncedIndex(index) { | |
| 257 | await this.syncBee.put('last-synced-index', index) | |
| 258 | } | |
| 259 | ||
| 260 | /** | |
| 261 | * Push all unsent posts to relay | |
| 262 | */ | |
| 263 | async push() { | |
| 264 | const lastSynced = await this.getLastSyncedIndex() | |
| 265 | const currentLength = this.myFeed.length | |
| 266 | ||
| 267 | if (lastSynced >= currentLength) { | |
| 268 | console.log('✓ Already up to date (no new posts)') | |
| 269 | return | |
| 270 | } | |
| 271 | ||
| 272 | console.log(`📤 Pushing ${currentLength - lastSynced} posts to relay...`) | |
| 273 | ||
| 274 | const pubkey = b4a.toString(this.anprotoKeypair.publicKey, 'base64') | |
| 275 | ||
| 276 | for (let i = lastSynced; i < currentLength; i++) { | |
| 277 | try { | |
| 278 | const block = await this.myFeed.get(i) | |
| 279 | const entry = JSON.parse(block.toString()) | |
| 280 | ||
| 281 | // Extract YAML and create signature | |
| 282 | const yaml = entry.text | |
| 283 | const timestamp = entry.timestamp | |
| 284 | const contentHash = crypto.hash(Buffer.from(yaml, 'utf-8')).toString('base64url') | |
| 285 | const signaturePayload = timestamp + contentHash | |
| 286 | const signature = ANProto.sign(signaturePayload, this.anprotoKeypair.secretKey) | |
| 287 | ||
| 288 | // Push to relay | |
| 289 | await this.relayClient.push(pubkey, yaml, signature, null) | |
| 290 | console.log(` ✓ Pushed post ${i}`) | |
| 291 | ||
| 292 | // Update sync state | |
| 293 | await this.setLastSyncedIndex(i + 1) | |
| 294 | } catch (err) { | |
| 295 | console.error(` ✗ Failed to push post ${i}:`, err.message) | |
| 296 | // Continue with next post | |
| 297 | } | |
| 298 | } | |
| 299 | ||
| 300 | console.log('✅ Push complete') | |
| 301 | } | |
| 302 | ||
| 303 | /** | |
| 304 | * Pull posts from relay and cache locally | |
| 305 | */ | |
| 306 | async pull() { | |
| 307 | console.log('📥 Pulling from relay...') | |
| 308 | ||
| 309 | try { | |
| 310 | const posts = await this.relayClient.pull() | |
| 311 | console.log(` Received ${posts.length} posts`) | |
| 312 | ||
| 313 | // TODO: Store in local Hyperbee cache | |
| 314 | // This would be similar to wirecore-relay's blobBee | |
| 315 | // For now, just log | |
| 316 | ||
| 317 | for (const post of posts.slice(0, 10)) { | |
| 318 | console.log(` • ${post.author.substring(0, 16)}...`) | |
| 319 | } | |
| 320 | ||
| 321 | console.log('✅ Pull complete') | |
| 322 | } catch (err) { | |
| 323 | console.error('❌ Pull failed:', err.message) | |
| 324 | } | |
| 325 | } | |
| 326 | ||
| 327 | /** | |
| 328 | * Watch for new posts and auto-push | |
| 329 | */ | |
| 330 | async watch() { | |
| 331 | console.log('👁️ Watching for new posts...') | |
| 332 | console.log(' Press Ctrl+C to stop\n') | |
| 333 | ||
| 334 | // Set up feed listener | |
| 335 | this.myFeed.on('append', async () => { | |
| 336 | console.log('🔔 New post detected!') | |
| 337 | await this.push() | |
| 338 | }) | |
| 339 | ||
| 340 | // Keep process alive | |
| 341 | await new Promise(() => {}) | |
| 342 | } | |
| 343 | ||
| 344 | /** | |
| 345 | * Daemon mode: watch + periodic pull | |
| 346 | */ | |
| 347 | async start() { | |
| 348 | console.log('🚀 Starting bridge daemon...') | |
| 349 | ||
| 350 | // Initial sync | |
| 351 | await this.push() | |
| 352 | await this.pull() | |
| 353 | ||
| 354 | // Watch for new posts | |
| 355 | this.myFeed.on('append', async () => { | |
| 356 | console.log('🔔 New post detected!') | |
| 357 | await this.push() | |
| 358 | }) | |
| 359 | ||
| 360 | // Periodic pull from relay (every 5 minutes) | |
| 361 | setInterval(async () => { | |
| 362 | console.log('⏰ Periodic pull from relay...') | |
| 363 | await this.pull() | |
| 364 | }, 5 * 60 * 1000) | |
| 365 | ||
| 366 | console.log('✅ Bridge daemon running') | |
| 367 | ||
| 368 | // Keep alive | |
| 369 | await new Promise(() => {}) | |
| 370 | } | |
| 371 | ||
| 372 | /** | |
| 373 | * One-time sync (push + pull) | |
| 374 | */ | |
| 375 | async sync() { | |
| 376 | console.log('🔄 Syncing with relay...') | |
| 377 | await this.push() | |
| 378 | await this.pull() | |
| 379 | console.log('✅ Sync complete') | |
| 380 | } | |
| 381 | ||
| 382 | /** | |
| 383 | * Status check | |
| 384 | */ | |
| 385 | async status() { | |
| 386 | const lastSynced = await this.getLastSyncedIndex() | |
| 387 | const currentLength = this.myFeed.length | |
| 388 | const pending = currentLength - lastSynced | |
| 389 | ||
| 390 | console.log('Bridge Status:') | |
| 391 | console.log(` Relay: ${this.relayUrl}`) | |
| 392 | console.log(` Storage: ${this.storagePath}`) | |
| 393 | console.log(` Feed length: ${currentLength}`) | |
| 394 | console.log(` Last synced: ${lastSynced}`) | |
| 395 | console.log(` Pending: ${pending}`) | |
| 396 | } | |
| 397 | } | |
| 398 | ``` | |
| 399 | ||
| 400 | **CLI Entry Point** (`pear-app/bridge/cli.js`): | |
| 401 | ||
| 402 | ```javascript | |
| 403 | #!/usr/bin/env node | |
| 404 | ||
| 405 | import { WirecoreBridge } from './wirecore-bridge.js' | |
| 406 | ||
| 407 | const args = process.argv.slice(2) | |
| 408 | const command = args[0] | |
| 409 | ||
| 410 | // Get storage path from pear-app | |
| 411 | const storagePath = process.env.PEAR_STORAGE || './wirecore-storage' | |
| 412 | const relayUrl = process.env.RELAY_URL || 'https://7rnx.net/relay' | |
| 413 | ||
| 414 | const bridge = new WirecoreBridge(storagePath, relayUrl) | |
| 415 | ||
| 416 | async function main() { | |
| 417 | await bridge.init() | |
| 418 | ||
| 419 | switch (command) { | |
| 420 | case 'start': | |
| 421 | await bridge.start() | |
| 422 | break | |
| 423 | ||
| 424 | case 'push': | |
| 425 | await bridge.push() | |
| 426 | process.exit(0) | |
| 427 | break | |
| 428 | ||
| 429 | case 'pull': | |
| 430 | await bridge.pull() | |
| 431 | process.exit(0) | |
| 432 | break | |
| 433 | ||
| 434 | case 'watch': | |
| 435 | await bridge.watch() | |
| 436 | break | |
| 437 | ||
| 438 | case 'sync': | |
| 439 | await bridge.sync() | |
| 440 | process.exit(0) | |
| 441 | break | |
| 442 | ||
| 443 | case 'status': | |
| 444 | await bridge.status() | |
| 445 | process.exit(0) | |
| 446 | break | |
| 447 | ||
| 448 | default: | |
| 449 | console.log('Usage:') | |
| 450 | console.log(' pear run . bridge start Start daemon') | |
| 451 | console.log(' pear run . bridge push Push local posts to relay') | |
| 452 | console.log(' pear run . bridge pull Pull relay posts') | |
| 453 | console.log(' pear run . bridge watch Watch and auto-push') | |
| 454 | console.log(' pear run . bridge sync Push + pull') | |
| 455 | console.log(' pear run . bridge status Show status') | |
| 456 | process.exit(1) | |
| 457 | } | |
| 458 | } | |
| 459 | ||
| 460 | main().catch(err => { | |
| 461 | console.error('Error:', err.message) | |
| 462 | process.exit(1) | |
| 463 | }) | |
| 464 | ``` | |
| 465 | ||
| 466 | --- | |
| 467 | ||
| 468 | ## 🔄 DATA FLOW | |
| 469 | ||
| 470 | ### Posting Flow (with Bridge) | |
| 471 | ||
| 472 | ``` | |
| 473 | 1. User: pear run . cli post "Hello" | |
| 474 | └─> Writes to own Hypercore feed | |
| 475 | ||
| 476 | 2. Hypercore feed emits 'append' event | |
| 477 | └─> Bridge detects new post (if running) | |
| 478 | ||
| 479 | 3. Bridge: reads post from feed | |
| 480 | └─> Extracts YAML, creates signature | |
| 481 | └─> Pushes to 7rnx.net/relay via WebSocket | |
| 482 | ||
| 483 | 4. Relay: stores in APDS | |
| 484 | └─> Bridges to pub.wiredove.net | |
| 485 | ||
| 486 | 5. Wiredove users see the post | |
| 487 | ``` | |
| 488 | ||
| 489 | ### Timeline Flow (with Bridge) | |
| 490 | ||
| 491 | ``` | |
| 492 | 1. User: pear run . cli timeline | |
| 493 | └─> Reads from followed Hypercore feeds (P2P) | |
| 494 | ||
| 495 | 2. Bridge running in background: | |
| 496 | └─> Periodically pulls from relay | |
| 497 | └─> Stores relay posts in local Hyperbee cache | |
| 498 | ||
| 499 | 3. Timeline shows: | |
| 500 | └─> P2P posts (directly from feeds) | |
| 501 | └─> Relay posts (from cache, marked as "via relay") | |
| 502 | ``` | |
| 503 | ||
| 504 | --- | |
| 505 | ||
| 506 | ## 🎮 USAGE EXAMPLES | |
| 507 | ||
| 508 | ### Basic Usage | |
| 509 | ||
| 510 | ```bash | |
| 511 | # Terminal 1: Run bridge daemon | |
| 512 | cd pear-app | |
| 513 | export RELAY_URL=https://7rnx.net/relay | |
| 514 | pear run . bridge start | |
| 515 | ||
| 516 | # Terminal 2: Use pear-app normally | |
| 517 | pear run . cli post "Hello P2P world!" | |
| 518 | # Bridge automatically pushes to relay | |
| 519 | ||
| 520 | pear run . cli timeline | |
| 521 | # Shows P2P posts + cached relay posts | |
| 522 | ``` | |
| 523 | ||
| 524 | ### One-Time Sync | |
| 525 | ||
| 526 | ```bash | |
| 527 | # Push all existing posts to relay | |
| 528 | pear run . bridge push | |
| 529 | ||
| 530 | # Pull relay posts to local cache | |
| 531 | pear run . bridge pull | |
| 532 | ||
| 533 | # Do both | |
| 534 | pear run . bridge sync | |
| 535 | ``` | |
| 536 | ||
| 537 | ### Web UI Management (Settings → Bridge) | |
| 538 | ||
| 539 | **Location**: Web interface settings panel | |
| 540 | ||
| 541 | **Features**: | |
| 542 | ```javascript | |
| 543 | // Bridge Settings UI | |
| 544 | { | |
| 545 | enabled: true, // Enable/disable bridge | |
| 546 | status: 'running', // running | stopped | error | |
| 547 | relayUrl: 'https://7rnx.net/relay', // Configurable relay | |
| 548 | autoStart: true, // Start with web server | |
| 549 | syncInterval: 300000, // Pull interval (5 min) | |
| 550 | lastSync: 1737583200000, // Last sync timestamp | |
| 551 | pendingPosts: 0, // Posts not yet pushed | |
| 552 | stats: { | |
| 553 | totalPushed: 42, | |
| 554 | totalPulled: 156, | |
| 555 | lastError: null | |
| 556 | } | |
| 557 | } | |
| 558 | ``` | |
| 559 | ||
| 560 | **Settings Panel UI** (in web interface): | |
| 561 | ||
| 562 | ```html | |
| 563 | <!-- Settings Modal → Bridge Tab --> | |
| 564 | <div class="settings-section" id="bridge-settings"> | |
| 565 | <h3>🌉 Relay Bridge</h3> | |
| 566 | <p class="help-text"> | |
| 567 | The bridge automatically syncs your posts with relay networks, | |
| 568 | enabling cross-network visibility with wiredove.net users. | |
| 569 | </p> | |
| 570 | ||
| 571 | <!-- Enable/Disable --> | |
| 572 | <div class="setting-row"> | |
| 573 | <label> | |
| 574 | <input type="checkbox" id="bridge-enabled" checked> | |
| 575 | Enable Bridge | |
| 576 | </label> | |
| 577 | </div> | |
| 578 | ||
| 579 | <!-- Relay URL --> | |
| 580 | <div class="setting-row"> | |
| 581 | <label>Relay URL:</label> | |
| 582 | <select id="relay-url"> | |
| 583 | <option value="https://7rnx.net/relay" selected>7rnx.net (Default)</option> | |
| 584 | <option value="https://pub.wiredove.net">pub.wiredove.net</option> | |
| 585 | <option value="custom">Custom...</option> | |
| 586 | </select> | |
| 587 | <input type="text" id="custom-relay-url" placeholder="https://..." style="display:none"> | |
| 588 | </div> | |
| 589 | ||
| 590 | <!-- Auto-start --> | |
| 591 | <div class="setting-row"> | |
| 592 | <label> | |
| 593 | <input type="checkbox" id="bridge-autostart" checked> | |
| 594 | Start bridge automatically with web server | |
| 595 | </label> | |
| 596 | </div> | |
| 597 | ||
| 598 | <!-- Status Display --> | |
| 599 | <div class="status-panel"> | |
| 600 | <h4>Status</h4> | |
| 601 | <div class="status-row"> | |
| 602 | <span class="label">Bridge:</span> | |
| 603 | <span id="bridge-status" class="status-badge running">Running</span> | |
| 604 | </div> | |
| 605 | <div class="status-row"> | |
| 606 | <span class="label">Last Sync:</span> | |
| 607 | <span id="bridge-last-sync">2 minutes ago</span> | |
| 608 | </div> | |
| 609 | <div class="status-row"> | |
| 610 | <span class="label">Pending Posts:</span> | |
| 611 | <span id="bridge-pending">0</span> | |
| 612 | </div> | |
| 613 | <div class="status-row"> | |
| 614 | <span class="label">Total Pushed:</span> | |
| 615 | <span id="bridge-pushed">42</span> | |
| 616 | </div> | |
| 617 | <div class="status-row"> | |
| 618 | <span class="label">Total Pulled:</span> | |
| 619 | <span id="bridge-pulled">156</span> | |
| 620 | </div> | |
| 621 | </div> | |
| 622 | ||
| 623 | <!-- Action Buttons --> | |
| 624 | <div class="button-row"> | |
| 625 | <button id="bridge-start-btn" class="btn btn-primary" disabled> | |
| 626 | ▶️ Start Bridge | |
| 627 | </button> | |
| 628 | <button id="bridge-stop-btn" class="btn btn-secondary"> | |
| 629 | ⏹️ Stop Bridge | |
| 630 | </button> | |
| 631 | <button id="bridge-sync-btn" class="btn btn-secondary"> | |
| 632 | 🔄 Sync Now | |
| 633 | </button> | |
| 634 | <button id="bridge-push-btn" class="btn btn-secondary"> | |
| 635 | 📤 Push All | |
| 636 | </button> | |
| 637 | <button id="bridge-pull-btn" class="btn btn-secondary"> | |
| 638 | 📥 Pull Latest | |
| 639 | </button> | |
| 640 | </div> | |
| 641 | ||
| 642 | <!-- Advanced Settings (Collapsible) --> | |
| 643 | <details> | |
| 644 | <summary>Advanced Settings</summary> | |
| 645 | <div class="setting-row"> | |
| 646 | <label>Sync Interval (minutes):</label> | |
| 647 | <input type="number" id="sync-interval" value="5" min="1" max="60"> | |
| 648 | </div> | |
| 649 | <div class="setting-row"> | |
| 650 | <label> | |
| 651 | <input type="checkbox" id="bridge-debug"> | |
| 652 | Enable debug logging | |
| 653 | </label> | |
| 654 | </div> | |
| 655 | </details> | |
| 656 | </div> | |
| 657 | ``` | |
| 658 | ||
| 659 | **API Endpoints** (add to index.js): | |
| 660 | ||
| 661 | ```javascript | |
| 662 | // GET /api/bridge/status - Get bridge status | |
| 663 | case '/api/bridge/status': | |
| 664 | const bridgeStatus = await getBridgeStatus() | |
| 665 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 666 | res.end(JSON.stringify(bridgeStatus)) | |
| 667 | break | |
| 668 | ||
| 669 | // POST /api/bridge/start - Start bridge | |
| 670 | case '/api/bridge/start': | |
| 671 | if (req.method === 'POST') { | |
| 672 | await startBridge() | |
| 673 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 674 | res.end(JSON.stringify({ success: true })) | |
| 675 | } | |
| 676 | break | |
| 677 | ||
| 678 | // POST /api/bridge/stop - Stop bridge | |
| 679 | case '/api/bridge/stop': | |
| 680 | if (req.method === 'POST') { | |
| 681 | await stopBridge() | |
| 682 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 683 | res.end(JSON.stringify({ success: true })) | |
| 684 | } | |
| 685 | break | |
| 686 | ||
| 687 | // POST /api/bridge/sync - Trigger sync | |
| 688 | case '/api/bridge/sync': | |
| 689 | if (req.method === 'POST') { | |
| 690 | await syncBridge() | |
| 691 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 692 | res.end(JSON.stringify({ success: true })) | |
| 693 | } | |
| 694 | break | |
| 695 | ||
| 696 | // POST /api/bridge/config - Update config | |
| 697 | case '/api/bridge/config': | |
| 698 | if (req.method === 'POST') { | |
| 699 | const body = await readBody(req) | |
| 700 | const config = JSON.parse(body) | |
| 701 | await updateBridgeConfig(config) | |
| 702 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 703 | res.end(JSON.stringify({ success: true })) | |
| 704 | } | |
| 705 | break | |
| 706 | ``` | |
| 707 | ||
| 708 | **Bridge Process Management** (in index.js): | |
| 709 | ||
| 710 | ```javascript | |
| 711 | import { spawn } from 'bare-subprocess' | |
| 712 | ||
| 713 | let bridgeProcess = null | |
| 714 | let bridgeConfig = { | |
| 715 | enabled: true, | |
| 716 | relayUrl: 'https://7rnx.net/relay', | |
| 717 | autoStart: true, | |
| 718 | syncInterval: 300000 | |
| 719 | } | |
| 720 | ||
| 721 | /** | |
| 722 | * Start bridge as child process | |
| 723 | */ | |
| 724 | async function startBridge() { | |
| 725 | if (bridgeProcess) { | |
| 726 | console.log('Bridge already running') | |
| 727 | return | |
| 728 | } | |
| 729 | ||
| 730 | console.log('🚀 Starting bridge...') | |
| 731 | ||
| 732 | bridgeProcess = spawn( | |
| 733 | process.execPath, | |
| 734 | ['bridge/cli.js', 'start'], | |
| 735 | { | |
| 736 | env: { | |
| 737 | ...process.env, | |
| 738 | RELAY_URL: bridgeConfig.relayUrl, | |
| 739 | PEAR_STORAGE: STORAGE_PATH | |
| 740 | }, | |
| 741 | stdio: 'inherit' | |
| 742 | } | |
| 743 | ) | |
| 744 | ||
| 745 | bridgeProcess.on('exit', (code) => { | |
| 746 | console.log(`Bridge exited with code ${code}`) | |
| 747 | bridgeProcess = null | |
| 748 | }) | |
| 749 | ||
| 750 | bridgeProcess.on('error', (err) => { | |
| 751 | console.error('Bridge error:', err.message) | |
| 752 | bridgeProcess = null | |
| 753 | }) | |
| 754 | ||
| 755 | console.log('✅ Bridge started (PID:', bridgeProcess.pid, ')') | |
| 756 | } | |
| 757 | ||
| 758 | /** | |
| 759 | * Stop bridge process | |
| 760 | */ | |
| 761 | async function stopBridge() { | |
| 762 | if (!bridgeProcess) { | |
| 763 | console.log('Bridge not running') | |
| 764 | return | |
| 765 | } | |
| 766 | ||
| 767 | console.log('⏹️ Stopping bridge...') | |
| 768 | bridgeProcess.kill() | |
| 769 | bridgeProcess = null | |
| 770 | console.log('✅ Bridge stopped') | |
| 771 | } | |
| 772 | ||
| 773 | /** | |
| 774 | * Get bridge status | |
| 775 | */ | |
| 776 | async function getBridgeStatus() { | |
| 777 | // Load sync state from bridge-sync Hyperbee | |
| 778 | const syncBee = new Hyperbee(store.get({ name: 'bridge-sync' }), { | |
| 779 | keyEncoding: 'utf-8', | |
| 780 | valueEncoding: 'json' | |
| 781 | }) | |
| 782 | await syncBee.ready() | |
| 783 | ||
| 784 | const lastSyncedNode = await syncBee.get('last-synced-index') | |
| 785 | const lastSynced = lastSyncedNode ? lastSyncedNode.value : 0 | |
| 786 | const pending = myFeed.length - lastSynced | |
| 787 | ||
| 788 | const statsNode = await syncBee.get('stats') | |
| 789 | const stats = statsNode ? statsNode.value : { totalPushed: 0, totalPulled: 0 } | |
| 790 | ||
| 791 | return { | |
| 792 | enabled: bridgeConfig.enabled, | |
| 793 | status: bridgeProcess ? 'running' : 'stopped', | |
| 794 | relayUrl: bridgeConfig.relayUrl, | |
| 795 | autoStart: bridgeConfig.autoStart, | |
| 796 | syncInterval: bridgeConfig.syncInterval, | |
| 797 | lastSync: stats.lastSync || null, | |
| 798 | pendingPosts: pending, | |
| 799 | stats | |
| 800 | } | |
| 801 | } | |
| 802 | ||
| 803 | /** | |
| 804 | * Trigger one-time sync | |
| 805 | */ | |
| 806 | async function syncBridge() { | |
| 807 | console.log('🔄 Triggering bridge sync...') | |
| 808 | ||
| 809 | // Spawn one-off sync process | |
| 810 | const syncProc = spawn( | |
| 811 | process.execPath, | |
| 812 | ['bridge/cli.js', 'sync'], | |
| 813 | { | |
| 814 | env: { | |
| 815 | ...process.env, | |
| 816 | RELAY_URL: bridgeConfig.relayUrl, | |
| 817 | PEAR_STORAGE: STORAGE_PATH | |
| 818 | }, | |
| 819 | stdio: 'inherit' | |
| 820 | } | |
| 821 | ) | |
| 822 | ||
| 823 | return new Promise((resolve, reject) => { | |
| 824 | syncProc.on('exit', (code) => { | |
| 825 | if (code === 0) { | |
| 826 | console.log('✅ Sync complete') | |
| 827 | resolve() | |
| 828 | } else { | |
| 829 | reject(new Error(`Sync failed with code ${code}`)) | |
| 830 | } | |
| 831 | }) | |
| 832 | syncProc.on('error', reject) | |
| 833 | }) | |
| 834 | } | |
| 835 | ||
| 836 | /** | |
| 837 | * Update bridge configuration | |
| 838 | */ | |
| 839 | async function updateBridgeConfig(newConfig) { | |
| 840 | bridgeConfig = { ...bridgeConfig, ...newConfig } | |
| 841 | ||
| 842 | // Save to metadata | |
| 843 | await metaBee.put('bridge-config', bridgeConfig) | |
| 844 | ||
| 845 | // If relay URL changed and bridge is running, restart | |
| 846 | if (bridgeProcess && newConfig.relayUrl) { | |
| 847 | await stopBridge() | |
| 848 | await startBridge() | |
| 849 | } | |
| 850 | } | |
| 851 | ||
| 852 | /** | |
| 853 | * Auto-start bridge if enabled | |
| 854 | */ | |
| 855 | async function autoStartBridge() { | |
| 856 | // Load saved config | |
| 857 | const configNode = await metaBee.get('bridge-config') | |
| 858 | if (configNode) { | |
| 859 | bridgeConfig = configNode.value | |
| 860 | } | |
| 861 | ||
| 862 | if (bridgeConfig.enabled && bridgeConfig.autoStart) { | |
| 863 | await startBridge() | |
| 864 | } | |
| 865 | } | |
| 866 | ``` | |
| 867 | ||
| 868 | **Web Server Initialization** (in startWebServer): | |
| 869 | ||
| 870 | ```javascript | |
| 871 | async function startWebServer(port = 3003) { | |
| 872 | // ... existing code ... | |
| 873 | ||
| 874 | // Auto-start bridge if enabled | |
| 875 | await autoStartBridge() | |
| 876 | ||
| 877 | console.log('🚀 Wirecore Web Server running!') | |
| 878 | // ... | |
| 879 | } | |
| 880 | ``` | |
| 881 | ||
| 882 | **Frontend JavaScript** (public/app-simple.js): | |
| 883 | ||
| 884 | ```javascript | |
| 885 | // Load bridge status on page load | |
| 886 | async function loadBridgeStatus() { | |
| 887 | try { | |
| 888 | const res = await fetch('/api/bridge/status') | |
| 889 | const status = await res.json() | |
| 890 | ||
| 891 | // Update UI | |
| 892 | document.getElementById('bridge-status').textContent = status.status | |
| 893 | document.getElementById('bridge-status').className = `status-badge ${status.status}` | |
| 894 | document.getElementById('bridge-last-sync').textContent = | |
| 895 | status.lastSync ? new Date(status.lastSync).toLocaleString() : 'Never' | |
| 896 | document.getElementById('bridge-pending').textContent = status.pendingPosts | |
| 897 | document.getElementById('bridge-pushed').textContent = status.stats.totalPushed | |
| 898 | document.getElementById('bridge-pulled').textContent = status.stats.totalPulled | |
| 899 | ||
| 900 | // Update button states | |
| 901 | document.getElementById('bridge-start-btn').disabled = status.status === 'running' | |
| 902 | document.getElementById('bridge-stop-btn').disabled = status.status !== 'running' | |
| 903 | } catch (err) { | |
| 904 | console.error('Failed to load bridge status:', err) | |
| 905 | } | |
| 906 | } | |
| 907 | ||
| 908 | // Start bridge | |
| 909 | document.getElementById('bridge-start-btn').addEventListener('click', async () => { | |
| 910 | try { | |
| 911 | await fetch('/api/bridge/start', { method: 'POST' }) | |
| 912 | await loadBridgeStatus() | |
| 913 | } catch (err) { | |
| 914 | alert('Failed to start bridge: ' + err.message) | |
| 915 | } | |
| 916 | }) | |
| 917 | ||
| 918 | // Stop bridge | |
| 919 | document.getElementById('bridge-stop-btn').addEventListener('click', async () => { | |
| 920 | try { | |
| 921 | await fetch('/api/bridge/stop', { method: 'POST' }) | |
| 922 | await loadBridgeStatus() | |
| 923 | } catch (err) { | |
| 924 | alert('Failed to stop bridge: ' + err.message) | |
| 925 | } | |
| 926 | }) | |
| 927 | ||
| 928 | // Sync now | |
| 929 | document.getElementById('bridge-sync-btn').addEventListener('click', async () => { | |
| 930 | try { | |
| 931 | await fetch('/api/bridge/sync', { method: 'POST' }) | |
| 932 | alert('Sync complete!') | |
| 933 | await loadBridgeStatus() | |
| 934 | } catch (err) { | |
| 935 | alert('Sync failed: ' + err.message) | |
| 936 | } | |
| 937 | }) | |
| 938 | ||
| 939 | // Save config | |
| 940 | document.getElementById('save-bridge-config').addEventListener('click', async () => { | |
| 941 | const config = { | |
| 942 | enabled: document.getElementById('bridge-enabled').checked, | |
| 943 | relayUrl: document.getElementById('relay-url').value, | |
| 944 | autoStart: document.getElementById('bridge-autostart').checked, | |
| 945 | syncInterval: parseInt(document.getElementById('sync-interval').value) * 60000 | |
| 946 | } | |
| 947 | ||
| 948 | try { | |
| 949 | await fetch('/api/bridge/config', { | |
| 950 | method: 'POST', | |
| 951 | headers: { 'Content-Type': 'application/json' }, | |
| 952 | body: JSON.stringify(config) | |
| 953 | }) | |
| 954 | alert('Bridge config saved!') | |
| 955 | } catch (err) { | |
| 956 | alert('Failed to save config: ' + err.message) | |
| 957 | } | |
| 958 | }) | |
| 959 | ||
| 960 | // Refresh status every 10 seconds | |
| 961 | setInterval(loadBridgeStatus, 10000) | |
| 962 | ``` | |
| 963 | ||
| 964 | ### Systemd Service (Production) | |
| 965 | ||
| 966 | ```ini | |
| 967 | # /etc/systemd/system/wirecore-bridge.service | |
| 968 | ||
| 969 | [Unit] | |
| 970 | Description=Wirecore Relay Bridge | |
| 971 | After=network.target | |
| 972 | ||
| 973 | [Service] | |
| 974 | Type=simple | |
| 975 | User=fleeky | |
| 976 | Environment=RELAY_URL=https://7rnx.net/relay | |
| 977 | Environment=PEAR_STORAGE=/home/fleeky/.config/pear/app-storage/by-dkey/<hash> | |
| 978 | WorkingDirectory=/storage/projects/code/hypercore-kb/hyper-fleeky/repos/wirecore/pear-app | |
| 979 | ExecStart=/home/fleeky/.config/pear/bin/pear run . bridge start | |
| 980 | Restart=always | |
| 981 | RestartSec=10 | |
| 982 | ||
| 983 | [Install] | |
| 984 | WantedBy=multi-user.target | |
| 985 | ``` | |
| 986 | ||
| 987 | ```bash | |
| 988 | systemctl enable wirecore-bridge | |
| 989 | systemctl start wirecore-bridge | |
| 990 | journalctl -u wirecore-bridge -f | |
| 991 | ``` | |
| 992 | ||
| 993 | **Note**: When running as systemd service, web UI control is limited to viewing status only. Full control (start/stop) requires web server to manage bridge as child process. | |
| 994 | ||
| 995 | --- | |
| 996 | ||
| 997 | ## 🔐 STORAGE ACCESS PATTERNS | |
| 998 | ||
| 999 | ### Pear App: Read-Write | |
| 1000 | ||
| 1001 | ```javascript | |
| 1002 | // Pear app has full access to own feed | |
| 1003 | const myFeed = store.get({ | |
| 1004 | publicKey: keypair.publicKey, | |
| 1005 | secretKey: keypair.secretKey // Can append | |
| 1006 | }) | |
| 1007 | ||
| 1008 | await myFeed.append(data) // ✅ Can write | |
| 1009 | ``` | |
| 1010 | ||
| 1011 | ### Bridge: Read-Only (Own Feed) | |
| 1012 | ||
| 1013 | ```javascript | |
| 1014 | // Bridge reads own feed to detect new posts | |
| 1015 | const myFeed = store.get({ | |
| 1016 | publicKey: keypair.publicKey, | |
| 1017 | secretKey: keypair.secretKey // Needed to open, but won't append | |
| 1018 | }) | |
| 1019 | ||
| 1020 | for (let i = lastSynced; i < myFeed.length; i++) { | |
| 1021 | const block = await myFeed.get(i) // ✅ Can read | |
| 1022 | // Push to relay | |
| 1023 | } | |
| 1024 | ``` | |
| 1025 | ||
| 1026 | ### Bridge: Read-Only (Followed Feeds) | |
| 1027 | ||
| 1028 | ```javascript | |
| 1029 | // Bridge can read followed feeds for future features | |
| 1030 | const followedFeed = store.get(publicKey) // No secret key = read-only | |
| 1031 | await followedFeed.ready() | |
| 1032 | ||
| 1033 | for (let i = 0; i < followedFeed.length; i++) { | |
| 1034 | const block = await followedFeed.get(i) // ✅ Can read | |
| 1035 | } | |
| 1036 | ``` | |
| 1037 | ||
| 1038 | **No lock conflicts**: Multiple processes can read from same Corestore simultaneously. | |
| 1039 | ||
| 1040 | --- | |
| 1041 | ||
| 1042 | ## 🌟 BENEFITS OF THIS ARCHITECTURE | |
| 1043 | ||
| 1044 | ### 1. Clean Separation | |
| 1045 | ||
| 1046 | | Component | Responsibility | Dependencies | | |
| 1047 | |-----------|---------------|--------------| | |
| 1048 | | Pear App | P2P operations | Hypercore, Hyperswarm | | |
| 1049 | | Bridge CLI | Relay sync | RelayClient, Corestore (read) | | |
| 1050 | | Relay | APDS storage | Hypercore backend, Bridge | | |
| 1051 | ||
| 1052 | ### 2. Optional Service | |
| 1053 | ||
| 1054 | ```bash | |
| 1055 | # Without bridge: Pure P2P | |
| 1056 | pear run . cli post "P2P only" | |
| 1057 | # Works, no relay | |
| 1058 | ||
| 1059 | # With bridge: P2P + Relay | |
| 1060 | pear run . bridge start & | |
| 1061 | pear run . cli post "P2P + Relay" | |
| 1062 | # Syncs to relay automatically | |
| 1063 | ``` | |
| 1064 | ||
| 1065 | ### 3. Flexible Deployment | |
| 1066 | ||
| 1067 | **Scenario A: Single user, local bridge** | |
| 1068 | ``` | |
| 1069 | User machine: | |
| 1070 | - pear-app (CLI) | |
| 1071 | - bridge (daemon) | |
| 1072 | - 7rnx.net/relay (remote) | |
| 1073 | ``` | |
| 1074 | ||
| 1075 | **Scenario B: Multi-user, shared bridge** | |
| 1076 | ``` | |
| 1077 | Server: | |
| 1078 | - bridge (daemon, syncs multiple users) | |
| 1079 | - 7rnx.net/relay (remote) | |
| 1080 | ||
| 1081 | User machines: | |
| 1082 | - pear-app (CLI only) | |
| 1083 | - P2P replication to server | |
| 1084 | - Bridge handles relay sync | |
| 1085 | ``` | |
| 1086 | ||
| 1087 | **Scenario C: Remote bridge** | |
| 1088 | ``` | |
| 1089 | Machine A: | |
| 1090 | - pear-app (CLI) | |
| 1091 | ||
| 1092 | Machine B: | |
| 1093 | - bridge (accesses A's storage via network) | |
| 1094 | - 7rnx.net/relay | |
| 1095 | ``` | |
| 1096 | ||
| 1097 | ### 4. Easier Debugging | |
| 1098 | ||
| 1099 | ```bash | |
| 1100 | # Check bridge status | |
| 1101 | pear run . bridge status | |
| 1102 | ||
| 1103 | # Bridge logs (if systemd) | |
| 1104 | journalctl -u wirecore-bridge -f | |
| 1105 | ||
| 1106 | # Pear app logs (separate) | |
| 1107 | pear run . cli post "test" --verbose | |
| 1108 | ``` | |
| 1109 | ||
| 1110 | ### 5. Future Extensions | |
| 1111 | ||
| 1112 | - **Bridge plugins**: Add support for other relays (Nostr, ActivityPub) | |
| 1113 | - **Multi-relay**: Sync to multiple relays simultaneously | |
| 1114 | - **Selective sync**: Only sync followed users to relay | |
| 1115 | - **Conflict resolution**: Handle relay vs P2P conflicts | |
| 1116 | - **Metrics**: Track sync performance, relay latency | |
| 1117 | ||
| 1118 | --- | |
| 1119 | ||
| 1120 | ## 📊 COMPARISON: Direct Integration vs Bridge Pattern | |
| 1121 | ||
| 1122 | | Aspect | Direct Integration | Bridge Pattern | | |
| 1123 | |--------|-------------------|----------------| | |
| 1124 | | Pear app complexity | High (relay logic) | Low (pure P2P) | | |
| 1125 | | Relay failure impact | Breaks posts | Queued by bridge | | |
| 1126 | | Code modularity | Mixed concerns | Clean separation | | |
| 1127 | | Testability | Hard (coupled) | Easy (isolated) | | |
| 1128 | | Deployment options | Limited | Flexible | | |
| 1129 | | Maintenance | Tightly coupled | Independent | | |
| 1130 | | holepunchto philosophy | ❌ Monolithic | ✅ Unix-like | | |
| 1131 | ||
| 1132 | --- | |
| 1133 | ||
| 1134 | ## 🚀 IMPLEMENTATION PLAN | |
| 1135 | ||
| 1136 | ### Week 1: Core Bridge | |
| 1137 | ||
| 1138 | - Create `WirecoreBridge` class | |
| 1139 | - Implement `push()` and `pull()` | |
| 1140 | - Add sync state tracking | |
| 1141 | - Basic CLI commands | |
| 1142 | ||
| 1143 | ### Week 2: Daemon Mode | |
| 1144 | ||
| 1145 | - Add `watch()` mode (auto-push) | |
| 1146 | - Add `start()` daemon mode | |
| 1147 | - Periodic pull from relay | |
| 1148 | - Error handling and retry logic | |
| 1149 | ||
| 1150 | ### Week 3: Production Deploy | |
| 1151 | ||
| 1152 | - Systemd service file | |
| 1153 | - Configuration options | |
| 1154 | - Logging and monitoring | |
| 1155 | - Documentation and examples | |
| 1156 | ||
| 1157 | --- | |
| 1158 | ||
| 1159 | ## 🎯 CONCLUSION | |
| 1160 | ||
| 1161 | **The Bridge Pattern is Superior Because**: | |
| 1162 | ||
| 1163 | 1. ✅ **Separation of concerns**: Pear app stays focused on P2P | |
| 1164 | 2. ✅ **Optional**: Bridge is addon, not requirement | |
| 1165 | 3. ✅ **Flexible**: Can run as daemon, systemd service, or one-shot | |
| 1166 | 4. ✅ **Resilient**: Bridge failure doesn't break P2P | |
| 1167 | 5. ✅ **Modular**: Can swap relay implementation easily | |
| 1168 | 6. ✅ **Unix philosophy**: Do one thing well | |
| 1169 | 7. ✅ **Future-proof**: Easy to extend with plugins | |
| 1170 | ||
| 1171 | **This is how holepunchto would design it.** | |
| 1172 | ||
| 1173 | --- | |
| 1174 | ||
| 1175 | **Document**: RELAY_BRIDGE_ARCHITECTURE.md | |
| 1176 | **Recommendation**: Implement bridge pattern | |
| 1177 | **Estimated Effort**: 2-3 weeks (includes daemon + systemd) | |
| 1178 | **Risk**: Low (clean separation, proven patterns) |
| … | … | @@ -58,6 +58,8 @@ let userDrive = null // Hyperdrive for user files (images, etc.) |
| 58 | 58 | let identity = null // Legacy IdentityKey (will be removed in Phase 2) |
| 59 | 59 | let anprotoKeypair = null // ANProto keypair { publicKey: Buffer, secretKey: Buffer } |
| 60 | 60 | let follows = new Map() // discoveryKey -> { feed, publicKey, name } |
| 61 | let bridgeProcess = null // Bridge daemon child process (for web UI management) | |
| 62 | let bridgeConfig = { relayUrl: 'https://7rnx.net/relay', autoStart: false } // Bridge configuration | |
| 61 | 63 | |
| 62 | 64 | /** |
| 63 | 65 | * Initialize Corestore and load state (without starting swarm) |
| … | … | @@ -937,6 +939,19 @@ async function startWebServer(port = 3003) { |
| 937 | 939 | await startSwarm() |
| 938 | 940 | await loadFollows() |
| 939 | 941 | |
| 942 | // Load bridge configuration | |
| 943 | await loadBridgeConfig() | |
| 944 | ||
| 945 | // Auto-start bridge if configured | |
| 946 | if (bridgeConfig.autoStart) { | |
| 947 | try { | |
| 948 | await startBridge() | |
| 949 | console.log('✅ Bridge auto-started') | |
| 950 | } catch (err) { | |
| 951 | console.error('Failed to auto-start bridge:', err.message) | |
| 952 | } | |
| 953 | } | |
| 954 | ||
| 940 | 955 | // Create HTTP server |
| 941 | 956 | const server = http.createServer(async (req, res) => { |
| 942 | 957 | const url = new URL(req.url, 'http://localhost') |
| … | … | @@ -1222,6 +1237,72 @@ async function handleAPI(req, res, url) { |
| 1222 | 1237 | } |
| 1223 | 1238 | break |
| 1224 | 1239 | |
| 1240 | case '/api/bridge/status': | |
| 1241 | { | |
| 1242 | const status = await getBridgeStatus() | |
| 1243 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 1244 | res.end(JSON.stringify(status)) | |
| 1245 | } | |
| 1246 | break | |
| 1247 | ||
| 1248 | case '/api/bridge/start': | |
| 1249 | if (req.method === 'POST') { | |
| 1250 | try { | |
| 1251 | await startBridge() | |
| 1252 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 1253 | res.end(JSON.stringify({ success: true, message: 'Bridge started' })) | |
| 1254 | } catch (err) { | |
| 1255 | res.writeHead(500, { 'Content-Type': 'application/json' }) | |
| 1256 | res.end(JSON.stringify({ error: err.message })) | |
| 1257 | } | |
| 1258 | } | |
| 1259 | break | |
| 1260 | ||
| 1261 | case '/api/bridge/stop': | |
| 1262 | if (req.method === 'POST') { | |
| 1263 | try { | |
| 1264 | await stopBridge() | |
| 1265 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 1266 | res.end(JSON.stringify({ success: true, message: 'Bridge stopped' })) | |
| 1267 | } catch (err) { | |
| 1268 | res.writeHead(500, { 'Content-Type': 'application/json' }) | |
| 1269 | res.end(JSON.stringify({ error: err.message })) | |
| 1270 | } | |
| 1271 | } | |
| 1272 | break | |
| 1273 | ||
| 1274 | case '/api/bridge/sync': | |
| 1275 | if (req.method === 'POST') { | |
| 1276 | try { | |
| 1277 | const result = await syncBridge() | |
| 1278 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 1279 | res.end(JSON.stringify({ success: true, ...result })) | |
| 1280 | } catch (err) { | |
| 1281 | res.writeHead(500, { 'Content-Type': 'application/json' }) | |
| 1282 | res.end(JSON.stringify({ error: err.message })) | |
| 1283 | } | |
| 1284 | } | |
| 1285 | break | |
| 1286 | ||
| 1287 | case '/api/bridge/config': | |
| 1288 | if (req.method === 'GET') { | |
| 1289 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 1290 | res.end(JSON.stringify(bridgeConfig)) | |
| 1291 | } else if (req.method === 'POST') { | |
| 1292 | try { | |
| 1293 | const body = await readBody(req) | |
| 1294 | const newConfig = JSON.parse(body) | |
| 1295 | bridgeConfig = { ...bridgeConfig, ...newConfig } | |
| 1296 | await saveBridgeConfig() | |
| 1297 | res.writeHead(200, { 'Content-Type': 'application/json' }) | |
| 1298 | res.end(JSON.stringify({ success: true, config: bridgeConfig })) | |
| 1299 | } catch (err) { | |
| 1300 | res.writeHead(500, { 'Content-Type': 'application/json' }) | |
| 1301 | res.end(JSON.stringify({ error: err.message })) | |
| 1302 | } | |
| 1303 | } | |
| 1304 | break | |
| 1305 | ||
| 1225 | 1306 | default: |
| 1226 | 1307 | // Check if it's a directory profile request |
| 1227 | 1308 | if (url.pathname.startsWith('/api/directory/')) { |
| … | … | @@ -1470,6 +1551,122 @@ async function followUser(publicKeyInput, name) { |
| 1470 | 1551 | broadcast({ type: 'new_follow', publicKey: publicKeyHex, name }) |
| 1471 | 1552 | } |
| 1472 | 1553 | |
| 1554 | // Bridge management functions | |
| 1555 | ||
| 1556 | /** | |
| 1557 | * Get bridge status (running, stats, config) | |
| 1558 | */ | |
| 1559 | async function getBridgeStatus() { | |
| 1560 | const { WirecoreBridge } = await import('./bridge/wirecore-bridge.js') | |
| 1561 | const bridge = new WirecoreBridge(STORAGE_PATH, bridgeConfig.relayUrl) | |
| 1562 | ||
| 1563 | try { | |
| 1564 | await bridge.init() | |
| 1565 | const status = await bridge.status() | |
| 1566 | await bridge.close() | |
| 1567 | ||
| 1568 | return { | |
| 1569 | running: bridgeProcess !== null && !bridgeProcess.killed, | |
| 1570 | pid: bridgeProcess ? bridgeProcess.pid : null, | |
| 1571 | config: bridgeConfig, | |
| 1572 | status | |
| 1573 | } | |
| 1574 | } catch (err) { | |
| 1575 | return { | |
| 1576 | running: false, | |
| 1577 | pid: null, | |
| 1578 | config: bridgeConfig, | |
| 1579 | error: err.message | |
| 1580 | } | |
| 1581 | } | |
| 1582 | } | |
| 1583 | ||
| 1584 | /** | |
| 1585 | * Start bridge daemon as child process | |
| 1586 | */ | |
| 1587 | async function startBridge() { | |
| 1588 | if (bridgeProcess && !bridgeProcess.killed) { | |
| 1589 | throw new Error('Bridge already running') | |
| 1590 | } | |
| 1591 | ||
| 1592 | const { spawn } = await import('bare-subprocess') | |
| 1593 | ||
| 1594 | // Start bridge daemon | |
| 1595 | const pullInterval = bridgeConfig.pullInterval || 300 // 5 minutes default | |
| 1596 | bridgeProcess = spawn('pear', ['run', '.', 'bridge', 'start', '--interval', pullInterval.toString()], { | |
| 1597 | stdio: 'inherit', | |
| 1598 | cwd: process.cwd() | |
| 1599 | }) | |
| 1600 | ||
| 1601 | bridgeProcess.on('exit', (code) => { | |
| 1602 | console.log(`Bridge process exited with code ${code}`) | |
| 1603 | bridgeProcess = null | |
| 1604 | }) | |
| 1605 | ||
| 1606 | // Wait a moment to ensure it started | |
| 1607 | await new Promise(resolve => setTimeout(resolve, 1000)) | |
| 1608 | ||
| 1609 | if (bridgeProcess.killed) { | |
| 1610 | throw new Error('Bridge failed to start') | |
| 1611 | } | |
| 1612 | ||
| 1613 | console.log('✅ Bridge daemon started (PID:', bridgeProcess.pid, ')') | |
| 1614 | } | |
| 1615 | ||
| 1616 | /** | |
| 1617 | * Stop bridge daemon | |
| 1618 | */ | |
| 1619 | async function stopBridge() { | |
| 1620 | if (!bridgeProcess || bridgeProcess.killed) { | |
| 1621 | throw new Error('Bridge not running') | |
| 1622 | } | |
| 1623 | ||
| 1624 | bridgeProcess.kill('SIGTERM') | |
| 1625 | ||
| 1626 | // Wait for graceful shutdown | |
| 1627 | await new Promise(resolve => setTimeout(resolve, 2000)) | |
| 1628 | ||
| 1629 | if (!bridgeProcess.killed) { | |
| 1630 | bridgeProcess.kill('SIGKILL') | |
| 1631 | } | |
| 1632 | ||
| 1633 | bridgeProcess = null | |
| 1634 | console.log('✅ Bridge daemon stopped') | |
| 1635 | } | |
| 1636 | ||
| 1637 | /** | |
| 1638 | * One-time sync (push + pull) | |
| 1639 | */ | |
| 1640 | async function syncBridge() { | |
| 1641 | const { WirecoreBridge } = await import('./bridge/wirecore-bridge.js') | |
| 1642 | const bridge = new WirecoreBridge(STORAGE_PATH, bridgeConfig.relayUrl) | |
| 1643 | ||
| 1644 | await bridge.init() | |
| 1645 | const result = await bridge.sync() | |
| 1646 | await bridge.close() | |
| 1647 | ||
| 1648 | return result | |
| 1649 | } | |
| 1650 | ||
| 1651 | /** | |
| 1652 | * Save bridge config to metadata | |
| 1653 | */ | |
| 1654 | async function saveBridgeConfig() { | |
| 1655 | if (!metaBee) return | |
| 1656 | await metaBee.put('bridge-config', bridgeConfig) | |
| 1657 | } | |
| 1658 | ||
| 1659 | /** | |
| 1660 | * Load bridge config from metadata | |
| 1661 | */ | |
| 1662 | async function loadBridgeConfig() { | |
| 1663 | if (!metaBee) return | |
| 1664 | const node = await metaBee.get('bridge-config') | |
| 1665 | if (node && node.value) { | |
| 1666 | bridgeConfig = { ...bridgeConfig, ...node.value } | |
| 1667 | } | |
| 1668 | } | |
| 1669 | ||
| 1473 | 1670 | /** |
| 1474 | 1671 | * Show usage information |
| 1475 | 1672 | */ |
| … | … | @@ -721,6 +721,269 @@ async function showUserProfileModal(pubkey) { |
| 721 | 721 | } |
| 722 | 722 | } |
| 723 | 723 | |
| 724 | // Show settings modal | |
| 725 | async function showSettingsModal() { | |
| 726 | const modal = document.createElement('div') | |
| 727 | modal.className = 'modal-overlay' | |
| 728 | modal.innerHTML = ` | |
| 729 | <div class="modal settings-modal"> | |
| 730 | <h2>⚙️ Settings</h2> | |
| 731 | ||
| 732 | <div class="settings-section"> | |
| 733 | <h3>🌉 Relay Bridge</h3> | |
| 734 | <p class="settings-description"> | |
| 735 | Bridge your P2P posts to web relays for broader reach. | |
| 736 | </p> | |
| 737 | ||
| 738 | <div id="bridge-status-display" class="bridge-status"> | |
| 739 | <div class="status-loading">Loading bridge status...</div> | |
| 740 | </div> | |
| 741 | ||
| 742 | <div class="settings-field"> | |
| 743 | <label for="relay-url">Relay URL</label> | |
| 744 | <select id="relay-url"> | |
| 745 | <option value="https://7rnx.net/relay">7rnx.net (Default)</option> | |
| 746 | <option value="https://pub.wiredove.net">pub.wiredove.net</option> | |
| 747 | <option value="custom">Custom...</option> | |
| 748 | </select> | |
| 749 | <input type="text" id="custom-relay-url" placeholder="https://your-relay.com" style="display: none; margin-top: 8px;" /> | |
| 750 | </div> | |
| 751 | ||
| 752 | <div class="settings-field"> | |
| 753 | <label> | |
| 754 | <input type="checkbox" id="auto-start-bridge" /> | |
| 755 | Auto-start bridge with web server | |
| 756 | </label> | |
| 757 | </div> | |
| 758 | ||
| 759 | <div class="bridge-actions"> | |
| 760 | <button id="bridge-start-btn" class="primary">▶ Start Bridge</button> | |
| 761 | <button id="bridge-stop-btn">⏹ Stop Bridge</button> | |
| 762 | <button id="bridge-sync-btn">🔄 Sync Now</button> | |
| 763 | </div> | |
| 764 | ||
| 765 | <div id="bridge-stats" class="bridge-stats" style="display: none; margin-top: 16px;"> | |
| 766 | <strong>Statistics:</strong> | |
| 767 | <div class="stats-grid"> | |
| 768 | <div class="stat-item"> | |
| 769 | <span class="stat-label">Pending Posts:</span> | |
| 770 | <span id="stat-pending" class="stat-value">-</span> | |
| 771 | </div> | |
| 772 | <div class="stat-item"> | |
| 773 | <span class="stat-label">Total Pushed:</span> | |
| 774 | <span id="stat-pushed" class="stat-value">-</span> | |
| 775 | </div> | |
| 776 | <div class="stat-item"> | |
| 777 | <span class="stat-label">Total Pulled:</span> | |
| 778 | <span id="stat-pulled" class="stat-value">-</span> | |
| 779 | </div> | |
| 780 | </div> | |
| 781 | </div> | |
| 782 | </div> | |
| 783 | ||
| 784 | <div class="modal-actions"> | |
| 785 | <button id="settings-save" class="primary">Save Settings</button> | |
| 786 | <button id="settings-close">Close</button> | |
| 787 | </div> | |
| 788 | </div> | |
| 789 | ` | |
| 790 | document.body.appendChild(modal) | |
| 791 | ||
| 792 | const relayUrlSelect = modal.querySelector('#relay-url') | |
| 793 | const customRelayInput = modal.querySelector('#custom-relay-url') | |
| 794 | const autoStartCheckbox = modal.querySelector('#auto-start-bridge') | |
| 795 | const statusDisplay = modal.querySelector('#bridge-status-display') | |
| 796 | const startBtn = modal.querySelector('#bridge-start-btn') | |
| 797 | const stopBtn = modal.querySelector('#bridge-stop-btn') | |
| 798 | const syncBtn = modal.querySelector('#bridge-sync-btn') | |
| 799 | ||
| 800 | // Load current bridge config | |
| 801 | try { | |
| 802 | const configRes = await fetch('/api/bridge/config') | |
| 803 | const config = await configRes.json() | |
| 804 | ||
| 805 | // Set relay URL | |
| 806 | if (config.relayUrl === 'https://7rnx.net/relay' || config.relayUrl === 'https://pub.wiredove.net') { | |
| 807 | relayUrlSelect.value = config.relayUrl | |
| 808 | } else { | |
| 809 | relayUrlSelect.value = 'custom' | |
| 810 | customRelayInput.value = config.relayUrl | |
| 811 | customRelayInput.style.display = 'block' | |
| 812 | } | |
| 813 | ||
| 814 | // Set auto-start | |
| 815 | autoStartCheckbox.checked = config.autoStart || false | |
| 816 | ||
| 817 | // Load bridge status | |
| 818 | updateBridgeStatus() | |
| 819 | } catch (err) { | |
| 820 | console.error('Failed to load bridge config:', err) | |
| 821 | statusDisplay.innerHTML = '<div class="status-error">❌ Failed to load bridge status</div>' | |
| 822 | } | |
| 823 | ||
| 824 | // Show/hide custom URL input | |
| 825 | relayUrlSelect.onchange = () => { | |
| 826 | if (relayUrlSelect.value === 'custom') { | |
| 827 | customRelayInput.style.display = 'block' | |
| 828 | } else { | |
| 829 | customRelayInput.style.display = 'none' | |
| 830 | } | |
| 831 | } | |
| 832 | ||
| 833 | // Update bridge status display | |
| 834 | async function updateBridgeStatus() { | |
| 835 | try { | |
| 836 | const res = await fetch('/api/bridge/status') | |
| 837 | const status = await res.json() | |
| 838 | ||
| 839 | const relayOnline = status.status && status.status.relay && status.status.relay.online | |
| 840 | const bridgeRunning = status.running | |
| 841 | ||
| 842 | let statusHtml = '<div class="status-info">' | |
| 843 | statusHtml += `<div class="status-item"><strong>Bridge:</strong> ${bridgeRunning ? '🟢 Running' : '🔴 Stopped'}</div>` | |
| 844 | statusHtml += `<div class="status-item"><strong>Relay:</strong> ${relayOnline ? '🟢 Online' : '🔴 Offline'}</div>` | |
| 845 | ||
| 846 | if (status.status && status.status.sync) { | |
| 847 | const pending = status.status.sync.pendingPosts || 0 | |
| 848 | statusHtml += `<div class="status-item"><strong>Pending:</strong> ${pending} posts</div>` | |
| 849 | } | |
| 850 | ||
| 851 | if (status.status && status.status.stats) { | |
| 852 | const pushed = status.status.stats.totalPushed || 0 | |
| 853 | const pulled = status.status.stats.totalPulled || 0 | |
| 854 | statusHtml += `<div class="status-item"><strong>Synced:</strong> ${pushed} pushed, ${pulled} pulled</div>` | |
| 855 | ||
| 856 | // Update stats display | |
| 857 | const statsSection = modal.querySelector('#bridge-stats') | |
| 858 | if (statsSection) { | |
| 859 | statsSection.style.display = 'block' | |
| 860 | modal.querySelector('#stat-pending').textContent = pending | |
| 861 | modal.querySelector('#stat-pushed').textContent = pushed | |
| 862 | modal.querySelector('#stat-pulled').textContent = pulled | |
| 863 | } | |
| 864 | } | |
| 865 | ||
| 866 | statusHtml += '</div>' | |
| 867 | statusDisplay.innerHTML = statusHtml | |
| 868 | ||
| 869 | // Update button states | |
| 870 | startBtn.disabled = bridgeRunning | |
| 871 | stopBtn.disabled = !bridgeRunning | |
| 872 | ||
| 873 | } catch (err) { | |
| 874 | console.error('Failed to load bridge status:', err) | |
| 875 | statusDisplay.innerHTML = '<div class="status-error">❌ Error loading status</div>' | |
| 876 | } | |
| 877 | } | |
| 878 | ||
| 879 | // Start bridge | |
| 880 | startBtn.onclick = async () => { | |
| 881 | try { | |
| 882 | startBtn.disabled = true | |
| 883 | startBtn.textContent = '⏳ Starting...' | |
| 884 | ||
| 885 | const res = await fetch('/api/bridge/start', { method: 'POST' }) | |
| 886 | if (!res.ok) throw new Error('Failed to start bridge') | |
| 887 | ||
| 888 | showToast('Bridge started', 'success') | |
| 889 | await updateBridgeStatus() | |
| 890 | ||
| 891 | startBtn.textContent = '▶ Start Bridge' | |
| 892 | } catch (err) { | |
| 893 | console.error('Failed to start bridge:', err) | |
| 894 | showToast('Failed to start bridge: ' + err.message, 'error') | |
| 895 | startBtn.textContent = '▶ Start Bridge' | |
| 896 | startBtn.disabled = false | |
| 897 | } | |
| 898 | } | |
| 899 | ||
| 900 | // Stop bridge | |
| 901 | stopBtn.onclick = async () => { | |
| 902 | try { | |
| 903 | stopBtn.disabled = true | |
| 904 | stopBtn.textContent = '⏳ Stopping...' | |
| 905 | ||
| 906 | const res = await fetch('/api/bridge/stop', { method: 'POST' }) | |
| 907 | if (!res.ok) throw new Error('Failed to stop bridge') | |
| 908 | ||
| 909 | showToast('Bridge stopped', 'success') | |
| 910 | await updateBridgeStatus() | |
| 911 | ||
| 912 | stopBtn.textContent = '⏹ Stop Bridge' | |
| 913 | } catch (err) { | |
| 914 | console.error('Failed to stop bridge:', err) | |
| 915 | showToast('Failed to stop bridge: ' + err.message, 'error') | |
| 916 | stopBtn.textContent = '⏹ Stop Bridge' | |
| 917 | stopBtn.disabled = false | |
| 918 | } | |
| 919 | } | |
| 920 | ||
| 921 | // Sync now | |
| 922 | syncBtn.onclick = async () => { | |
| 923 | try { | |
| 924 | syncBtn.disabled = true | |
| 925 | syncBtn.textContent = '⏳ Syncing...' | |
| 926 | ||
| 927 | const res = await fetch('/api/bridge/sync', { method: 'POST' }) | |
| 928 | if (!res.ok) throw new Error('Failed to sync') | |
| 929 | ||
| 930 | const result = await res.json() | |
| 931 | showToast(`Synced: ${result.pushed || 0} pushed, ${result.pulled || 0} pulled`, 'success') | |
| 932 | await updateBridgeStatus() | |
| 933 | ||
| 934 | syncBtn.textContent = '🔄 Sync Now' | |
| 935 | syncBtn.disabled = false | |
| 936 | } catch (err) { | |
| 937 | console.error('Failed to sync:', err) | |
| 938 | showToast('Failed to sync: ' + err.message, 'error') | |
| 939 | syncBtn.textContent = '🔄 Sync Now' | |
| 940 | syncBtn.disabled = false | |
| 941 | } | |
| 942 | } | |
| 943 | ||
| 944 | // Save settings | |
| 945 | modal.querySelector('#settings-save').onclick = async () => { | |
| 946 | try { | |
| 947 | const saveBtn = modal.querySelector('#settings-save') | |
| 948 | saveBtn.disabled = true | |
| 949 | saveBtn.textContent = 'Saving...' | |
| 950 | ||
| 951 | const relayUrl = relayUrlSelect.value === 'custom' ? customRelayInput.value.trim() : relayUrlSelect.value | |
| 952 | const autoStart = autoStartCheckbox.checked | |
| 953 | ||
| 954 | if (!relayUrl) { | |
| 955 | showToast('Please enter a relay URL', 'error') | |
| 956 | saveBtn.disabled = false | |
| 957 | saveBtn.textContent = 'Save Settings' | |
| 958 | return | |
| 959 | } | |
| 960 | ||
| 961 | const res = await fetch('/api/bridge/config', { | |
| 962 | method: 'POST', | |
| 963 | headers: { 'Content-Type': 'application/json' }, | |
| 964 | body: JSON.stringify({ relayUrl, autoStart }) | |
| 965 | }) | |
| 966 | ||
| 967 | if (!res.ok) throw new Error('Failed to save config') | |
| 968 | ||
| 969 | showToast('Settings saved', 'success') | |
| 970 | saveBtn.textContent = 'Save Settings' | |
| 971 | saveBtn.disabled = false | |
| 972 | } catch (err) { | |
| 973 | console.error('Failed to save settings:', err) | |
| 974 | showToast('Failed to save settings: ' + err.message, 'error') | |
| 975 | modal.querySelector('#settings-save').disabled = false | |
| 976 | modal.querySelector('#settings-save').textContent = 'Save Settings' | |
| 977 | } | |
| 978 | } | |
| 979 | ||
| 980 | // Close modal | |
| 981 | modal.querySelector('#settings-close').onclick = () => modal.remove() | |
| 982 | modal.onclick = (e) => { | |
| 983 | if (e.target === modal) modal.remove() | |
| 984 | } | |
| 985 | } | |
| 986 | ||
| 724 | 987 | // Setup event listeners |
| 725 | 988 | function setupEventListeners() { |
| 726 | 989 | // Compose button |
| … | … | @@ -755,6 +1018,12 @@ function setupEventListeners() { |
| 755 | 1018 | if (searchBtn) { |
| 756 | 1019 | searchBtn.onclick = showSearchModal |
| 757 | 1020 | } |
| 1021 | ||
| 1022 | // Settings button | |
| 1023 | const settingsBtn = document.getElementById('settings-btn') | |
| 1024 | if (settingsBtn) { | |
| 1025 | settingsBtn.onclick = showSettingsModal | |
| 1026 | } | |
| 758 | 1027 | } |
| 759 | 1028 | |
| 760 | 1029 | // Escape HTML to prevent XSS |
| … | … | @@ -0,0 +1,60 @@ |
| 1 | <!doctype html> | |
| 2 | <html lang="en"> | |
| 3 | <head> | |
| 4 | <meta charset="UTF-8"> | |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | <title>Wirecore - P2P Social Network</title> | |
| 7 | <link rel="icon" type="image/svg+xml" href="favicon.svg"> | |
| 8 | ||
| 9 | <!-- Fonts --> | |
| 10 | <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| 11 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| 12 | <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;600&family=Source+Sans+3:wght@300;400;600;700&display=swap" rel="stylesheet"> | |
| 13 | <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet"> | |
| 14 | ||
| 15 | <!-- Styles --> | |
| 16 | <link rel="stylesheet" href="style.css"> | |
| 17 | ||
| 18 | <!-- Scripts --> | |
| 19 | <script type="module" src="app-simple.js"></script> | |
| 20 | </head> | |
| 21 | <body> | |
| 22 | <!-- Navbar --> | |
| 23 | <nav id="navbar"> | |
| 24 | <div class="navbar-left"> | |
| 25 | <span class="logo">🕊️ Wirecore</span> | |
| 26 | <button id="compose-btn" class="icon-btn" title="Compose"> | |
| 27 | <span class="material-symbols-outlined">Edit_Square</span> | |
| 28 | </button> | |
| 29 | <button id="refresh-btn" class="icon-btn" title="Refresh"> | |
| 30 | <span class="material-symbols-outlined">Refresh</span> | |
| 31 | </button> | |
| 32 | <button id="follow-btn" class="icon-btn" title="Follow User"> | |
| 33 | <span class="material-symbols-outlined">Person_Add</span> | |
| 34 | </button> | |
| 35 | <button id="search-btn" class="icon-btn" title="Search"> | |
| 36 | <span class="material-symbols-outlined">Search</span> | |
| 37 | </button> | |
| 38 | </div> | |
| 39 | <div class="navbar-right"> | |
| 40 | <span id="pubkey-display" class="pubkey"></span> | |
| 41 | <button id="settings-btn" class="icon-btn" title="Settings"> | |
| 42 | <span class="material-symbols-outlined">Settings</span> | |
| 43 | </button> | |
| 44 | <button id="profile-btn" class="icon-btn" title="Profile"> | |
| 45 | <span class="material-symbols-outlined">Person</span> | |
| 46 | </button> | |
| 47 | </div> | |
| 48 | </nav> | |
| 49 | ||
| 50 | <!-- Main content --> | |
| 51 | <div id="scroller"> | |
| 52 | <div id="timeline"></div> | |
| 53 | </div> | |
| 54 | ||
| 55 | <!-- Toast notifications --> | |
| 56 | <div id="toast-container"></div> | |
| 57 | ||
| 58 | <!-- Modals will be appended here --> | |
| 59 | </body> | |
| 60 | </html> |
| … | … | @@ -0,0 +1,1116 @@ |
| 1 | /* Fedicore Web UI - Inspired by Wiredove */ | |
| 2 | ||
| 3 | * { | |
| 4 | box-sizing: border-box; | |
| 5 | } | |
| 6 | ||
| 7 | body { | |
| 8 | background-color: #f2f2f2; | |
| 9 | color: #444; | |
| 10 | font-family: "Source Sans 3", sans-serif; | |
| 11 | max-width: 100%; | |
| 12 | margin: 0; | |
| 13 | padding-top: 60px; | |
| 14 | padding-bottom: 10em; | |
| 15 | } | |
| 16 | ||
| 17 | #scroller { | |
| 18 | max-width: 680px; | |
| 19 | margin-left: auto; | |
| 20 | margin-right: auto; | |
| 21 | padding: 1em; | |
| 22 | } | |
| 23 | ||
| 24 | /* Navbar */ | |
| 25 | #navbar { | |
| 26 | padding: 0.75em 1em; | |
| 27 | position: fixed; | |
| 28 | width: 100%; | |
| 29 | z-index: 1000; | |
| 30 | top: 0; | |
| 31 | left: 0; | |
| 32 | background-color: rgba(248, 248, 248, 0.9); | |
| 33 | border-bottom: 1px solid #eee; | |
| 34 | backdrop-filter: blur(10px); | |
| 35 | display: flex; | |
| 36 | align-items: center; | |
| 37 | justify-content: space-between; | |
| 38 | gap: 1em; | |
| 39 | } | |
| 40 | ||
| 41 | .navbar-left, | |
| 42 | .navbar-right { | |
| 43 | display: flex; | |
| 44 | align-items: center; | |
| 45 | gap: 0.5em; | |
| 46 | } | |
| 47 | ||
| 48 | .navbar-right { | |
| 49 | margin-left: auto; | |
| 50 | } | |
| 51 | ||
| 52 | .logo { | |
| 53 | font-weight: 600; | |
| 54 | font-size: 1.1em; | |
| 55 | margin-right: 0.5em; | |
| 56 | } | |
| 57 | ||
| 58 | /* Buttons */ | |
| 59 | button { | |
| 60 | font-size: 0.9em; | |
| 61 | font-family: "Source Sans 3", sans-serif; | |
| 62 | background: #fff; | |
| 63 | background-image: linear-gradient(to bottom, #ffffff, #f2f2f2); | |
| 64 | border: 1px solid #e4e4e4; | |
| 65 | padding: 0.5em 1em; | |
| 66 | border-radius: 5px; | |
| 67 | cursor: pointer; | |
| 68 | transition: all 0.2s; | |
| 69 | } | |
| 70 | ||
| 71 | button:hover { | |
| 72 | background: #f2f2f2; | |
| 73 | } | |
| 74 | ||
| 75 | button:disabled { | |
| 76 | opacity: 0.5; | |
| 77 | cursor: not-allowed; | |
| 78 | } | |
| 79 | ||
| 80 | .icon-btn { | |
| 81 | padding: 0.4em 0.6em; | |
| 82 | display: inline-flex; | |
| 83 | align-items: center; | |
| 84 | justify-content: center; | |
| 85 | } | |
| 86 | ||
| 87 | .icon-btn .material-symbols-outlined { | |
| 88 | font-size: 20px; | |
| 89 | } | |
| 90 | ||
| 91 | /* Messages */ | |
| 92 | .message { | |
| 93 | padding: 1em; | |
| 94 | background: #f8f8f8; | |
| 95 | border: 1px solid #f5f5f5; | |
| 96 | margin-bottom: 0.75em; | |
| 97 | border-radius: 8px; | |
| 98 | animation: fadein 0.3s; | |
| 99 | transition: all 0.2s; | |
| 100 | } | |
| 101 | ||
| 102 | .message:hover { | |
| 103 | border: 1px solid #eee; | |
| 104 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); | |
| 105 | } | |
| 106 | ||
| 107 | .message-clickable { | |
| 108 | cursor: pointer; | |
| 109 | transition: all 0.2s; | |
| 110 | } | |
| 111 | ||
| 112 | .message-clickable:hover { | |
| 113 | transform: translateY(-2px); | |
| 114 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); | |
| 115 | } | |
| 116 | ||
| 117 | @keyframes fadein { | |
| 118 | from { opacity: 0; transform: translateY(-10px); } | |
| 119 | to { opacity: 1; transform: translateY(0); } | |
| 120 | } | |
| 121 | ||
| 122 | .message-header { | |
| 123 | display: flex; | |
| 124 | align-items: center; | |
| 125 | gap: 0.75em; | |
| 126 | margin-bottom: 0.5em; | |
| 127 | } | |
| 128 | ||
| 129 | .avatar { | |
| 130 | width: 40px; | |
| 131 | height: 40px; | |
| 132 | border-radius: 50%; | |
| 133 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| 134 | display: flex; | |
| 135 | align-items: center; | |
| 136 | justify-content: center; | |
| 137 | color: white; | |
| 138 | font-weight: 600; | |
| 139 | font-size: 1.1em; | |
| 140 | } | |
| 141 | ||
| 142 | .message-meta { | |
| 143 | flex: 1; | |
| 144 | } | |
| 145 | ||
| 146 | .avatar-link { | |
| 147 | text-decoration: none; | |
| 148 | cursor: pointer; | |
| 149 | transition: opacity 0.2s; | |
| 150 | } | |
| 151 | ||
| 152 | .avatar-link:hover { | |
| 153 | opacity: 0.8; | |
| 154 | } | |
| 155 | ||
| 156 | .message-author { | |
| 157 | font-weight: 600; | |
| 158 | color: #333; | |
| 159 | margin-right: 0.5em; | |
| 160 | text-decoration: none; | |
| 161 | cursor: pointer; | |
| 162 | transition: color 0.2s; | |
| 163 | } | |
| 164 | ||
| 165 | .message-author:hover { | |
| 166 | color: #045fd0; | |
| 167 | text-decoration: underline; | |
| 168 | } | |
| 169 | ||
| 170 | .message-pubkey { | |
| 171 | font-family: "Roboto Mono", monospace; | |
| 172 | font-size: 0.8em; | |
| 173 | color: #999; | |
| 174 | } | |
| 175 | ||
| 176 | .message-time { | |
| 177 | font-size: 0.85em; | |
| 178 | color: #999; | |
| 179 | display: block; | |
| 180 | margin-top: 0.15em; | |
| 181 | } | |
| 182 | ||
| 183 | .message-content { | |
| 184 | margin: 0.75em 0; | |
| 185 | line-height: 1.5; | |
| 186 | word-wrap: break-word; | |
| 187 | } | |
| 188 | ||
| 189 | .message-content p { | |
| 190 | margin: 0 0 0.5em 0; | |
| 191 | } | |
| 192 | ||
| 193 | .message-content p:last-child { | |
| 194 | margin-bottom: 0; | |
| 195 | } | |
| 196 | ||
| 197 | .message-content a { | |
| 198 | color: #045fd0; | |
| 199 | text-decoration: none; | |
| 200 | transition: color 0.2s; | |
| 201 | } | |
| 202 | ||
| 203 | .message-content a:hover { | |
| 204 | color: #8d82fe; | |
| 205 | text-decoration: underline; | |
| 206 | } | |
| 207 | ||
| 208 | .message-content a.hashtag { | |
| 209 | color: #10b981; | |
| 210 | font-weight: 500; | |
| 211 | } | |
| 212 | ||
| 213 | .message-content a.hashtag:hover { | |
| 214 | color: #059669; | |
| 215 | } | |
| 216 | ||
| 217 | .message-content img.post-image { | |
| 218 | max-width: 100%; | |
| 219 | height: auto; | |
| 220 | border-radius: 8px; | |
| 221 | margin: 0.75em 0; | |
| 222 | cursor: zoom-in; | |
| 223 | transition: opacity 0.2s; | |
| 224 | } | |
| 225 | ||
| 226 | .message-content img.post-image:hover { | |
| 227 | opacity: 0.9; | |
| 228 | } | |
| 229 | ||
| 230 | .message-image { | |
| 231 | margin: 0.75em 0; | |
| 232 | } | |
| 233 | ||
| 234 | .message-image img { | |
| 235 | max-width: 100%; | |
| 236 | height: auto; | |
| 237 | border-radius: 8px; | |
| 238 | cursor: zoom-in; | |
| 239 | transition: opacity 0.2s; | |
| 240 | } | |
| 241 | ||
| 242 | .message-image img:hover { | |
| 243 | opacity: 0.9; | |
| 244 | } | |
| 245 | ||
| 246 | /* Image Lightbox */ | |
| 247 | .image-lightbox { | |
| 248 | position: fixed; | |
| 249 | top: 0; | |
| 250 | left: 0; | |
| 251 | width: 100%; | |
| 252 | height: 100%; | |
| 253 | background: rgba(0, 0, 0, 0.9); | |
| 254 | z-index: 3000; | |
| 255 | display: flex; | |
| 256 | align-items: center; | |
| 257 | justify-content: center; | |
| 258 | padding: 2em; | |
| 259 | animation: fadein 0.2s; | |
| 260 | cursor: zoom-out; | |
| 261 | } | |
| 262 | ||
| 263 | .image-lightbox img { | |
| 264 | max-width: 90vw; | |
| 265 | max-height: 90vh; | |
| 266 | border-radius: 4px; | |
| 267 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); | |
| 268 | } | |
| 269 | ||
| 270 | .image-lightbox-close { | |
| 271 | position: absolute; | |
| 272 | top: 1em; | |
| 273 | right: 1em; | |
| 274 | background: rgba(255, 255, 255, 0.1); | |
| 275 | backdrop-filter: blur(10px); | |
| 276 | border: none; | |
| 277 | color: white; | |
| 278 | font-size: 2em; | |
| 279 | width: 50px; | |
| 280 | height: 50px; | |
| 281 | border-radius: 50%; | |
| 282 | cursor: pointer; | |
| 283 | display: flex; | |
| 284 | align-items: center; | |
| 285 | justify-content: center; | |
| 286 | transition: background 0.2s; | |
| 287 | } | |
| 288 | ||
| 289 | .image-lightbox-close:hover { | |
| 290 | background: rgba(255, 255, 255, 0.2); | |
| 291 | } | |
| 292 | ||
| 293 | .message-actions { | |
| 294 | display: flex; | |
| 295 | gap: 1em; | |
| 296 | margin-top: 0.75em; | |
| 297 | padding-top: 0.75em; | |
| 298 | border-top: 1px solid #f0f0f0; | |
| 299 | } | |
| 300 | ||
| 301 | .action-btn { | |
| 302 | background: none; | |
| 303 | border: none; | |
| 304 | padding: 0.25em 0.5em; | |
| 305 | font-size: 0.85em; | |
| 306 | color: #666; | |
| 307 | display: inline-flex; | |
| 308 | align-items: center; | |
| 309 | gap: 0.35em; | |
| 310 | cursor: pointer; | |
| 311 | transition: color 0.2s; | |
| 312 | } | |
| 313 | ||
| 314 | .action-btn:hover { | |
| 315 | color: #045fd0; | |
| 316 | } | |
| 317 | ||
| 318 | .action-btn .material-symbols-outlined { | |
| 319 | font-size: 18px; | |
| 320 | } | |
| 321 | ||
| 322 | /* Modal */ | |
| 323 | .modal-overlay { | |
| 324 | position: fixed; | |
| 325 | top: 0; | |
| 326 | left: 0; | |
| 327 | width: 100%; | |
| 328 | height: 100%; | |
| 329 | background: rgba(0, 0, 0, 0.5); | |
| 330 | backdrop-filter: blur(5px); | |
| 331 | z-index: 2000; | |
| 332 | display: flex; | |
| 333 | justify-content: center; | |
| 334 | align-items: center; | |
| 335 | padding: 1em; | |
| 336 | animation: fadein 0.2s; | |
| 337 | } | |
| 338 | ||
| 339 | .modal { | |
| 340 | background: #fff; | |
| 341 | border: 1px solid #e4e4e4; | |
| 342 | border-radius: 8px; | |
| 343 | padding: 2em; | |
| 344 | width: 100%; | |
| 345 | max-width: 500px; | |
| 346 | max-height: 90vh; | |
| 347 | overflow-y: auto; | |
| 348 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); | |
| 349 | } | |
| 350 | ||
| 351 | .modal h2 { | |
| 352 | margin: 0 0 1.5em 0; | |
| 353 | font-size: 1.5em; | |
| 354 | color: #333; | |
| 355 | } | |
| 356 | ||
| 357 | .modal-content { | |
| 358 | background: #f8f8f8; | |
| 359 | border: 1px solid #f5f5f5; | |
| 360 | border-radius: 8px; | |
| 361 | padding: 1.5em; | |
| 362 | width: 100%; | |
| 363 | max-width: 600px; | |
| 364 | max-height: 90vh; | |
| 365 | overflow-y: auto; | |
| 366 | } | |
| 367 | ||
| 368 | .modal-header { | |
| 369 | display: flex; | |
| 370 | justify-content: space-between; | |
| 371 | align-items: center; | |
| 372 | margin-bottom: 1em; | |
| 373 | } | |
| 374 | ||
| 375 | .modal-title { | |
| 376 | font-size: 1.2em; | |
| 377 | font-weight: 600; | |
| 378 | } | |
| 379 | ||
| 380 | .close-btn { | |
| 381 | background: none; | |
| 382 | border: none; | |
| 383 | font-size: 1.5em; | |
| 384 | cursor: pointer; | |
| 385 | padding: 0; | |
| 386 | color: #999; | |
| 387 | } | |
| 388 | ||
| 389 | .close-btn:hover { | |
| 390 | color: #666; | |
| 391 | } | |
| 392 | ||
| 393 | /* Profile Modal */ | |
| 394 | .profile-modal { | |
| 395 | max-width: 550px; | |
| 396 | } | |
| 397 | ||
| 398 | .profile-section { | |
| 399 | margin-bottom: 2em; | |
| 400 | padding-bottom: 1.5em; | |
| 401 | border-bottom: 1px solid #f0f0f0; | |
| 402 | } | |
| 403 | ||
| 404 | .profile-section:last-of-type { | |
| 405 | border-bottom: none; | |
| 406 | margin-bottom: 0; | |
| 407 | padding-bottom: 0; | |
| 408 | } | |
| 409 | ||
| 410 | .profile-section strong { | |
| 411 | display: block; | |
| 412 | font-size: 0.9em; | |
| 413 | text-transform: uppercase; | |
| 414 | letter-spacing: 0.5px; | |
| 415 | color: #666; | |
| 416 | margin-bottom: 0.75em; | |
| 417 | } | |
| 418 | ||
| 419 | .profile-header { | |
| 420 | display: flex; | |
| 421 | align-items: center; | |
| 422 | justify-content: space-between; | |
| 423 | margin-bottom: 0.75em; | |
| 424 | } | |
| 425 | ||
| 426 | .profile-header strong { | |
| 427 | margin: 0; | |
| 428 | } | |
| 429 | ||
| 430 | .edit-btn { | |
| 431 | background: none; | |
| 432 | border: none; | |
| 433 | padding: 0.25em; | |
| 434 | color: #666; | |
| 435 | cursor: pointer; | |
| 436 | display: inline-flex; | |
| 437 | align-items: center; | |
| 438 | transition: color 0.2s; | |
| 439 | } | |
| 440 | ||
| 441 | .edit-btn:hover { | |
| 442 | color: #045fd0; | |
| 443 | background: none; | |
| 444 | } | |
| 445 | ||
| 446 | .edit-btn .material-symbols-outlined { | |
| 447 | font-size: 20px; | |
| 448 | } | |
| 449 | ||
| 450 | .username-display { | |
| 451 | font-size: 1.3em; | |
| 452 | font-weight: 600; | |
| 453 | color: #333; | |
| 454 | padding: 0.5em 0; | |
| 455 | } | |
| 456 | ||
| 457 | .username-display em { | |
| 458 | font-weight: 400; | |
| 459 | font-size: 0.85em; | |
| 460 | } | |
| 461 | ||
| 462 | .username-edit-form { | |
| 463 | margin-top: 0.5em; | |
| 464 | } | |
| 465 | ||
| 466 | .username-edit-form input { | |
| 467 | margin-bottom: 0.75em; | |
| 468 | font-size: 1.1em; | |
| 469 | } | |
| 470 | ||
| 471 | .edit-actions { | |
| 472 | display: flex; | |
| 473 | gap: 0.5em; | |
| 474 | margin-bottom: 0.5em; | |
| 475 | } | |
| 476 | ||
| 477 | .edit-actions button { | |
| 478 | flex: 1; | |
| 479 | } | |
| 480 | ||
| 481 | .pubkey-full { | |
| 482 | font-family: "Roboto Mono", monospace; | |
| 483 | font-size: 0.8em; | |
| 484 | color: #666; | |
| 485 | background: #f8f8f8; | |
| 486 | padding: 0.75em; | |
| 487 | border-radius: 5px; | |
| 488 | display: block; | |
| 489 | word-break: break-all; | |
| 490 | transition: background 0.2s; | |
| 491 | margin-top: 0.5em; | |
| 492 | } | |
| 493 | ||
| 494 | .pubkey-full:hover { | |
| 495 | background: #f0f0f0; | |
| 496 | } | |
| 497 | ||
| 498 | .profile-info { | |
| 499 | margin-bottom: 2em; | |
| 500 | } | |
| 501 | ||
| 502 | .modal-actions { | |
| 503 | display: flex; | |
| 504 | justify-content: flex-end; | |
| 505 | gap: 0.5em; | |
| 506 | margin-top: 2em; | |
| 507 | padding-top: 1.5em; | |
| 508 | border-top: 1px solid #f0f0f0; | |
| 509 | } | |
| 510 | ||
| 511 | .modal-actions button { | |
| 512 | min-width: 100px; | |
| 513 | } | |
| 514 | ||
| 515 | button.primary { | |
| 516 | background: #045fd0; | |
| 517 | background-image: linear-gradient(to bottom, #0567e0, #045fd0); | |
| 518 | color: white; | |
| 519 | border-color: #034ba0; | |
| 520 | font-weight: 600; | |
| 521 | } | |
| 522 | ||
| 523 | button.primary:hover { | |
| 524 | background: #0567e0; | |
| 525 | } | |
| 526 | ||
| 527 | button.primary:disabled { | |
| 528 | background: #93c5fd; | |
| 529 | border-color: #93c5fd; | |
| 530 | } | |
| 531 | ||
| 532 | /* Forms */ | |
| 533 | textarea, input[type="text"] { | |
| 534 | width: 100%; | |
| 535 | font-size: 1em; | |
| 536 | font-family: "Source Sans 3", sans-serif; | |
| 537 | border: 1px solid #e4e4e4; | |
| 538 | border-radius: 5px; | |
| 539 | background: #fff; | |
| 540 | color: #444; | |
| 541 | padding: 0.75em; | |
| 542 | margin-bottom: 1em; | |
| 543 | transition: border 0.2s; | |
| 544 | } | |
| 545 | ||
| 546 | textarea:focus, input:focus { | |
| 547 | outline: none; | |
| 548 | border-color: #045fd0; | |
| 549 | } | |
| 550 | ||
| 551 | textarea { | |
| 552 | resize: vertical; | |
| 553 | min-height: 120px; | |
| 554 | } | |
| 555 | ||
| 556 | label { | |
| 557 | display: block; | |
| 558 | font-weight: 600; | |
| 559 | margin-bottom: 0.5em; | |
| 560 | color: #333; | |
| 561 | } | |
| 562 | ||
| 563 | /* Public key display */ | |
| 564 | .pubkey { | |
| 565 | font-family: "Roboto Mono", monospace; | |
| 566 | font-size: 0.85em; | |
| 567 | color: #999; | |
| 568 | } | |
| 569 | ||
| 570 | /* Toast notifications */ | |
| 571 | #toast-container { | |
| 572 | position: fixed; | |
| 573 | bottom: 2em; | |
| 574 | right: 2em; | |
| 575 | z-index: 3000; | |
| 576 | display: flex; | |
| 577 | flex-direction: column; | |
| 578 | gap: 0.5em; | |
| 579 | } | |
| 580 | ||
| 581 | .toast { | |
| 582 | background: #333; | |
| 583 | color: #fff; | |
| 584 | padding: 1em 1.5em; | |
| 585 | border-radius: 5px; | |
| 586 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); | |
| 587 | animation: slidein 0.3s; | |
| 588 | max-width: 300px; | |
| 589 | } | |
| 590 | ||
| 591 | @keyframes slidein { | |
| 592 | from { transform: translateX(400px); opacity: 0; } | |
| 593 | to { transform: translateX(0); opacity: 1; } | |
| 594 | } | |
| 595 | ||
| 596 | .toast.success { | |
| 597 | background: #10b981; | |
| 598 | } | |
| 599 | ||
| 600 | .toast.error { | |
| 601 | background: #ef4444; | |
| 602 | } | |
| 603 | ||
| 604 | /* Loading state */ | |
| 605 | .loading { | |
| 606 | text-align: center; | |
| 607 | padding: 2em; | |
| 608 | color: #999; | |
| 609 | } | |
| 610 | ||
| 611 | .loading::after { | |
| 612 | content: '...'; | |
| 613 | animation: dots 1.5s infinite; | |
| 614 | } | |
| 615 | ||
| 616 | @keyframes dots { | |
| 617 | 0%, 20% { content: '.'; } | |
| 618 | 40% { content: '..'; } | |
| 619 | 60%, 100% { content: '...'; } | |
| 620 | } | |
| 621 | ||
| 622 | /* Empty state */ | |
| 623 | .empty-state { | |
| 624 | text-align: center; | |
| 625 | padding: 3em 1em; | |
| 626 | color: #999; | |
| 627 | } | |
| 628 | ||
| 629 | .empty-state h3 { | |
| 630 | margin-bottom: 0.5em; | |
| 631 | color: #666; | |
| 632 | } | |
| 633 | ||
| 634 | /* Dark mode */ | |
| 635 | @media (prefers-color-scheme: dark) { | |
| 636 | body { | |
| 637 | background-color: #181818; | |
| 638 | color: #ccc; | |
| 639 | } | |
| 640 | ||
| 641 | #navbar { | |
| 642 | background-color: rgba(34, 34, 34, 0.9); | |
| 643 | border-bottom: 1px solid #333; | |
| 644 | } | |
| 645 | ||
| 646 | .message { | |
| 647 | background-color: #222; | |
| 648 | border: 1px solid #1e1e1e; | |
| 649 | } | |
| 650 | ||
| 651 | .message:hover { | |
| 652 | border: 1px solid #333; | |
| 653 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); | |
| 654 | } | |
| 655 | ||
| 656 | .modal { | |
| 657 | background-color: #222; | |
| 658 | border: 1px solid #333; | |
| 659 | } | |
| 660 | ||
| 661 | .modal h2 { | |
| 662 | color: #ddd; | |
| 663 | } | |
| 664 | ||
| 665 | .modal-content { | |
| 666 | background-color: #222; | |
| 667 | border: 1px solid #1e1e1e; | |
| 668 | } | |
| 669 | ||
| 670 | .profile-section { | |
| 671 | border-bottom-color: #2a2a2a; | |
| 672 | } | |
| 673 | ||
| 674 | .profile-section strong { | |
| 675 | color: #999; | |
| 676 | } | |
| 677 | ||
| 678 | .username-display { | |
| 679 | color: #ddd; | |
| 680 | } | |
| 681 | ||
| 682 | .pubkey-full { | |
| 683 | background: #1a1a1a; | |
| 684 | color: #999; | |
| 685 | } | |
| 686 | ||
| 687 | .pubkey-full:hover { | |
| 688 | background: #252525; | |
| 689 | } | |
| 690 | ||
| 691 | .edit-btn { | |
| 692 | color: #999; | |
| 693 | } | |
| 694 | ||
| 695 | .edit-btn:hover { | |
| 696 | color: #50afe4; | |
| 697 | } | |
| 698 | ||
| 699 | .modal-actions { | |
| 700 | border-top-color: #2a2a2a; | |
| 701 | } | |
| 702 | ||
| 703 | textarea, input { | |
| 704 | background: #1a1a1a; | |
| 705 | color: #ccc; | |
| 706 | border: 1px solid #333; | |
| 707 | } | |
| 708 | ||
| 709 | button { | |
| 710 | color: #ccc; | |
| 711 | background: #333; | |
| 712 | border: 1px solid #444; | |
| 713 | background-image: none; | |
| 714 | } | |
| 715 | ||
| 716 | button:hover { | |
| 717 | background: #3a3a3a; | |
| 718 | } | |
| 719 | ||
| 720 | .message-author { | |
| 721 | color: #ddd; | |
| 722 | } | |
| 723 | ||
| 724 | .message-author:hover { | |
| 725 | color: #50afe4; | |
| 726 | } | |
| 727 | ||
| 728 | .action-btn { | |
| 729 | color: #999; | |
| 730 | } | |
| 731 | ||
| 732 | .action-btn:hover { | |
| 733 | color: #50afe4; | |
| 734 | } | |
| 735 | ||
| 736 | .close-btn { | |
| 737 | color: #777; | |
| 738 | } | |
| 739 | ||
| 740 | .close-btn:hover { | |
| 741 | color: #999; | |
| 742 | } | |
| 743 | ||
| 744 | .message-content a { | |
| 745 | color: #50afe4; | |
| 746 | } | |
| 747 | ||
| 748 | .message-content a:hover { | |
| 749 | color: #8d82fe; | |
| 750 | } | |
| 751 | ||
| 752 | .message-content a.hashtag { | |
| 753 | color: #10b981; | |
| 754 | } | |
| 755 | ||
| 756 | .message-content a.hashtag:hover { | |
| 757 | color: #34d399; | |
| 758 | } | |
| 759 | } | |
| 760 | ||
| 761 | /* Responsive */ | |
| 762 | @media (max-width: 640px) { | |
| 763 | #navbar { | |
| 764 | padding: 0.5em 0.75em; | |
| 765 | gap: 0.5em; | |
| 766 | flex-wrap: nowrap; | |
| 767 | } | |
| 768 | ||
| 769 | .navbar-left, | |
| 770 | .navbar-right { | |
| 771 | gap: 0.25em; | |
| 772 | } | |
| 773 | ||
| 774 | .logo { | |
| 775 | font-size: 0.95em; | |
| 776 | margin-right: 0.25em; | |
| 777 | } | |
| 778 | ||
| 779 | .pubkey { | |
| 780 | display: none; | |
| 781 | } | |
| 782 | ||
| 783 | #scroller { | |
| 784 | padding: 0.5em; | |
| 785 | } | |
| 786 | ||
| 787 | .message { | |
| 788 | padding: 0.75em; | |
| 789 | } | |
| 790 | ||
| 791 | .modal-content { | |
| 792 | padding: 1em; | |
| 793 | } | |
| 794 | ||
| 795 | #toast-container { | |
| 796 | right: 1em; | |
| 797 | bottom: 1em; | |
| 798 | left: 1em; | |
| 799 | } | |
| 800 | ||
| 801 | .toast { | |
| 802 | max-width: none; | |
| 803 | } | |
| 804 | } | |
| 805 | ||
| 806 | /* Search Modal */ | |
| 807 | .search-modal { | |
| 808 | max-width: 600px; | |
| 809 | max-height: 80vh; | |
| 810 | display: flex; | |
| 811 | flex-direction: column; | |
| 812 | } | |
| 813 | ||
| 814 | .search-modal h2 { | |
| 815 | margin-bottom: 1em; | |
| 816 | } | |
| 817 | ||
| 818 | .search-modal #search-input { | |
| 819 | width: 100%; | |
| 820 | padding: 0.75em; | |
| 821 | font-size: 1rem; | |
| 822 | border: 1px solid #ddd; | |
| 823 | border-radius: 6px; | |
| 824 | margin-bottom: 1em; | |
| 825 | } | |
| 826 | ||
| 827 | .search-modal #search-input:focus { | |
| 828 | outline: none; | |
| 829 | border-color: #0084ff; | |
| 830 | } | |
| 831 | ||
| 832 | .search-results { | |
| 833 | flex: 1; | |
| 834 | overflow-y: auto; | |
| 835 | margin-bottom: 1em; | |
| 836 | min-height: 200px; | |
| 837 | max-height: 50vh; | |
| 838 | } | |
| 839 | ||
| 840 | .search-placeholder { | |
| 841 | text-align: center; | |
| 842 | color: #999; | |
| 843 | padding: 2em; | |
| 844 | } | |
| 845 | ||
| 846 | .search-placeholder.error { | |
| 847 | color: #d93025; | |
| 848 | } | |
| 849 | ||
| 850 | .user-item { | |
| 851 | display: flex; | |
| 852 | align-items: center; | |
| 853 | justify-content: space-between; | |
| 854 | padding: 1em; | |
| 855 | border: 1px solid #eee; | |
| 856 | border-radius: 6px; | |
| 857 | margin-bottom: 0.5em; | |
| 858 | background: #fff; | |
| 859 | transition: background 0.2s; | |
| 860 | } | |
| 861 | ||
| 862 | .user-item:hover { | |
| 863 | background: #f9f9f9; | |
| 864 | } | |
| 865 | ||
| 866 | .user-info { | |
| 867 | flex: 1; | |
| 868 | } | |
| 869 | ||
| 870 | .user-name { | |
| 871 | font-weight: 600; | |
| 872 | font-size: 1.1rem; | |
| 873 | margin-bottom: 0.25em; | |
| 874 | color: #333; | |
| 875 | } | |
| 876 | ||
| 877 | .user-pubkey { | |
| 878 | font-family: "Roboto Mono", monospace; | |
| 879 | font-size: 0.85rem; | |
| 880 | color: #666; | |
| 881 | margin-bottom: 0.25em; | |
| 882 | } | |
| 883 | ||
| 884 | .user-lastseen { | |
| 885 | font-size: 0.85rem; | |
| 886 | color: #999; | |
| 887 | } | |
| 888 | ||
| 889 | .view-profile-btn { | |
| 890 | padding: 0.5em 1em; | |
| 891 | background: #0084ff; | |
| 892 | color: white; | |
| 893 | border: none; | |
| 894 | border-radius: 6px; | |
| 895 | cursor: pointer; | |
| 896 | font-size: 0.9rem; | |
| 897 | transition: background 0.2s; | |
| 898 | } | |
| 899 | ||
| 900 | .view-profile-btn:hover { | |
| 901 | background: #0073e6; | |
| 902 | } | |
| 903 | ||
| 904 | /* Profile Modal */ | |
| 905 | .profile-modal { | |
| 906 | max-width: 700px; | |
| 907 | max-height: 85vh; | |
| 908 | overflow-y: auto; | |
| 909 | } | |
| 910 | ||
| 911 | .profile-modal h2 { | |
| 912 | margin-bottom: 1em; | |
| 913 | } | |
| 914 | ||
| 915 | .profile-modal h3 { | |
| 916 | margin-top: 1.5em; | |
| 917 | margin-bottom: 0.75em; | |
| 918 | font-size: 1.2rem; | |
| 919 | color: #333; | |
| 920 | } | |
| 921 | ||
| 922 | .profile-details { | |
| 923 | background: #f9f9f9; | |
| 924 | border: 1px solid #eee; | |
| 925 | border-radius: 6px; | |
| 926 | padding: 1em; | |
| 927 | margin-bottom: 1em; | |
| 928 | } | |
| 929 | ||
| 930 | .profile-field { | |
| 931 | margin-bottom: 0.75em; | |
| 932 | } | |
| 933 | ||
| 934 | .profile-field:last-child { | |
| 935 | margin-bottom: 0; | |
| 936 | } | |
| 937 | ||
| 938 | .profile-field strong { | |
| 939 | display: inline-block; | |
| 940 | min-width: 100px; | |
| 941 | color: #666; | |
| 942 | } | |
| 943 | ||
| 944 | .monospace { | |
| 945 | font-family: "Roboto Mono", monospace; | |
| 946 | font-size: 0.9rem; | |
| 947 | word-break: break-all; | |
| 948 | } | |
| 949 | ||
| 950 | .recent-posts { | |
| 951 | border: 1px solid #eee; | |
| 952 | border-radius: 6px; | |
| 953 | padding: 1em; | |
| 954 | background: #fff; | |
| 955 | margin-bottom: 1em; | |
| 956 | } | |
| 957 | ||
| 958 | .post-item { | |
| 959 | border-bottom: 1px solid #f0f0f0; | |
| 960 | padding: 1em 0; | |
| 961 | } | |
| 962 | ||
| 963 | .post-item:first-child { | |
| 964 | padding-top: 0; | |
| 965 | } | |
| 966 | ||
| 967 | .post-item:last-child { | |
| 968 | border-bottom: none; | |
| 969 | padding-bottom: 0; | |
| 970 | } | |
| 971 | ||
| 972 | .post-date { | |
| 973 | font-size: 0.85rem; | |
| 974 | color: #999; | |
| 975 | margin-bottom: 0.5em; | |
| 976 | } | |
| 977 | ||
| 978 | .post-text { | |
| 979 | line-height: 1.6; | |
| 980 | color: #333; | |
| 981 | white-space: pre-wrap; | |
| 982 | word-wrap: break-word; | |
| 983 | } | |
| 984 | ||
| 985 | /* Settings Modal */ | |
| 986 | .settings-modal { | |
| 987 | max-width: 700px; | |
| 988 | max-height: 85vh; | |
| 989 | overflow-y: auto; | |
| 990 | } | |
| 991 | ||
| 992 | .settings-section { | |
| 993 | margin-bottom: 2em; | |
| 994 | padding: 1.5em; | |
| 995 | background: #f9f9f9; | |
| 996 | border: 1px solid #eee; | |
| 997 | border-radius: 8px; | |
| 998 | } | |
| 999 | ||
| 1000 | .settings-section h3 { | |
| 1001 | margin-top: 0; | |
| 1002 | margin-bottom: 0.5em; | |
| 1003 | font-size: 1.2rem; | |
| 1004 | color: #333; | |
| 1005 | } | |
| 1006 | ||
| 1007 | .settings-description { | |
| 1008 | color: #666; | |
| 1009 | font-size: 0.95rem; | |
| 1010 | margin-bottom: 1.5em; | |
| 1011 | } | |
| 1012 | ||
| 1013 | .settings-field { | |
| 1014 | margin-bottom: 1em; | |
| 1015 | } | |
| 1016 | ||
| 1017 | .settings-field label { | |
| 1018 | display: block; | |
| 1019 | margin-bottom: 0.5em; | |
| 1020 | font-weight: 500; | |
| 1021 | color: #555; | |
| 1022 | } | |
| 1023 | ||
| 1024 | .settings-field input[type="text"], | |
| 1025 | .settings-field select { | |
| 1026 | width: 100%; | |
| 1027 | padding: 0.6em; | |
| 1028 | border: 1px solid #ddd; | |
| 1029 | border-radius: 4px; | |
| 1030 | font-family: "Source Sans 3", sans-serif; | |
| 1031 | font-size: 0.95rem; | |
| 1032 | } | |
| 1033 | ||
| 1034 | .settings-field input[type="checkbox"] { | |
| 1035 | margin-right: 0.5em; | |
| 1036 | } | |
| 1037 | ||
| 1038 | .bridge-status { | |
| 1039 | margin-bottom: 1.5em; | |
| 1040 | padding: 1em; | |
| 1041 | background: #fff; | |
| 1042 | border: 1px solid #ddd; | |
| 1043 | border-radius: 6px; | |
| 1044 | } | |
| 1045 | ||
| 1046 | .status-loading, | |
| 1047 | .status-error { | |
| 1048 | color: #666; | |
| 1049 | text-align: center; | |
| 1050 | padding: 0.5em; | |
| 1051 | } | |
| 1052 | ||
| 1053 | .status-error { | |
| 1054 | color: #d32f2f; | |
| 1055 | } | |
| 1056 | ||
| 1057 | .status-info { | |
| 1058 | display: flex; | |
| 1059 | flex-direction: column; | |
| 1060 | gap: 0.5em; | |
| 1061 | } | |
| 1062 | ||
| 1063 | .status-item { | |
| 1064 | display: flex; | |
| 1065 | align-items: center; | |
| 1066 | gap: 0.5em; | |
| 1067 | font-size: 0.95rem; | |
| 1068 | } | |
| 1069 | ||
| 1070 | .status-item strong { | |
| 1071 | min-width: 80px; | |
| 1072 | color: #666; | |
| 1073 | } | |
| 1074 | ||
| 1075 | .bridge-actions { | |
| 1076 | display: flex; | |
| 1077 | gap: 0.5em; | |
| 1078 | flex-wrap: wrap; | |
| 1079 | } | |
| 1080 | ||
| 1081 | .bridge-actions button { | |
| 1082 | flex: 1; | |
| 1083 | min-width: 120px; | |
| 1084 | } | |
| 1085 | ||
| 1086 | .bridge-stats { | |
| 1087 | padding: 1em; | |
| 1088 | background: #f5f5f5; | |
| 1089 | border: 1px solid #ddd; | |
| 1090 | border-radius: 6px; | |
| 1091 | } | |
| 1092 | ||
| 1093 | .stats-grid { | |
| 1094 | display: grid; | |
| 1095 | grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); | |
| 1096 | gap: 1em; | |
| 1097 | margin-top: 1em; | |
| 1098 | } | |
| 1099 | ||
| 1100 | .stat-item { | |
| 1101 | display: flex; | |
| 1102 | flex-direction: column; | |
| 1103 | gap: 0.25em; | |
| 1104 | } | |
| 1105 | ||
| 1106 | .stat-label { | |
| 1107 | font-size: 0.85rem; | |
| 1108 | color: #666; | |
| 1109 | font-weight: 500; | |
| 1110 | } | |
| 1111 | ||
| 1112 | .stat-value { | |
| 1113 | font-size: 1.3rem; | |
| 1114 | color: #333; | |
| 1115 | font-weight: 600; | |
| 1116 | } |