interactive intro to open social at-me.zzstoatzz.io

feat: improve guestbook architecture and UX

- Replace in-memory cache with UFOs API for persistent global state
- Split architecture: query page owner's PDS for button state, use UFOs for global list
- Fix unauthenticated user flow to trigger identity confirmation on button click
- Add signature count display to guestbook modal
- Fix font consistency across all guestbook text (unified monospace)
- Implement optimistic cache updates for sign/unsign actions

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

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

+462 -142
+1 -1
CLAUDE.md
··· 4 5 ## Critical reminders 6 7 - - Use `just dev` for local development (see justfile) 8 - Python: use `uv` or `uvx`, NEVER pip ([uv docs](https://docs.astral.sh/uv/)) 9 - ATProto client: always pass PDS URL at initialization to avoid JWT issues 10 - Never deploy without explicit user request
··· 4 5 ## Critical reminders 6 7 + - Use `just dev` for local development - cargo watch provides hot reloading for src/ and static/ changes, no need to manually restart 8 - Python: use `uv` or `uvx`, NEVER pip ([uv docs](https://docs.astral.sh/uv/)) 9 - ATProto client: always pass PDS URL at initialization to avoid JWT issues 10 - Never deploy without explicit user request
+1
src/main.rs
··· 56 .service(routes::sign_guestbook) 57 .service(routes::unsign_guestbook) 58 .service(routes::get_guestbook_signatures) 59 .service(routes::firehose_watch) 60 .service(routes::favicon) 61 .service(Files::new("/static", "./static"))
··· 56 .service(routes::sign_guestbook) 57 .service(routes::unsign_guestbook) 58 .service(routes::get_guestbook_signatures) 59 + .service(routes::check_page_owner_signature) 60 .service(routes::firehose_watch) 61 .service(routes::favicon) 62 .service(Files::new("/static", "./static"))
+241 -61
src/routes.rs
··· 47 pub timestamp: String, 48 } 49 50 - // Global guestbook signatures - tracks all signatures made via this app instance 51 - // Key is the signer's DID 52 - static GLOBAL_SIGNATURES: Lazy<DashMap<String, GuestbookSignature>> = 53 - Lazy::new(|| DashMap::new()); 54 55 // OAuth session type matching our OAuth client configuration 56 type OAuthSessionType = OAuthSession< ··· 389 390 // Fetch user avatar from Bluesky 391 let avatar = fetch_user_avatar(did).await; 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 426 // Fetch collections from PDS 427 let repo_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, did); ··· 848 .await 849 { 850 Ok(output) => { 851 - // Add to global signatures cache 852 - let (handle, avatar) = fetch_profile_info(&did).await; 853 - GLOBAL_SIGNATURES.insert(did.clone(), GuestbookSignature { 854 - did: did.clone(), 855 - handle, 856 - avatar, 857 - timestamp: chrono::Utc::now().to_rfc3339(), 858 - }); 859 - log::info!("Added signature to global cache for DID: {}", did); 860 861 HttpResponse::Ok().json(serde_json::json!({ 862 "success": true, ··· 1003 } 1004 } 1005 1006 - // Remove from global signatures cache 1007 - GLOBAL_SIGNATURES.remove(&did); 1008 - log::info!("Removed signature from global cache for DID: {}", did); 1009 1010 HttpResponse::Ok().json(serde_json::json!({ 1011 "success": true, ··· 1020 1021 #[get("/api/guestbook/signatures")] 1022 pub async fn get_guestbook_signatures() -> HttpResponse { 1023 - // Return all signatures from global cache 1024 - let mut signatures: Vec<GuestbookSignature> = GLOBAL_SIGNATURES 1025 - .iter() 1026 - .map(|entry| entry.value().clone()) 1027 - .collect(); 1028 1029 - // Sort by timestamp (most recent first) 1030 - signatures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); 1031 - 1032 - log::info!("Returning {} signatures from global cache", signatures.len()); 1033 1034 - HttpResponse::Ok() 1035 - .insert_header(("Cache-Control", "public, max-age=10")) 1036 - .json(signatures) 1037 } 1038 1039 async fn fetch_profile_info(did: &str) -> (Option<String>, Option<String>) { ··· 1057 let avatar = fetch_user_avatar(did).await; 1058 1059 (handle, avatar) 1060 } 1061 1062 #[get("/api/firehose/watch")]
··· 47 pub timestamp: String, 48 } 49 50 + // UFOs API response structure 51 + #[derive(Deserialize)] 52 + struct UfosRecord { 53 + did: String, 54 + record: serde_json::Value, 55 + } 56 + 57 + // Cache for UFOs API guestbook signatures 58 + struct CachedGuestbookSignatures { 59 + signatures: Vec<GuestbookSignature>, 60 + } 61 + 62 + static GUESTBOOK_CACHE: Lazy<Mutex<Option<CachedGuestbookSignatures>>> = 63 + Lazy::new(|| Mutex::new(None)); 64 65 // OAuth session type matching our OAuth client configuration 66 type OAuthSessionType = OAuthSession< ··· 399 400 // Fetch user avatar from Bluesky 401 let avatar = fetch_user_avatar(did).await; 402 403 // Fetch collections from PDS 404 let repo_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, did); ··· 825 .await 826 { 827 Ok(output) => { 828 + // Fetch fresh data from UFOs and add this signature 829 + match fetch_signatures_from_ufos().await { 830 + Ok(mut signatures) => { 831 + // Add the user's signature to the cache 832 + let (handle, avatar) = fetch_profile_info(&did).await; 833 + let new_signature = GuestbookSignature { 834 + did: did.clone(), 835 + handle, 836 + avatar, 837 + timestamp: chrono::Utc::now().to_rfc3339(), 838 + }; 839 + 840 + // Add at the beginning (most recent) 841 + signatures.insert(0, new_signature); 842 + 843 + // Update cache 844 + { 845 + let mut cache = GUESTBOOK_CACHE.lock().unwrap(); 846 + *cache = Some(CachedGuestbookSignatures { 847 + signatures, 848 + }); 849 + } 850 + 851 + log::info!("Added signature to cache for DID: {}", did); 852 + } 853 + Err(e) => { 854 + log::warn!("Failed to update cache after signing, invalidating instead: {}", e); 855 + invalidate_guestbook_cache(); 856 + } 857 + } 858 859 HttpResponse::Ok().json(serde_json::json!({ 860 "success": true, ··· 1001 } 1002 } 1003 1004 + // Fetch fresh data from UFOs and remove this DID 1005 + match fetch_signatures_from_ufos().await { 1006 + Ok(mut signatures) => { 1007 + // Remove the user's signature from the cache 1008 + signatures.retain(|sig| sig.did != did); 1009 + 1010 + // Update cache 1011 + { 1012 + let mut cache = GUESTBOOK_CACHE.lock().unwrap(); 1013 + *cache = Some(CachedGuestbookSignatures { 1014 + signatures: signatures.clone(), 1015 + }); 1016 + } 1017 + } 1018 + Err(e) => { 1019 + log::warn!("Failed to update cache after unsigning, invalidating instead: {}", e); 1020 + invalidate_guestbook_cache(); 1021 + } 1022 + } 1023 1024 HttpResponse::Ok().json(serde_json::json!({ 1025 "success": true, ··· 1034 1035 #[get("/api/guestbook/signatures")] 1036 pub async fn get_guestbook_signatures() -> HttpResponse { 1037 + // Check cache first 1038 + { 1039 + let cache = GUESTBOOK_CACHE.lock().unwrap(); 1040 + if let Some(cached) = cache.as_ref() { 1041 + // Cache is valid - return cached signatures 1042 + log::info!("Returning {} signatures from cache", cached.signatures.len()); 1043 + log::info!("Cached signature DIDs: {:?}", cached.signatures.iter().map(|s| &s.did).collect::<Vec<_>>()); 1044 + return HttpResponse::Ok() 1045 + .insert_header(("Cache-Control", "public, max-age=10")) 1046 + .json(&cached.signatures); 1047 + } 1048 + } 1049 1050 + // Cache miss or invalidated - fetch from UFOs API 1051 + log::info!("Cache miss - fetching from UFOs API"); 1052 + match fetch_signatures_from_ufos().await { 1053 + Ok(signatures) => { 1054 + // Update cache 1055 + { 1056 + let mut cache = GUESTBOOK_CACHE.lock().unwrap(); 1057 + *cache = Some(CachedGuestbookSignatures { 1058 + signatures: signatures.clone(), 1059 + }); 1060 + } 1061 1062 + log::info!("Returning {} signatures from UFOs API", signatures.len()); 1063 + HttpResponse::Ok() 1064 + .insert_header(("Cache-Control", "public, max-age=10")) 1065 + .json(signatures) 1066 + } 1067 + Err(e) => { 1068 + log::error!("Failed to fetch signatures from UFOs: {}", e); 1069 + HttpResponse::InternalServerError().json(serde_json::json!({ 1070 + "error": e 1071 + })) 1072 + } 1073 + } 1074 } 1075 1076 async fn fetch_profile_info(did: &str) -> (Option<String>, Option<String>) { ··· 1094 let avatar = fetch_user_avatar(did).await; 1095 1096 (handle, avatar) 1097 + } 1098 + 1099 + async fn fetch_signatures_from_ufos() -> Result<Vec<GuestbookSignature>, String> { 1100 + // Fetch all guestbook records from UFOs API 1101 + let ufos_url = format!( 1102 + "https://ufos-api.microcosm.blue/records?collection={}", 1103 + constants::GUESTBOOK_COLLECTION 1104 + ); 1105 + 1106 + log::info!("Fetching guestbook signatures from UFOs API"); 1107 + 1108 + let response = reqwest::get(&ufos_url) 1109 + .await 1110 + .map_err(|e| format!("failed to fetch from UFOs API: {}", e))?; 1111 + 1112 + let records: Vec<UfosRecord> = response.json() 1113 + .await 1114 + .map_err(|e| format!("failed to parse UFOs response: {}", e))?; 1115 + 1116 + log::info!("Fetched {} records from UFOs API", records.len()); 1117 + 1118 + // Fetch profile info for each DID in parallel 1119 + let profile_futures: Vec<_> = records.iter() 1120 + .map(|record| { 1121 + let did = record.did.clone(); 1122 + let timestamp = record.record["createdAt"] 1123 + .as_str() 1124 + .unwrap_or("") 1125 + .to_string(); 1126 + async move { 1127 + let (handle, avatar) = fetch_profile_info(&did).await; 1128 + GuestbookSignature { 1129 + did, 1130 + handle, 1131 + avatar, 1132 + timestamp, 1133 + } 1134 + } 1135 + }) 1136 + .collect(); 1137 + 1138 + let mut signatures = future::join_all(profile_futures).await; 1139 + 1140 + // Sort by timestamp (most recent first) 1141 + signatures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); 1142 + 1143 + log::info!("Processed {} signatures with profile info", signatures.len()); 1144 + 1145 + Ok(signatures) 1146 + } 1147 + 1148 + fn invalidate_guestbook_cache() { 1149 + let mut cache = GUESTBOOK_CACHE.lock().unwrap(); 1150 + *cache = None; 1151 + log::info!("Invalidated guestbook cache"); 1152 + } 1153 + 1154 + #[derive(Deserialize)] 1155 + pub struct CheckSignatureQuery { 1156 + did: String, 1157 + } 1158 + 1159 + #[get("/api/guestbook/check-signature")] 1160 + pub async fn check_page_owner_signature(query: web::Query<CheckSignatureQuery>) -> HttpResponse { 1161 + let did = &query.did; 1162 + 1163 + log::info!("Checking if DID has signed guestbook by querying their PDS: {}", did); 1164 + 1165 + // Fetch DID document to get PDS URL 1166 + let did_doc_url = format!("{}/{}", constants::PLC_DIRECTORY, did); 1167 + let pds = match reqwest::get(&did_doc_url).await { 1168 + Ok(response) => match response.json::<serde_json::Value>().await { 1169 + Ok(doc) => { 1170 + doc["service"] 1171 + .as_array() 1172 + .and_then(|services| { 1173 + services.iter().find(|s| { 1174 + s["type"].as_str() == Some("AtprotoPersonalDataServer") 1175 + }) 1176 + }) 1177 + .and_then(|s| s["serviceEndpoint"].as_str()) 1178 + .unwrap_or("") 1179 + .to_string() 1180 + } 1181 + Err(e) => { 1182 + log::error!("Failed to parse DID document: {}", e); 1183 + return HttpResponse::InternalServerError().json(serde_json::json!({ 1184 + "error": "failed to fetch DID document" 1185 + })); 1186 + } 1187 + }, 1188 + Err(e) => { 1189 + log::error!("Failed to fetch DID document: {}", e); 1190 + return HttpResponse::InternalServerError().json(serde_json::json!({ 1191 + "error": "failed to fetch DID document" 1192 + })); 1193 + } 1194 + }; 1195 + 1196 + // Query the PDS for guestbook records 1197 + let records_url = format!( 1198 + "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=1", 1199 + pds, did, constants::GUESTBOOK_COLLECTION 1200 + ); 1201 + 1202 + match reqwest::get(&records_url).await { 1203 + Ok(response) => { 1204 + if !response.status().is_success() { 1205 + // No records found or collection doesn't exist 1206 + log::info!("No guestbook records found for DID: {}", did); 1207 + return HttpResponse::Ok().json(serde_json::json!({ 1208 + "hasSigned": false 1209 + })); 1210 + } 1211 + 1212 + match response.json::<serde_json::Value>().await { 1213 + Ok(data) => { 1214 + let has_records = data["records"] 1215 + .as_array() 1216 + .map(|arr| !arr.is_empty()) 1217 + .unwrap_or(false); 1218 + 1219 + log::info!("DID {} has signed: {}", did, has_records); 1220 + 1221 + HttpResponse::Ok().json(serde_json::json!({ 1222 + "hasSigned": has_records 1223 + })) 1224 + } 1225 + Err(e) => { 1226 + log::error!("Failed to parse records response: {}", e); 1227 + HttpResponse::InternalServerError().json(serde_json::json!({ 1228 + "error": "failed to parse records" 1229 + })) 1230 + } 1231 + } 1232 + } 1233 + Err(e) => { 1234 + log::error!("Failed to fetch records from PDS: {}", e); 1235 + HttpResponse::InternalServerError().json(serde_json::json!({ 1236 + "error": "failed to fetch records" 1237 + })) 1238 + } 1239 + } 1240 } 1241 1242 #[get("/api/firehose/watch")]
+198 -71
src/templates/app.html
··· 1 <!DOCTYPE html> 2 <html> 3 <head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> ··· 10 <meta property="og:type" content="website"> 11 <meta property="og:url" content="https://at-me.fly.dev/"> 12 <meta property="og:title" content="@me - explore your atproto identity"> 13 - <meta property="og:description" content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 14 <meta property="og:image" content="https://at-me.fly.dev/static/og-image.png"> 15 16 <!-- Twitter --> 17 <meta property="twitter:card" content="summary_large_image"> 18 <meta property="twitter:url" content="https://at-me.fly.dev/"> 19 <meta property="twitter:title" content="@me - explore your atproto identity"> 20 - <meta property="twitter:description" content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 21 <meta property="twitter:image" content="https://at-me.fly.dev/static/og-image.png"> 22 23 <style> 24 - * { margin: 0; padding: 0; box-sizing: border-box; } 25 26 :root { 27 --bg: #f5f1e8; ··· 78 -webkit-tap-highlight-color: transparent; 79 } 80 81 - .info:hover, .info:active { 82 color: var(--text); 83 } 84 ··· 143 border-radius: 2px; 144 } 145 146 - .info-modal button:hover, .info-modal button:active { 147 background: var(--surface-hover); 148 border-color: var(--text-light); 149 } ··· 190 -webkit-tap-highlight-color: transparent; 191 } 192 193 - .identity:hover, .identity:active { 194 transform: translate(-50%, -50%) scale(1.05); 195 border-color: var(--text); 196 box-shadow: 0 0 20px rgba(255, 255, 255, 0.1); ··· 391 -webkit-tap-highlight-color: transparent; 392 } 393 394 - .detail-close:hover, .detail-close:active { 395 background: var(--surface-hover); 396 border-color: var(--text-light); 397 color: var(--text); ··· 420 -webkit-tap-highlight-color: transparent; 421 } 422 423 - .tree-item:hover, .tree-item:active { 424 background: var(--surface-hover); 425 border-color: var(--text-light); 426 } ··· 710 -webkit-tap-highlight-color: transparent; 711 } 712 713 - .copy-btn:hover, .copy-btn:active { 714 background: var(--surface-hover); 715 border-color: var(--text-light); 716 color: var(--text); ··· 748 border-radius: 2px; 749 } 750 751 - .load-more:hover, .load-more:active { 752 background: var(--surface-hover); 753 border-color: var(--text-light); 754 } ··· 773 background: var(--bg); 774 } 775 776 - #field.loading ~ .identity { 777 display: none; 778 } 779 ··· 787 } 788 789 @keyframes spin { 790 - to { transform: rotate(360deg); } 791 } 792 793 .loading-text { ··· 994 height: clamp(32px, 7vmin, 40px); 995 } 996 997 - .home-btn:hover, .home-btn:active { 998 background: var(--surface); 999 color: var(--text); 1000 border-color: var(--text-light); ··· 1019 gap: clamp(0.3rem, 0.8vmin, 0.5rem); 1020 } 1021 1022 - .watch-live-btn:hover, .watch-live-btn:active { 1023 background: var(--surface); 1024 color: var(--text); 1025 border-color: var(--text-light); ··· 1045 } 1046 1047 @keyframes pulse { 1048 - 0%, 100% { opacity: 1; } 1049 - 50% { opacity: 0.3; } 1050 } 1051 1052 @keyframes pulse-glow { 1053 - 0%, 100% { 1054 transform: scale(1); 1055 box-shadow: 0 0 0 rgba(255, 255, 255, 0); 1056 } 1057 50% { 1058 transform: scale(1.05); 1059 box-shadow: 0 0 15px rgba(255, 255, 255, 0.3); ··· 1061 } 1062 1063 @keyframes gentle-pulse { 1064 - 0%, 100% { 1065 transform: scale(1); 1066 box-shadow: 0 0 0 0 var(--text-light); 1067 } 1068 50% { 1069 transform: scale(1.02); 1070 box-shadow: 0 0 0 3px rgba(160, 160, 160, 0.2); ··· 1144 white-space: nowrap; 1145 } 1146 1147 - .sign-guestbook-btn:hover, .sign-guestbook-btn:active { 1148 background: var(--surface); 1149 color: var(--text); 1150 border-color: var(--text-light); ··· 1217 justify-content: center; 1218 } 1219 1220 - .view-guestbook-btn:hover, .view-guestbook-btn:active { 1221 background: var(--surface); 1222 color: var(--text); 1223 border-color: var(--text-light); ··· 1244 max-width: 700px; 1245 margin: 0 auto; 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%); 1255 border: 1px solid #d4c5a8; 1256 box-shadow: ··· 1264 @media (prefers-color-scheme: dark) { 1265 .guestbook-paper { 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%); 1275 border-color: #3a3530; 1276 box-shadow: ··· 1288 width: 2px; 1289 height: 100%; 1290 background: linear-gradient(to bottom, 1291 - transparent 0%, 1292 - rgba(212, 100, 100, 0.2) 5%, 1293 - rgba(212, 100, 100, 0.2) 95%, 1294 - transparent 100% 1295 - ); 1296 } 1297 1298 @media (prefers-color-scheme: dark) { 1299 .guestbook-paper::before { 1300 background: linear-gradient(to bottom, 1301 - transparent 0%, 1302 - rgba(180, 80, 80, 0.15) 5%, 1303 - rgba(180, 80, 80, 0.15) 95%, 1304 - transparent 100% 1305 - ); 1306 } 1307 } 1308 1309 .guestbook-paper-title { 1310 - font-family: 'Georgia', 'Times New Roman', serif; 1311 font-size: clamp(1.8rem, 4.5vmin, 2.5rem); 1312 color: #3a2f25; 1313 text-align: center; 1314 margin-bottom: clamp(0.5rem, 1.5vmin, 1rem); 1315 - font-weight: 400; 1316 letter-spacing: 0.02em; 1317 } 1318 ··· 1323 } 1324 1325 .guestbook-paper-subtitle { 1326 - font-family: 'Georgia', 'Times New Roman', serif; 1327 font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1328 color: #6b5d4f; 1329 text-align: center; 1330 margin-bottom: clamp(2rem, 5vmin, 3rem); 1331 - font-style: italic; 1332 } 1333 1334 @media (prefers-color-scheme: dark) { ··· 1337 } 1338 } 1339 1340 .guestbook-signatures-list { 1341 margin-top: clamp(1.5rem, 4vmin, 2.5rem); 1342 } ··· 1524 z-index: 2001; 1525 } 1526 1527 - .guestbook-close:hover, .guestbook-close:active { 1528 background: var(--surface-hover); 1529 border-color: var(--text-light); 1530 color: var(--text); ··· 1706 1707 /* Guestbook sign flicker - 13 second loop */ 1708 @keyframes neon-flicker { 1709 - 0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { 1710 opacity: 0.6; 1711 text-shadow: 0 0 4px currentColor; 1712 } 1713 - 20%, 24%, 55% { 1714 opacity: 0.2; 1715 text-shadow: none; 1716 } ··· 1718 1719 /* POV indicator flicker - subtle 37 second loop, flickers TO brightness */ 1720 @keyframes pov-subtle-flicker { 1721 - 0%, 98% { 1722 opacity: 0.4; 1723 text-shadow: 0 0 3px currentColor; 1724 } 1725 - 17%, 17.3%, 17.6% { 1726 opacity: 0.75; 1727 text-shadow: 0 0 8px currentColor, 0 0 12px currentColor; 1728 } 1729 - 17.15%, 17.45% { 1730 opacity: 0.5; 1731 text-shadow: 0 0 4px currentColor; 1732 } 1733 - 71%, 71.2% { 1734 opacity: 0.8; 1735 text-shadow: 0 0 10px currentColor, 0 0 15px currentColor; 1736 } 1737 71.1% { 1738 opacity: 0.45; 1739 text-shadow: 0 0 3px currentColor; ··· 1742 1743 @media (prefers-color-scheme: dark) { 1744 @keyframes neon-flicker { 1745 - 0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { 1746 opacity: 0.5; 1747 text-shadow: 0 0 6px currentColor, 0 0 12px rgba(255, 107, 157, 0.3); 1748 } 1749 - 20%, 24%, 55% { 1750 opacity: 0.15; 1751 text-shadow: 0 0 2px currentColor; 1752 } 1753 } 1754 1755 @keyframes pov-subtle-flicker { 1756 - 0%, 98% { 1757 opacity: 0.35; 1758 text-shadow: 0 0 4px currentColor, 0 0 8px rgba(138, 180, 248, 0.2); 1759 } 1760 - 17%, 17.3%, 17.6% { 1761 opacity: 0.75; 1762 text-shadow: 0 0 12px currentColor, 0 0 20px rgba(138, 180, 248, 0.6); 1763 } 1764 - 17.15%, 17.45% { 1765 opacity: 0.45; 1766 text-shadow: 0 0 6px currentColor, 0 0 10px rgba(138, 180, 248, 0.3); 1767 } 1768 - 71%, 71.2% { 1769 opacity: 0.8; 1770 text-shadow: 0 0 15px currentColor, 0 0 25px rgba(138, 180, 248, 0.7); 1771 } 1772 71.1% { 1773 opacity: 0.4; 1774 text-shadow: 0 0 5px currentColor, 0 0 9px rgba(138, 180, 248, 0.25); 1775 } 1776 } 1777 } 1778 - 1779 </style> 1780 </head> 1781 <body> 1782 <a href="/" class="home-btn" title="back to landing"> 1783 - <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg> 1784 </a> 1785 <div class="info" id="infoBtn" title="learn about your data"> 1786 - <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg> 1787 </div> 1788 <button class="watch-live-btn" id="watchLiveBtn"> 1789 <span class="watch-indicator"></span> 1790 <span class="watch-label">watch live</span> 1791 </button> 1792 - <div class="pov-indicator">point of view of <a class="pov-handle" id="povHandle" href="#" target="_blank" rel="noopener noreferrer"></a></div> 1793 <div class="guestbook-sign">sign the guest list</div> 1794 <div class="guestbook-buttons-container"> 1795 <button class="view-guestbook-btn" id="viewGuestbookBtn" title="view all signatures"> 1796 - <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg> 1797 </button> 1798 <button class="sign-guestbook-btn" id="signGuestbookBtn" title="sign the guestbook"> 1799 <span class="guestbook-icon"> 1800 - <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"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg> 1801 </span> 1802 <span class="guestbook-text">sign guestbook</span> 1803 <img class="guestbook-avatar" id="guestbookAvatar" style="display: none;" /> ··· 1807 <div class="firehose-toast" id="firehoseToast"> 1808 <div class="firehose-toast-action"></div> 1809 <div class="firehose-toast-collection"></div> 1810 - <a class='firehose-toast-link' id='firehoseToastLink' href='#' target='_blank' rel='noopener noreferrer'>view record</a> 1811 </div> 1812 1813 <div class="overlay" id="overlay"></div> 1814 <div class="info-modal" id="infoModal"> 1815 <h2>this is your data</h2> 1816 - <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> 1817 - <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> 1818 - <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> 1819 - <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> 1820 <button id="closeInfo">got it</button> 1821 - <button id="restartTour" onclick="window.restartOnboarding()" style="margin-left: 0.5rem; background: var(--surface-hover);">restart tour</button> 1822 - <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;"> 1823 - <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> 1824 - <a href="https://tangled.org" target="_blank" rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">tangled.org</a> 1825 </p> 1826 </div> 1827 ··· 1853 <script src="/static/app.js"></script> 1854 <script src="/static/onboarding.js"></script> 1855 </body> 1856 - </html>
··· 1 <!DOCTYPE html> 2 <html> 3 + 4 <head> 5 <meta charset="UTF-8"> 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> ··· 11 <meta property="og:type" content="website"> 12 <meta property="og:url" content="https://at-me.fly.dev/"> 13 <meta property="og:title" content="@me - explore your atproto identity"> 14 + <meta property="og:description" 15 + content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 16 <meta property="og:image" content="https://at-me.fly.dev/static/og-image.png"> 17 18 <!-- Twitter --> 19 <meta property="twitter:card" content="summary_large_image"> 20 <meta property="twitter:url" content="https://at-me.fly.dev/"> 21 <meta property="twitter:title" content="@me - explore your atproto identity"> 22 + <meta property="twitter:description" 23 + content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 24 <meta property="twitter:image" content="https://at-me.fly.dev/static/og-image.png"> 25 26 <style> 27 + * { 28 + margin: 0; 29 + padding: 0; 30 + box-sizing: border-box; 31 + } 32 33 :root { 34 --bg: #f5f1e8; ··· 85 -webkit-tap-highlight-color: transparent; 86 } 87 88 + .info:hover, 89 + .info:active { 90 color: var(--text); 91 } 92 ··· 151 border-radius: 2px; 152 } 153 154 + .info-modal button:hover, 155 + .info-modal button:active { 156 background: var(--surface-hover); 157 border-color: var(--text-light); 158 } ··· 199 -webkit-tap-highlight-color: transparent; 200 } 201 202 + .identity:hover, 203 + .identity:active { 204 transform: translate(-50%, -50%) scale(1.05); 205 border-color: var(--text); 206 box-shadow: 0 0 20px rgba(255, 255, 255, 0.1); ··· 401 -webkit-tap-highlight-color: transparent; 402 } 403 404 + .detail-close:hover, 405 + .detail-close:active { 406 background: var(--surface-hover); 407 border-color: var(--text-light); 408 color: var(--text); ··· 431 -webkit-tap-highlight-color: transparent; 432 } 433 434 + .tree-item:hover, 435 + .tree-item:active { 436 background: var(--surface-hover); 437 border-color: var(--text-light); 438 } ··· 722 -webkit-tap-highlight-color: transparent; 723 } 724 725 + .copy-btn:hover, 726 + .copy-btn:active { 727 background: var(--surface-hover); 728 border-color: var(--text-light); 729 color: var(--text); ··· 761 border-radius: 2px; 762 } 763 764 + .load-more:hover, 765 + .load-more:active { 766 background: var(--surface-hover); 767 border-color: var(--text-light); 768 } ··· 787 background: var(--bg); 788 } 789 790 + #field.loading~.identity { 791 display: none; 792 } 793 ··· 801 } 802 803 @keyframes spin { 804 + to { 805 + transform: rotate(360deg); 806 + } 807 } 808 809 .loading-text { ··· 1010 height: clamp(32px, 7vmin, 40px); 1011 } 1012 1013 + .home-btn:hover, 1014 + .home-btn:active { 1015 background: var(--surface); 1016 color: var(--text); 1017 border-color: var(--text-light); ··· 1036 gap: clamp(0.3rem, 0.8vmin, 0.5rem); 1037 } 1038 1039 + .watch-live-btn:hover, 1040 + .watch-live-btn:active { 1041 background: var(--surface); 1042 color: var(--text); 1043 border-color: var(--text-light); ··· 1063 } 1064 1065 @keyframes pulse { 1066 + 1067 + 0%, 1068 + 100% { 1069 + opacity: 1; 1070 + } 1071 + 1072 + 50% { 1073 + opacity: 0.3; 1074 + } 1075 } 1076 1077 @keyframes pulse-glow { 1078 + 1079 + 0%, 1080 + 100% { 1081 transform: scale(1); 1082 box-shadow: 0 0 0 rgba(255, 255, 255, 0); 1083 } 1084 + 1085 50% { 1086 transform: scale(1.05); 1087 box-shadow: 0 0 15px rgba(255, 255, 255, 0.3); ··· 1089 } 1090 1091 @keyframes gentle-pulse { 1092 + 1093 + 0%, 1094 + 100% { 1095 transform: scale(1); 1096 box-shadow: 0 0 0 0 var(--text-light); 1097 } 1098 + 1099 50% { 1100 transform: scale(1.02); 1101 box-shadow: 0 0 0 3px rgba(160, 160, 160, 0.2); ··· 1175 white-space: nowrap; 1176 } 1177 1178 + .sign-guestbook-btn:hover, 1179 + .sign-guestbook-btn:active { 1180 background: var(--surface); 1181 color: var(--text); 1182 border-color: var(--text-light); ··· 1249 justify-content: center; 1250 } 1251 1252 + .view-guestbook-btn:hover, 1253 + .view-guestbook-btn:active { 1254 background: var(--surface); 1255 color: var(--text); 1256 border-color: var(--text-light); ··· 1277 max-width: 700px; 1278 margin: 0 auto; 1279 background: 1280 + repeating-linear-gradient(0deg, 1281 transparent, 1282 transparent 31px, 1283 rgba(212, 197, 168, 0.15) 31px, 1284 + rgba(212, 197, 168, 0.15) 32px), 1285 linear-gradient(to bottom, #fdfcf8 0%, #f9f7f1 100%); 1286 border: 1px solid #d4c5a8; 1287 box-shadow: ··· 1295 @media (prefers-color-scheme: dark) { 1296 .guestbook-paper { 1297 background: 1298 + repeating-linear-gradient(0deg, 1299 transparent, 1300 transparent 31px, 1301 rgba(90, 80, 70, 0.2) 31px, 1302 + rgba(90, 80, 70, 0.2) 32px), 1303 linear-gradient(to bottom, #2a2520 0%, #1f1b17 100%); 1304 border-color: #3a3530; 1305 box-shadow: ··· 1317 width: 2px; 1318 height: 100%; 1319 background: linear-gradient(to bottom, 1320 + transparent 0%, 1321 + rgba(212, 100, 100, 0.2) 5%, 1322 + rgba(212, 100, 100, 0.2) 95%, 1323 + transparent 100%); 1324 } 1325 1326 @media (prefers-color-scheme: dark) { 1327 .guestbook-paper::before { 1328 background: linear-gradient(to bottom, 1329 + transparent 0%, 1330 + rgba(180, 80, 80, 0.15) 5%, 1331 + rgba(180, 80, 80, 0.15) 95%, 1332 + transparent 100%); 1333 } 1334 } 1335 1336 .guestbook-paper-title { 1337 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 1338 font-size: clamp(1.8rem, 4.5vmin, 2.5rem); 1339 color: #3a2f25; 1340 text-align: center; 1341 margin-bottom: clamp(0.5rem, 1.5vmin, 1rem); 1342 + font-weight: 500; 1343 letter-spacing: 0.02em; 1344 } 1345 ··· 1350 } 1351 1352 .guestbook-paper-subtitle { 1353 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 1354 font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1355 color: #6b5d4f; 1356 text-align: center; 1357 margin-bottom: clamp(2rem, 5vmin, 3rem); 1358 + font-style: normal; 1359 } 1360 1361 @media (prefers-color-scheme: dark) { ··· 1364 } 1365 } 1366 1367 + .guestbook-tally { 1368 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 1369 + text-align: center; 1370 + font-size: clamp(0.7rem, 1.8vmin, 0.85rem); 1371 + color: #6b5d4f; 1372 + margin: clamp(1rem, 2.5vmin, 1.5rem) 0 0; 1373 + font-weight: 500; 1374 + letter-spacing: 0.03em; 1375 + text-transform: lowercase; 1376 + } 1377 + 1378 + @media (prefers-color-scheme: dark) { 1379 + .guestbook-tally { 1380 + color: #8a7a6a; 1381 + } 1382 + } 1383 + 1384 .guestbook-signatures-list { 1385 margin-top: clamp(1.5rem, 4vmin, 2.5rem); 1386 } ··· 1568 z-index: 2001; 1569 } 1570 1571 + .guestbook-close:hover, 1572 + .guestbook-close:active { 1573 background: var(--surface-hover); 1574 border-color: var(--text-light); 1575 color: var(--text); ··· 1751 1752 /* Guestbook sign flicker - 13 second loop */ 1753 @keyframes neon-flicker { 1754 + 1755 + 0%, 1756 + 19%, 1757 + 21%, 1758 + 23%, 1759 + 25%, 1760 + 54%, 1761 + 56%, 1762 + 100% { 1763 opacity: 0.6; 1764 text-shadow: 0 0 4px currentColor; 1765 } 1766 + 1767 + 20%, 1768 + 24%, 1769 + 55% { 1770 opacity: 0.2; 1771 text-shadow: none; 1772 } ··· 1774 1775 /* POV indicator flicker - subtle 37 second loop, flickers TO brightness */ 1776 @keyframes pov-subtle-flicker { 1777 + 1778 + 0%, 1779 + 98% { 1780 opacity: 0.4; 1781 text-shadow: 0 0 3px currentColor; 1782 } 1783 + 1784 + 17%, 1785 + 17.3%, 1786 + 17.6% { 1787 opacity: 0.75; 1788 text-shadow: 0 0 8px currentColor, 0 0 12px currentColor; 1789 } 1790 + 1791 + 17.15%, 1792 + 17.45% { 1793 opacity: 0.5; 1794 text-shadow: 0 0 4px currentColor; 1795 } 1796 + 1797 + 71%, 1798 + 71.2% { 1799 opacity: 0.8; 1800 text-shadow: 0 0 10px currentColor, 0 0 15px currentColor; 1801 } 1802 + 1803 71.1% { 1804 opacity: 0.45; 1805 text-shadow: 0 0 3px currentColor; ··· 1808 1809 @media (prefers-color-scheme: dark) { 1810 @keyframes neon-flicker { 1811 + 1812 + 0%, 1813 + 19%, 1814 + 21%, 1815 + 23%, 1816 + 25%, 1817 + 54%, 1818 + 56%, 1819 + 100% { 1820 opacity: 0.5; 1821 text-shadow: 0 0 6px currentColor, 0 0 12px rgba(255, 107, 157, 0.3); 1822 } 1823 + 1824 + 20%, 1825 + 24%, 1826 + 55% { 1827 opacity: 0.15; 1828 text-shadow: 0 0 2px currentColor; 1829 } 1830 } 1831 1832 @keyframes pov-subtle-flicker { 1833 + 1834 + 0%, 1835 + 98% { 1836 opacity: 0.35; 1837 text-shadow: 0 0 4px currentColor, 0 0 8px rgba(138, 180, 248, 0.2); 1838 } 1839 + 1840 + 17%, 1841 + 17.3%, 1842 + 17.6% { 1843 opacity: 0.75; 1844 text-shadow: 0 0 12px currentColor, 0 0 20px rgba(138, 180, 248, 0.6); 1845 } 1846 + 1847 + 17.15%, 1848 + 17.45% { 1849 opacity: 0.45; 1850 text-shadow: 0 0 6px currentColor, 0 0 10px rgba(138, 180, 248, 0.3); 1851 } 1852 + 1853 + 71%, 1854 + 71.2% { 1855 opacity: 0.8; 1856 text-shadow: 0 0 15px currentColor, 0 0 25px rgba(138, 180, 248, 0.7); 1857 } 1858 + 1859 71.1% { 1860 opacity: 0.4; 1861 text-shadow: 0 0 5px currentColor, 0 0 9px rgba(138, 180, 248, 0.25); 1862 } 1863 } 1864 } 1865 </style> 1866 </head> 1867 + 1868 <body> 1869 <a href="/" class="home-btn" title="back to landing"> 1870 + <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" 1871 + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 1872 + <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /> 1873 + <polyline points="9 22 9 12 15 12 15 22" /> 1874 + </svg> 1875 </a> 1876 <div class="info" id="infoBtn" title="learn about your data"> 1877 + <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" 1878 + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 1879 + <circle cx="12" cy="12" r="10" /> 1880 + <path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" /> 1881 + <path d="M12 17h.01" /> 1882 + </svg> 1883 </div> 1884 <button class="watch-live-btn" id="watchLiveBtn"> 1885 <span class="watch-indicator"></span> 1886 <span class="watch-label">watch live</span> 1887 </button> 1888 + <div class="pov-indicator">point of view of <a class="pov-handle" id="povHandle" href="#" target="_blank" 1889 + rel="noopener noreferrer"></a></div> 1890 <div class="guestbook-sign">sign the guest list</div> 1891 <div class="guestbook-buttons-container"> 1892 <button class="view-guestbook-btn" id="viewGuestbookBtn" title="view all signatures"> 1893 + <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" 1894 + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 1895 + <line x1="8" x2="21" y1="6" y2="6" /> 1896 + <line x1="8" x2="21" y1="12" y2="12" /> 1897 + <line x1="8" x2="21" y1="18" y2="18" /> 1898 + <line x1="3" x2="3.01" y1="6" y2="6" /> 1899 + <line x1="3" x2="3.01" y1="12" y2="12" /> 1900 + <line x1="3" x2="3.01" y1="18" y2="18" /> 1901 + </svg> 1902 </button> 1903 <button class="sign-guestbook-btn" id="signGuestbookBtn" title="sign the guestbook"> 1904 <span class="guestbook-icon"> 1905 + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" 1906 + stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 1907 + <path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20" /> 1908 + </svg> 1909 </span> 1910 <span class="guestbook-text">sign guestbook</span> 1911 <img class="guestbook-avatar" id="guestbookAvatar" style="display: none;" /> ··· 1915 <div class="firehose-toast" id="firehoseToast"> 1916 <div class="firehose-toast-action"></div> 1917 <div class="firehose-toast-collection"></div> 1918 + <a class='firehose-toast-link' id='firehoseToastLink' href='#' target='_blank' rel='noopener noreferrer'>view 1919 + record</a> 1920 </div> 1921 1922 <div class="overlay" id="overlay"></div> 1923 <div class="info-modal" id="infoModal"> 1924 <h2>this is your data</h2> 1925 + <p>this visualization shows your <a href="https://atproto.com/guides/data-repos" target="_blank" 1926 + rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">Personal Data 1927 + Server</a> - where your social data actually lives. unlike traditional platforms that lock everything in 1928 + their database, your posts, likes, and follows are stored here, on infrastructure you control.</p> 1929 + <p>each circle represents an app that writes to your space. <a href="https://bsky.app" target="_blank" 1930 + rel="noopener noreferrer" style="color: var(--text); text-decoration: underline;">bluesky</a> for 1931 + microblogging. <a href="https://whtwnd.com" target="_blank" rel="noopener noreferrer" 1932 + style="color: var(--text); text-decoration: underline;">whitewind</a> for long-form posts. <a 1933 + href="https://tangled.org" target="_blank" rel="noopener noreferrer" 1934 + style="color: var(--text); text-decoration: underline;">tangled.org</a> for code hosting. they're all 1935 + just different views of the same underlying data - <strong>your</strong> data.</p> 1936 + <p>this is what "<a href="https://overreacted.io/open-social/" target="_blank" rel="noopener noreferrer" 1937 + style="color: var(--text); text-decoration: underline;">open social</a>" means: your followers, your 1938 + content, your connections - they all belong to you, not the app. switch apps anytime and take everything 1939 + with you. no platform can hold your social graph hostage.</p> 1940 + <p style="margin-bottom: 1rem;"><strong>how to explore:</strong> click your avatar in the center to see the 1941 + details of your identity. click any app to browse the records it's created in your repository.</p> 1942 <button id="closeInfo">got it</button> 1943 + <button id="restartTour" onclick="window.restartOnboarding()" 1944 + style="margin-left: 0.5rem; background: var(--surface-hover);">restart tour</button> 1945 + <p 1946 + 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;"> 1947 + <span>view <a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer" 1948 + style="color: var(--text); text-decoration: underline;">the source code</a> on</span> 1949 + <a href="https://tangled.org" target="_blank" rel="noopener noreferrer" 1950 + style="color: var(--text); text-decoration: underline;">tangled.org</a> 1951 </p> 1952 </div> 1953 ··· 1979 <script src="/static/app.js"></script> 1980 <script src="/static/onboarding.js"></script> 1981 </body> 1982 + 1983 + </html>
+21 -9
static/app.js
··· 1708 } 1709 1710 async function checkPageOwnerSignature() { 1711 - // Check if the page owner (did) has signed the guestbook 1712 try { 1713 - const response = await fetch('/api/guestbook/signatures'); 1714 if (!response.ok) return false; 1715 1716 - const signatures = await response.json(); 1717 - pageOwnerHasSigned = signatures.some(sig => sig.did === did || sig.did === `at://${did}`); 1718 updateGuestbookSign(); 1719 return pageOwnerHasSigned; 1720 } catch (error) { ··· 1988 return; 1989 } 1990 1991 - // If page owner already signed, show unsign modal (only if viewing own page) 1992 - if (pageOwnerHasSigned && viewingOwnPage) { 1993 - showUnsignModal(); 1994 - return; 1995 } 1996 1997 - // If not authenticated, show confirmation with the viewed handle 1998 if (!isAuthenticated) { 1999 if (viewedHandle) { 2000 showHandleConfirmation(viewedHandle); ··· 2131 <div class="guestbook-paper"> 2132 <h1 class="guestbook-paper-title">the @me guest list</h1> 2133 <p class="guestbook-paper-subtitle">visitors to this application</p> 2134 <div class="guestbook-signatures-list"> 2135 `; 2136
··· 1708 } 1709 1710 async function checkPageOwnerSignature() { 1711 + // Check if the page owner (did) has signed the guestbook by querying their PDS directly 1712 try { 1713 + const response = await fetch(`/api/guestbook/check-signature?did=${encodeURIComponent(did)}`); 1714 if (!response.ok) return false; 1715 1716 + const data = await response.json(); 1717 + pageOwnerHasSigned = data.hasSigned; 1718 + 1719 updateGuestbookSign(); 1720 return pageOwnerHasSigned; 1721 } catch (error) { ··· 1989 return; 1990 } 1991 1992 + // If page owner already signed, handle unsigning or identity confirmation 1993 + if (pageOwnerHasSigned) { 1994 + if (!isAuthenticated) { 1995 + // Unauthenticated user - show identity confirmation to sign in and then unsign 1996 + if (viewedHandle) { 1997 + showHandleConfirmation(viewedHandle); 1998 + } 1999 + return; 2000 + } else if (viewingOwnPage) { 2001 + // Authenticated as page owner - show unsign modal 2002 + showUnsignModal(); 2003 + return; 2004 + } 2005 + // If authenticated as someone else, the button is disabled, so this shouldn't be reached 2006 } 2007 2008 + // If not authenticated and page owner hasn't signed, show confirmation to sign in and then sign 2009 if (!isAuthenticated) { 2010 if (viewedHandle) { 2011 showHandleConfirmation(viewedHandle); ··· 2142 <div class="guestbook-paper"> 2143 <h1 class="guestbook-paper-title">the @me guest list</h1> 2144 <p class="guestbook-paper-subtitle">visitors to this application</p> 2145 + <div class="guestbook-tally">${signatures.length} signature${signatures.length !== 1 ? 's' : ''}</div> 2146 <div class="guestbook-signatures-list"> 2147 `; 2148