fedbox 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -28,6 +28,7 @@ const BANNER = `
28
28
  const COMMANDS = {
29
29
  init: runInit,
30
30
  start: runStart,
31
+ profile: runProfile,
31
32
  status: runStatus,
32
33
  post: runPost,
33
34
  follow: runFollow,
@@ -122,6 +123,20 @@ async function runStart() {
122
123
  await startServer()
123
124
  }
124
125
 
126
+ async function runProfile() {
127
+ if (!existsSync('fedbox.json')) {
128
+ console.log('❌ Not initialized. Run: fedbox init\n')
129
+ process.exit(1)
130
+ }
131
+
132
+ const config = JSON.parse(readFileSync('fedbox.json', 'utf8'))
133
+ const port = process.argv[3] || config.port || 3000
134
+ console.log(`🪪 Starting profile server on port ${port}...\n`)
135
+
136
+ const { startProfileServer } = await import('../lib/profile-server.js')
137
+ await startProfileServer(parseInt(port))
138
+ }
139
+
125
140
  async function runStatus() {
126
141
  if (!existsSync('fedbox.json')) {
127
142
  console.log('❌ Not initialized. Run: fedbox init\n')
@@ -372,7 +387,8 @@ Usage: fedbox <command> [args]
372
387
 
373
388
  Setup:
374
389
  init Set up a new Fediverse identity
375
- start Start the server
390
+ start Start the full ActivityPub server
391
+ profile [port] Start profile-only server (minimal, edit via web)
376
392
  status Show current configuration
377
393
 
378
394
  Social:
@@ -392,6 +408,10 @@ Quick start:
392
408
  $ fedbox post "Hello, Fediverse!"
393
409
  $ fedbox follow @user@mastodon.social
394
410
 
411
+ Profile only (for Solid/WebID testing):
412
+ $ fedbox profile
413
+ Then visit http://localhost:3000/ and click Edit
414
+
395
415
  For federation (so Mastodon can find you):
396
416
  $ ngrok http 3000
397
417
  Then update fedbox.json with your ngrok domain
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Fedbox Profile Server
3
+ * Minimal server that just serves the profile
4
+ * Great for testing, Solid integration, or static-like deployment
5
+ */
6
+
7
+ import { createServer } from 'http'
8
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs'
9
+ import { join, extname } from 'path'
10
+
11
+ let config = null
12
+
13
+ /**
14
+ * Load configuration
15
+ */
16
+ function loadConfig() {
17
+ if (!existsSync('fedbox.json')) {
18
+ throw new Error('Not initialized. Run: fedbox init')
19
+ }
20
+ config = JSON.parse(readFileSync('fedbox.json', 'utf8'))
21
+ return config
22
+ }
23
+
24
+ /**
25
+ * Get base URL
26
+ */
27
+ function getBaseUrl(port) {
28
+ if (config.domain) {
29
+ return `https://${config.domain}`
30
+ }
31
+ return `http://localhost:${port}`
32
+ }
33
+
34
+ /**
35
+ * Build actor object
36
+ */
37
+ function buildActor(baseUrl) {
38
+ const profileUrl = `${baseUrl}/${config.username}`
39
+ const actorId = `${profileUrl}#me`
40
+
41
+ const actor = {
42
+ '@context': [
43
+ 'https://www.w3.org/ns/activitystreams',
44
+ 'https://w3id.org/security/v1'
45
+ ],
46
+ type: 'Person',
47
+ id: actorId,
48
+ url: profileUrl,
49
+ preferredUsername: config.username,
50
+ name: config.displayName,
51
+ summary: config.summary ? `<p>${config.summary}</p>` : '',
52
+ inbox: `${profileUrl}/inbox`,
53
+ outbox: `${profileUrl}/outbox`,
54
+ followers: `${profileUrl}/followers`,
55
+ following: `${profileUrl}/following`,
56
+ endpoints: {
57
+ sharedInbox: `${baseUrl}/inbox`
58
+ },
59
+ publicKey: {
60
+ id: `${profileUrl}#main-key`,
61
+ owner: actorId,
62
+ publicKeyPem: config.publicKey
63
+ }
64
+ }
65
+
66
+ if (config.avatar) {
67
+ actor.icon = {
68
+ type: 'Image',
69
+ mediaType: config.avatar.endsWith('.png') ? 'image/png' :
70
+ config.avatar.endsWith('.gif') ? 'image/gif' : 'image/jpeg',
71
+ url: `${baseUrl}/public/${config.avatar}`
72
+ }
73
+ }
74
+
75
+ return actor
76
+ }
77
+
78
+ /**
79
+ * Render profile HTML
80
+ */
81
+ function renderProfile(actor, baseUrl) {
82
+ const avatarUrl = config.avatar ? `/public/${config.avatar}` : ''
83
+
84
+ return `<!DOCTYPE html>
85
+ <html>
86
+ <head>
87
+ <meta charset="utf-8">
88
+ <title>${config.displayName} (@${config.username})</title>
89
+ <meta name="viewport" content="width=device-width, initial-scale=1">
90
+ <link rel="alternate" type="application/activity+json" href="${actor.url}">
91
+ <script type="application/ld+json" id="profile">
92
+ ${JSON.stringify(actor, null, 2)}
93
+ </script>
94
+ <style>
95
+ * { box-sizing: border-box; }
96
+ body {
97
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
98
+ max-width: 600px;
99
+ margin: 0 auto;
100
+ padding: 2rem;
101
+ background: #1a1a2e;
102
+ color: #eee;
103
+ min-height: 100vh;
104
+ }
105
+ .card {
106
+ background: #16213e;
107
+ border-radius: 16px;
108
+ padding: 2rem;
109
+ text-align: center;
110
+ position: relative;
111
+ }
112
+ .edit-btn {
113
+ position: absolute;
114
+ top: 1rem;
115
+ right: 1rem;
116
+ background: #667eea;
117
+ color: white;
118
+ border: none;
119
+ padding: 0.5rem 1rem;
120
+ border-radius: 8px;
121
+ cursor: pointer;
122
+ font-size: 0.9rem;
123
+ }
124
+ .edit-btn:hover { background: #5a6fd6; }
125
+ .avatar {
126
+ width: 120px;
127
+ height: 120px;
128
+ border-radius: 50%;
129
+ background: linear-gradient(135deg, #667eea, #764ba2);
130
+ margin: 0 auto 1rem;
131
+ display: flex;
132
+ align-items: center;
133
+ justify-content: center;
134
+ font-size: 3rem;
135
+ overflow: hidden;
136
+ cursor: pointer;
137
+ position: relative;
138
+ }
139
+ .avatar img { width: 100%; height: 100%; object-fit: cover; }
140
+ .avatar-overlay {
141
+ display: none;
142
+ position: absolute;
143
+ inset: 0;
144
+ background: rgba(0,0,0,0.5);
145
+ align-items: center;
146
+ justify-content: center;
147
+ font-size: 1rem;
148
+ }
149
+ .editing .avatar-overlay { display: flex; }
150
+ .avatar-input { display: none; }
151
+ h1 { margin: 0 0 0.5rem; }
152
+ .handle { color: #888; margin-bottom: 1rem; }
153
+ .bio { color: #aaa; margin-bottom: 1.5rem; }
154
+ .badge {
155
+ display: inline-block;
156
+ background: #667eea;
157
+ color: white;
158
+ padding: 0.5rem 1rem;
159
+ border-radius: 20px;
160
+ margin-top: 1.5rem;
161
+ font-size: 0.9rem;
162
+ }
163
+ .edit-input {
164
+ background: #0f172a;
165
+ border: 1px solid #667eea;
166
+ color: #eee;
167
+ padding: 0.5rem;
168
+ border-radius: 8px;
169
+ font-size: inherit;
170
+ text-align: center;
171
+ width: 100%;
172
+ max-width: 300px;
173
+ }
174
+ .edit-textarea {
175
+ background: #0f172a;
176
+ border: 1px solid #667eea;
177
+ color: #aaa;
178
+ padding: 0.5rem;
179
+ border-radius: 8px;
180
+ font-size: 1rem;
181
+ text-align: center;
182
+ width: 100%;
183
+ max-width: 400px;
184
+ resize: vertical;
185
+ min-height: 60px;
186
+ }
187
+ .edit-actions {
188
+ display: none;
189
+ gap: 0.5rem;
190
+ justify-content: center;
191
+ margin-top: 1rem;
192
+ }
193
+ .editing .edit-actions { display: flex; }
194
+ .save-btn { background: #22c55e; color: white; border: none; padding: 0.5rem 1.5rem; border-radius: 8px; cursor: pointer; }
195
+ .cancel-btn { background: #64748b; color: white; border: none; padding: 0.5rem 1.5rem; border-radius: 8px; cursor: pointer; }
196
+ .view-mode { display: block; }
197
+ .edit-mode { display: none; }
198
+ .editing .view-mode { display: none; }
199
+ .editing .edit-mode { display: block; }
200
+ .editing .edit-btn { display: none; }
201
+ </style>
202
+ </head>
203
+ <body>
204
+ <div class="card" id="profile-card">
205
+ <button class="edit-btn" onclick="toggleEdit()">Edit</button>
206
+
207
+ <div class="avatar" onclick="document.getElementById('avatar-input').click()">
208
+ ${avatarUrl ? `<img src="${avatarUrl}" alt="avatar">` : '📦'}
209
+ <div class="avatar-overlay">Change</div>
210
+ </div>
211
+ <input type="file" id="avatar-input" class="avatar-input" accept="image/*" onchange="previewAvatar(this)">
212
+
213
+ <div class="view-mode"><h1>${config.displayName}</h1></div>
214
+ <div class="edit-mode">
215
+ <input type="text" class="edit-input" id="edit-name" value="${config.displayName}" placeholder="Display Name">
216
+ </div>
217
+
218
+ <p class="handle">@${config.username}</p>
219
+
220
+ <div class="view-mode">
221
+ ${config.summary ? `<p class="bio">${config.summary}</p>` : '<p class="bio" style="opacity:0.5">No bio yet</p>'}
222
+ </div>
223
+ <div class="edit-mode">
224
+ <textarea class="edit-textarea" id="edit-summary" placeholder="Write a short bio...">${config.summary || ''}</textarea>
225
+ </div>
226
+
227
+ <div class="edit-actions">
228
+ <button class="save-btn" onclick="saveProfile()">Save</button>
229
+ <button class="cancel-btn" onclick="toggleEdit()">Cancel</button>
230
+ </div>
231
+
232
+ <div class="badge">🪪 Profile</div>
233
+ </div>
234
+
235
+ <script>
236
+ let avatarFile = null;
237
+ function toggleEdit() {
238
+ document.getElementById('profile-card').classList.toggle('editing');
239
+ avatarFile = null;
240
+ }
241
+ function previewAvatar(input) {
242
+ if (input.files && input.files[0]) {
243
+ avatarFile = input.files[0];
244
+ const reader = new FileReader();
245
+ reader.onload = (e) => {
246
+ const avatar = document.querySelector('.avatar');
247
+ avatar.innerHTML = '<img src="' + e.target.result + '" alt="avatar"><div class="avatar-overlay">Change</div>';
248
+ };
249
+ reader.readAsDataURL(input.files[0]);
250
+ }
251
+ }
252
+ async function saveProfile() {
253
+ const formData = new FormData();
254
+ formData.append('displayName', document.getElementById('edit-name').value);
255
+ formData.append('summary', document.getElementById('edit-summary').value);
256
+ if (avatarFile) formData.append('avatar', avatarFile);
257
+ try {
258
+ const res = await fetch('/edit', { method: 'POST', body: formData });
259
+ if (res.ok) location.reload();
260
+ else alert('Failed to save');
261
+ } catch (err) { alert('Error: ' + err.message); }
262
+ }
263
+ </script>
264
+ </body>
265
+ </html>`
266
+ }
267
+
268
+ /**
269
+ * Parse multipart form data
270
+ */
271
+ function parseMultipart(body, boundary) {
272
+ const parts = {}
273
+ const boundaryBuffer = Buffer.from('--' + boundary)
274
+ const doubleCrlf = Buffer.from('\r\n\r\n')
275
+
276
+ let start = 0
277
+ while (true) {
278
+ const boundaryPos = body.indexOf(boundaryBuffer, start)
279
+ if (boundaryPos === -1) break
280
+ const afterBoundary = boundaryPos + boundaryBuffer.length
281
+ if (body.slice(afterBoundary, afterBoundary + 2).toString() === '--') break
282
+ const headersEnd = body.indexOf(doubleCrlf, afterBoundary)
283
+ if (headersEnd === -1) break
284
+ const headers = body.slice(afterBoundary + 2, headersEnd).toString()
285
+ const nextBoundary = body.indexOf(boundaryBuffer, headersEnd)
286
+ const content = body.slice(headersEnd + 4, nextBoundary - 2)
287
+ const nameMatch = headers.match(/name="([^"]+)"/)
288
+ const filenameMatch = headers.match(/filename="([^"]+)"/)
289
+ const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/)
290
+ if (nameMatch) {
291
+ const name = nameMatch[1]
292
+ if (filenameMatch) {
293
+ parts[name] = {
294
+ filename: filenameMatch[1],
295
+ contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
296
+ data: content
297
+ }
298
+ } else {
299
+ parts[name] = content.toString()
300
+ }
301
+ }
302
+ start = nextBoundary
303
+ }
304
+ return parts
305
+ }
306
+
307
+ /**
308
+ * Handle profile edit
309
+ */
310
+ async function handleEdit(req, res) {
311
+ const chunks = []
312
+ for await (const chunk of req) chunks.push(chunk)
313
+ const body = Buffer.concat(chunks)
314
+
315
+ const contentType = req.headers['content-type'] || ''
316
+ const boundary = contentType.split('boundary=')[1]
317
+ if (!boundary) {
318
+ res.writeHead(400)
319
+ return res.end('Bad request')
320
+ }
321
+
322
+ const parts = parseMultipart(body, boundary)
323
+ let updated = false
324
+
325
+ if (parts.displayName !== undefined) {
326
+ config.displayName = parts.displayName
327
+ updated = true
328
+ }
329
+ if (parts.summary !== undefined) {
330
+ config.summary = parts.summary
331
+ updated = true
332
+ }
333
+ if (parts.avatar && parts.avatar.data && parts.avatar.data.length > 0) {
334
+ if (!existsSync('public')) mkdirSync('public', { recursive: true })
335
+ const ext = parts.avatar.contentType?.includes('png') ? 'png' :
336
+ parts.avatar.contentType?.includes('gif') ? 'gif' : 'jpg'
337
+ const filename = `avatar.${ext}`
338
+ writeFileSync(join('public', filename), parts.avatar.data)
339
+ config.avatar = filename
340
+ updated = true
341
+ console.log(`📷 Avatar saved: public/${filename}`)
342
+ }
343
+
344
+ if (updated) {
345
+ writeFileSync('fedbox.json', JSON.stringify(config, null, 2))
346
+ console.log('✅ Profile updated')
347
+ }
348
+
349
+ res.writeHead(200)
350
+ res.end('OK')
351
+ }
352
+
353
+ /**
354
+ * Start the profile server
355
+ */
356
+ export async function startProfileServer(port = 3000) {
357
+ loadConfig()
358
+ const baseUrl = getBaseUrl(port)
359
+ const actor = buildActor(baseUrl)
360
+
361
+ const server = createServer(async (req, res) => {
362
+ const url = new URL(req.url, baseUrl)
363
+ const path = url.pathname
364
+ const accept = req.headers.accept || ''
365
+ const isAP = accept.includes('activity+json') || accept.includes('ld+json')
366
+
367
+ // CORS
368
+ res.setHeader('Access-Control-Allow-Origin', '*')
369
+ res.setHeader('Access-Control-Allow-Headers', '*')
370
+ if (req.method === 'OPTIONS') {
371
+ res.writeHead(204)
372
+ return res.end()
373
+ }
374
+
375
+ console.log(`${req.method} ${path}`)
376
+
377
+ // Profile edit
378
+ if (path === '/edit' && req.method === 'POST') {
379
+ return handleEdit(req, res)
380
+ }
381
+
382
+ // Static files
383
+ if (path.startsWith('/public/')) {
384
+ const filePath = join(process.cwd(), path)
385
+ if (existsSync(filePath)) {
386
+ const ext = extname(filePath)
387
+ const mimeTypes = {
388
+ '.js': 'application/javascript',
389
+ '.css': 'text/css',
390
+ '.png': 'image/png',
391
+ '.jpg': 'image/jpeg',
392
+ '.gif': 'image/gif',
393
+ '.svg': 'image/svg+xml'
394
+ }
395
+ res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
396
+ return res.end(readFileSync(filePath))
397
+ }
398
+ }
399
+
400
+ // Profile (root or /username)
401
+ if (path === '/' || path === `/${config.username}`) {
402
+ if (isAP) {
403
+ res.setHeader('Content-Type', 'application/activity+json')
404
+ return res.end(JSON.stringify(actor, null, 2))
405
+ }
406
+ res.setHeader('Content-Type', 'text/html')
407
+ return res.end(renderProfile(actor, baseUrl))
408
+ }
409
+
410
+ res.writeHead(404)
411
+ res.end('Not found')
412
+ })
413
+
414
+ server.listen(port, () => {
415
+ console.log(`
416
+ 🪪 Profile server running!
417
+
418
+ Profile: http://localhost:${port}/
419
+ WebID: http://localhost:${port}/${config.username}#me
420
+ JSON-LD: curl -H "Accept: application/activity+json" http://localhost:${port}/
421
+
422
+ Press Ctrl+C to stop
423
+ `)
424
+ })
425
+ }
426
+
427
+ export default { startProfileServer }
package/lib/server.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { createServer } from 'http'
8
- import { readFileSync, existsSync } from 'fs'
8
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs'
9
9
  import { join, extname } from 'path'
