Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

feat: add Share button and invite modal to document editor

+123 -18
+6 -18
internal/handler/handler.go
··· 51 51 // AccessToken is the ATProto access token for WebSocket auth. 52 52 // Empty string if user has no ATProto session. 53 53 AccessToken string 54 + // IsOwner is true when the current user owns (created) the document. 55 + IsOwner bool 54 56 // IsCollaborator is true when the current user is in the collaborators list. 55 57 IsCollaborator bool 56 58 } ··· 339 341 doc.RKey = rkey 340 342 341 343 // Fetch ATProto session to pass access token to the WebSocket client. 342 - editData := &DocumentEditData{Document: doc} 344 + // The user is always the owner here since we fetched the doc via client.DID(). 345 + editData := &DocumentEditData{Document: doc, IsOwner: true} 343 346 if session, err := h.DB.GetATProtoSession(user.ID); err == nil && session != nil { 344 347 editData.AccessToken = session.AccessToken 345 348 // Check if this user is listed as a collaborator on the doc. ··· 486 489 } 487 490 doc.RKey = rkey 488 491 489 - session, err := h.DB.GetATProtoSession(user.ID) 490 - if err != nil || session == nil { 491 - http.Error(w, "Unauthorized", http.StatusForbidden) 492 - return 493 - } 494 - 495 - parts := strings.Split(doc.URI, "/") 496 - ownerDID := "" 497 - if len(parts) >= 2 { 498 - ownerDID = parts[1] 499 - } 500 - if ownerDID == "" || session.DID != ownerDID { 501 - http.Error(w, "Unauthorized", http.StatusForbidden) 502 - return 503 - } 504 - 492 + // The document was fetched via client.DID(), so the current user is always the owner. 505 493 if len(doc.Collaborators) >= 5 { 506 494 http.Error(w, "Maximum collaborators reached", http.StatusBadRequest) 507 495 return 508 496 } 509 497 510 - invite, err := collaboration.CreateInvite(h.DB, rkey, session.DID) 498 + invite, err := collaboration.CreateInvite(h.DB, rkey, client.DID()) 511 499 if err != nil { 512 500 log.Printf("DocumentInvite: create invite: %v", err) 513 501 http.Error(w, "Failed to create invite", http.StatusInternalServerError)
+49
static/css/editor.css
··· 368 368 .editor-page:has(~ .comment-sidebar) { 369 369 right: 260px; 370 370 } 371 + 372 + /* ── Collaboration: Invite modal ─────────────────────────────────────────── */ 373 + 374 + .invite-modal { 375 + position: fixed; 376 + inset: 0; 377 + background: rgba(0,0,0,0.4); 378 + display: flex; 379 + align-items: center; 380 + justify-content: center; 381 + z-index: 200; 382 + } 383 + 384 + .invite-modal-box { 385 + background: var(--bg-card); 386 + border: 1px solid var(--border); 387 + border-radius: var(--radius); 388 + box-shadow: 0 8px 32px rgba(0,0,0,0.2); 389 + width: 440px; 390 + max-width: calc(100vw - 2rem); 391 + } 392 + 393 + .invite-modal-header { 394 + display: flex; 395 + align-items: center; 396 + justify-content: space-between; 397 + padding: 0.75rem 1rem; 398 + border-bottom: 1px solid var(--border); 399 + font-weight: 600; 400 + font-size: 0.9rem; 401 + } 402 + 403 + .invite-modal-close { 404 + background: none; 405 + border: none; 406 + color: var(--text-muted); 407 + cursor: pointer; 408 + font-size: 1rem; 409 + padding: 0.1rem 0.3rem; 410 + line-height: 1; 411 + } 412 + 413 + .invite-modal-close:hover { 414 + color: var(--text); 415 + } 416 + 417 + .invite-modal-body { 418 + padding: 1rem; 419 + }
+68
templates/document_edit.html
··· 17 17 {{if .IsCollaborator}} 18 18 <div id="presence-list" class="presence-list" title="Active collaborators"></div> 19 19 {{end}} 20 + {{if .IsOwner}} 21 + <button class="btn btn-sm btn-outline" id="btn-share" onclick="generateInvite()">Share</button> 22 + {{end}} 20 23 <button class="btn btn-sm btn-outline source-only active" id="btn-preview" onclick="togglePreview()">Preview</button> 21 24 <button class="btn btn-sm btn-outline source-only" id="btn-wrap" onclick="toggleWrap()">Wrap</button> 22 25 <button class="btn btn-sm btn-outline rich-only" id="btn-undo" onclick="richUndo()" title="Undo (⌘Z)">↩</button> ··· 62 65 </div> 63 66 </div> 64 67 </div> 68 + 69 + <!-- Invite modal --> 70 + {{if .IsOwner}} 71 + <div id="invite-modal" class="invite-modal" style="display:none"> 72 + <div class="invite-modal-box"> 73 + <div class="invite-modal-header"> 74 + <span>Share document</span> 75 + <button class="invite-modal-close" onclick="closeInviteModal()">✕</button> 76 + </div> 77 + <div id="invite-modal-body" class="invite-modal-body"> 78 + <p>Generating invite link...</p> 79 + </div> 80 + </div> 81 + </div> 82 + {{end}} 65 83 66 84 <!-- Comment sidebar --> 67 85 {{if .IsCollaborator}} ··· 413 431 if (!linkTooltipEl.contains(e.target) && !document.getElementById('editor-rich').contains(e.target)) { 414 432 hideLinkTooltip(); 415 433 } 434 + }); 435 + 436 + // ── Invite ──────────────────────────────────────────────────────────────── 437 + 438 + async function generateInvite() { 439 + const modal = document.getElementById('invite-modal'); 440 + const body = document.getElementById('invite-modal-body'); 441 + if (!modal) return; 442 + body.innerHTML = '<p>Generating invite link...</p>'; 443 + modal.style.display = 'flex'; 444 + 445 + try { 446 + const resp = await fetch(`/api/docs/${rkey}/invite`, { method: 'POST' }); 447 + const data = await resp.json(); 448 + if (!resp.ok) throw new Error(data.error || resp.statusText); 449 + const link = data.invite_url || data.inviteLink || data.url || ''; 450 + body.innerHTML = ` 451 + <p style="margin:0 0 0.5rem;font-size:0.85rem;color:var(--text-muted)"> 452 + Share this link. It expires in 7 days and can be used once. 453 + </p> 454 + <div style="display:flex;gap:0.5rem;align-items:center"> 455 + <input id="invite-link-input" type="text" value="${escHtml(link)}" readonly 456 + style="flex:1;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius); 457 + padding:0.4rem 0.5rem;font-size:0.85rem;color:var(--text);outline:none"> 458 + <button class="btn btn-sm" onclick="copyInviteLink()">Copy</button> 459 + </div> 460 + <p id="invite-copy-msg" style="margin:0.4rem 0 0;font-size:0.8rem;color:var(--primary);display:none">Copied!</p> 461 + `; 462 + } catch (e) { 463 + body.innerHTML = `<p style="color:var(--danger)">Failed to generate invite: ${escHtml(e.message)}</p>`; 464 + } 465 + } 466 + 467 + function copyInviteLink() { 468 + const input = document.getElementById('invite-link-input'); 469 + if (!input) return; 470 + navigator.clipboard.writeText(input.value).then(() => { 471 + const msg = document.getElementById('invite-copy-msg'); 472 + if (msg) { msg.style.display = 'block'; setTimeout(() => msg.style.display = 'none', 2000); } 473 + }); 474 + } 475 + 476 + function closeInviteModal() { 477 + const modal = document.getElementById('invite-modal'); 478 + if (modal) modal.style.display = 'none'; 479 + } 480 + 481 + // Close invite modal on backdrop click 482 + document.getElementById('invite-modal')?.addEventListener('click', e => { 483 + if (e.target === document.getElementById('invite-modal')) closeInviteModal(); 416 484 }); 417 485 418 486 // ── WebSocket / Collaboration ─────────────────────────────────────────────