interactive intro to open social

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>

Changed files
+462 -142
src
static
+1 -1
CLAUDE.md
··· 4 4 5 5 ## Critical reminders 6 6 7 - - Use `just dev` for local development (see justfile) 7 + - Use `just dev` for local development - cargo watch provides hot reloading for src/ and static/ changes, no need to manually restart 8 8 - Python: use `uv` or `uvx`, NEVER pip ([uv docs](https://docs.astral.sh/uv/)) 9 9 - ATProto client: always pass PDS URL at initialization to avoid JWT issues 10 10 - Never deploy without explicit user request
+1
src/main.rs
··· 56 56 .service(routes::sign_guestbook) 57 57 .service(routes::unsign_guestbook) 58 58 .service(routes::get_guestbook_signatures) 59 + .service(routes::check_page_owner_signature) 59 60 .service(routes::firehose_watch) 60 61 .service(routes::favicon) 61 62 .service(Files::new("/static", "./static"))
+241 -61
src/routes.rs
··· 47 47 pub timestamp: String, 48 48 } 49 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()); 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)); 54 64 55 65 // OAuth session type matching our OAuth client configuration 56 66 type OAuthSessionType = OAuthSession< ··· 389 399 390 400 // Fetch user avatar from Bluesky 391 401 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 402 426 403 // Fetch collections from PDS 427 404 let repo_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, did); ··· 848 825 .await 849 826 { 850 827 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); 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 + } 860 858 861 859 HttpResponse::Ok().json(serde_json::json!({ 862 860 "success": true, ··· 1003 1001 } 1004 1002 } 1005 1003 1006 - // Remove from global signatures cache 1007 - GLOBAL_SIGNATURES.remove(&did); 1008 - log::info!("Removed signature from global cache for DID: {}", did); 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 + } 1009 1023 1010 1024 HttpResponse::Ok().json(serde_json::json!({ 1011 1025 "success": true, ··· 1020 1034 1021 1035 #[get("/api/guestbook/signatures")] 1022 1036 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(); 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 + } 1028 1049 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()); 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 + } 1033 1061 1034 - HttpResponse::Ok() 1035 - .insert_header(("Cache-Control", "public, max-age=10")) 1036 - .json(signatures) 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 + } 1037 1074 } 1038 1075 1039 1076 async fn fetch_profile_info(did: &str) -> (Option<String>, Option<String>) { ··· 1057 1094 let avatar = fetch_user_avatar(did).await; 1058 1095 1059 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 + } 1060 1240 } 1061 1241 1062 1242 #[get("/api/firehose/watch")]
+198 -71
src/templates/app.html
··· 1 1 <!DOCTYPE html> 2 2 <html> 3 + 3 4 <head> 4 5 <meta charset="UTF-8"> 5 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> ··· 10 11 <meta property="og:type" content="website"> 11 12 <meta property="og:url" content="https://at-me.fly.dev/"> 12 13 <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:description" 15 + content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 14 16 <meta property="og:image" content="https://at-me.fly.dev/static/og-image.png"> 15 17 16 18 <!-- Twitter --> 17 19 <meta property="twitter:card" content="summary_large_image"> 18 20 <meta property="twitter:url" content="https://at-me.fly.dev/"> 19 21 <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"> 22 + <meta property="twitter:description" 23 + content="visualize your decentralized identity and see what apps have stored data in your Personal Data Server"> 21 24 <meta property="twitter:image" content="https://at-me.fly.dev/static/og-image.png"> 22 25 23 26 <style> 24 - * { margin: 0; padding: 0; box-sizing: border-box; } 27 + * { 28 + margin: 0; 29 + padding: 0; 30 + box-sizing: border-box; 31 + } 25 32 26 33 :root { 27 34 --bg: #f5f1e8; ··· 78 85 -webkit-tap-highlight-color: transparent; 79 86 } 80 87 81 - .info:hover, .info:active { 88 + .info:hover, 89 + .info:active { 82 90 color: var(--text); 83 91 } 84 92 ··· 143 151 border-radius: 2px; 144 152 } 145 153 146 - .info-modal button:hover, .info-modal button:active { 154 + .info-modal button:hover, 155 + .info-modal button:active { 147 156 background: var(--surface-hover); 148 157 border-color: var(--text-light); 149 158 } ··· 190 199 -webkit-tap-highlight-color: transparent; 191 200 } 192 201 193 - .identity:hover, .identity:active { 202 + .identity:hover, 203 + .identity:active { 194 204 transform: translate(-50%, -50%) scale(1.05); 195 205 border-color: var(--text); 196 206 box-shadow: 0 0 20px rgba(255, 255, 255, 0.1); ··· 391 401 -webkit-tap-highlight-color: transparent; 392 402 } 393 403 394 - .detail-close:hover, .detail-close:active { 404 + .detail-close:hover, 405 + .detail-close:active { 395 406 background: var(--surface-hover); 396 407 border-color: var(--text-light); 397 408 color: var(--text); ··· 420 431 -webkit-tap-highlight-color: transparent; 421 432 } 422 433 423 - .tree-item:hover, .tree-item:active { 434 + .tree-item:hover, 435 + .tree-item:active { 424 436 background: var(--surface-hover); 425 437 border-color: var(--text-light); 426 438 } ··· 710 722 -webkit-tap-highlight-color: transparent; 711 723 } 712 724 713 - .copy-btn:hover, .copy-btn:active { 725 + .copy-btn:hover, 726 + .copy-btn:active { 714 727 background: var(--surface-hover); 715 728 border-color: var(--text-light); 716 729 color: var(--text); ··· 748 761 border-radius: 2px; 749 762 } 750 763 751 - .load-more:hover, .load-more:active { 764 + .load-more:hover, 765 + .load-more:active { 752 766 background: var(--surface-hover); 753 767 border-color: var(--text-light); 754 768 } ··· 773 787 background: var(--bg); 774 788 } 775 789 776 - #field.loading ~ .identity { 790 + #field.loading~.identity { 777 791 display: none; 778 792 } 779 793 ··· 787 801 } 788 802 789 803 @keyframes spin { 790 - to { transform: rotate(360deg); } 804 + to { 805 + transform: rotate(360deg); 806 + } 791 807 } 792 808 793 809 .loading-text { ··· 994 1010 height: clamp(32px, 7vmin, 40px); 995 1011 } 996 1012 997 - .home-btn:hover, .home-btn:active { 1013 + .home-btn:hover, 1014 + .home-btn:active { 998 1015 background: var(--surface); 999 1016 color: var(--text); 1000 1017 border-color: var(--text-light); ··· 1019 1036 gap: clamp(0.3rem, 0.8vmin, 0.5rem); 1020 1037 } 1021 1038 1022 - .watch-live-btn:hover, .watch-live-btn:active { 1039 + .watch-live-btn:hover, 1040 + .watch-live-btn:active { 1023 1041 background: var(--surface); 1024 1042 color: var(--text); 1025 1043 border-color: var(--text-light); ··· 1045 1063 } 1046 1064 1047 1065 @keyframes pulse { 1048 - 0%, 100% { opacity: 1; } 1049 - 50% { opacity: 0.3; } 1066 + 1067 + 0%, 1068 + 100% { 1069 + opacity: 1; 1070 + } 1071 + 1072 + 50% { 1073 + opacity: 0.3; 1074 + } 1050 1075 } 1051 1076 1052 1077 @keyframes pulse-glow { 1053 - 0%, 100% { 1078 + 1079 + 0%, 1080 + 100% { 1054 1081 transform: scale(1); 1055 1082 box-shadow: 0 0 0 rgba(255, 255, 255, 0); 1056 1083 } 1084 + 1057 1085 50% { 1058 1086 transform: scale(1.05); 1059 1087 box-shadow: 0 0 15px rgba(255, 255, 255, 0.3); ··· 1061 1089 } 1062 1090 1063 1091 @keyframes gentle-pulse { 1064 - 0%, 100% { 1092 + 1093 + 0%, 1094 + 100% { 1065 1095 transform: scale(1); 1066 1096 box-shadow: 0 0 0 0 var(--text-light); 1067 1097 } 1098 + 1068 1099 50% { 1069 1100 transform: scale(1.02); 1070 1101 box-shadow: 0 0 0 3px rgba(160, 160, 160, 0.2); ··· 1144 1175 white-space: nowrap; 1145 1176 } 1146 1177 1147 - .sign-guestbook-btn:hover, .sign-guestbook-btn:active { 1178 + .sign-guestbook-btn:hover, 1179 + .sign-guestbook-btn:active { 1148 1180 background: var(--surface); 1149 1181 color: var(--text); 1150 1182 border-color: var(--text-light); ··· 1217 1249 justify-content: center; 1218 1250 } 1219 1251 1220 - .view-guestbook-btn:hover, .view-guestbook-btn:active { 1252 + .view-guestbook-btn:hover, 1253 + .view-guestbook-btn:active { 1221 1254 background: var(--surface); 1222 1255 color: var(--text); 1223 1256 border-color: var(--text-light); ··· 1244 1277 max-width: 700px; 1245 1278 margin: 0 auto; 1246 1279 background: 1247 - repeating-linear-gradient( 1248 - 0deg, 1280 + repeating-linear-gradient(0deg, 1249 1281 transparent, 1250 1282 transparent 31px, 1251 1283 rgba(212, 197, 168, 0.15) 31px, 1252 - rgba(212, 197, 168, 0.15) 32px 1253 - ), 1284 + rgba(212, 197, 168, 0.15) 32px), 1254 1285 linear-gradient(to bottom, #fdfcf8 0%, #f9f7f1 100%); 1255 1286 border: 1px solid #d4c5a8; 1256 1287 box-shadow: ··· 1264 1295 @media (prefers-color-scheme: dark) { 1265 1296 .guestbook-paper { 1266 1297 background: 1267 - repeating-linear-gradient( 1268 - 0deg, 1298 + repeating-linear-gradient(0deg, 1269 1299 transparent, 1270 1300 transparent 31px, 1271 1301 rgba(90, 80, 70, 0.2) 31px, 1272 - rgba(90, 80, 70, 0.2) 32px 1273 - ), 1302 + rgba(90, 80, 70, 0.2) 32px), 1274 1303 linear-gradient(to bottom, #2a2520 0%, #1f1b17 100%); 1275 1304 border-color: #3a3530; 1276 1305 box-shadow: ··· 1288 1317 width: 2px; 1289 1318 height: 100%; 1290 1319 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 - ); 1320 + transparent 0%, 1321 + rgba(212, 100, 100, 0.2) 5%, 1322 + rgba(212, 100, 100, 0.2) 95%, 1323 + transparent 100%); 1296 1324 } 1297 1325 1298 1326 @media (prefers-color-scheme: dark) { 1299 1327 .guestbook-paper::before { 1300 1328 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 - ); 1329 + transparent 0%, 1330 + rgba(180, 80, 80, 0.15) 5%, 1331 + rgba(180, 80, 80, 0.15) 95%, 1332 + transparent 100%); 1306 1333 } 1307 1334 } 1308 1335 1309 1336 .guestbook-paper-title { 1310 - font-family: 'Georgia', 'Times New Roman', serif; 1337 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 1311 1338 font-size: clamp(1.8rem, 4.5vmin, 2.5rem); 1312 1339 color: #3a2f25; 1313 1340 text-align: center; 1314 1341 margin-bottom: clamp(0.5rem, 1.5vmin, 1rem); 1315 - font-weight: 400; 1342 + font-weight: 500; 1316 1343 letter-spacing: 0.02em; 1317 1344 } 1318 1345 ··· 1323 1350 } 1324 1351 1325 1352 .guestbook-paper-subtitle { 1326 - font-family: 'Georgia', 'Times New Roman', serif; 1353 + font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace; 1327 1354 font-size: clamp(0.75rem, 1.6vmin, 0.9rem); 1328 1355 color: #6b5d4f; 1329 1356 text-align: center; 1330 1357 margin-bottom: clamp(2rem, 5vmin, 3rem); 1331 - font-style: italic; 1358 + font-style: normal; 1332 1359 } 1333 1360 1334 1361 @media (prefers-color-scheme: dark) { ··· 1337 1364 } 1338 1365 } 1339 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 + 1340 1384 .guestbook-signatures-list { 1341 1385 margin-top: clamp(1.5rem, 4vmin, 2.5rem); 1342 1386 } ··· 1524 1568 z-index: 2001; 1525 1569 } 1526 1570 1527 - .guestbook-close:hover, .guestbook-close:active { 1571 + .guestbook-close:hover, 1572 + .guestbook-close:active { 1528 1573 background: var(--surface-hover); 1529 1574 border-color: var(--text-light); 1530 1575 color: var(--text); ··· 1706 1751 1707 1752 /* Guestbook sign flicker - 13 second loop */ 1708 1753 @keyframes neon-flicker { 1709 - 0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { 1754 + 1755 + 0%, 1756 + 19%, 1757 + 21%, 1758 + 23%, 1759 + 25%, 1760 + 54%, 1761 + 56%, 1762 + 100% { 1710 1763 opacity: 0.6; 1711 1764 text-shadow: 0 0 4px currentColor; 1712 1765 } 1713 - 20%, 24%, 55% { 1766 + 1767 + 20%, 1768 + 24%, 1769 + 55% { 1714 1770 opacity: 0.2; 1715 1771 text-shadow: none; 1716 1772 } ··· 1718 1774 1719 1775 /* POV indicator flicker - subtle 37 second loop, flickers TO brightness */ 1720 1776 @keyframes pov-subtle-flicker { 1721 - 0%, 98% { 1777 + 1778 + 0%, 1779 + 98% { 1722 1780 opacity: 0.4; 1723 1781 text-shadow: 0 0 3px currentColor; 1724 1782 } 1725 - 17%, 17.3%, 17.6% { 1783 + 1784 + 17%, 1785 + 17.3%, 1786 + 17.6% { 1726 1787 opacity: 0.75; 1727 1788 text-shadow: 0 0 8px currentColor, 0 0 12px currentColor; 1728 1789 } 1729 - 17.15%, 17.45% { 1790 + 1791 + 17.15%, 1792 + 17.45% { 1730 1793 opacity: 0.5; 1731 1794 text-shadow: 0 0 4px currentColor; 1732 1795 } 1733 - 71%, 71.2% { 1796 + 1797 + 71%, 1798 + 71.2% { 1734 1799 opacity: 0.8; 1735 1800 text-shadow: 0 0 10px currentColor, 0 0 15px currentColor; 1736 1801 } 1802 + 1737 1803 71.1% { 1738 1804 opacity: 0.45; 1739 1805 text-shadow: 0 0 3px currentColor; ··· 1742 1808 1743 1809 @media (prefers-color-scheme: dark) { 1744 1810 @keyframes neon-flicker { 1745 - 0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { 1811 + 1812 + 0%, 1813 + 19%, 1814 + 21%, 1815 + 23%, 1816 + 25%, 1817 + 54%, 1818 + 56%, 1819 + 100% { 1746 1820 opacity: 0.5; 1747 1821 text-shadow: 0 0 6px currentColor, 0 0 12px rgba(255, 107, 157, 0.3); 1748 1822 } 1749 - 20%, 24%, 55% { 1823 + 1824 + 20%, 1825 + 24%, 1826 + 55% { 1750 1827 opacity: 0.15; 1751 1828 text-shadow: 0 0 2px currentColor; 1752 1829 } 1753 1830 } 1754 1831 1755 1832 @keyframes pov-subtle-flicker { 1756 - 0%, 98% { 1833 + 1834 + 0%, 1835 + 98% { 1757 1836 opacity: 0.35; 1758 1837 text-shadow: 0 0 4px currentColor, 0 0 8px rgba(138, 180, 248, 0.2); 1759 1838 } 1760 - 17%, 17.3%, 17.6% { 1839 + 1840 + 17%, 1841 + 17.3%, 1842 + 17.6% { 1761 1843 opacity: 0.75; 1762 1844 text-shadow: 0 0 12px currentColor, 0 0 20px rgba(138, 180, 248, 0.6); 1763 1845 } 1764 - 17.15%, 17.45% { 1846 + 1847 + 17.15%, 1848 + 17.45% { 1765 1849 opacity: 0.45; 1766 1850 text-shadow: 0 0 6px currentColor, 0 0 10px rgba(138, 180, 248, 0.3); 1767 1851 } 1768 - 71%, 71.2% { 1852 + 1853 + 71%, 1854 + 71.2% { 1769 1855 opacity: 0.8; 1770 1856 text-shadow: 0 0 15px currentColor, 0 0 25px rgba(138, 180, 248, 0.7); 1771 1857 } 1858 + 1772 1859 71.1% { 1773 1860 opacity: 0.4; 1774 1861 text-shadow: 0 0 5px currentColor, 0 0 9px rgba(138, 180, 248, 0.25); 1775 1862 } 1776 1863 } 1777 1864 } 1778 - 1779 1865 </style> 1780 1866 </head> 1867 + 1781 1868 <body> 1782 1869 <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> 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> 1784 1875 </a> 1785 1876 <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> 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> 1787 1883 </div> 1788 1884 <button class="watch-live-btn" id="watchLiveBtn"> 1789 1885 <span class="watch-indicator"></span> 1790 1886 <span class="watch-label">watch live</span> 1791 1887 </button> 1792 - <div class="pov-indicator">point of view of <a class="pov-handle" id="povHandle" href="#" target="_blank" rel="noopener noreferrer"></a></div> 1888 + <div class="pov-indicator">point of view of <a class="pov-handle" id="povHandle" href="#" target="_blank" 1889 + rel="noopener noreferrer"></a></div> 1793 1890 <div class="guestbook-sign">sign the guest list</div> 1794 1891 <div class="guestbook-buttons-container"> 1795 1892 <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> 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> 1797 1902 </button> 1798 1903 <button class="sign-guestbook-btn" id="signGuestbookBtn" title="sign the guestbook"> 1799 1904 <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> 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> 1801 1909 </span> 1802 1910 <span class="guestbook-text">sign guestbook</span> 1803 1911 <img class="guestbook-avatar" id="guestbookAvatar" style="display: none;" /> ··· 1807 1915 <div class="firehose-toast" id="firehoseToast"> 1808 1916 <div class="firehose-toast-action"></div> 1809 1917 <div class="firehose-toast-collection"></div> 1810 - <a class='firehose-toast-link' id='firehoseToastLink' href='#' target='_blank' rel='noopener noreferrer'>view record</a> 1918 + <a class='firehose-toast-link' id='firehoseToastLink' href='#' target='_blank' rel='noopener noreferrer'>view 1919 + record</a> 1811 1920 </div> 1812 1921 1813 1922 <div class="overlay" id="overlay"></div> 1814 1923 <div class="info-modal" id="infoModal"> 1815 1924 <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> 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> 1820 1942 <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> 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> 1825 1951 </p> 1826 1952 </div> 1827 1953 ··· 1853 1979 <script src="/static/app.js"></script> 1854 1980 <script src="/static/onboarding.js"></script> 1855 1981 </body> 1856 - </html> 1982 + 1983 + </html>
+21 -9
static/app.js
··· 1708 1708 } 1709 1709 1710 1710 async function checkPageOwnerSignature() { 1711 - // Check if the page owner (did) has signed the guestbook 1711 + // Check if the page owner (did) has signed the guestbook by querying their PDS directly 1712 1712 try { 1713 - const response = await fetch('/api/guestbook/signatures'); 1713 + const response = await fetch(`/api/guestbook/check-signature?did=${encodeURIComponent(did)}`); 1714 1714 if (!response.ok) return false; 1715 1715 1716 - const signatures = await response.json(); 1717 - pageOwnerHasSigned = signatures.some(sig => sig.did === did || sig.did === `at://${did}`); 1716 + const data = await response.json(); 1717 + pageOwnerHasSigned = data.hasSigned; 1718 + 1718 1719 updateGuestbookSign(); 1719 1720 return pageOwnerHasSigned; 1720 1721 } catch (error) { ··· 1988 1989 return; 1989 1990 } 1990 1991 1991 - // If page owner already signed, show unsign modal (only if viewing own page) 1992 - if (pageOwnerHasSigned && viewingOwnPage) { 1993 - showUnsignModal(); 1994 - return; 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 1995 2006 } 1996 2007 1997 - // If not authenticated, show confirmation with the viewed handle 2008 + // If not authenticated and page owner hasn't signed, show confirmation to sign in and then sign 1998 2009 if (!isAuthenticated) { 1999 2010 if (viewedHandle) { 2000 2011 showHandleConfirmation(viewedHandle); ··· 2131 2142 <div class="guestbook-paper"> 2132 2143 <h1 class="guestbook-paper-title">the @me guest list</h1> 2133 2144 <p class="guestbook-paper-subtitle">visitors to this application</p> 2145 + <div class="guestbook-tally">${signatures.length} signature${signatures.length !== 1 ? 's' : ''}</div> 2134 2146 <div class="guestbook-signatures-list"> 2135 2147 `; 2136 2148