10
10
  import { profile, auth, webfinger, outbox } from 'microfed'
11
11
  import {
@@ -77,7 +77,7 @@ function buildActor() {
77
77
  const actorId = `${profileUrl}#me`
78
78
 
79
79
  // Build actor manually for more control over structure
80
- return {
80
+ const actor = {
81
81
  '@context': [
82
82
  'https://www.w3.org/ns/activitystreams',
83
83
  'https://w3id.org/security/v1'
@@ -101,6 +101,18 @@ function buildActor() {
101
101
  publicKeyPem: config.publicKey
102
102
  }
103
103
  }
104
+
105
+ // Add icon if avatar is set
106
+ if (config.avatar) {
107
+ actor.icon = {
108
+ type: 'Image',
109
+ mediaType: config.avatar.endsWith('.png') ? 'image/png' :
110
+ config.avatar.endsWith('.gif') ? 'image/gif' : 'image/jpeg',
111
+ url: `${baseUrl}/public/${config.avatar}`
112
+ }
113
+ }
114
+
115
+ return actor
104
116
  }
105
117
 
106
118
  /**
@@ -384,7 +396,7 @@ async function handleRequest(req, res) {
384
396
  version: '2.1',
385
397
  software: {
386
398
  name: 'fedbox',
387
- version: '0.0.6',
399
+ version: '0.0.7',
388
400
  repository: 'https://github.com/micro-fed/fedbox'
389
401
  },
390
402
  protocols: ['activitypub'],
@@ -421,6 +433,11 @@ async function handleRequest(req, res) {
421
433
  }
422
434
  }
423
435
 
436
+ // Profile edit
437
+ if (path === `/${config.username}/edit` && req.method === 'POST') {
438
+ return handleProfileEdit(req, res)
439
+ }
440
+
424
441
  // Inbox
425
442
  if (path === `/${config.username}/inbox`) {
426
443
  return handleInbox(req, res)
@@ -581,13 +598,131 @@ async function handleInbox(req, res) {
581
598
  }
582
599
 
583
600
  /**
584
- * Render HTML profile with embedded JSON-LD
601
+ * Handle profile edit POST (multipart form data)
602
+ */
603
+ async function handleProfileEdit(req, res) {
604
+ const chunks = []
605
+ for await (const chunk of req) chunks.push(chunk)
606
+ const body = Buffer.concat(chunks)
607
+
608
+ // Parse multipart form data
609
+ const contentType = req.headers['content-type'] || ''
610
+ const boundary = contentType.split('boundary=')[1]
611
+
612
+ if (!boundary) {
613
+ res.writeHead(400)
614
+ return res.end('Bad request')
615
+ }
616
+
617
+ const parts = parseMultipart(body, boundary)
618
+ let updated = false
619
+
620
+ // Update display name
621
+ if (parts.displayName !== undefined) {
622
+ config.displayName = parts.displayName
623
+ updated = true
624
+ }
625
+
626
+ // Update summary
627
+ if (parts.summary !== undefined) {
628
+ config.summary = parts.summary
629
+ updated = true
630
+ }
631
+
632
+ // Handle avatar upload
633
+ if (parts.avatar && parts.avatar.data && parts.avatar.data.length > 0) {
634
+ // Ensure public directory exists
635
+ if (!existsSync('public')) {
636
+ mkdirSync('public', { recursive: true })
637
+ }
638
+
639
+ // Determine file extension from content type
640
+ const ext = parts.avatar.contentType?.includes('png') ? 'png' :
641
+ parts.avatar.contentType?.includes('gif') ? 'gif' : 'jpg'
642
+ const filename = `avatar.${ext}`
643
+
644
+ writeFileSync(join('public', filename), parts.avatar.data)
645
+ config.avatar = filename
646
+ updated = true
647
+ console.log(`📷 Avatar saved: public/${filename}`)
648
+ }
649
+
650
+ if (updated) {
651
+ // Save config
652
+ writeFileSync('fedbox.json', JSON.stringify(config, null, 2))
653
+ // Rebuild actor with new config
654
+ actor = buildActor()
655
+ console.log('✅ Profile updated')
656
+ }
657
+
658
+ res.writeHead(200)
659
+ res.end('OK')
660
+ }
661
+
662
+ /**
663
+ * Parse multipart form data
664
+ */
665
+ function parseMultipart(body, boundary) {
666
+ const parts = {}
667
+ const boundaryBuffer = Buffer.from('--' + boundary)
668
+ const crlf = Buffer.from('\r\n')
669
+ const doubleCrlf = Buffer.from('\r\n\r\n')
670
+
671
+ let start = 0
672
+ while (true) {
673
+ // Find next boundary
674
+ const boundaryPos = body.indexOf(boundaryBuffer, start)
675
+ if (boundaryPos === -1) break
676
+
677
+ // Check for end boundary
678
+ const afterBoundary = boundaryPos + boundaryBuffer.length
679
+ if (body.slice(afterBoundary, afterBoundary + 2).toString() === '--') break
680
+
681
+ // Find headers end
682
+ const headersEnd = body.indexOf(doubleCrlf, afterBoundary)
683
+ if (headersEnd === -1) break
684
+
685
+ const headers = body.slice(afterBoundary + 2, headersEnd).toString()
686
+
687
+ // Find next boundary for content end
688
+ const nextBoundary = body.indexOf(boundaryBuffer, headersEnd)
689
+ const content = body.slice(headersEnd + 4, nextBoundary - 2) // -2 for CRLF before boundary
690
+
691
+ // Parse headers
692
+ const nameMatch = headers.match(/name="([^"]+)"/)
693
+ const filenameMatch = headers.match(/filename="([^"]+)"/)
694
+ const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/)
695
+
696
+ if (nameMatch) {
697
+ const name = nameMatch[1]
698
+ if (filenameMatch) {
699
+ // File upload
700
+ parts[name] = {
701
+ filename: filenameMatch[1],
702
+ contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
703
+ data: content
704
+ }
705
+ } else {
706
+ // Regular field
707
+ parts[name] = content.toString()
708
+ }
709
+ }
710
+
711
+ start = nextBoundary
712
+ }
713
+
714
+ return parts
715
+ }
716
+
717
+ /**
718
+ * Render HTML profile with embedded JSON-LD and inline editing
585
719
  */
