interactive intro to open social

fix: improve guestbook singleton behavior and particle animations

- populate global signatures cache from existing visit records on page load
- make guestbook truly global - shows all visitors regardless of page
- check page owner signature status without authentication
- refine particle animations: slower speed, gentler easing, fade in/out
- make PDS pulse more subtle with proper centering transform
- fix pdsls.dev links to include at:// prefix and /app.at-me.visit path
- update justfile to watch static directory for template changes
- remove tangled.org logo from info modal

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+273 -162
src
templates
static
+2 -2
justfile
··· 2 2 3 3 # run the app with hot reloading 4 4 dev: 5 - cargo watch -w src -x 'run' 5 + cargo watch -w src -w static -x 'run' 6 6 7 7 # run on specific port with hot reloading 8 8 dev-port PORT='3000': 9 - PORT={{PORT}} cargo watch -w src -x 'run' 9 + PORT={{PORT}} cargo watch -w src -w static -x 'run' 10 10 11 11 # build the project 12 12 build:
+33
src/routes.rs
··· 390 390 // Fetch user avatar from Bluesky 391 391 let avatar = fetch_user_avatar(did).await; 392 392 393 + // Check if this user has guestbook visit records and add to global cache if not already there 394 + if !GLOBAL_SIGNATURES.contains_key(did) { 395 + let list_records_url = format!( 396 + "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}", 397 + pds, did, constants::GUESTBOOK_COLLECTION 398 + ); 399 + 400 + if let Ok(response) = reqwest::get(&list_records_url).await { 401 + if let Ok(data) = response.json::<serde_json::Value>().await { 402 + if let Some(records) = data["records"].as_array() { 403 + if !records.is_empty() { 404 + // User has visit records - add to global cache 405 + let (handle_opt, avatar_opt) = fetch_profile_info(did).await; 406 + if let Some(first_record) = records.first() { 407 + let timestamp = first_record["value"]["createdAt"] 408 + .as_str() 409 + .unwrap_or("") 410 + .to_string(); 411 + 412 + GLOBAL_SIGNATURES.insert(did.to_string(), GuestbookSignature { 413 + did: did.to_string(), 414 + handle: handle_opt, 415 + avatar: avatar_opt.clone(), 416 + timestamp, 417 + }); 418 + log::info!("Populated global cache with existing visit record for DID: {}", did); 419 + } 420 + } 421 + } 422 + } 423 + } 424 + } 425 + 393 426 // Fetch collections from PDS 394 427 let repo_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, did); 395 428 let repo_response = match reqwest::get(&repo_url).await {
+101 -97
src/templates/app.html
··· 760 760 } 761 761 } 762 762 763 - .footer { 764 - position: fixed; 765 - bottom: clamp(0.75rem, 2vmin, 1rem); 766 - left: 50%; 767 - transform: translateX(-50%); 768 - font-size: clamp(0.6rem, 1.2vmin, 0.7rem); 769 - color: var(--text); 770 - z-index: 100; 771 - background: var(--surface); 772 - padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.75rem, 2vmin, 1rem); 773 - border-radius: 4px; 774 - border: 1px solid var(--border); 775 - } 776 - 777 - .footer a { 778 - color: var(--text); 779 - text-decoration: none; 780 - border-bottom: 1px solid transparent; 781 - transition: all 0.2s ease; 782 - } 783 - 784 - .footer a:hover { 785 - border-bottom-color: var(--text); 786 - } 787 763 788 764 #field.loading { 789 765 position: fixed; ··· 1157 1133 color: var(--text-light); 1158 1134 border: 1px solid var(--border); 1159 1135 background: var(--bg); 1160 - padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.8rem, 2vmin, 1rem); 1136 + padding: clamp(0.4rem, 1vmin, 0.5rem) clamp(0.6rem, 1.5vmin, 0.8rem); 1161 1137 transition: all 0.2s ease; 1162 1138 cursor: pointer; 1163 1139 border-radius: 2px; ··· 1165 1141 align-items: center; 1166 1142 gap: clamp(0.3rem, 0.8vmin, 0.5rem); 1167 1143 height: clamp(32px, 7vmin, 40px); 1144 + white-space: nowrap; 1168 1145 } 1169 1146 1170 1147 .sign-guestbook-btn:hover, .sign-guestbook-btn:active { ··· 1217 1194 bottom: clamp(0.75rem, 2vmin, 1rem); 1218 1195 right: clamp(0.75rem, 2vmin, 1rem); 1219 1196 display: flex; 1197 + flex-direction: row; 1220 1198 align-items: center; 1221 1199 gap: clamp(0.5rem, 1.5vmin, 0.75rem); 1222 1200 z-index: 100; ··· 1245 1223 border-color: var(--text-light); 1246 1224 } 1247 1225 1248 - @media (max-width: 768px) { 1249 - .guestbook-buttons-container { 1250 - flex-direction: column-reverse; 1251 - gap: clamp(0.5rem, 1.5vmin, 0.75rem); 1252 - } 1253 - } 1254 1226 1255 - @media (max-width: 768px) { 1256 - .home-btn { 1257 - width: clamp(36px, 8vmin, 44px); 1258 - height: clamp(36px, 8vmin, 44px); 1259 - } 1260 - 1261 - .firehose-toast { 1262 - top: clamp(4.5rem, 9vmin, 5.5rem); 1263 - } 1264 - } 1265 1227 1266 1228 .guestbook-modal { 1267 1229 position: fixed; ··· 1281 1243 .guestbook-paper { 1282 1244 max-width: 700px; 1283 1245 margin: 0 auto; 1284 - background: linear-gradient(to bottom, #f9f7f1 0%, #f5f1e8 100%); 1246 + background: 1247 + repeating-linear-gradient( 1248 + 0deg, 1249 + transparent, 1250 + transparent 31px, 1251 + rgba(212, 197, 168, 0.15) 31px, 1252 + rgba(212, 197, 168, 0.15) 32px 1253 + ), 1254 + linear-gradient(to bottom, #fdfcf8 0%, #f9f7f1 100%); 1285 1255 border: 1px solid #d4c5a8; 1286 1256 box-shadow: 1287 - 0 2px 4px rgba(0, 0, 0, 0.1), 1288 - inset 0 0 60px rgba(255, 248, 240, 0.5); 1257 + 0 4px 6px rgba(0, 0, 0, 0.1), 1258 + 0 2px 4px rgba(0, 0, 0, 0.06), 1259 + inset 0 0 80px rgba(255, 248, 240, 0.6); 1289 1260 padding: clamp(2.5rem, 6vmin, 4rem) clamp(2rem, 5vmin, 3rem); 1290 1261 position: relative; 1291 1262 } 1292 1263 1293 1264 @media (prefers-color-scheme: dark) { 1294 1265 .guestbook-paper { 1295 - background: linear-gradient(to bottom, #2a2520 0%, #1f1b17 100%); 1266 + background: 1267 + repeating-linear-gradient( 1268 + 0deg, 1269 + transparent, 1270 + transparent 31px, 1271 + rgba(90, 80, 70, 0.2) 31px, 1272 + rgba(90, 80, 70, 0.2) 32px 1273 + ), 1274 + linear-gradient(to bottom, #2a2520 0%, #1f1b17 100%); 1296 1275 border-color: #3a3530; 1297 1276 box-shadow: 1298 - 0 2px 4px rgba(0, 0, 0, 0.5), 1299 - inset 0 0 60px rgba(60, 50, 40, 0.3); 1277 + 0 4px 6px rgba(0, 0, 0, 0.5), 1278 + 0 2px 4px rgba(0, 0, 0, 0.3), 1279 + inset 0 0 80px rgba(60, 50, 40, 0.4); 1300 1280 } 1301 1281 } 1302 1282 ··· 1333 1313 text-align: center; 1334 1314 margin-bottom: clamp(0.5rem, 1.5vmin, 1rem); 1335 1315 font-weight: 400; 1336 - letter-spacing: 0.05em; 1316 + letter-spacing: 0.02em; 1337 1317 } 1338 1318 1339 1319 @media (prefers-color-scheme: dark) { ··· 1655 1635 /* Retro neon guestbook sign */ 1656 1636 .guestbook-sign { 1657 1637 position: fixed; 1658 - bottom: clamp(3.5rem, 8vmin, 5rem); 1638 + bottom: clamp(3.5rem, 8.5vmin, 5.5rem); 1659 1639 right: clamp(0.75rem, 2vmin, 1rem); 1660 1640 font-family: ui-monospace, 'SF Mono', Monaco, monospace; 1661 1641 font-size: clamp(0.6rem, 1.3vmin, 0.7rem); ··· 1668 1648 animation: neon-flicker 8s infinite; 1669 1649 pointer-events: none; 1670 1650 user-select: none; 1651 + white-space: nowrap; 1671 1652 } 1672 1653 1673 1654 @media (prefers-color-scheme: dark) { ··· 1678 1659 } 1679 1660 } 1680 1661 1681 - /* POV indicator */ 1662 + /* POV indicator - subtle top banner */ 1682 1663 .pov-indicator { 1683 1664 position: fixed; 1684 - left: clamp(0.75rem, 2vmin, 1rem); 1685 - top: 50%; 1686 - transform: translateY(-50%); 1665 + left: 50%; 1666 + top: clamp(1rem, 2vmin, 1.5rem); 1667 + transform: translateX(-50%); 1687 1668 font-family: ui-monospace, 'SF Mono', Monaco, monospace; 1688 - font-size: clamp(0.6rem, 1.3vmin, 0.7rem); 1669 + font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1689 1670 color: var(--text-light); 1690 1671 text-transform: lowercase; 1691 - letter-spacing: 0.1em; 1672 + letter-spacing: 0.12em; 1692 1673 z-index: 50; 1693 - opacity: 0.6; 1694 - text-shadow: 0 0 4px currentColor; 1695 - animation: neon-flicker 8s infinite; 1674 + opacity: 0.4; 1675 + text-shadow: 0 0 3px currentColor; 1676 + animation: pov-subtle-flicker 37s infinite; 1696 1677 pointer-events: none; 1697 1678 user-select: none; 1698 - text-align: left; 1699 - max-width: clamp(120px, 25vw, 180px); 1700 - line-height: 1.6; 1679 + text-align: center; 1680 + line-height: 1.4; 1701 1681 } 1702 1682 1703 1683 .pov-handle { 1704 - display: block; 1705 - margin-top: 0.25rem; 1706 - font-size: clamp(0.65rem, 1.4vmin, 0.75rem); 1707 - opacity: 0.8; 1684 + display: inline; 1685 + margin-left: 0.3rem; 1686 + font-size: inherit; 1687 + opacity: 0.9; 1708 1688 } 1709 1689 1710 1690 @media (prefers-color-scheme: dark) { 1711 1691 .pov-indicator { 1712 - color: #6b9dff; 1713 - opacity: 0.5; 1714 - text-shadow: 0 0 6px currentColor, 0 0 12px rgba(107, 157, 255, 0.3); 1715 - } 1716 - } 1717 - 1718 - @media (max-width: 768px) { 1719 - .pov-indicator { 1720 - top: auto; 1721 - bottom: clamp(3.5rem, 8vmin, 5rem); 1722 - left: clamp(0.75rem, 2vmin, 1rem); 1723 - right: auto; 1724 - transform: none; 1725 - text-align: left; 1726 - max-width: 60vw; 1692 + color: #8ab4f8; 1693 + opacity: 0.35; 1694 + text-shadow: 0 0 4px currentColor, 0 0 8px rgba(138, 180, 248, 0.2); 1727 1695 } 1728 1696 } 1729 1697 1698 + /* Guestbook sign flicker - 13 second loop */ 1730 1699 @keyframes neon-flicker { 1731 1700 0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { 1732 1701 opacity: 0.6; ··· 1738 1707 } 1739 1708 } 1740 1709 1710 + /* POV indicator flicker - subtle 37 second loop, flickers TO brightness */ 1711 + @keyframes pov-subtle-flicker { 1712 + 0%, 98% { 1713 + opacity: 0.4; 1714 + text-shadow: 0 0 3px currentColor; 1715 + } 1716 + 17%, 17.3%, 17.6% { 1717 + opacity: 0.75; 1718 + text-shadow: 0 0 8px currentColor, 0 0 12px currentColor; 1719 + } 1720 + 17.15%, 17.45% { 1721 + opacity: 0.5; 1722 + text-shadow: 0 0 4px currentColor; 1723 + } 1724 + 71%, 71.2% { 1725 + opacity: 0.8; 1726 + text-shadow: 0 0 10px currentColor, 0 0 15px currentColor; 1727 + } 1728 + 71.1% { 1729 + opacity: 0.45; 1730 + text-shadow: 0 0 3px currentColor; 1731 + } 1732 + } 1733 + 1741 1734 @media (prefers-color-scheme: dark) { 1742 1735 @keyframes neon-flicker { 1743 1736 0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { ··· 1749 1742 text-shadow: 0 0 2px currentColor; 1750 1743 } 1751 1744 } 1752 - } 1753 1745 1754 - @media (max-width: 768px) { 1755 - .guestbook-sign { 1756 - bottom: auto; 1757 - top: clamp(1rem, 2vmin, 1.5rem); 1758 - right: auto; 1759 - left: 50%; 1760 - transform: translateX(-50%); 1761 - font-size: clamp(0.55rem, 1.2vmin, 0.65rem); 1746 + @keyframes pov-subtle-flicker { 1747 + 0%, 98% { 1748 + opacity: 0.35; 1749 + text-shadow: 0 0 4px currentColor, 0 0 8px rgba(138, 180, 248, 0.2); 1750 + } 1751 + 17%, 17.3%, 17.6% { 1752 + opacity: 0.75; 1753 + text-shadow: 0 0 12px currentColor, 0 0 20px rgba(138, 180, 248, 0.6); 1754 + } 1755 + 17.15%, 17.45% { 1756 + opacity: 0.45; 1757 + text-shadow: 0 0 6px currentColor, 0 0 10px rgba(138, 180, 248, 0.3); 1758 + } 1759 + 71%, 71.2% { 1760 + opacity: 0.8; 1761 + text-shadow: 0 0 15px currentColor, 0 0 25px rgba(138, 180, 248, 0.7); 1762 + } 1763 + 71.1% { 1764 + opacity: 0.4; 1765 + text-shadow: 0 0 5px currentColor, 0 0 9px rgba(138, 180, 248, 0.25); 1766 + } 1762 1767 } 1763 1768 } 1769 + 1764 1770 </style> 1765 1771 </head> 1766 1772 <body> ··· 1774 1780 <span class="watch-indicator"></span> 1775 1781 <span class="watch-label">watch live</span> 1776 1782 </button> 1777 - <div class="pov-indicator"> 1778 - point of view: 1779 - <span class="pov-handle" id="povHandle"></span> 1780 - </div> 1783 + <div class="pov-indicator">point of view:<span class="pov-handle" id="povHandle"></span></div> 1781 1784 <div class="guestbook-sign">sign the guest list</div> 1782 1785 <div class="guestbook-buttons-container"> 1783 1786 <button class="view-guestbook-btn" id="viewGuestbookBtn" title="view all signatures"> ··· 1804 1807 <p>this visualization shows your <a href="https://atproto.com/guides/data-repos" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Personal Data Server</a> - where your social data actually lives. unlike traditional platforms that lock everything in their database, your posts, likes, and follows are stored here, on infrastructure you control.</p> 1805 1808 <p>each circle represents an app that writes to your space. <a href="https://bsky.app" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">bluesky</a> for microblogging. <a href="https://whtwnd.com" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">whitewind</a> for long-form posts. <a href="https://tangled.org" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">tangled.org</a> for code hosting. they're all just different views of the same underlying data - <strong>your</strong> data.</p> 1806 1809 <p>this is what "<a href="https://overreacted.io/open-social/" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">open social</a>" means: your followers, your content, your connections - they all belong to you, not the app. switch apps anytime and take everything with you. no platform can hold your social graph hostage.</p> 1807 - <p><strong>how to explore:</strong> click your avatar in the center to see the details of your identity. click any app to browse the records it's created in your repository.</p> 1810 + <p style="margin-bottom: 1rem;"><strong>how to explore:</strong> click your avatar in the center to see the details of your identity. click any app to browse the records it's created in your repository.</p> 1808 1811 <button id="closeInfo">got it</button> 1809 1812 <button id="restartTour" onclick="window.restartOnboarding()" style="margin-left: 0.5rem; background: var(--surface-hover);">restart tour</button> 1813 + <p style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--border); font-size: 0.7rem; color: var(--text-light); display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;"> 1814 + <span>view <a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">the source code for this project</a> on</span> 1815 + <a href="https://tangled.org" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">tangled.org</a> 1816 + </p> 1810 1817 </div> 1811 1818 1812 1819 <div class="onboarding-overlay" id="onboardingOverlay"> ··· 1831 1838 </div> 1832 1839 <div id="detail" class="detail-panel"></div> 1833 1840 1834 - <div class="footer"> 1835 - <a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer">view source</a> 1836 - </div> 1837 1841 <script> 1838 1842 window.DID = '{DID}'; 1839 1843 </script>
+137 -63
static/app.js
··· 5 5 let globalPds = null; 6 6 let globalHandle = null; 7 7 let globalApps = null; // Store apps for repositioning on resize 8 + let pageOwnerHasSigned = false; // Track if the page owner (did) has signed the guestbook 8 9 9 10 // Adaptive handle text sizing 10 11 function adaptHandleTextSize(handleEl) { ··· 975 976 this.color = color; 976 977 this.metadata = metadata; // {action, collection, namespace} 977 978 this.progress = 0; 978 - this.speed = 0.012; // Slower for visibility 979 - this.size = 5; 980 - this.glowSize = 10; 979 + this.speed = 0.008; // Slower, more graceful 980 + this.size = 6; // Slightly larger core 981 + this.glowSize = 14; // Softer, wider glow 981 982 } 982 983 983 984 update() { 984 985 if (this.progress < 1) { 985 986 this.progress += this.speed; 986 - // Cubic ease-in-out 987 - const eased = this.progress < 0.5 988 - ? 4 * this.progress * this.progress * this.progress 989 - : 1 - Math.pow(-2 * this.progress + 2, 3) / 2; 990 - 987 + // Gentle ease-out for organic feel 988 + const eased = 1 - Math.pow(1 - this.progress, 3); 989 + 991 990 this.x = this.startX + (this.endX - this.startX) * eased; 992 991 this.y = this.startY + (this.endY - this.startY) * eased; 993 992 } ··· 995 994 } 996 995 997 996 draw(ctx) { 998 - // Outer glow 997 + // Calculate fade based on progress for elegant entry/exit 998 + const fadeIn = Math.min(this.progress * 4, 1); // Fade in over first 25% 999 + const fadeOut = this.progress > 0.8 ? 1 - ((this.progress - 0.8) / 0.2) : 1; // Fade out in last 20% 1000 + const opacity = Math.min(fadeIn, fadeOut); 1001 + 1002 + // Outer glow - softer and more diffuse 999 1003 ctx.beginPath(); 1000 1004 ctx.arc(this.x, this.y, this.glowSize, 0, Math.PI * 2); 1001 1005 const gradient = ctx.createRadialGradient( 1002 1006 this.x, this.y, 0, 1003 1007 this.x, this.y, this.glowSize 1004 1008 ); 1005 - gradient.addColorStop(0, this.color + '80'); 1009 + // Use lower opacity for subtlety 1010 + gradient.addColorStop(0, this.color + Math.floor(opacity * 60).toString(16).padStart(2, '0')); 1011 + gradient.addColorStop(0.5, this.color + Math.floor(opacity * 30).toString(16).padStart(2, '0')); 1006 1012 gradient.addColorStop(1, this.color + '00'); 1007 1013 ctx.fillStyle = gradient; 1008 1014 ctx.fill(); 1009 1015 1010 - // Inner particle 1016 + // Inner particle - subtle core 1011 1017 ctx.beginPath(); 1012 1018 ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); 1013 - ctx.fillStyle = this.color; 1019 + ctx.fillStyle = this.color + Math.floor(opacity * 180).toString(16).padStart(2, '0'); 1014 1020 ctx.fill(); 1015 1021 } 1016 1022 } ··· 1077 1083 function pulseIdentity() { 1078 1084 const identity = document.querySelector('.identity'); 1079 1085 if (identity) { 1080 - identity.style.transition = 'all 0.3s ease'; 1081 - identity.style.transform = 'scale(1.15)'; 1082 - identity.style.boxShadow = '0 0 25px rgba(255, 255, 255, 0.6)'; 1086 + // Subtle but visible pulse with contextual glow 1087 + const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 1088 + identity.style.transition = 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)'; 1089 + // Maintain centering transform while applying gentle scale 1090 + identity.style.transform = 'translate(-50%, -50%) scale(1.03)'; 1091 + identity.style.boxShadow = `0 0 25px ${textColor}50, 0 0 45px ${textColor}25`; 1083 1092 1084 1093 setTimeout(() => { 1085 - identity.style.transform = ''; 1094 + identity.style.transition = 'all 0.7s cubic-bezier(0.4, 0, 0.2, 1)'; 1095 + identity.style.transform = 'translate(-50%, -50%)'; 1086 1096 identity.style.boxShadow = ''; 1087 - }, 300); 1097 + }, 400); 1088 1098 } 1089 1099 } 1090 1100 ··· 1190 1200 }, 4000); // Slightly longer to read details 1191 1201 } 1192 1202 1193 - function getParticleColor(action) { 1194 - const colors = { 1195 - 'create': '#4ade80', // green 1196 - 'update': '#60a5fa', // blue 1197 - 'delete': '#f87171' // red 1198 - }; 1199 - return colors[action] || '#a0a0a0'; 1203 + function getParticleColor() { 1204 + // Use theme-aware color that represents data flow 1205 + // Get the text color from CSS variables and use it with reduced opacity 1206 + const textColor = getComputedStyle(document.documentElement).getPropertyValue('--text').trim(); 1207 + 1208 + // If we can parse it as rgb, use it; otherwise fall back to a neutral color 1209 + if (textColor.startsWith('rgb')) { 1210 + // Extract RGB values and return hex 1211 + const match = textColor.match(/(\d+),\s*(\d+),\s*(\d+)/); 1212 + if (match) { 1213 + const r = parseInt(match[1]); 1214 + const g = parseInt(match[2]); 1215 + const b = parseInt(match[3]); 1216 + return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); 1217 + } 1218 + } 1219 + 1220 + // Fallback: soft blue-gray that works in both themes and represents "information flow" 1221 + return '#8ba4b8'; 1200 1222 } 1201 1223 1202 1224 function createFirehoseParticle(event) { ··· 1257 1279 } 1258 1280 1259 1281 // Create particle (flows from app TO PDS, or pulses at PDS if no app circle) 1282 + // Color represents data flow, not specific to action type 1260 1283 const particle = new FirehoseParticle( 1261 1284 startX, startY, 1262 1285 endX, endY, 1263 - getParticleColor(event.action), 1286 + getParticleColor(), 1264 1287 { 1265 1288 action: event.action, 1266 1289 collection: event.collection, ··· 1730 1753 }, 5000); 1731 1754 } 1732 1755 1756 + async function checkPageOwnerSignature() { 1757 + // Check if the page owner (did) has signed the guestbook 1758 + try { 1759 + const response = await fetch('/api/guestbook/signatures'); 1760 + if (!response.ok) return false; 1761 + 1762 + const signatures = await response.json(); 1763 + pageOwnerHasSigned = signatures.some(sig => sig.did === did || sig.did === `at://${did}`); 1764 + console.log('[Guestbook] Page owner signed?', pageOwnerHasSigned); 1765 + return pageOwnerHasSigned; 1766 + } catch (error) { 1767 + console.error('[Guestbook] Error checking page owner signature:', error); 1768 + return false; 1769 + } 1770 + } 1771 + 1733 1772 function updateGuestbookButton() { 1734 1773 const signGuestbookBtn = document.getElementById('signGuestbookBtn'); 1735 1774 if (!signGuestbookBtn) return; ··· 1757 1796 authenticatedDid, 1758 1797 pageDid: did, 1759 1798 viewingOwnPage, 1760 - hasRecords 1799 + pageOwnerHasSigned 1761 1800 }); 1762 1801 1763 - if (viewingOwnPage && hasRecords) { 1764 - // Viewing own page and already signed - show checkmark 1802 + // If page owner has already signed, show "signed" state (regardless of who's viewing) 1803 + if (pageOwnerHasSigned) { 1765 1804 if (avatarImg) avatarImg.style.display = 'none'; 1766 1805 iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 1767 1806 iconSpan.style.display = 'flex'; 1768 1807 textSpan.textContent = 'signed'; 1769 1808 signGuestbookBtn.classList.add('signed'); 1770 - signGuestbookBtn.setAttribute('title', 'you\'ve signed the guestbook'); 1771 - signGuestbookBtn.disabled = false; 1809 + signGuestbookBtn.setAttribute('title', viewingOwnPage ? 'you\'ve signed the guestbook' : 'this user has signed the guestbook'); 1810 + signGuestbookBtn.disabled = false; // Allow clicking to view/unsign if own page 1772 1811 } else if (isAuthenticated) { 1773 1812 // Authenticated user - show THEIR avatar (not the page owner's) 1774 1813 if (authenticatedAvatar && avatarImg) { ··· 1783 1822 1784 1823 textSpan.textContent = 'sign as'; 1785 1824 1786 - if (viewingOwnPage && !hasRecords) { 1825 + if (viewingOwnPage) { 1787 1826 // Viewing own page, authenticated, ready to sign 1788 1827 signGuestbookBtn.style.background = 'var(--surface)'; 1789 1828 signGuestbookBtn.style.color = 'var(--text)'; ··· 1886 1925 1887 1926 modal.innerHTML = ` 1888 1927 <h2>watch it happen</h2> 1889 - <p style="margin-bottom: 1rem;">want to see your guestbook signature flow into your PDS in real-time?</p> 1890 - <p style="margin-bottom: 1.5rem; color: var(--text-lighter);">turn on "watch live" to see the record creation visualized as it happens.</p> 1928 + <p style="margin-bottom: 1rem;">want to see your app activity in real-time?</p> 1929 + <p style="margin-bottom: 1.5rem; color: var(--text-lighter);">turn on "watch live" to see your data flowing into your PDS as it happens.</p> 1891 1930 <div style="display: flex; gap: 0.5rem; justify-content: flex-end;"> 1892 1931 <button id="skipBtn" style="background: var(--bg);">skip</button> 1893 1932 <button id="watchBtn" style="background: var(--surface-hover);">enable watch live</button> ··· 1956 1995 overlay.addEventListener('click', closeModal); 1957 1996 1958 1997 confirmBtn.addEventListener('click', async () => { 1959 - confirmBtn.disabled = true; 1960 - confirmBtn.textContent = 'deleting...'; 1998 + // Close unsign modal first 1999 + closeModal(); 1961 2000 1962 - try { 1963 - const response = await fetch('/api/sign-guestbook', { 1964 - method: 'DELETE' 1965 - }); 2001 + // Show watch prompt before deleting 2002 + showWatchPrompt(async () => { 2003 + // Perform deletion 2004 + try { 2005 + const response = await fetch('/api/sign-guestbook', { 2006 + method: 'DELETE' 2007 + }); 1966 2008 1967 - const data = await response.json(); 2009 + const data = await response.json(); 1968 2010 1969 - if (data.success) { 1970 - closeModal(); 1971 - // Refresh auth status to update button 1972 - await checkAuthStatus(); 1973 - } else { 1974 - throw new Error(data.error || 'Unknown error'); 2011 + if (data.success) { 2012 + // Refresh page owner signature status and auth status to update button 2013 + await checkPageOwnerSignature(); 2014 + await checkAuthStatus(); 2015 + } else { 2016 + throw new Error(data.error || 'Unknown error'); 2017 + } 2018 + } catch (error) { 2019 + console.error('[Guestbook] Error unsigning:', error); 1975 2020 } 1976 - } catch (error) { 1977 - console.error('[Guestbook] Error unsigning:', error); 1978 - confirmBtn.textContent = 'error'; 1979 - setTimeout(() => { 1980 - confirmBtn.textContent = 'delete record'; 1981 - confirmBtn.disabled = false; 1982 - }, 2000); 1983 - } 2021 + }); 1984 2022 }); 1985 2023 } 1986 2024 1987 - document.addEventListener('DOMContentLoaded', () => { 2025 + document.addEventListener('DOMContentLoaded', async () => { 1988 2026 const signGuestbookBtn = document.getElementById('signGuestbookBtn'); 1989 2027 if (!signGuestbookBtn) { 1990 2028 console.error('[Guestbook] Sign guestbook button not found!'); 1991 2029 return; 1992 2030 } 1993 2031 2032 + // Check if page owner has signed (no auth required) 2033 + await checkPageOwnerSignature(); 2034 + 1994 2035 // Check auth status on load 1995 2036 checkAuthStatus(); 1996 2037 ··· 2003 2044 return; 2004 2045 } 2005 2046 2006 - // If user already has records, show unsign modal 2007 - if (hasRecords && viewingOwnPage) { 2047 + // If page owner already signed, show unsign modal (only if viewing own page) 2048 + if (pageOwnerHasSigned && viewingOwnPage) { 2008 2049 showUnsignModal(); 2009 2050 return; 2010 2051 } ··· 2043 2084 if (data.success) { 2044 2085 iconSpan.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'; 2045 2086 textSpan.textContent = 'signed!'; 2046 - // Refresh auth status to update button 2087 + // Refresh page owner signature status and auth status to update button 2088 + await checkPageOwnerSignature(); 2047 2089 await checkAuthStatus(); 2048 2090 setTimeout(() => { 2049 2091 signGuestbookBtn.disabled = false; ··· 2080 2122 guestbookClose.addEventListener('click', () => { 2081 2123 guestbookModal.classList.remove('visible'); 2082 2124 }); 2125 + 2126 + // Add Escape key handler for closing guest list modal 2127 + document.addEventListener('keydown', (e) => { 2128 + if (e.key === 'Escape' && guestbookModal.classList.contains('visible')) { 2129 + guestbookModal.classList.remove('visible'); 2130 + } 2131 + }); 2083 2132 } 2084 2133 }); 2085 2134 ··· 2112 2161 if (signatures.length === 0) { 2113 2162 guestbookContent.innerHTML = ` 2114 2163 <div class="guestbook-paper"> 2115 - <h1 class="guestbook-paper-title">Guestbook</h1> 2116 - <p class="guestbook-paper-subtitle">Visitors to this Personal Data Server</p> 2164 + <h1 class="guestbook-paper-title">the @me guest list</h1> 2165 + <p class="guestbook-paper-subtitle">visitors to this application</p> 2117 2166 <div class="guestbook-empty"> 2118 - <div class="guestbook-empty-text">No signatures yet. Be the first to sign!</div> 2167 + <div class="guestbook-empty-text">no signatures yet. be the first to sign!</div> 2119 2168 </div> 2120 2169 </div> 2121 2170 `; 2122 2171 return; 2123 2172 } 2124 2173 2174 + // Helper function to format timestamp 2175 + const formatDate = (isoString) => { 2176 + if (!isoString) return ''; 2177 + const date = new Date(isoString); 2178 + if (isNaN(date)) return ''; 2179 + const month = String(date.getMonth() + 1).padStart(2, '0'); 2180 + const day = String(date.getDate()).padStart(2, '0'); 2181 + const year = date.getFullYear(); 2182 + return `${month}/${day}/${year}`; 2183 + }; 2184 + 2125 2185 // Render signatures with paper aesthetic 2126 2186 let html = ` 2127 2187 <div class="guestbook-paper"> 2128 - <h1 class="guestbook-paper-title">Guestbook</h1> 2129 - <p class="guestbook-paper-subtitle">Visitors to this application</p> 2188 + <h1 class="guestbook-paper-title">the @me guest list</h1> 2189 + <p class="guestbook-paper-subtitle">visitors to this application</p> 2130 2190 <div class="guestbook-signatures-list"> 2131 2191 `; 2132 2192 2133 2193 signatures.forEach((sig, index) => { 2134 2194 const handle = sig.handle || 'unknown'; 2135 2195 const did = sig.did || 'did:unknown'; 2196 + // Add at:// prefix if not present for pdsls.dev URL 2197 + const atUri = did.startsWith('at://') ? did : `at://${did}`; 2198 + const formattedDate = formatDate(sig.timestamp); 2199 + const pdsHost = 'pdsls.dev'; // Use pdsls.dev for looking up DIDs 2136 2200 2137 2201 html += ` 2138 2202 <div class="guestbook-paper-signature"> ··· 2145 2209 <span class="guestbook-metadata-label">handle:</span> 2146 2210 <span class="guestbook-metadata-value">@${handle}</span> 2147 2211 </div> 2212 + ${formattedDate ? ` 2213 + <div class="guestbook-metadata-item"> 2214 + <span class="guestbook-metadata-label">signed:</span> 2215 + <span class="guestbook-metadata-value">${formattedDate}</span> 2216 + </div> 2217 + ` : ''} 2148 2218 <div class="guestbook-metadata-item"> 2149 2219 <span class="guestbook-metadata-label">bluesky:</span> 2150 2220 <a href="https://bsky.app/profile/${handle}" target="_blank" rel="noopener noreferrer" class="guestbook-metadata-link">view profile ↗</a> 2221 + </div> 2222 + <div class="guestbook-metadata-item"> 2223 + <span class="guestbook-metadata-label">pdsls.dev:</span> 2224 + <a href="https://${pdsHost}/${atUri}/app.at-me.visit" target="_blank" rel="noopener noreferrer" class="guestbook-metadata-link">view on pdsls.dev ↗</a> 2151 2225 </div> 2152 2226 </div> 2153 2227 </div>