+213
-17
src/index.ts
+213
-17
src/index.ts
···
262
262
<html>
263
263
<head>
264
264
<title>ANProto over ATProto</title>
265
+
<link rel="preconnect" href="https://fonts.googleapis.com">
266
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
267
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
265
268
<style>
266
269
:root { --bg:#f7f7f8; --card:#fff; --border:#e3e3e6; --text:#111; --muted:#666; --accent:#111; }
267
270
* { box-sizing: border-box; }
···
270
273
.brand { font-weight: 700; letter-spacing: -0.01em; }
271
274
.auth { display: flex; align-items: center; gap: 12px; }
272
275
.login-form { display: flex; align-items: center; gap: 8px; }
273
-
.login-form input { padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; min-width: 200px; }
274
-
.btn { cursor: pointer; border: 1px solid var(--border); background: var(--text); color: #fff; border-radius: 10px; padding: 8px 12px; font-weight: 600; }
275
-
.btn.secondary { background: #fff; color: var(--text); }
276
+
.login-form input { padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; min-width: 200px; font-size: 1rem; }
277
+
.btn { cursor: pointer; border: 1px solid #b7b7bb; background: linear-gradient(180deg, #2a2a2f, #111114); color: #fff; border-radius: 10px; padding: 8px 12px; font-weight: 600; box-shadow: inset 0 1px 0 rgba(255,255,255,0.18), inset 0 -2px 0 rgba(0,0,0,0.35), inset 0 10px 20px rgba(255,255,255,0.04); }
278
+
.btn.secondary { background: linear-gradient(180deg, #ffffff, #f2f2f2); color: var(--text); box-shadow: inset 0 1px 0 rgba(255,255,255,0.7), inset 0 -1px 0 rgba(0,0,0,0.08); }
279
+
.btn.sm { padding: 6px 10px; font-size: 1rem; }
276
280
.user-chip { display: flex; align-items: center; gap: 10px; background: #fff; border: 1px solid var(--border); border-radius: 999px; padding: 6px 10px; }
277
281
.avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); background: #ddd; }
278
-
main { max-width: 960px; margin: 0 auto; padding: 24px 18px 40px; display: grid; gap: 20px; }
282
+
.avatar.interactive { cursor: pointer; box-shadow: 0 0 0 0 transparent; transition: box-shadow 120ms ease, transform 120ms ease; }
283
+
.avatar.interactive:hover { box-shadow: 0 0 0 3px rgba(0,0,0,0.05); transform: translateY(-1px); }
284
+
.avatar.profile { width: 72px; height: 72px; }
285
+
main { max-width: 1100px; margin: 0 auto; padding: 24px 18px 40px; display: grid; gap: 20px; grid-template-columns: 1fr; }
279
286
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
287
+
.profile-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
288
+
.profile-meta { display: flex; flex-direction: column; gap: 8px; min-width: 240px; }
289
+
.profile-name { font-weight: 700; font-size: 1rem; }
290
+
.name-block { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
291
+
.name-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
292
+
.name-form input { flex: 1 1 180px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 1rem; }
293
+
.pubkey-pill { background: #f0f0f2; border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; font-size: 1rem; width: fit-content; }
280
294
.composer textarea { width: 100%; min-height: 140px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; font-size: 1rem; }
281
295
.composer .actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
282
296
.status { margin-top: 8px; font-size: 0.92rem; color: var(--muted); }
283
297
.muted { color: var(--muted); }
298
+
.feed { padding: 0; }
284
299
.feed-list { display: flex; flex-direction: column; gap: 12px; }
285
-
.feed-item { border: 1px solid var(--border); border-radius: 12px; padding: 12px; background: #fff; }
286
-
.feed-head { display: flex; justify-content: space-between; align-items: center; font-size: 0.9rem; color: var(--muted); }
287
-
.pill { background: #f0f0f2; border: 1px solid var(--border); border-radius: 999px; padding: 4px 10px; font-size: 0.85rem; }
300
+
.feed-item { border: 1px solid var(--border); border-radius: 12px; padding: 12px 12px 8px; background: #fff; display: grid; grid-template-columns: 44px 1fr; gap: 12px; align-items: flex-start; }
301
+
.feed-head { display: flex; justify-content: space-between; align-items: center; font-size: 1rem; color: var(--muted); }
302
+
.feed-identity { display: flex; align-items: center; gap: 8px; }
303
+
.feed-avatar .avatar { width: 40px; height: 40px; }
304
+
.feed-main { min-width: 0; }
305
+
.pill { background: #f0f0f2; border: 1px solid var(--border); border-radius: 999px; padding: 4px 10px; font-size: 1rem; }
306
+
.key-hint { font-size: 0.85rem; color: var(--muted); margin-right: 8px; }
288
307
.logout { margin-left: 8px; }
289
308
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
290
309
.feed-body { margin-top: 6px; line-height: 1.5; }
···
326
345
<main>
327
346
<section class="card composer">
328
347
<form id="composer-form">
348
+
<div class="profile-row" style="margin-bottom:12px;">
349
+
<div id="profile-avatar-wrapper">
350
+
<div class="avatar profile" style="background:#ddd;"></div>
351
+
</div>
352
+
<div class="profile-meta">
353
+
<div class="name-block">
354
+
<div id="profile-name-display" class="profile-name">Anonymous</div>
355
+
<div class="name-form">
356
+
<input id="name-input" type="text" placeholder="Name yourself" />
357
+
<button class="btn sm" type="button" id="name-save">Save</button>
358
+
</div>
359
+
</div>
360
+
<div class="pubkey-pill" id="pubkey-display">Loading key...</div>
361
+
</div>
362
+
</div>
363
+
<input type="file" id="avatar-input" accept="image/*" style="display:none;" />
329
364
<textarea id="composer-text" name="anmsg" placeholder="What are you doing in this world?"></textarea>
330
365
<div class="actions">
331
366
<button class="btn" type="submit">${loggedIn ? 'Sign & Publish' : 'Sign (login to publish)'}</button>
332
367
<button class="btn secondary" type="button" id="regen-key">New keypair</button>
333
-
<div id="pubkey-display" class="muted" style="font-size:0.9rem;">Loading key...</div>
334
368
</div>
335
369
<div class="status" id="composer-status"></div>
336
370
</form>
337
371
</section>
338
372
339
-
<section class="card feed">
340
-
<div class="feed-head">
341
-
<h3 style="margin:0;">Feed</h3>
342
-
<span class="pill">Local APDS</span>
343
-
</div>
373
+
<section class="feed">
344
374
<div id="feed-list" class="feed-list">
345
375
<div class="muted">Loading feed...</div>
346
376
</div>
···
358
388
const pubkeyEl = document.getElementById('pubkey-display')
359
389
const regenBtn = document.getElementById('regen-key')
360
390
const feedList = document.getElementById('feed-list')
391
+
const profileAvatarWrapper = document.getElementById('profile-avatar-wrapper')
392
+
const avatarInput = document.getElementById('avatar-input')
393
+
const nameInput = document.getElementById('name-input')
394
+
const nameSave = document.getElementById('name-save')
395
+
const nameDisplay = document.getElementById('profile-name-display')
361
396
362
397
const setStatus = (text, isError) => {
363
398
if (!statusEl) return
···
385
420
await ensureKeypair()
386
421
await renderPubkey()
387
422
423
+
const initProfile = async () => {
424
+
try {
425
+
const pub = await apds.pubkey()
426
+
const avatarEl = await apds.visual(pub)
427
+
avatarEl.classList.add('avatar', 'profile', 'interactive')
428
+
avatarEl.alt = 'avatar'
429
+
avatarEl.addEventListener('click', () => avatarInput?.click())
430
+
431
+
const existingImage = await apds.get('image')
432
+
if (existingImage) {
433
+
try {
434
+
avatarEl.src = await apds.get(existingImage)
435
+
} catch (err) {
436
+
console.warn('Could not load stored avatar', err)
437
+
}
438
+
}
439
+
440
+
if (profileAvatarWrapper) {
441
+
profileAvatarWrapper.innerHTML = ''
442
+
profileAvatarWrapper.appendChild(avatarEl)
443
+
}
444
+
445
+
const savedName = await apds.get('name')
446
+
if (typeof savedName === 'string' && savedName.trim()) {
447
+
if (nameDisplay) nameDisplay.textContent = savedName
448
+
if (nameInput) nameInput.placeholder = savedName
449
+
}
450
+
} catch (err) {
451
+
console.warn('Failed to init profile', err)
452
+
setStatus('Could not load local profile', true)
453
+
}
454
+
}
455
+
456
+
avatarInput?.addEventListener('change', (e) => {
457
+
const file = e.target?.files?.[0]
458
+
if (!file) return
459
+
if (!file.type.startsWith('image/')) {
460
+
setStatus('Please choose an image file', true)
461
+
return
462
+
}
463
+
const maxBytes = 6 * 1024 * 1024
464
+
if (file.size > maxBytes) {
465
+
setStatus('Image too large (max 6MB)', true)
466
+
return
467
+
}
468
+
469
+
const reader = new FileReader()
470
+
reader.onload = (ev) => {
471
+
const result = ev.target?.result
472
+
if (!result) return
473
+
const canvas = document.createElement('canvas')
474
+
const ctx = canvas.getContext('2d')
475
+
if (!ctx) {
476
+
setStatus('Canvas unsupported', true)
477
+
return
478
+
}
479
+
const img = new Image()
480
+
img.onload = async () => {
481
+
try {
482
+
const size = 256
483
+
let dataUrl
484
+
if (img.width > size || img.height > size) {
485
+
const { width, height } = img
486
+
let cropWidth
487
+
let cropHeight
488
+
if (width > height) {
489
+
cropWidth = size
490
+
cropHeight = cropWidth * (height / width)
491
+
} else {
492
+
cropHeight = size
493
+
cropWidth = cropHeight * (width / height)
494
+
}
495
+
canvas.width = cropWidth
496
+
canvas.height = cropHeight
497
+
ctx.drawImage(img, 0, 0, width, height, 0, 0, cropWidth, cropHeight)
498
+
dataUrl = canvas.toDataURL()
499
+
} else {
500
+
canvas.width = img.width
501
+
canvas.height = img.height
502
+
ctx.drawImage(img, 0, 0)
503
+
dataUrl = canvas.toDataURL()
504
+
}
505
+
if (profileAvatarWrapper?.firstChild && profileAvatarWrapper.firstChild instanceof HTMLImageElement) {
506
+
profileAvatarWrapper.firstChild.src = dataUrl
507
+
}
508
+
const hash = await apds.make(dataUrl)
509
+
await apds.put('image', hash)
510
+
setStatus('Avatar updated', false)
511
+
} catch (err) {
512
+
console.warn('Avatar upload failed', err)
513
+
setStatus('Avatar upload failed', true)
514
+
}
515
+
}
516
+
img.src = result
517
+
}
518
+
reader.readAsDataURL(file)
519
+
})
520
+
521
+
nameSave?.addEventListener('click', async () => {
522
+
const val = (nameInput?.value || '').trim()
523
+
if (!val) {
524
+
setStatus('Enter a name first', true)
525
+
return
526
+
}
527
+
try {
528
+
await apds.put('name', val)
529
+
if (nameDisplay) nameDisplay.textContent = val
530
+
if (nameInput) {
531
+
nameInput.value = ''
532
+
nameInput.placeholder = val
533
+
}
534
+
setStatus('Name saved', false)
535
+
} catch (err) {
536
+
console.warn('Name save failed', err)
537
+
setStatus('Could not save name', true)
538
+
}
539
+
})
540
+
541
+
await initProfile()
542
+
388
543
regenBtn?.addEventListener('click', async () => {
389
544
const kp = await apds.generate()
390
545
await apds.put('keypair', kp)
···
449
604
const html = items
450
605
.map((item) => {
451
606
const author = item.author || 'unknown author'
607
+
const displayName = item.displayName || author
608
+
const avatarUrl = item.avatarUrl || ''
452
609
const time = item.time || ''
453
610
const bodyHtml = item.bodyHtml || ''
454
611
const hash = item.hash || ''
612
+
const keyHint = item.keyHint || ''
455
613
return (
456
614
'<div class="feed-item">' +
457
-
'<div class="feed-head">' +
458
-
'<span class="pill">' + author + '</span>' +
459
-
'<span class="muted">' + time + '</span>' +
615
+
'<div class="feed-avatar">' +
616
+
(avatarUrl ? '<img class="avatar" src="' + avatarUrl + '" alt="avatar">' : '<div class="avatar"></div>') +
460
617
'</div>' +
618
+
'<div class="feed-main">' +
619
+
'<div class="feed-head">' +
620
+
'<div class="feed-identity">' +
621
+
'<div style="font-weight:600; color:#111;">' + displayName + '</div>' +
622
+
'</div>' +
623
+
'<div style="display:flex; align-items:center; gap:6px;">' +
624
+
(keyHint ? '<span class="key-hint">' + keyHint + '</span>' : '') +
625
+
(hash
626
+
? '<a class="muted" href="' + hash + '" style="text-decoration:none; font-size:0.9rem;">' + time + '</a>'
627
+
: '<span class="muted">' + time + '</span>') +
628
+
'</div>' +
629
+
'</div>' +
461
630
'<div class="feed-body">' + bodyHtml + '</div>' +
462
-
'<div class="muted" style="font-size:0.85rem; margin-top:6px;">' + hash + '</div>' +
631
+
'</div>' +
463
632
'</div>'
464
633
)
465
634
})
···
479
648
const mapped = await Promise.all(
480
649
[...entries].reverse().map(async (msg) => {
481
650
let bodyForRender = msg.text || ''
651
+
let displayName = msg.author || ''
652
+
let avatarUrl = ''
482
653
try {
483
654
const parsed = await apds.parseYaml(msg.text || '')
484
655
bodyForRender = parsed?.body ?? ''
656
+
if (parsed?.name && typeof parsed.name === 'string') {
657
+
displayName = parsed.name
658
+
}
659
+
if (parsed?.image && typeof parsed.image === 'string') {
660
+
try {
661
+
const maybeDataUrl =
662
+
parsed.image.startsWith('data:') || parsed.image.startsWith('http')
663
+
? parsed.image
664
+
: await apds.get(parsed.image)
665
+
avatarUrl = maybeDataUrl || ''
666
+
} catch (err) {
667
+
console.warn('Could not load image from yaml', err)
668
+
}
669
+
}
485
670
} catch (err) {
486
671
// fall back to raw text
487
672
}
673
+
if (!avatarUrl) {
674
+
try {
675
+
const avatarEl = await apds.visual(msg.author || '')
676
+
avatarUrl = avatarEl?.src || ''
677
+
} catch (err) {
678
+
// keep empty
679
+
}
680
+
}
488
681
return {
489
682
hash: msg.hash,
490
683
bodyHtml: marked.parse(bodyForRender),
491
684
author: msg.author || '',
685
+
displayName,
686
+
avatarUrl,
687
+
keyHint: msg.author ? msg.author.slice(0, 5) : '',
492
688
time: msg.ts ? await apds.human(msg.ts) : '',
493
689
}
494
690
}),