586
720
  function renderProfile() {
587
721
  const followers = getFollowerCount()
588
722
  const following = getFollowingCount()
589
723
  const posts = getPosts(10)
590
724
  const profileUrl = `${getBaseUrl()}/${config.username}`
725
+ const avatarUrl = config.avatar ? `/public/${config.avatar}` : ''
591
726
 
592
727
  return `<!DOCTYPE html>
593
728
  <html>
@@ -616,7 +751,21 @@ ${JSON.stringify(actor, null, 2)}
616
751
  padding: 2rem;
617
752
  text-align: center;
618
753
  margin-bottom: 1.5rem;
754
+ position: relative;
755
+ }
756
+ .edit-btn {
757
+ position: absolute;
758
+ top: 1rem;
759
+ right: 1rem;
760
+ background: #667eea;
761
+ color: white;
762
+ border: none;
763
+ padding: 0.5rem 1rem;
764
+ border-radius: 8px;
765
+ cursor: pointer;
766
+ font-size: 0.9rem;
619
767
  }
768
+ .edit-btn:hover { background: #5a6fd6; }
620
769
  .avatar {
621
770
  width: 120px;
622
771
  height: 120px;
@@ -627,7 +776,26 @@ ${JSON.stringify(actor, null, 2)}
627
776
  align-items: center;
628
777
  justify-content: center;
629
778
  font-size: 3rem;
779
+ overflow: hidden;
780
+ cursor: pointer;
781
+ position: relative;
782
+ }
783
+ .avatar img {
784
+ width: 100%;
785
+ height: 100%;
786
+ object-fit: cover;
787
+ }
788
+ .avatar-overlay {
789
+ display: none;
790
+ position: absolute;
791
+ inset: 0;
792
+ background: rgba(0,0,0,0.5);
793
+ align-items: center;
794
+ justify-content: center;
795
+ font-size: 1rem;
630
796
  }
797
+ .editing .avatar-overlay { display: flex; }
798
+ .avatar-input { display: none; }
631
799
  h1 { margin: 0 0 0.5rem; }
632
800
  .handle { color: #888; margin-bottom: 1rem; }
633
801
  .bio { color: #aaa; margin-bottom: 1.5rem; }
@@ -654,14 +822,88 @@ ${JSON.stringify(actor, null, 2)}
654
822
  .post-content { margin-bottom: 0.5rem; }
655
823
  .post-meta { color: #666; font-size: 0.8rem; }
656
824
  .post a { color: #667eea; text-decoration: none; }
825
+ /* Edit mode */
826
+ .edit-input {
827
+ background: #0f172a;
828
+ border: 1px solid #667eea;
829
+ color: #eee;
830
+ padding: 0.5rem;
831
+ border-radius: 8px;
832
+ font-size: inherit;
833
+ text-align: center;
834
+ width: 100%;
835
+ max-width: 300px;
836
+ }
837
+ .edit-input:focus { outline: none; border-color: #818cf8; }
838
+ .edit-textarea {
839
+ background: #0f172a;
840
+ border: 1px solid #667eea;
841
+ color: #aaa;
842
+ padding: 0.5rem;
843
+ border-radius: 8px;
844
+ font-size: 1rem;
845
+ text-align: center;
846
+ width: 100%;
847
+ max-width: 400px;
848
+ resize: vertical;
849
+ min-height: 60px;
850
+ }
851
+ .edit-actions {
852
+ display: none;
853
+ gap: 0.5rem;
854
+ justify-content: center;
855
+ margin-top: 1rem;
856
+ }
857
+ .editing .edit-actions { display: flex; }
858
+ .save-btn {
859
+ background: #22c55e;
860
+ color: white;
861
+ border: none;
862
+ padding: 0.5rem 1.5rem;
863
+ border-radius: 8px;
864
+ cursor: pointer;
865
+ }
866
+ .cancel-btn {
867
+ background: #64748b;
868
+ color: white;
869
+ border: none;
870
+ padding: 0.5rem 1.5rem;
871
+ border-radius: 8px;
872
+ cursor: pointer;
873
+ }
874
+ .view-mode { display: block; }
875
+ .edit-mode { display: none; }
876
+ .editing .view-mode { display: none; }
877
+ .editing .edit-mode { display: block; }
878
+ .editing .edit-btn { display: none; }
657
879
  </style>
658
880
  </head>
659
881
  <body>
660
- <div class="card">
661
- <div class="avatar">📦</div>
662
- <h1>${config.displayName}</h1>
882
+ <div class="card" id="profile-card">
883
+ <button class="edit-btn" onclick="toggleEdit()">Edit</button>
884
+
885
+ <div class="avatar" onclick="document.getElementById('avatar-input').click()">
886
+ ${avatarUrl ? `<img src="${avatarUrl}" alt="avatar">` : '📦'}
887
+ <div class="avatar-overlay">Change</div>
888
+ </div>
889
+ <input type="file" id="avatar-input" class="avatar-input" accept="image/*" onchange="previewAvatar(this)">
890
+
891
+ <div class="view-mode">
892
+ <h1>${config.displayName}</h1>
893
+ </div>
894
+ <div class="edit-mode">
895
+ <input type="text" class="edit-input" id="edit-name" value="${config.displayName}" placeholder="Display Name">
896
+ </div>
897
+
663
898
  <p class="handle">@${config.username}@${getDomain()}</p>
664
- ${config.summary ? `<p class="bio">${config.summary}</p>` : ''}
899
+
900
+ <div class="view-mode">
901
+ ${config.summary ? `<p class="bio">${config.summary}</p>` : '<p class="bio" style="opacity:0.5">No bio yet</p>'}
902
+ </div>
903
+ <div class="edit-mode">
904
+ <textarea class="edit-textarea" id="edit-summary" placeholder="Write a short bio...">${config.summary || ''}</textarea>
905
+ </div>
906
+
665
907
  <div class="stats">
666
908
  <div class="stat">
667
909
  <div class="stat-num">${followers}</div>
@@ -676,6 +918,12 @@ ${JSON.stringify(actor, null, 2)}
676
918
  <div class="stat-label">Posts</div>
677
919
  </div>
678
920
  </div>
921
+
922
+ <div class="edit-actions">
923
+ <button class="save-btn" onclick="saveProfile()">Save</button>
924
+ <button class="cancel-btn" onclick="toggleEdit()">Cancel</button>
925
+ </div>
926
+
679
927
  <div class="badge">📦 Powered by Fedbox</div>
680
928
  </div>
681
929
 
@@ -691,6 +939,50 @@ ${JSON.stringify(actor, null, 2)}
691
939
  `).join('')}
