OverviewFilesCommitsIssuesDocsReleasesPeers

wirecore commits / a5e5f06

Add bridge web UI with settings panel
- Added bridge API endpoints: status, start, stop, sync, config - Created settings modal with bridge controls and status display - Added bridge process management functions - Implemented auto-start configuration option - Added bridge statistics display (pending, pushed, pulled) - Styled settings UI with comprehensive CSS - Released as v4181 Users can now manage the relay bridge directly from the web interface in Settings. The bridge syncs posts between P2P feeds and web relays. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
a5e5f0601f63240b716c9d6199cf7fcbe391a5c1
by plankatron <fleeky@gmail.com> · 2026-01-22T15:42:19.000Z

Changed files (5) +2820 -0

RELAY_BRIDGE_ARCHITECTURE.md +1178 -0
……@@ -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
11Instead of integrating relay directly into the pear-app, create a **separate bridge CLI** that:
12
131. Reads from local Hypercore storage (same Corestore as pear-app)
142. Watches for new posts in feeds
153. Pushes to 7rnx.net/relay when new content appears
164. Pulls from relay and stores in local Hyperbee cache
175. 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
1081. **Run bridge as systemd service** (like wirecore-relay)
1092. **Bridge can sync multiple users** on same machine
1103. **Bridge failure doesn't break P2P** (graceful degradation)
1114. **Can run bridge remotely** (access via network)
1125. **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)
151pear run . bridge start --relay https://7rnx.net/relay
152
153# One-time push of all local posts to relay
154pear run . bridge push
155
156# One-time pull from relay to local cache
157pear run . bridge pull
158
159# Sync both directions
160pear run . bridge sync
161
162# Watch for new posts and auto-push
163pear run . bridge watch
164
165# Status check
166pear 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
183import Corestore from 'corestore'
184import Hyperbee from 'hyperbee'
185import { RelayClient } from '../lib/relay-client.js'
186
187export 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
405import { WirecoreBridge } from './wirecore-bridge.js'
406
407const args = process.argv.slice(2)
408const command = args[0]
409
410// Get storage path from pear-app
411const storagePath = process.env.PEAR_STORAGE || './wirecore-storage'
412const relayUrl = process.env.RELAY_URL || 'https://7rnx.net/relay'
413
414const bridge = new WirecoreBridge(storagePath, relayUrl)
415
416async 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
460main().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```
4731. User: pear run . cli post "Hello"
474 └─> Writes to own Hypercore feed
475
4762. Hypercore feed emits 'append' event
477 └─> Bridge detects new post (if running)
478
4793. Bridge: reads post from feed
480 └─> Extracts YAML, creates signature
481 └─> Pushes to 7rnx.net/relay via WebSocket
482
4834. Relay: stores in APDS
484 └─> Bridges to pub.wiredove.net
485
4865. Wiredove users see the post
487```
488
489### Timeline Flow (with Bridge)
490
491```
4921. User: pear run . cli timeline
493 └─> Reads from followed Hypercore feeds (P2P)
494
4952. Bridge running in background:
496 └─> Periodically pulls from relay
497 └─> Stores relay posts in local Hyperbee cache
498
4993. 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
512cd pear-app
513export RELAY_URL=https://7rnx.net/relay
514pear run . bridge start
515
516# Terminal 2: Use pear-app normally
517pear run . cli post "Hello P2P world!"
518# Bridge automatically pushes to relay
519
520pear 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
528pear run . bridge push
529
530# Pull relay posts to local cache
531pear run . bridge pull
532
533# Do both
534pear 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
663case '/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
670case '/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
679case '/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
688case '/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
697case '/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
711import { spawn } from 'bare-subprocess'
712
713let bridgeProcess = null
714let 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 */
724async 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 */
761async 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 */
776async 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 */
806async 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 */
839async 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 */
855async 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
871async 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
886async 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
909document.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
919document.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
929document.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
940document.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
961setInterval(loadBridgeStatus, 10000)
962```
963
964### Systemd Service (Production)
965
966```ini
967# /etc/systemd/system/wirecore-bridge.service
968
969[Unit]
970Description=Wirecore Relay Bridge
971After=network.target
972
973[Service]
974Type=simple
975User=fleeky
976Environment=RELAY_URL=https://7rnx.net/relay
977Environment=PEAR_STORAGE=/home/fleeky/.config/pear/app-storage/by-dkey/<hash>
978WorkingDirectory=/storage/projects/code/hypercore-kb/hyper-fleeky/repos/wirecore/pear-app
979ExecStart=/home/fleeky/.config/pear/bin/pear run . bridge start
980Restart=always
981RestartSec=10
982
983[Install]
984WantedBy=multi-user.target
985```
986
987```bash
988systemctl enable wirecore-bridge
989systemctl start wirecore-bridge
990journalctl -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
1003const myFeed = store.get({
1004 publicKey: keypair.publicKey,
1005 secretKey: keypair.secretKey // Can append
1006})
1007
1008await 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
1015const myFeed = store.get({
1016 publicKey: keypair.publicKey,
1017 secretKey: keypair.secretKey // Needed to open, but won't append
1018})
1019
1020for (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
1030const followedFeed = store.get(publicKey) // No secret key = read-only
1031await followedFeed.ready()
1032
1033for (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
1056pear run . cli post "P2P only"
1057# Works, no relay
1058
1059# With bridge: P2P + Relay
1060pear run . bridge start &
1061pear 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```
1069User machine:
1070 - pear-app (CLI)
1071 - bridge (daemon)
1072 - 7rnx.net/relay (remote)
1073```
1074
1075**Scenario B: Multi-user, shared bridge**
1076```
1077Server:
1078 - bridge (daemon, syncs multiple users)
1079 - 7rnx.net/relay (remote)
1080
1081User machines:
1082 - pear-app (CLI only)
1083 - P2P replication to server
1084 - Bridge handles relay sync
1085```
1086
1087**Scenario C: Remote bridge**
1088```
1089Machine A:
1090 - pear-app (CLI)
1091
1092Machine 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
1101pear run . bridge status
1102
1103# Bridge logs (if systemd)
1104journalctl -u wirecore-bridge -f
1105
1106# Pear app logs (separate)
1107pear 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
11631. ✅ **Separation of concerns**: Pear app stays focused on P2P
11642. ✅ **Optional**: Bridge is addon, not requirement
11653. ✅ **Flexible**: Can run as daemon, systemd service, or one-shot
11664. ✅ **Resilient**: Bridge failure doesn't break P2P
11675. ✅ **Modular**: Can swap relay implementation easily
11686. ✅ **Unix philosophy**: Do one thing well
11697. ✅ **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)
pear-app/index.js +197 -0
……@@ -58,6 +58,8 @@ let userDrive = null // Hyperdrive for user files (images, etc.)
5858let identity = null // Legacy IdentityKey (will be removed in Phase 2)
5959let anprotoKeypair = null // ANProto keypair { publicKey: Buffer, secretKey: Buffer }
6060let follows = new Map() // discoveryKey -> { feed, publicKey, name }
61let bridgeProcess = null // Bridge daemon child process (for web UI management)
62let bridgeConfig = { relayUrl: 'https://7rnx.net/relay', autoStart: false } // Bridge configuration
6163
6264/**
6365 * Initialize Corestore and load state (without starting swarm)
……@@ -937,6 +939,19 @@ async function startWebServer(port = 3003) {
937939 await startSwarm()
938940 await loadFollows()
939941
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
940955 // Create HTTP server
941956 const server = http.createServer(async (req, res) => {
942957 const url = new URL(req.url, 'http://localhost')
……@@ -1222,6 +1237,72 @@ async function handleAPI(req, res, url) {
12221237 }
12231238 break
12241239
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
12251306 default:
12261307 // Check if it's a directory profile request
12271308 if (url.pathname.startsWith('/api/directory/')) {
……@@ -1470,6 +1551,122 @@ async function followUser(publicKeyInput, name) {
14701551 broadcast({ type: 'new_follow', publicKey: publicKeyHex, name })
14711552}
14721553
1554// Bridge management functions
1555
1556/**
1557 * Get bridge status (running, stats, config)
1558 */
1559async 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 */
1587async 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 */
1619async 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 */
1640async 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 */
1654async function saveBridgeConfig() {
1655 if (!metaBee) return
1656 await metaBee.put('bridge-config', bridgeConfig)
1657}
1658
1659/**
1660 * Load bridge config from metadata
1661 */
1662async 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
14731670/**
14741671 * Show usage information
14751672 */
pear-app/public/app-simple.js +269 -0
……@@ -721,6 +721,269 @@ async function showUserProfileModal(pubkey) {
721721 }
722722}
723723
724// Show settings modal
725async 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
724987// Setup event listeners
725988function setupEventListeners() {
726989 // Compose button
……@@ -755,6 +1018,12 @@ function setupEventListeners() {
7551018 if (searchBtn) {
7561019 searchBtn.onclick = showSearchModal
7571020 }
1021
1022 // Settings button
1023 const settingsBtn = document.getElementById('settings-btn')
1024 if (settingsBtn) {
1025 settingsBtn.onclick = showSettingsModal
1026 }
7581027}
7591028
7601029// Escape HTML to prevent XSS
pear-app/public/index.html +60 -0
……@@ -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>
pear-app/public/style.css +1116 -0
……@@ -0,0 +1,1116 @@
1/* Fedicore Web UI - Inspired by Wiredove */
2
3* {
4 box-sizing: border-box;
5}
6
7body {
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 */
59button {
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
71button:hover {
72 background: #f2f2f2;
73}
74
75button: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
515button.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
523button.primary:hover {
524 background: #0567e0;
525}
526
527button.primary:disabled {
528 background: #93c5fd;
529 border-color: #93c5fd;
530}
531
532/* Forms */
533textarea, 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
546textarea:focus, input:focus {
547 outline: none;
548 border-color: #045fd0;
549}
550
551textarea {
552 resize: vertical;
553 min-height: 120px;
554}
555
556label {
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}