ANProto over ATProto -- using Bluesky PDSes to store ANProto messages and blobs

ui tweaks

Changed files
+213 -17
src
+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 }),