692
940
  </div>
693
941
  ` : ''}
942
+
943
+ <script>
944
+ let avatarFile = null;
945
+
946
+ function toggleEdit() {
947
+ document.getElementById('profile-card').classList.toggle('editing');
948
+ avatarFile = null;
949
+ }
950
+
951
+ function previewAvatar(input) {
952
+ if (input.files && input.files[0]) {
953
+ avatarFile = input.files[0];
954
+ const reader = new FileReader();
955
+ reader.onload = (e) => {
956
+ const avatar = document.querySelector('.avatar');
957
+ avatar.innerHTML = '<img src="' + e.target.result + '" alt="avatar"><div class="avatar-overlay">Change</div>';
958
+ };
959
+ reader.readAsDataURL(input.files[0]);
960
+ }
961
+ }
962
+
963
+ async function saveProfile() {
964
+ const formData = new FormData();
965
+ formData.append('displayName', document.getElementById('edit-name').value);
966
+ formData.append('summary', document.getElementById('edit-summary').value);
967
+ if (avatarFile) {
968
+ formData.append('avatar', avatarFile);
969
+ }
970
+
971
+ try {
972
+ const res = await fetch('/${config.username}/edit', {
973
+ method: 'POST',
974
+ body: formData
975
+ });
976
+ if (res.ok) {
977
+ location.reload();
978
+ } else {
979
+ alert('Failed to save');
980
+ }
981
+ } catch (err) {
982
+ alert('Error: ' + err.message);
983
+ }
984
+ }
985
+ </script>
694
986
  </body>
695
987
  </html>`
696
988
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedbox",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Zero to Fediverse in 60 seconds",
5
5
  "type": "module",
6
6
  "main": "lib/server.js",